@angular/ssr
Version:
Angular server side rendering utilities
474 lines (463 loc) • 18.7 kB
JavaScript
import { renderApplication, renderModule, ɵSERVER_CONTEXT as _SERVER_CONTEXT } from '@angular/platform-server';
import * as fs from 'node:fs';
import { dirname, join, normalize, resolve } from 'node:path';
import { URL as URL$1, fileURLToPath } from 'node:url';
import { ɵInlineCriticalCssProcessor as _InlineCriticalCssProcessor, AngularAppEngine } from '@angular/ssr';
import { readFile } from 'node:fs/promises';
import { argv } from 'node:process';
/**
* Attaches listeners to the Node.js process to capture and handle unhandled rejections and uncaught exceptions.
* Captured errors are logged to the console. This function logs errors to the console, preventing unhandled errors
* from crashing the server. It is particularly useful for Zoneless apps, ensuring error handling without relying on Zone.js.
*
* @remarks
* This function is a no-op if zone.js is available.
* For Zone-based apps, similar functionality is provided by Zone.js itself. See the Zone.js implementation here:
* https://github.com/angular/angular/blob/4a8d0b79001ec09bcd6f2d6b15117aa6aac1932c/packages/zone.js/lib/node/node.ts#L94%7C
*
* @internal
*/
function attachNodeGlobalErrorHandlers() {
if (typeof Zone !== 'undefined') {
return;
}
// Ensure that the listeners are registered only once.
// Otherwise, multiple instances may be registered during edit/refresh.
const gThis = globalThis;
if (gThis.ngAttachNodeGlobalErrorHandlersCalled) {
return;
}
gThis.ngAttachNodeGlobalErrorHandlersCalled = true;
process
// eslint-disable-next-line no-console
.on('unhandledRejection', (error) => console.error('unhandledRejection', error))
// eslint-disable-next-line no-console
.on('uncaughtException', (error) => console.error('uncaughtException', error));
}
class CommonEngineInlineCriticalCssProcessor {
resourceCache = new Map();
async process(html, outputPath) {
const beasties = new _InlineCriticalCssProcessor(async (path) => {
let resourceContent = this.resourceCache.get(path);
if (resourceContent === undefined) {
resourceContent = await readFile(path, 'utf-8');
this.resourceCache.set(path, resourceContent);
}
return resourceContent;
}, outputPath);
return beasties.process(html);
}
}
const PERFORMANCE_MARK_PREFIX = '🅰️';
function printPerformanceLogs() {
let maxWordLength = 0;
const benchmarks = [];
for (const { name, duration } of performance.getEntriesByType('measure')) {
if (!name.startsWith(PERFORMANCE_MARK_PREFIX)) {
continue;
}
// `🅰️:Retrieve SSG Page` -> `Retrieve SSG Page:`
const step = name.slice(PERFORMANCE_MARK_PREFIX.length + 1) + ':';
if (step.length > maxWordLength) {
maxWordLength = step.length;
}
benchmarks.push([step, `${duration.toFixed(1)}ms`]);
performance.clearMeasures(name);
}
/* eslint-disable no-console */
console.log('********** Performance results **********');
for (const [step, value] of benchmarks) {
const spaces = maxWordLength - step.length + 5;
console.log(step + ' '.repeat(spaces) + value);
}
console.log('*****************************************');
/* eslint-enable no-console */
}
async function runMethodAndMeasurePerf(label, asyncMethod) {
const labelName = `${PERFORMANCE_MARK_PREFIX}:${label}`;
const startLabel = `start:${labelName}`;
const endLabel = `end:${labelName}`;
try {
performance.mark(startLabel);
return await asyncMethod();
}
finally {
performance.mark(endLabel);
performance.measure(labelName, startLabel, endLabel);
performance.clearMarks(startLabel);
performance.clearMarks(endLabel);
}
}
function noopRunMethodAndMeasurePerf(label, asyncMethod) {
return asyncMethod();
}
const SSG_MARKER_REGEXP = /ng-server-context=["']\w*\|?ssg\|?\w*["']/;
/**
* A common engine to use to server render an application.
*/
class CommonEngine {
options;
templateCache = new Map();
inlineCriticalCssProcessor = new CommonEngineInlineCriticalCssProcessor();
pageIsSSG = new Map();
constructor(options) {
this.options = options;
attachNodeGlobalErrorHandlers();
}
/**
* Render an HTML document for a specific URL with specified
* render options
*/
async render(opts) {
const enablePerformanceProfiler = this.options?.enablePerformanceProfiler;
const runMethod = enablePerformanceProfiler
? runMethodAndMeasurePerf
: noopRunMethodAndMeasurePerf;
let html = await runMethod('Retrieve SSG Page', () => this.retrieveSSGPage(opts));
if (html === undefined) {
html = await runMethod('Render Page', () => this.renderApplication(opts));
if (opts.inlineCriticalCss !== false) {
const content = await runMethod('Inline Critical CSS', () =>
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
this.inlineCriticalCss(html, opts));
html = content;
}
}
if (enablePerformanceProfiler) {
printPerformanceLogs();
}
return html;
}
inlineCriticalCss(html, opts) {
const outputPath = opts.publicPath ?? (opts.documentFilePath ? dirname(opts.documentFilePath) : '');
return this.inlineCriticalCssProcessor.process(html, outputPath);
}
async retrieveSSGPage(opts) {
const { publicPath, documentFilePath, url } = opts;
if (!publicPath || !documentFilePath || url === undefined) {
return undefined;
}
const { pathname } = new URL$1(url, 'resolve://');
// Do not use `resolve` here as otherwise it can lead to path traversal vulnerability.
// See: https://portswigger.net/web-security/file-path-traversal
const pagePath = join(publicPath, pathname, 'index.html');
if (this.pageIsSSG.get(pagePath)) {
// Serve pre-rendered page.
return fs.promises.readFile(pagePath, 'utf-8');
}
if (!pagePath.startsWith(normalize(publicPath))) {
// Potential path traversal detected.
return undefined;
}
if (pagePath === resolve(documentFilePath) || !(await exists(pagePath))) {
// View matches with prerender path or file does not exist.
this.pageIsSSG.set(pagePath, false);
return undefined;
}
// Static file exists.
const content = await fs.promises.readFile(pagePath, 'utf-8');
const isSSG = SSG_MARKER_REGEXP.test(content);
this.pageIsSSG.set(pagePath, isSSG);
return isSSG ? content : undefined;
}
async renderApplication(opts) {
const moduleOrFactory = this.options?.bootstrap ?? opts.bootstrap;
if (!moduleOrFactory) {
throw new Error('A module or bootstrap option must be provided.');
}
const extraProviders = [
{ provide: _SERVER_CONTEXT, useValue: 'ssr' },
...(opts.providers ?? []),
...(this.options?.providers ?? []),
];
let document = opts.document;
if (!document && opts.documentFilePath) {
document = await this.getDocument(opts.documentFilePath);
}
const commonRenderingOptions = {
url: opts.url,
document,
};
return isBootstrapFn(moduleOrFactory)
? renderApplication(moduleOrFactory, {
platformProviders: extraProviders,
...commonRenderingOptions,
})
: renderModule(moduleOrFactory, { extraProviders, ...commonRenderingOptions });
}
/** Retrieve the document from the cache or the filesystem */
async getDocument(filePath) {
let doc = this.templateCache.get(filePath);
if (!doc) {
doc = await fs.promises.readFile(filePath, 'utf-8');
this.templateCache.set(filePath, doc);
}
return doc;
}
}
async function exists(path) {
try {
await fs.promises.access(path, fs.constants.F_OK);
return true;
}
catch {
return false;
}
}
function isBootstrapFn(value) {
// We can differentiate between a module and a bootstrap function by reading compiler-generated `ɵmod` static property:
return typeof value === 'function' && !('ɵmod' in value);
}
/**
* A set containing all the pseudo-headers defined in the HTTP/2 specification.
*
* This set can be used to filter out pseudo-headers from a list of headers,
* as they are not allowed to be set directly using the `Node.js` Undici API or
* the web `Headers` API.
*/
const HTTP2_PSEUDO_HEADERS = new Set([':method', ':scheme', ':authority', ':path', ':status']);
/**
* Converts a Node.js `IncomingMessage` or `Http2ServerRequest` into a
* Web Standard `Request` object.
*
* This function adapts the Node.js request objects to a format that can
* be used by web platform APIs.
*
* @param nodeRequest - The Node.js request object (`IncomingMessage` or `Http2ServerRequest`) to convert.
* @returns A Web Standard `Request` object.
*/
function createWebRequestFromNodeRequest(nodeRequest) {
const { headers, method = 'GET' } = nodeRequest;
const withBody = method !== 'GET' && method !== 'HEAD';
return new Request(createRequestUrl(nodeRequest), {
method,
headers: createRequestHeaders(headers),
body: withBody ? nodeRequest : undefined,
duplex: withBody ? 'half' : undefined,
});
}
/**
* Creates a `Headers` object from Node.js `IncomingHttpHeaders`.
*
* @param nodeHeaders - The Node.js `IncomingHttpHeaders` object to convert.
* @returns A `Headers` object containing the converted headers.
*/
function createRequestHeaders(nodeHeaders) {
const headers = new Headers();
for (const [name, value] of Object.entries(nodeHeaders)) {
if (HTTP2_PSEUDO_HEADERS.has(name)) {
continue;
}
if (typeof value === 'string') {
headers.append(name, value);
}
else if (Array.isArray(value)) {
for (const item of value) {
headers.append(name, item);
}
}
}
return headers;
}
/**
* Creates a `URL` object from a Node.js `IncomingMessage`, taking into account the protocol, host, and port.
*
* @param nodeRequest - The Node.js `IncomingMessage` or `Http2ServerRequest` object to extract URL information from.
* @returns A `URL` object representing the request URL.
*/
function createRequestUrl(nodeRequest) {
const { headers, socket, url = '', originalUrl, } = nodeRequest;
const protocol = getFirstHeaderValue(headers['x-forwarded-proto']) ??
('encrypted' in socket && socket.encrypted ? 'https' : 'http');
const hostname = getFirstHeaderValue(headers['x-forwarded-host']) ?? headers.host ?? headers[':authority'];
if (Array.isArray(hostname)) {
throw new Error('host value cannot be an array.');
}
let hostnameWithPort = hostname;
if (!hostname?.includes(':')) {
const port = getFirstHeaderValue(headers['x-forwarded-port']);
if (port) {
hostnameWithPort += `:${port}`;
}
}
return new URL(originalUrl ?? url, `${protocol}://${hostnameWithPort}`);
}
/**
* Extracts the first value from a multi-value header string.
*
* @param value - A string or an array of strings representing the header values.
* If it's a string, values are expected to be comma-separated.
* @returns The first trimmed value from the multi-value header, or `undefined` if the input is invalid or empty.
*
* @example
* ```typescript
* getFirstHeaderValue("value1, value2, value3"); // "value1"
* getFirstHeaderValue(["value1", "value2"]); // "value1"
* getFirstHeaderValue(undefined); // undefined
* ```
*/
function getFirstHeaderValue(value) {
return value?.toString().split(',', 1)[0]?.trim();
}
/**
* Angular server application engine.
* Manages Angular server applications (including localized ones), handles rendering requests,
* and optionally transforms index HTML before rendering.
*
* @remarks This class should be instantiated once and used as a singleton across the server-side
* application to ensure consistent handling of rendering requests and resource management.
*/
class AngularNodeAppEngine {
angularAppEngine = new AngularAppEngine();
constructor() {
attachNodeGlobalErrorHandlers();
}
/**
* Handles an incoming HTTP request by serving prerendered content, performing server-side rendering,
* or delivering a static file for client-side rendered routes based on the `RenderMode` setting.
*
* This method adapts Node.js's `IncomingMessage` or `Http2ServerRequest`
* to a format compatible with the `AngularAppEngine` and delegates the handling logic to it.
*
* @param request - The incoming HTTP request (`IncomingMessage` or `Http2ServerRequest`).
* @param requestContext - Optional context for rendering, such as metadata associated with the request.
* @returns A promise that resolves to the resulting HTTP response object, or `null` if no matching Angular route is found.
*
* @remarks A request to `https://www.example.com/page/index.html` will serve or render the Angular route
* corresponding to `https://www.example.com/page`.
*/
async handle(request, requestContext) {
const webRequest = createWebRequestFromNodeRequest(request);
return this.angularAppEngine.handle(webRequest, requestContext);
}
}
/**
* Attaches metadata to the handler function to mark it as a special handler for Node.js environments.
*
* @typeParam T - The type of the handler function.
* @param handler - The handler function to be defined and annotated.
* @returns The same handler function passed as an argument, with metadata attached.
*
* @example
* Usage in an Express application:
* ```ts
* const app = express();
* export default createNodeRequestHandler(app);
* ```
*
* @example
* Usage in a Hono application:
* ```ts
* const app = new Hono();
* export default createNodeRequestHandler(async (req, res, next) => {
* try {
* const webRes = await app.fetch(createWebRequestFromNodeRequest(req));
* if (webRes) {
* await writeResponseToNodeResponse(webRes, res);
* } else {
* next();
* }
* } catch (error) {
* next(error);
* }
* }));
* ```
*
* @example
* Usage in a Fastify application:
* ```ts
* const app = Fastify();
* export default createNodeRequestHandler(async (req, res) => {
* await app.ready();
* app.server.emit('request', req, res);
* res.send('Hello from Fastify with Node Next Handler!');
* }));
* ```
*/
function createNodeRequestHandler(handler) {
handler['__ng_node_request_handler__'] = true;
return handler;
}
/**
* Streams a web-standard `Response` into a Node.js `ServerResponse`
* or `Http2ServerResponse`.
*
* This function adapts the web `Response` object to write its content
* to a Node.js response object, handling both HTTP/1.1 and HTTP/2.
*
* @param source - The web-standard `Response` object to stream from.
* @param destination - The Node.js response object (`ServerResponse` or `Http2ServerResponse`) to stream into.
* @returns A promise that resolves once the streaming operation is complete.
*/
async function writeResponseToNodeResponse(source, destination) {
const { status, headers, body } = source;
destination.statusCode = status;
let cookieHeaderSet = false;
for (const [name, value] of headers.entries()) {
if (name === 'set-cookie') {
if (cookieHeaderSet) {
continue;
}
// Sets the 'set-cookie' header only once to ensure it is correctly applied.
// Concatenating 'set-cookie' values can lead to incorrect behavior, so we use a single value from `headers.getSetCookie()`.
destination.setHeader(name, headers.getSetCookie());
cookieHeaderSet = true;
}
else {
destination.setHeader(name, value);
}
}
if ('flushHeaders' in destination) {
destination.flushHeaders();
}
if (!body) {
destination.end();
return;
}
try {
const reader = body.getReader();
destination.on('close', () => {
reader.cancel().catch((error) => {
// eslint-disable-next-line no-console
console.error(`An error occurred while writing the response body for: ${destination.req.url}.`, error);
});
});
// eslint-disable-next-line no-constant-condition
while (true) {
const { done, value } = await reader.read();
if (done) {
destination.end();
break;
}
const canContinue = destination.write(value);
if (canContinue === false) {
// Explicitly check for `false`, as AWS may return `undefined` even though this is not valid.
// See: https://github.com/CodeGenieApp/serverless-express/issues/683
await new Promise((resolve) => destination.once('drain', resolve));
}
}
}
catch {
destination.end('Internal server error.');
}
}
/**
* Determines whether the provided URL represents the main entry point module.
*
* This function checks if the provided URL corresponds to the main ESM module being executed directly.
* It's useful for conditionally executing code that should only run when a module is the entry point,
* such as starting a server or initializing an application.
*
* It performs two key checks:
* 1. Verifies if the URL starts with 'file:', ensuring it is a local file.
* 2. Compares the URL's resolved file path with the first command-line argument (`process.argv[1]`),
* which points to the file being executed.
*
* @param url The URL of the module to check. This should typically be `import.meta.url`.
* @returns `true` if the provided URL represents the main entry point, otherwise `false`.
*/
function isMainModule(url) {
return url.startsWith('file:') && argv[1] === fileURLToPath(url);
}
export { AngularNodeAppEngine, CommonEngine, createNodeRequestHandler, createWebRequestFromNodeRequest, isMainModule, writeResponseToNodeResponse };
//# sourceMappingURL=node.mjs.map