Skip to content

Commit f3229c4

Browse files
committed
fix(@angular-devkit/build-angular): update vite to be able to serve app-shell and SSG pages
This commits, update the application builder and vite dev-server to be able to serve the app-shell and prerendered pages.
1 parent 8b74a50 commit f3229c4

File tree

5 files changed

+123
-53
lines changed

5 files changed

+123
-53
lines changed

packages/angular_devkit/build_angular/src/builders/application/execute-build.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,7 @@ export async function executeBuild(
181181
);
182182

183183
const { output, warnings, errors } = await prerenderPages(
184+
workspaceRoot,
184185
options.tsconfig,
185186
appShellOptions,
186187
prerenderOptions,

packages/angular_devkit/build_angular/src/builders/dev-server/vite-server.ts

Lines changed: 65 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import { BinaryLike, createHash } from 'node:crypto';
1515
import { readFile } from 'node:fs/promises';
1616
import { ServerResponse } from 'node:http';
1717
import type { AddressInfo } from 'node:net';
18-
import path from 'node:path';
18+
import path, { posix } from 'node:path';
1919
import type { Connect, InlineConfig, ViteDevServer } from 'vite';
2020
import { JavaScriptTransformer } from '../../tools/esbuild/javascript-transformer';
2121
import { RenderOptions, renderPage } from '../../utils/server-rendering/render-page';
@@ -32,6 +32,8 @@ interface OutputFileRecord {
3232
updated: boolean;
3333
}
3434

35+
const SSG_MARKER_REGEXP = /ng-server-context=["']\w*\|?ssg\|?\w*["']/;
36+
3537
function hashContent(contents: BinaryLike): Buffer {
3638
// TODO: Consider xxhash
3739
return createHash('sha256').update(contents).digest();
@@ -335,50 +337,46 @@ export async function setupServer(
335337
next: Connect.NextFunction,
336338
) {
337339
const url = req.originalUrl;
338-
if (!url) {
340+
if (!url || url.endsWith('.html')) {
339341
next();
340342

341343
return;
342344
}
343345

346+
const potentialPrerendered = outputFiles.get(posix.join(url, 'index.html'))?.contents;
347+
if (potentialPrerendered) {
348+
const content = Buffer.from(potentialPrerendered).toString('utf-8');
349+
if (SSG_MARKER_REGEXP.test(content)) {
350+
transformIndexHtmlAndAddHeaders(url, potentialPrerendered, res, next);
351+
352+
return;
353+
}
354+
}
355+
344356
const rawHtml = outputFiles.get('/index.server.html')?.contents;
345357
if (!rawHtml) {
346358
next();
347359

348360
return;
349361
}
350362

351-
server
352-
.transformIndexHtml(url, Buffer.from(rawHtml).toString('utf-8'))
353-
.then(async (html) => {
354-
const { content } = await renderPage({
355-
document: html,
356-
route: pathnameWithoutServePath(url, serverOptions),
357-
serverContext: 'ssr',
358-
loadBundle: (path: string) =>
359-
server.ssrLoadModule(path.slice(1)) as ReturnType<
360-
NonNullable<RenderOptions['loadBundle']>
361-
>,
362-
// Files here are only needed for critical CSS inlining.
363-
outputFiles: {},
364-
// TODO: add support for critical css inlining.
365-
inlineCriticalCss: false,
366-
});
367-
368-
if (content) {
369-
res.setHeader('Content-Type', 'text/html');
370-
res.setHeader('Cache-Control', 'no-cache');
371-
if (serverOptions.headers) {
372-
Object.entries(serverOptions.headers).forEach(([name, value]) =>
373-
res.setHeader(name, value),
374-
);
375-
}
376-
res.end(content);
377-
} else {
378-
next();
379-
}
380-
})
381-
.catch((error) => next(error));
363+
transformIndexHtmlAndAddHeaders(url, rawHtml, res, next, async (html) => {
364+
const { content } = await renderPage({
365+
document: html,
366+
route: pathnameWithoutServePath(url, serverOptions),
367+
serverContext: 'ssr',
368+
loadBundle: (path: string) =>
369+
server.ssrLoadModule(path.slice(1)) as ReturnType<
370+
NonNullable<RenderOptions['loadBundle']>
371+
>,
372+
// Files here are only needed for critical CSS inlining.
373+
outputFiles: {},
374+
// TODO: add support for critical css inlining.
375+
inlineCriticalCss: false,
376+
});
377+
378+
return content;
379+
});
382380
}
383381

384382
if (ssr) {
@@ -399,19 +397,7 @@ export async function setupServer(
399397
if (pathname === '/' || pathname === `/index.html`) {
400398
const rawHtml = outputFiles.get('/index.html')?.contents;
401399
if (rawHtml) {
402-
server
403-
.transformIndexHtml(req.url, Buffer.from(rawHtml).toString('utf-8'))
404-
.then((processedHtml) => {
405-
res.setHeader('Content-Type', 'text/html');
406-
res.setHeader('Cache-Control', 'no-cache');
407-
if (serverOptions.headers) {
408-
Object.entries(serverOptions.headers).forEach(([name, value]) =>
409-
res.setHeader(name, value),
410-
);
411-
}
412-
res.end(processedHtml);
413-
})
414-
.catch((error) => next(error));
400+
transformIndexHtmlAndAddHeaders(req.url, rawHtml, res, next);
415401

416402
return;
417403
}
@@ -420,6 +406,39 @@ export async function setupServer(
420406
next();
421407
});
422408
};
409+
410+
function transformIndexHtmlAndAddHeaders(
411+
url: string,
412+
rawHtml: Uint8Array,
413+
res: ServerResponse<import('http').IncomingMessage>,
414+
next: Connect.NextFunction,
415+
additionalTransformer?: (html: string) => Promise<string | undefined>,
416+
) {
417+
server
418+
.transformIndexHtml(url, Buffer.from(rawHtml).toString('utf-8'))
419+
.then(async (processedHtml) => {
420+
if (additionalTransformer) {
421+
const content = await additionalTransformer(processedHtml);
422+
if (!content) {
423+
next();
424+
425+
return;
426+
}
427+
428+
processedHtml = content;
429+
}
430+
431+
res.setHeader('Content-Type', 'text/html');
432+
res.setHeader('Cache-Control', 'no-cache');
433+
if (serverOptions.headers) {
434+
Object.entries(serverOptions.headers).forEach(([name, value]) =>
435+
res.setHeader(name, value),
436+
);
437+
}
438+
res.end(processedHtml);
439+
})
440+
.catch((error) => next(error));
441+
}
423442
},
424443
},
425444
],

packages/angular_devkit/build_angular/src/tools/esbuild/application-code-bundle.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,7 @@ export function createServerCodeBundleOptions(
164164
const polyfills = [`import '@angular/platform-server/init';`];
165165

166166
if (options.polyfills?.includes('zone.js')) {
167-
polyfills.push(`import 'zone.js/node';`);
167+
polyfills.push(`import 'zone.js/fesm2015/zone-node.js';`);
168168
}
169169

170170
if (jit) {

packages/angular_devkit/build_angular/src/utils/server-rendering/esm-in-memory-file-loader.ts

Lines changed: 48 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,19 +6,38 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

9+
import { join } from 'node:path';
910
import { workerData } from 'node:worker_threads';
1011
import { fileURLToPath } from 'url';
12+
import { JavaScriptTransformer } from '../../tools/esbuild/javascript-transformer';
1113

1214
/**
1315
* Node.js ESM loader to redirect imports to in memory files.
1416
* @see: https://nodejs.org/api/esm.html#loaders for more information about loaders.
1517
*/
1618

17-
const { outputFiles } = workerData as {
19+
const { outputFiles, workspaceRoot } = workerData as {
1820
outputFiles: Record<string, string>;
21+
workspaceRoot: string;
1922
};
2023

21-
export function resolve(specifier: string, context: {}, nextResolve: Function) {
24+
const TRANSFORMED_FILES: Record<string, string> = {};
25+
const CHUNKS_REGEXP = /file:\/\/\/(main\.server|chunk-\w+)\.mjs/;
26+
const WORKSPACE_ROOT_FILE = new URL(join(workspaceRoot, 'index.mjs'), 'file:').href;
27+
28+
const JAVASCRIPT_TRANSFORMER = new JavaScriptTransformer(
29+
// Always enable JIT linking to support applications built with and without AOT.
30+
// In a development environment the additional scope information does not
31+
// have a negative effect unlike production where final output size is relevant.
32+
{ sourcemap: true, jit: true },
33+
1,
34+
);
35+
36+
export function resolve(
37+
specifier: string,
38+
context: { parentURL: undefined | string },
39+
nextResolve: Function,
40+
) {
2241
if (!isFileProtocol(specifier)) {
2342
const normalizedSpecifier = specifier.replace(/^\.\//, '');
2443
if (normalizedSpecifier in outputFiles) {
@@ -32,12 +51,24 @@ export function resolve(specifier: string, context: {}, nextResolve: Function) {
3251

3352
// Defer to the next hook in the chain, which would be the
3453
// Node.js default resolve if this is the last user-specified loader.
35-
return nextResolve(specifier);
54+
return nextResolve(
55+
specifier,
56+
isBundleEntryPointOrChunk(context) ? { ...context, parentURL: WORKSPACE_ROOT_FILE } : context,
57+
);
3658
}
3759

38-
export function load(url: string, context: { format?: string | null }, nextLoad: Function) {
60+
export async function load(url: string, context: { format?: string | null }, nextLoad: Function) {
3961
if (isFileProtocol(url)) {
40-
const source = outputFiles[fileURLToPath(url).slice(1)]; // Remove leading slash
62+
const filePath = fileURLToPath(url);
63+
let source =
64+
outputFiles[filePath.slice(1)] /* Remove leading slash */ ?? TRANSFORMED_FILES[filePath];
65+
66+
if (source === undefined) {
67+
source = TRANSFORMED_FILES[filePath] = Buffer.from(
68+
await JAVASCRIPT_TRANSFORMER.transformFile(filePath),
69+
).toString('utf-8');
70+
}
71+
4172
if (source !== undefined) {
4273
const { format } = context;
4374

@@ -56,3 +87,15 @@ export function load(url: string, context: { format?: string | null }, nextLoad:
5687
function isFileProtocol(url: string): boolean {
5788
return url.startsWith('file://');
5889
}
90+
91+
function handleProcessExit(): void {
92+
void JAVASCRIPT_TRANSFORMER.close();
93+
}
94+
95+
function isBundleEntryPointOrChunk(context: { parentURL: undefined | string }): boolean {
96+
return !!context.parentURL && CHUNKS_REGEXP.test(context.parentURL);
97+
}
98+
99+
process.once('exit', handleProcessExit);
100+
process.once('SIGINT', handleProcessExit);
101+
process.once('uncaughtException', handleProcessExit);

packages/angular_devkit/build_angular/src/utils/server-rendering/prerender.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ interface AppShellOptions {
2424
}
2525

2626
export async function prerenderPages(
27+
workspaceRoot: string,
2728
tsConfigPath: string,
2829
appShellOptions: AppShellOptions = {},
2930
prerenderOptions: PrerenderOptions = {},
@@ -52,6 +53,7 @@ export async function prerenderPages(
5253
filename: require.resolve('./render-worker'),
5354
maxThreads: Math.min(allRoutes.size, maxThreads),
5455
workerData: {
56+
workspaceRoot,
5557
outputFiles: outputFilesForWorker,
5658
inlineCriticalCss,
5759
document,
@@ -77,7 +79,12 @@ export async function prerenderPages(
7779
const render: Promise<RenderResult> = renderWorker.run({ route, serverContext });
7880
const renderResult: Promise<void> = render.then(({ content, warnings, errors }) => {
7981
if (content !== undefined) {
80-
const outPath = isAppShellRoute ? 'index.html' : posix.join(route, 'index.html');
82+
const outPath = isAppShellRoute
83+
? 'index.html'
84+
: posix.join(
85+
route.startsWith('/') ? route.slice(1) /* Remove leading slash */ : route,
86+
'index.html',
87+
);
8188
output[outPath] = content;
8289
}
8390

0 commit comments

Comments
 (0)