Skip to content

Commit a3f50d5

Browse files
committed
feat(@angular/build): introduce outputMode option to the application builder
The `outputMode` option defines the build output target, offering two modes: - `'static'`: Generates a static site suitable for deployment on any static hosting service. This mode can produce a fully client-side rendered (CSR) or static site generated (SSG) site. When SSG is enabled, redirects are handled using the `<meta>` tag. - `'server'`: Produces an application designed for deployment on a server that supports server-side rendering (SSR) or a hybrid approach. Additionally, the `outputMode` option determines whether the new API is used. If enabled, it bundles the `server.ts` as a separate entry point, preventing it from directly referencing `main.server.ts` and excluding it from localization. This option will replace `appShell` and `prerendering` when server routing configuration is present.
1 parent ac71ce1 commit a3f50d5

File tree

21 files changed

+522
-174
lines changed

21 files changed

+522
-174
lines changed

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

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

111114
// @public

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

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

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

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

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

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

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

+42-20
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';
22+
import { loadEsmModule } from '../../utils/load-esm';
1923
import {
2024
SERVER_APP_MANIFEST_FILENAME,
2125
generateAngularServerAppManifest,
2226
} from '../../utils/server-rendering/manifest';
2327
import { prerenderPages } from '../../utils/server-rendering/prerender';
28+
import { RoutersExtractorWorkerResult as SerializableRouteTreeNode } from '../../utils/server-rendering/routes-extractor-worker';
2429
import { augmentAppWithServiceWorkerEsbuild } from '../../utils/service-worker';
2530
import { INDEX_HTML_SERVER, NormalizedApplicationBuildOptions } from './options';
31+
import { OutputMode } from './schema';
32+
33+
type Writeable<T extends readonly unknown[]> = T extends readonly (infer U)[] ? U[] : never;
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).
@@ -97,7 +106,7 @@ export async function executePostBundleSteps(
97106
}
98107

99108
// Create server manifest
100-
if (prerenderOptions || appShellOptions || ssrOptions) {
109+
if (serverEntryPoint) {
101110
additionalOutputFiles.push(
102111
createOutputFile(
103112
SERVER_APP_MANIFEST_FILENAME,
@@ -114,36 +123,32 @@ export async function executePostBundleSteps(
114123

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

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

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

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

158-
if (ssrOptions) {
163+
const { RenderMode } = await loadEsmModule<typeof import('@angular/ssr')>('@angular/ssr');
164+
const serializableRouteTreeNodeForManifest: Writeable<SerializableRouteTreeNode> = [];
165+
166+
for (const metadata of serializableRouteTreeNode) {
167+
switch (metadata.renderMode) {
168+
case RenderMode.Prerender:
169+
prerenderedRoutes[metadata.route] = { headers: metadata.headers };
170+
break;
171+
172+
case RenderMode.Server:
173+
case RenderMode.Client:
174+
serializableRouteTreeNodeForManifest.push(metadata);
175+
176+
break;
177+
}
178+
}
179+
180+
if (outputMode === OutputMode.Server) {
159181
// Regenerate the manifest to append route tree. This is only needed if SSR is enabled.
160182
const manifest = additionalOutputFiles.find((f) => f.path === SERVER_APP_MANIFEST_FILENAME);
161183
assert(manifest, `${SERVER_APP_MANIFEST_FILENAME} was not found in output files.`);
@@ -165,7 +187,7 @@ export async function executePostBundleSteps(
165187
additionalHtmlOutputFiles,
166188
outputFiles,
167189
optimizationOptions.styles.inlineCritical ?? false,
168-
serializableRouteTreeNode,
190+
serializableRouteTreeNodeForManifest,
169191
),
170192
);
171193
}

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

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

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

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

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

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

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

+4-3
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

@@ -236,6 +236,7 @@ export async function* buildApplication(
236236
typeDirectory = outputOptions.browser;
237237
break;
238238
case BuildOutputFileType.Server:
239+
case BuildOutputFileType.SSRServer:
239240
typeDirectory = outputOptions.server;
240241
break;
241242
case BuildOutputFileType.Root:

0 commit comments

Comments
 (0)