From 92209dd2e93af450e3fc657609efe95c6a6b3963 Mon Sep 17 00:00:00 2001 From: Alan Agius Date: Mon, 23 Sep 2024 08:04:13 +0000 Subject: [PATCH] feat(@angular/ssr): add `createRequestHandler` and `createNodeRequestHandler `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); }); ``` --- goldens/public-api/angular/ssr/index.api.md | 3 + .../public-api/angular/ssr/node/index.api.md | 3 + .../tools/vite/middlewares/ssr-middleware.ts | 42 ++++-- .../tools/vite/setup-middlewares-plugin.ts | 7 +- packages/angular/ssr/node/public_api.ts | 1 + packages/angular/ssr/node/src/handler.ts | 74 ++++++++++ packages/angular/ssr/public_api.ts | 1 + packages/angular/ssr/src/handler.ts | 47 ++++++ .../e2e/tests/vite/ssr-entry-express.ts | 13 +- .../e2e/tests/vite/ssr-entry-fastify.ts | 13 +- .../legacy-cli/e2e/tests/vite/ssr-entry-h3.ts | 135 ++++++++++++++++++ .../e2e/tests/vite/ssr-entry-hono.ts | 25 ++-- 12 files changed, 326 insertions(+), 38 deletions(-) create mode 100644 packages/angular/ssr/node/src/handler.ts create mode 100644 packages/angular/ssr/src/handler.ts create mode 100644 tests/legacy-cli/e2e/tests/vite/ssr-entry-h3.ts diff --git a/goldens/public-api/angular/ssr/index.api.md b/goldens/public-api/angular/ssr/index.api.md index 3c9501138fb5..840e3c987b3f 100644 --- a/goldens/public-api/angular/ssr/index.api.md +++ b/goldens/public-api/angular/ssr/index.api.md @@ -13,6 +13,9 @@ export class AngularAppEngine { static ɵhooks: Hooks; } +// @public +export function createRequestHandler(handler: RequestHandlerFunction): RequestHandlerFunction; + // @public export enum PrerenderFallback { Client = 1, diff --git a/goldens/public-api/angular/ssr/node/index.api.md b/goldens/public-api/angular/ssr/node/index.api.md index a15378457b4d..84fb4f129929 100644 --- a/goldens/public-api/angular/ssr/node/index.api.md +++ b/goldens/public-api/angular/ssr/node/index.api.md @@ -43,6 +43,9 @@ export interface CommonEngineRenderOptions { url?: string; } +// @public +export function createNodeRequestHandler(handler: T): T; + // @public export function createWebRequestFromNodeRequest(nodeRequest: IncomingMessage): Request; 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 61ba6590c90b..e8b0a954e84b 100644 --- a/packages/angular/build/src/tools/vite/middlewares/ssr-middleware.ts +++ b/packages/angular/build/src/tools/vite/middlewares/ssr-middleware.ts @@ -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'; @@ -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('@angular/ssr/node'); @@ -66,16 +64,19 @@ export function createAngularSsrInternalMiddleware( }; } -export function createAngularSsrExternalMiddleware( +export async function createAngularSsrExternalMiddleware( server: ViteDevServer, indexHtmlTransformer?: (content: string) => Promise, -): Connect.NextHandleFunction { +): Promise { let fallbackWarningShown = false; let cachedAngularAppEngine: typeof SSRAngularAppEngine | undefined; let angularSsrInternalMiddleware: | ReturnType | undefined; + const { createWebRequestFromNodeRequest, writeResponseToNodeResponse } = + await loadEsmModule('@angular/ssr/node'); + return function angularSsrExternalMiddleware( req: Connect.IncomingMessage, res: ServerResponse, @@ -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( @@ -104,7 +105,9 @@ export function createAngularSsrExternalMiddleware( indexHtmlTransformer, ); - return angularSsrInternalMiddleware(req, res, next); + angularSsrInternalMiddleware(req, res, next); + + return; } if (cachedAngularAppEngine !== AngularAppEngine) { @@ -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 { + 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/tools/vite/setup-middlewares-plugin.ts b/packages/angular/build/src/tools/vite/setup-middlewares-plugin.ts index c753ccf123b3..d7b6e6fa870a 100644 --- a/packages/angular/build/src/tools/vite/setup-middlewares-plugin.ts +++ b/packages/angular/build/src/tools/vite/setup-middlewares-plugin.ts @@ -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; } diff --git a/packages/angular/ssr/node/public_api.ts b/packages/angular/ssr/node/public_api.ts index 721e6a4cf215..a08d88d2738e 100644 --- a/packages/angular/ssr/node/public_api.ts +++ b/packages/angular/ssr/node/public_api.ts @@ -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'; diff --git a/packages/angular/ssr/node/src/handler.ts b/packages/angular/ssr/node/src/handler.ts new file mode 100644 index 000000000000..40fedb46e0f6 --- /dev/null +++ b/packages/angular/ssr/node/src/handler.ts @@ -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; + +/** + * 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(handler: T): T { + (handler as T & { __ng_node_request_handler__?: boolean })['__ng_node_request_handler__'] = true; + + return handler; +} diff --git a/packages/angular/ssr/public_api.ts b/packages/angular/ssr/public_api.ts index 86856b065309..256a0169c89b 100644 --- a/packages/angular/ssr/public_api.ts +++ b/packages/angular/ssr/public_api.ts @@ -9,6 +9,7 @@ export * from './private_export'; export { AngularAppEngine } from './src/app-engine'; +export { createRequestHandler } from './src/handler'; export { type PrerenderFallback, diff --git a/packages/angular/ssr/src/handler.ts b/packages/angular/ssr/src/handler.ts new file mode 100644 index 000000000000..16425ff3ccb2 --- /dev/null +++ b/packages/angular/ssr/src/handler.ts @@ -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 | 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; +} diff --git a/tests/legacy-cli/e2e/tests/vite/ssr-entry-express.ts b/tests/legacy-cli/e2e/tests/vite/ssr-entry-express.ts index 86803a71f2e0..37166e8c2a12 100644 --- a/tests/legacy-cli/e2e/tests/vite/ssr-entry-express.ts +++ b/tests/legacy-cli/e2e/tests/vite/ssr-entry-express.ts @@ -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'; @@ -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'; @@ -94,7 +95,7 @@ export default async function () { }); } - export default defineNodeNextHandler(server); + export default createNodeRequestHandler(server); `, }); @@ -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 { const response = await fetch(new URL(pathname, `http://localhost:${port}`)); const text = await response.text(); assert.match(text, match); @@ -133,9 +134,11 @@ async function modifyFileAndWaitUntilUpdated( filePath: string, searchValue: string, replaceValue: string, -) { +): Promise { await Promise.all([ - waitForAnyProcessOutputToMatch(/Application bundle generation complete./), + waitForAnyProcessOutputToMatch(/Page reload sent to client/), replaceInFile(filePath, searchValue, replaceValue), ]); + + await setTimeout(200); } diff --git a/tests/legacy-cli/e2e/tests/vite/ssr-entry-fastify.ts b/tests/legacy-cli/e2e/tests/vite/ssr-entry-fastify.ts index ea83cd0ed6d7..f1de8f471b8a 100644 --- a/tests/legacy-cli/e2e/tests/vite/ssr-entry-fastify.ts +++ b/tests/legacy-cli/e2e/tests/vite/ssr-entry-fastify.ts @@ -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'; @@ -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() { @@ -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); }); @@ -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 { const response = await fetch(new URL(pathname, `http://localhost:${port}`)); const text = await response.text(); assert.match(text, match); @@ -133,9 +134,11 @@ async function modifyFileAndWaitUntilUpdated( filePath: string, searchValue: string, replaceValue: string, -) { +): Promise { await Promise.all([ - waitForAnyProcessOutputToMatch(/Application bundle generation complete./), + waitForAnyProcessOutputToMatch(/Page reload sent to client/), replaceInFile(filePath, searchValue, replaceValue), ]); + + await setTimeout(200); } diff --git a/tests/legacy-cli/e2e/tests/vite/ssr-entry-h3.ts b/tests/legacy-cli/e2e/tests/vite/ssr-entry-h3.ts new file mode 100644 index 000000000000..7f364ecd3d3c --- /dev/null +++ b/tests/legacy-cli/e2e/tests/vite/ssr-entry-h3.ts @@ -0,0 +1,135 @@ +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'; +import { ngServe, updateJsonFile, useSha } from '../../utils/project'; +import { getGlobalVariable } from '../../utils/env'; + +export default async function () { + assert( + getGlobalVariable('argv')['esbuild'], + 'This test should not be called in the Webpack suite.', + ); + + // Forcibly remove in case another test doesn't clean itself up. + await uninstallPackage('@angular/ssr'); + await ng('add', '@angular/ssr', '--skip-confirmation', '--skip-install'); + await useSha(); + await installWorkspacePackages(); + await installPackage('h3@1'); + + // Update angular.json + await updateJsonFile('angular.json', (workspaceJson) => { + const appArchitect = workspaceJson.projects['test-project'].architect; + const options = appArchitect.build.options; + options.outputMode = 'server'; + }); + + await writeMultipleFiles({ + // Replace the template of app.component.html as it makes it harder to debug + 'src/app/app.component.html': '', + 'src/app/app.config.server.ts': ` + import { mergeApplicationConfig, ApplicationConfig } from '@angular/core'; + import { provideServerRendering } from '@angular/platform-server'; + import { provideServerRoutesConfig } from '@angular/ssr'; + import { routes } from './app.routes.server'; + import { appConfig } from './app.config'; + + const serverConfig: ApplicationConfig = { + providers: [ + provideServerRoutesConfig(routes), + provideServerRendering() + ] + }; + + export const config = mergeApplicationConfig(appConfig, serverConfig); + `, + 'src/app/app.routes.ts': ` + import { Routes } from '@angular/router'; + import { HomeComponent } from './home/home.component'; + + export const routes: Routes = [ + { path: 'home', component: HomeComponent } + ]; + `, + 'src/app/app.routes.server.ts': ` + import { RenderMode, ServerRoute } from '@angular/ssr'; + + export const routes: ServerRoute[] = [ + { path: '**', renderMode: RenderMode.Server } + ]; + `, + 'server.ts': ` + import { AngularAppEngine, createRequestHandler } from '@angular/ssr'; + import { createApp, createRouter, toWebHandler, defineEventHandler, toWebRequest } from 'h3'; + + export function app() { + const server = createApp(); + const router = createRouter(); + const angularAppEngine = new AngularAppEngine(); + + router.use( + '/api/**', + defineEventHandler(() => ({ hello: 'foo' })), + ); + + router.use( + '/**', + defineEventHandler((event) => angularAppEngine.render(toWebRequest(event))), + ); + + server.use(router); + + return server; + } + + const server = app(); + const handler = toWebHandler(server); + export default createRequestHandler(handler); + `, + }); + + await silentNg('generate', 'component', 'home'); + + const port = await ngServe(); + + // Verify the server is running and the API response is correct. + await validateResponse('/main.js', /bootstrapApplication/); + await validateResponse('/api/test', /foo/); + await validateResponse('/home', /home works/); + + // Modify the home component and validate the change. + await modifyFileAndWaitUntilUpdated( + 'src/app/home/home.component.html', + 'home works', + 'yay home works!!!', + ); + await validateResponse('/api/test', /foo/); + await validateResponse('/home', /yay home works/); + + // Modify the API response and validate the change. + await modifyFileAndWaitUntilUpdated('server.ts', `{ hello: 'foo' }`, `{ hello: 'bar' }`); + await validateResponse('/api/test', /bar/); + await validateResponse('/home', /yay home works/); + + async function validateResponse(pathname: string, match: RegExp): Promise { + const response = await fetch(new URL(pathname, `http://localhost:${port}`)); + const text = await response.text(); + assert.match(text, match); + assert.equal(response.status, 200); + } +} + +async function modifyFileAndWaitUntilUpdated( + filePath: string, + searchValue: string, + replaceValue: string, +): Promise { + await Promise.all([ + waitForAnyProcessOutputToMatch(/Page reload sent to client/), + replaceInFile(filePath, searchValue, replaceValue), + ]); + + await setTimeout(200); +} diff --git a/tests/legacy-cli/e2e/tests/vite/ssr-entry-hono.ts b/tests/legacy-cli/e2e/tests/vite/ssr-entry-hono.ts index 18aad82e78ae..83c099db9426 100644 --- a/tests/legacy-cli/e2e/tests/vite/ssr-entry-hono.ts +++ b/tests/legacy-cli/e2e/tests/vite/ssr-entry-hono.ts @@ -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'; @@ -60,8 +61,7 @@ export default async function () { ]; `, 'server.ts': ` - import { AngularAppEngine } from '@angular/ssr'; - import { createWebRequestFromNodeRequest, writeResponseToNodeResponse, defineNodeNextHandler } from '@angular/ssr/node'; + import { AngularAppEngine, createRequestHandler } from '@angular/ssr'; import { Hono } from 'hono'; export function app() { @@ -78,18 +78,7 @@ export default async function () { } const server = app(); - export default defineNodeNextHandler(async (req, res, next) => { - try { - const webRes = await server.fetch(createWebRequestFromNodeRequest(req)); - if (webRes) { - await writeResponseToNodeResponse(webRes, res); - } else { - next(); - } - } catch (error) { - next(error); - } - }); + export default createRequestHandler(server.fetch); `, }); @@ -116,7 +105,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 { const response = await fetch(new URL(pathname, `http://localhost:${port}`)); const text = await response.text(); assert.match(text, match); @@ -128,9 +117,11 @@ async function modifyFileAndWaitUntilUpdated( filePath: string, searchValue: string, replaceValue: string, -) { +): Promise { await Promise.all([ - waitForAnyProcessOutputToMatch(/Application bundle generation complete./), + waitForAnyProcessOutputToMatch(/Page reload sent to client/), replaceInFile(filePath, searchValue, replaceValue), ]); + + await setTimeout(200); }