diff --git a/WORKSPACE b/WORKSPACE index b89c04efbe6d..424fac2140bf 100644 --- a/WORKSPACE +++ b/WORKSPACE @@ -152,3 +152,9 @@ register_toolchains( load("@npm//@angular/build-tooling/bazel/browsers:browser_repositories.bzl", "browser_repositories") browser_repositories() + +load("@build_bazel_rules_nodejs//toolchains/esbuild:esbuild_repositories.bzl", "esbuild_repositories") + +esbuild_repositories( + npm_repository = "npm", +) diff --git a/goldens/circular-deps/packages.json b/goldens/circular-deps/packages.json index ba9e8df1ecc4..cf73d8504177 100644 --- a/goldens/circular-deps/packages.json +++ b/goldens/circular-deps/packages.json @@ -23,5 +23,7 @@ [ "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/render.ts"] ] diff --git a/package.json b/package.json index 3e68fb98d31d..bbfb5b05bfe2 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,7 @@ "@ampproject/remapping": "2.3.0", "@angular/animations": "18.2.0-rc.0", "@angular/bazel": "patch:@angular/bazel@https%3A//github.com/angular/bazel-builds.git%23commit=71bd2e043e076365effdb6076f33b2d8d6bd6d02#~/.yarn/patches/@angular-bazel-https-9848736cf4.patch", - "@angular/build-tooling": "https://github.com/angular/dev-infra-private-build-tooling-builds.git#8128c8cc982b49ca12490da8d97692143aefd026", + "@angular/build-tooling": "https://github.com/angular/dev-infra-private-build-tooling-builds.git#77f89d89f65ef2d3f2b33991eff98839c2f9d704", "@angular/cdk": "18.1.3", "@angular/common": "18.2.0-rc.0", "@angular/compiler": "18.2.0-rc.0", diff --git a/packages/angular/ssr/BUILD.bazel b/packages/angular/ssr/BUILD.bazel index 0ed44cfb2080..cf4a46bb98ac 100644 --- a/packages/angular/ssr/BUILD.bazel +++ b/packages/angular/ssr/BUILD.bazel @@ -1,22 +1,28 @@ +load("@npm//@angular/build-tooling/bazel/api-golden:index.bzl", "api_golden_test_npm_package") load("@rules_pkg//:pkg.bzl", "pkg_tar") load("//tools:defaults.bzl", "ng_package", "ts_library") -load("@npm//@angular/build-tooling/bazel/api-golden:index.bzl", "api_golden_test_npm_package") package(default_visibility = ["//visibility:public"]) ts_library( name = "ssr", package_name = "@angular/ssr", - srcs = glob([ - "*.ts", - "src/**/*.ts", - ]), + srcs = glob( + include = [ + "*.ts", + "src/**/*.ts", + ], + exclude = [ + "**/*_spec.ts", + ], + ), module_name = "@angular/ssr", deps = [ "@npm//@angular/core", "@npm//@angular/platform-server", "@npm//@types/node", "@npm//critters", + "@npm//mrmime", ], ) diff --git a/packages/angular/ssr/package.json b/packages/angular/ssr/package.json index 7ca5ff5d1d5d..96245fe9970a 100644 --- a/packages/angular/ssr/package.json +++ b/packages/angular/ssr/package.json @@ -14,12 +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" }, + "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", + "zone.js": "^0.14.0" + }, "schematics": "./schematics/collection.json", "repository": { "type": "git", diff --git a/packages/angular/ssr/public_api.ts b/packages/angular/ssr/public_api.ts index a3efc5a84ecf..079653a3e4c2 100644 --- a/packages/angular/ssr/public_api.ts +++ b/packages/angular/ssr/public_api.ts @@ -10,4 +10,9 @@ export { CommonEngine, type CommonEngineRenderOptions, type CommonEngineOptions, -} from './src/common-engine'; +} from './src/common-engine/common-engine'; + +// TODO(alanagius): enable at a later stage +// export { AngularAppEngine } from './src/app-engine'; +// export { AngularServerApp } from './src/app'; +// export { REQUEST, REQUEST_CONTEXT, RESPONSE_INIT } from './src/tokens'; diff --git a/packages/angular/ssr/src/app-engine.ts b/packages/angular/ssr/src/app-engine.ts new file mode 100644 index 000000000000..6abc6c9b982d --- /dev/null +++ b/packages/angular/ssr/src/app-engine.ts @@ -0,0 +1,150 @@ +/** + * @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 { lookup as lookupMimeType } from 'mrmime'; +import { AngularServerApp } from './app'; +import { Hooks } from './hooks'; +import { getPotentialLocaleIdFromUrl } from './i18n'; +import { getAngularAppEngineManifest } from './manifest'; + +/** + * Angular server application engine. + * Manages Angular server applications (including localized ones), handles rendering requests, + * and optionally transforms index HTML before rendering. + */ +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. + */ + 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. + * @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(); + + /** + * Renders an HTTP request using the appropriate Angular server application and returns a response. + * + * 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. + * + * 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 + * corresponding to `https://www.example.com/page`. + * + * @param request - The incoming HTTP request object to be rendered. + * @param requestContext - Optional additional context for the request, such as metadata. + * @returns A promise that resolves to a Response object, or `null` if the request URL represents a file (e.g., `./logo.png`) + * rather than an application route. + */ + async render(request: Request, requestContext?: unknown): Promise { + // 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) { + 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); + } + } + + return serverApp.render(request, requestContext); + } + + /** + * Retrieves the entry point path and locale for the Angular server application based on the provided URL. + * + * This method determines the appropriate entry point and locale for rendering the application by examining the URL. + * 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. + */ + private getEntryPointFromUrl(url: URL): + | [ + locale: string, + loadModule: () => Promise<{ + AngularServerApp: typeof AngularServerApp; + }>, + ] + | null { + // Find bundle for locale + const { entryPoints, basePath } = this.manifest; + if (entryPoints.size === 1) { + return entryPoints.entries().next().value; + } + + const potentialLocale = getPotentialLocaleIdFromUrl(url, basePath); + const entryPoint = entryPoints.get(potentialLocale); + + 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); +} diff --git a/packages/angular/ssr/src/app.ts b/packages/angular/ssr/src/app.ts new file mode 100644 index 000000000000..436393282db0 --- /dev/null +++ b/packages/angular/ssr/src/app.ts @@ -0,0 +1,104 @@ +/** + * @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 { Hooks } from './hooks'; +import { getAngularAppManifest } from './manifest'; +import { ServerRenderContext, render } from './render'; + +/** + * 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 { + /** + * 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; + + /** + * 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) { + this.isDevMode = options.isDevMode ?? false; + this.hooks = options.hooks ?? new Hooks(); + } + + /** + * Renders a response for the given HTTP request using the server application. + * + * This method processes the request and returns a response based on the specified rendering context. + * + * @param request - The incoming HTTP request to be rendered. + * @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. + */ + render( + request: Request, + requestContext?: unknown, + serverContext: ServerRenderContext = ServerRenderContext.SSR, + ): Promise { + return render(this, request, serverContext, requestContext); + } + + /** + * 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 { + const asset = this.manifest.assets[path]; + if (!asset) { + throw new Error(`Server asset '${path}' does not exist.`); + } + + return asset(); + } +} diff --git a/packages/angular/ssr/src/common-engine.ts b/packages/angular/ssr/src/common-engine/common-engine.ts similarity index 100% rename from packages/angular/ssr/src/common-engine.ts rename to packages/angular/ssr/src/common-engine/common-engine.ts diff --git a/packages/angular/ssr/src/inline-css-processor.ts b/packages/angular/ssr/src/common-engine/inline-css-processor.ts similarity index 100% rename from packages/angular/ssr/src/inline-css-processor.ts rename to packages/angular/ssr/src/common-engine/inline-css-processor.ts diff --git a/packages/angular/ssr/src/peformance-profiler.ts b/packages/angular/ssr/src/common-engine/peformance-profiler.ts similarity index 100% rename from packages/angular/ssr/src/peformance-profiler.ts rename to packages/angular/ssr/src/common-engine/peformance-profiler.ts diff --git a/packages/angular/ssr/src/console.ts b/packages/angular/ssr/src/console.ts new file mode 100644 index 000000000000..9a61a912615c --- /dev/null +++ b/packages/angular/ssr/src/console.ts @@ -0,0 +1,37 @@ +/** + * @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 { ɵConsole } from '@angular/core'; + +/** + * Custom implementation of the Angular Console service that filters out specific log messages. + * + * This class extends the internal Angular `ɵConsole` class to provide customized logging behavior. + * It overrides the `log` method to suppress logs that match certain predefined messages. + */ +export class Console extends ɵConsole { + /** + * A set of log messages that should be ignored and not printed to the console. + */ + private readonly ignoredLogs = new Set(['Angular is running in development mode.']); + + /** + * Logs a message to the console if it is not in the set of ignored messages. + * + * @param message - The message to log to the console. + * + * This method overrides the `log` method of the `ɵConsole` class. It checks if the + * message is in the `ignoredLogs` set. If it is not, it delegates the logging to + * the parent class's `log` method. Otherwise, the message is suppressed. + */ + override log(message: string): void { + if (!this.ignoredLogs.has(message)) { + super.log(message); + } + } +} diff --git a/packages/angular/ssr/src/hooks.ts b/packages/angular/ssr/src/hooks.ts new file mode 100644 index 000000000000..ab34973583b8 --- /dev/null +++ b/packages/angular/ssr/src/hooks.ts @@ -0,0 +1,123 @@ +/** + * @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 + */ + +/** + * Handler function type for HTML transformation hooks. + * It takes an object containing the HTML content to be modified. + * + * @param ctx - The context object containing the HTML content. + * @returns The modified HTML content or a promise that resolves to the modified HTML content. + */ +type HtmlTransformHandler = (ctx: { html: string }) => string | Promise; + +/** + * Defines the names of available hooks for registering and triggering custom logic within the application. + */ +type HookName = keyof HooksMapping; + +/** + * Mapping of hook names to their corresponding handler types. + */ +interface HooksMapping { + 'html:transform:pre': HtmlTransformHandler; +} + +/** + * Manages a collection of hooks and provides methods to register and execute them. + * Hooks are functions that can be invoked with specific arguments to allow modifications or enhancements. + */ +export class Hooks { + /** + * A map of hook names to arrays of hook functions. + * Each hook name can have multiple associated functions, which are executed in sequence. + */ + private readonly store = new Map(); + + /** + * Executes all hooks associated with the specified name, passing the given argument to each hook function. + * The hooks are invoked sequentially, and the argument may be modified by each hook. + * + * @template Hook - The type of the hook name. It should be one of the keys of `HooksMapping`. + * @param name - The name of the hook whose functions will be executed. + * @param context - The input value to be passed to each hook function. The value is mutated by each hook function. + * @returns A promise that resolves once all hook functions have been executed. + * + * @example + * ```typescript + * const hooks = new Hooks(); + * hooks.on('html:transform:pre', async (ctx) => { + * ctx.html = ctx.html.replace(/foo/g, 'bar'); + * return ctx.html; + * }); + * const result = await hooks.run('html:transform:pre', { html: '
foo
' }); + * console.log(result); // '
bar
' + * ``` + * @internal + */ + async run( + name: Hook, + context: Parameters[0], + ): Promise>> { + const hooks = this.store.get(name); + switch (name) { + case 'html:transform:pre': { + if (!hooks) { + return context.html as Awaited>; + } + + const ctx = { ...context }; + for (const hook of hooks) { + ctx.html = await hook(ctx); + } + + return ctx.html as Awaited>; + } + default: + throw new Error(`Running hook "${name}" is not supported.`); + } + } + + /** + * Registers a new hook function under the specified hook name. + * This function should be a function that takes an argument of type `T` and returns a `string` or `Promise`. + * + * @template Hook - The type of the hook name. It should be one of the keys of `HooksMapping`. + * @param name - The name of the hook under which the function will be registered. + * @param handler - A function to be executed when the hook is triggered. The handler will be called with an argument + * that may be modified by the hook functions. + * + * @remarks + * - If there are existing handlers registered under the given hook name, the new handler will be added to the list. + * - If no handlers are registered under the given hook name, a new list will be created with the handler as its first element. + * + * @example + * ```typescript + * hooks.on('html:transform:pre', async (ctx) => { + * return ctx.html.replace(/foo/g, 'bar'); + * }); + * ``` + */ + on(name: Hook, handler: HooksMapping[Hook]): void { + const hooks = this.store.get(name); + if (hooks) { + hooks.push(handler); + } else { + this.store.set(name, [handler]); + } + } + + /** + * Checks if there are any hooks registered under the specified name. + * + * @param name - The name of the hook to check. + * @returns `true` if there are hooks registered under the specified name, otherwise `false`. + */ + has(name: HookName): boolean { + return !!this.store.get(name)?.length; + } +} diff --git a/packages/angular/ssr/src/i18n.ts b/packages/angular/ssr/src/i18n.ts new file mode 100644 index 000000000000..d87c69666794 --- /dev/null +++ b/packages/angular/ssr/src/i18n.ts @@ -0,0 +1,45 @@ +/** + * @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 + */ + +/** + * Extracts a potential locale ID from a given URL based on the specified base path. + * + * This function parses the URL to locate a potential locale identifier that immediately + * follows the base path segment in the URL's pathname. If the URL does not contain a valid + * locale ID, an empty string is returned. + * + * @param url - The full URL from which to extract the locale ID. + * @param basePath - The base path used as the reference point for extracting the locale ID. + * @returns The extracted locale ID if present, or an empty string if no valid locale ID is found. + * + * @example + * ```js + * const url = new URL('https://example.com/base/en/page'); + * const basePath = '/base'; + * const localeId = getPotentialLocaleIdFromUrl(url, basePath); + * console.log(localeId); // Output: 'en' + * ``` + */ +export function getPotentialLocaleIdFromUrl(url: URL, basePath: string): string { + const { pathname } = url; + + // Move forward of the base path section. + let start = basePath.length; + if (pathname[start] === '/') { + start++; + } + + // Find the next forward slash. + let end = pathname.indexOf('/', start); + if (end === -1) { + end = pathname.length; + } + + // Extract the potential locale id. + return pathname.slice(start, end); +} diff --git a/packages/angular/ssr/src/manifest.ts b/packages/angular/ssr/src/manifest.ts new file mode 100644 index 000000000000..5de9e03ba105 --- /dev/null +++ b/packages/angular/ssr/src/manifest.ts @@ -0,0 +1,114 @@ +/** + * @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 { ApplicationRef, Type } from '@angular/core'; +import type { AngularServerApp } from './app'; + +/** + * 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. + * - `value`: A function that returns a promise resolving to an object containing the `AngularServerApp` type. + */ + entryPoints: Map Promise<{ AngularServerApp: typeof AngularServerApp }>>; + + /** + * The base path for the server application. + */ + basePath: string; +} + +/** + * Manifest for a specific Angular server application, defining assets and bootstrap logic. + */ +export interface AngularAppManifest { + /** + * A record of assets required by the server application. + * Each entry consists of: + * - `key`: The path of the asset. + * - `value`: A function returning a promise that resolves to the file contents. + */ + assets: Record Promise>; + + /** + * 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 | (() => Promise); + + /** + * Indicates whether critical CSS should be inlined. + */ + inlineCriticalCss?: boolean; +} + +/** + * Angular app manifest object. + */ +let angularAppManifest: AngularAppManifest | undefined; + +/** + * Sets the Angular app manifest. + * + * @param manifest - The manifest object to set. + */ +export function setAngularAppManifest(manifest: AngularAppManifest): void { + angularAppManifest = manifest; +} + +/** + * Gets the Angular app manifest. + * + * @returns The Angular app manifest. + * @throws Will throw an error if the Angular app manifest is not set. + */ +export function getAngularAppManifest(): AngularAppManifest { + if (!angularAppManifest) { + throw new Error( + 'Angular app manifest is not set.' + + `Please ensure you are using the '@angular/build:application' builder to build your server application.`, + ); + } + + return angularAppManifest; +} + +/** + * Angular app engine manifest object. + */ +let angularAppEngineManifest: AngularAppEngineManifest | undefined; + +/** + * Sets the Angular app engine manifest. + * + * @param manifest - The engine manifest object to set. + */ +export function setAngularAppEngineManifest(manifest: AngularAppEngineManifest): void { + angularAppEngineManifest = manifest; +} + +/** + * Gets the Angular app engine manifest. + * + * @returns The Angular app engine manifest. + * @throws Will throw an error if the Angular app engine manifest is not set. + */ +export function getAngularAppEngineManifest(): AngularAppEngineManifest { + if (!angularAppEngineManifest) { + throw new Error( + 'Angular app engine manifest is not set.' + + `Please ensure you are using the '@angular/build:application' builder to build your server application.`, + ); + } + + return angularAppEngineManifest; +} diff --git a/packages/angular/ssr/src/render.ts b/packages/angular/ssr/src/render.ts new file mode 100644 index 000000000000..bb4bf1fcb87c --- /dev/null +++ b/packages/angular/ssr/src/render.ts @@ -0,0 +1,104 @@ +/** + * @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 { StaticProvider, ɵConsole, ɵresetCompiledComponents } from '@angular/core'; +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'; + +/** + * Enum representing the different contexts in which server rendering can occur. + */ +export enum ServerRenderContext { + SSR = 'ssr', + SSG = 'ssg', + AppShell = 'app-shell', +} + +/** + * Renders an Angular server application to produce a response for the given HTTP request. + * Supports server-side rendering (SSR), static site generation (SSG), or app shell rendering. + * + * @param app - The server application instance to render. + * @param request - The incoming HTTP request object. + * @param serverContext - Context specifying the rendering mode. + * @param requestContext - Optional additional context for the request, such as metadata. + * @returns A promise that resolves to a response object representing the rendered content. + */ +export async function render( + app: AngularServerApp, + request: Request, + serverContext: ServerRenderContext, + requestContext?: unknown, +): Promise { + const isSsrMode = serverContext === ServerRenderContext.SSR; + const responseInit: ResponseInit = {}; + const platformProviders: StaticProvider = [ + { + provide: SERVER_CONTEXT, + useValue: serverContext, + }, + ]; + + if (isSsrMode) { + platformProviders.push( + { + provide: REQUEST, + useValue: request, + }, + { + provide: REQUEST_CONTEXT, + useValue: requestContext, + }, + { + provide: RESPONSE_INIT, + useValue: responseInit, + }, + ); + } + + const { manifest, hooks, isDevMode } = app; + + if (isDevMode) { + // 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.getServerAsset('index.server.html'); + // 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), + responseInit, + ); +} diff --git a/packages/angular/ssr/src/request.ts b/packages/angular/ssr/src/request.ts new file mode 100644 index 000000000000..b29ab88df1f4 --- /dev/null +++ b/packages/angular/ssr/src/request.ts @@ -0,0 +1,74 @@ +/** + * @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 type { IncomingHttpHeaders, IncomingMessage } from 'node:http'; + +/** + * Converts a Node.js `IncomingMessage` into a Web Standard `Request`. + * + * @param nodeRequest - The Node.js `IncomingMessage` object to convert. + * @returns A Web Standard `Request` object. + */ +export function createWebRequestFromNodeRequest(nodeRequest: IncomingMessage): Request { + const { headers, method = 'GET' } = nodeRequest; + const withBody = method !== 'GET' && method !== 'HEAD'; + + return new Request(createRequestUrl(nodeRequest), { + method, + headers: createRequestHeaders(headers), + body: withBody ? nodeRequest : undefined, + duplex: withBody ? 'half' : undefined, + }); +} + +/** + * Creates a `Headers` object from Node.js `IncomingHttpHeaders`. + * + * @param nodeHeaders - The Node.js `IncomingHttpHeaders` object to convert. + * @returns A `Headers` object containing the converted headers. + */ +function createRequestHeaders(nodeHeaders: IncomingHttpHeaders): Headers { + const headers = new Headers(); + + for (const [name, value] of Object.entries(nodeHeaders)) { + if (typeof value === 'string') { + headers.append(name, value); + } else if (Array.isArray(value)) { + for (const item of value) { + headers.append(name, item); + } + } + } + + return headers; +} + +/** + * Creates a `URL` object from a Node.js `IncomingMessage`, taking into account the protocol, host, and port. + * + * @param nodeRequest - The Node.js `IncomingMessage` object to extract URL information from. + * @returns A `URL` object representing the request URL. + */ +function createRequestUrl(nodeRequest: IncomingMessage): URL { + const { headers, socket, url = '' } = nodeRequest; + const protocol = + headers['x-forwarded-proto'] ?? ('encrypted' in socket && socket.encrypted ? 'https' : 'http'); + const hostname = headers['x-forwarded-host'] ?? headers.host ?? headers[':authority']; + const port = headers['x-forwarded-port'] ?? socket.localPort; + + if (Array.isArray(hostname)) { + throw new Error('host value cannot be an array.'); + } + + let hostnameWithPort = hostname; + if (port && !hostname?.includes(':')) { + hostnameWithPort += `:${port}`; + } + + return new URL(url, `${protocol}://${hostnameWithPort}`); +} diff --git a/packages/angular/ssr/src/response.ts b/packages/angular/ssr/src/response.ts new file mode 100644 index 000000000000..1610dcbf7de8 --- /dev/null +++ b/packages/angular/ssr/src/response.ts @@ -0,0 +1,73 @@ +/** + * @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 type { ServerResponse } from 'node:http'; + +/** + * Streams a web-standard `Response` into a Node.js `ServerResponse`. + * + * @param source - The web-standard `Response` object to stream from. + * @param destination - The Node.js `ServerResponse` object to stream into. + * @returns A promise that resolves once the streaming operation is complete. + */ +export async function writeResponseToNodeResponse( + source: Response, + destination: ServerResponse, +): Promise { + const { status, headers, body } = source; + destination.statusCode = status; + + let cookieHeaderSet = false; + for (const [name, value] of headers.entries()) { + if (name === 'set-cookie') { + if (cookieHeaderSet) { + continue; + } + + // Sets the 'set-cookie' header only once to ensure it is correctly applied. + // Concatenating 'set-cookie' values can lead to incorrect behavior, so we use a single value from `headers.getSetCookie()`. + destination.setHeader(name, headers.getSetCookie()); + cookieHeaderSet = true; + } else { + destination.setHeader(name, value); + } + } + + if (!body) { + destination.end(); + + return; + } + + try { + const reader = body.getReader(); + + destination.on('close', () => { + reader.cancel().catch((error) => { + // eslint-disable-next-line no-console + console.error( + `An error occurred while writing the response body for: ${destination.req.url}.`, + error, + ); + }); + }); + + // eslint-disable-next-line no-constant-condition + while (true) { + const { done, value } = await reader.read(); + if (done) { + destination.end(); + break; + } + + destination.write(value); + } + } catch { + destination.end('Internal server error.'); + } +} diff --git a/packages/angular/ssr/src/tokens.ts b/packages/angular/ssr/src/tokens.ts new file mode 100644 index 000000000000..e63593658e6e --- /dev/null +++ b/packages/angular/ssr/src/tokens.ts @@ -0,0 +1,24 @@ +/** + * @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 { InjectionToken } from '@angular/core'; + +/** + * Injection token for the current request. + */ +export const REQUEST = new InjectionToken('REQUEST'); + +/** + * Injection token for the response initialization options. + */ +export const RESPONSE_INIT = new InjectionToken('RESPONSE_INIT'); + +/** + * Injection token for additional request context. + */ +export const REQUEST_CONTEXT = new InjectionToken('REQUEST_CONTEXT'); diff --git a/packages/angular/ssr/src/utils.ts b/packages/angular/ssr/src/utils.ts new file mode 100644 index 000000000000..aebe1d0e86eb --- /dev/null +++ b/packages/angular/ssr/src/utils.ts @@ -0,0 +1,55 @@ +/** + * @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 type { ApplicationRef, StaticProvider, Type } from '@angular/core'; +import { renderApplication, renderModule } from '@angular/platform-server'; + +/** + * Renders an Angular application or module to an HTML string. + * + * This function determines whether the provided `bootstrap` value is an Angular module + * or a bootstrap function and calls the appropriate rendering method (`renderModule` or + * `renderApplication`) based on that determination. + * + * @param html - The HTML string to be used as the initial document content. + * @param bootstrap - Either an Angular module type or a function that returns a promise + * resolving to an `ApplicationRef`. + * @param url - The URL of the application. This is used for server-side rendering to + * correctly handle route-based rendering. + * @param platformProviders - An array of platform providers to be used during the + * rendering process. + * @returns A promise that resolves to a string containing the rendered HTML. + */ +export function renderAngular( + html: string, + bootstrap: Type | (() => Promise), + url: string, + platformProviders: StaticProvider[], +): Promise { + return isNgModule(bootstrap) + ? renderModule(bootstrap, { url, document: html, extraProviders: platformProviders }) + : renderApplication(bootstrap, { + url, + document: html, + platformProviders, + }); +} + +/** + * Type guard to determine if a given value is an Angular module. + * Angular modules are identified by the presence of the `ɵmod` static property. + * This function helps distinguish between Angular modules and bootstrap functions. + * + * @param value - The value to be checked. + * @returns True if the value is an Angular module (i.e., it has the `ɵmod` property), false otherwise. + */ +export function isNgModule( + value: Type | (() => Promise), +): value is Type { + return typeof value === 'object' && 'ɵmod' in value; +} diff --git a/packages/angular/ssr/test/BUILD.bazel b/packages/angular/ssr/test/BUILD.bazel new file mode 100644 index 000000000000..2353da03ee74 --- /dev/null +++ b/packages/angular/ssr/test/BUILD.bazel @@ -0,0 +1,54 @@ +load("@npm//@angular/build-tooling/bazel/spec-bundling:index.bzl", "spec_bundle") +load("@npm//@bazel/jasmine:index.bzl", "jasmine_node_test") +load("//tools:defaults.bzl", "ts_library") + +ESM_TESTS = [ + "app_spec.ts", + "app-engine_spec.ts", +] + +ts_library( + name = "unit_test_lib", + testonly = True, + srcs = glob( + include = ["**/*_spec.ts"], + exclude = ESM_TESTS, + ), + deps = [ + "//packages/angular/ssr", + ], +) + +ts_library( + name = "unit_test_with_esm_deps_lib", + testonly = True, + srcs = ESM_TESTS + ["testing-utils.ts"], + deps = [ + "//packages/angular/ssr", + "@npm//@angular/common", + "@npm//@angular/compiler", + "@npm//@angular/core", + "@npm//@angular/platform-browser", + "@npm//@angular/platform-server", + "@npm//@angular/router", + "@npm//zone.js", + ], +) + +spec_bundle( + name = "unit_test_with_esm_deps_lib_bundled", + downlevel_async_await = False, + platform = "node", + run_angular_linker = False, + deps = [ + ":unit_test_with_esm_deps_lib", + ], +) + +jasmine_node_test( + name = "test", + deps = [ + ":unit_test_lib", + ":unit_test_with_esm_deps_lib_bundled", + ], +) diff --git a/packages/angular/ssr/test/app-engine_spec.ts b/packages/angular/ssr/test/app-engine_spec.ts new file mode 100644 index 000000000000..22207640ec9a --- /dev/null +++ b/packages/angular/ssr/test/app-engine_spec.ts @@ -0,0 +1,142 @@ +/** + * @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 + */ + +/* eslint-disable import/no-unassigned-import */ +import 'zone.js/node'; +import '@angular/compiler'; +/* eslint-enable import/no-unassigned-import */ + +import { Component } from '@angular/core'; +import { AngularServerApp } from '../src/app'; +import { AngularAppEngine } from '../src/app-engine'; +import { setAngularAppEngineManifest } from '../src/manifest'; +import { setAngularAppTestingManifest } from './testing-utils'; + +describe('AngularAppEngine', () => { + let appEngine: AngularAppEngine; + + describe('Localized app', () => { + beforeAll(() => { + 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. + entryPoints: new Map( + ['it', 'en'].map((locale) => [ + locale, + async () => { + @Component({ + standalone: true, + selector: 'app-home', + template: `Home works ${locale.toUpperCase()}`, + }) + class HomeComponent {} + + setAngularAppTestingManifest([{ path: 'home', component: HomeComponent }], locale); + + return { AngularServerApp }; + }, + ]), + ), + basePath: '', + }); + + appEngine = new AngularAppEngine(); + }); + + it('should return null for requests to unknown pages', async () => { + const request = new Request('https://example.com/unknown/page'); + const response = await appEngine.render(request); + expect(response).toBeNull(); + }); + + it('should return null for requests with unknown locales', async () => { + const request = new Request('https://example.com/es/home'); + const response = await appEngine.render(request); + expect(response).toBeNull(); + }); + + it('should return a rendered page with correct locale', async () => { + const request = new Request('https://example.com/it/home'); + const response = await appEngine.render(request); + expect(await response?.text()).toContain('Home works IT'); + }); + + it('should correctly render the content when the URL ends with "index.html" with correct locale', async () => { + const request = new Request('https://example.com/it/home/index.html'); + const response = await appEngine.render(request); + expect(await response?.text()).toContain('Home works IT'); + }); + + // TODO: (Angular will render this as it will render all routes even unknown routes) + // ERROR RuntimeError: NG04002: Cannot match any routes. URL Segment: 'unknown/page' + xit('should return null for requests to unknown pages in a locale', async () => { + const request = new Request('https://example.com/it/unknown/page'); + const response = await appEngine.render(request); + expect(response).toBeNull(); + }); + + it('should return null for requests to file-like resources in a locale', async () => { + const request = new Request('https://example.com/it/logo.png'); + const response = await appEngine.render(request); + expect(response).toBeNull(); + }); + }); + + describe('Non-localized app', () => { + beforeAll(() => { + setAngularAppEngineManifest({ + entryPoints: new Map([ + [ + '', + async () => { + @Component({ + standalone: true, + selector: 'app-home', + template: `Home works`, + }) + class HomeComponent {} + + setAngularAppTestingManifest([{ path: 'home', component: HomeComponent }]); + + return { AngularServerApp }; + }, + ], + ]), + basePath: '', + }); + + appEngine = new AngularAppEngine(); + }); + + it('should return null for requests to file-like resources', async () => { + const request = new Request('https://example.com/logo.png'); + const response = await appEngine.render(request); + expect(response).toBeNull(); + }); + + // TODO: (Angular will render this as it will render all routes even unknown routes) + // ERROR RuntimeError: NG04002: Cannot match any routes. URL Segment: 'unknown/page' + xit('should return null for requests to unknown pages', async () => { + const request = new Request('https://example.com/unknown/page'); + const response = await appEngine.render(request); + expect(response).toBeNull(); + }); + + it('should return a rendered page for known paths', async () => { + const request = new Request('https://example.com/home'); + const response = await appEngine.render(request); + expect(await response?.text()).toContain('Home works'); + }); + + it('should correctly render the content when the URL ends with "index.html"', async () => { + const request = new Request('https://example.com/home/index.html'); + const response = await appEngine.render(request); + expect(await response?.text()).toContain('Home works'); + }); + }); +}); diff --git a/packages/angular/ssr/test/app_spec.ts b/packages/angular/ssr/test/app_spec.ts new file mode 100644 index 000000000000..0eeeb6ada55f --- /dev/null +++ b/packages/angular/ssr/test/app_spec.ts @@ -0,0 +1,77 @@ +/** + * @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 + */ + +/* eslint-disable import/no-unassigned-import */ +import 'zone.js/node'; +import '@angular/compiler'; +/* eslint-enable import/no-unassigned-import */ + +import { Component } from '@angular/core'; +import { AngularServerApp } from '../src/app'; +import { ServerRenderContext } from '../src/render'; +import { setAngularAppTestingManifest } from './testing-utils'; + +describe('AngularServerApp', () => { + let app: AngularServerApp; + + beforeAll(() => { + @Component({ + standalone: true, + selector: 'app-home', + template: `Home works`, + }) + class HomeComponent {} + + setAngularAppTestingManifest([{ path: 'home', component: HomeComponent }]); + + app = new AngularServerApp({ + isDevMode: true, + }); + }); + + describe('render', () => { + it(`should include 'ng-server-context="ssr"' by default`, async () => { + const response = await app.render(new Request('http://localhost/home')); + expect(await response.text()).toContain('ng-server-context="ssr"'); + }); + + it(`should include the provided 'ng-server-context' value`, async () => { + const response = await app.render( + new Request('http://localhost/home'), + undefined, + ServerRenderContext.SSG, + ); + expect(await response.text()).toContain('ng-server-context="ssg"'); + }); + + it('should correctly render the content for the requested page', async () => { + const response = await app.render(new Request('http://localhost/home')); + expect(await response.text()).toContain('Home works'); + }); + + it(`should correctly render the content when the URL ends with 'index.html'`, async () => { + const response = await app.render(new Request('http://localhost/home/index.html')); + expect(await response.text()).toContain('Home works'); + }); + }); + + describe('getServerAsset', () => { + it('should return the content of an existing asset', async () => { + const content = await app.getServerAsset('index.server.html'); + expect(content).toContain(''); + }); + + it('should throw an error if the asset does not exist', async () => { + await expectAsync(app.getServerAsset('nonexistent.html')).toBeRejectedWith( + jasmine.objectContaining({ + message: jasmine.stringMatching(`Server asset 'nonexistent.html' does not exist`), + }), + ); + }); + }); +}); diff --git a/packages/angular/ssr/test/hooks_spec.ts b/packages/angular/ssr/test/hooks_spec.ts new file mode 100644 index 000000000000..ea2e32f87590 --- /dev/null +++ b/packages/angular/ssr/test/hooks_spec.ts @@ -0,0 +1,64 @@ +/** + * @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 { Hooks } from '../src/hooks'; + +describe('Hooks', () => { + let hooks: Hooks & { run: Function }; + + beforeEach(() => { + hooks = new Hooks() as Hooks & { run: Function }; + }); + + describe('on', () => { + it('should register a pre-transform hook', () => { + hooks.on('html:transform:pre', (ctx) => ''); + expect(hooks.has('html:transform:pre')).toBeTrue(); + }); + + it('should register multiple hooks under the same name', () => { + hooks.on('html:transform:pre', (ctx) => ''); + hooks.on('html:transform:pre', (ctx) => ''); + expect(hooks.has('html:transform:pre')).toBeTrue(); + }); + }); + + describe('run', () => { + it('should execute pre-transform hooks in sequence', async () => { + hooks.on('html:transform:pre', ({ html }) => html + '1'); + hooks.on('html:transform:pre', ({ html }) => html + '2'); + + const result = await hooks.run('html:transform:pre', { html: 'start' }); + expect(result).toBe('start12'); + }); + + it('should return the context html if no hooks are registered', async () => { + const result = await hooks.run('html:transform:pre', { html: 'start' }); + expect(result).toBe('start'); + }); + + it('should throw an error for unknown hook names', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await expectAsync(hooks.run('unknown:hook' as any, { html: 'start' })).toBeRejectedWithError( + 'Running hook "unknown:hook" is not supported.', + ); + }); + }); + + describe('has', () => { + it('should return true if a hook is registered', () => { + const handler = (ctx: { html: string }) => ''; + hooks.on('html:transform:pre', handler); + expect(hooks.has('html:transform:pre')).toBeTrue(); + }); + + it('should return false if no hook is registered', () => { + expect(hooks.has('html:transform:pre')).toBeFalse(); + }); + }); +}); diff --git a/packages/angular/ssr/test/i18n_spec.ts b/packages/angular/ssr/test/i18n_spec.ts new file mode 100644 index 000000000000..3996e37187c6 --- /dev/null +++ b/packages/angular/ssr/test/i18n_spec.ts @@ -0,0 +1,67 @@ +/** + * @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 { getPotentialLocaleIdFromUrl } from '../src/i18n'; + +describe('getPotentialLocaleIdFromUrl', () => { + it('should extract locale ID correctly when basePath is present', () => { + const url = new URL('https://example.com/base/en/page'); + const basePath = '/base'; + const localeId = getPotentialLocaleIdFromUrl(url, basePath); + expect(localeId).toBe('en'); + }); + + it('should extract locale ID correctly when basePath has trailing slash', () => { + const url = new URL('https://example.com/base/en/page'); + const basePath = '/base/'; + const localeId = getPotentialLocaleIdFromUrl(url, basePath); + expect(localeId).toBe('en'); + }); + + it('should extract locale ID correctly when url has no trailing slash', () => { + const url = new URL('https://example.com/base/en'); + const basePath = '/base/'; + const localeId = getPotentialLocaleIdFromUrl(url, basePath); + expect(localeId).toBe('en'); + }); + + it('should extract locale ID correctly when url no trailing slash', () => { + const url = new URL('https://example.com/base/en/'); + const basePath = '/base/'; + const localeId = getPotentialLocaleIdFromUrl(url, basePath); + expect(localeId).toBe('en'); + }); + + it('should handle URL with no pathname after basePath', () => { + const url = new URL('https://example.com/base/'); + const basePath = '/base'; + const localeId = getPotentialLocaleIdFromUrl(url, basePath); + expect(localeId).toBe(''); + }); + + it('should handle URL where basePath is the entire pathname', () => { + const url = new URL('https://example.com/base'); + const basePath = '/base'; + const localeId = getPotentialLocaleIdFromUrl(url, basePath); + expect(localeId).toBe(''); + }); + + it('should handle complex basePath correctly', () => { + const url = new URL('https://example.com/base/path/en/page'); + const basePath = '/base/path'; + const localeId = getPotentialLocaleIdFromUrl(url, basePath); + expect(localeId).toBe('en'); + }); + + it('should handle URL with query parameters and hash', () => { + const url = new URL('https://example.com/base/en?query=param#hash'); + const basePath = '/base'; + const localeId = getPotentialLocaleIdFromUrl(url, basePath); + expect(localeId).toBe('en'); + }); +}); diff --git a/packages/angular/ssr/test/request_spec.ts b/packages/angular/ssr/test/request_spec.ts new file mode 100644 index 000000000000..93513bf6cfd7 --- /dev/null +++ b/packages/angular/ssr/test/request_spec.ts @@ -0,0 +1,150 @@ +/** + * @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 { IncomingMessage, Server, ServerResponse, createServer, request } from 'node:http'; +import { AddressInfo } from 'node:net'; +import { createWebRequestFromNodeRequest } from '../src/request'; + +describe('createWebRequestFromNodeRequest', () => { + let server: Server; + let port: number; + + function extractNodeRequest(makeRequest: () => void): Promise { + const nodeRequest = getNodeRequest(); + makeRequest(); + + return nodeRequest; + } + + async function getNodeRequest(): Promise { + const { req, res } = await new Promise<{ req: IncomingMessage; res: ServerResponse }>( + (resolve) => { + server.once('request', (req, res) => resolve({ req, res })); + }, + ); + + res.end(); + + return req; + } + + beforeAll((done) => { + server = createServer(); + server.listen(0, () => { + port = (server.address() as AddressInfo).port; + done(); + }); + }); + + afterAll((done) => { + server.close(done); + }); + + describe('GET Handling', () => { + it('should correctly handle a basic GET request', async () => { + const nodeRequest = await extractNodeRequest(() => { + request({ + host: 'localhost', + port, + path: '/basic-get', + method: 'GET', + }).end(); + }); + + const webRequest = createWebRequestFromNodeRequest(nodeRequest); + expect(webRequest.method).toBe('GET'); + expect(webRequest.url).toBe(`http://localhost:${port}/basic-get`); + }); + + it('should correctly handle GET request with query parameters', async () => { + const nodeRequest = await extractNodeRequest(() => { + request({ + host: 'localhost', + port, + path: '/search?query=hello&page=2', + method: 'GET', + }).end(); + }); + + const webRequest = createWebRequestFromNodeRequest(nodeRequest); + expect(webRequest.method).toBe('GET'); + expect(webRequest.url).toBe(`http://localhost:${port}/search?query=hello&page=2`); + }); + + it('should correctly handle GET request with custom headers', async () => { + const nodeRequest = await extractNodeRequest(() => { + request({ + hostname: 'localhost', + port, + path: '/with-headers', + method: 'GET', + headers: { + 'X-Custom-Header1': 'value1', + 'X-Custom-Header2': 'value2', + }, + }).end(); + }); + + const webRequest = createWebRequestFromNodeRequest(nodeRequest); + expect(webRequest.method).toBe('GET'); + expect(webRequest.url).toBe(`http://localhost:${port}/with-headers`); + expect(webRequest.headers.get('x-custom-header1')).toBe('value1'); + expect(webRequest.headers.get('x-custom-header2')).toBe('value2'); + }); + }); + + describe('POST Handling', () => { + it('should handle POST request with JSON body and correct response', async () => { + const postData = JSON.stringify({ message: 'Hello from POST' }); + const nodeRequest = await extractNodeRequest(() => { + const clientRequest = request({ + hostname: 'localhost', + port, + path: '/post-json', + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(postData), + }, + }); + clientRequest.write(postData); + clientRequest.end(); + }); + + const webRequest = createWebRequestFromNodeRequest(nodeRequest); + expect(webRequest.method).toBe('POST'); + expect(webRequest.url).toBe(`http://localhost:${port}/post-json`); + expect(webRequest.headers.get('content-type')).toBe('application/json'); + expect(await webRequest.json()).toEqual({ message: 'Hello from POST' }); + }); + + it('should handle POST request with empty text body', async () => { + const postData = ''; + const nodeRequest = await extractNodeRequest(() => { + const clientRequest = request({ + hostname: 'localhost', + port, + path: '/post-text', + method: 'POST', + headers: { + 'Content-Type': 'text/plain', + 'Content-Length': Buffer.byteLength(postData), + }, + }); + clientRequest.write(postData); + clientRequest.end(); + }); + + const webRequest = createWebRequestFromNodeRequest(nodeRequest); + expect(webRequest.method).toBe('POST'); + expect(webRequest.url).toBe(`http://localhost:${port}/post-text`); + expect(webRequest.headers.get('content-type')).toBe('text/plain'); + expect(await webRequest.text()).toBe(''); + }); + }); +}); diff --git a/packages/angular/ssr/test/response_spec.ts b/packages/angular/ssr/test/response_spec.ts new file mode 100644 index 000000000000..a2e527dba280 --- /dev/null +++ b/packages/angular/ssr/test/response_spec.ts @@ -0,0 +1,110 @@ +/** + * @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 { IncomingMessage, Server, createServer, request as requestCb } from 'node:http'; +import { AddressInfo } from 'node:net'; +import { writeResponseToNodeResponse } from '../src/response'; + +describe('writeResponseToNodeResponse', () => { + let server: Server; + + function simulateResponse( + res: Response, + ): Promise<{ response: IncomingMessage; body: string | null }> { + server.once('request', (req, nodeResponse) => { + void writeResponseToNodeResponse(res, nodeResponse); + }); + + return new Promise<{ + body: string | null; + response: IncomingMessage; + }>((resolve, reject) => { + const { port } = server.address() as AddressInfo; + const clientRequest = requestCb( + { + host: 'localhost', + port, + }, + (response) => { + let body: string | null = null; + response + .on('data', (chunk) => { + body ??= ''; + body += chunk; + }) + .on('end', () => resolve({ response, body })) + .on('error', reject); + }, + ); + + clientRequest.end(); + }); + } + + beforeAll((done) => { + server = createServer(); + server.listen(0, done); + }); + + afterAll((done) => { + server.close(done); + }); + + it('should write status, headers, and body to Node.js response', async () => { + const { response, body } = await simulateResponse( + new Response('Hello, world!', { + status: 201, + headers: { + 'Content-Type': 'text/plain', + 'X-Custom-Header': 'custom-value', + }, + }), + ); + + expect(response.statusCode).toBe(201); + expect(response.headers['content-type']).toBe('text/plain'); + expect(response.headers['x-custom-header']).toBe('custom-value'); + expect(body).toBe('Hello, world!'); + }); + + it('should handle empty body', async () => { + const { response, body } = await simulateResponse( + new Response(null, { + status: 204, + }), + ); + + expect(response.statusCode).toBe(204); + expect(body).toBeNull(); + }); + + it('should handle JSON content types', async () => { + const jsonData = JSON.stringify({ message: 'Hello JSON' }); + const { response, body } = await simulateResponse( + new Response(jsonData, { + headers: { 'Content-Type': 'application/json' }, + }), + ); + + expect(response.statusCode).toBe(200); + expect(body).toBe(jsonData); + }); + + it('should set cookies on the ServerResponse', async () => { + const cookieValue: string[] = [ + 'myCookie=myValue; Path=/; HttpOnly', + 'anotherCookie=anotherValue; Path=/test', + ]; + + const headers = new Headers(); + cookieValue.forEach((v) => headers.append('set-cookie', v)); + const { response } = await simulateResponse(new Response(null, { headers })); + + expect(response.headers['set-cookie']).toEqual(cookieValue); + }); +}); diff --git a/packages/angular/ssr/test/testing-utils.ts b/packages/angular/ssr/test/testing-utils.ts new file mode 100644 index 000000000000..23c1e95f4bc5 --- /dev/null +++ b/packages/angular/ssr/test/testing-utils.ts @@ -0,0 +1,53 @@ +/** + * @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 { Component } from '@angular/core'; +import { bootstrapApplication } from '@angular/platform-browser'; +import { provideServerRendering } from '@angular/platform-server'; +import { RouterOutlet, Routes, provideRouter } from '@angular/router'; +import { setAngularAppManifest } from '../src/manifest'; + +/** + * Configures the Angular application for testing by setting up the Angular app manifest, + * configuring server-side rendering, and bootstrapping the application with the provided routes. + * This function generates a basic HTML template with a base href and sets up the necessary + * Angular components and providers for testing purposes. + * + * @param routes - An array of route definitions to be used by the Angular Router. + * @param [baseHref=''] - An optional base href to be used in the HTML template. + */ +export function setAngularAppTestingManifest(routes: Routes, baseHref = ''): void { + setAngularAppManifest({ + inlineCriticalCss: false, + assets: { + 'index.server.html': async () => + ` + + + + + + + +`, + }, + bootstrap: () => () => { + @Component({ + standalone: true, + selector: 'app-root', + template: '', + imports: [RouterOutlet], + }) + class AppComponent {} + + return bootstrapApplication(AppComponent, { + providers: [provideServerRendering(), provideRouter(routes)], + }); + }, + }); +} diff --git a/tsconfig.json b/tsconfig.json index fe9e0e782ebd..0a27835fe75d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,6 +5,7 @@ "module": "commonjs", "moduleResolution": "node", "noEmitOnError": true, + "stripInternal": true, "noFallthroughCasesInSwitch": true, "noImplicitOverride": true, "isolatedModules": true, diff --git a/yarn.lock b/yarn.lock index dcc50868fed6..f42e97114269 100644 --- a/yarn.lock +++ b/yarn.lock @@ -40,18 +40,18 @@ __metadata: languageName: unknown linkType: soft -"@angular-devkit/architect@npm:0.1802.0-next.3": - version: 0.1802.0-next.3 - resolution: "@angular-devkit/architect@npm:0.1802.0-next.3" +"@angular-devkit/architect@npm:0.1802.0-rc.0": + version: 0.1802.0-rc.0 + resolution: "@angular-devkit/architect@npm:0.1802.0-rc.0" dependencies: - "@angular-devkit/core": "npm:18.2.0-next.3" + "@angular-devkit/core": "npm:18.2.0-rc.0" rxjs: "npm:7.8.1" dependenciesMeta: esbuild: built: true puppeteer: built: true - checksum: 10c0/42e71104ecea9739aa0ef6a1054d0543ea520173b479c9322dcaf75bf1c86a9bdc01ab0fc7e5a25e435979e979dc4a779ad06ccab179f94ec82d8bad784599de + checksum: 10c0/822147fb349253bafd3e2a7d172fbc911b762326468b1d4a289f14a97059f64e0db6b4fc4404fecc0024bf0a3c1083f3b1233167b89417e64a86d0f19f1fac14 languageName: node linkType: hard @@ -198,9 +198,9 @@ __metadata: languageName: unknown linkType: soft -"@angular-devkit/core@npm:18.2.0-next.3": - version: 18.2.0-next.3 - resolution: "@angular-devkit/core@npm:18.2.0-next.3" +"@angular-devkit/core@npm:18.2.0-rc.0": + version: 18.2.0-rc.0 + resolution: "@angular-devkit/core@npm:18.2.0-rc.0" dependencies: ajv: "npm:8.17.1" ajv-formats: "npm:3.0.1" @@ -218,7 +218,7 @@ __metadata: peerDependenciesMeta: chokidar: optional: true - checksum: 10c0/c847d0c1c7af6b144012c048d98588bf42508a35f66e8fe538ab8fd3b92f312e8e1bb50cc786a77d510aa1fd51016c0eb2c0d50d32093e713fe9993b6cf4557c + checksum: 10c0/b2ccb59a6c4a3d3535a8644e0c2a0bd3837c69ca52825c79814d024371908e13ecbd6ff4cbae43d3a2e3d059a96e39628a018067cf2d3be661bb7e6dd0ff7993 languageName: node linkType: hard @@ -328,12 +328,12 @@ __metadata: languageName: node linkType: hard -"@angular/build-tooling@https://github.com/angular/dev-infra-private-build-tooling-builds.git#8128c8cc982b49ca12490da8d97692143aefd026": - version: 0.0.0-d66f2009955fd4b3430d9cf7072d94f4b4da95e7 - resolution: "@angular/build-tooling@https://github.com/angular/dev-infra-private-build-tooling-builds.git#commit=8128c8cc982b49ca12490da8d97692143aefd026" +"@angular/build-tooling@https://github.com/angular/dev-infra-private-build-tooling-builds.git#77f89d89f65ef2d3f2b33991eff98839c2f9d704": + version: 0.0.0-7d0c4c64f7aa63d84160b32ecd679f5e11807d71 + resolution: "@angular/build-tooling@https://github.com/angular/dev-infra-private-build-tooling-builds.git#commit=77f89d89f65ef2d3f2b33991eff98839c2f9d704" dependencies: "@angular/benchpress": "npm:0.3.0" - "@angular/build": "npm:18.2.0-next.3" + "@angular/build": "npm:18.2.0-rc.0" "@babel/core": "npm:^7.16.0" "@babel/helper-annotate-as-pure": "npm:^7.18.6" "@babel/plugin-proposal-async-generator-functions": "npm:^7.20.1" @@ -370,7 +370,7 @@ __metadata: dependenciesMeta: re2: built: false - checksum: 10c0/f1d416decdfe3d3109cba99119ce0dbc1a45d819811419f7d55b921f8757c5731fba06a05b6a25349797eac6995d65bd5330a12cc526ba2c390323f7ab6ac507 + checksum: 10c0/09b8f6df739c22699cca1feb5edca0e26c03860b1868beaa0b659814fde5d4d60c1494fa66d53be537b72c99948b78916fbe45508b6c18450ae5efcc971a1be7 languageName: node linkType: hard @@ -428,17 +428,17 @@ __metadata: languageName: unknown linkType: soft -"@angular/build@npm:18.2.0-next.3": - version: 18.2.0-next.3 - resolution: "@angular/build@npm:18.2.0-next.3" +"@angular/build@npm:18.2.0-rc.0": + version: 18.2.0-rc.0 + resolution: "@angular/build@npm:18.2.0-rc.0" dependencies: "@ampproject/remapping": "npm:2.3.0" - "@angular-devkit/architect": "npm:0.1802.0-next.3" + "@angular-devkit/architect": "npm:0.1802.0-rc.0" "@babel/core": "npm:7.25.2" "@babel/helper-annotate-as-pure": "npm:7.24.7" "@babel/helper-split-export-declaration": "npm:7.24.7" "@babel/plugin-syntax-import-attributes": "npm:7.24.7" - "@inquirer/confirm": "npm:3.1.19" + "@inquirer/confirm": "npm:3.1.22" "@vitejs/plugin-basic-ssl": "npm:1.1.0" browserslist: "npm:^4.23.0" critters: "npm:0.0.24" @@ -446,13 +446,13 @@ __metadata: fast-glob: "npm:3.3.2" https-proxy-agent: "npm:7.0.5" listr2: "npm:8.2.4" - lmdb: "npm:3.0.12" + lmdb: "npm:3.0.13" magic-string: "npm:0.30.11" mrmime: "npm:2.0.0" parse5-html-rewriting-stream: "npm:7.0.0" picomatch: "npm:4.0.2" piscina: "npm:4.6.1" - rollup: "npm:4.19.1" + rollup: "npm:4.20.0" sass: "npm:1.77.8" semver: "npm:7.6.3" vite: "npm:5.3.5" @@ -484,7 +484,7 @@ __metadata: optional: true tailwindcss: optional: true - checksum: 10c0/3378b9b6086397200e7ce1723e78162d99103243ccc4f8ebd7ed69cc509be72fac4e97f8e9568c62ffd629817fe0c27b09730643500b2581e801e3c3c8167017 + checksum: 10c0/bc498e370cbfc92d09172f27291e227fe687cf9901e62b5aa2112c68637aa61f52c6eac3d2333f48d702eb849eb0f912e3167a61d7d037b9568885e996b61bda languageName: node linkType: hard @@ -566,6 +566,20 @@ __metadata: languageName: node linkType: hard +"@angular/compiler@npm:18.2.0-next.2": + version: 18.2.0-next.2 + resolution: "@angular/compiler@npm:18.2.0-next.2" + dependencies: + tslib: "npm:^2.3.0" + peerDependencies: + "@angular/core": 18.2.0-next.2 + peerDependenciesMeta: + "@angular/core": + optional: true + checksum: 10c0/be26bbe2ec041f1dd353c8ea6eba861367592f71d09e1d12932a5ccf76cc1423124b626baf90a551061a849508e8e6bdc235166f43152edd52dcdbf5232d1e26 + languageName: node + linkType: hard + "@angular/compiler@npm:18.2.0-rc.0": version: 18.2.0-rc.0 resolution: "@angular/compiler@npm:18.2.0-rc.0" @@ -621,7 +635,7 @@ __metadata: "@ampproject/remapping": "npm:2.3.0" "@angular/animations": "npm:18.2.0-rc.0" "@angular/bazel": "patch:@angular/bazel@https%3A//github.com/angular/bazel-builds.git%23commit=71bd2e043e076365effdb6076f33b2d8d6bd6d02#~/.yarn/patches/@angular-bazel-https-9848736cf4.patch" - "@angular/build-tooling": "https://github.com/angular/dev-infra-private-build-tooling-builds.git#8128c8cc982b49ca12490da8d97692143aefd026" + "@angular/build-tooling": "https://github.com/angular/dev-infra-private-build-tooling-builds.git#77f89d89f65ef2d3f2b33991eff98839c2f9d704" "@angular/cdk": "npm:18.1.3" "@angular/common": "npm:18.2.0-rc.0" "@angular/compiler": "npm:18.2.0-rc.0" @@ -869,6 +883,22 @@ __metadata: languageName: node linkType: hard +"@angular/platform-browser@npm:18.2.0-next.2": + version: 18.2.0-next.2 + resolution: "@angular/platform-browser@npm:18.2.0-next.2" + dependencies: + tslib: "npm:^2.3.0" + peerDependencies: + "@angular/animations": 18.2.0-next.2 + "@angular/common": 18.2.0-next.2 + "@angular/core": 18.2.0-next.2 + peerDependenciesMeta: + "@angular/animations": + optional: true + checksum: 10c0/065f6205b56a4e4c4e324145708aef1fe0875e4109082f207fb6029ab52bc36685f8c2daf0d5fbe82545b8e1c2fe9ba2625c80f0a59c262bca12a7c45713c32c + languageName: node + linkType: hard + "@angular/platform-browser@npm:18.2.0-rc.0": version: 18.2.0-rc.0 resolution: "@angular/platform-browser@npm:18.2.0-rc.0" @@ -885,6 +915,22 @@ __metadata: languageName: node linkType: hard +"@angular/platform-server@npm:18.2.0-next.2": + version: 18.2.0-next.2 + resolution: "@angular/platform-server@npm:18.2.0-next.2" + dependencies: + tslib: "npm:^2.3.0" + xhr2: "npm:^0.2.0" + peerDependencies: + "@angular/animations": 18.2.0-next.2 + "@angular/common": 18.2.0-next.2 + "@angular/compiler": 18.2.0-next.2 + "@angular/core": 18.2.0-next.2 + "@angular/platform-browser": 18.2.0-next.2 + checksum: 10c0/df599c894f14eca1021898d828ef0bb000f438c6a0ad767a9aceb669f4e491eb3fed9dddecaba936b576ea3b7c072403f4937482f039d260578c7ba8da7e2ade + languageName: node + linkType: hard + "@angular/platform-server@npm:18.2.0-rc.0": version: 18.2.0-rc.0 resolution: "@angular/platform-server@npm:18.2.0-rc.0" @@ -916,6 +962,20 @@ __metadata: languageName: unknown linkType: soft +"@angular/router@npm:18.2.0-next.2": + version: 18.2.0-next.2 + resolution: "@angular/router@npm:18.2.0-next.2" + dependencies: + tslib: "npm:^2.3.0" + peerDependencies: + "@angular/common": 18.2.0-next.2 + "@angular/core": 18.2.0-next.2 + "@angular/platform-browser": 18.2.0-next.2 + rxjs: ^6.5.3 || ^7.4.0 + checksum: 10c0/ed93e907dc3108e6ad51b1de58f3d574cafb2abf55b9865b9d5c847efd7effad833d7716dab4a5973a4106caf82ebbd1c0be8a27eb7d289eaee5670df4c14db5 + languageName: node + linkType: hard + "@angular/router@npm:18.2.0-rc.0": version: 18.2.0-rc.0 resolution: "@angular/router@npm:18.2.0-rc.0" @@ -948,8 +1008,14 @@ __metadata: version: 0.0.0-use.local resolution: "@angular/ssr@workspace:packages/angular/ssr" dependencies: + "@angular/compiler": "npm:18.2.0-next.2" + "@angular/platform-browser": "npm:18.2.0-next.2" + "@angular/platform-server": "npm:18.2.0-next.2" + "@angular/router": "npm:18.2.0-next.2" critters: "npm:0.0.24" + mrmime: "npm:2.0.0" tslib: "npm:^2.3.0" + zone.js: "npm:^0.14.0" peerDependencies: "@angular/common": ^18.0.0 || ^18.2.0-next.0 "@angular/core": ^18.0.0 || ^18.2.0-next.0 @@ -2321,7 +2387,18 @@ __metadata: languageName: node linkType: hard -"@babel/types@npm:^7.0.0, @babel/types@npm:^7.20.7, @babel/types@npm:^7.24.7, @babel/types@npm:^7.24.8, @babel/types@npm:^7.24.9, @babel/types@npm:^7.25.0, @babel/types@npm:^7.25.2, @babel/types@npm:^7.4.4": +"@babel/types@npm:^7.0.0, @babel/types@npm:^7.20.7, @babel/types@npm:^7.24.7, @babel/types@npm:^7.24.8, @babel/types@npm:^7.24.9, @babel/types@npm:^7.25.0, @babel/types@npm:^7.4.4": + version: 7.25.0 + resolution: "@babel/types@npm:7.25.0" + dependencies: + "@babel/helper-string-parser": "npm:^7.24.8" + "@babel/helper-validator-identifier": "npm:^7.24.7" + to-fast-properties: "npm:^2.0.0" + checksum: 10c0/3b2087d72442d53944b5365c7082f120e5040b0333d4a82406187c19056261ae2a35e087f8408348baadf1dcd156dc74573ec151272191b4a22b564297473da1 + languageName: node + linkType: hard + +"@babel/types@npm:^7.25.2": version: 7.25.2 resolution: "@babel/types@npm:7.25.2" dependencies: @@ -2970,16 +3047,6 @@ __metadata: languageName: node linkType: hard -"@inquirer/confirm@npm:3.1.19": - version: 3.1.19 - resolution: "@inquirer/confirm@npm:3.1.19" - dependencies: - "@inquirer/core": "npm:^9.0.7" - "@inquirer/type": "npm:^1.5.1" - checksum: 10c0/285bc69f5df9ca2b8e44417b8207cc8db386d3db3b1a0d5608cac0711f7927c7b569431424414e6a721ef9efafe88acfb1a1dfb50f7b93a2cd5def5b6f2cfdac - languageName: node - linkType: hard - "@inquirer/confirm@npm:3.1.22, @inquirer/confirm@npm:^3.1.22": version: 3.1.22 resolution: "@inquirer/confirm@npm:3.1.22" @@ -3011,27 +3078,6 @@ __metadata: languageName: node linkType: hard -"@inquirer/core@npm:^9.0.7": - version: 9.0.9 - resolution: "@inquirer/core@npm:9.0.9" - dependencies: - "@inquirer/figures": "npm:^1.0.5" - "@inquirer/type": "npm:^1.5.2" - "@types/mute-stream": "npm:^0.0.4" - "@types/node": "npm:^22.1.0" - "@types/wrap-ansi": "npm:^3.0.0" - ansi-escapes: "npm:^4.3.2" - cli-spinners: "npm:^2.9.2" - cli-width: "npm:^4.1.0" - mute-stream: "npm:^1.0.0" - signal-exit: "npm:^4.1.0" - strip-ansi: "npm:^6.0.1" - wrap-ansi: "npm:^6.2.0" - yoctocolors-cjs: "npm:^2.1.2" - checksum: 10c0/9992947cd7a557256b6871159f30efcbacc088b3c14955352e58350a578d377462f840cc3bbe1f2ec38edc6ab3d819df5742da4d8adf3ef060c958ba08f32afa - languageName: node - linkType: hard - "@inquirer/editor@npm:^2.1.22": version: 2.1.22 resolution: "@inquirer/editor@npm:2.1.22" @@ -3295,13 +3341,6 @@ __metadata: languageName: node linkType: hard -"@lmdb/lmdb-darwin-arm64@npm:3.0.12": - version: 3.0.12 - resolution: "@lmdb/lmdb-darwin-arm64@npm:3.0.12" - conditions: os=darwin & cpu=arm64 - languageName: node - linkType: hard - "@lmdb/lmdb-darwin-arm64@npm:3.0.13": version: 3.0.13 resolution: "@lmdb/lmdb-darwin-arm64@npm:3.0.13" @@ -3309,13 +3348,6 @@ __metadata: languageName: node linkType: hard -"@lmdb/lmdb-darwin-x64@npm:3.0.12": - version: 3.0.12 - resolution: "@lmdb/lmdb-darwin-x64@npm:3.0.12" - conditions: os=darwin & cpu=x64 - languageName: node - linkType: hard - "@lmdb/lmdb-darwin-x64@npm:3.0.13": version: 3.0.13 resolution: "@lmdb/lmdb-darwin-x64@npm:3.0.13" @@ -3323,13 +3355,6 @@ __metadata: languageName: node linkType: hard -"@lmdb/lmdb-linux-arm64@npm:3.0.12": - version: 3.0.12 - resolution: "@lmdb/lmdb-linux-arm64@npm:3.0.12" - conditions: os=linux & cpu=arm64 - languageName: node - linkType: hard - "@lmdb/lmdb-linux-arm64@npm:3.0.13": version: 3.0.13 resolution: "@lmdb/lmdb-linux-arm64@npm:3.0.13" @@ -3337,13 +3362,6 @@ __metadata: languageName: node linkType: hard -"@lmdb/lmdb-linux-arm@npm:3.0.12": - version: 3.0.12 - resolution: "@lmdb/lmdb-linux-arm@npm:3.0.12" - conditions: os=linux & cpu=arm - languageName: node - linkType: hard - "@lmdb/lmdb-linux-arm@npm:3.0.13": version: 3.0.13 resolution: "@lmdb/lmdb-linux-arm@npm:3.0.13" @@ -3351,13 +3369,6 @@ __metadata: languageName: node linkType: hard -"@lmdb/lmdb-linux-x64@npm:3.0.12": - version: 3.0.12 - resolution: "@lmdb/lmdb-linux-x64@npm:3.0.12" - conditions: os=linux & cpu=x64 - languageName: node - linkType: hard - "@lmdb/lmdb-linux-x64@npm:3.0.13": version: 3.0.13 resolution: "@lmdb/lmdb-linux-x64@npm:3.0.13" @@ -3365,13 +3376,6 @@ __metadata: languageName: node linkType: hard -"@lmdb/lmdb-win32-x64@npm:3.0.12": - version: 3.0.12 - resolution: "@lmdb/lmdb-win32-x64@npm:3.0.12" - conditions: os=win32 & cpu=x64 - languageName: node - linkType: hard - "@lmdb/lmdb-win32-x64@npm:3.0.13": version: 3.0.13 resolution: "@lmdb/lmdb-win32-x64@npm:3.0.13" @@ -3390,7 +3394,7 @@ __metadata: languageName: node linkType: hard -"@microsoft/api-extractor@npm:7.47.5, @microsoft/api-extractor@npm:^7.24.2": +"@microsoft/api-extractor@npm:7.47.5": version: 7.47.5 resolution: "@microsoft/api-extractor@npm:7.47.5" dependencies: @@ -3413,6 +3417,29 @@ __metadata: languageName: node linkType: hard +"@microsoft/api-extractor@npm:^7.24.2": + version: 7.47.4 + resolution: "@microsoft/api-extractor@npm:7.47.4" + dependencies: + "@microsoft/api-extractor-model": "npm:7.29.4" + "@microsoft/tsdoc": "npm:~0.15.0" + "@microsoft/tsdoc-config": "npm:~0.17.0" + "@rushstack/node-core-library": "npm:5.5.1" + "@rushstack/rig-package": "npm:0.5.3" + "@rushstack/terminal": "npm:0.13.3" + "@rushstack/ts-command-line": "npm:4.22.3" + lodash: "npm:~4.17.15" + minimatch: "npm:~3.0.3" + resolve: "npm:~1.22.1" + semver: "npm:~7.5.4" + source-map: "npm:~0.6.1" + typescript: "npm:5.4.2" + bin: + api-extractor: bin/api-extractor + checksum: 10c0/8052029d23e163b36b0f5fcc82fbbc9b4c080a3ec72996619426b2b01a0ec2a4d325eb272eb8c6e9bdd9b3b2ea7b549378db17b0062fae3ffd5751d6e6c5fce3 + languageName: node + linkType: hard + "@microsoft/tsdoc-config@npm:~0.17.0": version: 0.17.0 resolution: "@microsoft/tsdoc-config@npm:0.17.0" @@ -4040,13 +4067,6 @@ __metadata: languageName: node linkType: hard -"@rollup/rollup-android-arm-eabi@npm:4.19.1": - version: 4.19.1 - resolution: "@rollup/rollup-android-arm-eabi@npm:4.19.1" - conditions: os=android & cpu=arm - languageName: node - linkType: hard - "@rollup/rollup-android-arm-eabi@npm:4.20.0": version: 4.20.0 resolution: "@rollup/rollup-android-arm-eabi@npm:4.20.0" @@ -4054,13 +4074,6 @@ __metadata: languageName: node linkType: hard -"@rollup/rollup-android-arm64@npm:4.19.1": - version: 4.19.1 - resolution: "@rollup/rollup-android-arm64@npm:4.19.1" - conditions: os=android & cpu=arm64 - languageName: node - linkType: hard - "@rollup/rollup-android-arm64@npm:4.20.0": version: 4.20.0 resolution: "@rollup/rollup-android-arm64@npm:4.20.0" @@ -4068,13 +4081,6 @@ __metadata: languageName: node linkType: hard -"@rollup/rollup-darwin-arm64@npm:4.19.1": - version: 4.19.1 - resolution: "@rollup/rollup-darwin-arm64@npm:4.19.1" - conditions: os=darwin & cpu=arm64 - languageName: node - linkType: hard - "@rollup/rollup-darwin-arm64@npm:4.20.0": version: 4.20.0 resolution: "@rollup/rollup-darwin-arm64@npm:4.20.0" @@ -4082,13 +4088,6 @@ __metadata: languageName: node linkType: hard -"@rollup/rollup-darwin-x64@npm:4.19.1": - version: 4.19.1 - resolution: "@rollup/rollup-darwin-x64@npm:4.19.1" - conditions: os=darwin & cpu=x64 - languageName: node - linkType: hard - "@rollup/rollup-darwin-x64@npm:4.20.0": version: 4.20.0 resolution: "@rollup/rollup-darwin-x64@npm:4.20.0" @@ -4096,13 +4095,6 @@ __metadata: languageName: node linkType: hard -"@rollup/rollup-linux-arm-gnueabihf@npm:4.19.1": - version: 4.19.1 - resolution: "@rollup/rollup-linux-arm-gnueabihf@npm:4.19.1" - conditions: os=linux & cpu=arm & libc=glibc - languageName: node - linkType: hard - "@rollup/rollup-linux-arm-gnueabihf@npm:4.20.0": version: 4.20.0 resolution: "@rollup/rollup-linux-arm-gnueabihf@npm:4.20.0" @@ -4110,13 +4102,6 @@ __metadata: languageName: node linkType: hard -"@rollup/rollup-linux-arm-musleabihf@npm:4.19.1": - version: 4.19.1 - resolution: "@rollup/rollup-linux-arm-musleabihf@npm:4.19.1" - conditions: os=linux & cpu=arm & libc=musl - languageName: node - linkType: hard - "@rollup/rollup-linux-arm-musleabihf@npm:4.20.0": version: 4.20.0 resolution: "@rollup/rollup-linux-arm-musleabihf@npm:4.20.0" @@ -4124,13 +4109,6 @@ __metadata: languageName: node linkType: hard -"@rollup/rollup-linux-arm64-gnu@npm:4.19.1": - version: 4.19.1 - resolution: "@rollup/rollup-linux-arm64-gnu@npm:4.19.1" - conditions: os=linux & cpu=arm64 & libc=glibc - languageName: node - linkType: hard - "@rollup/rollup-linux-arm64-gnu@npm:4.20.0": version: 4.20.0 resolution: "@rollup/rollup-linux-arm64-gnu@npm:4.20.0" @@ -4138,13 +4116,6 @@ __metadata: languageName: node linkType: hard -"@rollup/rollup-linux-arm64-musl@npm:4.19.1": - version: 4.19.1 - resolution: "@rollup/rollup-linux-arm64-musl@npm:4.19.1" - conditions: os=linux & cpu=arm64 & libc=musl - languageName: node - linkType: hard - "@rollup/rollup-linux-arm64-musl@npm:4.20.0": version: 4.20.0 resolution: "@rollup/rollup-linux-arm64-musl@npm:4.20.0" @@ -4152,13 +4123,6 @@ __metadata: languageName: node linkType: hard -"@rollup/rollup-linux-powerpc64le-gnu@npm:4.19.1": - version: 4.19.1 - resolution: "@rollup/rollup-linux-powerpc64le-gnu@npm:4.19.1" - conditions: os=linux & cpu=ppc64 & libc=glibc - languageName: node - linkType: hard - "@rollup/rollup-linux-powerpc64le-gnu@npm:4.20.0": version: 4.20.0 resolution: "@rollup/rollup-linux-powerpc64le-gnu@npm:4.20.0" @@ -4166,13 +4130,6 @@ __metadata: languageName: node linkType: hard -"@rollup/rollup-linux-riscv64-gnu@npm:4.19.1": - version: 4.19.1 - resolution: "@rollup/rollup-linux-riscv64-gnu@npm:4.19.1" - conditions: os=linux & cpu=riscv64 & libc=glibc - languageName: node - linkType: hard - "@rollup/rollup-linux-riscv64-gnu@npm:4.20.0": version: 4.20.0 resolution: "@rollup/rollup-linux-riscv64-gnu@npm:4.20.0" @@ -4180,13 +4137,6 @@ __metadata: languageName: node linkType: hard -"@rollup/rollup-linux-s390x-gnu@npm:4.19.1": - version: 4.19.1 - resolution: "@rollup/rollup-linux-s390x-gnu@npm:4.19.1" - conditions: os=linux & cpu=s390x & libc=glibc - languageName: node - linkType: hard - "@rollup/rollup-linux-s390x-gnu@npm:4.20.0": version: 4.20.0 resolution: "@rollup/rollup-linux-s390x-gnu@npm:4.20.0" @@ -4194,13 +4144,6 @@ __metadata: languageName: node linkType: hard -"@rollup/rollup-linux-x64-gnu@npm:4.19.1": - version: 4.19.1 - resolution: "@rollup/rollup-linux-x64-gnu@npm:4.19.1" - conditions: os=linux & cpu=x64 & libc=glibc - languageName: node - linkType: hard - "@rollup/rollup-linux-x64-gnu@npm:4.20.0": version: 4.20.0 resolution: "@rollup/rollup-linux-x64-gnu@npm:4.20.0" @@ -4208,13 +4151,6 @@ __metadata: languageName: node linkType: hard -"@rollup/rollup-linux-x64-musl@npm:4.19.1": - version: 4.19.1 - resolution: "@rollup/rollup-linux-x64-musl@npm:4.19.1" - conditions: os=linux & cpu=x64 & libc=musl - languageName: node - linkType: hard - "@rollup/rollup-linux-x64-musl@npm:4.20.0": version: 4.20.0 resolution: "@rollup/rollup-linux-x64-musl@npm:4.20.0" @@ -4222,13 +4158,6 @@ __metadata: languageName: node linkType: hard -"@rollup/rollup-win32-arm64-msvc@npm:4.19.1": - version: 4.19.1 - resolution: "@rollup/rollup-win32-arm64-msvc@npm:4.19.1" - conditions: os=win32 & cpu=arm64 - languageName: node - linkType: hard - "@rollup/rollup-win32-arm64-msvc@npm:4.20.0": version: 4.20.0 resolution: "@rollup/rollup-win32-arm64-msvc@npm:4.20.0" @@ -4236,13 +4165,6 @@ __metadata: languageName: node linkType: hard -"@rollup/rollup-win32-ia32-msvc@npm:4.19.1": - version: 4.19.1 - resolution: "@rollup/rollup-win32-ia32-msvc@npm:4.19.1" - conditions: os=win32 & cpu=ia32 - languageName: node - linkType: hard - "@rollup/rollup-win32-ia32-msvc@npm:4.20.0": version: 4.20.0 resolution: "@rollup/rollup-win32-ia32-msvc@npm:4.20.0" @@ -4250,13 +4172,6 @@ __metadata: languageName: node linkType: hard -"@rollup/rollup-win32-x64-msvc@npm:4.19.1": - version: 4.19.1 - resolution: "@rollup/rollup-win32-x64-msvc@npm:4.19.1" - conditions: os=win32 & cpu=x64 - languageName: node - linkType: hard - "@rollup/rollup-win32-x64-msvc@npm:4.20.0": version: 4.20.0 resolution: "@rollup/rollup-win32-x64-msvc@npm:4.20.0" @@ -4325,6 +4240,18 @@ __metadata: languageName: node linkType: hard +"@rushstack/ts-command-line@npm:4.22.3": + version: 4.22.3 + resolution: "@rushstack/ts-command-line@npm:4.22.3" + dependencies: + "@rushstack/terminal": "npm:0.13.3" + "@types/argparse": "npm:1.0.38" + argparse: "npm:~1.0.9" + string-argv: "npm:~0.3.1" + checksum: 10c0/02b318832d80829b749e7123511f3828945bca4cd07e59d4afbfc07c5140acbf9cab28b1f36c1023f14c16280fdb992198b132a679e4d240710fc02138a0ba3a + languageName: node + linkType: hard + "@rushstack/ts-command-line@npm:4.22.4": version: 4.22.4 resolution: "@rushstack/ts-command-line@npm:4.22.4" @@ -12196,41 +12123,6 @@ __metadata: languageName: node linkType: hard -"lmdb@npm:3.0.12": - version: 3.0.12 - resolution: "lmdb@npm:3.0.12" - dependencies: - "@lmdb/lmdb-darwin-arm64": "npm:3.0.12" - "@lmdb/lmdb-darwin-x64": "npm:3.0.12" - "@lmdb/lmdb-linux-arm": "npm:3.0.12" - "@lmdb/lmdb-linux-arm64": "npm:3.0.12" - "@lmdb/lmdb-linux-x64": "npm:3.0.12" - "@lmdb/lmdb-win32-x64": "npm:3.0.12" - msgpackr: "npm:^1.10.2" - node-addon-api: "npm:^6.1.0" - node-gyp: "npm:latest" - node-gyp-build-optional-packages: "npm:5.2.2" - ordered-binary: "npm:^1.4.1" - weak-lru-cache: "npm:^1.2.2" - dependenciesMeta: - "@lmdb/lmdb-darwin-arm64": - optional: true - "@lmdb/lmdb-darwin-x64": - optional: true - "@lmdb/lmdb-linux-arm": - optional: true - "@lmdb/lmdb-linux-arm64": - optional: true - "@lmdb/lmdb-linux-x64": - optional: true - "@lmdb/lmdb-win32-x64": - optional: true - bin: - download-lmdb-prebuilds: bin/download-prebuilds.js - checksum: 10c0/8f1b4e323e0afb89fd0d106b154c23b56a43f9585b5e2a053ced8132f79c7ca93dde92b43e676ef8d674ccf2d52d16218d9485e1660a4797053cd3ddcf74d550 - languageName: node - linkType: hard - "lmdb@npm:3.0.13": version: 3.0.13 resolution: "lmdb@npm:3.0.13" @@ -15243,69 +15135,6 @@ __metadata: languageName: node linkType: hard -"rollup@npm:4.19.1": - version: 4.19.1 - resolution: "rollup@npm:4.19.1" - dependencies: - "@rollup/rollup-android-arm-eabi": "npm:4.19.1" - "@rollup/rollup-android-arm64": "npm:4.19.1" - "@rollup/rollup-darwin-arm64": "npm:4.19.1" - "@rollup/rollup-darwin-x64": "npm:4.19.1" - "@rollup/rollup-linux-arm-gnueabihf": "npm:4.19.1" - "@rollup/rollup-linux-arm-musleabihf": "npm:4.19.1" - "@rollup/rollup-linux-arm64-gnu": "npm:4.19.1" - "@rollup/rollup-linux-arm64-musl": "npm:4.19.1" - "@rollup/rollup-linux-powerpc64le-gnu": "npm:4.19.1" - "@rollup/rollup-linux-riscv64-gnu": "npm:4.19.1" - "@rollup/rollup-linux-s390x-gnu": "npm:4.19.1" - "@rollup/rollup-linux-x64-gnu": "npm:4.19.1" - "@rollup/rollup-linux-x64-musl": "npm:4.19.1" - "@rollup/rollup-win32-arm64-msvc": "npm:4.19.1" - "@rollup/rollup-win32-ia32-msvc": "npm:4.19.1" - "@rollup/rollup-win32-x64-msvc": "npm:4.19.1" - "@types/estree": "npm:1.0.5" - fsevents: "npm:~2.3.2" - dependenciesMeta: - "@rollup/rollup-android-arm-eabi": - optional: true - "@rollup/rollup-android-arm64": - optional: true - "@rollup/rollup-darwin-arm64": - optional: true - "@rollup/rollup-darwin-x64": - optional: true - "@rollup/rollup-linux-arm-gnueabihf": - optional: true - "@rollup/rollup-linux-arm-musleabihf": - optional: true - "@rollup/rollup-linux-arm64-gnu": - optional: true - "@rollup/rollup-linux-arm64-musl": - optional: true - "@rollup/rollup-linux-powerpc64le-gnu": - optional: true - "@rollup/rollup-linux-riscv64-gnu": - optional: true - "@rollup/rollup-linux-s390x-gnu": - optional: true - "@rollup/rollup-linux-x64-gnu": - optional: true - "@rollup/rollup-linux-x64-musl": - optional: true - "@rollup/rollup-win32-arm64-msvc": - optional: true - "@rollup/rollup-win32-ia32-msvc": - optional: true - "@rollup/rollup-win32-x64-msvc": - optional: true - fsevents: - optional: true - bin: - rollup: dist/bin/rollup - checksum: 10c0/2e526c38b4bcb22a058cf95e40c8c105a86f27d582c677c47df9315a17b18e75c772edc0773ca4d12d58ceca254bb5d63d4172041f6fd9f01e1a613d8bba6d09 - languageName: node - linkType: hard - "rollup@npm:4.20.0, rollup@npm:^4.13.0, rollup@npm:^4.18.0, rollup@npm:^4.4.0": version: 4.20.0 resolution: "rollup@npm:4.20.0"