Skip to content

Commit

Permalink
feat(@angular/ssr): dynamic route resolution using Angular router
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
alan-agius4 committed Aug 14, 2024
1 parent 37693c4 commit bca5683
Show file tree
Hide file tree
Showing 22 changed files with 1,353 additions and 176 deletions.
6 changes: 5 additions & 1 deletion goldens/circular-deps/packages.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@
"packages/angular/cli/src/analytics/analytics.ts",
"packages/angular/cli/src/command-builder/command-module.ts"
],
["packages/angular/ssr/src/app.ts", "packages/angular/ssr/src/manifest.ts"],
[
"packages/angular/ssr/src/app.ts",
"packages/angular/ssr/src/assets.ts",
"packages/angular/ssr/src/manifest.ts"
],
["packages/angular/ssr/src/app.ts", "packages/angular/ssr/src/render.ts"]
]
3 changes: 2 additions & 1 deletion packages/angular/ssr/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,12 @@ ts_library(
),
module_name = "@angular/ssr",
deps = [
"@npm//@angular/common",
"@npm//@angular/core",
"@npm//@angular/platform-server",
"@npm//@angular/router",
"@npm//@types/node",
"@npm//critters",
"@npm//mrmime",
],
)

Expand Down
14 changes: 8 additions & 6 deletions packages/angular/ssr/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,18 +14,20 @@
},
"dependencies": {
"critters": "0.0.24",
"mrmime": "2.0.0",
"tslib": "^2.3.0"
},
"peerDependencies": {
"@angular/common": "^18.0.0 || ^18.2.0-next.0",
"@angular/core": "^18.0.0 || ^18.2.0-next.0"
"@angular/core": "^18.0.0 || ^18.2.0-next.0",
"@angular/router": "^18.0.0 || ^18.2.0-next.0"
},
"devDependencies": {
"@angular/compiler": "18.2.0-next.2",
"@angular/platform-browser": "18.2.0-next.2",
"@angular/platform-server": "18.2.0-next.2",
"@angular/router": "18.2.0-next.2",
"@angular/common": "18.2.0-rc.0",
"@angular/compiler": "18.2.0-rc.0",
"@angular/core": "18.2.0-rc.0",
"@angular/platform-browser": "18.2.0-rc.0",
"@angular/platform-server": "18.2.0-rc.0",
"@angular/router": "18.2.0-rc.0",
"zone.js": "^0.14.0"
},
"schematics": "./schematics/collection.json",
Expand Down
22 changes: 0 additions & 22 deletions packages/angular/ssr/src/app-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
* found in the LICENSE file at https://angular.dev/license
*/

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

const entryPoint = this.getEntryPointFromUrl(url);
if (!entryPoint) {
Expand Down Expand Up @@ -131,20 +126,3 @@ export class AngularAppEngine {
return entryPoint ? [potentialLocale, entryPoint] : null;
}
}

/**
* Determines if the given pathname corresponds to a file-like resource.
*
* @param pathname - The pathname to check.
* @returns True if the pathname appears to be a file, false otherwise.
*/
function isFileLike(pathname: string): boolean {
const dotIndex = pathname.lastIndexOf('.');
if (dotIndex === -1) {
return false;
}

const extension = pathname.slice(dotIndex);

return extension === '.ico' || !!lookupMimeType(extension);
}
51 changes: 31 additions & 20 deletions packages/angular/ssr/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@
* found in the LICENSE file at https://angular.dev/license
*/

import { ServerAssets } from './assets';
import { Hooks } from './hooks';
import { getAngularAppManifest } from './manifest';
import { ServerRenderContext, render } from './render';
import { ServerRouter } from './routes/router';

/**
* Configuration options for initializing a `AngularServerApp` instance.
Expand Down Expand Up @@ -53,14 +55,25 @@ export class AngularServerApp {
*/
readonly isDevMode: boolean;

/**
* An instance of ServerAsset that handles server-side asset.
* @internal
*/
readonly assets = new ServerAssets(this.manifest);

/**
* The router instance used for route matching and handling.
*/
private router: ServerRouter | undefined;

/**
* Creates a new `AngularServerApp` instance with the provided configuration options.
*
* @param options - The configuration options for the server application.
* - `isDevMode`: Flag indicating if the application is in development mode.
* - `hooks`: Optional hooks for customizing application behavior.
*/
constructor(options: AngularServerAppOptions) {
constructor(readonly options: AngularServerAppOptions) {
this.isDevMode = options.isDevMode ?? false;
this.hooks = options.hooks ?? new Hooks();
}
Expand All @@ -74,31 +87,29 @@ export class AngularServerApp {
* @param requestContext - Optional additional context for rendering, such as request metadata.
* @param serverContext - The rendering context.
*
* @returns A promise that resolves to the HTTP response object resulting from the rendering.
* @returns A promise that resolves to the HTTP response object resulting from the rendering, or null if no match is found.
*/
render(
async render(
request: Request,
requestContext?: unknown,
serverContext: ServerRenderContext = ServerRenderContext.SSR,
): Promise<Response> {
return render(this, request, serverContext, requestContext);
}
): Promise<Response | null> {
const url = new URL(request.url);
this.router ??= await ServerRouter.from(this.manifest, url);

/**
* Retrieves the content of a server-side asset using its path.
*
* This method fetches the content of a specific asset defined in the server application's manifest.
*
* @param path - The path to the server asset.
* @returns A promise that resolves to the asset content as a string.
* @throws Error If the asset path is not found in the manifest, an error is thrown.
*/
async getServerAsset(path: string): Promise<string> {
const asset = this.manifest.assets[path];
if (!asset) {
throw new Error(`Server asset '${path}' does not exist.`);
const matchedRoute = this.router.match(url);
if (!matchedRoute) {
// Not a known Angular route.
return null;
}

const { redirectTo } = matchedRoute;
if (redirectTo !== undefined) {
// 302 Found is used by default for redirections
// See: https://developer.mozilla.org/en-US/docs/Web/API/Response/redirect_static#status
return Response.redirect(new URL(redirectTo, url), 302);
}

return asset();
return render(this, request, serverContext, requestContext);
}
}
47 changes: 47 additions & 0 deletions packages/angular/ssr/src/assets.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/**
* @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 { AngularAppManifest } from './manifest';

/**
* Manages server-side assets.
*/
export class ServerAssets {
/**
* Creates an instance of ServerAsset.
*
* @param manifest - The manifest containing the server assets.
*/
constructor(private readonly manifest: AngularAppManifest) {}

/**
* Retrieves the content of a server-side asset using its path.
*
* @param path - The path to the server asset.
* @returns A promise that resolves to the asset content as a string.
* @throws Error If the asset path is not found in the manifest, an error is thrown.
*/
async getServerAsset(path: string): Promise<string> {
const asset = this.manifest.assets[path];
if (!asset) {
throw new Error(`Server asset '${path}' does not exist.`);
}

return asset();
}

/**
* Retrieves and caches the content of 'index.server.html'.
*
* @returns A promise that resolves to the content of 'index.server.html'.
* @throws Error If there is an issue retrieving the asset.
*/
getIndexServerHtml(): Promise<string> {
return this.getServerAsset('index.server.html');
}
}
46 changes: 30 additions & 16 deletions packages/angular/ssr/src/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,25 +6,29 @@
* found in the LICENSE file at https://angular.dev/license
*/

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

/**
* Manifest for the Angular server application engine, defining entry points.
*/
export interface AngularAppEngineManifest {
/**
* A map of entry points for the server application.
* Each entry consists of:
* - `key`: The base href.
* Each entry in the map consists of:
* - `key`: The base href for the entry point.
* - `value`: A function that returns a promise resolving to an object containing the `AngularServerApp` type.
*/
entryPoints: Map<string, () => Promise<{ AngularServerApp: typeof AngularServerApp }>>;
readonly entryPoints: Readonly<
Map<string, () => Promise<{ AngularServerApp: typeof AngularServerApp }>>
>;

/**
* The base path for the server application.
* This is used to determine the root path of the application.
*/
basePath: string;
readonly basePath: string;
}

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

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

/**
* Indicates whether critical CSS should be inlined.
* Indicates whether critical CSS should be inlined into the HTML.
* If set to `true`, critical CSS will be inlined for faster page rendering.
*/
inlineCriticalCss?: boolean;
readonly inlineCriticalCss?: boolean;

/**
* The route tree representation for the routing configuration of the application.
* This represents the routing information of the application, mapping route paths to their corresponding metadata.
* It is used for route matching and navigation within the server application.
*/
readonly routes?: SerializableRouteTreeNode;
}

/**
* Angular app manifest object.
* The Angular app manifest object.
* This is used internally to store the current Angular app manifest.
*/
let angularAppManifest: AngularAppManifest | undefined;

/**
* Sets the Angular app manifest.
*
* @param manifest - The manifest object to set.
* @param manifest - The manifest object to set for the Angular application.
*/
export function setAngularAppManifest(manifest: AngularAppManifest): void {
angularAppManifest = manifest;
Expand All @@ -74,7 +87,7 @@ export function setAngularAppManifest(manifest: AngularAppManifest): void {
export function getAngularAppManifest(): AngularAppManifest {
if (!angularAppManifest) {
throw new Error(
'Angular app manifest is not set.' +
'Angular app manifest is not set. ' +
`Please ensure you are using the '@angular/build:application' builder to build your server application.`,
);
}
Expand All @@ -83,7 +96,8 @@ export function getAngularAppManifest(): AngularAppManifest {
}

/**
* Angular app engine manifest object.
* The Angular app engine manifest object.
* This is used internally to store the current Angular app engine manifest.
*/
let angularAppEngineManifest: AngularAppEngineManifest | undefined;

Expand All @@ -105,7 +119,7 @@ export function setAngularAppEngineManifest(manifest: AngularAppEngineManifest):
export function getAngularAppEngineManifest(): AngularAppEngineManifest {
if (!angularAppEngineManifest) {
throw new Error(
'Angular app engine manifest is not set.' +
'Angular app engine manifest is not set. ' +
`Please ensure you are using the '@angular/build:application' builder to build your server application.`,
);
}
Expand Down
15 changes: 3 additions & 12 deletions packages/angular/ssr/src/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { ɵSERVER_CONTEXT as SERVER_CONTEXT } from '@angular/platform-server';
import type { AngularServerApp } from './app';
import { Console } from './console';
import { REQUEST, REQUEST_CONTEXT, RESPONSE_INIT } from './tokens';
import { renderAngular } from './utils';
import { renderAngular } from './utils/ng';

/**
* Enum representing the different contexts in which server rendering can occur.
Expand Down Expand Up @@ -82,23 +82,14 @@ export async function render(
});
}

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

let url = request.url;

// A request to `http://www.example.com/page/index.html` will render the Angular route corresponding to `http://www.example.com/page`.
if (url.includes('/index.html')) {
const urlToModify = new URL(url);
urlToModify.pathname = urlToModify.pathname.replace(/index\.html$/, '');
url = urlToModify.toString();
}

return new Response(
await renderAngular(html, manifest.bootstrap(), url, platformProviders),
await renderAngular(html, manifest.bootstrap(), new URL(request.url), platformProviders),
responseInit,
);
}
Loading

0 comments on commit bca5683

Please sign in to comment.