Skip to content

Commit 3b00fc9

Browse files
committedSep 19, 2024·
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
];

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

+10-5
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,11 @@ 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.ServerApplication ||
231+
file.type === BuildOutputFileType.ServerRoot)
232+
) {
229233
return;
230234
}
231235

@@ -235,7 +239,8 @@ export async function* buildApplication(
235239
case BuildOutputFileType.Media:
236240
typeDirectory = outputOptions.browser;
237241
break;
238-
case BuildOutputFileType.Server:
242+
case BuildOutputFileType.ServerApplication:
243+
case BuildOutputFileType.ServerRoot:
239244
typeDirectory = outputOptions.server;
240245
break;
241246
case BuildOutputFileType.Root:

‎packages/angular/build/src/builders/application/options.ts

+50-1
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import {
2929
Schema as ApplicationBuilderOptions,
3030
I18NTranslation,
3131
OutputHashing,
32+
OutputMode,
3233
OutputPathClass,
3334
} from './schema';
3435

@@ -79,6 +80,16 @@ interface InternalOptions {
7980
* This is only used by the development server which currently only supports a single locale per build.
8081
*/
8182
forceI18nFlatOutput?: boolean;
83+
84+
/**
85+
* When set to `true`, disables the generation of a full manifest with routes.
86+
*
87+
* This option is primarily used during development to improve performance,
88+
* as the full manifest is generated at runtime when using the development server.
89+
*
90+
* @default false
91+
*/
92+
disableFullServerManifestGeneration?: boolean;
8293
}
8394

8495
/** Full set of options for `application` builder. */
@@ -179,6 +190,37 @@ export async function normalizeOptions(
179190
}
180191
}
181192

193+
// Validate prerender and ssr options when using the outputMode
194+
if (options.outputMode === OutputMode.Server) {
195+
if (!options.server) {
196+
throw new Error('The "server" option is required when "outputMode" is set to "server".');
197+
}
198+
199+
if (typeof options.ssr === 'boolean' || !options.ssr?.entry) {
200+
throw new Error('The "ssr.entry" option is required when "outputMode" is set to "server".');
201+
}
202+
}
203+
204+
if (options.outputMode) {
205+
if (!options.server) {
206+
options.ssr = false;
207+
}
208+
209+
if (options.prerender) {
210+
context.logger.warn(
211+
'The "prerender" option is no longer needed when "outputMode" is specified.',
212+
);
213+
} else {
214+
options.prerender = !!options.server;
215+
}
216+
217+
if (options.appShell) {
218+
context.logger.warn(
219+
'The "appShell" option is no longer needed when "outputMode" is specified.',
220+
);
221+
}
222+
}
223+
182224
// A configuration file can exist in the project or workspace root
183225
const searchDirectories = await generateSearchDirectories([projectRoot, workspaceRoot]);
184226
const postcssConfiguration = await loadPostcssConfiguration(searchDirectories);
@@ -235,7 +277,10 @@ export async function normalizeOptions(
235277
clean: options.deleteOutputPath ?? true,
236278
// For app-shell and SSG server files are not required by users.
237279
// Omit these when SSR is not enabled.
238-
ignoreServer: ssrOptions === undefined || serverEntryPoint === undefined,
280+
ignoreServer:
281+
((ssrOptions === undefined || serverEntryPoint === undefined) &&
282+
options.outputMode === undefined) ||
283+
options.outputMode === OutputMode.Static,
239284
};
240285

241286
const outputNames = {
@@ -317,6 +362,7 @@ export async function normalizeOptions(
317362
poll,
318363
polyfills,
319364
statsJson,
365+
outputMode,
320366
stylePreprocessorOptions,
321367
subresourceIntegrity,
322368
verbose,
@@ -328,6 +374,7 @@ export async function normalizeOptions(
328374
deployUrl,
329375
clearScreen,
330376
define,
377+
disableFullServerManifestGeneration = false,
331378
} = options;
332379

333380
// Return all the normalized options
@@ -352,6 +399,7 @@ export async function normalizeOptions(
352399
serverEntryPoint,
353400
prerenderOptions,
354401
appShellOptions,
402+
outputMode,
355403
ssrOptions,
356404
verbose,
357405
watch,
@@ -387,6 +435,7 @@ export async function normalizeOptions(
387435
colors: supportColor(),
388436
clearScreen,
389437
define,
438+
disableFullServerManifestGeneration,
390439
};
391440
}
392441

‎packages/angular/build/src/builders/application/schema.json

+5
Original file line numberDiff line numberDiff line change
@@ -528,6 +528,11 @@
528528
"type": "boolean",
529529
"description": "Generates an application shell during build time.",
530530
"default": false
531+
},
532+
"outputMode": {
533+
"type": "string",
534+
"description": "Defines the build output target. 'static': Generates a static site for deployment on any static hosting service. 'server': Produces an application designed for deployment on a server that supports server-side rendering (SSR).",
535+
"enum": ["static", "server"]
531536
}
532537
},
533538
"additionalProperties": false,

‎packages/angular/build/src/builders/application/setup-bundling.ts

+16-3
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
createBrowserPolyfillBundleOptions,
1313
createServerMainCodeBundleOptions,
1414
createServerPolyfillBundleOptions,
15+
createSsrEntryCodeBundleOptions,
1516
} from '../../tools/esbuild/application-code-bundle';
1617
import { BundlerContext } from '../../tools/esbuild/bundler-context';
1718
import { createGlobalScriptsBundleOptions } from '../../tools/esbuild/global-scripts';
@@ -36,9 +37,10 @@ export function setupBundlerContexts(
3637
codeBundleCache: SourceFileCache,
3738
): BundlerContext[] {
3839
const {
40+
outputMode,
41+
serverEntryPoint,
3942
appShellOptions,
4043
prerenderOptions,
41-
serverEntryPoint,
4244
ssrOptions,
4345
workspaceRoot,
4446
watch = false,
@@ -90,9 +92,9 @@ export function setupBundlerContexts(
9092
}
9193

9294
// Skip server build when none of the features are enabled.
93-
if (serverEntryPoint && (prerenderOptions || appShellOptions || ssrOptions)) {
95+
if (serverEntryPoint && (outputMode || prerenderOptions || appShellOptions || ssrOptions)) {
9496
const nodeTargets = [...target, ...getSupportedNodeTargets()];
95-
// Server application code
97+
9698
bundlerContexts.push(
9799
new BundlerContext(
98100
workspaceRoot,
@@ -101,6 +103,17 @@ export function setupBundlerContexts(
101103
),
102104
);
103105

106+
if (outputMode && ssrOptions?.entry) {
107+
// New behavior introduced: 'server.ts' is now bundled separately from 'main.server.ts'.
108+
bundlerContexts.push(
109+
new BundlerContext(
110+
workspaceRoot,
111+
watch,
112+
createSsrEntryCodeBundleOptions(options, nodeTargets, codeBundleCache),
113+
),
114+
);
115+
}
116+
104117
// Server polyfills code
105118
const serverPolyfillBundleOptions = createServerPolyfillBundleOptions(
106119
options,
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import { buildApplication } from '../../index';
10+
import { OutputMode } from '../../schema';
11+
import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup';
12+
13+
describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => {
14+
beforeEach(async () => {
15+
await harness.modifyFile('src/tsconfig.app.json', (content) => {
16+
const tsConfig = JSON.parse(content);
17+
tsConfig.files ??= [];
18+
tsConfig.files.push('main.server.ts', 'server.ts');
19+
20+
return JSON.stringify(tsConfig);
21+
});
22+
23+
await harness.writeFile('src/server.ts', `console.log('Hello!');`);
24+
});
25+
26+
describe('Option: "outputMode"', () => {
27+
it(`should not emit 'server' directory when OutputMode is Static`, async () => {
28+
harness.useTarget('build', {
29+
...BASE_OPTIONS,
30+
outputMode: OutputMode.Static,
31+
server: 'src/main.server.ts',
32+
ssr: { entry: 'src/server.ts' },
33+
});
34+
35+
const { result } = await harness.executeOnce();
36+
expect(result?.success).toBeTrue();
37+
38+
harness.expectDirectory('dist/server').toNotExist();
39+
});
40+
41+
it(`should emit 'server' directory when OutputMode is Server`, async () => {
42+
harness.useTarget('build', {
43+
...BASE_OPTIONS,
44+
outputMode: OutputMode.Server,
45+
server: 'src/main.server.ts',
46+
ssr: { entry: 'src/server.ts' },
47+
});
48+
49+
const { result } = await harness.executeOnce();
50+
expect(result?.success).toBeTrue();
51+
52+
harness.expectFile('dist/server/main.server.mjs').toExist();
53+
harness.expectFile('dist/server/server.mjs').toExist();
54+
});
55+
});
56+
});

‎packages/angular/build/src/builders/dev-server/vite-server.ts

+28-23
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import type { BuilderContext } from '@angular-devkit/architect';
1111
import type { Plugin } from 'esbuild';
1212
import assert from 'node:assert';
1313
import { readFile } from 'node:fs/promises';
14-
import { builtinModules } from 'node:module';
14+
import { builtinModules, isBuiltin } from 'node:module';
1515
import { join } from 'node:path';
1616
import type { Connect, DepOptimizationConfig, InlineConfig, ViteDevServer } from 'vite';
1717
import { createAngularMemoryPlugin } from '../../tools/vite/angular-memory-plugin';
@@ -93,21 +93,19 @@ export async function* serveWithVite(
9393
builderName,
9494
)) as unknown as ApplicationBuilderInternalOptions;
9595

96-
if (browserOptions.prerender || browserOptions.ssr) {
96+
if (browserOptions.prerender) {
9797
// Disable prerendering if enabled and force SSR.
9898
// This is so instead of prerendering all the routes for every change, the page is "prerendered" when it is requested.
9999
browserOptions.prerender = false;
100-
101-
// Avoid bundling and processing the ssr entry-point as this is not used by the dev-server.
102-
browserOptions.ssr = true;
103-
104-
// https://nodejs.org/api/process.html#processsetsourcemapsenabledval
105-
process.setSourceMapsEnabled(true);
106100
}
107101

108102
// Set all packages as external to support Vite's prebundle caching
109103
browserOptions.externalPackages = serverOptions.prebundle;
110104

105+
// Disable generating a full manifest with routes.
106+
// This is done during runtime when using the dev-server.
107+
browserOptions.disableFullServerManifestGeneration = true;
108+
111109
// The development server currently only supports a single locale when localizing.
112110
// This matches the behavior of the Webpack-based development server but could be expanded in the future.
113111
if (
@@ -123,7 +121,14 @@ export async function* serveWithVite(
123121
browserOptions.forceI18nFlatOutput = true;
124122
}
125123

126-
const { vendor: thirdPartySourcemaps } = normalizeSourceMaps(browserOptions.sourceMap ?? false);
124+
const { vendor: thirdPartySourcemaps, scripts: scriptsSourcemaps } = normalizeSourceMaps(
125+
browserOptions.sourceMap ?? false,
126+
);
127+
128+
if (scriptsSourcemaps && browserOptions.server) {
129+
// https://nodejs.org/api/process.html#processsetsourcemapsenabledval
130+
process.setSourceMapsEnabled(true);
131+
}
127132

128133
// Setup the prebundling transformer that will be shared across Vite prebundling requests
129134
const prebundleTransformer = new JavaScriptTransformer(
@@ -229,9 +234,9 @@ export async function* serveWithVite(
229234
'externalMetadata'
230235
] as ExternalResultMetadata;
231236
const implicitServerFiltered = implicitServer.filter(
232-
(m) => removeNodeJsBuiltinModules(m) && removeAbsoluteUrls(m),
237+
(m) => !isBuiltin(m) && !isAbsoluteUrl(m),
233238
);
234-
const implicitBrowserFiltered = implicitBrowser.filter(removeAbsoluteUrls);
239+
const implicitBrowserFiltered = implicitBrowser.filter((m) => !isAbsoluteUrl(m));
235240

236241
if (browserOptions.ssr && serverOptions.prebundle !== false) {
237242
const previousImplicitServer = new Set(externalMetadata.implicitServer);
@@ -249,7 +254,7 @@ export async function* serveWithVite(
249254
externalMetadata.implicitBrowser.length = 0;
250255

251256
externalMetadata.explicitBrowser.push(...explicit);
252-
externalMetadata.explicitServer.push(...explicit, ...nodeJsBuiltinModules);
257+
externalMetadata.explicitServer.push(...explicit, ...builtinModules);
253258
externalMetadata.implicitServer.push(...implicitServerFiltered);
254259
externalMetadata.implicitBrowser.push(...implicitBrowserFiltered);
255260

@@ -386,7 +391,7 @@ async function handleUpdate(
386391
for (const [file, record] of generatedFiles) {
387392
if (record.updated) {
388393
updatedFiles.push(file);
389-
isServerFileUpdated ||= record.type === BuildOutputFileType.Server;
394+
isServerFileUpdated ||= record.type === BuildOutputFileType.ServerApplication;
390395

391396
const updatedModules = server.moduleGraph.getModulesByFile(
392397
normalizePath(join(server.config.root, file)),
@@ -744,14 +749,14 @@ function getDepOptimizationConfig({
744749
};
745750
}
746751

747-
const nodeJsBuiltinModules = new Set(builtinModules);
748-
749-
/** Remove any Node.js builtin modules to avoid Vite's prebundling from processing them as files. */
750-
function removeNodeJsBuiltinModules(value: string): boolean {
751-
return !nodeJsBuiltinModules.has(value);
752-
}
753-
754-
/** Remove any absolute URLs (http://, https://, //) to avoid Vite's prebundling from processing them as files. */
755-
function removeAbsoluteUrls(value: string): boolean {
756-
return !/^(?:https?:)?\/\//.test(value);
752+
/**
753+
* Checks if the given value is an absolute URL.
754+
*
755+
* This function helps in avoiding Vite's prebundling from processing absolute URLs (http://, https://, //) as files.
756+
*
757+
* @param value - The URL or path to check.
758+
* @returns `true` if the value is not an absolute URL; otherwise, `false`.
759+
*/
760+
function isAbsoluteUrl(value: string): boolean {
761+
return /^(?:https?:)?\/\//.test(value);
757762
}

‎packages/angular/build/src/builders/extract-i18n/application-extraction.ts

+3-4
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import type {
1717
ApplicationBuilderInternalOptions,
1818
} from '../application/options';
1919
import { ResultFile, ResultKind } from '../application/results';
20+
import { OutputMode } from '../application/schema';
2021
import type { NormalizedExtractI18nOptions } from './options';
2122

2223
export async function extractMessages(
@@ -44,10 +45,8 @@ export async function extractMessages(
4445
buildOptions.budgets = undefined;
4546
buildOptions.index = false;
4647
buildOptions.serviceWorker = false;
47-
48-
buildOptions.ssr = false;
49-
buildOptions.appShell = false;
50-
buildOptions.prerender = false;
48+
buildOptions.outputMode = OutputMode.Static;
49+
buildOptions.server = undefined;
5150

5251
// Build the application with the build options
5352
const builderResult = await first(buildApplicationInternal(buildOptions, context, extensions));

‎packages/angular/build/src/tools/esbuild/application-code-bundle.ts

+144-2
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,10 @@ import { createHash } from 'node:crypto';
1212
import { extname, relative } from 'node:path';
1313
import type { NormalizedApplicationBuildOptions } from '../../builders/application/options';
1414
import { allowMangle } from '../../utils/environment-options';
15-
import { SERVER_APP_MANIFEST_FILENAME } from '../../utils/server-rendering/manifest';
15+
import {
16+
SERVER_APP_ENGINE_MANIFEST_FILENAME,
17+
SERVER_APP_MANIFEST_FILENAME,
18+
} from '../../utils/server-rendering/manifest';
1619
import { createCompilerPlugin } from './angular/compiler-plugin';
1720
import { SourceFileCache } from './angular/source-file-cache';
1821
import { BundlerOptionsFactory } from './bundler-context';
@@ -220,6 +223,7 @@ export function createServerMainCodeBundleOptions(
220223
const {
221224
serverEntryPoint: mainServerEntryPoint,
222225
workspaceRoot,
226+
outputMode,
223227
externalPackages,
224228
ssrOptions,
225229
polyfills,
@@ -245,8 +249,9 @@ export function createServerMainCodeBundleOptions(
245249
};
246250

247251
const ssrEntryPoint = ssrOptions?.entry;
252+
const isOldBehaviour = !outputMode;
248253

249-
if (ssrEntryPoint) {
254+
if (ssrEntryPoint && isOldBehaviour) {
250255
// Old behavior: 'server.ts' was bundled together with the SSR (Server-Side Rendering) code.
251256
// This approach combined server-side logic and rendering into a single bundle.
252257
entryPoints['server'] = ssrEntryPoint;
@@ -347,6 +352,143 @@ export function createServerMainCodeBundleOptions(
347352
return buildOptions;
348353
}
349354

355+
export function createSsrEntryCodeBundleOptions(
356+
options: NormalizedApplicationBuildOptions,
357+
target: string[],
358+
sourceFileCache: SourceFileCache,
359+
): BuildOptions {
360+
const { workspaceRoot, ssrOptions, externalPackages } = options;
361+
const serverEntryPoint = ssrOptions?.entry;
362+
assert(
363+
serverEntryPoint,
364+
'createSsrEntryCodeBundleOptions should not be called without a defined serverEntryPoint.',
365+
);
366+
367+
const { pluginOptions, styleOptions } = createCompilerPluginOptions(
368+
options,
369+
target,
370+
sourceFileCache,
371+
);
372+
373+
const ssrEntryNamespace = 'angular:ssr-entry';
374+
const ssrInjectManifestNamespace = 'angular:ssr-entry-inject-manifest';
375+
const ssrInjectRequireNamespace = 'angular:ssr-entry-inject-require';
376+
const buildOptions: BuildOptions = {
377+
...getEsBuildServerCommonOptions(options),
378+
target,
379+
entryPoints: {
380+
// TODO: consider renaming to index
381+
'server': ssrEntryNamespace,
382+
},
383+
supported: getFeatureSupport(target, true),
384+
plugins: [
385+
createSourcemapIgnorelistPlugin(),
386+
createCompilerPlugin(
387+
// JS/TS options
388+
{ ...pluginOptions, noopTypeScriptCompilation: true },
389+
// Component stylesheet options
390+
styleOptions,
391+
),
392+
],
393+
inject: [ssrInjectRequireNamespace, ssrInjectManifestNamespace],
394+
};
395+
396+
buildOptions.plugins ??= [];
397+
398+
if (externalPackages) {
399+
buildOptions.packages = 'external';
400+
} else {
401+
buildOptions.plugins.push(createRxjsEsmResolutionPlugin());
402+
}
403+
404+
// Mark manifest file as external. As this will be generated later on.
405+
(buildOptions.external ??= []).push('*/main.server.mjs', ...SERVER_GENERATED_EXTERNALS);
406+
407+
buildOptions.plugins.push(
408+
{
409+
name: 'angular-ssr-metadata',
410+
setup(build) {
411+
build.onEnd((result) => {
412+
if (result.metafile) {
413+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
414+
(result.metafile as any)['ng-ssr-entry-bundle'] = true;
415+
}
416+
});
417+
},
418+
},
419+
createVirtualModulePlugin({
420+
namespace: ssrInjectRequireNamespace,
421+
cache: sourceFileCache?.loadResultCache,
422+
loadContent: () => {
423+
const contents: string[] = [
424+
// Note: Needed as esbuild does not provide require shims / proxy from ESModules.
425+
// See: https://github.com/evanw/esbuild/issues/1921.
426+
`import { createRequire } from 'node:module';`,
427+
`globalThis['require'] ??= createRequire(import.meta.url);`,
428+
];
429+
430+
return {
431+
contents: contents.join('\n'),
432+
loader: 'js',
433+
resolveDir: workspaceRoot,
434+
};
435+
},
436+
}),
437+
createVirtualModulePlugin({
438+
namespace: ssrInjectManifestNamespace,
439+
cache: sourceFileCache?.loadResultCache,
440+
loadContent: () => {
441+
const contents: string[] = [
442+
// Configure `@angular/ssr` app engine manifest.
443+
`import manifest from './${SERVER_APP_ENGINE_MANIFEST_FILENAME}';`,
444+
`import { ɵsetAngularAppEngineManifest } from '@angular/ssr';`,
445+
`ɵsetAngularAppEngineManifest(manifest);`,
446+
];
447+
448+
return {
449+
contents: contents.join('\n'),
450+
loader: 'js',
451+
resolveDir: workspaceRoot,
452+
};
453+
},
454+
}),
455+
createVirtualModulePlugin({
456+
namespace: ssrEntryNamespace,
457+
cache: sourceFileCache?.loadResultCache,
458+
loadContent: () => {
459+
const serverEntryPointJsImport = entryFileToWorkspaceRelative(
460+
workspaceRoot,
461+
serverEntryPoint,
462+
);
463+
const contents: string[] = [
464+
// Re-export all symbols including default export
465+
`import * as server from '${serverEntryPointJsImport}';`,
466+
`export * from '${serverEntryPointJsImport}';`,
467+
// The below is needed to avoid
468+
// `Import "default" will always be undefined because there is no matching export` warning when no default is present.
469+
`const defaultExportName = 'default';`,
470+
`export default server[defaultExportName]`,
471+
472+
// Add @angular/ssr exports
473+
`export { AngularAppEngine } from '@angular/ssr';`,
474+
];
475+
476+
return {
477+
contents: contents.join('\n'),
478+
loader: 'js',
479+
resolveDir: workspaceRoot,
480+
};
481+
},
482+
}),
483+
);
484+
485+
if (options.plugins) {
486+
buildOptions.plugins.push(...options.plugins);
487+
}
488+
489+
return buildOptions;
490+
}
491+
350492
function getEsBuildServerCommonOptions(options: NormalizedApplicationBuildOptions): BuildOptions {
351493
return {
352494
...getEsBuildCommonOptions(options),

‎packages/angular/build/src/tools/esbuild/budget-stats.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ export function generateBudgetStats(
3939
}
4040

4141
// Exclude server bundles
42-
if (type === BuildOutputFileType.Server) {
42+
if (type === BuildOutputFileType.ServerApplication || type === BuildOutputFileType.ServerRoot) {
4343
continue;
4444
}
4545

‎packages/angular/build/src/tools/esbuild/bundler-context.ts

+10-5
Original file line numberDiff line numberDiff line change
@@ -47,10 +47,11 @@ export interface InitialFileRecord {
4747
}
4848

4949
export enum BuildOutputFileType {
50-
Browser = 1,
51-
Media = 2,
52-
Server = 3,
53-
Root = 4,
50+
Browser,
51+
Media,
52+
ServerApplication,
53+
ServerRoot,
54+
Root,
5455
}
5556

5657
export interface BuildOutputFile extends OutputFile {
@@ -147,6 +148,7 @@ export class BundlerContext {
147148
}
148149

149150
result.initialFiles.forEach((value, key) => initialFiles.set(key, value));
151+
150152
outputFiles.push(...result.outputFiles);
151153
result.externalImports.browser?.forEach((value) => externalImportsBrowser.add(value));
152154
result.externalImports.server?.forEach((value) => externalImportsServer.add(value));
@@ -370,7 +372,10 @@ export class BundlerContext {
370372
if (!/\.([cm]?js|css|wasm)(\.map)?$/i.test(file.path)) {
371373
fileType = BuildOutputFileType.Media;
372374
} else if (this.#platformIsServer) {
373-
fileType = BuildOutputFileType.Server;
375+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
376+
fileType = (result.metafile as any)['ng-ssr-entry-bundle']
377+
? BuildOutputFileType.ServerRoot
378+
: BuildOutputFileType.ServerApplication;
374379
} else {
375380
fileType = BuildOutputFileType.Browser;
376381
}

‎packages/angular/build/src/tools/esbuild/bundler-execution-result.ts

+12-4
Original file line numberDiff line numberDiff line change
@@ -31,14 +31,16 @@ export interface ExternalResultMetadata {
3131
explicit: string[];
3232
}
3333

34+
export type PrerenderedRoutesRecord = Record<string, { headers?: Record<string, string> }>;
35+
3436
/**
3537
* Represents the result of a single builder execute call.
3638
*/
3739
export class ExecutionResult {
3840
outputFiles: BuildOutputFile[] = [];
3941
assetFiles: BuildOutputAsset[] = [];
4042
errors: (Message | PartialMessage)[] = [];
41-
prerenderedRoutes: string[] = [];
43+
prerenderedRoutes: PrerenderedRoutesRecord = {};
4244
warnings: (Message | PartialMessage)[] = [];
4345
logs: string[] = [];
4446
externalMetadata?: ExternalResultMetadata;
@@ -77,10 +79,16 @@ export class ExecutionResult {
7779
}
7880
}
7981

80-
addPrerenderedRoutes(routes: string[]): void {
81-
this.prerenderedRoutes.push(...routes);
82+
addPrerenderedRoutes(routes: PrerenderedRoutesRecord): void {
83+
Object.assign(this.prerenderedRoutes, routes);
84+
8285
// Sort the prerendered routes.
83-
this.prerenderedRoutes.sort((a, b) => a.localeCompare(b));
86+
const sortedObj: PrerenderedRoutesRecord = {};
87+
for (const key of Object.keys(this.prerenderedRoutes).sort()) {
88+
sortedObj[key] = this.prerenderedRoutes[key];
89+
}
90+
91+
this.prerenderedRoutes = sortedObj;
8492
}
8593

8694
addWarning(error: PartialMessage | string): void {

‎packages/angular/build/src/tools/esbuild/i18n-inliner.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,8 @@ export class I18nInliner {
4444
const files = new Map<string, Blob>();
4545
const pendingMaps = [];
4646
for (const file of options.outputFiles) {
47-
if (file.type === BuildOutputFileType.Root) {
47+
if (file.type === BuildOutputFileType.Root || file.type === BuildOutputFileType.ServerRoot) {
48+
// Skip also the server entry-point.
4849
// Skip stats and similar files.
4950
continue;
5051
}

‎packages/angular/build/src/tools/esbuild/utils.ts

+19-5
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,19 @@ import { pathToFileURL } from 'node:url';
1515
import { brotliCompress } from 'node:zlib';
1616
import { coerce } from 'semver';
1717
import { NormalizedApplicationBuildOptions } from '../../builders/application/options';
18+
import { OutputMode } from '../../builders/application/schema';
1819
import { BudgetCalculatorResult } from '../../utils/bundle-calculator';
19-
import { SERVER_APP_MANIFEST_FILENAME } from '../../utils/server-rendering/manifest';
20+
import {
21+
SERVER_APP_ENGINE_MANIFEST_FILENAME,
22+
SERVER_APP_MANIFEST_FILENAME,
23+
} from '../../utils/server-rendering/manifest';
2024
import { BundleStats, generateEsbuildBuildStatsTable } from '../../utils/stats-table';
2125
import { BuildOutputFile, BuildOutputFileType, InitialFileRecord } from './bundler-context';
22-
import { BuildOutputAsset, ExecutionResult } from './bundler-execution-result';
26+
import {
27+
BuildOutputAsset,
28+
ExecutionResult,
29+
PrerenderedRoutesRecord,
30+
} from './bundler-execution-result';
2331

2432
export function logBuildStats(
2533
metafile: Metafile,
@@ -48,7 +56,8 @@ export function logBuildStats(
4856
continue;
4957
}
5058

51-
const isPlatformServer = type === BuildOutputFileType.Server;
59+
const isPlatformServer =
60+
type === BuildOutputFileType.ServerApplication || type === BuildOutputFileType.ServerRoot;
5261
if (isPlatformServer && !ssrOutputEnabled) {
5362
// Only log server build stats when SSR is enabled.
5463
continue;
@@ -412,7 +421,7 @@ interface BuildManifest {
412421
server?: URL | undefined;
413422
browser: URL;
414423
};
415-
prerenderedRoutes?: string[];
424+
prerenderedRoutes: PrerenderedRoutesRecord;
416425
}
417426

418427
export async function createJsonBuildManifest(
@@ -423,6 +432,7 @@ export async function createJsonBuildManifest(
423432
colors: color,
424433
outputOptions: { base, server, browser },
425434
ssrOptions,
435+
outputMode,
426436
} = normalizedOptions;
427437

428438
const { warnings, errors, prerenderedRoutes } = result;
@@ -433,7 +443,10 @@ export async function createJsonBuildManifest(
433443
outputPaths: {
434444
root: pathToFileURL(base),
435445
browser: pathToFileURL(join(base, browser)),
436-
server: ssrOptions ? pathToFileURL(join(base, server)) : undefined,
446+
server:
447+
outputMode !== OutputMode.Static && ssrOptions
448+
? pathToFileURL(join(base, server))
449+
: undefined,
437450
},
438451
prerenderedRoutes,
439452
};
@@ -495,4 +508,5 @@ export function getEntryPointName(entryPoint: string): string {
495508
export const SERVER_GENERATED_EXTERNALS = new Set([
496509
'./polyfills.server.mjs',
497510
'./' + SERVER_APP_MANIFEST_FILENAME,
511+
'./' + SERVER_APP_ENGINE_MANIFEST_FILENAME,
498512
]);

‎packages/angular/build/src/utils/server-rendering/manifest.ts

+38-8
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,10 @@ import {
1313
getLocaleBaseHref,
1414
} from '../../builders/application/options';
1515
import type { BuildOutputFile } from '../../tools/esbuild/bundler-context';
16+
import { PrerenderedRoutesRecord } from '../../tools/esbuild/bundler-execution-result';
1617

1718
export const SERVER_APP_MANIFEST_FILENAME = 'angular-app-manifest.mjs';
19+
export const SERVER_APP_ENGINE_MANIFEST_FILENAME = 'angular-app-engine-manifest.mjs';
1820

1921
const MAIN_SERVER_OUTPUT_FILENAME = 'main.server.mjs';
2022

@@ -29,11 +31,13 @@ const MAIN_SERVER_OUTPUT_FILENAME = 'main.server.mjs';
2931
* includes settings for inlining locales and determining the output structure.
3032
* @param baseHref - The base HREF for the application. This is used to set the base URL
3133
* for all relative URLs in the application.
34+
* @param perenderedRoutes - A record mapping static paths to their associated data.
3235
* @returns A string representing the content of the SSR server manifest for App Engine.
3336
*/
3437
export function generateAngularServerAppEngineManifest(
3538
i18nOptions: NormalizedApplicationBuildOptions['i18nOptions'],
3639
baseHref: string | undefined,
40+
perenderedRoutes: PrerenderedRoutesRecord | undefined = {},
3741
): string {
3842
const entryPointsContent: string[] = [];
3943

@@ -42,19 +46,40 @@ export function generateAngularServerAppEngineManifest(
4246
const importPath =
4347
'./' + (i18nOptions.flatOutput ? '' : locale + '/') + MAIN_SERVER_OUTPUT_FILENAME;
4448

45-
const localWithBaseHref = getLocaleBaseHref('', i18nOptions, locale) || '/';
46-
entryPointsContent.push(`['${localWithBaseHref}', () => import('${importPath}')]`);
49+
let localeWithBaseHref = getLocaleBaseHref('', i18nOptions, locale) || '/';
50+
51+
// Remove leading and trailing slashes.
52+
const start = localeWithBaseHref[0] === '/' ? 1 : 0;
53+
const end = localeWithBaseHref[localeWithBaseHref.length - 1] === '/' ? -1 : undefined;
54+
localeWithBaseHref = localeWithBaseHref.slice(start, end);
55+
56+
entryPointsContent.push(`['${localeWithBaseHref}', () => import('${importPath}')]`);
4757
}
4858
} else {
49-
entryPointsContent.push(`['/', () => import('./${MAIN_SERVER_OUTPUT_FILENAME}')]`);
59+
entryPointsContent.push(`['', () => import('./${MAIN_SERVER_OUTPUT_FILENAME}')]`);
5060
}
5161

52-
const manifestContent = `
53-
{
54-
basePath: '${baseHref ?? '/'}',
55-
entryPoints: new Map([${entryPointsContent.join(', \n')}]),
62+
const staticHeaders: string[] = [];
63+
for (const [path, { headers }] of Object.entries(perenderedRoutes)) {
64+
if (!headers) {
65+
continue;
66+
}
67+
68+
const headersValues: string[] = [];
69+
for (const [name, value] of Object.entries(headers)) {
70+
headersValues.push(`['${name}', '${encodeURIComponent(value)}']`);
71+
}
72+
73+
staticHeaders.push(`['${path}', [${headersValues.join(', ')}]]`);
5674
}
57-
`;
75+
76+
const manifestContent = `
77+
export default {
78+
basePath: '${baseHref ?? '/'}',
79+
entryPoints: new Map([${entryPointsContent.join(', \n')}]),
80+
staticPathsHeaders: new Map([${staticHeaders.join(', \n')}]),
81+
};
82+
`;
5883

5984
return manifestContent;
6085
}
@@ -75,6 +100,9 @@ export function generateAngularServerAppEngineManifest(
75100
* in the server-side rendered pages.
76101
* @param routes - An optional array of route definitions for the application, used for
77102
* server-side rendering and routing.
103+
* @param locale - An optional string representing the locale or language code to be used for
104+
* the application, helping with localization and rendering content specific to the locale.
105+
*
78106
* @returns A string representing the content of the SSR server manifest for the Node.js
79107
* environment.
80108
*/
@@ -83,6 +111,7 @@ export function generateAngularServerAppManifest(
83111
outputFiles: BuildOutputFile[],
84112
inlineCriticalCss: boolean,
85113
routes: readonly unknown[] | undefined,
114+
locale: string | undefined,
86115
): string {
87116
const serverAssetsContent: string[] = [];
88117
for (const file of [...additionalHtmlOutputFiles.values(), ...outputFiles]) {
@@ -101,6 +130,7 @@ export default {
101130
inlineCriticalCss: ${inlineCriticalCss},
102131
routes: ${JSON.stringify(routes, undefined, 2)},
103132
assets: new Map([${serverAssetsContent.join(', \n')}]),
133+
locale: ${locale !== undefined ? `'${locale}'` : undefined},
104134
};
105135
`;
106136

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import type { RenderMode, ɵextractRoutesAndCreateRouteTree } from '@angular/ssr';
10+
import { ESMInMemoryFileLoaderWorkerData } from './esm-in-memory-loader/loader-hooks';
11+
12+
type Writeable<T extends readonly unknown[]> = T extends readonly (infer U)[] ? U[] : never;
13+
14+
export interface RoutesExtractorWorkerData extends ESMInMemoryFileLoaderWorkerData {
15+
assetFiles: Record</** Destination */ string, /** Source */ string>;
16+
}
17+
18+
export type SerializableRouteTreeNode = ReturnType<
19+
Awaited<ReturnType<typeof ɵextractRoutesAndCreateRouteTree>>['routeTree']['toObject']
20+
>;
21+
22+
export type WritableSerializableRouteTreeNode = Writeable<SerializableRouteTreeNode>;
23+
24+
export interface RoutersExtractorWorkerResult {
25+
serializedRouteTree: SerializableRouteTreeNode;
26+
errors: string[];
27+
}
28+
29+
/**
30+
* Local copy of `RenderMode` exported from `@angular/ssr`.
31+
* This constant is needed to handle interop between CommonJS (CJS) and ES Modules (ESM) formats.
32+
*
33+
* It maps `RenderMode` enum values to their corresponding numeric identifiers.
34+
*/
35+
export const RouteRenderMode: Record<keyof typeof RenderMode, RenderMode> = {
36+
AppShell: 0,
37+
Server: 1,
38+
Client: 2,
39+
Prerender: 3,
40+
};

‎packages/angular/build/src/utils/server-rendering/prerender.ts

+127-110
Original file line numberDiff line numberDiff line change
@@ -9,25 +9,24 @@
99
import { readFile } from 'node:fs/promises';
1010
import { extname, join, posix } from 'node:path';
1111
import { pathToFileURL } from 'node:url';
12+
import { NormalizedApplicationBuildOptions } from '../../builders/application/options';
13+
import { OutputMode } from '../../builders/application/schema';
1214
import { BuildOutputFile, BuildOutputFileType } from '../../tools/esbuild/bundler-context';
1315
import { BuildOutputAsset } from '../../tools/esbuild/bundler-execution-result';
16+
import { assertIsError } from '../error';
1417
import { urlJoin } from '../url';
1518
import { WorkerPool } from '../worker-pool';
16-
import type { RenderWorkerData } from './render-worker';
17-
import type {
19+
import {
20+
RouteRenderMode,
1821
RoutersExtractorWorkerResult,
1922
RoutesExtractorWorkerData,
2023
SerializableRouteTreeNode,
21-
} from './routes-extractor-worker';
24+
WritableSerializableRouteTreeNode,
25+
} from './models';
26+
import type { RenderWorkerData } from './render-worker';
2227

23-
interface PrerenderOptions {
24-
routesFile?: string;
25-
discoverRoutes?: boolean;
26-
}
27-
28-
interface AppShellOptions {
29-
route?: string;
30-
}
28+
type PrerenderOptions = NormalizedApplicationBuildOptions['prerenderOptions'];
29+
type AppShellOptions = NormalizedApplicationBuildOptions['appShellOptions'];
3130

3231
/**
3332
* Represents the output of a prerendering process.
@@ -48,18 +47,17 @@ type PrerenderOutput = Record<string, { content: string; appShellRoute: boolean
4847
export async function prerenderPages(
4948
workspaceRoot: string,
5049
baseHref: string,
51-
appShellOptions: AppShellOptions = {},
52-
prerenderOptions: PrerenderOptions = {},
50+
appShellOptions: AppShellOptions | undefined,
51+
prerenderOptions: PrerenderOptions | undefined,
5352
outputFiles: Readonly<BuildOutputFile[]>,
5453
assets: Readonly<BuildOutputAsset[]>,
54+
outputMode: OutputMode | undefined,
5555
sourcemap = false,
5656
maxThreads = 1,
57-
verbose = false,
5857
): Promise<{
5958
output: PrerenderOutput;
6059
warnings: string[];
6160
errors: string[];
62-
prerenderedRoutes: Set<string>;
6361
serializableRouteTreeNode: SerializableRouteTreeNode;
6462
}> {
6563
const outputFilesForWorker: Record<string, string> = {};
@@ -68,7 +66,7 @@ export async function prerenderPages(
6866
const errors: string[] = [];
6967

7068
for (const { text, path, type } of outputFiles) {
71-
if (type !== BuildOutputFileType.Server) {
69+
if (type !== BuildOutputFileType.ServerApplication && type !== BuildOutputFileType.ServerRoot) {
7270
continue;
7371
}
7472

@@ -99,45 +97,64 @@ export async function prerenderPages(
9997
}
10098

10199
// Get routes to prerender
102-
const {
103-
routes: allRoutes,
104-
warnings: routesWarnings,
105-
errors: routesErrors,
106-
serializableRouteTreeNode,
107-
} = await getAllRoutes(
108-
workspaceRoot,
109-
baseHref,
110-
outputFilesForWorker,
111-
assetsReversed,
112-
appShellOptions,
113-
prerenderOptions,
114-
sourcemap,
115-
verbose,
116-
);
100+
const { errors: extractionErrors, serializedRouteTree: serializableRouteTreeNode } =
101+
await getAllRoutes(
102+
workspaceRoot,
103+
baseHref,
104+
outputFilesForWorker,
105+
assetsReversed,
106+
appShellOptions,
107+
prerenderOptions,
108+
sourcemap,
109+
outputMode,
110+
).catch((err) => {
111+
return {
112+
errors: [
113+
`An error occurred while extracting routes.\n\n${err.stack ?? err.message ?? err}`,
114+
],
115+
serializedRouteTree: [],
116+
};
117+
});
118+
119+
errors.push(...extractionErrors);
120+
121+
const serializableRouteTreeNodeForPrerender: WritableSerializableRouteTreeNode = [];
122+
for (const metadata of serializableRouteTreeNode) {
123+
if (outputMode !== OutputMode.Static && metadata.redirectTo) {
124+
continue;
125+
}
117126

118-
if (routesErrors?.length) {
119-
errors.push(...routesErrors);
120-
}
127+
switch (metadata.renderMode) {
128+
case undefined: /* Legacy building mode */
129+
case RouteRenderMode.Prerender:
130+
case RouteRenderMode.AppShell:
131+
serializableRouteTreeNodeForPrerender.push(metadata);
121132

122-
if (routesWarnings?.length) {
123-
warnings.push(...routesWarnings);
133+
break;
134+
case RouteRenderMode.Server:
135+
if (outputMode === OutputMode.Static) {
136+
errors.push(
137+
`Route '${metadata.route}' is configured with server render mode, but the build 'outputMode' is set to 'static'.`,
138+
);
139+
}
140+
break;
141+
}
124142
}
125143

126-
if (allRoutes.size < 1 || errors.length > 0) {
144+
if (!serializableRouteTreeNodeForPrerender.length || errors.length > 0) {
127145
return {
128146
errors,
129147
warnings,
130148
output: {},
131149
serializableRouteTreeNode,
132-
prerenderedRoutes: allRoutes,
133150
};
134151
}
135152

136153
// Render routes
137154
const { errors: renderingErrors, output } = await renderPages(
138155
baseHref,
139156
sourcemap,
140-
allRoutes,
157+
serializableRouteTreeNodeForPrerender,
141158
maxThreads,
142159
workspaceRoot,
143160
outputFilesForWorker,
@@ -152,25 +169,18 @@ export async function prerenderPages(
152169
warnings,
153170
output,
154171
serializableRouteTreeNode,
155-
prerenderedRoutes: allRoutes,
156172
};
157173
}
158174

159-
class RoutesSet extends Set<string> {
160-
override add(value: string): this {
161-
return super.add(addLeadingSlash(value));
162-
}
163-
}
164-
165175
async function renderPages(
166176
baseHref: string,
167177
sourcemap: boolean,
168-
allRoutes: Set<string>,
178+
serializableRouteTreeNode: SerializableRouteTreeNode,
169179
maxThreads: number,
170180
workspaceRoot: string,
171181
outputFilesForWorker: Record<string, string>,
172182
assetFilesForWorker: Record<string, string>,
173-
appShellOptions: AppShellOptions,
183+
appShellOptions: AppShellOptions | undefined,
174184
): Promise<{
175185
output: PrerenderOutput;
176186
errors: string[];
@@ -190,7 +200,7 @@ async function renderPages(
190200

191201
const renderWorker = new WorkerPool({
192202
filename: require.resolve('./render-worker'),
193-
maxThreads: Math.min(allRoutes.size, maxThreads),
203+
maxThreads: Math.min(serializableRouteTreeNode.length, maxThreads),
194204
workerData: {
195205
workspaceRoot,
196206
outputFiles: outputFilesForWorker,
@@ -201,22 +211,31 @@ async function renderPages(
201211

202212
try {
203213
const renderingPromises: Promise<void>[] = [];
204-
const appShellRoute = appShellOptions.route && addLeadingSlash(appShellOptions.route);
214+
const appShellRoute = appShellOptions && addLeadingSlash(appShellOptions.route);
205215
const baseHrefWithLeadingSlash = addLeadingSlash(baseHref);
206216

207-
for (const route of allRoutes) {
217+
for (const { route, redirectTo, renderMode } of serializableRouteTreeNode) {
208218
// Remove base href from file output path.
209219
const routeWithoutBaseHref = addLeadingSlash(
210220
route.slice(baseHrefWithLeadingSlash.length - 1),
211221
);
222+
const outPath = posix.join(removeLeadingSlash(routeWithoutBaseHref), 'index.html');
223+
224+
if (typeof redirectTo === 'string') {
225+
output[outPath] = { content: generateRedirectStaticPage(redirectTo), appShellRoute: false };
212226

213-
const render: Promise<string | null> = renderWorker.run({ url: route });
227+
continue;
228+
}
229+
230+
const isAppShellRoute =
231+
renderMode === RouteRenderMode.AppShell ||
232+
// Legacy handling
233+
(renderMode === undefined && appShellRoute === routeWithoutBaseHref);
234+
235+
const render: Promise<string | null> = renderWorker.run({ url: route, isAppShellRoute });
214236
const renderResult: Promise<void> = render
215237
.then((content) => {
216238
if (content !== null) {
217-
const outPath = posix.join(removeLeadingSlash(routeWithoutBaseHref), 'index.html');
218-
const isAppShellRoute = appShellRoute === routeWithoutBaseHref;
219-
220239
output[outPath] = { content, appShellRoute: isAppShellRoute };
221240
}
222241
})
@@ -246,33 +265,31 @@ async function getAllRoutes(
246265
baseHref: string,
247266
outputFilesForWorker: Record<string, string>,
248267
assetFilesForWorker: Record<string, string>,
249-
appShellOptions: AppShellOptions,
250-
prerenderOptions: PrerenderOptions,
268+
appShellOptions: AppShellOptions | undefined,
269+
prerenderOptions: PrerenderOptions | undefined,
251270
sourcemap: boolean,
252-
verbose: boolean,
253-
): Promise<{
254-
routes: Set<string>;
255-
warnings?: string[];
256-
errors?: string[];
257-
serializableRouteTreeNode: SerializableRouteTreeNode;
258-
}> {
259-
const { routesFile, discoverRoutes } = prerenderOptions;
260-
const routes = new RoutesSet();
261-
const { route: appShellRoute } = appShellOptions;
262-
263-
if (appShellRoute !== undefined) {
264-
routes.add(urlJoin(baseHref, appShellRoute));
271+
outputMode: OutputMode | undefined,
272+
): Promise<{ serializedRouteTree: SerializableRouteTreeNode; errors: string[] }> {
273+
const { routesFile, discoverRoutes } = prerenderOptions ?? {};
274+
const routes: WritableSerializableRouteTreeNode = [];
275+
276+
if (appShellOptions) {
277+
routes.push({
278+
route: urlJoin(baseHref, appShellOptions.route),
279+
});
265280
}
266281

267282
if (routesFile) {
268283
const routesFromFile = (await readFile(routesFile, 'utf8')).split(/\r?\n/);
269284
for (const route of routesFromFile) {
270-
routes.add(urlJoin(baseHref, route.trim()));
285+
routes.push({
286+
route: urlJoin(baseHref, route.trim()),
287+
});
271288
}
272289
}
273290

274291
if (!discoverRoutes) {
275-
return { routes, serializableRouteTreeNode: [] };
292+
return { errors: [], serializedRouteTree: routes };
276293
}
277294

278295
const workerExecArgv = [
@@ -296,47 +313,22 @@ async function getAllRoutes(
296313
execArgv: workerExecArgv,
297314
});
298315

299-
const errors: string[] = [];
300-
const { serializedRouteTree: serializableRouteTreeNode }: RoutersExtractorWorkerResult =
301-
await renderWorker
302-
.run({})
303-
.catch((err) => {
304-
errors.push(`An error occurred while extracting routes.\n\n${err.stack}`);
305-
})
306-
.finally(() => {
307-
void renderWorker.destroy();
308-
});
309-
310-
const skippedRedirects: string[] = [];
311-
const skippedOthers: string[] = [];
312-
for (const { route, redirectTo } of serializableRouteTreeNode) {
313-
if (redirectTo) {
314-
skippedRedirects.push(route);
315-
} else if (route.includes('*')) {
316-
skippedOthers.push(route);
317-
} else {
318-
routes.add(route);
319-
}
320-
}
316+
try {
317+
const { serializedRouteTree, errors }: RoutersExtractorWorkerResult = await renderWorker.run({
318+
outputMode,
319+
});
321320

322-
let warnings: string[] | undefined;
323-
if (verbose) {
324-
if (skippedOthers.length) {
325-
(warnings ??= []).push(
326-
'The following routes were skipped from prerendering because they contain routes with dynamic parameters:\n' +
327-
skippedOthers.join('\n'),
328-
);
329-
}
321+
return { errors, serializedRouteTree: [...routes, ...serializedRouteTree] };
322+
} catch (err) {
323+
assertIsError(err);
330324

331-
if (skippedRedirects.length) {
332-
(warnings ??= []).push(
333-
'The following routes were skipped from prerendering because they contain redirects:\n',
334-
skippedRedirects.join('\n'),
335-
);
336-
}
325+
return {
326+
errors: [`An error occurred while extracting routes.\n\n${err.stack}`],
327+
serializedRouteTree: [],
328+
};
329+
} finally {
330+
void renderWorker.destroy();
337331
}
338-
339-
return { routes, serializableRouteTreeNode, warnings };
340332
}
341333

342334
function addLeadingSlash(value: string): string {
@@ -346,3 +338,28 @@ function addLeadingSlash(value: string): string {
346338
function removeLeadingSlash(value: string): string {
347339
return value.charAt(0) === '/' ? value.slice(1) : value;
348340
}
341+
342+
/**
343+
* Generates a static HTML page with a meta refresh tag to redirect the user to a specified URL.
344+
*
345+
* This function creates a simple HTML page that performs a redirect using a meta tag.
346+
* It includes a fallback link in case the meta-refresh doesn't work.
347+
*
348+
* @param url - The URL to which the page should redirect.
349+
* @returns The HTML content of the static redirect page.
350+
*/
351+
function generateRedirectStaticPage(url: string): string {
352+
return `
353+
<!DOCTYPE html>
354+
<html>
355+
<head>
356+
<meta charset="utf-8">
357+
<title>Redirecting</title>
358+
<meta http-equiv="refresh" content="0; url=${url}">
359+
</head>
360+
<body>
361+
<pre>Redirecting to <a href="${url}">${url}</a></pre>
362+
</body>
363+
</html>
364+
`.trim();
365+
}

‎packages/angular/build/src/utils/server-rendering/routes-extractor-worker.ts

+10-16
Original file line numberDiff line numberDiff line change
@@ -6,33 +6,27 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9-
import type { ɵextractRoutesAndCreateRouteTree } from '@angular/ssr';
10-
import type { ESMInMemoryFileLoaderWorkerData } from './esm-in-memory-loader/loader-hooks';
9+
import { OutputMode } from '../../builders/application/schema';
1110
import { patchFetchToLoadInMemoryAssets } from './fetch-patch';
1211
import { loadEsmModuleFromMemory } from './load-esm-from-memory';
12+
import { RoutersExtractorWorkerResult } from './models';
1313

14-
export interface RoutesExtractorWorkerData extends ESMInMemoryFileLoaderWorkerData {
15-
assetFiles: Record</** Destination */ string, /** Source */ string>;
16-
}
17-
18-
export type SerializableRouteTreeNode = ReturnType<
19-
Awaited<ReturnType<typeof ɵextractRoutesAndCreateRouteTree>>['routeTree']['toObject']
20-
>;
21-
22-
export interface RoutersExtractorWorkerResult {
23-
serializedRouteTree: SerializableRouteTreeNode;
24-
errors: string[];
14+
export interface ExtractRoutesOptions {
15+
outputMode?: OutputMode;
2516
}
2617

2718
/** Renders an application based on a provided options. */
28-
async function extractRoutes(): Promise<RoutersExtractorWorkerResult> {
19+
async function extractRoutes({
20+
outputMode,
21+
}: ExtractRoutesOptions): Promise<RoutersExtractorWorkerResult> {
2922
const { ɵextractRoutesAndCreateRouteTree: extractRoutesAndCreateRouteTree } =
3023
await loadEsmModuleFromMemory('./main.server.mjs');
3124

3225
const { routeTree, errors } = await extractRoutesAndCreateRouteTree(
3326
new URL('http://local-angular-prerender/'),
34-
/** manifest */ undefined,
35-
/** invokeGetPrerenderParams */ true,
27+
undefined /** manifest */,
28+
true /** invokeGetPrerenderParams */,
29+
outputMode === OutputMode.Server /** includePrerenderFallbackRoutes */,
3630
);
3731

3832
return {

‎packages/angular/ssr/public_api.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ export { AngularAppEngine } from './src/app-engine';
1212

1313
export {
1414
type PrerenderFallback,
15-
type RenderMode,
1615
type ServerRoute,
1716
provideServerRoutesConfig,
17+
RenderMode,
1818
} from './src/routes/route-config';

‎packages/angular/ssr/src/app-engine.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import type { AngularServerApp } from './app';
1010
import { Hooks } from './hooks';
1111
import { getPotentialLocaleIdFromUrl } from './i18n';
1212
import { EntryPointExports, getAngularAppEngineManifest } from './manifest';
13-
import { stripIndexHtmlFromURL } from './utils/url';
13+
import { stripIndexHtmlFromURL, stripTrailingSlash } from './utils/url';
1414

1515
/**
1616
* Angular server application engine.
@@ -116,7 +116,7 @@ export class AngularAppEngine {
116116
}
117117

118118
const { pathname } = stripIndexHtmlFromURL(new URL(request.url));
119-
const headers = this.manifest.staticPathsHeaders.get(pathname);
119+
const headers = this.manifest.staticPathsHeaders.get(stripTrailingSlash(pathname));
120120

121121
return new Map(headers);
122122
}

‎packages/angular/ssr/src/app.ts

+19-5
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9-
import { StaticProvider, ɵresetCompiledComponents } from '@angular/core';
9+
import { LOCALE_ID, StaticProvider, ɵresetCompiledComponents } from '@angular/core';
1010
import { ServerAssets } from './assets';
1111
import { Hooks } from './hooks';
1212
import { getAngularAppManifest } from './manifest';
@@ -172,7 +172,10 @@ export class AngularServerApp {
172172
// Initialize the response with status and headers if available.
173173
responseInit = {
174174
status,
175-
headers: headers ? new Headers(headers) : undefined,
175+
headers: new Headers({
176+
'Content-Type': 'text/html;charset=UTF-8',
177+
...headers,
178+
}),
176179
};
177180

178181
if (renderMode === RenderMode.Client) {
@@ -196,15 +199,26 @@ export class AngularServerApp {
196199
);
197200
}
198201

199-
const { manifest, hooks, assets } = this;
202+
const {
203+
manifest: { bootstrap, inlineCriticalCss, locale },
204+
hooks,
205+
assets,
206+
} = this;
207+
208+
if (locale !== undefined) {
209+
platformProviders.push({
210+
provide: LOCALE_ID,
211+
useValue: locale,
212+
});
213+
}
200214

201215
let html = await assets.getIndexServerHtml();
202216
// Skip extra microtask if there are no pre hooks.
203217
if (hooks.has('html:transform:pre')) {
204218
html = await hooks.run('html:transform:pre', { html });
205219
}
206220

207-
this.boostrap ??= await manifest.bootstrap();
221+
this.boostrap ??= await bootstrap();
208222

209223
html = await renderAngular(
210224
html,
@@ -214,7 +228,7 @@ export class AngularServerApp {
214228
SERVER_CONTEXT_VALUE[renderMode],
215229
);
216230

217-
if (manifest.inlineCriticalCss) {
231+
if (inlineCriticalCss) {
218232
// Optionally inline critical CSS.
219233
this.inlineCriticalCssProcessor ??= new InlineCriticalCssProcessor((path: string) => {
220234
const fileName = path.split('/').pop() ?? path;

‎packages/angular/ssr/src/manifest.ts

+6
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,12 @@ export interface AngularAppManifest {
8989
* It is used for route matching and navigation within the server application.
9090
*/
9191
readonly routes?: SerializableRouteTreeNode;
92+
93+
/**
94+
* An optional string representing the locale or language code to be used for
95+
* the application, aiding with localization and rendering content specific to the locale.
96+
*/
97+
readonly locale?: string;
9298
}
9399

94100
/**

‎packages/angular/ssr/test/app_spec.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -114,15 +114,15 @@ describe('AngularServerApp', () => {
114114
expect(Object.fromEntries(headers)).toEqual({
115115
'cache-control': 'no-cache',
116116
'x-some-header': 'value',
117-
'content-type': 'text/plain;charset=UTF-8',
117+
'content-type': 'text/html;charset=UTF-8',
118118
});
119119
});
120120

121121
it('should return only default headers for pages without specific header configurations', async () => {
122122
const response = await app.render(new Request('http://localhost/home'));
123123
const headers = response?.headers.entries() ?? [];
124124
expect(Object.fromEntries(headers)).toEqual({
125-
'content-type': 'text/plain;charset=UTF-8', // default header
125+
'content-type': 'text/html;charset=UTF-8', // default header
126126
});
127127
});
128128

‎tests/legacy-cli/e2e.bzl

+1
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ ESBUILD_TESTS = [
4242

4343
WEBPACK_IGNORE_TESTS = [
4444
"tests/vite/**",
45+
"tests/server-rendering/server-routes-*",
4546
"tests/commands/serve/ssr-http-requests-assets.js",
4647
"tests/build/prerender/http-requests-assets.js",
4748
"tests/build/prerender/error-with-sourcemaps.js",

‎tests/legacy-cli/e2e/tests/build/prerender/discover-routes-standalone.ts

+9-9
Original file line numberDiff line numberDiff line change
@@ -111,15 +111,15 @@ export default async function () {
111111
// prerendered-routes.json file is only generated when using esbuild.
112112
const generatedRoutesStats = await readFile('dist/test-project/prerendered-routes.json');
113113
deepStrictEqual(JSON.parse(generatedRoutesStats), {
114-
routes: [
115-
'/',
116-
'/lazy-one',
117-
'/lazy-one/lazy-one-child',
118-
'/lazy-two',
119-
'/two',
120-
'/two/two-child-one',
121-
'/two/two-child-two',
122-
],
114+
routes: {
115+
'/': {},
116+
'/lazy-one': {},
117+
'/lazy-one/lazy-one-child': {},
118+
'/lazy-two': {},
119+
'/two': {},
120+
'/two/two-child-one': {},
121+
'/two/two-child-two': {},
122+
},
123123
});
124124
}
125125
}

‎tests/legacy-cli/e2e/tests/i18n/setup.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -102,10 +102,11 @@ export async function setupI18nConfig() {
102102
`
103103
import { Component, Inject, LOCALE_ID } from '@angular/core';
104104
import { DatePipe } from '@angular/common';
105+
import { RouterOutlet } from '@angular/router';
105106
106107
@Component({
107108
selector: 'app-root',
108-
imports: [DatePipe],
109+
imports: [DatePipe, RouterOutlet],
109110
standalone: true,
110111
templateUrl: './app.component.html'
111112
})
@@ -124,6 +125,7 @@ export async function setupI18nConfig() {
124125
<p id="locale">{{ locale }}</p>
125126
<p id="date">{{ jan | date : 'LLLL' }}</p>
126127
<p id="plural" i18n>Updated {minutes, plural, =0 {just now} =1 {one minute ago} other {{{minutes}} minutes ago}}</p>
128+
<router-outlet></router-outlet>
127129
`,
128130
);
129131

‎tests/legacy-cli/e2e/tests/build/ssr/express-engine-csp-nonce.ts renamed to ‎tests/legacy-cli/e2e/tests/server-rendering/express-engine-csp-nonce.ts

+6-6
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1-
import { getGlobalVariable } from '../../../utils/env';
2-
import { rimraf, writeMultipleFiles } from '../../../utils/fs';
3-
import { findFreePort } from '../../../utils/network';
4-
import { installWorkspacePackages } from '../../../utils/packages';
5-
import { execAndWaitForOutputToMatch, ng } from '../../../utils/process';
6-
import { updateJsonFile, useSha } from '../../../utils/project';
1+
import { getGlobalVariable } from '../../utils/env';
2+
import { rimraf, writeMultipleFiles } from '../../utils/fs';
3+
import { findFreePort } from '../../utils/network';
4+
import { installWorkspacePackages } from '../../utils/packages';
5+
import { execAndWaitForOutputToMatch, ng } from '../../utils/process';
6+
import { updateJsonFile, useSha } from '../../utils/project';
77

88
export default async function () {
99
const useWebpackBuilder = !getGlobalVariable('argv')['esbuild'];

‎tests/legacy-cli/e2e/tests/build/ssr/express-engine-ngmodule.ts renamed to ‎tests/legacy-cli/e2e/tests/server-rendering/express-engine-ngmodule.ts

+6-6
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1-
import { getGlobalVariable } from '../../../utils/env';
2-
import { rimraf, writeMultipleFiles } from '../../../utils/fs';
3-
import { findFreePort } from '../../../utils/network';
4-
import { installWorkspacePackages } from '../../../utils/packages';
5-
import { execAndWaitForOutputToMatch, ng } from '../../../utils/process';
6-
import { updateJsonFile, useCIChrome, useCIDefaults, useSha } from '../../../utils/project';
1+
import { getGlobalVariable } from '../../utils/env';
2+
import { rimraf, writeMultipleFiles } from '../../utils/fs';
3+
import { findFreePort } from '../../utils/network';
4+
import { installWorkspacePackages } from '../../utils/packages';
5+
import { execAndWaitForOutputToMatch, ng } from '../../utils/process';
6+
import { updateJsonFile, useCIChrome, useCIDefaults, useSha } from '../../utils/project';
77

88
export default async function () {
99
// forcibly remove in case another test doesn't clean itself up

‎tests/legacy-cli/e2e/tests/build/ssr/express-engine-standalone.ts renamed to ‎tests/legacy-cli/e2e/tests/server-rendering/express-engine-standalone.ts

+6-6
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1-
import { getGlobalVariable } from '../../../utils/env';
2-
import { rimraf, writeMultipleFiles } from '../../../utils/fs';
3-
import { findFreePort } from '../../../utils/network';
4-
import { installWorkspacePackages } from '../../../utils/packages';
5-
import { execAndWaitForOutputToMatch, ng } from '../../../utils/process';
6-
import { updateJsonFile, useSha } from '../../../utils/project';
1+
import { getGlobalVariable } from '../../utils/env';
2+
import { rimraf, writeMultipleFiles } from '../../utils/fs';
3+
import { findFreePort } from '../../utils/network';
4+
import { installWorkspacePackages } from '../../utils/packages';
5+
import { execAndWaitForOutputToMatch, ng } from '../../utils/process';
6+
import { updateJsonFile, useSha } from '../../utils/project';
77

88
export default async function () {
99
// forcibly remove in case another test doesn't clean itself up
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { join } from 'node:path';
2+
import assert from 'node:assert';
3+
import { expectFileToMatch, writeFile } from '../../utils/fs';
4+
import { noSilentNg, silentNg } from '../../utils/process';
5+
import { setupProjectWithSSRAppEngine, spawnServer } from './setup';
6+
import { langTranslations, setupI18nConfig } from '../i18n/setup';
7+
8+
export default async function () {
9+
// Setup project
10+
await setupI18nConfig();
11+
await setupProjectWithSSRAppEngine();
12+
13+
// Add routes
14+
await writeFile(
15+
'src/app/app.routes.ts',
16+
`
17+
import { Routes } from '@angular/router';
18+
import { HomeComponent } from './home/home.component';
19+
import { SsrComponent } from './ssr/ssr.component';
20+
import { SsgComponent } from './ssg/ssg.component';
21+
22+
export const routes: Routes = [
23+
{
24+
path: '',
25+
component: HomeComponent,
26+
},
27+
{
28+
path: 'ssg',
29+
component: SsgComponent,
30+
},
31+
{
32+
path: 'ssr',
33+
component: SsrComponent,
34+
},
35+
];
36+
`,
37+
);
38+
39+
// Add server routing
40+
await writeFile(
41+
'src/app/app.routes.server.ts',
42+
`
43+
import { RenderMode, ServerRoute } from '@angular/ssr';
44+
45+
export const routes: ServerRoute[] = [
46+
{
47+
path: '',
48+
renderMode: RenderMode.Prerender,
49+
},
50+
{
51+
path: 'ssg',
52+
renderMode: RenderMode.Prerender,
53+
},
54+
{
55+
path: '**',
56+
renderMode: RenderMode.Server,
57+
},
58+
];
59+
`,
60+
);
61+
62+
// Generate components for the above routes
63+
const componentNames: string[] = ['home', 'ssg', 'csr', 'ssr'];
64+
for (const componentName of componentNames) {
65+
await silentNg('generate', 'component', componentName);
66+
}
67+
68+
await noSilentNg('build', '--output-mode=server', '--base-href=/base/');
69+
70+
for (const { lang, outputPath } of langTranslations) {
71+
await expectFileToMatch(join(outputPath, 'index.html'), `<p id="locale">${lang}</p>`);
72+
await expectFileToMatch(join(outputPath, 'ssg/index.html'), `<p id="locale">${lang}</p>`);
73+
}
74+
75+
// Tests responses
76+
const port = await spawnServer();
77+
const pathname = '/ssr';
78+
79+
for (const { lang } of langTranslations) {
80+
const res = await fetch(`http://localhost:${port}/base/${lang}${pathname}`);
81+
const text = await res.text();
82+
83+
assert.match(
84+
text,
85+
new RegExp(`<p id="locale">${lang}</p>`),
86+
`Response for '${lang}${pathname}': '<p id="locale">${lang}</p>' was not matched in content.`,
87+
);
88+
}
89+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import { join } from 'node:path';
2+
import assert from 'node:assert';
3+
import { expectFileToMatch, writeFile } from '../../utils/fs';
4+
import { noSilentNg, silentNg } from '../../utils/process';
5+
import { setupProjectWithSSRAppEngine, spawnServer } from './setup';
6+
import { langTranslations, setupI18nConfig } from '../i18n/setup';
7+
8+
export default async function () {
9+
// Setup project
10+
await setupI18nConfig();
11+
await setupProjectWithSSRAppEngine();
12+
13+
// Add routes
14+
await writeFile(
15+
'src/app/app.routes.ts',
16+
`
17+
import { Routes } from '@angular/router';
18+
import { HomeComponent } from './home/home.component';
19+
import { SsrComponent } from './ssr/ssr.component';
20+
import { SsgComponent } from './ssg/ssg.component';
21+
22+
export const routes: Routes = [
23+
{
24+
path: '',
25+
component: HomeComponent,
26+
},
27+
{
28+
path: 'ssg',
29+
component: SsgComponent,
30+
},
31+
{
32+
path: 'ssr',
33+
component: SsrComponent,
34+
},
35+
];
36+
`,
37+
);
38+
39+
// Add server routing
40+
await writeFile(
41+
'src/app/app.routes.server.ts',
42+
`
43+
import { RenderMode, ServerRoute } from '@angular/ssr';
44+
45+
export const routes: ServerRoute[] = [
46+
{
47+
path: '',
48+
renderMode: RenderMode.Prerender,
49+
},
50+
{
51+
path: 'ssg',
52+
renderMode: RenderMode.Prerender,
53+
},
54+
{
55+
path: '**',
56+
renderMode: RenderMode.Server,
57+
},
58+
];
59+
`,
60+
);
61+
62+
// Generate components for the above routes
63+
const componentNames: string[] = ['home', 'ssg', 'ssr'];
64+
for (const componentName of componentNames) {
65+
await silentNg('generate', 'component', componentName);
66+
}
67+
68+
await noSilentNg('build', '--output-mode=server');
69+
70+
const expects: Record<string, string> = {
71+
'index.html': 'home works!',
72+
'ssg/index.html': 'ssg works!',
73+
};
74+
75+
for (const { lang, outputPath } of langTranslations) {
76+
for (const [filePath, fileMatch] of Object.entries(expects)) {
77+
await expectFileToMatch(join(outputPath, filePath), `<p id="locale">${lang}</p>`);
78+
await expectFileToMatch(join(outputPath, filePath), fileMatch);
79+
}
80+
}
81+
82+
// Tests responses
83+
const port = await spawnServer();
84+
const pathname = '/ssr';
85+
86+
// We run the tests twice to ensure that the locale ID is set correctly.
87+
for (const iteration of [1, 2]) {
88+
for (const { lang, translation } of langTranslations) {
89+
const res = await fetch(`http://localhost:${port}/${lang}${pathname}`);
90+
const text = await res.text();
91+
92+
for (const match of [`<p id="date">${translation.date}</p>`, `<p id="locale">${lang}</p>`]) {
93+
assert.match(
94+
text,
95+
new RegExp(match),
96+
`Response for '${lang}${pathname}': '${match}' was not matched in content. Iteration: ${iteration}.`,
97+
);
98+
}
99+
}
100+
}
101+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
import { join } from 'node:path';
2+
import { existsSync } from 'node:fs';
3+
import assert from 'node:assert';
4+
import { expectFileToMatch, writeFile } from '../../utils/fs';
5+
import { noSilentNg, silentNg } from '../../utils/process';
6+
import { setupProjectWithSSRAppEngine, spawnServer } from './setup';
7+
8+
export default async function () {
9+
// Setup project
10+
await setupProjectWithSSRAppEngine();
11+
12+
// Add routes
13+
await writeFile(
14+
'src/app/app.routes.ts',
15+
`
16+
import { Routes } from '@angular/router';
17+
import { HomeComponent } from './home/home.component';
18+
import { CsrComponent } from './csr/csr.component';
19+
import { SsrComponent } from './ssr/ssr.component';
20+
import { SsgComponent } from './ssg/ssg.component';
21+
import { AppShellComponent } from './app-shell/app-shell.component';
22+
import { SsgWithParamsComponent } from './ssg-with-params/ssg-with-params.component';
23+
24+
export const routes: Routes = [
25+
{
26+
path: 'app-shell',
27+
component: AppShellComponent
28+
},
29+
{
30+
path: '',
31+
component: HomeComponent,
32+
},
33+
{
34+
path: 'ssg',
35+
component: SsgComponent,
36+
},
37+
{
38+
path: 'ssr',
39+
component: SsrComponent,
40+
},
41+
{
42+
path: 'csr',
43+
component: CsrComponent,
44+
},
45+
{
46+
path: 'redirect',
47+
redirectTo: 'ssg'
48+
},
49+
{
50+
path: 'ssg/:id',
51+
component: SsgWithParamsComponent,
52+
},
53+
];
54+
`,
55+
);
56+
57+
// Add server routing
58+
await writeFile(
59+
'src/app/app.routes.server.ts',
60+
`
61+
import { RenderMode, ServerRoute } from '@angular/ssr';
62+
63+
export const routes: ServerRoute[] = [
64+
{
65+
path: 'ssg/:id',
66+
renderMode: RenderMode.Prerender,
67+
headers: { 'x-custom': 'ssg-with-params' },
68+
getPrerenderParams: async() => [{id: 'one'}, {id: 'two'}],
69+
},
70+
{
71+
path: 'ssr',
72+
renderMode: RenderMode.Server,
73+
headers: { 'x-custom': 'ssr' },
74+
},
75+
{
76+
path: 'csr',
77+
renderMode: RenderMode.Client,
78+
headers: { 'x-custom': 'csr' },
79+
},
80+
{
81+
path: 'app-shell',
82+
renderMode: RenderMode.AppShell,
83+
},
84+
{
85+
path: '**',
86+
renderMode: RenderMode.Prerender,
87+
headers: { 'x-custom': 'ssg' },
88+
},
89+
];
90+
`,
91+
);
92+
93+
// Generate components for the above routes
94+
const componentNames: string[] = ['home', 'ssg', 'ssg-with-params', 'csr', 'ssr', 'app-shell'];
95+
96+
for (const componentName of componentNames) {
97+
await silentNg('generate', 'component', componentName);
98+
}
99+
100+
await noSilentNg('build', '--output-mode=server');
101+
102+
const expects: Record<string, string> = {
103+
'index.html': 'home works!',
104+
'ssg/index.html': 'ssg works!',
105+
'ssg/one/index.html': 'ssg-with-params works!',
106+
'ssg/two/index.html': 'ssg-with-params works!',
107+
};
108+
109+
for (const [filePath, fileMatch] of Object.entries(expects)) {
110+
await expectFileToMatch(join('dist/test-project/browser', filePath), fileMatch);
111+
}
112+
113+
const filesDoNotExist: string[] = ['csr/index.html', 'ssr/index.html', 'redirect/index.html'];
114+
for (const filePath of filesDoNotExist) {
115+
const file = join('dist/test-project/browser', filePath);
116+
assert.equal(existsSync(file), false, `Expected '${file}' to not exist.`);
117+
}
118+
119+
// Tests responses
120+
const responseExpects: Record<
121+
string,
122+
{ headers: Record<string, string>; content: string; serverContext: string }
123+
> = {
124+
'/': {
125+
content: 'home works',
126+
serverContext: 'ng-server-context="ssg"',
127+
headers: {
128+
'x-custom': 'ssg',
129+
},
130+
},
131+
'/ssg': {
132+
content: 'ssg works!',
133+
serverContext: 'ng-server-context="ssg"',
134+
headers: {
135+
'x-custom': 'ssg',
136+
},
137+
},
138+
'/ssr': {
139+
content: 'ssr works!',
140+
serverContext: 'ng-server-context="ssr"',
141+
headers: {
142+
'x-custom': 'ssr',
143+
},
144+
},
145+
'/csr': {
146+
content: 'app-shell works',
147+
serverContext: 'ng-server-context="app-shell"',
148+
headers: {
149+
'x-custom': 'csr',
150+
},
151+
},
152+
};
153+
154+
const port = await spawnServer();
155+
for (const [pathname, { content, headers, serverContext }] of Object.entries(responseExpects)) {
156+
const res = await fetch(`http://localhost:${port}${pathname}`);
157+
const text = await res.text();
158+
159+
assert.match(
160+
text,
161+
new RegExp(content),
162+
`Response for '${pathname}': ${content} was not matched in content.`,
163+
);
164+
165+
assert.match(
166+
text,
167+
new RegExp(serverContext),
168+
`Response for '${pathname}': ${serverContext} was not matched in content.`,
169+
);
170+
171+
for (const [name, value] of Object.entries(headers)) {
172+
assert.equal(
173+
res.headers.get(name),
174+
value,
175+
`Response for '${pathname}': ${name} header value did not match expected.`,
176+
);
177+
}
178+
}
179+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { join } from 'node:path';
2+
import { expectFileToMatch, replaceInFile, writeFile } from '../../utils/fs';
3+
import { noSilentNg, silentNg } from '../../utils/process';
4+
import { setupProjectWithSSRAppEngine } from './setup';
5+
import { existsSync } from 'node:fs';
6+
import { expectToFail } from '../../utils/utils';
7+
import assert from 'node:assert';
8+
9+
export default async function () {
10+
// Setup project
11+
await setupProjectWithSSRAppEngine();
12+
13+
// Add routes
14+
await writeFile(
15+
'src/app/app.routes.ts',
16+
`
17+
import { Routes } from '@angular/router';
18+
import { HomeComponent } from './home/home.component';
19+
import { SsgComponent } from './ssg/ssg.component';
20+
import { SsgWithParamsComponent } from './ssg-with-params/ssg-with-params.component';
21+
22+
export const routes: Routes = [
23+
{
24+
path: '',
25+
component: HomeComponent,
26+
},
27+
{
28+
path: 'ssg',
29+
component: SsgComponent,
30+
},
31+
{
32+
path: 'ssg-redirect',
33+
redirectTo: 'ssg'
34+
},
35+
{
36+
path: 'ssg/:id',
37+
component: SsgWithParamsComponent,
38+
},
39+
];
40+
`,
41+
);
42+
43+
// Add server routing
44+
await writeFile(
45+
'src/app/app.routes.server.ts',
46+
`
47+
import { RenderMode, ServerRoute } from '@angular/ssr';
48+
49+
export const routes: ServerRoute[] = [
50+
{
51+
path: 'ssg/:id',
52+
renderMode: RenderMode.Prerender,
53+
getPrerenderParams: async() => [{id: 'one'}, {id: 'two'}],
54+
},
55+
{
56+
path: '**',
57+
renderMode: RenderMode.Server,
58+
},
59+
];
60+
`,
61+
);
62+
63+
// Generate components for the above routes
64+
const componentNames: string[] = ['home', 'ssg', 'ssg-with-params'];
65+
66+
for (const componentName of componentNames) {
67+
await silentNg('generate', 'component', componentName);
68+
}
69+
70+
// Should error as above we set `RenderMode.Server`
71+
const { message: errorMessage } = await expectToFail(() =>
72+
noSilentNg('build', '--output-mode=static'),
73+
);
74+
assert.match(
75+
errorMessage,
76+
new RegExp(
77+
`Route '/' is configured with server render mode, but the build 'outputMode' is set to 'static'.`,
78+
),
79+
);
80+
81+
// Fix the error
82+
await replaceInFile('src/app/app.routes.server.ts', 'RenderMode.Server', 'RenderMode.Prerender');
83+
await noSilentNg('build', '--output-mode=static');
84+
85+
const expects: Record<string, string> = {
86+
'index.html': 'home works!',
87+
'ssg/index.html': 'ssg works!',
88+
'ssg/one/index.html': 'ssg-with-params works!',
89+
'ssg/two/index.html': 'ssg-with-params works!',
90+
// When static redirects as generated as meta tags.
91+
'ssg-redirect/index.html': '<meta http-equiv="refresh" content="0; url=/ssg">',
92+
};
93+
94+
for (const [filePath, fileMatch] of Object.entries(expects)) {
95+
await expectFileToMatch(join('dist/test-project/browser', filePath), fileMatch);
96+
}
97+
98+
// Check that server directory does not exist
99+
assert(
100+
!existsSync('dist/test-project/server'),
101+
'Server directory should not exist when output-mode is static',
102+
);
103+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import { getGlobalVariable } from '../../utils/env';
2+
import { writeFile } from '../../utils/fs';
3+
import { findFreePort } from '../../utils/network';
4+
import { installWorkspacePackages, uninstallPackage } from '../../utils/packages';
5+
import { execAndWaitForOutputToMatch, ng } from '../../utils/process';
6+
import { updateJsonFile, useSha } from '../../utils/project';
7+
import assert from 'node:assert';
8+
9+
export async function spawnServer(): Promise<number> {
10+
const port = await findFreePort();
11+
await execAndWaitForOutputToMatch(
12+
'npm',
13+
['run', 'serve:ssr:test-project'],
14+
/Node Express server listening on/,
15+
{
16+
'PORT': String(port),
17+
},
18+
);
19+
20+
return port;
21+
}
22+
23+
export async function setupProjectWithSSRAppEngine(): Promise<void> {
24+
assert(
25+
getGlobalVariable('argv')['esbuild'],
26+
'This test should not be called in the Webpack suite.',
27+
);
28+
29+
// Forcibly remove in case another test doesn't clean itself up.
30+
await uninstallPackage('@angular/ssr');
31+
await ng('add', '@angular/ssr', '--skip-confirmation', '--skip-install');
32+
33+
await useSha();
34+
await installWorkspacePackages();
35+
36+
// Add server config
37+
await writeFile(
38+
'src/app/app.config.server.ts',
39+
`
40+
import { mergeApplicationConfig, ApplicationConfig } from '@angular/core';
41+
import { provideServerRendering } from '@angular/platform-server';
42+
import { provideServerRoutesConfig } from '@angular/ssr';
43+
import { routes } from './app.routes.server';
44+
import { appConfig } from './app.config';
45+
46+
const serverConfig: ApplicationConfig = {
47+
providers: [
48+
provideServerRoutesConfig(routes),
49+
provideServerRendering()
50+
]
51+
};
52+
53+
export const config = mergeApplicationConfig(appConfig, serverConfig);
54+
`,
55+
);
56+
57+
// Update server.ts
58+
await writeFile(
59+
'server.ts',
60+
`
61+
import { AngularNodeAppEngine, writeResponseToNodeResponse } from '@angular/ssr/node';
62+
import express from 'express';
63+
import { fileURLToPath } from 'node:url';
64+
import { dirname, resolve } from 'node:path';
65+
66+
// The Express app is exported so that it can be used by serverless Functions.
67+
export function app(): express.Express {
68+
const server = express();
69+
const serverDistFolder = dirname(fileURLToPath(import.meta.url));
70+
const browserDistFolder = resolve(serverDistFolder, '../browser');
71+
72+
const angularNodeAppEngine = new AngularNodeAppEngine();
73+
74+
server.set('view engine', 'html');
75+
server.set('views', browserDistFolder);
76+
77+
server.get('**', express.static(browserDistFolder, {
78+
maxAge: '1y',
79+
index: 'index.html',
80+
setHeaders: (res, path) => {
81+
const headers = angularNodeAppEngine.getPrerenderHeaders(res.req);
82+
for (const [key, value] of headers) {
83+
res.setHeader(key, value);
84+
}
85+
}
86+
}));
87+
88+
// All regular routes use the Angular engine
89+
server.get('**', (req, res, next) => {
90+
angularNodeAppEngine
91+
.render(req)
92+
.then((response) => {
93+
if (response) {
94+
return writeResponseToNodeResponse(response, res);
95+
}
96+
97+
return next();
98+
})
99+
.catch((err) => next(err));
100+
});
101+
102+
return server;
103+
}
104+
105+
function run(): void {
106+
const port = process.env['PORT'] || 4000;
107+
108+
// Start up the Node server
109+
const server = app();
110+
server.listen(port, () => {
111+
console.log(\`Node Express server listening on http://localhost:\${port}\`);
112+
});
113+
}
114+
115+
run();
116+
`,
117+
);
118+
119+
// Update angular.json
120+
await updateJsonFile('angular.json', (workspaceJson) => {
121+
const appArchitect = workspaceJson.projects['test-project'].architect;
122+
const options = appArchitect.build.options;
123+
124+
delete options.prerender;
125+
delete options.appShell;
126+
});
127+
}

0 commit comments

Comments
 (0)
Please sign in to comment.