refactor(@angular/ssr): expose private APIs for build system integration and refactor app management

- Exposed several utility functions as private APIs to support the integration with the build system.
- Removed `isDevMode` and caching logic from `AngularAppEngine`. This was necessary to better handle updates when using Vite. Instead, `AngularServerApp` is now treated as a singleton to simplify management.
- Switched asset storage from an `Object` to a `Map` in the manifest for improved efficiency and consistency.

This refactor sets the groundwork for seamless wiring with the build system.
This commit is contained in:
Alan Agius 2024-08-21 06:48:00 +00:00 committed by Alan Agius
parent d6155d2761
commit 1c185183c3
13 changed files with 158 additions and 163 deletions

View File

@ -0,0 +1,18 @@
/**
* @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
*/
export { ServerRenderContext as ɵServerRenderContext } from './src/render';
export { getRoutesFromAngularRouterConfig as ɵgetRoutesFromAngularRouterConfig } from './src/routes/ng-routes';
export {
getOrCreateAngularServerApp as ɵgetOrCreateAngularServerApp,
destroyAngularServerApp as ɵdestroyAngularServerApp,
} from './src/app';
export {
setAngularAppManifest as ɵsetAngularAppManifest,
setAngularAppEngineManifest as ɵsetAngularAppEngineManifest,
} from './src/manifest';

View File

@ -12,4 +12,4 @@ export {
type CommonEngineOptions,
} from './src/common-engine/common-engine';
export { getRoutesFromAngularRouterConfig as ɵgetRoutesFromAngularRouterConfig } from './src/routes/ng-routes';
export * from './private_export';

View File

@ -6,10 +6,9 @@
* found in the LICENSE file at https://angular.dev/license
*/
import { AngularServerApp } from './app';
import { Hooks } from './hooks';
import { getPotentialLocaleIdFromUrl } from './i18n';
import { getAngularAppEngineManifest } from './manifest';
import { EntryPointExports, getAngularAppEngineManifest } from './manifest';
/**
* Angular server application engine.
@ -19,43 +18,33 @@ import { getAngularAppEngineManifest } from './manifest';
export class AngularAppEngine {
/**
* Hooks for extending or modifying the behavior of the server application.
* @internal This property is accessed by the Angular CLI when running the dev-server.
* These hooks are used by the Angular CLI when running the development server and
* provide extensibility points for the application lifecycle.
*
* @internal
*/
static hooks = new Hooks();
/**
* Hooks for extending or modifying the behavior of the server application.
* This instance can be used to attach custom functionality to various events in the server application lifecycle.
* Provides access to the hooks for extending or modifying the server application's behavior.
* This allows attaching custom functionality to various server application lifecycle events.
*
* @internal
*/
get hooks(): Hooks {
return AngularAppEngine.hooks;
}
/**
* Specifies if the application is operating in development mode.
* This property controls the activation of features intended for production, such as caching mechanisms.
* @internal
*/
static isDevMode = false;
/**
* The manifest for the server application.
*/
private readonly manifest = getAngularAppEngineManifest();
/**
* Map of locale strings to corresponding `AngularServerApp` instances.
* Each instance represents an Angular server application.
*/
private readonly appsCache = new Map<string, AngularServerApp>();
/**
* Renders an HTTP request using the appropriate Angular server application and returns a response.
* Renders a response for the given HTTP request using the server application.
*
* This method determines the entry point for the Angular server application based on the request URL,
* and caches the server application instances for reuse. If the application is in development mode,
* the cache is bypassed and a new instance is created for each request.
* This method processes the request, determines the appropriate route and rendering context,
* and returns an HTTP response.
*
* If the request URL appears to be for a file (excluding `/index.html`), the method returns `null`.
* A request to `https://www.example.com/page/index.html` will render the Angular route
@ -69,25 +58,14 @@ 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 entryPoint = this.getEntryPointFromUrl(url);
if (!entryPoint) {
return null;
}
const [locale, loadModule] = entryPoint;
let serverApp = this.appsCache.get(locale);
if (!serverApp) {
const { AngularServerApp } = await loadModule();
serverApp = new AngularServerApp({
isDevMode: AngularAppEngine.isDevMode,
hooks: this.hooks,
});
if (!AngularAppEngine.isDevMode) {
this.appsCache.set(locale, serverApp);
}
}
const { ɵgetOrCreateAngularServerApp: getOrCreateAngularServerApp } = await entryPoint();
const serverApp = getOrCreateAngularServerApp();
serverApp.hooks = this.hooks;
return serverApp.render(request, requestContext);
}
@ -99,30 +77,18 @@ export class AngularAppEngine {
* If there is only one entry point available, it is returned regardless of the URL.
* Otherwise, the method extracts a potential locale identifier from the URL and looks up the corresponding entry point.
*
* @param url - The URL used to derive the locale and determine the entry point.
* @returns An array containing:
* - The first element is the locale extracted from the URL.
* - The second element is a function that returns a promise resolving to an object with the `AngularServerApp` type.
*
* Returns `null` if no matching entry point is found for the extracted locale.
* @param url - The URL used to derive the locale and determine the appropriate entry point.
* @returns A function that returns a promise resolving to an object with the `EntryPointExports` type,
* or `undefined` if no matching entry point is found for the extracted locale.
*/
private getEntryPointFromUrl(url: URL):
| [
locale: string,
loadModule: () => Promise<{
AngularServerApp: typeof AngularServerApp;
}>,
]
| null {
// Find bundle for locale
private getEntryPointFromUrl(url: URL): (() => Promise<EntryPointExports>) | undefined {
const { entryPoints, basePath } = this.manifest;
if (entryPoints.size === 1) {
return entryPoints.entries().next().value;
return entryPoints.values().next().value;
}
const potentialLocale = getPotentialLocaleIdFromUrl(url, basePath);
const entryPoint = entryPoints.get(potentialLocale);
return entryPoint ? [potentialLocale, entryPoint] : null;
return entryPoints.get(potentialLocale);
}
}

View File

@ -12,49 +12,24 @@ import { getAngularAppManifest } from './manifest';
import { ServerRenderContext, render } from './render';
import { ServerRouter } from './routes/router';
/**
* Configuration options for initializing a `AngularServerApp` instance.
*/
export interface AngularServerAppOptions {
/**
* Indicates whether the application is in development mode.
*
* When set to `true`, the application runs in development mode with additional debugging features.
*/
isDevMode?: boolean;
/**
* Optional hooks for customizing the server application's behavior.
*/
hooks?: Hooks;
}
/**
* Represents a locale-specific Angular server application managed by the server application engine.
*
* The `AngularServerApp` class handles server-side rendering and asset management for a specific locale.
*/
export class AngularServerApp {
/**
* Hooks for extending or modifying the behavior of the server application.
* This instance can be used to attach custom functionality to various events in the server application lifecycle.
*/
hooks = new Hooks();
/**
* The manifest associated with this server application.
* @internal
*/
readonly manifest = getAngularAppManifest();
/**
* Hooks for extending or modifying the behavior of the server application.
* This instance can be used to attach custom functionality to various events in the server application lifecycle.
* @internal
*/
readonly hooks: Hooks;
/**
* Specifies if the server application is operating in development mode.
* This property controls the activation of features intended for production, such as caching mechanisms.
* @internal
*/
readonly isDevMode: boolean;
/**
* An instance of ServerAsset that handles server-side asset.
* @internal
@ -66,18 +41,6 @@ export class AngularServerApp {
*/
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(readonly options: AngularServerAppOptions) {
this.isDevMode = options.isDevMode ?? false;
this.hooks = options.hooks ?? new Hooks();
}
/**
* Renders a response for the given HTTP request using the server application.
*
@ -113,3 +76,34 @@ export class AngularServerApp {
return render(this, request, serverContext, requestContext);
}
}
let angularServerApp: AngularServerApp | undefined;
/**
* Retrieves or creates an instance of `AngularServerApp`.
* - If an instance of `AngularServerApp` already exists, it will return the existing one.
* - If no instance exists, it will create a new one with the provided options.
* @returns The existing or newly created instance of `AngularServerApp`.
*/
export function getOrCreateAngularServerApp(): AngularServerApp {
return (angularServerApp ??= new AngularServerApp());
}
/**
* Resets the instance of `AngularServerApp` to undefined, effectively
* clearing the reference. Use this to recreate the instance.
*/
export function resetAngularServerApp(): void {
angularServerApp = undefined;
}
/**
* Destroys the existing `AngularServerApp` instance, releasing associated resources and resetting the
* reference to `undefined`.
*
* This function is primarily used to enable the recreation of the `AngularServerApp` instance,
* typically when server configuration or application state needs to be refreshed.
*/
export function destroyAngularServerApp(): void {
angularServerApp = undefined;
}

View File

@ -27,7 +27,7 @@ export class ServerAssets {
* @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];
const asset = this.manifest.assets.get(path);
if (!asset) {
throw new Error(`Server asset '${path}' does not exist.`);
}

9
packages/angular/ssr/src/global.d.ts vendored Normal file
View File

@ -0,0 +1,9 @@
/**
* @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
*/
declare const ngDevMode: boolean | undefined;

View File

@ -6,10 +6,25 @@
* found in the LICENSE file at https://angular.dev/license
*/
import type { AngularServerApp } from './app';
import type { destroyAngularServerApp, getOrCreateAngularServerApp } from './app';
import type { SerializableRouteTreeNode } from './routes/route-tree';
import { AngularBootstrap } from './utils/ng';
/**
* Represents the exports of an Angular server application entry point.
*/
export interface EntryPointExports {
/**
* A reference to the function that creates an Angular server application instance.
*/
ɵgetOrCreateAngularServerApp: typeof getOrCreateAngularServerApp;
/**
* A reference to the function that destroys the `AngularServerApp` instance.
*/
ɵdestroyAngularServerApp: typeof destroyAngularServerApp;
}
/**
* Manifest for the Angular server application engine, defining entry points.
*/
@ -18,11 +33,9 @@ export interface AngularAppEngineManifest {
* A map of entry points for the server application.
* 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.
* - `value`: A function that returns a promise resolving to an object of type `EntryPointExports`.
*/
readonly entryPoints: Readonly<
Map<string, () => Promise<{ AngularServerApp: typeof AngularServerApp }>>
>;
readonly entryPoints: Readonly<Map<string, () => Promise<EntryPointExports>>>;
/**
* The base path for the server application.
@ -36,12 +49,12 @@ export interface AngularAppEngineManifest {
*/
export interface AngularAppManifest {
/**
* A record of assets required by the server application.
* Each entry in the record consists of:
* A map of assets required by the server application.
* Each entry in the map consists of:
* - `key`: The path of the asset.
* - `value`: A function returning a promise that resolves to the file contents of the asset.
*/
readonly assets: Readonly<Record<string, () => Promise<string>>>;
readonly assets: Readonly<Map<string, () => Promise<string>>>;
/**
* The bootstrap mechanism for the server application.

View File

@ -64,25 +64,25 @@ export async function render(
);
}
const { manifest, hooks, isDevMode } = app;
if (isDevMode) {
if (typeof ngDevMode === 'undefined' || ngDevMode) {
// Need to clean up GENERATED_COMP_IDS map in `@angular/core`.
// Otherwise an incorrect component ID generation collision detected warning will be displayed in development.
// See: https://github.com/angular/angular-cli/issues/25924
ɵresetCompiledComponents();
// An Angular Console Provider that does not print a set of predefined logs.
platformProviders.push({
provide: ɵConsole,
// Using `useClass` would necessitate decorating `Console` with `@Injectable`,
// which would require switching from `ts_library` to `ng_module`. This change
// would also necessitate various patches of `@angular/bazel` to support ESM.
useFactory: () => new Console(),
});
}
let html = await app.assets.getIndexServerHtml();
// An Angular Console Provider that does not print a set of predefined logs.
platformProviders.push({
provide: ɵConsole,
// Using `useClass` would necessitate decorating `Console` with `@Injectable`,
// which would require switching from `ts_library` to `ng_module`. This change
// would also necessitate various patches of `@angular/bazel` to support ESM.
useFactory: () => new Console(),
});
const { manifest, hooks, assets } = app;
let html = await 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 });

View File

@ -188,10 +188,12 @@ export async function getRoutesFromAngularRouterConfig(
document: string,
url: URL,
): Promise<AngularRouterConfigResult> {
// Need to clean up GENERATED_COMP_IDS map in `@angular/core`.
// Otherwise an incorrect component ID generation collision detected warning will be displayed in development.
// See: https://github.com/angular/angular-cli/issues/25924
ɵresetCompiledComponents();
if (typeof ngDevMode === 'undefined' || ngDevMode) {
// Need to clean up GENERATED_COMP_IDS map in `@angular/core`.
// Otherwise an incorrect component ID generation collision detected warning will be displayed in development.
// See: https://github.com/angular/angular-cli/issues/25924
ɵresetCompiledComponents();
}
const { protocol, host } = url;

View File

@ -11,8 +11,8 @@ import 'zone.js/node';
import '@angular/compiler';
/* eslint-enable import/no-unassigned-import */
import { Component, ɵresetCompiledComponents } from '@angular/core';
import { AngularServerApp } from '../src/app';
import { Component } from '@angular/core';
import { destroyAngularServerApp, getOrCreateAngularServerApp } from '../src/app';
import { AngularAppEngine } from '../src/app-engine';
import { setAngularAppEngineManifest } from '../src/manifest';
import { setAngularAppTestingManifest } from './testing-utils';
@ -21,14 +21,9 @@ describe('AngularAppEngine', () => {
let appEngine: AngularAppEngine;
describe('Localized app', () => {
beforeEach(() => {
// Need to clean up GENERATED_COMP_IDS map in `@angular/core`.
// Otherwise an incorrect component ID generation collision detected warning will be displayed.
// See: https://github.com/angular/angular-cli/issues/25924
ɵresetCompiledComponents();
});
beforeAll(() => {
destroyAngularServerApp();
setAngularAppEngineManifest({
// Note: Although we are testing only one locale, we need to configure two or more
// to ensure that we test a different code path.
@ -45,7 +40,10 @@ describe('AngularAppEngine', () => {
setAngularAppTestingManifest([{ path: 'home', component: HomeComponent }], locale);
return { AngularServerApp };
return {
ɵgetOrCreateAngularServerApp: getOrCreateAngularServerApp,
ɵdestroyAngularServerApp: destroyAngularServerApp,
};
},
]),
),
@ -93,14 +91,9 @@ describe('AngularAppEngine', () => {
});
describe('Non-localized app', () => {
beforeEach(() => {
// Need to clean up GENERATED_COMP_IDS map in `@angular/core`.
// Otherwise an incorrect component ID generation collision detected warning will be displayed.
// See: https://github.com/angular/angular-cli/issues/25924
ɵresetCompiledComponents();
});
beforeAll(() => {
destroyAngularServerApp();
setAngularAppEngineManifest({
entryPoints: new Map([
[
@ -115,7 +108,10 @@ describe('AngularAppEngine', () => {
setAngularAppTestingManifest([{ path: 'home', component: HomeComponent }]);
return { AngularServerApp };
return {
ɵgetOrCreateAngularServerApp: getOrCreateAngularServerApp,
ɵdestroyAngularServerApp: destroyAngularServerApp,
};
},
],
]),

View File

@ -11,22 +11,17 @@ import 'zone.js/node';
import '@angular/compiler';
/* eslint-enable import/no-unassigned-import */
import { Component, ɵresetCompiledComponents } from '@angular/core';
import { AngularServerApp } from '../src/app';
import { Component } from '@angular/core';
import { AngularServerApp, destroyAngularServerApp } from '../src/app';
import { ServerRenderContext } from '../src/render';
import { setAngularAppTestingManifest } from './testing-utils';
describe('AngularServerApp', () => {
let app: AngularServerApp;
beforeEach(() => {
// Need to clean up GENERATED_COMP_IDS map in `@angular/core`.
// Otherwise an incorrect component ID generation collision detected warning will be displayed.
// See: https://github.com/angular/angular-cli/issues/25924
ɵresetCompiledComponents();
});
beforeAll(() => {
destroyAngularServerApp();
@Component({
standalone: true,
selector: 'app-home',
@ -41,9 +36,7 @@ describe('AngularServerApp', () => {
{ path: 'redirect/absolute', redirectTo: '/home' },
]);
app = new AngularServerApp({
isDevMode: true,
});
app = new AngularServerApp();
});
describe('render', () => {
@ -83,7 +76,7 @@ describe('AngularServerApp', () => {
expect(response?.status).toBe(302);
});
it('should correctly handle absoloute nested redirects', async () => {
it('should correctly handle absolute nested redirects', async () => {
const response = await app.render(new Request('http://localhost/redirect/absolute'));
expect(response?.headers.get('location')).toContain('http://localhost/home');
expect(response?.status).toBe(302);

View File

@ -15,10 +15,12 @@ describe('ServerAsset', () => {
beforeAll(() => {
assetManager = new ServerAssets({
bootstrap: undefined as never,
assets: {
'index.server.html': async () => '<html>Index</html>',
'index.other.html': async () => '<html>Other</html>',
},
assets: new Map(
Object.entries({
'index.server.html': async () => '<html>Index</html>',
'index.other.html': async () => '<html>Other</html>',
}),
),
});
});

View File

@ -24,9 +24,10 @@ import { setAngularAppManifest } from '../src/manifest';
export function setAngularAppTestingManifest(routes: Routes, baseHref = ''): void {
setAngularAppManifest({
inlineCriticalCss: false,
assets: {
'index.server.html': async () =>
`
assets: new Map(
Object.entries({
'index.server.html': async () =>
`
<html>
<head>
<base href="/${baseHref}" />
@ -35,7 +36,8 @@ export function setAngularAppTestingManifest(routes: Routes, baseHref = ''): voi
<app-root></app-root>
</body>
</html>`,
},
}),
),
bootstrap: () => () => {
@Component({
standalone: true,