Skip to content

Commit 6a48a11

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. (cherry picked from commit f3229c4)
1 parent e8a6e07 commit 6a48a11

File tree

5 files changed

+123
-53
lines changed

5 files changed

+123
-53
lines changed

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

+1
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,

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

+65-46
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 { Connect, InlineConfig, ViteDevServer, createServer, normalizePath } 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();
@@ -328,50 +330,46 @@ export async function setupServer(
328330
next: Connect.NextFunction,
329331
) {
330332
const url = req.originalUrl;
331-
if (!url) {
333+
if (!url || url.endsWith('.html')) {
332334
next();
333335

334336
return;
335337
}
336338

339+
const potentialPrerendered = outputFiles.get(posix.join(url, 'index.html'))?.contents;
340+
if (potentialPrerendered) {
341+
const content = Buffer.from(potentialPrerendered).toString('utf-8');
342+
if (SSG_MARKER_REGEXP.test(content)) {
343+
transformIndexHtmlAndAddHeaders(url, potentialPrerendered, res, next);
344+
345+
return;
346+
}
347+
}
348+
337349
const rawHtml = outputFiles.get('/index.server.html')?.contents;
338350
if (!rawHtml) {
339351
next();
340352

341353
return;
342354
}
343355

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

377375
if (ssr) {
@@ -392,19 +390,7 @@ export async function setupServer(
392390
if (pathname === '/' || pathname === `/index.html`) {
393391
const rawHtml = outputFiles.get('/index.html')?.contents;
394392
if (rawHtml) {
395-
server
396-
.transformIndexHtml(req.url, Buffer.from(rawHtml).toString('utf-8'))
397-
.then((processedHtml) => {
398-
res.setHeader('Content-Type', 'text/html');
399-
res.setHeader('Cache-Control', 'no-cache');
400-
if (serverOptions.headers) {
401-
Object.entries(serverOptions.headers).forEach(([name, value]) =>
402-
res.setHeader(name, value),
403-
);
404-
}
405-
res.end(processedHtml);
406-
})
407-
.catch((error) => next(error));
393+
transformIndexHtmlAndAddHeaders(req.url, rawHtml, res, next);
408394

409395
return;
410396
}
@@ -413,6 +399,39 @@ export async function setupServer(
413399
next();
414400
});
415401
};
402+
403+
function transformIndexHtmlAndAddHeaders(
404+
url: string,
405+
rawHtml: Uint8Array,
406+
res: ServerResponse<import('http').IncomingMessage>,
407+
next: Connect.NextFunction,
408+
additionalTransformer?: (html: string) => Promise<string | undefined>,
409+
) {
410+
server
411+
.transformIndexHtml(url, Buffer.from(rawHtml).toString('utf-8'))
412+
.then(async (processedHtml) => {
413+
if (additionalTransformer) {
414+
const content = await additionalTransformer(processedHtml);
415+
if (!content) {
416+
next();
417+
418+
return;
419+
}
420+
421+
processedHtml = content;
422+
}
423+
424+
res.setHeader('Content-Type', 'text/html');
425+
res.setHeader('Cache-Control', 'no-cache');
426+
if (serverOptions.headers) {
427+
Object.entries(serverOptions.headers).forEach(([name, value]) =>
428+
res.setHeader(name, value),
429+
);
430+
}
431+
res.end(processedHtml);
432+
})
433+
.catch((error) => next(error));
434+
}
416435
},
417436
},
418437
],

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

+1-1
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) {

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

+48-5
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);

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

+8-1
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)