Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(@angular-devkit/build-angular): update vite to be able to serve app-shell and SSG pages #25653

Merged
merged 1 commit into from Aug 14, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
Expand Up @@ -181,6 +181,7 @@ export async function executeBuild(
);

const { output, warnings, errors } = await prerenderPages(
workspaceRoot,
options.tsconfig,
appShellOptions,
prerenderOptions,
Expand Down
Expand Up @@ -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';
Expand All @@ -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();
Expand Down Expand Up @@ -335,50 +337,46 @@ 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();

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<RenderOptions['loadBundle']>
>,
// 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<RenderOptions['loadBundle']>
>,
// Files here are only needed for critical CSS inlining.
outputFiles: {},
// TODO: add support for critical css inlining.
inlineCriticalCss: false,
});

return content;
});
}

if (ssr) {
Expand All @@ -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;
}
Expand All @@ -420,6 +406,39 @@ export async function setupServer(
next();
});
};

function transformIndexHtmlAndAddHeaders(
url: string,
rawHtml: Uint8Array,
res: ServerResponse<import('http').IncomingMessage>,
next: Connect.NextFunction,
additionalTransformer?: (html: string) => Promise<string | undefined>,
) {
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));
}
},
},
],
Expand Down
Expand Up @@ -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';`);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should consider updating the zone.js package to include a exports definition so we don't have to deep import.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, we should that.

}

if (jit) {
Expand Down
Expand Up @@ -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<string, string>;
workspaceRoot: string;
};

export function resolve(specifier: string, context: {}, nextResolve: Function) {
const TRANSFORMED_FILES: Record<string, string> = {};
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) {
Expand All @@ -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;

Expand All @@ -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);
Expand Up @@ -24,6 +24,7 @@ interface AppShellOptions {
}

export async function prerenderPages(
workspaceRoot: string,
tsConfigPath: string,
appShellOptions: AppShellOptions = {},
prerenderOptions: PrerenderOptions = {},
Expand Down Expand Up @@ -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,
Expand All @@ -77,7 +79,12 @@ export async function prerenderPages(
const render: Promise<RenderResult> = renderWorker.run({ route, serverContext });
const renderResult: Promise<void> = 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;
}

Expand Down