Skip to content

Commit

Permalink
feat(@angular/ssr): add createRequestHandler and `createNodeRequest…
Browse files Browse the repository at this point in the history
…Handler `utilities

Introduced the `createRequestHandler` and `createNodeRequestHandler` utilities to expose middleware functions from the `server.ts` entry point for use with Vite.
This provides flexibility in integrating different server frameworks, including Express, Hono, and Fastify, with Angular SSR.

Examples:

**Express**
```ts
export default createNodeRequestHandler(app);
```

**Nest.js**
```ts
const app = await NestFactory.create(AppModule);
export default createNodeRequestHandler(app);
```

**Hono**
```ts
const app = new Hono();
export default createRequestHandler(app.fetch);
```

**Fastify**
```ts
export default createNodeRequestHandler(async (req, res) => {
  await app.ready();
  app.server.emit('request', req, res);
});
```
  • Loading branch information
alan-agius4 committed Sep 23, 2024
1 parent bbc2901 commit 92209dd
Show file tree
Hide file tree
Showing 12 changed files with 326 additions and 38 deletions.
3 changes: 3 additions & 0 deletions goldens/public-api/angular/ssr/index.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ export class AngularAppEngine {
static ɵhooks: Hooks;
}

// @public
export function createRequestHandler(handler: RequestHandlerFunction): RequestHandlerFunction;

// @public
export enum PrerenderFallback {
Client = 1,
Expand Down
3 changes: 3 additions & 0 deletions goldens/public-api/angular/ssr/node/index.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ export interface CommonEngineRenderOptions {
url?: string;
}

// @public
export function createNodeRequestHandler<T extends RequestHandlerFunction>(handler: T): T;

// @public
export function createWebRequestFromNodeRequest(nodeRequest: IncomingMessage): Request;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@

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';
Expand All @@ -29,10 +31,6 @@ export function createAngularSsrInternalMiddleware(
return next();
}

const resolvedUrls = server.resolvedUrls;
const baseUrl = resolvedUrls?.local[0] ?? resolvedUrls?.network[0];
const url = new URL(req.url, baseUrl);

(async () => {
const { writeResponseToNodeResponse, createWebRequestFromNodeRequest } =
await loadEsmModule<typeof import('@angular/ssr/node')>('@angular/ssr/node');
Expand Down Expand Up @@ -66,16 +64,19 @@ export function createAngularSsrInternalMiddleware(
};
}

export function createAngularSsrExternalMiddleware(
export async function createAngularSsrExternalMiddleware(
server: ViteDevServer,
indexHtmlTransformer?: (content: string) => Promise<string>,
): Connect.NextHandleFunction {
): Promise<Connect.NextHandleFunction> {
let fallbackWarningShown = false;
let cachedAngularAppEngine: typeof SSRAngularAppEngine | undefined;
let angularSsrInternalMiddleware:
| ReturnType<typeof createAngularSsrInternalMiddleware>
| undefined;

const { createWebRequestFromNodeRequest, writeResponseToNodeResponse } =
await loadEsmModule<typeof import('@angular/ssr/node')>('@angular/ssr/node');

return function angularSsrExternalMiddleware(
req: Connect.IncomingMessage,
res: ServerResponse,
Expand All @@ -89,7 +90,7 @@ export function createAngularSsrExternalMiddleware(
AngularAppEngine: typeof SSRAngularAppEngine;
};

if (typeof handler !== 'function' || !('__ng_node_next_handler__' in handler)) {
if (!isSsrNodeRequestHandler(handler) && !isSsrRequestHandler(handler)) {
if (!fallbackWarningShown) {
// eslint-disable-next-line no-console
console.warn(
Expand All @@ -104,7 +105,9 @@ export function createAngularSsrExternalMiddleware(
indexHtmlTransformer,
);

return angularSsrInternalMiddleware(req, res, next);
angularSsrInternalMiddleware(req, res, next);

return;
}

if (cachedAngularAppEngine !== AngularAppEngine) {
Expand All @@ -118,7 +121,28 @@ export function createAngularSsrExternalMiddleware(
}

// Forward the request to the middleware in server.ts
return (handler as unknown as Connect.NextHandleFunction)(req, res, next);
if (isSsrNodeRequestHandler(handler)) {
await handler(req, res, next);
} else {
const webRes = await handler(createWebRequestFromNodeRequest(req));
if (!webRes) {
next();

return;
}

await writeResponseToNodeResponse(webRes, res);
}
})().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 @@ -77,9 +77,12 @@ export function createAngularSetupMiddlewaresPlugin(

// Returning a function, installs middleware after the main transform middleware but
// before the built-in HTML middleware
return () => {
// eslint-disable-next-line @typescript-eslint/no-misused-promises
return async () => {
if (ssrMode === ServerSsrMode.ExternalSsrMiddleware) {
server.middlewares.use(createAngularSsrExternalMiddleware(server, indexHtmlTransformer));
server.middlewares.use(
await createAngularSsrExternalMiddleware(server, indexHtmlTransformer),
);

return;
}
Expand Down
1 change: 1 addition & 0 deletions packages/angular/ssr/node/public_api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export {

export { AngularNodeAppEngine } from './src/app-engine';

export { createNodeRequestHandler } from './src/handler';
export { writeResponseToNodeResponse } from './src/response';
export { createWebRequestFromNodeRequest } from './src/request';
export { isMainModule } from './src/module';
74 changes: 74 additions & 0 deletions packages/angular/ssr/node/src/handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/**
* @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 { IncomingMessage, ServerResponse } from 'node:http';

/**
* Represents a middleware function for handling HTTP requests in a Node.js environment.
*
* @param req - The incoming HTTP request object.
* @param res - The outgoing HTTP response object.
* @param next - A callback function that signals the completion of the middleware or forwards the error if provided.
*
* @returns A Promise that resolves to void or simply void. The handler can be asynchronous.
*/
type RequestHandlerFunction = (
req: IncomingMessage,
res: ServerResponse,
next: (err?: unknown) => void,
) => Promise<void> | void;

/**
* Attaches metadata to the handler function to mark it as a special handler for Node.js environments.
*
* @typeParam T - The type of the handler function.
* @param handler - The handler function to be defined and annotated.
* @returns The same handler function passed as an argument, with metadata attached.
*
* @example
* Usage in an Express application:
* ```ts
* const app = express();
* export default createNodeRequestHandler(app);
* ```
*
* @example
* Usage in a Hono application:
* ```ts
* const app = new Hono();
* export default createNodeRequestHandler(async (req, res, next) => {
* try {
* const webRes = await app.fetch(createWebRequestFromNodeRequest(req));
* if (webRes) {
* await writeResponseToNodeResponse(webRes, res);
* } else {
* next();
* }
* } catch (error) {
* next(error);
* }
* }));
* ```
*
* @example
* Usage in a Fastify application:
* ```ts
* const app = Fastify();
* export default createNodeRequestHandler(async (req, res) => {
* await app.ready();
* app.server.emit('request', req, res);
* res.send('Hello from Fastify with Node Next Handler!');
* }));
* ```
* @developerPreview
*/
export function createNodeRequestHandler<T extends RequestHandlerFunction>(handler: T): T {
(handler as T & { __ng_node_request_handler__?: boolean })['__ng_node_request_handler__'] = true;

return handler;
}
1 change: 1 addition & 0 deletions packages/angular/ssr/public_api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
export * from './private_export';

export { AngularAppEngine } from './src/app-engine';
export { createRequestHandler } from './src/handler';

export {
type PrerenderFallback,
Expand Down
47 changes: 47 additions & 0 deletions packages/angular/ssr/src/handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/**
* @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
*/

/**
* Function for handling HTTP requests in a web environment.
*
* @param request - The incoming HTTP request object.
* @returns A Promise resolving to a `Response` object, `null`, or directly a `Response`,
* supporting both synchronous and asynchronous handling.
*/
type RequestHandlerFunction = (request: Request) => Promise<Response | null> | null | Response;

/**
* Annotates a request handler function with metadata, marking it as a special
* handler.
*
* @param handler - The request handler function to be annotated.
* @returns The same handler function passed in, with metadata attached.
*
* @example
* Example usage in a Hono application:
* ```ts
* const app = new Hono();
* export default createRequestHandler(app.fetch);
* ```
*
* @example
* Example usage in a H3 application:
* ```ts
* const app = createApp();
* const handler = toWebHandler(app);
* export default createRequestHandler(handler);
* ```
* @developerPreview
*/
export function createRequestHandler(handler: RequestHandlerFunction): RequestHandlerFunction {
(handler as RequestHandlerFunction & { __ng_request_handler__?: boolean })[
'__ng_request_handler__'
] = true;

return handler;
}
13 changes: 8 additions & 5 deletions tests/legacy-cli/e2e/tests/vite/ssr-entry-express.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import assert from 'node:assert';
import { setTimeout } from 'node:timers/promises';
import { replaceInFile, writeMultipleFiles } from '../../utils/fs';
import { ng, silentNg, waitForAnyProcessOutputToMatch } from '../../utils/process';
import { installWorkspacePackages, uninstallPackage } from '../../utils/packages';
Expand Down Expand Up @@ -59,7 +60,7 @@ export default async function () {
];
`,
'server.ts': `
import { AngularNodeAppEngine, writeResponseToNodeResponse, isMainModule, defineNodeNextHandler } from '@angular/ssr/node';
import { AngularNodeAppEngine, writeResponseToNodeResponse, isMainModule, createNodeRequestHandler } from '@angular/ssr/node';
import express from 'express';
import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path';
Expand Down Expand Up @@ -94,7 +95,7 @@ export default async function () {
});
}
export default defineNodeNextHandler(server);
export default createNodeRequestHandler(server);
`,
});

Expand All @@ -121,7 +122,7 @@ export default async function () {
await validateResponse('/api/test', /bar/);
await validateResponse('/home', /yay home works/);

async function validateResponse(pathname: string, match: RegExp) {
async function validateResponse(pathname: string, match: RegExp): Promise<void> {
const response = await fetch(new URL(pathname, `http://localhost:${port}`));
const text = await response.text();
assert.match(text, match);
Expand All @@ -133,9 +134,11 @@ async function modifyFileAndWaitUntilUpdated(
filePath: string,
searchValue: string,
replaceValue: string,
) {
): Promise<void> {
await Promise.all([
waitForAnyProcessOutputToMatch(/Application bundle generation complete./),
waitForAnyProcessOutputToMatch(/Page reload sent to client/),
replaceInFile(filePath, searchValue, replaceValue),
]);

await setTimeout(200);
}
13 changes: 8 additions & 5 deletions tests/legacy-cli/e2e/tests/vite/ssr-entry-fastify.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import assert from 'node:assert';
import { setTimeout } from 'node:timers/promises';
import { replaceInFile, writeMultipleFiles } from '../../utils/fs';
import { ng, silentNg, waitForAnyProcessOutputToMatch } from '../../utils/process';
import { installPackage, installWorkspacePackages, uninstallPackage } from '../../utils/packages';
Expand Down Expand Up @@ -60,7 +61,7 @@ export default async function () {
];
`,
'server.ts': `
import { AngularNodeAppEngine, writeResponseToNodeResponse, isMainModule, defineNodeNextHandler } from '@angular/ssr/node';
import { AngularNodeAppEngine, writeResponseToNodeResponse, isMainModule, createNodeRequestHandler } from '@angular/ssr/node';
import fastify from 'fastify';
export function app() {
Expand Down Expand Up @@ -91,7 +92,7 @@ export default async function () {
});
}
export default defineNodeNextHandler(async (req, res) => {
export default createNodeRequestHandler(async (req, res) => {
await server.ready();
server.server.emit('request', req, res);
});
Expand Down Expand Up @@ -121,7 +122,7 @@ export default async function () {
await validateResponse('/api/test', /bar/);
await validateResponse('/home', /yay home works/);

async function validateResponse(pathname: string, match: RegExp) {
async function validateResponse(pathname: string, match: RegExp): Promise<void> {
const response = await fetch(new URL(pathname, `http://localhost:${port}`));
const text = await response.text();
assert.match(text, match);
Expand All @@ -133,9 +134,11 @@ async function modifyFileAndWaitUntilUpdated(
filePath: string,
searchValue: string,
replaceValue: string,
) {
): Promise<void> {
await Promise.all([
waitForAnyProcessOutputToMatch(/Application bundle generation complete./),
waitForAnyProcessOutputToMatch(/Page reload sent to client/),
replaceInFile(filePath, searchValue, replaceValue),
]);

await setTimeout(200);
}
Loading

0 comments on commit 92209dd

Please sign in to comment.