From f3229c44ad58a872734fa965e20ecd9e76722fa1 Mon Sep 17 00:00:00 2001 From: Alan Agius Date: Wed, 9 Aug 2023 12:22:09 +0000 Subject: [PATCH] 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. --- .../src/builders/application/execute-build.ts | 1 + .../src/builders/dev-server/vite-server.ts | 111 ++++++++++-------- .../tools/esbuild/application-code-bundle.ts | 2 +- .../esm-in-memory-file-loader.ts | 53 ++++++++- .../src/utils/server-rendering/prerender.ts | 9 +- 5 files changed, 123 insertions(+), 53 deletions(-) diff --git a/packages/angular_devkit/build_angular/src/builders/application/execute-build.ts b/packages/angular_devkit/build_angular/src/builders/application/execute-build.ts index 30409f39cc9a..4d7f3cb0f43a 100644 --- a/packages/angular_devkit/build_angular/src/builders/application/execute-build.ts +++ b/packages/angular_devkit/build_angular/src/builders/application/execute-build.ts @@ -181,6 +181,7 @@ export async function executeBuild( ); const { output, warnings, errors } = await prerenderPages( + workspaceRoot, options.tsconfig, appShellOptions, prerenderOptions, diff --git a/packages/angular_devkit/build_angular/src/builders/dev-server/vite-server.ts b/packages/angular_devkit/build_angular/src/builders/dev-server/vite-server.ts index 264d3d7f9a56..45386814f816 100644 --- a/packages/angular_devkit/build_angular/src/builders/dev-server/vite-server.ts +++ b/packages/angular_devkit/build_angular/src/builders/dev-server/vite-server.ts @@ -15,7 +15,7 @@ import { BinaryLike, createHash } from 'node:crypto'; import { readFile } from 'node:fs/promises'; import { ServerResponse } from 'node:http'; import type { AddressInfo } from 'node:net'; -import path from 'node:path'; +import path, { posix } from 'node:path'; import type { Connect, InlineConfig, ViteDevServer } from 'vite'; import { JavaScriptTransformer } from '../../tools/esbuild/javascript-transformer'; import { RenderOptions, renderPage } from '../../utils/server-rendering/render-page'; @@ -32,6 +32,8 @@ interface OutputFileRecord { updated: boolean; } +const SSG_MARKER_REGEXP = /ng-server-context=["']\w*\|?ssg\|?\w*["']/; + function hashContent(contents: BinaryLike): Buffer { // TODO: Consider xxhash return createHash('sha256').update(contents).digest(); @@ -335,12 +337,22 @@ export async function setupServer( next: Connect.NextFunction, ) { const url = req.originalUrl; - if (!url) { + if (!url || url.endsWith('.html')) { next(); return; } + const potentialPrerendered = outputFiles.get(posix.join(url, 'index.html'))?.contents; + if (potentialPrerendered) { + const content = Buffer.from(potentialPrerendered).toString('utf-8'); + if (SSG_MARKER_REGEXP.test(content)) { + transformIndexHtmlAndAddHeaders(url, potentialPrerendered, res, next); + + return; + } + } + const rawHtml = outputFiles.get('/index.server.html')?.contents; if (!rawHtml) { next(); @@ -348,37 +360,23 @@ export async function setupServer( return; } - server - .transformIndexHtml(url, Buffer.from(rawHtml).toString('utf-8')) - .then(async (html) => { - const { content } = await renderPage({ - document: html, - route: pathnameWithoutServePath(url, serverOptions), - serverContext: 'ssr', - loadBundle: (path: string) => - server.ssrLoadModule(path.slice(1)) as ReturnType< - NonNullable - >, - // Files here are only needed for critical CSS inlining. - outputFiles: {}, - // TODO: add support for critical css inlining. - inlineCriticalCss: false, - }); - - if (content) { - res.setHeader('Content-Type', 'text/html'); - res.setHeader('Cache-Control', 'no-cache'); - if (serverOptions.headers) { - Object.entries(serverOptions.headers).forEach(([name, value]) => - res.setHeader(name, value), - ); - } - res.end(content); - } else { - next(); - } - }) - .catch((error) => next(error)); + transformIndexHtmlAndAddHeaders(url, rawHtml, res, next, async (html) => { + const { content } = await renderPage({ + document: html, + route: pathnameWithoutServePath(url, serverOptions), + serverContext: 'ssr', + loadBundle: (path: string) => + server.ssrLoadModule(path.slice(1)) as ReturnType< + NonNullable + >, + // Files here are only needed for critical CSS inlining. + outputFiles: {}, + // TODO: add support for critical css inlining. + inlineCriticalCss: false, + }); + + return content; + }); } if (ssr) { @@ -399,19 +397,7 @@ export async function setupServer( if (pathname === '/' || pathname === `/index.html`) { const rawHtml = outputFiles.get('/index.html')?.contents; if (rawHtml) { - server - .transformIndexHtml(req.url, Buffer.from(rawHtml).toString('utf-8')) - .then((processedHtml) => { - res.setHeader('Content-Type', 'text/html'); - res.setHeader('Cache-Control', 'no-cache'); - if (serverOptions.headers) { - Object.entries(serverOptions.headers).forEach(([name, value]) => - res.setHeader(name, value), - ); - } - res.end(processedHtml); - }) - .catch((error) => next(error)); + transformIndexHtmlAndAddHeaders(req.url, rawHtml, res, next); return; } @@ -420,6 +406,39 @@ export async function setupServer( next(); }); }; + + function transformIndexHtmlAndAddHeaders( + url: string, + rawHtml: Uint8Array, + res: ServerResponse, + next: Connect.NextFunction, + additionalTransformer?: (html: string) => Promise, + ) { + server + .transformIndexHtml(url, Buffer.from(rawHtml).toString('utf-8')) + .then(async (processedHtml) => { + if (additionalTransformer) { + const content = await additionalTransformer(processedHtml); + if (!content) { + next(); + + return; + } + + processedHtml = content; + } + + res.setHeader('Content-Type', 'text/html'); + res.setHeader('Cache-Control', 'no-cache'); + if (serverOptions.headers) { + Object.entries(serverOptions.headers).forEach(([name, value]) => + res.setHeader(name, value), + ); + } + res.end(processedHtml); + }) + .catch((error) => next(error)); + } }, }, ], diff --git a/packages/angular_devkit/build_angular/src/tools/esbuild/application-code-bundle.ts b/packages/angular_devkit/build_angular/src/tools/esbuild/application-code-bundle.ts index 0d118469a595..3043a1ed0ace 100644 --- a/packages/angular_devkit/build_angular/src/tools/esbuild/application-code-bundle.ts +++ b/packages/angular_devkit/build_angular/src/tools/esbuild/application-code-bundle.ts @@ -164,7 +164,7 @@ export function createServerCodeBundleOptions( const polyfills = [`import '@angular/platform-server/init';`]; if (options.polyfills?.includes('zone.js')) { - polyfills.push(`import 'zone.js/node';`); + polyfills.push(`import 'zone.js/fesm2015/zone-node.js';`); } if (jit) { diff --git a/packages/angular_devkit/build_angular/src/utils/server-rendering/esm-in-memory-file-loader.ts b/packages/angular_devkit/build_angular/src/utils/server-rendering/esm-in-memory-file-loader.ts index 387d6090c969..fc253c6d3477 100644 --- a/packages/angular_devkit/build_angular/src/utils/server-rendering/esm-in-memory-file-loader.ts +++ b/packages/angular_devkit/build_angular/src/utils/server-rendering/esm-in-memory-file-loader.ts @@ -6,19 +6,38 @@ * found in the LICENSE file at https://angular.io/license */ +import { join } from 'node:path'; import { workerData } from 'node:worker_threads'; import { fileURLToPath } from 'url'; +import { JavaScriptTransformer } from '../../tools/esbuild/javascript-transformer'; /** * Node.js ESM loader to redirect imports to in memory files. * @see: https://nodejs.org/api/esm.html#loaders for more information about loaders. */ -const { outputFiles } = workerData as { +const { outputFiles, workspaceRoot } = workerData as { outputFiles: Record; + workspaceRoot: string; }; -export function resolve(specifier: string, context: {}, nextResolve: Function) { +const TRANSFORMED_FILES: Record = {}; +const CHUNKS_REGEXP = /file:\/\/\/(main\.server|chunk-\w+)\.mjs/; +const WORKSPACE_ROOT_FILE = new URL(join(workspaceRoot, 'index.mjs'), 'file:').href; + +const JAVASCRIPT_TRANSFORMER = new JavaScriptTransformer( + // Always enable JIT linking to support applications built with and without AOT. + // In a development environment the additional scope information does not + // have a negative effect unlike production where final output size is relevant. + { sourcemap: true, jit: true }, + 1, +); + +export function resolve( + specifier: string, + context: { parentURL: undefined | string }, + nextResolve: Function, +) { if (!isFileProtocol(specifier)) { const normalizedSpecifier = specifier.replace(/^\.\//, ''); if (normalizedSpecifier in outputFiles) { @@ -32,12 +51,24 @@ export function resolve(specifier: string, context: {}, nextResolve: Function) { // Defer to the next hook in the chain, which would be the // Node.js default resolve if this is the last user-specified loader. - return nextResolve(specifier); + return nextResolve( + specifier, + isBundleEntryPointOrChunk(context) ? { ...context, parentURL: WORKSPACE_ROOT_FILE } : context, + ); } -export function load(url: string, context: { format?: string | null }, nextLoad: Function) { +export async function load(url: string, context: { format?: string | null }, nextLoad: Function) { if (isFileProtocol(url)) { - const source = outputFiles[fileURLToPath(url).slice(1)]; // Remove leading slash + const filePath = fileURLToPath(url); + let source = + outputFiles[filePath.slice(1)] /* Remove leading slash */ ?? TRANSFORMED_FILES[filePath]; + + if (source === undefined) { + source = TRANSFORMED_FILES[filePath] = Buffer.from( + await JAVASCRIPT_TRANSFORMER.transformFile(filePath), + ).toString('utf-8'); + } + if (source !== undefined) { const { format } = context; @@ -56,3 +87,15 @@ export function load(url: string, context: { format?: string | null }, nextLoad: function isFileProtocol(url: string): boolean { return url.startsWith('file://'); } + +function handleProcessExit(): void { + void JAVASCRIPT_TRANSFORMER.close(); +} + +function isBundleEntryPointOrChunk(context: { parentURL: undefined | string }): boolean { + return !!context.parentURL && CHUNKS_REGEXP.test(context.parentURL); +} + +process.once('exit', handleProcessExit); +process.once('SIGINT', handleProcessExit); +process.once('uncaughtException', handleProcessExit); diff --git a/packages/angular_devkit/build_angular/src/utils/server-rendering/prerender.ts b/packages/angular_devkit/build_angular/src/utils/server-rendering/prerender.ts index cb92d3366dae..d03d4821fd72 100644 --- a/packages/angular_devkit/build_angular/src/utils/server-rendering/prerender.ts +++ b/packages/angular_devkit/build_angular/src/utils/server-rendering/prerender.ts @@ -24,6 +24,7 @@ interface AppShellOptions { } export async function prerenderPages( + workspaceRoot: string, tsConfigPath: string, appShellOptions: AppShellOptions = {}, prerenderOptions: PrerenderOptions = {}, @@ -52,6 +53,7 @@ export async function prerenderPages( filename: require.resolve('./render-worker'), maxThreads: Math.min(allRoutes.size, maxThreads), workerData: { + workspaceRoot, outputFiles: outputFilesForWorker, inlineCriticalCss, document, @@ -77,7 +79,12 @@ export async function prerenderPages( const render: Promise = renderWorker.run({ route, serverContext }); const renderResult: Promise = render.then(({ content, warnings, errors }) => { if (content !== undefined) { - const outPath = isAppShellRoute ? 'index.html' : posix.join(route, 'index.html'); + const outPath = isAppShellRoute + ? 'index.html' + : posix.join( + route.startsWith('/') ? route.slice(1) /* Remove leading slash */ : route, + 'index.html', + ); output[outPath] = content; }