From c25a30b57c705f75a4135f6047234e1f9419fc40 Mon Sep 17 00:00:00 2001 From: Alan Agius Date: Thu, 7 Nov 2024 18:28:26 +0000 Subject: [PATCH] fix(@angular/ssr): support for HTTP/2 request/response handling This commit introduces support for handling HTTP/2 requests and responses in the `@angular/ssr` package. Closes #28807 --- .../public-api/angular/ssr/node/index.api.md | 8 +- packages/angular/ssr/node/src/app-engine.ts | 11 +- packages/angular/ssr/node/src/request.ts | 30 +++- packages/angular/ssr/node/src/response.ts | 13 +- ...{request_spec.ts => request_http1_spec.ts} | 2 +- .../ssr/node/test/request_http2_spec.ts | 152 ++++++++++++++++++ ...esponse_spec.ts => response_http1_spec.ts} | 2 +- .../ssr/node/test/response_http2_spec.ts | 119 ++++++++++++++ .../e2e/tests/vite/ssr-entry-express.ts | 9 +- .../e2e/tests/vite/ssr-entry-fastify.ts | 9 +- .../legacy-cli/e2e/tests/vite/ssr-entry-h3.ts | 9 +- .../e2e/tests/vite/ssr-entry-hono.ts | 7 - .../legacy-cli/e2e/tests/vite/ssr-with-ssl.ts | 59 +++++++ 13 files changed, 383 insertions(+), 47 deletions(-) rename packages/angular/ssr/node/test/{request_spec.ts => request_http1_spec.ts} (98%) create mode 100644 packages/angular/ssr/node/test/request_http2_spec.ts rename packages/angular/ssr/node/test/{response_spec.ts => response_http1_spec.ts} (98%) create mode 100644 packages/angular/ssr/node/test/response_http2_spec.ts create mode 100644 tests/legacy-cli/e2e/tests/vite/ssr-with-ssl.ts diff --git a/goldens/public-api/angular/ssr/node/index.api.md b/goldens/public-api/angular/ssr/node/index.api.md index 9b196f0bfd41..6488d0f363dd 100644 --- a/goldens/public-api/angular/ssr/node/index.api.md +++ b/goldens/public-api/angular/ssr/node/index.api.md @@ -5,6 +5,8 @@ ```ts import { ApplicationRef } from '@angular/core'; +import type { Http2ServerRequest } from 'node:http2'; +import type { Http2ServerResponse } from 'node:http2'; import type { IncomingMessage } from 'node:http'; import type { ServerResponse } from 'node:http'; import { StaticProvider } from '@angular/core'; @@ -12,7 +14,7 @@ import { Type } from '@angular/core'; // @public export class AngularNodeAppEngine { - handle(request: IncomingMessage, requestContext?: unknown): Promise; + handle(request: IncomingMessage | Http2ServerRequest, requestContext?: unknown): Promise; } // @public @@ -46,7 +48,7 @@ export interface CommonEngineRenderOptions { export function createNodeRequestHandler(handler: T): T; // @public -export function createWebRequestFromNodeRequest(nodeRequest: IncomingMessage): Request; +export function createWebRequestFromNodeRequest(nodeRequest: IncomingMessage | Http2ServerRequest): Request; // @public export function isMainModule(url: string): boolean; @@ -55,7 +57,7 @@ export function isMainModule(url: string): boolean; export type NodeRequestHandlerFunction = (req: IncomingMessage, res: ServerResponse, next: (err?: unknown) => void) => Promise | void; // @public -export function writeResponseToNodeResponse(source: Response, destination: ServerResponse): Promise; +export function writeResponseToNodeResponse(source: Response, destination: ServerResponse | Http2ServerResponse): Promise; // (No @packageDocumentation comment for this package) diff --git a/packages/angular/ssr/node/src/app-engine.ts b/packages/angular/ssr/node/src/app-engine.ts index 7a4b4ad25050..a59502ee47a8 100644 --- a/packages/angular/ssr/node/src/app-engine.ts +++ b/packages/angular/ssr/node/src/app-engine.ts @@ -8,6 +8,7 @@ import { AngularAppEngine } from '@angular/ssr'; import type { IncomingMessage } from 'node:http'; +import type { Http2ServerRequest } from 'node:http2'; import { createWebRequestFromNodeRequest } from './request'; /** @@ -27,14 +28,20 @@ export class AngularNodeAppEngine { * Handles an incoming HTTP request by serving prerendered content, performing server-side rendering, * or delivering a static file for client-side rendered routes based on the `RenderMode` setting. * - * @param request - The HTTP request to handle. + * This method adapts Node.js's `IncomingMessage` or `Http2ServerRequest` + * to a format compatible with the `AngularAppEngine` and delegates the handling logic to it. + * + * @param request - The incoming HTTP request (`IncomingMessage` or `Http2ServerRequest`). * @param requestContext - Optional context for rendering, such as metadata associated with the request. * @returns A promise that resolves to the resulting HTTP response object, or `null` if no matching Angular route is found. * * @remarks A request to `https://www.example.com/page/index.html` will serve or render the Angular route * corresponding to `https://www.example.com/page`. */ - async handle(request: IncomingMessage, requestContext?: unknown): Promise { + async handle( + request: IncomingMessage | Http2ServerRequest, + requestContext?: unknown, + ): Promise { const webRequest = createWebRequestFromNodeRequest(request); return this.angularAppEngine.handle(webRequest, requestContext); diff --git a/packages/angular/ssr/node/src/request.ts b/packages/angular/ssr/node/src/request.ts index 38433781026c..990a3100df05 100644 --- a/packages/angular/ssr/node/src/request.ts +++ b/packages/angular/ssr/node/src/request.ts @@ -7,15 +7,31 @@ */ import type { IncomingHttpHeaders, IncomingMessage } from 'node:http'; +import type { Http2ServerRequest } from 'node:http2'; /** - * Converts a Node.js `IncomingMessage` into a Web Standard `Request`. + * A set containing all the pseudo-headers defined in the HTTP/2 specification. * - * @param nodeRequest - The Node.js `IncomingMessage` object to convert. + * This set can be used to filter out pseudo-headers from a list of headers, + * as they are not allowed to be set directly using the `Node.js` Undici API or + * the web `Headers` API. + */ +const HTTP2_PSEUDO_HEADERS = new Set([':method', ':scheme', ':authority', ':path', ':status']); + +/** + * Converts a Node.js `IncomingMessage` or `Http2ServerRequest` into a + * Web Standard `Request` object. + * + * This function adapts the Node.js request objects to a format that can + * be used by web platform APIs. + * + * @param nodeRequest - The Node.js request object (`IncomingMessage` or `Http2ServerRequest`) to convert. * @returns A Web Standard `Request` object. * @developerPreview */ -export function createWebRequestFromNodeRequest(nodeRequest: IncomingMessage): Request { +export function createWebRequestFromNodeRequest( + nodeRequest: IncomingMessage | Http2ServerRequest, +): Request { const { headers, method = 'GET' } = nodeRequest; const withBody = method !== 'GET' && method !== 'HEAD'; @@ -37,6 +53,10 @@ function createRequestHeaders(nodeHeaders: IncomingHttpHeaders): Headers { const headers = new Headers(); for (const [name, value] of Object.entries(nodeHeaders)) { + if (HTTP2_PSEUDO_HEADERS.has(name)) { + continue; + } + if (typeof value === 'string') { headers.append(name, value); } else if (Array.isArray(value)) { @@ -52,10 +72,10 @@ function createRequestHeaders(nodeHeaders: IncomingHttpHeaders): Headers { /** * Creates a `URL` object from a Node.js `IncomingMessage`, taking into account the protocol, host, and port. * - * @param nodeRequest - The Node.js `IncomingMessage` object to extract URL information from. + * @param nodeRequest - The Node.js `IncomingMessage` or `Http2ServerRequest` object to extract URL information from. * @returns A `URL` object representing the request URL. */ -function createRequestUrl(nodeRequest: IncomingMessage): URL { +function createRequestUrl(nodeRequest: IncomingMessage | Http2ServerRequest): URL { const { headers, socket, diff --git a/packages/angular/ssr/node/src/response.ts b/packages/angular/ssr/node/src/response.ts index f243be1a7acb..936693c2b168 100644 --- a/packages/angular/ssr/node/src/response.ts +++ b/packages/angular/ssr/node/src/response.ts @@ -7,18 +7,23 @@ */ import type { ServerResponse } from 'node:http'; +import type { Http2ServerRequest, Http2ServerResponse } from 'node:http2'; /** - * Streams a web-standard `Response` into a Node.js `ServerResponse`. + * Streams a web-standard `Response` into a Node.js `ServerResponse` + * or `Http2ServerResponse`. + * + * This function adapts the web `Response` object to write its content + * to a Node.js response object, handling both HTTP/1.1 and HTTP/2. * * @param source - The web-standard `Response` object to stream from. - * @param destination - The Node.js `ServerResponse` object to stream into. + * @param destination - The Node.js response object (`ServerResponse` or `Http2ServerResponse`) to stream into. * @returns A promise that resolves once the streaming operation is complete. * @developerPreview */ export async function writeResponseToNodeResponse( source: Response, - destination: ServerResponse, + destination: ServerResponse | Http2ServerResponse, ): Promise { const { status, headers, body } = source; destination.statusCode = status; @@ -66,7 +71,7 @@ export async function writeResponseToNodeResponse( break; } - destination.write(value); + (destination as ServerResponse).write(value); } } catch { destination.end('Internal server error.'); diff --git a/packages/angular/ssr/node/test/request_spec.ts b/packages/angular/ssr/node/test/request_http1_spec.ts similarity index 98% rename from packages/angular/ssr/node/test/request_spec.ts rename to packages/angular/ssr/node/test/request_http1_spec.ts index 93513bf6cfd7..6559af458954 100644 --- a/packages/angular/ssr/node/test/request_spec.ts +++ b/packages/angular/ssr/node/test/request_http1_spec.ts @@ -10,7 +10,7 @@ import { IncomingMessage, Server, ServerResponse, createServer, request } from ' import { AddressInfo } from 'node:net'; import { createWebRequestFromNodeRequest } from '../src/request'; -describe('createWebRequestFromNodeRequest', () => { +describe('createWebRequestFromNodeRequest (HTTP/1.1)', () => { let server: Server; let port: number; diff --git a/packages/angular/ssr/node/test/request_http2_spec.ts b/packages/angular/ssr/node/test/request_http2_spec.ts new file mode 100644 index 000000000000..61f12eada863 --- /dev/null +++ b/packages/angular/ssr/node/test/request_http2_spec.ts @@ -0,0 +1,152 @@ +/** + * @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 { + ClientHttp2Session, + Http2Server, + Http2ServerRequest, + Http2ServerResponse, + connect, + createServer, +} from 'node:http2'; +import { AddressInfo } from 'node:net'; +import { createWebRequestFromNodeRequest } from '../src/request'; + +describe('createWebRequestFromNodeRequest (HTTP/2)', () => { + let server: Http2Server; + let port: number; + let client: ClientHttp2Session; + + function extractNodeRequest(makeRequest: () => void): Promise { + const nodeRequest = getNodeRequest(); + makeRequest(); + + return nodeRequest; + } + + async function getNodeRequest(): Promise { + const { req, res } = await new Promise<{ + req: Http2ServerRequest; + res: Http2ServerResponse; + }>((resolve) => { + server.once('request', (req, res) => resolve({ req, res })); + }); + + res.end(); + + return req; + } + + beforeAll((done) => { + server = createServer(); + server.listen(0, () => { + port = (server.address() as AddressInfo).port; + done(); + client = connect(`http://localhost:${port}`); + }); + }); + + afterAll((done) => { + client.close(); + server.close(done); + }); + + describe('GET Handling', () => { + it('should correctly handle a basic GET request', async () => { + const nodeRequest = await extractNodeRequest(() => { + client + .request({ + ':path': '/basic-get', + ':method': 'GET', + }) + .end(); + }); + + const webRequest = createWebRequestFromNodeRequest(nodeRequest); + expect(webRequest.method).toBe('GET'); + expect(webRequest.url).toBe(`http://localhost:${port}/basic-get`); + }); + + it('should correctly handle GET request with query parameters', async () => { + const nodeRequest = await extractNodeRequest(() => { + client + .request({ + ':scheme': 'http', + ':path': '/search?query=hello&page=2', + ':method': 'POST', + }) + .end(); + }); + + const webRequest = createWebRequestFromNodeRequest(nodeRequest); + expect(webRequest.method).toBe('POST'); + expect(webRequest.url).toBe(`http://localhost:${port}/search?query=hello&page=2`); + }); + + it('should correctly handle GET request with custom headers', async () => { + const nodeRequest = await extractNodeRequest(() => { + client + .request({ + ':path': '/with-headers', + ':method': 'GET', + 'X-Custom-Header1': 'value1', + 'X-Custom-Header2': 'value2', + }) + .end(); + }); + + const webRequest = createWebRequestFromNodeRequest(nodeRequest); + expect(webRequest.method).toBe('GET'); + expect(webRequest.url).toBe(`http://localhost:${port}/with-headers`); + expect(webRequest.headers.get('x-custom-header1')).toBe('value1'); + expect(webRequest.headers.get('x-custom-header2')).toBe('value2'); + }); + }); + + describe('POST Handling', () => { + it('should handle POST request with JSON body and correct response', async () => { + const postData = JSON.stringify({ message: 'Hello from POST' }); + const nodeRequest = await extractNodeRequest(() => { + const clientRequest = client.request({ + ':path': '/post-json', + ':method': 'POST', + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(postData), + }); + clientRequest.write(postData); + clientRequest.end(); + }); + + const webRequest = createWebRequestFromNodeRequest(nodeRequest); + expect(webRequest.method).toBe('POST'); + expect(webRequest.url).toBe(`http://localhost:${port}/post-json`); + expect(webRequest.headers.get('content-type')).toBe('application/json'); + expect(await webRequest.json()).toEqual({ message: 'Hello from POST' }); + }); + + it('should handle POST request with empty text body', async () => { + const postData = ''; + const nodeRequest = await extractNodeRequest(() => { + const clientRequest = client.request({ + ':path': '/post-text', + ':method': 'POST', + 'Content-Type': 'text/plain', + 'Content-Length': Buffer.byteLength(postData), + }); + clientRequest.write(postData); + clientRequest.end(); + }); + + const webRequest = createWebRequestFromNodeRequest(nodeRequest); + expect(webRequest.method).toBe('POST'); + expect(webRequest.url).toBe(`http://localhost:${port}/post-text`); + expect(webRequest.headers.get('content-type')).toBe('text/plain'); + expect(await webRequest.text()).toBe(''); + }); + }); +}); diff --git a/packages/angular/ssr/node/test/response_spec.ts b/packages/angular/ssr/node/test/response_http1_spec.ts similarity index 98% rename from packages/angular/ssr/node/test/response_spec.ts rename to packages/angular/ssr/node/test/response_http1_spec.ts index a2e527dba280..8deae2b7e3b4 100644 --- a/packages/angular/ssr/node/test/response_spec.ts +++ b/packages/angular/ssr/node/test/response_http1_spec.ts @@ -10,7 +10,7 @@ import { IncomingMessage, Server, createServer, request as requestCb } from 'nod import { AddressInfo } from 'node:net'; import { writeResponseToNodeResponse } from '../src/response'; -describe('writeResponseToNodeResponse', () => { +describe('writeResponseToNodeResponse (HTTP/1.1)', () => { let server: Server; function simulateResponse( diff --git a/packages/angular/ssr/node/test/response_http2_spec.ts b/packages/angular/ssr/node/test/response_http2_spec.ts new file mode 100644 index 000000000000..c2de8e977561 --- /dev/null +++ b/packages/angular/ssr/node/test/response_http2_spec.ts @@ -0,0 +1,119 @@ +/** + * @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 { + Http2Server, + IncomingHttpHeaders, + IncomingHttpStatusHeader, + connect, + createServer, +} from 'node:http2'; +import { AddressInfo } from 'node:net'; +import { writeResponseToNodeResponse } from '../src/response'; + +describe('writeResponseToNodeResponse (HTTP/2)', () => { + let server: Http2Server; + function simulateResponse(res: Response): Promise<{ + body: string | null; + headers: IncomingHttpHeaders & IncomingHttpStatusHeader; + statusCode: number | undefined; + }> { + server.once('request', (req, nodeResponse) => { + void writeResponseToNodeResponse(res, nodeResponse); + }); + + const { port } = server.address() as AddressInfo; + const client = connect(`http://localhost:${port}`); + + return new Promise<{ + body: string | null; + headers: IncomingHttpHeaders & IncomingHttpStatusHeader; + statusCode: number | undefined; + }>((resolve, reject) => { + const req = client.request({ + ':path': '/', + }); + + req.once('response', (headers) => { + let body: string | null = null; + + req + .on('data', (chunk) => { + body ??= ''; + body += chunk; + }) + .on('end', () => resolve({ headers, statusCode: headers[':status'], body })) + .on('error', reject); + }); + }).finally(() => { + client.close(); + }); + } + + beforeAll((done) => { + server = createServer(); + server.listen(0, done); + }); + + afterAll((done) => { + server.close(done); + }); + + it('should write status, headers, and body to Node.js response', async () => { + const { headers, statusCode, body } = await simulateResponse( + new Response('Hello, world!', { + status: 201, + headers: { + 'Content-Type': 'text/plain', + 'X-Custom-Header': 'custom-value', + }, + }), + ); + + expect(statusCode).toBe(201); + expect(headers['content-type']).toBe('text/plain'); + expect(headers['x-custom-header']).toBe('custom-value'); + expect(body).toBe('Hello, world!'); + }); + + it('should handle empty body', async () => { + const { statusCode, body } = await simulateResponse( + new Response(null, { + status: 204, + }), + ); + + expect(statusCode).toBe(204); + expect(body).toBeNull(); + }); + + it('should handle JSON content types', async () => { + const jsonData = JSON.stringify({ message: 'Hello JSON' }); + const { statusCode, body } = await simulateResponse( + new Response(jsonData, { + headers: { 'Content-Type': 'application/json' }, + }), + ); + + expect(statusCode).toBe(200); + expect(body).toBe(jsonData); + }); + + it('should set cookies on the ServerResponse', async () => { + const cookieValue: string[] = [ + 'myCookie=myValue; Path=/; HttpOnly', + 'anotherCookie=anotherValue; Path=/test', + ]; + + const headers = new Headers(); + cookieValue.forEach((v) => headers.append('set-cookie', v)); + const { headers: resHeaders } = await simulateResponse(new Response(null, { headers })); + + expect(resHeaders['set-cookie']).toEqual(cookieValue); + }); +}); 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 ef222d7e6940..5337ad5e5cc5 100644 --- a/tests/legacy-cli/e2e/tests/vite/ssr-entry-express.ts +++ b/tests/legacy-cli/e2e/tests/vite/ssr-entry-express.ts @@ -3,7 +3,7 @@ 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'; -import { ngServe, updateJsonFile, useSha } from '../../utils/project'; +import { ngServe, useSha } from '../../utils/project'; import { getGlobalVariable } from '../../utils/env'; export default async function () { @@ -18,13 +18,6 @@ export default async function () { await useSha(); await installWorkspacePackages(); - // 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': '', 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 c811287023c6..87c84ad0010f 100644 --- a/tests/legacy-cli/e2e/tests/vite/ssr-entry-fastify.ts +++ b/tests/legacy-cli/e2e/tests/vite/ssr-entry-fastify.ts @@ -3,7 +3,7 @@ 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 { ngServe, useSha } from '../../utils/project'; import { getGlobalVariable } from '../../utils/env'; export default async function () { @@ -19,13 +19,6 @@ export default async function () { await installWorkspacePackages(); await installPackage('fastify@5'); - // 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': '', diff --git a/tests/legacy-cli/e2e/tests/vite/ssr-entry-h3.ts b/tests/legacy-cli/e2e/tests/vite/ssr-entry-h3.ts index 34072abf371d..0027190395f4 100644 --- a/tests/legacy-cli/e2e/tests/vite/ssr-entry-h3.ts +++ b/tests/legacy-cli/e2e/tests/vite/ssr-entry-h3.ts @@ -3,7 +3,7 @@ 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 { ngServe, useSha } from '../../utils/project'; import { getGlobalVariable } from '../../utils/env'; export default async function () { @@ -19,13 +19,6 @@ export default async function () { 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': '', 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 7ddc04b90492..353a6cf5b855 100644 --- a/tests/legacy-cli/e2e/tests/vite/ssr-entry-hono.ts +++ b/tests/legacy-cli/e2e/tests/vite/ssr-entry-hono.ts @@ -19,13 +19,6 @@ export default async function () { await installWorkspacePackages(); await installPackage('hono@4'); - // 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': '', diff --git a/tests/legacy-cli/e2e/tests/vite/ssr-with-ssl.ts b/tests/legacy-cli/e2e/tests/vite/ssr-with-ssl.ts new file mode 100644 index 000000000000..0708153d478e --- /dev/null +++ b/tests/legacy-cli/e2e/tests/vite/ssr-with-ssl.ts @@ -0,0 +1,59 @@ +import assert from 'node:assert'; +import { writeMultipleFiles } from '../../utils/fs'; +import { ng, silentNg } from '../../utils/process'; +import { installWorkspacePackages, uninstallPackage } from '../../utils/packages'; +import { ngServe, 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', '--server-routing', '--skip-confirmation', '--skip-install'); + await useSha(); + await installWorkspacePackages(); + + await writeMultipleFiles({ + // Replace the template of app.component.html as it makes it harder to debug + 'src/app/app.component.html': '', + '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 serverRoutes: ServerRoute[] = [ + { path: '**', renderMode: RenderMode.Server } + ]; + `, + }); + + await silentNg('generate', 'component', 'home'); + + const port = await ngServe('--ssl'); + + // Verify the server is running and the API response is correct. + await validateResponse('/main.js', /bootstrapApplication/); + await validateResponse('/home', /home works/); + + async function validateResponse(pathname: string, match: RegExp): Promise { + try { + process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; + const response = await fetch(new URL(pathname, `https://localhost:${port}`)); + const text = await response.text(); + assert.match(text, match); + assert.equal(response.status, 200); + } finally { + process.env.NODE_TLS_REJECT_UNAUTHORIZED = '1'; + } + } +}