diff --git a/packages/angular_devkit/build_angular/src/builders/application/execute-post-bundle.ts b/packages/angular_devkit/build_angular/src/builders/application/execute-post-bundle.ts index 6a5d1869ebfa..df5e8691d5aa 100644 --- a/packages/angular_devkit/build_angular/src/builders/application/execute-post-bundle.ts +++ b/packages/angular_devkit/build_angular/src/builders/application/execute-post-bundle.ts @@ -117,6 +117,7 @@ export async function executePostBundleSteps( appShellOptions, prerenderOptions, outputFiles, + assetFiles, indexContentOutputNoCssInlining, sourcemapOptions.scripts, optimizationOptions.styles.inlineCritical, diff --git a/packages/angular_devkit/build_angular/src/builders/prerender/routes-extractor-worker.ts b/packages/angular_devkit/build_angular/src/builders/prerender/routes-extractor-worker.ts index 1932f8f0ef1f..f7bfa76b4b09 100644 --- a/packages/angular_devkit/build_angular/src/builders/prerender/routes-extractor-worker.ts +++ b/packages/angular_devkit/build_angular/src/builders/prerender/routes-extractor-worker.ts @@ -51,7 +51,7 @@ async function extract(): Promise { ); const routes: string[] = []; - for await (const { route, success } of extractRoutes(bootstrapAppFnOrModule, document)) { + for await (const { route, success } of extractRoutes(bootstrapAppFnOrModule, document, '')) { if (success) { routes.push(route); } diff --git a/packages/angular_devkit/build_angular/src/utils/routes-extractor/extractor.ts b/packages/angular_devkit/build_angular/src/utils/routes-extractor/extractor.ts index 5e4ecdfef35e..822e73b2b4e9 100644 --- a/packages/angular_devkit/build_angular/src/utils/routes-extractor/extractor.ts +++ b/packages/angular_devkit/build_angular/src/utils/routes-extractor/extractor.ts @@ -78,11 +78,12 @@ async function* getRoutesFromRouterConfig( export async function* extractRoutes( bootstrapAppFnOrModule: (() => Promise) | Type, document: string, + url: string, ): AsyncIterableIterator { const platformRef = createPlatformFactory(platformCore, 'server', [ { provide: INITIAL_CONFIG, - useValue: { document, url: '' }, + useValue: { document, url }, }, { provide: ɵConsole, diff --git a/packages/angular_devkit/build_angular/src/utils/server-rendering/prerender-server.ts b/packages/angular_devkit/build_angular/src/utils/server-rendering/prerender-server.ts new file mode 100644 index 000000000000..0f0ac441d0bf --- /dev/null +++ b/packages/angular_devkit/build_angular/src/utils/server-rendering/prerender-server.ts @@ -0,0 +1,125 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import { lookup as lookupMimeType } from 'mrmime'; +import { readFile } from 'node:fs/promises'; +import { IncomingMessage, RequestListener, ServerResponse, createServer } from 'node:http'; +import { extname, posix } from 'node:path'; +import { BuildOutputAsset } from '../../tools/esbuild/bundler-execution-result'; + +/** + * Start a server that can handle HTTP requests to assets. + * + * @example + * ```ts + * httpClient.get('/assets/content.json'); + * ``` + * @returns the server address. + */ +export async function startServer(assets: Readonly): Promise<{ + address: string; + close?: () => void; +}> { + if (Object.keys(assets).length === 0) { + return { + address: '', + }; + } + + const assetsReversed: Record = {}; + for (const { source, destination } of assets) { + assetsReversed[addLeadingSlash(destination.replace(/\\/g, posix.sep))] = source; + } + + const assetsCache: Map = new Map(); + const server = createServer(); + server.on('request', requestHandler(assetsReversed, assetsCache)); + + await new Promise((resolve) => { + server.listen(0, '127.0.0.1', () => { + resolve(); + }); + }); + + const serverAddress = server.address(); + let address: string; + if (!serverAddress) { + address = ''; + } else if (typeof serverAddress === 'string') { + address = serverAddress; + } else { + const { port, address: host } = serverAddress; + address = `http://${host}:${port}`; + } + + return { + address, + close: () => { + assetsCache.clear(); + server.unref(); + server.close(); + }, + }; +} +function requestHandler( + assetsReversed: Record, + assetsCache: Map, +): RequestListener { + return (req, res) => { + if (!req.url) { + res.destroy(new Error('Request url was empty.')); + + return; + } + + const { pathname } = new URL(req.url, 'resolve://'); + + const asset = assetsReversed[pathname]; + if (!asset) { + res.statusCode = 404; + res.statusMessage = 'Asset not found.'; + res.end(); + + return; + } + + const cachedAsset = assetsCache.get(pathname); + if (cachedAsset) { + const { content, mimeType } = cachedAsset; + if (mimeType) { + res.setHeader('Content-Type', mimeType); + } + + res.end(content); + + return; + } + + readFile(asset) + .then((content) => { + const extension = extname(pathname); + const mimeType = lookupMimeType(extension); + + assetsCache.set(pathname, { + mimeType, + content, + }); + + if (mimeType) { + res.setHeader('Content-Type', mimeType); + } + + res.end(content); + }) + .catch((e) => res.destroy(e)); + }; +} + +function addLeadingSlash(value: string): string { + return value.charAt(0) === '/' ? value : '/' + value; +} 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 623d0445e1e5..e030e1f82826 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 @@ -7,10 +7,12 @@ */ import { readFile } from 'node:fs/promises'; -import { extname, join, posix } from 'node:path'; +import { extname, posix } from 'node:path'; import Piscina from 'piscina'; import { BuildOutputFile, BuildOutputFileType } from '../../tools/esbuild/bundler-context'; +import { BuildOutputAsset } from '../../tools/esbuild/bundler-execution-result'; import { getESMLoaderArgs } from './esm-in-memory-loader/node-18-utils'; +import { startServer } from './prerender-server'; import type { RenderResult, ServerContext } from './render-page'; import type { RenderWorkerData } from './render-worker'; import type { @@ -32,6 +34,7 @@ export async function prerenderPages( appShellOptions: AppShellOptions = {}, prerenderOptions: PrerenderOptions = {}, outputFiles: Readonly, + assets: Readonly, document: string, sourcemap = false, inlineCriticalCss = false, @@ -43,11 +46,10 @@ export async function prerenderPages( errors: string[]; prerenderedRoutes: Set; }> { - const output: Record = {}; - const warnings: string[] = []; - const errors: string[] = []; const outputFilesForWorker: Record = {}; const serverBundlesSourceMaps = new Map(); + const warnings: string[] = []; + const errors: string[] = []; for (const { text, path, type } of outputFiles) { const fileExt = extname(path); @@ -74,28 +76,91 @@ export async function prerenderPages( } serverBundlesSourceMaps.clear(); - const { routes: allRoutes, warnings: routesWarnings } = await getAllRoutes( - workspaceRoot, - outputFilesForWorker, - document, - appShellOptions, - prerenderOptions, - sourcemap, - verbose, - ); - - if (routesWarnings?.length) { - warnings.push(...routesWarnings); - } + // Start server to handle HTTP requests to assets. + // TODO: consider starting this is a seperate process to avoid any blocks to the main thread. + const { address: assetsServerAddress, close: closeAssetsServer } = await startServer(assets); + + try { + // Get routes to prerender + const { routes: allRoutes, warnings: routesWarnings } = await getAllRoutes( + workspaceRoot, + outputFilesForWorker, + document, + appShellOptions, + prerenderOptions, + sourcemap, + verbose, + assetsServerAddress, + ); + + if (routesWarnings?.length) { + warnings.push(...routesWarnings); + } + + if (allRoutes.size < 1) { + return { + errors, + warnings, + output: {}, + prerenderedRoutes: allRoutes, + }; + } + + // Render routes + const { + warnings: renderingWarnings, + errors: renderingErrors, + output, + } = await renderPages( + sourcemap, + allRoutes, + maxThreads, + workspaceRoot, + outputFilesForWorker, + inlineCriticalCss, + document, + assetsServerAddress, + appShellOptions, + ); + + errors.push(...renderingErrors); + warnings.push(...renderingWarnings); - if (allRoutes.size < 1) { return { errors, warnings, output, prerenderedRoutes: allRoutes, }; + } finally { + void closeAssetsServer?.(); } +} + +class RoutesSet extends Set { + override add(value: string): this { + return super.add(addLeadingSlash(value)); + } +} + +async function renderPages( + sourcemap: boolean, + allRoutes: Set, + maxThreads: number, + workspaceRoot: string, + outputFilesForWorker: Record, + inlineCriticalCss: boolean, + document: string, + baseUrl: string, + appShellOptions: AppShellOptions, +): Promise<{ + output: Record; + warnings: string[]; + errors: string[]; +}> { + const output: Record = {}; + const warnings: string[] = []; + const errors: string[] = []; const workerExecArgv = getESMLoaderArgs(); if (sourcemap) { @@ -110,6 +175,7 @@ export async function prerenderPages( outputFiles: outputFilesForWorker, inlineCriticalCss, document, + baseUrl, } as RenderWorkerData, execArgv: workerExecArgv, }); @@ -153,16 +219,9 @@ export async function prerenderPages( errors, warnings, output, - prerenderedRoutes: allRoutes, }; } -class RoutesSet extends Set { - override add(value: string): this { - return super.add(addLeadingSlash(value)); - } -} - async function getAllRoutes( workspaceRoot: string, outputFilesForWorker: Record, @@ -171,11 +230,12 @@ async function getAllRoutes( prerenderOptions: PrerenderOptions, sourcemap: boolean, verbose: boolean, + assetsServerAddress: string, ): Promise<{ routes: Set; warnings?: string[] }> { const { routesFile, discoverRoutes } = prerenderOptions; const routes = new RoutesSet(); - const { route: appShellRoute } = appShellOptions; + if (appShellRoute !== undefined) { routes.add(appShellRoute); } @@ -204,6 +264,7 @@ async function getAllRoutes( outputFiles: outputFilesForWorker, document, verbose, + url: assetsServerAddress, } as RoutesExtractorWorkerData, execArgv: workerExecArgv, }); diff --git a/packages/angular_devkit/build_angular/src/utils/server-rendering/render-worker.ts b/packages/angular_devkit/build_angular/src/utils/server-rendering/render-worker.ts index 6a1449bcca22..eaafa6e1cb90 100644 --- a/packages/angular_devkit/build_angular/src/utils/server-rendering/render-worker.ts +++ b/packages/angular_devkit/build_angular/src/utils/server-rendering/render-worker.ts @@ -13,6 +13,7 @@ import { RenderResult, ServerContext, renderPage } from './render-page'; export interface RenderWorkerData extends ESMInMemoryFileLoaderWorkerData { document: string; inlineCriticalCss?: boolean; + baseUrl: string; } export interface RenderOptions { @@ -23,8 +24,15 @@ export interface RenderOptions { /** * This is passed as workerData when setting up the worker via the `piscina` package. */ -const { outputFiles, document, inlineCriticalCss } = workerData as RenderWorkerData; +const { outputFiles, document, inlineCriticalCss, baseUrl } = workerData as RenderWorkerData; +/** Renders an application based on a provided options. */ export default function (options: RenderOptions): Promise { - return renderPage({ ...options, outputFiles, document, inlineCriticalCss }); + return renderPage({ + ...options, + route: baseUrl + options.route, + outputFiles, + document, + inlineCriticalCss, + }); } diff --git a/packages/angular_devkit/build_angular/src/utils/server-rendering/routes-extractor-worker.ts b/packages/angular_devkit/build_angular/src/utils/server-rendering/routes-extractor-worker.ts index 735a2ea52d4d..e4a070d8e155 100644 --- a/packages/angular_devkit/build_angular/src/utils/server-rendering/routes-extractor-worker.ts +++ b/packages/angular_devkit/build_angular/src/utils/server-rendering/routes-extractor-worker.ts @@ -14,6 +14,8 @@ import { MainServerBundleExports, RenderUtilsServerBundleExports } from './main- export interface RoutesExtractorWorkerData extends ESMInMemoryFileLoaderWorkerData { document: string; verbose: boolean; + url: string; + assetsServerAddress: string; } export interface RoutersExtractorWorkerResult { @@ -24,7 +26,7 @@ export interface RoutersExtractorWorkerResult { /** * This is passed as workerData when setting up the worker via the `piscina` package. */ -const { document, verbose } = workerData as RoutesExtractorWorkerData; +const { document, verbose, url } = workerData as RoutesExtractorWorkerData; export default async function (): Promise { const { extractRoutes } = await loadEsmModule( @@ -40,6 +42,7 @@ export default async function (): Promise { for await (const { route, success, redirect } of extractRoutes( bootstrapAppFnOrModule, document, + url, )) { if (success) { routes.push(route); diff --git a/tests/legacy-cli/e2e/tests/build/prerender/http-requests-assets.ts b/tests/legacy-cli/e2e/tests/build/prerender/http-requests-assets.ts new file mode 100644 index 000000000000..5a2c96afc340 --- /dev/null +++ b/tests/legacy-cli/e2e/tests/build/prerender/http-requests-assets.ts @@ -0,0 +1,83 @@ +import { ng } from '../../../utils/process'; +import { getGlobalVariable } from '../../../utils/env'; +import { expectFileToMatch, rimraf, writeMultipleFiles } from '../../../utils/fs'; +import { installWorkspacePackages } from '../../../utils/packages'; +import { useSha } from '../../../utils/project'; + +export default async function () { + // TODO(alanagius): allow this test to run for non snapshots once FW v17.0.0-rc.1 is released on NPM. + const isSnapshotBuild = getGlobalVariable('argv')['ng-snapshots']; + if (!isSnapshotBuild) { + return; + } + + const useWebpackBuilder = !getGlobalVariable('argv')['esbuild']; + if (useWebpackBuilder) { + // Not supported by the webpack based builder. + return; + } + + // Forcibly remove in case another test doesn't clean itself up. + await rimraf('node_modules/@angular/ssr'); + await ng('add', '@angular/ssr', '--skip-confirmation'); + await useSha(); + await installWorkspacePackages(); + + await writeMultipleFiles({ + // Add http client and route + 'src/app/app.config.ts': ` + import { ApplicationConfig } from '@angular/core'; + import { provideRouter } from '@angular/router'; + + import {HomeComponent} from './home/home.component'; + import { provideClientHydration } from '@angular/platform-browser'; + import { provideHttpClient, withFetch } from '@angular/common/http'; + + export const appConfig: ApplicationConfig = { + providers: [ + provideRouter([{ + path: '', + component: HomeComponent, + }]), + provideClientHydration(), + provideHttpClient(withFetch()), + ], + }; + `, + // Add asset + 'src/assets/media.json': JSON.stringify({ dataFromAssets: true }), + // Update component to do an HTTP call to asset. + 'src/app/app.component.ts': ` + import { Component, inject } from '@angular/core'; + import { CommonModule } from '@angular/common'; + import { RouterOutlet } from '@angular/router'; + import { HttpClient } from '@angular/common/http'; + + @Component({ + selector: 'app-root', + standalone: true, + imports: [CommonModule, RouterOutlet], + template: \` +

{{ data | json }}

+ + \`, + }) + export class AppComponent { + data: any; + constructor() { + const http = inject(HttpClient); + http.get('/assets/media.json').subscribe((d) => { + this.data = d; + }); + } + } + `, + }); + + await ng('generate', 'component', 'home'); + await ng('build', '--configuration=production', '--prerender'); + await expectFileToMatch( + 'dist/test-project/browser/index.html', + /

{[\S\s]*"dataFromAssets":[\s\S]*true[\S\s]*}<\/p>/, + ); +}