Skip to content

Commit 3c9697a

Browse files
committed
feat(@angular/ssr): introduce new hybrid rendering API
This commit introduces the new hybrid rendering API for Angular's Server-Side Rendering (SSR). The API aims to enhance the flexibility of SSR as discussed in angular/angular#56785 - This API is currently not accessible. - Additional work is required in the Angular CLI to: - Wire up the manifest. - Integrate other necessary components.
1 parent ad4c782 commit 3c9697a

31 files changed

+1981
-16
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
diff --git a/bazel/spec-bundling/esbuild.config-tmpl.mjs b/bazel/spec-bundling/esbuild.config-tmpl.mjs
2+
index b7c0e373287a5a969a7de7362949e2bb082090db..642bbd9a17a0dd8d602746fc3db42fba0e1625a2 100644
3+
--- a/bazel/spec-bundling/esbuild.config-tmpl.mjs
4+
+++ b/bazel/spec-bundling/esbuild.config-tmpl.mjs
5+
@@ -6,8 +6,6 @@
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
-import {createEsbuildAngularOptimizePlugin} from '@angular/build-tooling/shared-scripts/angular-optimization/esbuild-plugin.mjs';
10+
-
11+
// List of supported features as per ESBuild. See:
12+
// https://esbuild.github.io/api/#supported.
13+
const supported = {};
14+
@@ -35,20 +33,4 @@ export default {
15+
// https://esbuild.github.io/api/#keep-names.
16+
keepNames: true,
17+
supported,
18+
- plugins: [
19+
- await createEsbuildAngularOptimizePlugin({
20+
- optimize: undefined,
21+
- downlevelAsyncGeneratorsIfPresent: downlevelAsyncAwait,
22+
- enableLinker: TMPL_RUN_LINKER
23+
- ? {
24+
- ensureNoPartialDeclaration: true,
25+
- linkerOptions: {
26+
- // JIT mode is needed for tests overriding components/modules etc.
27+
- linkerJitMode: true,
28+
- unknownDeclarationVersionHandling: TMPL_LINKER_UNKNOWN_DECLARATION_HANDLING,
29+
- },
30+
- }
31+
- : undefined,
32+
- }),
33+
- ],
34+
};
35+
diff --git a/bazel/spec-bundling/spec-bundle.bzl b/bazel/spec-bundling/spec-bundle.bzl
36+
index f057d94cefb98100eba7d2c04b82578a80594a11..ea4e677df69c0cd4c672658bff42af00a77d5bf5 100644
37+
--- a/bazel/spec-bundling/spec-bundle.bzl
38+
+++ b/bazel/spec-bundling/spec-bundle.bzl
39+
@@ -64,7 +64,6 @@ def spec_bundle(
40+
name = "%s_config" % name,
41+
config_file = ":%s_config_file" % name,
42+
testonly = True,
43+
- deps = ["@npm//@angular/build-tooling/shared-scripts/angular-optimization:js_lib"],
44+
)
45+
46+
if is_browser_test and not workspace_name:

WORKSPACE

+6
Original file line numberDiff line numberDiff line change
@@ -152,3 +152,9 @@ register_toolchains(
152152
load("@npm//@angular/build-tooling/bazel/browsers:browser_repositories.bzl", "browser_repositories")
153153

154154
browser_repositories()
155+
156+
load("@build_bazel_rules_nodejs//toolchains/esbuild:esbuild_repositories.bzl", "esbuild_repositories")
157+
158+
esbuild_repositories(
159+
npm_repository = "npm",
160+
)

goldens/circular-deps/packages.json

+3-1
Original file line numberDiff line numberDiff line change
@@ -23,5 +23,7 @@
2323
[
2424
"packages/angular/cli/src/analytics/analytics.ts",
2525
"packages/angular/cli/src/command-builder/command-module.ts"
26-
]
26+
],
27+
["packages/angular/ssr/src/app.ts", "packages/angular/ssr/src/manifest.ts"],
28+
["packages/angular/ssr/src/app.ts", "packages/angular/ssr/src/render.ts"]
2729
]

package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@
5454
"@ampproject/remapping": "2.3.0",
5555
"@angular/animations": "18.2.0-rc.0",
5656
"@angular/bazel": "patch:@angular/bazel@https%3A//github.com/angular/bazel-builds.git%23commit=71bd2e043e076365effdb6076f33b2d8d6bd6d02#~/.yarn/patches/@angular-bazel-https-9848736cf4.patch",
57-
"@angular/build-tooling": "https://github.com/angular/dev-infra-private-build-tooling-builds.git#8128c8cc982b49ca12490da8d97692143aefd026",
57+
"@angular/build-tooling": "patch:@angular/build-tooling@https%3A//github.com/angular/dev-infra-private-build-tooling-builds.git%23commit=b7b27dd03b146b43caab5762a9304c47d8fde3e4#~/.yarn/patches/@angular-build-tooling-https-06f4984cdf.patch",
5858
"@angular/cdk": "18.1.3",
5959
"@angular/common": "18.2.0-rc.0",
6060
"@angular/compiler": "18.2.0-rc.0",

packages/angular/ssr/BUILD.bazel

+11-5
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,28 @@
1+
load("@npm//@angular/build-tooling/bazel/api-golden:index.bzl", "api_golden_test_npm_package")
12
load("@rules_pkg//:pkg.bzl", "pkg_tar")
23
load("//tools:defaults.bzl", "ng_package", "ts_library")
3-
load("@npm//@angular/build-tooling/bazel/api-golden:index.bzl", "api_golden_test_npm_package")
44

55
package(default_visibility = ["//visibility:public"])
66

77
ts_library(
88
name = "ssr",
99
package_name = "@angular/ssr",
10-
srcs = glob([
11-
"*.ts",
12-
"src/**/*.ts",
13-
]),
10+
srcs = glob(
11+
include = [
12+
"*.ts",
13+
"src/**/*.ts",
14+
],
15+
exclude = [
16+
"**/*_spec.ts",
17+
],
18+
),
1419
module_name = "@angular/ssr",
1520
deps = [
1621
"@npm//@angular/core",
1722
"@npm//@angular/platform-server",
1823
"@npm//@types/node",
1924
"@npm//critters",
25+
"@npm//mrmime",
2026
],
2127
)
2228

packages/angular/ssr/package.json

+8
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,20 @@
1414
},
1515
"dependencies": {
1616
"critters": "0.0.24",
17+
"mrmime": "2.0.0",
1718
"tslib": "^2.3.0"
1819
},
1920
"peerDependencies": {
2021
"@angular/common": "^18.0.0 || ^18.2.0-next.0",
2122
"@angular/core": "^18.0.0 || ^18.2.0-next.0"
2223
},
24+
"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",
29+
"zone.js": "^0.14.0"
30+
},
2331
"schematics": "./schematics/collection.json",
2432
"repository": {
2533
"type": "git",

packages/angular/ssr/public_api.ts

+6-1
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,9 @@ export {
1010
CommonEngine,
1111
type CommonEngineRenderOptions,
1212
type CommonEngineOptions,
13-
} from './src/common-engine';
13+
} from './src/common-engine/common-engine';
14+
15+
// TODO(alanagius): enable at a later stage
16+
// export { AngularAppEngine } from './src/app-engine';
17+
// export { AngularServerApp } from './src/app';
18+
// export { REQUEST, REQUEST_CONTEXT, RESPONSE_INIT } from './src/tokens';
+150
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
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 { lookup as lookupMimeType } from 'mrmime';
10+
import { AngularServerApp } from './app';
11+
import { Hooks } from './hooks';
12+
import { getPotentialLocaleIdFromUrl } from './i18n';
13+
import { getAngularAppEngineManifest } from './manifest';
14+
15+
/**
16+
* Angular server application engine.
17+
* Manages Angular server applications (including localized ones), handles rendering requests,
18+
* and optionally transforms index HTML before rendering.
19+
*/
20+
export class AngularAppEngine {
21+
/**
22+
* Hooks for extending or modifying the behavior of the server application.
23+
* @internal This property is accessed by the Angular CLI when running the dev-server.
24+
*/
25+
static hooks = new Hooks();
26+
27+
/**
28+
* Hooks for extending or modifying the behavior of the server application.
29+
* This instance can be used to attach custom functionality to various events in the server application lifecycle.
30+
* @internal
31+
*/
32+
get hooks(): Hooks {
33+
return AngularAppEngine.hooks;
34+
}
35+
36+
/**
37+
* Specifies if the application is operating in development mode.
38+
* This property controls the activation of features intended for production, such as caching mechanisms.
39+
* @internal
40+
*/
41+
static isDevMode = false;
42+
43+
/**
44+
* The manifest for the server application.
45+
*/
46+
private readonly manifest = getAngularAppEngineManifest();
47+
48+
/**
49+
* Map of locale strings to corresponding `AngularServerApp` instances.
50+
* Each instance represents an Angular server application.
51+
*/
52+
private readonly appsCache = new Map<string, AngularServerApp>();
53+
54+
/**
55+
* Renders an HTTP request using the appropriate Angular server application and returns a response.
56+
*
57+
* This method determines the entry point for the Angular server application based on the request URL,
58+
* and caches the server application instances for reuse. If the application is in development mode,
59+
* the cache is bypassed and a new instance is created for each request.
60+
*
61+
* If the request URL appears to be for a file (excluding `/index.html`), the method returns `null`.
62+
* A request to `https://www.example.com/page/index.html` will render the Angular route
63+
* corresponding to `https://www.example.com/page`.
64+
*
65+
* @param request - The incoming HTTP request object to be rendered.
66+
* @param requestContext - Optional additional context for the request, such as metadata.
67+
* @returns A promise that resolves to a Response object, or `null` if the request URL represents a file (e.g., `./logo.png`)
68+
* rather than an application route.
69+
*/
70+
async render(request: Request, requestContext?: unknown): Promise<Response | null> {
71+
// Skip if the request looks like a file but not `/index.html`.
72+
const url = new URL(request.url);
73+
const { pathname } = url;
74+
if (isFileLike(pathname) && !pathname.endsWith('/index.html')) {
75+
return null;
76+
}
77+
78+
const entryPoint = this.getEntryPointFromUrl(url);
79+
if (!entryPoint) {
80+
return null;
81+
}
82+
83+
const [locale, loadModule] = entryPoint;
84+
let serverApp = this.appsCache.get(locale);
85+
if (!serverApp) {
86+
const { AngularServerApp } = await loadModule();
87+
serverApp = new AngularServerApp({
88+
isDevMode: AngularAppEngine.isDevMode,
89+
hooks: this.hooks,
90+
});
91+
92+
if (!AngularAppEngine.isDevMode) {
93+
this.appsCache.set(locale, serverApp);
94+
}
95+
}
96+
97+
return serverApp.render(request, requestContext);
98+
}
99+
100+
/**
101+
* Retrieves the entry point path and locale for the Angular server application based on the provided URL.
102+
*
103+
* This method determines the appropriate entry point and locale for rendering the application by examining the URL.
104+
* If there is only one entry point available, it is returned regardless of the URL.
105+
* Otherwise, the method extracts a potential locale identifier from the URL and looks up the corresponding entry point.
106+
*
107+
* @param url - The URL used to derive the locale and determine the entry point.
108+
* @returns An array containing:
109+
* - The first element is the locale extracted from the URL.
110+
* - The second element is a function that returns a promise resolving to an object with the `AngularServerApp` type.
111+
*
112+
* Returns `null` if no matching entry point is found for the extracted locale.
113+
*/
114+
private getEntryPointFromUrl(url: URL):
115+
| [
116+
locale: string,
117+
loadModule: () => Promise<{
118+
AngularServerApp: typeof AngularServerApp;
119+
}>,
120+
]
121+
| null {
122+
// Find bundle for locale
123+
const { entryPoints, basePath } = this.manifest;
124+
if (entryPoints.size === 1) {
125+
return entryPoints.entries().next().value;
126+
}
127+
128+
const potentialLocale = getPotentialLocaleIdFromUrl(url, basePath);
129+
const entryPoint = entryPoints.get(potentialLocale);
130+
131+
return entryPoint ? [potentialLocale, entryPoint] : null;
132+
}
133+
}
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

+104
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
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 { Hooks } from './hooks';
10+
import { getAngularAppManifest } from './manifest';
11+
import { ServerRenderContext, render } from './render';
12+
13+
/**
14+
* Configuration options for initializing a `AngularServerApp` instance.
15+
*/
16+
export interface AngularServerAppOptions {
17+
/**
18+
* Indicates whether the application is in development mode.
19+
*
20+
* When set to `true`, the application runs in development mode with additional debugging features.
21+
*/
22+
isDevMode?: boolean;
23+
24+
/**
25+
* Optional hooks for customizing the server application's behavior.
26+
*/
27+
hooks?: Hooks;
28+
}
29+
30+
/**
31+
* Represents a locale-specific Angular server application managed by the server application engine.
32+
*
33+
* The `AngularServerApp` class handles server-side rendering and asset management for a specific locale.
34+
*/
35+
export class AngularServerApp {
36+
/**
37+
* The manifest associated with this server application.
38+
* @internal
39+
*/
40+
readonly manifest = getAngularAppManifest();
41+
42+
/**
43+
* Hooks for extending or modifying the behavior of the server application.
44+
* This instance can be used to attach custom functionality to various events in the server application lifecycle.
45+
* @internal
46+
*/
47+
readonly hooks: Hooks;
48+
49+
/**
50+
* Specifies if the server application is operating in development mode.
51+
* This property controls the activation of features intended for production, such as caching mechanisms.
52+
* @internal
53+
*/
54+
readonly isDevMode: boolean;
55+
56+
/**
57+
* Creates a new `AngularServerApp` instance with the provided configuration options.
58+
*
59+
* @param options - The configuration options for the server application.
60+
* - `isDevMode`: Flag indicating if the application is in development mode.
61+
* - `hooks`: Optional hooks for customizing application behavior.
62+
*/
63+
constructor(options: AngularServerAppOptions) {
64+
this.isDevMode = options.isDevMode ?? false;
65+
this.hooks = options.hooks ?? new Hooks();
66+
}
67+
68+
/**
69+
* Renders a response for the given HTTP request using the server application.
70+
*
71+
* This method processes the request and returns a response based on the specified rendering context.
72+
*
73+
* @param request - The incoming HTTP request to be rendered.
74+
* @param requestContext - Optional additional context for rendering, such as request metadata.
75+
* @param serverContext - The rendering context.
76+
*
77+
* @returns A promise that resolves to the HTTP response object resulting from the rendering.
78+
*/
79+
render(
80+
request: Request,
81+
requestContext?: unknown,
82+
serverContext: ServerRenderContext = ServerRenderContext.SSR,
83+
): Promise<Response> {
84+
return render(this, request, serverContext, requestContext);
85+
}
86+
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+
}
101+
102+
return asset();
103+
}
104+
}

0 commit comments

Comments
 (0)