Skip to content

Commit 3b00fc9

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. - **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 8723e0c commit 3b00fc9

38 files changed

+1343
-268
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+
ServerApplication = 2,
110+
// (undocumented)
111+
ServerRoot = 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.ServerRoot,
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

+48-22
Original file line numberDiff line numberDiff line change
@@ -12,17 +12,25 @@ 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 {
27+
RouteRenderMode,
28+
WritableSerializableRouteTreeNode,
29+
} from '../../utils/server-rendering/models';
2330
import { prerenderPages } from '../../utils/server-rendering/prerender';
2431
import { augmentAppWithServiceWorkerEsbuild } from '../../utils/service-worker';
2532
import { INDEX_HTML_SERVER, NormalizedApplicationBuildOptions } from './options';
33+
import { OutputMode } from './schema';
2634

2735
/**
2836
* Run additional builds steps including SSG, AppShell, Index HTML file and Service worker generation.
@@ -43,25 +51,26 @@ export async function executePostBundleSteps(
4351
warnings: string[];
4452
additionalOutputFiles: BuildOutputFile[];
4553
additionalAssets: BuildOutputAsset[];
46-
prerenderedRoutes: string[];
54+
prerenderedRoutes: PrerenderedRoutesRecord;
4755
}> {
4856
const additionalAssets: BuildOutputAsset[] = [];
4957
const additionalOutputFiles: BuildOutputFile[] = [];
5058
const allErrors: string[] = [];
5159
const allWarnings: string[] = [];
52-
const prerenderedRoutes: string[] = [];
60+
const prerenderedRoutes: PrerenderedRoutesRecord = {};
5361

5462
const {
5563
baseHref = '/',
5664
serviceWorker,
5765
indexHtmlOptions,
5866
optimizationOptions,
5967
sourcemapOptions,
60-
ssrOptions,
68+
outputMode,
69+
serverEntryPoint,
6170
prerenderOptions,
6271
appShellOptions,
6372
workspaceRoot,
64-
verbose,
73+
disableFullServerManifestGeneration,
6574
} = options;
6675

6776
// Index HTML content without CSS inlining to be used for server rendering (AppShell, SSG and SSR).
@@ -91,13 +100,13 @@ export async function executePostBundleSteps(
91100
if (ssrContent) {
92101
additionalHtmlOutputFiles.set(
93102
INDEX_HTML_SERVER,
94-
createOutputFile(INDEX_HTML_SERVER, ssrContent, BuildOutputFileType.Server),
103+
createOutputFile(INDEX_HTML_SERVER, ssrContent, BuildOutputFileType.ServerApplication),
95104
);
96105
}
97106
}
98107

99108
// Create server manifest
100-
if (prerenderOptions || appShellOptions || ssrOptions) {
109+
if (serverEntryPoint) {
101110
additionalOutputFiles.push(
102111
createOutputFile(
103112
SERVER_APP_MANIFEST_FILENAME,
@@ -106,44 +115,41 @@ export async function executePostBundleSteps(
106115
outputFiles,
107116
optimizationOptions.styles.inlineCritical ?? false,
108117
undefined,
118+
locale,
109119
),
110-
BuildOutputFileType.Server,
120+
BuildOutputFileType.ServerApplication,
111121
),
112122
);
113123
}
114124

115125
// Pre-render (SSG) and App-shell
116126
// If localization is enabled, prerendering is handled in the inlining process.
117-
if ((prerenderOptions || appShellOptions) && !allErrors.length) {
127+
if (
128+
!disableFullServerManifestGeneration &&
129+
(prerenderOptions || appShellOptions || (outputMode && serverEntryPoint)) &&
130+
!allErrors.length
131+
) {
118132
assert(
119133
indexHtmlOptions,
120134
'The "index" option is required when using the "ssg" or "appShell" options.',
121135
);
122136

123-
const {
124-
output,
125-
warnings,
126-
errors,
127-
prerenderedRoutes: generatedRoutes,
128-
serializableRouteTreeNode,
129-
} = await prerenderPages(
137+
const { output, warnings, errors, serializableRouteTreeNode } = await prerenderPages(
130138
workspaceRoot,
131139
baseHref,
132140
appShellOptions,
133141
prerenderOptions,
134142
[...outputFiles, ...additionalOutputFiles],
135143
assetFiles,
144+
outputMode,
136145
sourcemapOptions.scripts,
137146
maxWorkers,
138-
verbose,
139147
);
140148

141149
allErrors.push(...errors);
142150
allWarnings.push(...warnings);
143-
prerenderedRoutes.push(...Array.from(generatedRoutes));
144-
145-
const indexHasBeenPrerendered = generatedRoutes.has(indexHtmlOptions.output);
146151

152+
const indexHasBeenPrerendered = output[indexHtmlOptions.output];
147153
for (const [path, { content, appShellRoute }] of Object.entries(output)) {
148154
// Update the index contents with the app shell under these conditions:
149155
// - Replace 'index.html' with the app shell only if it hasn't been prerendered yet.
@@ -155,7 +161,26 @@ export async function executePostBundleSteps(
155161
);
156162
}
157163

158-
if (ssrOptions) {
164+
const serializableRouteTreeNodeForManifest: WritableSerializableRouteTreeNode = [];
165+
166+
for (const metadata of serializableRouteTreeNode) {
167+
switch (metadata.renderMode) {
168+
case RouteRenderMode.Prerender:
169+
case /* Legacy building mode */ undefined: {
170+
if (!metadata.redirectTo || outputMode === OutputMode.Static) {
171+
prerenderedRoutes[metadata.route] = { headers: metadata.headers };
172+
}
173+
break;
174+
}
175+
case RouteRenderMode.Server:
176+
case RouteRenderMode.Client:
177+
serializableRouteTreeNodeForManifest.push(metadata);
178+
179+
break;
180+
}
181+
}
182+
183+
if (outputMode === OutputMode.Server) {
159184
// Regenerate the manifest to append route tree. This is only needed if SSR is enabled.
160185
const manifest = additionalOutputFiles.find((f) => f.path === SERVER_APP_MANIFEST_FILENAME);
161186
assert(manifest, `${SERVER_APP_MANIFEST_FILENAME} was not found in output files.`);
@@ -165,7 +190,8 @@ export async function executePostBundleSteps(
165190
additionalHtmlOutputFiles,
166191
outputFiles,
167192
optimizationOptions.styles.inlineCritical ?? false,
168-
serializableRouteTreeNode,
193+
serializableRouteTreeNodeForManifest,
194+
locale,
169195
),
170196
);
171197
}

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.ServerRoot,
125+
),
117126
// Updated files for each locale.
118127
...updatedOutputFiles,
119128
];

0 commit comments

Comments
 (0)