Skip to content

Commit 3814f19

Browse files
committed
feat(@angular/build): introduce outputMode option to the application builder
The `outputMode` option accepts two values: - **`static`:** Generates a static output (HTML, CSS, JavaScript) suitable for deployment on static hosting services or CDNs. This mode supports both client-side rendering (CSR) and static site generation (SSG). - **`server`:** Generates a server bundle in addition to static assets, enabling server-side rendering (SSR) and hybrid rendering strategies. This output is intended for deployment on a Node.js server or serverless environment. ## Key changes: - **Replaces `appShell` and `prerender`:** The `outputMode` option simplifies the CLI by replacing the `appShell` and `prerender` options when server-side routing is configured. - **Controls Server API Usage:** `outputMode` determines whether the new server API is utilized. In `server` mode, `server.ts` is bundled as a separate entry point, preventing direct references to `main.server.ts` and excluding it from localization. Closes #27356, closes #27403, closes #25726, closes #25718 and closes #27196
1 parent 09d2eb9 commit 3814f19

37 files changed

+1285
-245
lines changed

goldens/circular-deps/packages.json

+5-3
Original file line numberDiff line numberDiff line change
@@ -10,16 +10,18 @@
1010
[
1111
"packages/angular/build/src/tools/esbuild/bundler-context.ts",
1212
"packages/angular/build/src/tools/esbuild/utils.ts",
13-
"packages/angular/build/src/tools/esbuild/bundler-execution-result.ts"
13+
"packages/angular/build/src/utils/server-rendering/manifest.ts"
1414
],
1515
[
1616
"packages/angular/build/src/tools/esbuild/bundler-context.ts",
1717
"packages/angular/build/src/tools/esbuild/utils.ts",
18-
"packages/angular/build/src/utils/server-rendering/manifest.ts"
18+
"packages/angular/build/src/utils/server-rendering/manifest.ts",
19+
"packages/angular/build/src/tools/esbuild/bundler-execution-result.ts"
1920
],
2021
[
2122
"packages/angular/build/src/tools/esbuild/bundler-execution-result.ts",
22-
"packages/angular/build/src/tools/esbuild/utils.ts"
23+
"packages/angular/build/src/tools/esbuild/utils.ts",
24+
"packages/angular/build/src/utils/server-rendering/manifest.ts"
2325
],
2426
[
2527
"packages/angular/cli/src/analytics/analytics-collector.ts",

goldens/public-api/angular/build/index.api.md

+6-3
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ export interface ApplicationBuilderOptions {
4040
namedChunks?: boolean;
4141
optimization?: OptimizationUnion;
4242
outputHashing?: OutputHashing;
43+
outputMode?: OutputMode;
4344
outputPath: OutputPathUnion;
4445
poll?: number;
4546
polyfills?: string[];
@@ -99,13 +100,15 @@ export interface BuildOutputFile extends OutputFile {
99100
// @public (undocumented)
100101
export enum BuildOutputFileType {
101102
// (undocumented)
102-
Browser = 1,
103+
Browser = 0,
103104
// (undocumented)
104-
Media = 2,
105+
Media = 1,
105106
// (undocumented)
106107
Root = 4,
107108
// (undocumented)
108-
Server = 3
109+
Server = 2,
110+
// (undocumented)
111+
SSRServer = 3
109112
}
110113

111114
// @public

packages/angular/build/src/builders/application/execute-build.ts

+32-2
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
*/
88

99
import { BuilderContext } from '@angular-devkit/architect';
10+
import assert from 'node:assert';
1011
import { SourceFileCache } from '../../tools/esbuild/angular/source-file-cache';
1112
import { generateBudgetStats } from '../../tools/esbuild/budget-stats';
1213
import { BuildOutputFileType, BundlerContext } from '../../tools/esbuild/bundler-context';
@@ -18,13 +19,19 @@ import { calculateEstimatedTransferSizes, logBuildStats } from '../../tools/esbu
1819
import { BudgetCalculatorResult, checkBudgets } from '../../utils/bundle-calculator';
1920
import { shouldOptimizeChunks } from '../../utils/environment-options';
2021
import { resolveAssets } from '../../utils/resolve-assets';
22+
import {
23+
SERVER_APP_ENGINE_MANIFEST_FILENAME,
24+
generateAngularServerAppEngineManifest,
25+
} from '../../utils/server-rendering/manifest';
2126
import { getSupportedBrowsers } from '../../utils/supported-browsers';
2227
import { optimizeChunks } from './chunk-optimizer';
2328
import { executePostBundleSteps } from './execute-post-bundle';
2429
import { inlineI18n, loadActiveTranslations } from './i18n';
2530
import { NormalizedApplicationBuildOptions } from './options';
31+
import { OutputMode } from './schema';
2632
import { setupBundlerContexts } from './setup-bundling';
2733

34+
// eslint-disable-next-line max-lines-per-function
2835
export async function executeBuild(
2936
options: NormalizedApplicationBuildOptions,
3037
context: BuilderContext,
@@ -36,8 +43,10 @@ export async function executeBuild(
3643
i18nOptions,
3744
optimizationOptions,
3845
assets,
46+
outputMode,
3947
cacheOptions,
40-
prerenderOptions,
48+
serverEntryPoint,
49+
baseHref,
4150
ssrOptions,
4251
verbose,
4352
colors,
@@ -160,6 +169,15 @@ export async function executeBuild(
160169
executionResult.htmlBaseHref = options.baseHref;
161170
}
162171

172+
// Create server app engine manifest
173+
if (serverEntryPoint) {
174+
executionResult.addOutputFile(
175+
SERVER_APP_ENGINE_MANIFEST_FILENAME,
176+
generateAngularServerAppEngineManifest(i18nOptions, baseHref, undefined),
177+
BuildOutputFileType.SSRServer,
178+
);
179+
}
180+
163181
// Perform i18n translation inlining if enabled
164182
if (i18nOptions.shouldInline) {
165183
const result = await inlineI18n(options, executionResult, initialFiles);
@@ -183,8 +201,20 @@ export async function executeBuild(
183201
executionResult.assetFiles.push(...result.additionalAssets);
184202
}
185203

186-
if (prerenderOptions) {
204+
if (serverEntryPoint) {
187205
const prerenderedRoutes = executionResult.prerenderedRoutes;
206+
207+
// Regenerate the manifest to append prerendered routes data. This is only needed if SSR is enabled.
208+
if (outputMode === OutputMode.Server && Object.keys(prerenderedRoutes).length) {
209+
const manifest = executionResult.outputFiles.find(
210+
(f) => f.path === SERVER_APP_ENGINE_MANIFEST_FILENAME,
211+
);
212+
assert(manifest, `${SERVER_APP_ENGINE_MANIFEST_FILENAME} was not found in output files.`);
213+
manifest.contents = new TextEncoder().encode(
214+
generateAngularServerAppEngineManifest(i18nOptions, baseHref, prerenderedRoutes),
215+
);
216+
}
217+
188218
executionResult.addOutputFile(
189219
'prerendered-routes.json',
190220
JSON.stringify({ routes: prerenderedRoutes }, null, 2),

packages/angular/build/src/builders/application/execute-post-bundle.ts

+45-20
Original file line numberDiff line numberDiff line change
@@ -12,17 +12,24 @@ import {
1212
BuildOutputFileType,
1313
InitialFileRecord,
1414
} from '../../tools/esbuild/bundler-context';
15-
import { BuildOutputAsset } from '../../tools/esbuild/bundler-execution-result';
15+
import {
16+
BuildOutputAsset,
17+
PrerenderedRoutesRecord,
18+
} from '../../tools/esbuild/bundler-execution-result';
1619
import { generateIndexHtml } from '../../tools/esbuild/index-html-generator';
1720
import { createOutputFile } from '../../tools/esbuild/utils';
1821
import { maxWorkers } from '../../utils/environment-options';
1922
import {
2023
SERVER_APP_MANIFEST_FILENAME,
2124
generateAngularServerAppManifest,
2225
} from '../../utils/server-rendering/manifest';
26+
import { RouteRenderMode, SerializableRouteTreeNode } from '../../utils/server-rendering/models';
2327
import { prerenderPages } from '../../utils/server-rendering/prerender';
2428
import { augmentAppWithServiceWorkerEsbuild } from '../../utils/service-worker';
2529
import { INDEX_HTML_SERVER, NormalizedApplicationBuildOptions } from './options';
30+
import { OutputMode } from './schema';
31+
32+
type Writeable<T extends readonly unknown[]> = T extends readonly (infer U)[] ? U[] : never;
2633

2734
/**
2835
* Run additional builds steps including SSG, AppShell, Index HTML file and Service worker generation.
@@ -43,25 +50,26 @@ export async function executePostBundleSteps(
4350
warnings: string[];
4451
additionalOutputFiles: BuildOutputFile[];
4552
additionalAssets: BuildOutputAsset[];
46-
prerenderedRoutes: string[];
53+
prerenderedRoutes: PrerenderedRoutesRecord;
4754
}> {
4855
const additionalAssets: BuildOutputAsset[] = [];
4956
const additionalOutputFiles: BuildOutputFile[] = [];
5057
const allErrors: string[] = [];
5158
const allWarnings: string[] = [];
52-
const prerenderedRoutes: string[] = [];
59+
const prerenderedRoutes: PrerenderedRoutesRecord = {};
5360

5461
const {
5562
baseHref = '/',
5663
serviceWorker,
5764
indexHtmlOptions,
5865
optimizationOptions,
5966
sourcemapOptions,
60-
ssrOptions,
67+
outputMode,
68+
serverEntryPoint,
6169
prerenderOptions,
6270
appShellOptions,
6371
workspaceRoot,
64-
verbose,
72+
disableFullServerManifestGeneration,
6573
} = options;
6674

6775
// Index HTML content without CSS inlining to be used for server rendering (AppShell, SSG and SSR).
@@ -97,7 +105,7 @@ export async function executePostBundleSteps(
97105
}
98106

99107
// Create server manifest
100-
if (prerenderOptions || appShellOptions || ssrOptions) {
108+
if (serverEntryPoint) {
101109
additionalOutputFiles.push(
102110
createOutputFile(
103111
SERVER_APP_MANIFEST_FILENAME,
@@ -106,6 +114,7 @@ export async function executePostBundleSteps(
106114
outputFiles,
107115
optimizationOptions.styles.inlineCritical ?? false,
108116
undefined,
117+
locale,
109118
),
110119
BuildOutputFileType.Server,
111120
),
@@ -114,36 +123,32 @@ export async function executePostBundleSteps(
114123

115124
// Pre-render (SSG) and App-shell
116125
// If localization is enabled, prerendering is handled in the inlining process.
117-
if ((prerenderOptions || appShellOptions) && !allErrors.length) {
126+
if (
127+
!disableFullServerManifestGeneration &&
128+
(prerenderOptions || appShellOptions || (outputMode && serverEntryPoint)) &&
129+
!allErrors.length
130+
) {
118131
assert(
119132
indexHtmlOptions,
120133
'The "index" option is required when using the "ssg" or "appShell" options.',
121134
);
122135

123-
const {
124-
output,
125-
warnings,
126-
errors,
127-
prerenderedRoutes: generatedRoutes,
128-
serializableRouteTreeNode,
129-
} = await prerenderPages(
136+
const { output, warnings, errors, serializableRouteTreeNode } = await prerenderPages(
130137
workspaceRoot,
131138
baseHref,
132139
appShellOptions,
133140
prerenderOptions,
134141
[...outputFiles, ...additionalOutputFiles],
135142
assetFiles,
143+
outputMode,
136144
sourcemapOptions.scripts,
137145
maxWorkers,
138-
verbose,
139146
);
140147

141148
allErrors.push(...errors);
142149
allWarnings.push(...warnings);
143-
prerenderedRoutes.push(...Array.from(generatedRoutes));
144-
145-
const indexHasBeenPrerendered = generatedRoutes.has(indexHtmlOptions.output);
146150

151+
const indexHasBeenPrerendered = output[indexHtmlOptions.output];
147152
for (const [path, { content, appShellRoute }] of Object.entries(output)) {
148153
// Update the index contents with the app shell under these conditions:
149154
// - Replace 'index.html' with the app shell only if it hasn't been prerendered yet.
@@ -155,7 +160,26 @@ export async function executePostBundleSteps(
155160
);
156161
}
157162

158-
if (ssrOptions) {
163+
const serializableRouteTreeNodeForManifest: Writeable<SerializableRouteTreeNode> = [];
164+
165+
for (const metadata of serializableRouteTreeNode) {
166+
switch (metadata.renderMode) {
167+
case RouteRenderMode.Prerender:
168+
case /* Legacy building mode */ undefined: {
169+
if (!metadata.redirectTo || outputMode === OutputMode.Static) {
170+
prerenderedRoutes[metadata.route] = { headers: metadata.headers };
171+
}
172+
break;
173+
}
174+
case RouteRenderMode.Server:
175+
case RouteRenderMode.Client:
176+
serializableRouteTreeNodeForManifest.push(metadata);
177+
178+
break;
179+
}
180+
}
181+
182+
if (outputMode === OutputMode.Server) {
159183
// Regenerate the manifest to append route tree. This is only needed if SSR is enabled.
160184
const manifest = additionalOutputFiles.find((f) => f.path === SERVER_APP_MANIFEST_FILENAME);
161185
assert(manifest, `${SERVER_APP_MANIFEST_FILENAME} was not found in output files.`);
@@ -165,7 +189,8 @@ export async function executePostBundleSteps(
165189
additionalHtmlOutputFiles,
166190
outputFiles,
167191
optimizationOptions.styles.inlineCritical ?? false,
168-
serializableRouteTreeNode,
192+
serializableRouteTreeNodeForManifest,
193+
locale,
169194
),
170195
);
171196
}

packages/angular/build/src/builders/application/i18n.ts

+21-12
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,12 @@
77
*/
88

99
import { BuilderContext } from '@angular-devkit/architect';
10-
import { join, posix } from 'node:path';
10+
import { join } from 'node:path';
1111
import { BuildOutputFileType, InitialFileRecord } from '../../tools/esbuild/bundler-context';
12-
import { ExecutionResult } from '../../tools/esbuild/bundler-execution-result';
12+
import {
13+
ExecutionResult,
14+
PrerenderedRoutesRecord,
15+
} from '../../tools/esbuild/bundler-execution-result';
1316
import { I18nInliner } from '../../tools/esbuild/i18n-inliner';
1417
import { maxWorkers } from '../../utils/environment-options';
1518
import { loadTranslations } from '../../utils/i18n-options';
@@ -28,7 +31,11 @@ export async function inlineI18n(
2831
options: NormalizedApplicationBuildOptions,
2932
executionResult: ExecutionResult,
3033
initialFiles: Map<string, InitialFileRecord>,
31-
): Promise<{ errors: string[]; warnings: string[]; prerenderedRoutes: string[] }> {
34+
): Promise<{
35+
errors: string[];
36+
warnings: string[];
37+
prerenderedRoutes: PrerenderedRoutesRecord;
38+
}> {
3239
// Create the multi-threaded inliner with common options and the files generated from the build.
3340
const inliner = new I18nInliner(
3441
{
@@ -39,10 +46,14 @@ export async function inlineI18n(
3946
maxWorkers,
4047
);
4148

42-
const inlineResult: { errors: string[]; warnings: string[]; prerenderedRoutes: string[] } = {
49+
const inlineResult: {
50+
errors: string[];
51+
warnings: string[];
52+
prerenderedRoutes: PrerenderedRoutesRecord;
53+
} = {
4354
errors: [],
4455
warnings: [],
45-
prerenderedRoutes: [],
56+
prerenderedRoutes: {},
4657
};
4758

4859
// For each active locale, use the inliner to process the output files of the build.
@@ -95,15 +106,11 @@ export async function inlineI18n(
95106
destination: join(locale, assetFile.destination),
96107
});
97108
}
98-
99-
inlineResult.prerenderedRoutes.push(
100-
...generatedRoutes.map((route) => posix.join('/', locale, route)),
101-
);
102109
} else {
103-
inlineResult.prerenderedRoutes.push(...generatedRoutes);
104110
executionResult.assetFiles.push(...additionalAssets);
105111
}
106112

113+
inlineResult.prerenderedRoutes = { ...inlineResult.prerenderedRoutes, ...generatedRoutes };
107114
updatedOutputFiles.push(...localeOutputFiles);
108115
}
109116
} finally {
@@ -112,8 +119,10 @@ export async function inlineI18n(
112119

113120
// Update the result with all localized files.
114121
executionResult.outputFiles = [
115-
// Root files are not modified.
116-
...executionResult.outputFiles.filter(({ type }) => type === BuildOutputFileType.Root),
122+
// Root and SSR entry files are not modified.
123+
...executionResult.outputFiles.filter(
124+
({ type }) => type === BuildOutputFileType.Root || type === BuildOutputFileType.SSRServer,
125+
),
117126
// Updated files for each locale.
118127
...updatedOutputFiles,
119128
];

packages/angular/build/src/builders/application/index.ts

+8-4
Original file line numberDiff line numberDiff line change
@@ -88,16 +88,16 @@ export async function* buildApplicationInternal(
8888

8989
yield* runEsBuildBuildAction(
9090
async (rebuildState) => {
91-
const { prerenderOptions, jsonLogs } = normalizedOptions;
91+
const { serverEntryPoint, jsonLogs } = normalizedOptions;
9292

9393
const startTime = process.hrtime.bigint();
9494
const result = await executeBuild(normalizedOptions, context, rebuildState);
9595

9696
if (jsonLogs) {
9797
result.addLog(await createJsonBuildManifest(result, normalizedOptions));
9898
} else {
99-
if (prerenderOptions) {
100-
const prerenderedRoutesLength = result.prerenderedRoutes.length;
99+
if (serverEntryPoint) {
100+
const prerenderedRoutesLength = Object.keys(result.prerenderedRoutes).length;
101101
let prerenderMsg = `Prerendered ${prerenderedRoutesLength} static route`;
102102
prerenderMsg += prerenderedRoutesLength !== 1 ? 's.' : '.';
103103

@@ -225,7 +225,10 @@ export async function* buildApplication(
225225
// Writes the output files to disk and ensures the containing directories are present
226226
const directoryExists = new Set<string>();
227227
await emitFilesToDisk(Object.entries(result.files), async ([filePath, file]) => {
228-
if (outputOptions.ignoreServer && file.type === BuildOutputFileType.Server) {
228+
if (
229+
outputOptions.ignoreServer &&
230+
(file.type === BuildOutputFileType.Server || file.type === BuildOutputFileType.SSRServer)
231+
) {
229232
return;
230233
}
231234

@@ -236,6 +239,7 @@ export async function* buildApplication(
236239
typeDirectory = outputOptions.browser;
237240
break;
238241
case BuildOutputFileType.Server:
242+
case BuildOutputFileType.SSRServer:
239243
typeDirectory = outputOptions.server;
240244
break;
241245
case BuildOutputFileType.Root:

0 commit comments

Comments
 (0)