Skip to content

Commit

Permalink
feat(@angular/build): utilize ssr.entry during prerendering to enab…
Browse files Browse the repository at this point in the history
…le 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.
  • Loading branch information
alan-agius4 committed Oct 1, 2024
1 parent 3a3be8b commit f630726
Show file tree
Hide file tree
Showing 9 changed files with 283 additions and 29 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -136,13 +138,3 @@ export async function createAngularSsrExternalMiddleware(
})().catch(next);
};
}

function isSsrNodeRequestHandler(
value: unknown,
): value is ReturnType<typeof createNodeRequestHandler> {
return typeof value === 'function' && '__ng_node_request_handler__' in value;
}

function isSsrRequestHandler(value: unknown): value is ReturnType<typeof createRequestHandler> {
return typeof value === 'function' && '__ng_request_handler__' in value;
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ const { assetFiles } = workerData as {
const assetsCache: Map<string, { headers: undefined | Record<string, string>; 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;
Expand All @@ -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);
}
Expand Down
64 changes: 64 additions & 0 deletions packages/angular/build/src/utils/server-rendering/launch-server.ts
Original file line number Diff line number Diff line change
@@ -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<URL> {
const { default: handler } = await loadEsmModuleFromMemory('./server.mjs');
const { createWebRequestFromNodeRequest, writeResponseToNodeResponse } =
await loadEsmModule<typeof import('@angular/ssr/node')>('@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<void>((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}/`);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<MainServerBundleExports> {
): Promise<MainServerBundleExports>;
export function loadEsmModuleFromMemory(path: './server.mjs'): Promise<ServerBundleExports>;
export function loadEsmModuleFromMemory(
path: './main.server.mjs' | './server.mjs',
): Promise<MainServerBundleExports | ServerBundleExports> {
return loadEsmModule(new URL(path, 'memory://')).catch((e) => {
assertIsError(e);

Expand Down
12 changes: 9 additions & 3 deletions packages/angular/build/src/utils/server-rendering/prerender.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@ export async function prerenderPages(
outputFilesForWorker,
assetsReversed,
appShellOptions,
outputMode,
);

errors.push(...renderingErrors);
Expand All @@ -186,6 +187,7 @@ async function renderPages(
outputFilesForWorker: Record<string, string>,
assetFilesForWorker: Record<string, string>,
appShellOptions: AppShellOptions | undefined,
outputMode: OutputMode | undefined,
): Promise<{
output: PrerenderOutput;
errors: string[];
Expand All @@ -210,6 +212,8 @@ async function renderPages(
workspaceRoot,
outputFiles: outputFilesForWorker,
assetFiles: assetFilesForWorker,
outputMode,
hasSsrEntry: !!outputFilesForWorker['/server.mjs'],
} as RenderWorkerData,
execArgv: workerExecArgv,
});
Expand Down Expand Up @@ -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) {
Expand Down
25 changes: 22 additions & 3 deletions packages/angular/build/src/utils/server-rendering/render-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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</** Destination */ string, /** Source */ string>;
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 <outputPath>/<route>/index.html.
*/
Expand All @@ -26,15 +41,19 @@ async function renderPage({ url }: RenderOptions): Promise<string | null> {
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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<RoutersExtractorWorkerResult> {
async function extractRoutes(): Promise<RoutersExtractorWorkerResult> {
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 */,
Expand All @@ -35,8 +46,12 @@ async function extractRoutes({
};
}

function initialize() {
patchFetchToLoadInMemoryAssets();
async function initialize() {
if (outputMode !== undefined && hasSsrEntry) {
serverURL = await launchServer();
}

patchFetchToLoadInMemoryAssets(serverURL);

return extractRoutes;
}
Expand Down
21 changes: 21 additions & 0 deletions packages/angular/build/src/utils/server-rendering/utils.ts
Original file line number Diff line number Diff line change
@@ -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<typeof createNodeRequestHandler> {
return typeof value === 'function' && '__ng_node_request_handler__' in value;
}
export function isSsrRequestHandler(
value: unknown,
): value is ReturnType<typeof createRequestHandler> {
return typeof value === 'function' && '__ng_request_handler__' in value;
}
Loading

0 comments on commit f630726

Please sign in to comment.