Skip to content

Commit 41fb2ed

Browse files
committedSep 6, 2024
feat(@angular/ssr): Add getHeaders Method to AngularAppEngine and AngularNodeAppEngine for handling pages static headers
This commit introduces a new `getHeaders` method to the `AngularAppEngine` and `AngularNodeAppEngine` classes, designed to facilitate the retrieval of HTTP headers based on URL pathnames for statically generated (SSG) pages. ```typescript const angularAppEngine = new AngularNodeAppEngine(); app.use(express.static('dist/browser', { setHeaders: (res, path) => { // Retrieve headers for the current request const headers = angularAppEngine.getHeaders(res.req); // Apply the retrieved headers to the response for (const { key, value } of headers) { res.setHeader(key, value); } } })); ``` In this example, the `getHeaders` method is used within an Express application to dynamically set HTTP headers for static files. This ensures that appropriate headers are applied based on the specific request, enhancing the handling of SSG pages.
1 parent 576ff60 commit 41fb2ed

File tree

7 files changed

+298
-56
lines changed

7 files changed

+298
-56
lines changed
 

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

+1
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
// @public
88
export interface AngularServerAppManager {
9+
getHeaders(request: Request): Readonly<Map<string, string>>;
910
render(request: Request, requestContext?: unknown): Promise<Response | null>;
1011
}
1112

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

+2-2
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,14 @@
66

77
import { ApplicationRef } from '@angular/core';
88
import type { IncomingMessage } from 'node:http';
9-
import type { IncomingMessage as IncomingMessage_2 } from 'http';
109
import type { ServerResponse } from 'node:http';
1110
import { StaticProvider } from '@angular/core';
1211
import { Type } from '@angular/core';
1312

1413
// @public
1514
export interface AngularNodeServerAppManager {
16-
render(request: Request | IncomingMessage_2, requestContext?: unknown): Promise<Response | null>;
15+
getHeaders(request: IncomingMessage): Readonly<Map<string, string>>;
16+
render(request: IncomingMessage, requestContext?: unknown): Promise<Response | null>;
1717
}
1818

1919
// @public

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

+39-26
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9-
import { ɵAngularAppEngine as AngularAppEngine } from '@angular/ssr';
10-
import type { IncomingMessage } from 'http';
9+
import { destroyAngularAppEngine, getOrCreateAngularAppEngine } from '@angular/ssr';
10+
import type { IncomingMessage } from 'node:http';
1111
import { createWebRequestFromNodeRequest } from './request';
1212

1313
/**
@@ -27,42 +27,54 @@ export interface AngularNodeServerAppManager {
2727
* Example: A request to `https://www.example.com/page/index.html` will render the Angular route
2828
* associated with `https://www.example.com/page`.
2929
*
30-
* @param request - The incoming HTTP request object to be rendered. It can be a `Request` or `IncomingMessage` object.
30+
* @param request - The incoming HTTP request object to be rendered.
3131
* @param requestContext - Optional additional context for the request, such as metadata or custom settings.
3232
* @returns A promise that resolves to a `Response` object, or `null` if the request URL is for a static file
3333
* (e.g., `./logo.png`) rather than an application route.
3434
*/
35-
render(request: Request | IncomingMessage, requestContext?: unknown): Promise<Response | null>;
35+
render(request: IncomingMessage, requestContext?: unknown): Promise<Response | null>;
36+
37+
/**
38+
* Retrieves HTTP headers for a request associated with statically generated (SSG) pages,
39+
* based on the URL pathname.
40+
*
41+
* @param request - The incoming request object.
42+
* @returns A `Map` containing the HTTP headers as key-value pairs.
43+
* @note This function should be used exclusively for retrieving headers of SSG pages.
44+
* @example
45+
* ```typescript
46+
* const angularAppEngine = getOrCreateAngularNodeAppEngine();
47+
*
48+
* app.use(express.static('dist/browser', {
49+
* setHeaders: (res, path) => {
50+
* // Retrieve headers for the current request
51+
* const headers = angularAppEngine.getHeaders(res.req);
52+
*
53+
* // Apply the retrieved headers to the response
54+
* for (const { key, value } of headers) {
55+
* res.setHeader(key, value);
56+
* }
57+
* }
58+
}));
59+
* ```
60+
*/
61+
getHeaders(request: IncomingMessage): Readonly<Map<string, string>>;
3662
}
3763

3864
/**
3965
* Angular server application engine.
4066
* Manages Angular server applications (including localized ones), handles rendering requests,
4167
* and optionally transforms index HTML before rendering.
4268
*/
43-
export class AngularNodeAppEngine extends AngularAppEngine implements AngularNodeServerAppManager {
44-
/**
45-
* Renders an HTTP response based on the incoming request using the Angular server application.
46-
*
47-
* The method processes the incoming request, determines the appropriate route, and prepares the
48-
* rendering context to generate a response. If the request URL corresponds to a static file (excluding `/index.html`),
49-
* the method returns `null`.
50-
*
51-
* Example: A request to `https://www.example.com/page/index.html` will render the Angular route
52-
* associated with `https://www.example.com/page`.
53-
*
54-
* @param request - The incoming HTTP request object to be rendered. It can be a `Request` or `IncomingMessage` object.
55-
* @param requestContext - Optional additional context for the request, such as metadata or custom settings.
56-
* @returns A promise that resolves to a `Response` object, or `null` if the request URL is for a static file
57-
* (e.g., `./logo.png`) rather than an application route.
58-
*/
59-
override render(
60-
request: Request | IncomingMessage,
61-
requestContext?: unknown,
62-
): Promise<Response | null> {
63-
const webReq = request instanceof Request ? request : createWebRequestFromNodeRequest(request);
69+
class AngularNodeAppEngine implements AngularNodeServerAppManager {
70+
private readonly angularAppEngine = getOrCreateAngularAppEngine();
71+
72+
render(request: IncomingMessage, requestContext?: unknown): Promise<Response | null> {
73+
return this.angularAppEngine.render(createWebRequestFromNodeRequest(request), requestContext);
74+
}
6475

65-
return super.render(webReq, requestContext);
76+
getHeaders(request: IncomingMessage): Readonly<Map<string, string>> {
77+
return this.angularAppEngine.getHeaders(createWebRequestFromNodeRequest(request));
6678
}
6779
}
6880

@@ -92,4 +104,5 @@ export function getOrCreateAngularNodeAppEngine(): AngularNodeServerAppManager {
92104
*/
93105
export function destroyAngularNodeAppEngine(): void {
94106
angularNodeAppEngine = undefined;
107+
destroyAngularAppEngine();
95108
}

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

+32
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import type { AngularServerApp } from './app';
1010
import { Hooks } from './hooks';
1111
import { getPotentialLocaleIdFromUrl } from './i18n';
1212
import { EntryPointExports, getAngularAppEngineManifest } from './manifest';
13+
import { stripIndexHtmlFromURL } from './utils/url';
1314

1415
/**
1516
* Angular server application engine.
@@ -34,12 +35,24 @@ export interface AngularServerAppManager {
3435
* rather than an application route.
3536
*/
3637
render(request: Request, requestContext?: unknown): Promise<Response | null>;
38+
39+
/**
40+
* Retrieves HTTP headers for a request associated with statically generated (SSG) pages,
41+
* based on the URL pathname.
42+
*
43+
* @param request - The incoming request object.
44+
* @returns A `Map` containing the HTTP headers as key-value pairs.
45+
* @note This function should be used exclusively for retrieving headers of SSG pages.
46+
*/
47+
getHeaders(request: Request): Readonly<Map<string, string>>;
3748
}
3849

3950
/**
4051
* Angular server application engine.
4152
* Manages Angular server applications (including localized ones), handles rendering requests,
4253
* and optionally transforms index HTML before rendering.
54+
*
55+
* @developerPreview
4356
*/
4457
export class AngularAppEngine implements AngularServerAppManager {
4558
/**
@@ -120,6 +133,25 @@ export class AngularAppEngine implements AngularServerAppManager {
120133

121134
return entryPoints.get(potentialLocale);
122135
}
136+
137+
/**
138+
* Retrieves HTTP headers for a request associated with statically generated (SSG) pages,
139+
* based on the URL pathname.
140+
*
141+
* @param request - The incoming request object.
142+
* @returns A `Map` containing the HTTP headers as key-value pairs.
143+
* @note This function should be used exclusively for retrieving headers of SSG pages.
144+
*/
145+
getHeaders(request: Request): Readonly<Map<string, string>> {
146+
if (this.manifest.staticPathsHeaders.size === 0) {
147+
return new Map();
148+
}
149+
150+
const { pathname } = stripIndexHtmlFromURL(new URL(request.url));
151+
const headers = this.manifest.staticPathsHeaders.get(pathname);
152+
153+
return new Map(headers);
154+
}
123155
}
124156

125157
let angularAppEngine: AngularAppEngine | undefined;

‎packages/angular/ssr/src/manifest.ts

+12
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,18 @@ export interface AngularAppEngineManifest {
4343
* This is used to determine the root path of the application.
4444
*/
4545
readonly basePath: string;
46+
47+
/**
48+
* A map that associates static paths with their corresponding HTTP headers.
49+
* Each entry in the map consists of:
50+
* - `key`: The static path as a string.
51+
* - `value`: An array of tuples, where each tuple contains:
52+
* - `headerName`: The name of the HTTP header.
53+
* - `headerValue`: The value of the HTTP header.
54+
*/
55+
readonly staticPathsHeaders: Readonly<
56+
Map<string, readonly [headerName: string, headerValue: string][]>
57+
>;
4658
}
4759

4860
/**
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
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 { EnvironmentProviders, InjectionToken, makeEnvironmentProviders } from '@angular/core';
10+
11+
/**
12+
* Different rendering modes for server routes.
13+
* @developerPreview
14+
*/
15+
export enum RenderMode {
16+
/** AppShell rendering mode, typically used for pre-rendered shells of the application. */
17+
AppShell,
18+
19+
/** Server-Side Rendering (SSR) mode, where content is rendered on the server for each request. */
20+
SSR,
21+
22+
/** Client-Side Rendering (CSR) mode, where content is rendered on the client side in the browser. */
23+
CSR,
24+
25+
/** Static Site Generation (SSG) mode, where content is pre-rendered at build time and served as static files. */
26+
SSG,
27+
}
28+
29+
/**
30+
* Fallback strategies for Static Site Generation (SSG) routes.
31+
* @developerPreview
32+
*/
33+
export enum SSGFallback {
34+
/** Use Server-Side Rendering (SSR) as the fallback for this route. */
35+
SSR,
36+
37+
/** Use Client-Side Rendering (CSR) as the fallback for this route. */
38+
CSR,
39+
40+
/** No fallback; Angular will not handle the response if the path is not pre-rendered. */
41+
None,
42+
}
43+
44+
/**
45+
* Common interface for server routes, providing shared properties.
46+
*/
47+
export interface ServerRouteCommon {
48+
/** The path associated with this route. */
49+
path: string;
50+
51+
/** Optional additional headers to include in the response for this route. */
52+
headers?: Record<string, string>;
53+
54+
/** Optional status code to return for this route. */
55+
status?: number;
56+
}
57+
58+
/**
59+
* A server route that uses AppShell rendering mode.
60+
*/
61+
export interface ServerRouteAppShell extends Omit<ServerRouteCommon, 'headers' | 'status'> {
62+
/** Specifies that the route uses AppShell rendering mode. */
63+
renderMode: RenderMode.AppShell;
64+
}
65+
66+
/**
67+
* A server route that uses Client-Side Rendering (CSR) mode.
68+
*/
69+
export interface ServerRouteCSR extends ServerRouteCommon {
70+
/** Specifies that the route uses Client-Side Rendering (CSR) mode. */
71+
renderMode: RenderMode.CSR;
72+
}
73+
74+
/**
75+
* A server route that uses Static Site Generation (SSG) mode.
76+
*/
77+
export interface ServerRouteSSG extends Omit<ServerRouteCommon, 'status'> {
78+
/** Specifies that the route uses Static Site Generation (SSG) mode. */
79+
renderMode: RenderMode.SSG;
80+
81+
/**
82+
* Optional fallback strategy to use if the SSG path is not pre-rendered.
83+
* Defaults to `SSGFallback.SSR` if not provided.
84+
*/
85+
fallback?: SSGFallback;
86+
/**
87+
* A function that returns a Promise resolving to an array of objects, each representing a route path with URL parameters.
88+
* This function runs in the injector context, allowing access to Angular services and dependencies.
89+
*
90+
* @returns A Promise resolving to an array where each element is an object with string keys (representing URL parameter names)
91+
* and string values (representing the corresponding values for those parameters in the route path).
92+
*
93+
* @example
94+
* ```typescript
95+
* export const serverRouteConfig: ServerRoutes[] = [
96+
* {
97+
* path: '/product/:id',
98+
* remderMode: RenderMode.SSG,
99+
* async getPrerenderPaths() {
100+
* const productService = inject(ProductService);
101+
* const ids = await productService.getIds(); // Assuming this returns ['1', '2', '3']
102+
*
103+
* return ids.map(id => ({ id })); // Generates paths like: [{ id: '1' }, { id: '2' }, { id: '3' }]
104+
* },
105+
* },
106+
* ];
107+
* ```
108+
*/
109+
getPrerenderPaths?: () => Promise<Record<string, string>[]>;
110+
}
111+
112+
/**
113+
* A server route that uses Server-Side Rendering (SSR) mode.
114+
*/
115+
export interface ServerRouteSSR extends ServerRouteCommon {
116+
/** Specifies that the route uses Server-Side Rendering (SSR) mode. */
117+
renderMode: RenderMode.SSR;
118+
}
119+
120+
/**
121+
* Server route configuration.
122+
* @developerPreview
123+
*/
124+
export type ServerRoute = ServerRouteAppShell | ServerRouteCSR | ServerRouteSSG | ServerRouteSSR;
125+
126+
/**
127+
* Token for providing the server routes configuration.
128+
* @internal
129+
*/
130+
export const SERVER_ROUTES_CONFIG = new InjectionToken<ServerRoute[]>('SERVER_ROUTES_CONFIG');
131+
132+
/**
133+
* Configures the necessary providers for server routes configuration.
134+
*
135+
* @param routes - An array of server routes to be provided.
136+
* @returns An `EnvironmentProviders` object that contains the server routes configuration.
137+
* @developerPreview
138+
*/
139+
export function provideServerRoutesConfig(routes: ServerRoute[]): EnvironmentProviders {
140+
return makeEnvironmentProviders([
141+
{
142+
provide: SERVER_ROUTES_CONFIG,
143+
useValue: routes,
144+
},
145+
]);
146+
}

‎packages/angular/ssr/test/app-engine_spec.ts

+66-28
Original file line numberDiff line numberDiff line change
@@ -47,45 +47,82 @@ describe('AngularAppEngine', () => {
4747
]),
4848
),
4949
basePath: '',
50+
staticPathsHeaders: new Map([
51+
[
52+
'/about',
53+
[
54+
['Cache-Control', 'no-cache'],
55+
['X-Some-Header', 'value'],
56+
],
57+
],
58+
]),
5059
});
5160

5261
appEngine = new AngularAppEngine();
5362
});
5463

55-
it('should return null for requests to unknown pages', async () => {
56-
const request = new Request('https://example.com/unknown/page');
57-
const response = await appEngine.render(request);
58-
expect(response).toBeNull();
59-
});
64+
describe('render', () => {
65+
it('should return null for requests to unknown pages', async () => {
66+
const request = new Request('https://example.com/unknown/page');
67+
const response = await appEngine.render(request);
68+
expect(response).toBeNull();
69+
});
6070

61-
it('should return null for requests with unknown locales', async () => {
62-
const request = new Request('https://example.com/es/home');
63-
const response = await appEngine.render(request);
64-
expect(response).toBeNull();
65-
});
71+
it('should return null for requests with unknown locales', async () => {
72+
const request = new Request('https://example.com/es/home');
73+
const response = await appEngine.render(request);
74+
expect(response).toBeNull();
75+
});
6676

67-
it('should return a rendered page with correct locale', async () => {
68-
const request = new Request('https://example.com/it/home');
69-
const response = await appEngine.render(request);
70-
expect(await response?.text()).toContain('Home works IT');
71-
});
77+
it('should return a rendered page with correct locale', async () => {
78+
const request = new Request('https://example.com/it/home');
79+
const response = await appEngine.render(request);
80+
expect(await response?.text()).toContain('Home works IT');
81+
});
7282

73-
it('should correctly render the content when the URL ends with "index.html" with correct locale', async () => {
74-
const request = new Request('https://example.com/it/home/index.html');
75-
const response = await appEngine.render(request);
76-
expect(await response?.text()).toContain('Home works IT');
77-
});
83+
it('should correctly render the content when the URL ends with "index.html" with correct locale', async () => {
84+
const request = new Request('https://example.com/it/home/index.html');
85+
const response = await appEngine.render(request);
86+
expect(await response?.text()).toContain('Home works IT');
87+
});
7888

79-
it('should return null for requests to unknown pages in a locale', async () => {
80-
const request = new Request('https://example.com/it/unknown/page');
81-
const response = await appEngine.render(request);
82-
expect(response).toBeNull();
89+
it('should return null for requests to unknown pages in a locale', async () => {
90+
const request = new Request('https://example.com/it/unknown/page');
91+
const response = await appEngine.render(request);
92+
expect(response).toBeNull();
93+
});
94+
95+
it('should return null for requests to file-like resources in a locale', async () => {
96+
const request = new Request('https://example.com/it/logo.png');
97+
const response = await appEngine.render(request);
98+
expect(response).toBeNull();
99+
});
83100
});
84101

85-
it('should return null for requests to file-like resources in a locale', async () => {
86-
const request = new Request('https://example.com/it/logo.png');
87-
const response = await appEngine.render(request);
88-
expect(response).toBeNull();
102+
describe('getHeaders', () => {
103+
it('should return headers for a known path without index.html', () => {
104+
const request = new Request('https://example.com/about');
105+
const headers = appEngine.getHeaders(request);
106+
expect(Object.fromEntries(headers.entries())).toEqual({
107+
'Cache-Control': 'no-cache',
108+
'X-Some-Header': 'value',
109+
});
110+
});
111+
112+
it('should return headers for a known path with index.html', () => {
113+
const request = new Request('https://example.com/about/index.html');
114+
const headers = appEngine.getHeaders(request);
115+
expect(Object.fromEntries(headers.entries())).toEqual({
116+
'Cache-Control': 'no-cache',
117+
'X-Some-Header': 'value',
118+
});
119+
});
120+
121+
it('should return no headers for unknown paths', () => {
122+
const request = new Request('https://example.com/unknown/path');
123+
const headers = appEngine.getHeaders(request);
124+
expect(headers).toHaveSize(0);
125+
});
89126
});
90127
});
91128

@@ -115,6 +152,7 @@ describe('AngularAppEngine', () => {
115152
],
116153
]),
117154
basePath: '',
155+
staticPathsHeaders: new Map(),
118156
});
119157

120158
appEngine = new AngularAppEngine();

0 commit comments

Comments
 (0)
Please sign in to comment.