Skip to content

Commit f630726

Browse files
committedOct 1, 2024·
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 3a3be8b commit f630726

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

+4-12
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

+2-2
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
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
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 { loadEsmModule } from '../load-esm';
12+
import { loadEsmModuleFromMemory } from './load-esm-from-memory';
13+
import { isSsrNodeRequestHandler, isSsrRequestHandler } from './utils';
14+
15+
export const DEFAULT_URL = new URL('http://ng-localhost/');
16+
17+
/**
18+
* Launches a server that handles local requests.
19+
*
20+
* @returns A promise that resolves to the URL of the running server.
21+
*/
22+
export async function launchServer(): Promise<URL> {
23+
const { default: handler } = await loadEsmModuleFromMemory('./server.mjs');
24+
const { createWebRequestFromNodeRequest, writeResponseToNodeResponse } =
25+
await loadEsmModule<typeof import('@angular/ssr/node')>('@angular/ssr/node');
26+
27+
if (!isSsrNodeRequestHandler(handler) && !isSsrRequestHandler(handler)) {
28+
return DEFAULT_URL;
29+
}
30+
31+
const server = createServer((req, res) => {
32+
(async () => {
33+
// handle request
34+
if (isSsrNodeRequestHandler(handler)) {
35+
await handler(req, res, (e) => {
36+
throw e;
37+
});
38+
} else {
39+
const webRes = await handler(createWebRequestFromNodeRequest(req));
40+
if (webRes) {
41+
await writeResponseToNodeResponse(webRes, res);
42+
} else {
43+
res.statusCode = 501;
44+
res.end('Not Implemented.');
45+
}
46+
}
47+
})().catch((e) => {
48+
res.statusCode = 500;
49+
res.end('Internal Server Error.');
50+
// eslint-disable-next-line no-console
51+
console.error(e);
52+
});
53+
});
54+
55+
server.unref();
56+
57+
await new Promise<void>((resolve) => server.listen(0, 'localhost', resolve));
58+
59+
const serverAddress = server.address();
60+
assert(serverAddress, 'Server address should be defined.');
61+
assert(typeof serverAddress !== 'string', 'Server address should not be a string.');
62+
63+
return new URL(`http://localhost:${serverAddress.port}/`);
64+
}

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

+12-1
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

+9-3
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,7 @@ export async function prerenderPages(
165165
outputFilesForWorker,
166166
assetsReversed,
167167
appShellOptions,
168+
outputMode,
168169
);
169170

170171
errors.push(...renderingErrors);
@@ -186,6 +187,7 @@ async function renderPages(
186187
outputFilesForWorker: Record<string, string>,
187188
assetFilesForWorker: Record<string, string>,
188189
appShellOptions: AppShellOptions | undefined,
190+
outputMode: OutputMode | undefined,
189191
): Promise<{
190192
output: PrerenderOutput;
191193
errors: string[];
@@ -210,6 +212,8 @@ async function renderPages(
210212
workspaceRoot,
211213
outputFiles: outputFilesForWorker,
212214
assetFiles: assetFilesForWorker,
215+
outputMode,
216+
hasSsrEntry: !!outputFilesForWorker['/server.mjs'],
213217
} as RenderWorkerData,
214218
execArgv: workerExecArgv,
215219
});
@@ -314,14 +318,16 @@ async function getAllRoutes(
314318
workspaceRoot,
315319
outputFiles: outputFilesForWorker,
316320
assetFiles: assetFilesForWorker,
321+
outputMode,
322+
hasSsrEntry: !!outputFilesForWorker['/server.mjs'],
317323
} as RoutesExtractorWorkerData,
318324
execArgv: workerExecArgv,
319325
});
320326

321327
try {
322-
const { serializedRouteTree, errors }: RoutersExtractorWorkerResult = await renderWorker.run({
323-
outputMode,
324-
});
328+
const { serializedRouteTree, errors }: RoutersExtractorWorkerResult = await renderWorker.run(
329+
{},
330+
);
325331

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

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

+22-3
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,33 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9+
import { workerData } from 'worker_threads';
10+
import type { OutputMode } from '../../builders/application/schema';
911
import type { ESMInMemoryFileLoaderWorkerData } from './esm-in-memory-loader/loader-hooks';
1012
import { patchFetchToLoadInMemoryAssets } from './fetch-patch';
13+
import { DEFAULT_URL, launchServer } from './launch-server';
1114
import { loadEsmModuleFromMemory } from './load-esm-from-memory';
1215

1316
export interface RenderWorkerData extends ESMInMemoryFileLoaderWorkerData {
1417
assetFiles: Record</** Destination */ string, /** Source */ string>;
18+
outputMode: OutputMode | undefined;
19+
hasSsrEntry: boolean;
1520
}
1621

1722
export interface RenderOptions {
1823
url: string;
1924
}
2025

26+
/**
27+
* This is passed as workerData when setting up the worker via the `piscina` package.
28+
*/
29+
const { outputMode, hasSsrEntry } = workerData as {
30+
outputMode: OutputMode | undefined;
31+
hasSsrEntry: boolean;
32+
};
33+
34+
let serverURL = DEFAULT_URL;
35+
2136
/**
2237
* Renders each route in routes and writes them to <outputPath>/<route>/index.html.
2338
*/
@@ -26,15 +41,19 @@ async function renderPage({ url }: RenderOptions): Promise<string | null> {
2641
await loadEsmModuleFromMemory('./main.server.mjs');
2742
const angularServerApp = getOrCreateAngularServerApp();
2843
const response = await angularServerApp.renderStatic(
29-
new URL(url, 'http://local-angular-prerender'),
44+
new URL(url, serverURL),
3045
AbortSignal.timeout(30_000),
3146
);
3247

3348
return response ? response.text() : null;
3449
}
3550

36-
function initialize() {
37-
patchFetchToLoadInMemoryAssets();
51+
async function initialize() {
52+
if (outputMode !== undefined && hasSsrEntry) {
53+
serverURL = await launchServer();
54+
}
55+
56+
patchFetchToLoadInMemoryAssets(serverURL);
3857

3958
return renderPage;
4059
}

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

+23-8
Original file line numberDiff line numberDiff line change
@@ -6,24 +6,35 @@
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 { DEFAULT_URL, 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, hasSsrEntry } = workerData as {
25+
outputMode: OutputMode | undefined;
26+
hasSsrEntry: boolean;
27+
};
28+
29+
let serverURL = DEFAULT_URL;
30+
1831
/** Renders an application based on a provided options. */
19-
async function extractRoutes({
20-
outputMode,
21-
}: ExtractRoutesOptions): Promise<RoutersExtractorWorkerResult> {
32+
async function extractRoutes(): Promise<RoutersExtractorWorkerResult> {
2233
const { ɵextractRoutesAndCreateRouteTree: extractRoutesAndCreateRouteTree } =
2334
await loadEsmModuleFromMemory('./main.server.mjs');
2435

2536
const { routeTree, errors } = await extractRoutesAndCreateRouteTree(
26-
new URL('http://local-angular-prerender/'),
37+
serverURL,
2738
undefined /** manifest */,
2839
true /** invokeGetPrerenderParams */,
2940
outputMode === OutputMode.Server /** includePrerenderFallbackRoutes */,
@@ -35,8 +46,12 @@ async function extractRoutes({
3546
};
3647
}
3748

38-
function initialize() {
39-
patchFetchToLoadInMemoryAssets();
49+
async function initialize() {
50+
if (outputMode !== undefined && hasSsrEntry) {
51+
serverURL = await launchServer();
52+
}
53+
54+
patchFetchToLoadInMemoryAssets(serverURL);
4055

4156
return extractRoutes;
4257
}
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+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import { match } from 'node:assert';
2+
import { readFile, writeMultipleFiles } from '../../utils/fs';
3+
import { noSilentNg, silentNg } from '../../utils/process';
4+
import { setupProjectWithSSRAppEngine } from './setup';
5+
6+
export default async function () {
7+
// Setup project
8+
await setupProjectWithSSRAppEngine();
9+
10+
await writeMultipleFiles({
11+
// Add asset
12+
'public/media.json': JSON.stringify({ dataFromAssets: true }),
13+
// Update component to do an HTTP call to asset and API.
14+
'src/app/app.component.ts': `
15+
import { Component, inject } from '@angular/core';
16+
import { JsonPipe } from '@angular/common';
17+
import { RouterOutlet } from '@angular/router';
18+
import { HttpClient } from '@angular/common/http';
19+
20+
@Component({
21+
selector: 'app-root',
22+
standalone: true,
23+
imports: [JsonPipe, RouterOutlet],
24+
template: \`
25+
<p>{{ assetsData | json }}</p>
26+
<p>{{ apiData | json }}</p>
27+
<router-outlet></router-outlet>
28+
\`,
29+
})
30+
export class AppComponent {
31+
assetsData: any;
32+
apiData: any;
33+
34+
constructor() {
35+
const http = inject(HttpClient);
36+
37+
http.get('/media.json').toPromise().then((d) => {
38+
this.assetsData = d;
39+
});
40+
41+
http.get('/api').toPromise().then((d) => {
42+
this.apiData = d;
43+
});
44+
}
45+
}
46+
`,
47+
// Add http client and route
48+
'src/app/app.config.ts': `
49+
import { ApplicationConfig } from '@angular/core';
50+
import { provideRouter } from '@angular/router';
51+
52+
import { HomeComponent } from './home/home.component';
53+
import { provideClientHydration } from '@angular/platform-browser';
54+
import { provideHttpClient, withFetch } from '@angular/common/http';
55+
56+
export const appConfig: ApplicationConfig = {
57+
providers: [
58+
provideRouter([{
59+
path: 'home',
60+
component: HomeComponent,
61+
}]),
62+
provideClientHydration(),
63+
provideHttpClient(withFetch()),
64+
],
65+
};
66+
`,
67+
'src/app/app.routes.server.ts': `
68+
import { RenderMode, ServerRoute } from '@angular/ssr';
69+
70+
export const routes: ServerRoute[] = [
71+
{
72+
path: '**',
73+
renderMode: RenderMode.Prerender,
74+
},
75+
];
76+
`,
77+
'server.ts': `
78+
import { AngularNodeAppEngine, writeResponseToNodeResponse, isMainModule, createNodeRequestHandler } from '@angular/ssr/node';
79+
import express from 'express';
80+
import { fileURLToPath } from 'node:url';
81+
import { dirname, resolve } from 'node:path';
82+
83+
export function app(): express.Express {
84+
const server = express();
85+
const serverDistFolder = dirname(fileURLToPath(import.meta.url));
86+
const browserDistFolder = resolve(serverDistFolder, '../browser');
87+
const angularNodeAppEngine = new AngularNodeAppEngine();
88+
89+
server.use('/api', (req, res) => res.json({ dataFromAPI: true }));
90+
91+
server.get('**', express.static(browserDistFolder, {
92+
maxAge: '1y',
93+
index: 'index.html'
94+
}));
95+
96+
server.get('**', (req, res, next) => {
97+
angularNodeAppEngine.render(req)
98+
.then((response) => response ? writeResponseToNodeResponse(response, res) : next())
99+
.catch(next);
100+
});
101+
102+
return server;
103+
}
104+
105+
const server = app();
106+
if (isMainModule(import.meta.url)) {
107+
const port = process.env['PORT'] || 4000;
108+
server.listen(port, () => {
109+
console.log(\`Node Express server listening on http://localhost:\${port}\`);
110+
});
111+
}
112+
113+
export default createNodeRequestHandler(server);
114+
`,
115+
});
116+
117+
await silentNg('generate', 'component', 'home');
118+
119+
// Fix the error
120+
await noSilentNg('build', '--output-mode=static');
121+
122+
const contents = await readFile('dist/test-project/browser/home/index.html');
123+
match(contents, /<p>{[\S\s]*"dataFromAssets":[\s\S]*true[\S\s]*}<\/p>/);
124+
match(contents, /<p>{[\S\s]*"dataFromAPI":[\s\S]*true[\S\s]*}<\/p>/);
125+
match(contents, /home works!/);
126+
}

0 commit comments

Comments
 (0)
Please sign in to comment.