UNPKG

@angular/ssr

Version:

Angular server side rendering utilities

364 lines (352 loc) 12.1 kB
import { ɵInlineCriticalCssProcessor as _InlineCriticalCssProcessor, AngularAppEngine } from '@angular/ssr'; import { validateUrl, normalizeTrustProxyHeaders, parseForwardedHeader, isProxyHeaderAllowed, getFirstHeaderValue } from './_validation-chunk.mjs'; import { renderApplication, ɵSERVER_CONTEXT as _SERVER_CONTEXT, renderModule } 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 { readFile } from 'node:fs/promises'; import { argv } from 'node:process'; function getAllowedHostsFromEnv() { return getArrayFromEnv('NG_ALLOWED_HOSTS'); } function getTrustProxyHeadersFromEnv() { return getArrayFromEnv('NG_TRUST_PROXY_HEADERS'); } function getArrayFromEnv(envName) { const envValue = process.env[envName]; if (!envValue) { return undefined; } const values = []; for (const value of envValue.split(',')) { const trimmed = value.trim(); if (trimmed.length > 0) { values.push(trimmed); } } return values; } function attachNodeGlobalErrorHandlers() { if (typeof Zone !== 'undefined') { return; } const gThis = globalThis; if (gThis.ngAttachNodeGlobalErrorHandlersCalled) { return; } gThis.ngAttachNodeGlobalErrorHandlersCalled = true; process.on('unhandledRejection', error => console.error('unhandledRejection', error)).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; } 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); } console.log('********** Performance results **********'); for (const [step, value] of benchmarks) { const spaces = maxWordLength - step.length + 5; console.log(step + ' '.repeat(spaces) + value); } console.log('*****************************************'); } 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*["']/; class CommonEngine { options; templateCache = new Map(); inlineCriticalCssProcessor = new CommonEngineInlineCriticalCssProcessor(); pageIsSSG = new Map(); allowedHosts; constructor(options) { this.options = options; this.allowedHosts = new Set(getAllowedHostsFromEnv() ?? this.options?.allowedHosts ?? []); attachNodeGlobalErrorHandlers(); } async render(opts) { const { url } = opts; if (url && URL$1.canParse(url)) { const urlObj = new URL$1(url); try { validateUrl(urlObj, this.allowedHosts); } catch (error) { console.error(`ERROR: ${error.message}` + 'Please provide a list of allowed hosts in the "allowedHosts" option in the "CommonEngine" constructor.'); throw error; } } 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', () => 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://'); const pagePath = join(publicPath, pathname, 'index.html'); if (this.pageIsSSG.get(pagePath)) { return fs.promises.readFile(pagePath, 'utf-8'); } if (!pagePath.startsWith(normalize(publicPath))) { return undefined; } if (pagePath === resolve(documentFilePath) || !(await exists(pagePath))) { return undefined; } const content = await fs.promises.readFile(pagePath, 'utf-8'); const isSSG = SSG_MARKER_REGEXP.test(content); if (isSSG) { this.pageIsSSG.set(pagePath, true); return content; } return 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, allowedHosts: ['*'] }; return isBootstrapFn(moduleOrFactory) ? renderApplication(moduleOrFactory, { platformProviders: extraProviders, ...commonRenderingOptions }) : renderModule(moduleOrFactory, { extraProviders, ...commonRenderingOptions }); } 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) { return typeof value === 'function' && !('ɵmod' in value); } const HTTP2_PSEUDO_HEADERS = new Set([':method', ':scheme', ':authority', ':path', ':status']); function createWebRequestFromNodeRequest(nodeRequest, trustProxyHeaders) { const trustProxyHeadersNormalized = normalizeTrustProxyHeaders(trustProxyHeaders); const { headers, method = 'GET' } = nodeRequest; const withBody = method !== 'GET' && method !== 'HEAD'; const referrer = headers.referer && URL.canParse(headers.referer) ? headers.referer : undefined; return new Request(createRequestUrl(nodeRequest, trustProxyHeadersNormalized), { method, headers: createRequestHeaders(headers), body: withBody ? nodeRequest : undefined, duplex: withBody ? 'half' : undefined, referrer }); } 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; } function createRequestUrl(nodeRequest, trustProxyHeaders) { const { headers, socket, url = '', originalUrl } = nodeRequest; const forwardedHeaderValue = getAllowedProxyHeaderValue(headers, 'forwarded', trustProxyHeaders); const forwardedParams = parseForwardedHeader(forwardedHeaderValue); const protocol = forwardedParams.proto ?? getAllowedProxyHeaderValue(headers, 'x-forwarded-proto', trustProxyHeaders) ?? ('encrypted' in socket && socket.encrypted ? 'https' : 'http'); const hostname = forwardedParams.host ?? getAllowedProxyHeaderValue(headers, 'x-forwarded-host', trustProxyHeaders) ?? 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 = getAllowedProxyHeaderValue(headers, 'x-forwarded-port', trustProxyHeaders); if (port) { hostnameWithPort += `:${port}`; } } return new URL(`${protocol}://${hostnameWithPort}${originalUrl ?? url}`); } function getAllowedProxyHeaderValue(headers, headerName, trustProxyHeaders) { return isProxyHeaderAllowed(headerName, trustProxyHeaders) ? getFirstHeaderValue(headers[headerName]) : undefined; } class AngularNodeAppEngine { angularAppEngine; trustProxyHeaders; constructor(options) { const appEngineOptions = { ...options, allowedHosts: options?.allowedHosts ?? getAllowedHostsFromEnv(), trustProxyHeaders: options?.trustProxyHeaders ?? getTrustProxyHeadersFromEnv() }; this.angularAppEngine = new AngularAppEngine(appEngineOptions); this.trustProxyHeaders = appEngineOptions.trustProxyHeaders; attachNodeGlobalErrorHandlers(); } async handle(request, requestContext) { const webRequest = request instanceof Request ? request : createWebRequestFromNodeRequest(request, this.trustProxyHeaders); return this.angularAppEngine.handle(webRequest, requestContext); } } function createNodeRequestHandler(handler) { handler['__ng_node_request_handler__'] = true; return handler; } 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; } 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 => { console.error(`An error occurred while writing the response body for: ${destination.req.url}.`, error); }); }); while (true) { const { done, value } = await reader.read(); if (done) { destination.end(); break; } const canContinue = destination.write(value); if (canContinue === false) { await new Promise(resolve => destination.once('drain', resolve)); } } } catch { destination.end('Internal server error.'); } } function isMainModule(url) { return url.startsWith('file:') && argv[1] === fileURLToPath(url); } export { AngularNodeAppEngine, CommonEngine, createNodeRequestHandler, createWebRequestFromNodeRequest, isMainModule, writeResponseToNodeResponse }; //# sourceMappingURL=node.mjs.map