diff --git a/packages/angular/build/src/tools/vite/middlewares/ssr-middleware.ts b/packages/angular/build/src/tools/vite/middlewares/ssr-middleware.ts index e8b0a954e84b..e03e3940eb6e 100644 --- a/packages/angular/build/src/tools/vite/middlewares/ssr-middleware.ts +++ b/packages/angular/build/src/tools/vite/middlewares/ssr-middleware.ts @@ -8,13 +8,15 @@ import type { AngularAppEngine as SSRAngularAppEngine, - createRequestHandler, ɵgetOrCreateAngularServerApp as getOrCreateAngularServerApp, } from '@angular/ssr'; -import type { createNodeRequestHandler } from '@angular/ssr/node'; import type { ServerResponse } from 'node:http'; import type { Connect, ViteDevServer } from 'vite'; import { loadEsmModule } from '../../../utils/load-esm'; +import { + isSsrNodeRequestHandler, + isSsrRequestHandler, +} from '../../../utils/server-rendering/utils'; export function createAngularSsrInternalMiddleware( server: ViteDevServer, @@ -136,13 +138,3 @@ export async function createAngularSsrExternalMiddleware( })().catch(next); }; } - -function isSsrNodeRequestHandler( - value: unknown, -): value is ReturnType { - return typeof value === 'function' && '__ng_node_request_handler__' in value; -} - -function isSsrRequestHandler(value: unknown): value is ReturnType { - return typeof value === 'function' && '__ng_request_handler__' in value; -} diff --git a/packages/angular/build/src/utils/server-rendering/fetch-patch.ts b/packages/angular/build/src/utils/server-rendering/fetch-patch.ts index ec67c249ecb3..7216f732e732 100644 --- a/packages/angular/build/src/utils/server-rendering/fetch-patch.ts +++ b/packages/angular/build/src/utils/server-rendering/fetch-patch.ts @@ -21,7 +21,7 @@ const { assetFiles } = workerData as { const assetsCache: Map; content: Buffer }> = new Map(); -export function patchFetchToLoadInMemoryAssets(): void { +export function patchFetchToLoadInMemoryAssets(baseURL: URL): void { const originalFetch = globalThis.fetch; const patchedFetch: typeof fetch = async (input, init) => { let url: URL; @@ -38,7 +38,7 @@ export function patchFetchToLoadInMemoryAssets(): void { const { hostname } = url; const pathname = decodeURIComponent(url.pathname); - if (hostname !== 'local-angular-prerender' || !assetFiles[pathname]) { + if (hostname !== baseURL.hostname || !assetFiles[pathname]) { // Only handle relative requests or files that are in assets. return originalFetch(input, init); } diff --git a/packages/angular/build/src/utils/server-rendering/launch-server.ts b/packages/angular/build/src/utils/server-rendering/launch-server.ts new file mode 100644 index 000000000000..88e60032face --- /dev/null +++ b/packages/angular/build/src/utils/server-rendering/launch-server.ts @@ -0,0 +1,64 @@ +/** + * @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.dev/license + */ + +import assert from 'node:assert'; +import { createServer } from 'node:http'; +import { loadEsmModule } from '../load-esm'; +import { loadEsmModuleFromMemory } from './load-esm-from-memory'; +import { isSsrNodeRequestHandler, isSsrRequestHandler } from './utils'; + +export const DEFAULT_URL = new URL('http://ng-localhost/'); + +/** + * Launches a server that handles local requests. + * + * @returns A promise that resolves to the URL of the running server. + */ +export async function launchServer(): Promise { + const { default: handler } = await loadEsmModuleFromMemory('./server.mjs'); + const { createWebRequestFromNodeRequest, writeResponseToNodeResponse } = + await loadEsmModule('@angular/ssr/node'); + + if (!isSsrNodeRequestHandler(handler) && !isSsrRequestHandler(handler)) { + return DEFAULT_URL; + } + + const server = createServer((req, res) => { + (async () => { + // handle request + if (isSsrNodeRequestHandler(handler)) { + await handler(req, res, (e) => { + throw e; + }); + } else { + const webRes = await handler(createWebRequestFromNodeRequest(req)); + if (webRes) { + await writeResponseToNodeResponse(webRes, res); + } else { + res.statusCode = 501; + res.end('Not Implemented.'); + } + } + })().catch((e) => { + res.statusCode = 500; + res.end('Internal Server Error.'); + // eslint-disable-next-line no-console + console.error(e); + }); + }); + + server.unref(); + + await new Promise((resolve) => server.listen(0, 'localhost', resolve)); + + const serverAddress = server.address(); + assert(serverAddress, 'Server address should be defined.'); + assert(typeof serverAddress !== 'string', 'Server address should not be a string.'); + + return new URL(`http://localhost:${serverAddress.port}/`); +} diff --git a/packages/angular/build/src/utils/server-rendering/load-esm-from-memory.ts b/packages/angular/build/src/utils/server-rendering/load-esm-from-memory.ts index d9a4f4b35414..7ebb197f9258 100644 --- a/packages/angular/build/src/utils/server-rendering/load-esm-from-memory.ts +++ b/packages/angular/build/src/utils/server-rendering/load-esm-from-memory.ts @@ -20,9 +20,20 @@ interface MainServerBundleExports { ɵgetOrCreateAngularServerApp: typeof ɵgetOrCreateAngularServerApp; } +/** + * Represents the exports available from the server bundle. + */ +interface ServerBundleExports { + default: unknown; +} + export function loadEsmModuleFromMemory( path: './main.server.mjs', -): Promise { +): Promise; +export function loadEsmModuleFromMemory(path: './server.mjs'): Promise; +export function loadEsmModuleFromMemory( + path: './main.server.mjs' | './server.mjs', +): Promise { return loadEsmModule(new URL(path, 'memory://')).catch((e) => { assertIsError(e); diff --git a/packages/angular/build/src/utils/server-rendering/prerender.ts b/packages/angular/build/src/utils/server-rendering/prerender.ts index 2dae25cff0cc..c82208c417e7 100644 --- a/packages/angular/build/src/utils/server-rendering/prerender.ts +++ b/packages/angular/build/src/utils/server-rendering/prerender.ts @@ -165,6 +165,7 @@ export async function prerenderPages( outputFilesForWorker, assetsReversed, appShellOptions, + outputMode, ); errors.push(...renderingErrors); @@ -186,6 +187,7 @@ async function renderPages( outputFilesForWorker: Record, assetFilesForWorker: Record, appShellOptions: AppShellOptions | undefined, + outputMode: OutputMode | undefined, ): Promise<{ output: PrerenderOutput; errors: string[]; @@ -210,6 +212,8 @@ async function renderPages( workspaceRoot, outputFiles: outputFilesForWorker, assetFiles: assetFilesForWorker, + outputMode, + hasSsrEntry: !!outputFilesForWorker['/server.mjs'], } as RenderWorkerData, execArgv: workerExecArgv, }); @@ -314,14 +318,16 @@ async function getAllRoutes( workspaceRoot, outputFiles: outputFilesForWorker, assetFiles: assetFilesForWorker, + outputMode, + hasSsrEntry: !!outputFilesForWorker['/server.mjs'], } as RoutesExtractorWorkerData, execArgv: workerExecArgv, }); try { - const { serializedRouteTree, errors }: RoutersExtractorWorkerResult = await renderWorker.run({ - outputMode, - }); + const { serializedRouteTree, errors }: RoutersExtractorWorkerResult = await renderWorker.run( + {}, + ); return { errors, serializedRouteTree: [...routes, ...serializedRouteTree] }; } catch (err) { diff --git a/packages/angular/build/src/utils/server-rendering/render-worker.ts b/packages/angular/build/src/utils/server-rendering/render-worker.ts index a8af04be4939..ddd274e4edac 100644 --- a/packages/angular/build/src/utils/server-rendering/render-worker.ts +++ b/packages/angular/build/src/utils/server-rendering/render-worker.ts @@ -6,18 +6,33 @@ * found in the LICENSE file at https://angular.dev/license */ +import { workerData } from 'worker_threads'; +import type { OutputMode } from '../../builders/application/schema'; import type { ESMInMemoryFileLoaderWorkerData } from './esm-in-memory-loader/loader-hooks'; import { patchFetchToLoadInMemoryAssets } from './fetch-patch'; +import { DEFAULT_URL, launchServer } from './launch-server'; import { loadEsmModuleFromMemory } from './load-esm-from-memory'; export interface RenderWorkerData extends ESMInMemoryFileLoaderWorkerData { assetFiles: Record; + outputMode: OutputMode | undefined; + hasSsrEntry: boolean; } export interface RenderOptions { url: string; } +/** + * This is passed as workerData when setting up the worker via the `piscina` package. + */ +const { outputMode, hasSsrEntry } = workerData as { + outputMode: OutputMode | undefined; + hasSsrEntry: boolean; +}; + +let serverURL = DEFAULT_URL; + /** * Renders each route in routes and writes them to //index.html. */ @@ -26,15 +41,19 @@ async function renderPage({ url }: RenderOptions): Promise { await loadEsmModuleFromMemory('./main.server.mjs'); const angularServerApp = getOrCreateAngularServerApp(); const response = await angularServerApp.renderStatic( - new URL(url, 'http://local-angular-prerender'), + new URL(url, serverURL), AbortSignal.timeout(30_000), ); return response ? response.text() : null; } -function initialize() { - patchFetchToLoadInMemoryAssets(); +async function initialize() { + if (outputMode !== undefined && hasSsrEntry) { + serverURL = await launchServer(); + } + + patchFetchToLoadInMemoryAssets(serverURL); return renderPage; } diff --git a/packages/angular/build/src/utils/server-rendering/routes-extractor-worker.ts b/packages/angular/build/src/utils/server-rendering/routes-extractor-worker.ts index ab1994ae3bfd..34dcfd529b90 100644 --- a/packages/angular/build/src/utils/server-rendering/routes-extractor-worker.ts +++ b/packages/angular/build/src/utils/server-rendering/routes-extractor-worker.ts @@ -6,24 +6,35 @@ * found in the LICENSE file at https://angular.dev/license */ +import { workerData } from 'worker_threads'; import { OutputMode } from '../../builders/application/schema'; +import { ESMInMemoryFileLoaderWorkerData } from './esm-in-memory-loader/loader-hooks'; import { patchFetchToLoadInMemoryAssets } from './fetch-patch'; +import { DEFAULT_URL, launchServer } from './launch-server'; import { loadEsmModuleFromMemory } from './load-esm-from-memory'; import { RoutersExtractorWorkerResult } from './models'; -export interface ExtractRoutesOptions { - outputMode?: OutputMode; +export interface ExtractRoutesWorkerData extends ESMInMemoryFileLoaderWorkerData { + outputMode: OutputMode | undefined; } +/** + * This is passed as workerData when setting up the worker via the `piscina` package. + */ +const { outputMode, hasSsrEntry } = workerData as { + outputMode: OutputMode | undefined; + hasSsrEntry: boolean; +}; + +let serverURL = DEFAULT_URL; + /** Renders an application based on a provided options. */ -async function extractRoutes({ - outputMode, -}: ExtractRoutesOptions): Promise { +async function extractRoutes(): Promise { const { ɵextractRoutesAndCreateRouteTree: extractRoutesAndCreateRouteTree } = await loadEsmModuleFromMemory('./main.server.mjs'); const { routeTree, errors } = await extractRoutesAndCreateRouteTree( - new URL('http://local-angular-prerender/'), + serverURL, undefined /** manifest */, true /** invokeGetPrerenderParams */, outputMode === OutputMode.Server /** includePrerenderFallbackRoutes */, @@ -35,8 +46,12 @@ async function extractRoutes({ }; } -function initialize() { - patchFetchToLoadInMemoryAssets(); +async function initialize() { + if (outputMode !== undefined && hasSsrEntry) { + serverURL = await launchServer(); + } + + patchFetchToLoadInMemoryAssets(serverURL); return extractRoutes; } diff --git a/packages/angular/build/src/utils/server-rendering/utils.ts b/packages/angular/build/src/utils/server-rendering/utils.ts new file mode 100644 index 000000000000..83c90187178b --- /dev/null +++ b/packages/angular/build/src/utils/server-rendering/utils.ts @@ -0,0 +1,21 @@ +/** + * @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.dev/license + */ + +import type { createRequestHandler } from '@angular/ssr'; +import type { createNodeRequestHandler } from '@angular/ssr/node'; + +export function isSsrNodeRequestHandler( + value: unknown, +): value is ReturnType { + return typeof value === 'function' && '__ng_node_request_handler__' in value; +} +export function isSsrRequestHandler( + value: unknown, +): value is ReturnType { + return typeof value === 'function' && '__ng_request_handler__' in value; +} diff --git a/tests/legacy-cli/e2e/tests/server-rendering/server-routes-output-mode-static-http-calls.ts b/tests/legacy-cli/e2e/tests/server-rendering/server-routes-output-mode-static-http-calls.ts new file mode 100644 index 000000000000..027a9ed9abb0 --- /dev/null +++ b/tests/legacy-cli/e2e/tests/server-rendering/server-routes-output-mode-static-http-calls.ts @@ -0,0 +1,126 @@ +import { match } from 'node:assert'; +import { readFile, writeMultipleFiles } from '../../utils/fs'; +import { noSilentNg, silentNg } from '../../utils/process'; +import { setupProjectWithSSRAppEngine } from './setup'; + +export default async function () { + // Setup project + await setupProjectWithSSRAppEngine(); + + await writeMultipleFiles({ + // Add asset + 'public/media.json': JSON.stringify({ dataFromAssets: true }), + // Update component to do an HTTP call to asset and API. + 'src/app/app.component.ts': ` + import { Component, inject } from '@angular/core'; + import { JsonPipe } from '@angular/common'; + import { RouterOutlet } from '@angular/router'; + import { HttpClient } from '@angular/common/http'; + + @Component({ + selector: 'app-root', + standalone: true, + imports: [JsonPipe, RouterOutlet], + template: \` +

{{ assetsData | json }}

+

{{ apiData | json }}

+ + \`, + }) + export class AppComponent { + assetsData: any; + apiData: any; + + constructor() { + const http = inject(HttpClient); + + http.get('/media.json').toPromise().then((d) => { + this.assetsData = d; + }); + + http.get('/api').toPromise().then((d) => { + this.apiData = d; + }); + } + } + `, + // 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: 'home', + component: HomeComponent, + }]), + provideClientHydration(), + provideHttpClient(withFetch()), + ], + }; + `, + 'src/app/app.routes.server.ts': ` + import { RenderMode, ServerRoute } from '@angular/ssr'; + + export const routes: ServerRoute[] = [ + { + path: '**', + renderMode: RenderMode.Prerender, + }, + ]; + `, + 'server.ts': ` + import { AngularNodeAppEngine, writeResponseToNodeResponse, isMainModule, createNodeRequestHandler } from '@angular/ssr/node'; + import express from 'express'; + import { fileURLToPath } from 'node:url'; + import { dirname, resolve } from 'node:path'; + + export function app(): express.Express { + const server = express(); + const serverDistFolder = dirname(fileURLToPath(import.meta.url)); + const browserDistFolder = resolve(serverDistFolder, '../browser'); + const angularNodeAppEngine = new AngularNodeAppEngine(); + + server.use('/api', (req, res) => res.json({ dataFromAPI: true })); + + server.get('**', express.static(browserDistFolder, { + maxAge: '1y', + index: 'index.html' + })); + + server.get('**', (req, res, next) => { + angularNodeAppEngine.render(req) + .then((response) => response ? writeResponseToNodeResponse(response, res) : next()) + .catch(next); + }); + + return server; + } + + const server = app(); + if (isMainModule(import.meta.url)) { + const port = process.env['PORT'] || 4000; + server.listen(port, () => { + console.log(\`Node Express server listening on http://localhost:\${port}\`); + }); + } + + export default createNodeRequestHandler(server); + `, + }); + + await silentNg('generate', 'component', 'home'); + + // Fix the error + await noSilentNg('build', '--output-mode=static'); + + const contents = await readFile('dist/test-project/browser/home/index.html'); + match(contents, /

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

{[\S\s]*"dataFromAPI":[\s\S]*true[\S\s]*}<\/p>/); + match(contents, /home works!/); +}