Skip to content

Commit

Permalink
fix(@angular-devkit/build-angular): update vite to be able to serve a…
Browse files Browse the repository at this point in the history
…pp-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)
  • Loading branch information
alan-agius4 committed Aug 14, 2023
1 parent e8a6e07 commit 6a48a11
Show file tree
Hide file tree
Showing 5 changed files with 123 additions and 53 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,7 @@ export async function executeBuild(
);

const { output, warnings, errors } = await prerenderPages(
workspaceRoot,
options.tsconfig,
appShellOptions,
prerenderOptions,
Expand Down
Original file line number Diff line number Diff line change
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 { Connect, InlineConfig, ViteDevServer, createServer, normalizePath } 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 @@ -328,50 +330,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 @@ -392,19 +390,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 @@ -413,6 +399,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
Original file line number Diff line number Diff line change
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';`);
}

if (jit) {
Expand Down
Original file line number Diff line number Diff line change
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);
Original file line number Diff line number Diff line change
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

0 comments on commit 6a48a11

Please sign in to comment.