Skip to content

Commit 39a0ae4

Browse files
committed
feat(@angular/build): utilize ssr.entry during prerendering to enable access to local API routes
The `ssr.entry` (server.ts file) is now utilized during prerendering, allowing access to locally defined API routes for improved data fetching and rendering.
1 parent 158774a commit 39a0ae4

File tree

9 files changed

+283
-29
lines changed

9 files changed

+283
-29
lines changed

packages/angular/build/src/tools/vite/middlewares/ssr-middleware.ts

Lines changed: 4 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,15 @@
88

99
import type {
1010
AngularAppEngine as SSRAngularAppEngine,
11-
createRequestHandler,
1211
ɵgetOrCreateAngularServerApp as getOrCreateAngularServerApp,
1312
} from '@angular/ssr';
14-
import type { createNodeRequestHandler } from '@angular/ssr/node';
1513
import type { ServerResponse } from 'node:http';
1614
import type { Connect, ViteDevServer } from 'vite';
1715
import { loadEsmModule } from '../../../utils/load-esm';
16+
import {
17+
isSsrNodeRequestHandler,
18+
isSsrRequestHandler,
19+
} from '../../../utils/server-rendering/utils';
1820

1921
export function createAngularSsrInternalMiddleware(
2022
server: ViteDevServer,
@@ -136,13 +138,3 @@ export async function createAngularSsrExternalMiddleware(
136138
})().catch(next);
137139
};
138140
}
139-
140-
function isSsrNodeRequestHandler(
141-
value: unknown,
142-
): value is ReturnType<typeof createNodeRequestHandler> {
143-
return typeof value === 'function' && '__ng_node_request_handler__' in value;
144-
}
145-
146-
function isSsrRequestHandler(value: unknown): value is ReturnType<typeof createRequestHandler> {
147-
return typeof value === 'function' && '__ng_request_handler__' in value;
148-
}

packages/angular/build/src/utils/server-rendering/fetch-patch.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ const { assetFiles } = workerData as {
2121
const assetsCache: Map<string, { headers: undefined | Record<string, string>; content: Buffer }> =
2222
new Map();
2323

24-
export function patchFetchToLoadInMemoryAssets(): void {
24+
export function patchFetchToLoadInMemoryAssets(baseURL: URL): void {
2525
const originalFetch = globalThis.fetch;
2626
const patchedFetch: typeof fetch = async (input, init) => {
2727
let url: URL;
@@ -38,7 +38,7 @@ export function patchFetchToLoadInMemoryAssets(): void {
3838
const { hostname } = url;
3939
const pathname = decodeURIComponent(url.pathname);
4040

41-
if (hostname !== 'local-angular-prerender' || !assetFiles[pathname]) {
41+
if (hostname !== baseURL.hostname || !assetFiles[pathname]) {
4242
// Only handle relative requests or files that are in assets.
4343
return originalFetch(input, init);
4444
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import assert from 'node:assert';
10+
import { createServer } from 'node:http';
11+
import { workerData } from 'node:worker_threads';
12+
import { OutputMode } from '../../builders/application/schema';
13+
import { loadEsmModule } from '../load-esm';
14+
import { loadEsmModuleFromMemory } from './load-esm-from-memory';
15+
import { isSsrNodeRequestHandler, isSsrRequestHandler } from './utils';
16+
17+
const DEFAULT_URL = new URL('http://ng-localhost/');
18+
19+
/**
20+
* This is passed as workerData when setting up the worker via the `piscina` package.
21+
*/
22+
const { outputMode } = workerData as {
23+
outputMode: OutputMode | undefined;
24+
};
25+
26+
/**
27+
* Launches a server that handles requests based on the output mode and the provided server handler.
28+
* If the output mode is not defined or the handler is invalid, it returns a default URL.
29+
*
30+
* @returns A promise that resolves to the URL of the running server.
31+
*/
32+
export async function launchServer(): Promise<URL> {
33+
if (!outputMode) {
34+
// Legacy mode
35+
return DEFAULT_URL;
36+
}
37+
38+
let handler: unknown;
39+
try {
40+
const serverEntry = await loadEsmModuleFromMemory('./server.mjs');
41+
handler = serverEntry.default;
42+
} catch {
43+
return DEFAULT_URL;
44+
}
45+
46+
const { createWebRequestFromNodeRequest, writeResponseToNodeResponse } =
47+
await loadEsmModule<typeof import('@angular/ssr/node')>('@angular/ssr/node');
48+
49+
if (!isSsrNodeRequestHandler(handler) && !isSsrRequestHandler(handler)) {
50+
return DEFAULT_URL;
51+
}
52+
53+
// eslint-disable-next-line @typescript-eslint/no-misused-promises
54+
const server = createServer(async (req, res) => {
55+
// handle request
56+
if (isSsrNodeRequestHandler(handler)) {
57+
// eslint-disable-next-line no-console
58+
await handler(req, res, (e) => {
59+
throw e;
60+
});
61+
} else {
62+
const webRes = await handler(createWebRequestFromNodeRequest(req));
63+
if (webRes) {
64+
await writeResponseToNodeResponse(webRes, res);
65+
} else {
66+
res.statusCode = 501;
67+
res.end('Not Implemented.');
68+
}
69+
}
70+
});
71+
72+
server.unref();
73+
74+
return new Promise<URL>((resolve) => {
75+
server.listen(0, () => {
76+
const serverAddress = server.address();
77+
assert(serverAddress, 'Server address should be defined.');
78+
assert(typeof serverAddress !== 'string', 'Server address should not be a string.');
79+
80+
resolve(new URL(`http://localhost:${serverAddress.port}/`));
81+
});
82+
});
83+
}

packages/angular/build/src/utils/server-rendering/load-esm-from-memory.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,20 @@ interface MainServerBundleExports {
2020
ɵgetOrCreateAngularServerApp: typeof ɵgetOrCreateAngularServerApp;
2121
}
2222

23+
/**
24+
* Represents the exports available from the server bundle.
25+
*/
26+
interface ServerBundleExports {
27+
default: unknown;
28+
}
29+
2330
export function loadEsmModuleFromMemory(
2431
path: './main.server.mjs',
25-
): Promise<MainServerBundleExports> {
32+
): Promise<MainServerBundleExports>;
33+
export function loadEsmModuleFromMemory(path: './server.mjs'): Promise<ServerBundleExports>;
34+
export function loadEsmModuleFromMemory(
35+
path: './main.server.mjs' | './server.mjs',
36+
): Promise<MainServerBundleExports | ServerBundleExports> {
2637
return loadEsmModule(new URL(path, 'memory://')).catch((e) => {
2738
assertIsError(e);
2839

packages/angular/build/src/utils/server-rendering/prerender.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,7 @@ export async function prerenderPages(
160160
outputFilesForWorker,
161161
assetsReversed,
162162
appShellOptions,
163+
outputMode,
163164
);
164165

165166
errors.push(...renderingErrors);
@@ -181,6 +182,7 @@ async function renderPages(
181182
outputFilesForWorker: Record<string, string>,
182183
assetFilesForWorker: Record<string, string>,
183184
appShellOptions: AppShellOptions | undefined,
185+
outputMode: OutputMode | undefined,
184186
): Promise<{
185187
output: PrerenderOutput;
186188
errors: string[];
@@ -205,6 +207,7 @@ async function renderPages(
205207
workspaceRoot,
206208
outputFiles: outputFilesForWorker,
207209
assetFiles: assetFilesForWorker,
210+
outputMode,
208211
} as RenderWorkerData,
209212
execArgv: workerExecArgv,
210213
});
@@ -309,14 +312,15 @@ async function getAllRoutes(
309312
workspaceRoot,
310313
outputFiles: outputFilesForWorker,
311314
assetFiles: assetFilesForWorker,
315+
outputMode,
312316
} as RoutesExtractorWorkerData,
313317
execArgv: workerExecArgv,
314318
});
315319

316320
try {
317-
const { serializedRouteTree, errors }: RoutersExtractorWorkerResult = await renderWorker.run({
318-
outputMode,
319-
});
321+
const { serializedRouteTree, errors }: RoutersExtractorWorkerResult = await renderWorker.run(
322+
{},
323+
);
320324

321325
return { errors, serializedRouteTree: [...routes, ...serializedRouteTree] };
322326
} catch (err) {

packages/angular/build/src/utils/server-rendering/render-worker.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,23 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9+
import type { OutputMode } from '../../builders/application/schema';
910
import type { ESMInMemoryFileLoaderWorkerData } from './esm-in-memory-loader/loader-hooks';
1011
import { patchFetchToLoadInMemoryAssets } from './fetch-patch';
12+
import { launchServer } from './launch-server';
1113
import { loadEsmModuleFromMemory } from './load-esm-from-memory';
1214

1315
export interface RenderWorkerData extends ESMInMemoryFileLoaderWorkerData {
1416
assetFiles: Record</** Destination */ string, /** Source */ string>;
17+
outputMode: OutputMode | undefined;
1518
}
1619

1720
export interface RenderOptions {
1821
url: string;
1922
}
2023

24+
let serverURL: URL;
25+
2126
/**
2227
* Renders each route in routes and writes them to <outputPath>/<route>/index.html.
2328
*/
@@ -26,15 +31,16 @@ async function renderPage({ url }: RenderOptions): Promise<string | null> {
2631
await loadEsmModuleFromMemory('./main.server.mjs');
2732
const angularServerApp = getOrCreateAngularServerApp();
2833
const response = await angularServerApp.renderStatic(
29-
new URL(url, 'http://local-angular-prerender'),
34+
new URL(url, serverURL),
3035
AbortSignal.timeout(30_000),
3136
);
3237

3338
return response ? response.text() : null;
3439
}
3540

36-
function initialize() {
37-
patchFetchToLoadInMemoryAssets();
41+
async function initialize() {
42+
serverURL = await launchServer();
43+
patchFetchToLoadInMemoryAssets(serverURL);
3844

3945
return renderPage;
4046
}

packages/angular/build/src/utils/server-rendering/routes-extractor-worker.ts

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,24 +6,34 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9+
import { workerData } from 'worker_threads';
910
import { OutputMode } from '../../builders/application/schema';
11+
import { ESMInMemoryFileLoaderWorkerData } from './esm-in-memory-loader/loader-hooks';
1012
import { patchFetchToLoadInMemoryAssets } from './fetch-patch';
13+
import { launchServer } from './launch-server';
1114
import { loadEsmModuleFromMemory } from './load-esm-from-memory';
1215
import { RoutersExtractorWorkerResult } from './models';
1316

14-
export interface ExtractRoutesOptions {
15-
outputMode?: OutputMode;
17+
export interface ExtractRoutesWorkerData extends ESMInMemoryFileLoaderWorkerData {
18+
outputMode: OutputMode | undefined;
1619
}
1720

21+
/**
22+
* This is passed as workerData when setting up the worker via the `piscina` package.
23+
*/
24+
const { outputMode } = workerData as {
25+
outputMode: OutputMode | undefined;
26+
};
27+
28+
let serverURL: URL;
29+
1830
/** Renders an application based on a provided options. */
19-
async function extractRoutes({
20-
outputMode,
21-
}: ExtractRoutesOptions): Promise<RoutersExtractorWorkerResult> {
31+
async function extractRoutes(): Promise<RoutersExtractorWorkerResult> {
2232
const { ɵextractRoutesAndCreateRouteTree: extractRoutesAndCreateRouteTree } =
2333
await loadEsmModuleFromMemory('./main.server.mjs');
2434

2535
const { routeTree, errors } = await extractRoutesAndCreateRouteTree(
26-
new URL('http://local-angular-prerender/'),
36+
serverURL,
2737
undefined /** manifest */,
2838
true /** invokeGetPrerenderParams */,
2939
outputMode === OutputMode.Server /** includePrerenderFallbackRoutes */,
@@ -35,8 +45,9 @@ async function extractRoutes({
3545
};
3646
}
3747

38-
function initialize() {
39-
patchFetchToLoadInMemoryAssets();
48+
async function initialize() {
49+
serverURL = await launchServer();
50+
patchFetchToLoadInMemoryAssets(serverURL);
4051

4152
return extractRoutes;
4253
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import type { createRequestHandler } from '@angular/ssr';
10+
import type { createNodeRequestHandler } from '@angular/ssr/node';
11+
12+
export function isSsrNodeRequestHandler(
13+
value: unknown,
14+
): value is ReturnType<typeof createNodeRequestHandler> {
15+
return typeof value === 'function' && '__ng_node_request_handler__' in value;
16+
}
17+
export function isSsrRequestHandler(
18+
value: unknown,
19+
): value is ReturnType<typeof createRequestHandler> {
20+
return typeof value === 'function' && '__ng_request_handler__' in value;
21+
}

0 commit comments

Comments
 (0)