Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions goldens/public-api/angular/ssr/node/index.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,16 @@
```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';
import { Type } from '@angular/core';

// @public
export class AngularNodeAppEngine {
handle(request: IncomingMessage, requestContext?: unknown): Promise<Response | null>;
handle(request: IncomingMessage | Http2ServerRequest, requestContext?: unknown): Promise<Response | null>;
}

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

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

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

// @public
export function writeResponseToNodeResponse(source: Response, destination: ServerResponse): Promise<void>;
export function writeResponseToNodeResponse(source: Response, destination: ServerResponse | Http2ServerResponse<Http2ServerRequest>): Promise<void>;

// (No @packageDocumentation comment for this package)

Expand Down
11 changes: 9 additions & 2 deletions packages/angular/ssr/node/src/app-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand All @@ -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<Response | null> {
async handle(
request: IncomingMessage | Http2ServerRequest,
requestContext?: unknown,
): Promise<Response | null> {
const webRequest = createWebRequestFromNodeRequest(request);

return this.angularAppEngine.handle(webRequest, requestContext);
Expand Down
30 changes: 25 additions & 5 deletions packages/angular/ssr/node/src/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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)) {
Expand All @@ -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,
Expand Down
13 changes: 9 additions & 4 deletions packages/angular/ssr/node/src/response.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Http2ServerRequest>,
): Promise<void> {
const { status, headers, body } = source;
destination.statusCode = status;
Expand Down Expand Up @@ -66,7 +71,7 @@ export async function writeResponseToNodeResponse(
break;
}

destination.write(value);
(destination as ServerResponse).write(value);
}
} catch {
destination.end('Internal server error.');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
152 changes: 152 additions & 0 deletions packages/angular/ssr/node/test/request_http2_spec.ts
Original file line number Diff line number Diff line change
@@ -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<Http2ServerRequest> {
const nodeRequest = getNodeRequest();
makeRequest();

return nodeRequest;
}

async function getNodeRequest(): Promise<Http2ServerRequest> {
const { req, res } = await new Promise<{
req: Http2ServerRequest;
res: Http2ServerResponse<Http2ServerRequest>;
}>((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('');
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading