Skip to content

Commit bca5683

Browse files
committed
feat(@angular/ssr): dynamic route resolution using Angular router
This enhancement eliminates the dependency on file extensions for server-side rendering (SSR) route handling, leveraging Angular's router configuration for more dynamic and flexible route determination. Additionally, configured redirectTo routes now correctly respond with a 302 redirect status. The new router uses a radix tree for storing routes. This data structure allows for efficient prefix-based lookups and insertions, which is particularly crucial when dealing with nested and parameterized routes. This change also lays the groundwork for potential future server-side routing configurations, further enhancing the capabilities of Angular's SSR functionality.
1 parent 37693c4 commit bca5683

22 files changed

+1353
-176
lines changed

goldens/circular-deps/packages.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@
2424
"packages/angular/cli/src/analytics/analytics.ts",
2525
"packages/angular/cli/src/command-builder/command-module.ts"
2626
],
27-
["packages/angular/ssr/src/app.ts", "packages/angular/ssr/src/manifest.ts"],
27+
[
28+
"packages/angular/ssr/src/app.ts",
29+
"packages/angular/ssr/src/assets.ts",
30+
"packages/angular/ssr/src/manifest.ts"
31+
],
2832
["packages/angular/ssr/src/app.ts", "packages/angular/ssr/src/render.ts"]
2933
]

packages/angular/ssr/BUILD.bazel

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,12 @@ ts_library(
1818
),
1919
module_name = "@angular/ssr",
2020
deps = [
21+
"@npm//@angular/common",
2122
"@npm//@angular/core",
2223
"@npm//@angular/platform-server",
24+
"@npm//@angular/router",
2325
"@npm//@types/node",
2426
"@npm//critters",
25-
"@npm//mrmime",
2627
],
2728
)
2829

packages/angular/ssr/package.json

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,18 +14,20 @@
1414
},
1515
"dependencies": {
1616
"critters": "0.0.24",
17-
"mrmime": "2.0.0",
1817
"tslib": "^2.3.0"
1918
},
2019
"peerDependencies": {
2120
"@angular/common": "^18.0.0 || ^18.2.0-next.0",
22-
"@angular/core": "^18.0.0 || ^18.2.0-next.0"
21+
"@angular/core": "^18.0.0 || ^18.2.0-next.0",
22+
"@angular/router": "^18.0.0 || ^18.2.0-next.0"
2323
},
2424
"devDependencies": {
25-
"@angular/compiler": "18.2.0-next.2",
26-
"@angular/platform-browser": "18.2.0-next.2",
27-
"@angular/platform-server": "18.2.0-next.2",
28-
"@angular/router": "18.2.0-next.2",
25+
"@angular/common": "18.2.0-rc.0",
26+
"@angular/compiler": "18.2.0-rc.0",
27+
"@angular/core": "18.2.0-rc.0",
28+
"@angular/platform-browser": "18.2.0-rc.0",
29+
"@angular/platform-server": "18.2.0-rc.0",
30+
"@angular/router": "18.2.0-rc.0",
2931
"zone.js": "^0.14.0"
3032
},
3133
"schematics": "./schematics/collection.json",

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

Lines changed: 0 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9-
import { lookup as lookupMimeType } from 'mrmime';
109
import { AngularServerApp } from './app';
1110
import { Hooks } from './hooks';
1211
import { getPotentialLocaleIdFromUrl } from './i18n';
@@ -70,10 +69,6 @@ export class AngularAppEngine {
7069
async render(request: Request, requestContext?: unknown): Promise<Response | null> {
7170
// Skip if the request looks like a file but not `/index.html`.
7271
const url = new URL(request.url);
73-
const { pathname } = url;
74-
if (isFileLike(pathname) && !pathname.endsWith('/index.html')) {
75-
return null;
76-
}
7772

7873
const entryPoint = this.getEntryPointFromUrl(url);
7974
if (!entryPoint) {
@@ -131,20 +126,3 @@ export class AngularAppEngine {
131126
return entryPoint ? [potentialLocale, entryPoint] : null;
132127
}
133128
}
134-
135-
/**
136-
* Determines if the given pathname corresponds to a file-like resource.
137-
*
138-
* @param pathname - The pathname to check.
139-
* @returns True if the pathname appears to be a file, false otherwise.
140-
*/
141-
function isFileLike(pathname: string): boolean {
142-
const dotIndex = pathname.lastIndexOf('.');
143-
if (dotIndex === -1) {
144-
return false;
145-
}
146-
147-
const extension = pathname.slice(dotIndex);
148-
149-
return extension === '.ico' || !!lookupMimeType(extension);
150-
}

packages/angular/ssr/src/app.ts

Lines changed: 31 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,11 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9+
import { ServerAssets } from './assets';
910
import { Hooks } from './hooks';
1011
import { getAngularAppManifest } from './manifest';
1112
import { ServerRenderContext, render } from './render';
13+
import { ServerRouter } from './routes/router';
1214

1315
/**
1416
* Configuration options for initializing a `AngularServerApp` instance.
@@ -53,14 +55,25 @@ export class AngularServerApp {
5355
*/
5456
readonly isDevMode: boolean;
5557

58+
/**
59+
* An instance of ServerAsset that handles server-side asset.
60+
* @internal
61+
*/
62+
readonly assets = new ServerAssets(this.manifest);
63+
64+
/**
65+
* The router instance used for route matching and handling.
66+
*/
67+
private router: ServerRouter | undefined;
68+
5669
/**
5770
* Creates a new `AngularServerApp` instance with the provided configuration options.
5871
*
5972
* @param options - The configuration options for the server application.
6073
* - `isDevMode`: Flag indicating if the application is in development mode.
6174
* - `hooks`: Optional hooks for customizing application behavior.
6275
*/
63-
constructor(options: AngularServerAppOptions) {
76+
constructor(readonly options: AngularServerAppOptions) {
6477
this.isDevMode = options.isDevMode ?? false;
6578
this.hooks = options.hooks ?? new Hooks();
6679
}
@@ -74,31 +87,29 @@ export class AngularServerApp {
7487
* @param requestContext - Optional additional context for rendering, such as request metadata.
7588
* @param serverContext - The rendering context.
7689
*
77-
* @returns A promise that resolves to the HTTP response object resulting from the rendering.
90+
* @returns A promise that resolves to the HTTP response object resulting from the rendering, or null if no match is found.
7891
*/
79-
render(
92+
async render(
8093
request: Request,
8194
requestContext?: unknown,
8295
serverContext: ServerRenderContext = ServerRenderContext.SSR,
83-
): Promise<Response> {
84-
return render(this, request, serverContext, requestContext);
85-
}
96+
): Promise<Response | null> {
97+
const url = new URL(request.url);
98+
this.router ??= await ServerRouter.from(this.manifest, url);
8699

87-
/**
88-
* Retrieves the content of a server-side asset using its path.
89-
*
90-
* This method fetches the content of a specific asset defined in the server application's manifest.
91-
*
92-
* @param path - The path to the server asset.
93-
* @returns A promise that resolves to the asset content as a string.
94-
* @throws Error If the asset path is not found in the manifest, an error is thrown.
95-
*/
96-
async getServerAsset(path: string): Promise<string> {
97-
const asset = this.manifest.assets[path];
98-
if (!asset) {
99-
throw new Error(`Server asset '${path}' does not exist.`);
100+
const matchedRoute = this.router.match(url);
101+
if (!matchedRoute) {
102+
// Not a known Angular route.
103+
return null;
104+
}
105+
106+
const { redirectTo } = matchedRoute;
107+
if (redirectTo !== undefined) {
108+
// 302 Found is used by default for redirections
109+
// See: https://developer.mozilla.org/en-US/docs/Web/API/Response/redirect_static#status
110+
return Response.redirect(new URL(redirectTo, url), 302);
100111
}
101112

102-
return asset();
113+
return render(this, request, serverContext, requestContext);
103114
}
104115
}

packages/angular/ssr/src/assets.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
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 { AngularAppManifest } from './manifest';
10+
11+
/**
12+
* Manages server-side assets.
13+
*/
14+
export class ServerAssets {
15+
/**
16+
* Creates an instance of ServerAsset.
17+
*
18+
* @param manifest - The manifest containing the server assets.
19+
*/
20+
constructor(private readonly manifest: AngularAppManifest) {}
21+
22+
/**
23+
* Retrieves the content of a server-side asset using its path.
24+
*
25+
* @param path - The path to the server asset.
26+
* @returns A promise that resolves to the asset content as a string.
27+
* @throws Error If the asset path is not found in the manifest, an error is thrown.
28+
*/
29+
async getServerAsset(path: string): Promise<string> {
30+
const asset = this.manifest.assets[path];
31+
if (!asset) {
32+
throw new Error(`Server asset '${path}' does not exist.`);
33+
}
34+
35+
return asset();
36+
}
37+
38+
/**
39+
* Retrieves and caches the content of 'index.server.html'.
40+
*
41+
* @returns A promise that resolves to the content of 'index.server.html'.
42+
* @throws Error If there is an issue retrieving the asset.
43+
*/
44+
getIndexServerHtml(): Promise<string> {
45+
return this.getServerAsset('index.server.html');
46+
}
47+
}

packages/angular/ssr/src/manifest.ts

Lines changed: 30 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -6,25 +6,29 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9-
import { ApplicationRef, Type } from '@angular/core';
109
import type { AngularServerApp } from './app';
10+
import type { SerializableRouteTreeNode } from './routes/route-tree';
11+
import { AngularBootstrap } from './utils/ng';
1112

1213
/**
1314
* Manifest for the Angular server application engine, defining entry points.
1415
*/
1516
export interface AngularAppEngineManifest {
1617
/**
1718
* A map of entry points for the server application.
18-
* Each entry consists of:
19-
* - `key`: The base href.
19+
* Each entry in the map consists of:
20+
* - `key`: The base href for the entry point.
2021
* - `value`: A function that returns a promise resolving to an object containing the `AngularServerApp` type.
2122
*/
22-
entryPoints: Map<string, () => Promise<{ AngularServerApp: typeof AngularServerApp }>>;
23+
readonly entryPoints: Readonly<
24+
Map<string, () => Promise<{ AngularServerApp: typeof AngularServerApp }>>
25+
>;
2326

2427
/**
2528
* The base path for the server application.
29+
* This is used to determine the root path of the application.
2630
*/
27-
basePath: string;
31+
readonly basePath: string;
2832
}
2933

3034
/**
@@ -33,33 +37,42 @@ export interface AngularAppEngineManifest {
3337
export interface AngularAppManifest {
3438
/**
3539
* A record of assets required by the server application.
36-
* Each entry consists of:
40+
* Each entry in the record consists of:
3741
* - `key`: The path of the asset.
38-
* - `value`: A function returning a promise that resolves to the file contents.
42+
* - `value`: A function returning a promise that resolves to the file contents of the asset.
3943
*/
40-
assets: Record<string, () => Promise<string>>;
44+
readonly assets: Readonly<Record<string, () => Promise<string>>>;
4145

4246
/**
4347
* The bootstrap mechanism for the server application.
4448
* A function that returns a reference to an NgModule or a function returning a promise that resolves to an ApplicationRef.
4549
*/
46-
bootstrap: () => Type<unknown> | (() => Promise<ApplicationRef>);
50+
readonly bootstrap: () => AngularBootstrap;
4751

4852
/**
49-
* Indicates whether critical CSS should be inlined.
53+
* Indicates whether critical CSS should be inlined into the HTML.
54+
* If set to `true`, critical CSS will be inlined for faster page rendering.
5055
*/
51-
inlineCriticalCss?: boolean;
56+
readonly inlineCriticalCss?: boolean;
57+
58+
/**
59+
* The route tree representation for the routing configuration of the application.
60+
* This represents the routing information of the application, mapping route paths to their corresponding metadata.
61+
* It is used for route matching and navigation within the server application.
62+
*/
63+
readonly routes?: SerializableRouteTreeNode;
5264
}
5365

5466
/**
55-
* Angular app manifest object.
67+
* The Angular app manifest object.
68+
* This is used internally to store the current Angular app manifest.
5669
*/
5770
let angularAppManifest: AngularAppManifest | undefined;
5871

5972
/**
6073
* Sets the Angular app manifest.
6174
*
62-
* @param manifest - The manifest object to set.
75+
* @param manifest - The manifest object to set for the Angular application.
6376
*/
6477
export function setAngularAppManifest(manifest: AngularAppManifest): void {
6578
angularAppManifest = manifest;
@@ -74,7 +87,7 @@ export function setAngularAppManifest(manifest: AngularAppManifest): void {
7487
export function getAngularAppManifest(): AngularAppManifest {
7588
if (!angularAppManifest) {
7689
throw new Error(
77-
'Angular app manifest is not set.' +
90+
'Angular app manifest is not set. ' +
7891
`Please ensure you are using the '@angular/build:application' builder to build your server application.`,
7992
);
8093
}
@@ -83,7 +96,8 @@ export function getAngularAppManifest(): AngularAppManifest {
8396
}
8497

8598
/**
86-
* Angular app engine manifest object.
99+
* The Angular app engine manifest object.
100+
* This is used internally to store the current Angular app engine manifest.
87101
*/
88102
let angularAppEngineManifest: AngularAppEngineManifest | undefined;
89103

@@ -105,7 +119,7 @@ export function setAngularAppEngineManifest(manifest: AngularAppEngineManifest):
105119
export function getAngularAppEngineManifest(): AngularAppEngineManifest {
106120
if (!angularAppEngineManifest) {
107121
throw new Error(
108-
'Angular app engine manifest is not set.' +
122+
'Angular app engine manifest is not set. ' +
109123
`Please ensure you are using the '@angular/build:application' builder to build your server application.`,
110124
);
111125
}

packages/angular/ssr/src/render.ts

Lines changed: 3 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { ɵSERVER_CONTEXT as SERVER_CONTEXT } from '@angular/platform-server';
1111
import type { AngularServerApp } from './app';
1212
import { Console } from './console';
1313
import { REQUEST, REQUEST_CONTEXT, RESPONSE_INIT } from './tokens';
14-
import { renderAngular } from './utils';
14+
import { renderAngular } from './utils/ng';
1515

1616
/**
1717
* Enum representing the different contexts in which server rendering can occur.
@@ -82,23 +82,14 @@ export async function render(
8282
});
8383
}
8484

85-
let html = await app.getServerAsset('index.server.html');
85+
let html = await app.assets.getIndexServerHtml();
8686
// Skip extra microtask if there are no pre hooks.
8787
if (hooks.has('html:transform:pre')) {
8888
html = await hooks.run('html:transform:pre', { html });
8989
}
9090

91-
let url = request.url;
92-
93-
// A request to `http://www.example.com/page/index.html` will render the Angular route corresponding to `http://www.example.com/page`.
94-
if (url.includes('/index.html')) {
95-
const urlToModify = new URL(url);
96-
urlToModify.pathname = urlToModify.pathname.replace(/index\.html$/, '');
97-
url = urlToModify.toString();
98-
}
99-
10091
return new Response(
101-
await renderAngular(html, manifest.bootstrap(), url, platformProviders),
92+
await renderAngular(html, manifest.bootstrap(), new URL(request.url), platformProviders),
10293
responseInit,
10394
);
10495
}

0 commit comments

Comments
 (0)