Skip to content

Commit 280ebbd

Browse files
committed
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 (cherry picked from commit ffbe9b9)
1 parent 59500d0 commit 280ebbd

13 files changed

+383
-47
lines changed

goldens/public-api/angular/ssr/node/index.api.md

+5-3
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,16 @@
55
```ts
66

77
import { ApplicationRef } from '@angular/core';
8+
import type { Http2ServerRequest } from 'node:http2';
9+
import type { Http2ServerResponse } from 'node:http2';
810
import type { IncomingMessage } from 'node:http';
911
import type { ServerResponse } from 'node:http';
1012
import { StaticProvider } from '@angular/core';
1113
import { Type } from '@angular/core';
1214

1315
// @public
1416
export class AngularNodeAppEngine {
15-
handle(request: IncomingMessage, requestContext?: unknown): Promise<Response | null>;
17+
handle(request: IncomingMessage | Http2ServerRequest, requestContext?: unknown): Promise<Response | null>;
1618
}
1719

1820
// @public
@@ -46,7 +48,7 @@ export interface CommonEngineRenderOptions {
4648
export function createNodeRequestHandler<T extends NodeRequestHandlerFunction>(handler: T): T;
4749

4850
// @public
49-
export function createWebRequestFromNodeRequest(nodeRequest: IncomingMessage): Request;
51+
export function createWebRequestFromNodeRequest(nodeRequest: IncomingMessage | Http2ServerRequest): Request;
5052

5153
// @public
5254
export function isMainModule(url: string): boolean;
@@ -55,7 +57,7 @@ export function isMainModule(url: string): boolean;
5557
export type NodeRequestHandlerFunction = (req: IncomingMessage, res: ServerResponse, next: (err?: unknown) => void) => Promise<void> | void;
5658

5759
// @public
58-
export function writeResponseToNodeResponse(source: Response, destination: ServerResponse): Promise<void>;
60+
export function writeResponseToNodeResponse(source: Response, destination: ServerResponse | Http2ServerResponse<Http2ServerRequest>): Promise<void>;
5961

6062
// (No @packageDocumentation comment for this package)
6163

packages/angular/ssr/node/src/app-engine.ts

+9-2
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
import { AngularAppEngine } from '@angular/ssr';
1010
import type { IncomingMessage } from 'node:http';
11+
import type { Http2ServerRequest } from 'node:http2';
1112
import { createWebRequestFromNodeRequest } from './request';
1213

1314
/**
@@ -27,14 +28,20 @@ export class AngularNodeAppEngine {
2728
* Handles an incoming HTTP request by serving prerendered content, performing server-side rendering,
2829
* or delivering a static file for client-side rendered routes based on the `RenderMode` setting.
2930
*
30-
* @param request - The HTTP request to handle.
31+
* This method adapts Node.js's `IncomingMessage` or `Http2ServerRequest`
32+
* to a format compatible with the `AngularAppEngine` and delegates the handling logic to it.
33+
*
34+
* @param request - The incoming HTTP request (`IncomingMessage` or `Http2ServerRequest`).
3135
* @param requestContext - Optional context for rendering, such as metadata associated with the request.
3236
* @returns A promise that resolves to the resulting HTTP response object, or `null` if no matching Angular route is found.
3337
*
3438
* @remarks A request to `https://www.example.com/page/index.html` will serve or render the Angular route
3539
* corresponding to `https://www.example.com/page`.
3640
*/
37-
async handle(request: IncomingMessage, requestContext?: unknown): Promise<Response | null> {
41+
async handle(
42+
request: IncomingMessage | Http2ServerRequest,
43+
requestContext?: unknown,
44+
): Promise<Response | null> {
3845
const webRequest = createWebRequestFromNodeRequest(request);
3946

4047
return this.angularAppEngine.handle(webRequest, requestContext);

packages/angular/ssr/node/src/request.ts

+25-5
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,31 @@
77
*/
88

99
import type { IncomingHttpHeaders, IncomingMessage } from 'node:http';
10+
import type { Http2ServerRequest } from 'node:http2';
1011

1112
/**
12-
* Converts a Node.js `IncomingMessage` into a Web Standard `Request`.
13+
* A set containing all the pseudo-headers defined in the HTTP/2 specification.
1314
*
14-
* @param nodeRequest - The Node.js `IncomingMessage` object to convert.
15+
* This set can be used to filter out pseudo-headers from a list of headers,
16+
* as they are not allowed to be set directly using the `Node.js` Undici API or
17+
* the web `Headers` API.
18+
*/
19+
const HTTP2_PSEUDO_HEADERS = new Set([':method', ':scheme', ':authority', ':path', ':status']);
20+
21+
/**
22+
* Converts a Node.js `IncomingMessage` or `Http2ServerRequest` into a
23+
* Web Standard `Request` object.
24+
*
25+
* This function adapts the Node.js request objects to a format that can
26+
* be used by web platform APIs.
27+
*
28+
* @param nodeRequest - The Node.js request object (`IncomingMessage` or `Http2ServerRequest`) to convert.
1529
* @returns A Web Standard `Request` object.
1630
* @developerPreview
1731
*/
18-
export function createWebRequestFromNodeRequest(nodeRequest: IncomingMessage): Request {
32+
export function createWebRequestFromNodeRequest(
33+
nodeRequest: IncomingMessage | Http2ServerRequest,
34+
): Request {
1935
const { headers, method = 'GET' } = nodeRequest;
2036
const withBody = method !== 'GET' && method !== 'HEAD';
2137

@@ -37,6 +53,10 @@ function createRequestHeaders(nodeHeaders: IncomingHttpHeaders): Headers {
3753
const headers = new Headers();
3854

3955
for (const [name, value] of Object.entries(nodeHeaders)) {
56+
if (HTTP2_PSEUDO_HEADERS.has(name)) {
57+
continue;
58+
}
59+
4060
if (typeof value === 'string') {
4161
headers.append(name, value);
4262
} else if (Array.isArray(value)) {
@@ -52,10 +72,10 @@ function createRequestHeaders(nodeHeaders: IncomingHttpHeaders): Headers {
5272
/**
5373
* Creates a `URL` object from a Node.js `IncomingMessage`, taking into account the protocol, host, and port.
5474
*
55-
* @param nodeRequest - The Node.js `IncomingMessage` object to extract URL information from.
75+
* @param nodeRequest - The Node.js `IncomingMessage` or `Http2ServerRequest` object to extract URL information from.
5676
* @returns A `URL` object representing the request URL.
5777
*/
58-
function createRequestUrl(nodeRequest: IncomingMessage): URL {
78+
function createRequestUrl(nodeRequest: IncomingMessage | Http2ServerRequest): URL {
5979
const {
6080
headers,
6181
socket,

packages/angular/ssr/node/src/response.ts

+9-4
Original file line numberDiff line numberDiff line change
@@ -7,18 +7,23 @@
77
*/
88

99
import type { ServerResponse } from 'node:http';
10+
import type { Http2ServerRequest, Http2ServerResponse } from 'node:http2';
1011

1112
/**
12-
* Streams a web-standard `Response` into a Node.js `ServerResponse`.
13+
* Streams a web-standard `Response` into a Node.js `ServerResponse`
14+
* or `Http2ServerResponse`.
15+
*
16+
* This function adapts the web `Response` object to write its content
17+
* to a Node.js response object, handling both HTTP/1.1 and HTTP/2.
1318
*
1419
* @param source - The web-standard `Response` object to stream from.
15-
* @param destination - The Node.js `ServerResponse` object to stream into.
20+
* @param destination - The Node.js response object (`ServerResponse` or `Http2ServerResponse`) to stream into.
1621
* @returns A promise that resolves once the streaming operation is complete.
1722
* @developerPreview
1823
*/
1924
export async function writeResponseToNodeResponse(
2025
source: Response,
21-
destination: ServerResponse,
26+
destination: ServerResponse | Http2ServerResponse<Http2ServerRequest>,
2227
): Promise<void> {
2328
const { status, headers, body } = source;
2429
destination.statusCode = status;
@@ -66,7 +71,7 @@ export async function writeResponseToNodeResponse(
6671
break;
6772
}
6873

69-
destination.write(value);
74+
(destination as ServerResponse).write(value);
7075
}
7176
} catch {
7277
destination.end('Internal server error.');

packages/angular/ssr/node/test/request_spec.ts packages/angular/ssr/node/test/request_http1_spec.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { IncomingMessage, Server, ServerResponse, createServer, request } from '
1010
import { AddressInfo } from 'node:net';
1111
import { createWebRequestFromNodeRequest } from '../src/request';
1212

13-
describe('createWebRequestFromNodeRequest', () => {
13+
describe('createWebRequestFromNodeRequest (HTTP/1.1)', () => {
1414
let server: Server;
1515
let port: number;
1616

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
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 {
10+
ClientHttp2Session,
11+
Http2Server,
12+
Http2ServerRequest,
13+
Http2ServerResponse,
14+
connect,
15+
createServer,
16+
} from 'node:http2';
17+
import { AddressInfo } from 'node:net';
18+
import { createWebRequestFromNodeRequest } from '../src/request';
19+
20+
describe('createWebRequestFromNodeRequest (HTTP/2)', () => {
21+
let server: Http2Server;
22+
let port: number;
23+
let client: ClientHttp2Session;
24+
25+
function extractNodeRequest(makeRequest: () => void): Promise<Http2ServerRequest> {
26+
const nodeRequest = getNodeRequest();
27+
makeRequest();
28+
29+
return nodeRequest;
30+
}
31+
32+
async function getNodeRequest(): Promise<Http2ServerRequest> {
33+
const { req, res } = await new Promise<{
34+
req: Http2ServerRequest;
35+
res: Http2ServerResponse<Http2ServerRequest>;
36+
}>((resolve) => {
37+
server.once('request', (req, res) => resolve({ req, res }));
38+
});
39+
40+
res.end();
41+
42+
return req;
43+
}
44+
45+
beforeAll((done) => {
46+
server = createServer();
47+
server.listen(0, () => {
48+
port = (server.address() as AddressInfo).port;
49+
done();
50+
client = connect(`http://localhost:${port}`);
51+
});
52+
});
53+
54+
afterAll((done) => {
55+
client.close();
56+
server.close(done);
57+
});
58+
59+
describe('GET Handling', () => {
60+
it('should correctly handle a basic GET request', async () => {
61+
const nodeRequest = await extractNodeRequest(() => {
62+
client
63+
.request({
64+
':path': '/basic-get',
65+
':method': 'GET',
66+
})
67+
.end();
68+
});
69+
70+
const webRequest = createWebRequestFromNodeRequest(nodeRequest);
71+
expect(webRequest.method).toBe('GET');
72+
expect(webRequest.url).toBe(`http://localhost:${port}/basic-get`);
73+
});
74+
75+
it('should correctly handle GET request with query parameters', async () => {
76+
const nodeRequest = await extractNodeRequest(() => {
77+
client
78+
.request({
79+
':scheme': 'http',
80+
':path': '/search?query=hello&page=2',
81+
':method': 'POST',
82+
})
83+
.end();
84+
});
85+
86+
const webRequest = createWebRequestFromNodeRequest(nodeRequest);
87+
expect(webRequest.method).toBe('POST');
88+
expect(webRequest.url).toBe(`http://localhost:${port}/search?query=hello&page=2`);
89+
});
90+
91+
it('should correctly handle GET request with custom headers', async () => {
92+
const nodeRequest = await extractNodeRequest(() => {
93+
client
94+
.request({
95+
':path': '/with-headers',
96+
':method': 'GET',
97+
'X-Custom-Header1': 'value1',
98+
'X-Custom-Header2': 'value2',
99+
})
100+
.end();
101+
});
102+
103+
const webRequest = createWebRequestFromNodeRequest(nodeRequest);
104+
expect(webRequest.method).toBe('GET');
105+
expect(webRequest.url).toBe(`http://localhost:${port}/with-headers`);
106+
expect(webRequest.headers.get('x-custom-header1')).toBe('value1');
107+
expect(webRequest.headers.get('x-custom-header2')).toBe('value2');
108+
});
109+
});
110+
111+
describe('POST Handling', () => {
112+
it('should handle POST request with JSON body and correct response', async () => {
113+
const postData = JSON.stringify({ message: 'Hello from POST' });
114+
const nodeRequest = await extractNodeRequest(() => {
115+
const clientRequest = client.request({
116+
':path': '/post-json',
117+
':method': 'POST',
118+
'Content-Type': 'application/json',
119+
'Content-Length': Buffer.byteLength(postData),
120+
});
121+
clientRequest.write(postData);
122+
clientRequest.end();
123+
});
124+
125+
const webRequest = createWebRequestFromNodeRequest(nodeRequest);
126+
expect(webRequest.method).toBe('POST');
127+
expect(webRequest.url).toBe(`http://localhost:${port}/post-json`);
128+
expect(webRequest.headers.get('content-type')).toBe('application/json');
129+
expect(await webRequest.json()).toEqual({ message: 'Hello from POST' });
130+
});
131+
132+
it('should handle POST request with empty text body', async () => {
133+
const postData = '';
134+
const nodeRequest = await extractNodeRequest(() => {
135+
const clientRequest = client.request({
136+
':path': '/post-text',
137+
':method': 'POST',
138+
'Content-Type': 'text/plain',
139+
'Content-Length': Buffer.byteLength(postData),
140+
});
141+
clientRequest.write(postData);
142+
clientRequest.end();
143+
});
144+
145+
const webRequest = createWebRequestFromNodeRequest(nodeRequest);
146+
expect(webRequest.method).toBe('POST');
147+
expect(webRequest.url).toBe(`http://localhost:${port}/post-text`);
148+
expect(webRequest.headers.get('content-type')).toBe('text/plain');
149+
expect(await webRequest.text()).toBe('');
150+
});
151+
});
152+
});

packages/angular/ssr/node/test/response_spec.ts packages/angular/ssr/node/test/response_http1_spec.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { IncomingMessage, Server, createServer, request as requestCb } from 'nod
1010
import { AddressInfo } from 'node:net';
1111
import { writeResponseToNodeResponse } from '../src/response';
1212

13-
describe('writeResponseToNodeResponse', () => {
13+
describe('writeResponseToNodeResponse (HTTP/1.1)', () => {
1414
let server: Server;
1515

1616
function simulateResponse(

0 commit comments

Comments
 (0)