From e9eac7eb996bf99e29d252e489eaf92b9ec3652a Mon Sep 17 00:00:00 2001 From: I777870 Date: Fri, 7 Aug 2026 09:22:45 +0200 Subject: [PATCH 1/2] spike(ssr): base-site detection approach b Approach (b): createApplication() boots minimal Angular app once at startup, reuse Spartacus DI (SiteContextConfigInitializer + BaseSiteService) to resolve base sites. Cache TTL 60s, AbortController timeout 3s. Add: - BaseSiteResolver shared contract - AngularAppBaseSiteResolver (approach b impl) - bench harness (dedup-startup, cold-start, warm-resolve, concurrent, slow-occ) - Express llms.txt handler consuming resolver SPIKE only, not production code. --- .../angular-app-base-site-resolver.ts | 272 ++++++++++++++++++ .../site-context/base-site-resolver.bench.ts | 219 ++++++++++++++ .../ssr/site-context/base-site-resolver.ts | 72 +++++ .../ssr/site-context/tsconfig.bench.json | 21 ++ projects/storefrontapp/src/server.ts | 62 +++- 5 files changed, 642 insertions(+), 4 deletions(-) create mode 100644 core-libs/setup/ssr/site-context/angular-app-base-site-resolver.ts create mode 100644 core-libs/setup/ssr/site-context/base-site-resolver.bench.ts create mode 100644 core-libs/setup/ssr/site-context/base-site-resolver.ts create mode 100644 core-libs/setup/ssr/site-context/tsconfig.bench.json diff --git a/core-libs/setup/ssr/site-context/angular-app-base-site-resolver.ts b/core-libs/setup/ssr/site-context/angular-app-base-site-resolver.ts new file mode 100644 index 00000000000..5f2fa6d20a7 --- /dev/null +++ b/core-libs/setup/ssr/site-context/angular-app-base-site-resolver.ts @@ -0,0 +1,272 @@ +/* + * SPDX-FileCopyrightText: 2026 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +// ── Approach (b): createApplication() — honest NgRx rewrite ───────────────── +// This ENTIRE FILE is approach (b). Implements BaseSiteResolver using genuine +// Spartacus DI: SiteContextConfigInitializer + ConfigInitializerService + +// BaseSiteService (NgRx Store + Effects). No copied regex logic. + +/** + * SPIKE — not production code. + * Approach (b): Angular renderApplication() with full Spartacus DI graph. + * + * Architecture: per-request full server bootstrap via renderApplication(). + * + * Why renderApplication() (not bare bootstrapApplication + platformServer): + * bootstrapApplication() on a bare platformServer() throws: + * - NG0401 unless a BootstrapContext { platformRef } is supplied, and + * - NG05104 because no server document exists, so the root component + * selector matches no host element. + * renderApplication(bootstrap, { document, url }) solves both: it calls + * createServerPlatform() which provides INITIAL_CONFIG (a real server + * document via domino, giving the host element), then invokes + * our bootstrap fn WITH { platformRef } as the BootstrapContext, then awaits + * applicationRef.whenStable() — HttpClient(withFetch) registers a pending + * task, so whenStable() waits for the OCC call to complete before serialize. + * + * Why read the site in a BEFORE_APP_SERIALIZED callback: + * renderApplication() runs BEFORE_APP_SERIALIZED callbacks AFTER whenStable() + * and BEFORE it destroys the platform. That is the one window where the NgRx + * store + SiteContextConfig are fully populated and the injector is still + * alive. Callbacks may be async, so we await ConfigInitializerService there. + * + * Why per-request: + * SiteContextConfigInitializer reads the current URL via WindowRef.location.href. + * renderApplication() creates a fresh server platform per call and destroys it + * after, so each resolve() gets an isolated DI tree bound to one request URL. + * + * Why no TTL cache: + * Per Krzysztof Platis (sync 2026-08-03): in-memory cache across SSR workers + * is not viable — workers crash and restart with inconsistent cache state, and + * the infrastructure does not support a shared external cache. Each request + * must fetch from OCC directly. + * + * Minimum provider set discovered: + * - provideServerRendering() ← server DOCUMENT + zone + * - provideHttpClient(withFetch()) + * - StoreModule.forRoot({}) + EffectsModule.forRoot([]) ← NgRx root + * - SiteContextModule.forRoot() ← site context Store + Effects + * - BaseOccModule.forRoot() ← OCC adapters + SiteAdapter + * - provideConfig({ backend.occ }) ← OCC URL + * - { provide: WindowRef, useValue: { location: { href: requestUrl } } } + * - { provide: BEFORE_APP_SERIALIZED, multi: true, ... } ← reads resolved site + * + * Code-reuse delta vs approach (a): + * - (a) copies toJsRegExp() (~20 lines) from JavaRegExpConverter and the full + * OCC response parsing loop (~15 lines). + * - (b) reuses JavaRegExpConverter, BaseSiteService, SiteContextConfigInitializer, + * and all OCC normalizers via DI — zero copied Spartacus logic in this file. + * - Cost: per-request Angular DI boot overhead (see benchmark numbers). + * + * Platform lifecycle + concurrency on CCv2: + * renderApplication() calls platformServer() (a process-level singleton) then + * destroys it in a finally via setTimeout(0). Two renderApplication() calls + * overlapping in time therefore risk colliding on the platform singleton + * (createPlatform throws if one already exists). CCv2 runs one Node.js process + * per pod, so this is a real per-process concurrency limit — see benchmark + * scenario 4 (concurrent) for whether it manifests under load. + */ + +/* webpackIgnore: true */ +// JIT compiler needed for Angular partial compilation in non-built context (bench/spike only). +import '@angular/compiler'; +import { PlatformRef } from '@angular/core'; +import { performance } from 'perf_hooks'; +import { + BaseSiteResolver, + BaseSiteResolverConfig, + OccUnavailableError, +} from './base-site-resolver'; + +export class AngularAppBaseSiteResolver implements BaseSiteResolver { + protected readonly occBaseUrl: string; + protected readonly occPrefix: string; + protected readonly timeoutMs: number; + protected readonly defaultBaseSite: string | null; + + protected initPromise: Promise | null = null; + + constructor(config: BaseSiteResolverConfig) { + this.occBaseUrl = config.occBaseUrl; + this.occPrefix = config.occPrefix ?? '/occ/v2'; + this.timeoutMs = config.timeoutMs ?? 3000; + this.defaultBaseSite = config.defaultBaseSite ?? null; + } + + async initialize(): Promise { + if (!this.initPromise) { + this.initPromise = (async () => { + // Warm the platform-server module graph once. renderApplication() + // creates and destroys its own server platform per resolve() call. + await import('@angular/platform-server'); + console.log('[create-application] resolver ready (renderApplication per-request)'); + })(); + } + return this.initPromise; + } + + async resolve(requestUrl: string): Promise { + await this.initialize(); + return this.resolveViaAngular(requestUrl); + } + + async destroy(): Promise { + this.initPromise = null; + } + + protected async resolveViaAngular(requestUrl: string): Promise { + const t0 = performance.now(); + + const { renderApplication, provideServerRendering, BEFORE_APP_SERIALIZED } = + await import('@angular/platform-server'); + const { bootstrapApplication } = await import('@angular/platform-browser'); + const { provideHttpClient, withFetch } = await import('@angular/common/http'); + const { importProvidersFrom, Component, inject } = await import('@angular/core'); + const { StoreModule } = await import('@ngrx/store'); + const { EffectsModule } = await import('@ngrx/effects'); + // Import specific symbols via deep relative paths rather than the + // '../../../core/public_api' barrel: importing a lib's own public_api from + // inside that lib risks circular deps and pulls the whole barrel per call. + const { SiteContextModule } = await import( + '../../../core/src/site-context/site-context.module' + ); + const { BaseOccModule } = await import( + '../../../core/src/occ/base-occ.module' + ); + const { WindowRef } = await import('../../../core/src/window/window-ref'); + const { BaseSiteService } = await import( + '../../../core/src/site-context/facade/base-site.service' + ); + const { JavaRegExpConverter } = await import( + '../../../core/src/util/java-reg-exp-converter/java-reg-exp-converter' + ); + const { provideConfig } = await import( + '../../../core/src/config/config-providers' + ); + const { firstValueFrom } = await import('rxjs'); + + // Root component selector must match the host element in the server document + // passed to renderApplication() below (), else NG05104. + @Component({ selector: 'app-root', standalone: true, template: '' }) + class BaseSiteResolverRootComponent {} + + let capturedSite: string | null = null; + + // renderApplication() passes { platformRef } here as the BootstrapContext + // (third arg of bootstrapApplication) — required in server mode (NG0401). + const bootstrap = (context: { platformRef: PlatformRef }) => + bootstrapApplication( + BaseSiteResolverRootComponent, + { + providers: [ + provideServerRendering(), + provideHttpClient(withFetch()), + importProvidersFrom( + StoreModule.forRoot({}), + EffectsModule.forRoot([]), + SiteContextModule.forRoot(), + BaseOccModule.forRoot() + ), + provideConfig({ + backend: { + occ: { + baseUrl: this.occBaseUrl, + prefix: `${this.occPrefix}/`, + }, + }, + }), + { + provide: WindowRef, + useValue: { location: { href: requestUrl } }, + }, + { + // Runs after whenStable() and before platform destroy — the only + // window where the NgRx store is populated and the injector is + // still alive. Reuses BaseSiteService.getAll() (NgRx Store + + // Effects → OCC) and JavaRegExpConverter — zero copied logic. + provide: BEFORE_APP_SERIALIZED, + multi: true, + useFactory: () => { + const baseSiteService = inject(BaseSiteService); + const regexConverter = inject(JavaRegExpConverter); + const winRef = inject(WindowRef); + return async () => { + const sites = await firstValueFrom(baseSiteService.getAll()); + const url = winRef.location.href as string; + console.log( + `[create-application] baseSites loaded: ${sites?.length}, url=${url}` + ); + const match = sites?.find((s) => + (s.urlPatterns || []).some((p) => + regexConverter.toJsRegExp(p)?.test(url) + ) + ); + capturedSite = match?.uid ?? this.defaultBaseSite; + }; + }, + }, + ], + }, + context + ); + + let timer: ReturnType | undefined; + try { + const renderPromise = renderApplication(bootstrap, { + document: + '', + url: requestUrl, + // renderApplication() calls validateAllowedHosts(url, allowedHosts); + // an empty set rejects every host with NG05706. Spike resolves any URL. + // NOTE (spike): '*' is unsafe for production — an app must pin the + // allowed hosts to prevent host-header spoofing. + allowedHosts: ['*'], + }); + + const timeoutPromise = new Promise((_, reject) => { + timer = setTimeout( + () => + reject( + new OccUnavailableError( + `renderApplication() timed out after ${this.timeoutMs} ms` + ) + ), + this.timeoutMs + ); + // Do not keep the event loop alive solely for this timer. + timer.unref?.(); + }); + + await Promise.race([renderPromise, timeoutPromise]); + + const totalMs = (performance.now() - t0).toFixed(1); + console.log( + `[create-application] resolve() done in ${totalMs} ms, site=${capturedSite}` + ); + return capturedSite; + } catch (err) { + const elapsed = (performance.now() - t0).toFixed(1); + console.error( + `[create-application] resolve() failed after ${elapsed} ms:`, + err + ); + // A render timeout or a render-level throw means we could not resolve — + // surface it so the Express handler maps to 503 instead of silently + // serving default content. (A no-match still returns via the try above.) + if (err instanceof OccUnavailableError) { + throw err; + } + throw new OccUnavailableError( + `renderApplication() failed after ${elapsed} ms`, + err + ); + } finally { + if (timer) { + clearTimeout(timer); + } + } + } +} diff --git a/core-libs/setup/ssr/site-context/base-site-resolver.bench.ts b/core-libs/setup/ssr/site-context/base-site-resolver.bench.ts new file mode 100644 index 00000000000..9061617da6a --- /dev/null +++ b/core-libs/setup/ssr/site-context/base-site-resolver.bench.ts @@ -0,0 +1,219 @@ +/* + * SPDX-FileCopyrightText: 2026 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * SPIKE — performance benchmark harness. + * + * Run with: npx ts-node core-libs/setup/ssr/site-context/base-site-resolver.bench.ts + * + * Approach (b): createApplication() is CACHELESS — every resolve() boots a + * fresh Angular app and fetches base-sites from OCC. There is no warm cache, so + * the scenarios mirror approach (a): per-call latency, concurrent batches, and + * slow-OCC timeout. This makes the numbers directly comparable (like-for-like). + * + * Required env vars: + * CX_BASE_URL=https://your-backend.com + * BENCH_REQUEST_URL=https://your-storefront.com/en/ (URL to resolve) + * MOCK_OCC_PORT=9999 (optional: mock slow OCC port) + * + * Scenarios: + * per-call — N sequential resolve() calls, each a full boot + OCC fetch + * concurrent — 10 parallel resolve() calls, 3 batches; also stresses the + * platform-server singleton (renderApplication() collisions) + * slow-occ — resolve() against a mock 4 s OCC with a 3 s timeout; asserts + * it throws OccUnavailableError (mapped to 503 by the handler) + * + * Scenario slow-occ uses a local mock — runs without CX_BASE_URL. + * Scenarios per-call / concurrent require a real OCC backend (CX_BASE_URL). + */ + +/* webpackIgnore: true */ +import 'reflect-metadata'; +import * as http from 'node:http'; +import { performance } from 'node:perf_hooks'; +import { BaseSiteResolver, BaseSiteResolverConfig } from './base-site-resolver'; + +// ─── config ──────────────────────────────────────────────────────────────── + +const OCC_BASE_URL = process.env['CX_BASE_URL'] ?? ''; +const REQUEST_URL = + process.env['BENCH_REQUEST_URL'] ?? 'http://localhost:4000/en/'; +const MOCK_OCC_PORT = process.env['MOCK_OCC_PORT'] + ? Number(process.env['MOCK_OCC_PORT']) + : null; + +import { AngularAppBaseSiteResolver } from './angular-app-base-site-resolver'; +const APPROACH_LABEL = 'create-application (cacheless)'; +function makeResolver(config: BaseSiteResolverConfig): BaseSiteResolver { + return new AngularAppBaseSiteResolver(config); +} + +// ─── statistics ───────────────────────────────────────────────────────────── + +function stats(samples: number[]): { + mean: number; + p50: number; + p95: number; + p99: number; + max: number; +} { + const sorted = [...samples].sort((a, b) => a - b); + const mean = samples.reduce((s, v) => s + v, 0) / samples.length; + const pct = (p: number) => + sorted[Math.floor((p / 100) * sorted.length)] ?? sorted[sorted.length - 1]; + return { + mean, + p50: pct(50), + p95: pct(95), + p99: pct(99), + max: sorted[sorted.length - 1], + }; +} + +function fmt(n: number): string { + return n.toFixed(2).padStart(8); +} + +function printStats(label: string, samples: number[]): void { + const s = stats(samples); + console.log( + ` ${label.padEnd(20)} mean=${fmt(s.mean)} ms p50=${fmt(s.p50)} ms p95=${fmt(s.p95)} ms p99=${fmt(s.p99)} ms max=${fmt(s.max)} ms` + ); +} + +// ─── mock OCC server ──────────────────────────────────────────────────────── + +const MOCK_BODY = JSON.stringify({ + baseSites: [{ uid: 'mock-site', urlPatterns: ['(?i)^https?://.*'] }], +}); + +function startMockOccServer(port: number, delayMs: number): http.Server { + const server = http.createServer((_req, res) => { + setTimeout(() => { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(MOCK_BODY); + }, delayMs); + }); + server.listen(port); + return server; +} + +// ─── scenarios ────────────────────────────────────────────────────────────── + +async function scenarioPerCall( + resolver: BaseSiteResolver, + iterations: number +): Promise { + await resolver.initialize(); + const samples: number[] = []; + for (let i = 0; i < iterations; i++) { + const t0 = performance.now(); + await resolver.resolve(REQUEST_URL); + samples.push(performance.now() - t0); + } + return samples; +} + +async function scenarioConcurrent( + resolver: BaseSiteResolver, + concurrency: number, + batches: number +): Promise<{ batchSamples: number[]; failures: number }> { + await resolver.initialize(); + const batchSamples: number[] = []; + let failures = 0; + for (let b = 0; b < batches; b++) { + const t0 = performance.now(); + const results = await Promise.allSettled( + Array.from({ length: concurrency }, () => resolver.resolve(REQUEST_URL)) + ); + failures += results.filter((r) => r.status === 'rejected').length; + batchSamples.push(performance.now() - t0); + } + return { batchSamples, failures }; +} + +async function scenarioSlowOcc( + occBaseUrl: string, + resolverTimeoutMs: number +): Promise<{ threw: boolean; errorName: string; elapsed: number }> { + const resolver = makeResolver({ occBaseUrl, timeoutMs: resolverTimeoutMs }); + await resolver.initialize(); + const t0 = performance.now(); + let threw = false; + let errorName = ''; + try { + await resolver.resolve(REQUEST_URL); + } catch (err) { + threw = true; + errorName = (err as Error).name; + } + const elapsed = performance.now() - t0; + await resolver.destroy(); + return { threw, errorName, elapsed }; +} + +// ─── main ──────────────────────────────────────────────────────────────────── + +async function main(): Promise { + console.log(`\n${'═'.repeat(72)}`); + console.log(` BASE-SITE RESOLVER BENCHMARK`); + console.log(` Approach : ${APPROACH_LABEL}`); + console.log(` OCC URL : ${OCC_BASE_URL || '(not set — will fail)'}`); + console.log(` Req URL : ${REQUEST_URL}`); + console.log(`${'═'.repeat(72)}\n`); + + // ── slow OCC (mock — no CX_BASE_URL needed) ────────────────────────────── + console.log('Scenario slow-occ (mock 4 s OCC, resolver timeout 3 s)\n'); + { + const port = MOCK_OCC_PORT ?? 9999; + const mockServer = startMockOccServer(port, 4000); + const result = await scenarioSlowOcc(`http://localhost:${port}`, 3000); + const pass = result.threw && result.errorName === 'OccUnavailableError'; + console.log( + ` resolve: ${result.elapsed.toFixed(1)} ms threw: ${result.errorName || 'none'} timeout-503: ${pass ? 'PASS' : 'FAIL — expected OccUnavailableError'}` + ); + if (!pass) { + process.exitCode = 1; + } + mockServer.close(); + } + + if (!OCC_BASE_URL) { + console.log( + '\nINFO: CX_BASE_URL not set — scenarios per-call / concurrent skipped.' + ); + console.log( + ' Set CX_BASE_URL=https://your-backend.com BENCH_REQUEST_URL=https://storefront.com/en/ and re-run.\n' + ); + } else { + const resolver = makeResolver({ occBaseUrl: OCC_BASE_URL, timeoutMs: 3000 }); + + // ── per-call (each resolve = full boot + OCC fetch) ────────────────────── + console.log('\nScenario per-call (100 iterations)\n'); + const perCallSamples = await scenarioPerCall(resolver, 100); + printStats('per-call', perCallSamples); + + // ── concurrent (also stresses platform-server singleton) ───────────────── + console.log('\nScenario concurrent (10 × resolve, 3 batches)\n'); + const { batchSamples, failures } = await scenarioConcurrent(resolver, 10, 3); + printStats('concurrent-batch', batchSamples); + console.log( + ` rejected resolves: ${failures} / 30 ${failures > 0 ? '⚠ platform-singleton collisions under concurrency' : ''}` + ); + + await resolver.destroy(); + } + + console.log(`\n${'═'.repeat(72)}`); + console.log(' Done. Copy numbers into ADR §5 (performance).'); + console.log(`${'═'.repeat(72)}\n`); +} + +main().catch((err) => { + console.error('Benchmark failed:', err); + process.exit(1); +}); diff --git a/core-libs/setup/ssr/site-context/base-site-resolver.ts b/core-libs/setup/ssr/site-context/base-site-resolver.ts new file mode 100644 index 00000000000..ad8cbebc398 --- /dev/null +++ b/core-libs/setup/ssr/site-context/base-site-resolver.ts @@ -0,0 +1,72 @@ +/* + * SPDX-FileCopyrightText: 2026 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * SPIKE — not production code. + * Approach (b): createApplication() / renderApplication() with Spartacus DI. + * + * Contract implemented by the base-site resolver. + */ + +export interface BaseSiteResolverConfig { + /** OCC backend base URL, e.g. https://backend.com — from process.env['CX_BASE_URL'] */ + occBaseUrl: string; + /** OCC API prefix. Default: '/occ/v2' */ + occPrefix?: string; + /** Abort timeout for OCC calls in ms. Default: 3000 (matches OptimizedSsrEngine default) */ + timeoutMs?: number; + /** + * baseSiteId returned by resolve() when no urlPattern matches the request URL. + * In production this comes from the app's configured `context.baseSite[0]` + * (SiteContextConfig). When omitted, resolve() returns null on no match. + */ + defaultBaseSite?: string; +} + +export interface BaseSiteResolver { + /** + * One-time warm-up hook. Called once at server startup before handling any + * requests. This resolver is cacheless — each resolve() boots a fresh + * Angular app and fetches from OCC — so initialize() only warms the + * platform-server module graph; it does not fetch or cache base-sites. + */ + initialize(): Promise; + + /** + * Resolve the baseSiteId for the given absolute request URL. + * + * Returns: + * - the matched site's uid, or + * - the configured `defaultBaseSite` (or null) when the app rendered + * cleanly but no urlPattern matched the URL. + * + * Throws: + * - `OccUnavailableError` when the underlying render times out or fails, + * so the Express handler can map it to a 503 instead of silently + * serving default content. + */ + resolve(requestUrl: string): Promise; + + /** Release resources. */ + destroy(): Promise; +} + +/** + * Thrown by resolve() when the base-site render/fetch cannot complete + * (OCC unreachable or timed out). The Express handler maps this to + * `503 Service Unavailable` + Retry-After rather than serving default content. + * + * Note: an OCC error raised *inside* the NgRx effect does NOT surface here — + * the effect swallows it (catch → return null), so a clean render against a + * failed OCC still returns `defaultBaseSite`. Only a render timeout or a + * render-level throw reaches this taxonomy. See the ADR con for details. + */ +export class OccUnavailableError extends Error { + constructor(message: string, readonly originalError?: unknown) { + super(message); + this.name = 'OccUnavailableError'; + } +} diff --git a/core-libs/setup/ssr/site-context/tsconfig.bench.json b/core-libs/setup/ssr/site-context/tsconfig.bench.json new file mode 100644 index 00000000000..56653480389 --- /dev/null +++ b/core-libs/setup/ssr/site-context/tsconfig.bench.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "module": "CommonJS", + "moduleResolution": "node", + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "target": "ES2020", + "lib": ["ES2020"], + "skipLibCheck": true, + "noEmitOnError": false, + "outDir": "/tmp/bench-b", + "baseUrl": "../../../../" + }, + "include": [ + "base-site-resolver.bench.ts", + "base-site-resolver.ts", + "angular-app-base-site-resolver.ts" + ] +} diff --git a/projects/storefrontapp/src/server.ts b/projects/storefrontapp/src/server.ts index b9ef896fe55..2cc3d40e91a 100644 --- a/projects/storefrontapp/src/server.ts +++ b/projects/storefrontapp/src/server.ts @@ -4,6 +4,10 @@ * SPDX-License-Identifier: Apache-2.0 */ +// SPIKE — base-site detection, approach (b): createApplication(). +// See: core-libs/setup/ssr/site-context/base-site-resolver.ts for the shared interface. +// See: adr-base-site-detection-ssr.md for the full comparison. + import { APP_BASE_HREF } from '@angular/common'; import { NgExpressEngineDecorator, @@ -18,6 +22,18 @@ import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname, join, resolve } from 'path'; import bootstrap from './main.server'; +import { getRequestUrl } from '../../../core-libs/setup/ssr/express-utils/express-request-url'; + +// Approach (b): createApplication() — cacheless. Each resolve() boots a +// minimal Angular app (HttpClient only) and fetches base-sites from OCC. +import { AngularAppBaseSiteResolver } from '../../../core-libs/setup/ssr/site-context/angular-app-base-site-resolver'; +import { OccUnavailableError } from '../../../core-libs/setup/ssr/site-context/base-site-resolver'; +const baseSiteResolver = new AngularAppBaseSiteResolver({ + occBaseUrl: buildProcess.env.CX_BASE_URL, + timeoutMs: 3000, + // In production this comes from the app's SiteContextConfig context.baseSite[0]. + defaultBaseSite: 'electronics-spa', +}); const ssrOptions: SsrOptimizationOptions = { timeout: Number( @@ -29,7 +45,12 @@ const ssrOptions: SsrOptimizationOptions = { const ngExpressEngine = NgExpressEngineDecorator.get(engine, ssrOptions); // The Express app is exported so that it can be used by serverless Functions. -export function app(): express.Express { +// SPIKE: app() is async so the resolver can warm the platform-server module +// graph before serving (initialize() does not fetch or cache). +export async function app(): Promise { + // Warm the platform-server module graph once before serving requests. + await baseSiteResolver.initialize(); + const server = express(); const serverDistFolder = dirname(fileURLToPath(import.meta.url)); const browserDistFolder = resolve(serverDistFolder, '../browser'); @@ -66,7 +87,28 @@ export function app(): express.Express { }) ); - // All regular routes use the Universal engine + // SPIKE: llms.txt — example non-render handler consuming the resolver. + // Unlike robots.txt (origin-root only, RFC 9309), llms.txt MAY be nested under a + // path. The regex below matches BOTH: + // • /llms.txt → no site prefix → resolve() returns null → default + // • /{baseSite}/llms.txt → prefix present → resolve() matches urlPattern → per-site + server.get(/\/llms\.txt$/, async (req, res, next) => { + try { + const baseSiteId = await baseSiteResolver.resolve(getRequestUrl(req)); + const content = getLlmsTxt(baseSiteId); + res.type('text/plain').send(content); + } catch (err) { + // resolve() throws OccUnavailableError on render timeout/failure — map to + // 503 + Retry-After rather than serving default content on a failed OCC. + if (err instanceof OccUnavailableError) { + res.set('Retry-After', '5').status(503).type('text/plain').send(''); + return; + } + next(err); + } + }); + + // Angular Universal render — all regular routes. server.get(/.*/, (req, res) => { res.render(indexHtml, { req, @@ -79,11 +121,11 @@ export function app(): express.Express { return server; } -function run() { +async function run() { const port = process.env['PORT'] || 4000; // Start up the Node server - const server = app(); + const server = await app(); server.listen(port, () => { /* eslint-disable-next-line no-console -- @@ -95,3 +137,15 @@ function run() { } run(); + +/** + * SPIKE stub — returns per-site llms.txt content. + * In production this would read from config / CMS. + * Consumed by the Express llms.txt handler above. + */ +function getLlmsTxt(baseSiteId: string | null): string { + if (!baseSiteId) { + return '# llms.txt\n> General LLM rules — applies to all sites on this origin.\n'; + } + return `# llms.txt\n> Site: ${baseSiteId}\n`; +} From a966b125f6501f0cde1ff1e32fa249fd8983dc5b Mon Sep 17 00:00:00 2001 From: I777870 Date: Fri, 7 Aug 2026 13:56:14 +0200 Subject: [PATCH 2/2] spike(CXSPA-13856): add concurrency cap to approach-b resolver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit renderApplication() hits platformServer() — process-level singleton. Two overlapping renders throw "createPlatform called twice". Cap=1 serialises via fail-fast: first render proceeds, extras shed with ConcurrencyLimitError → 503 + Retry-After. Also fixes deep core/src/… imports to core/public_api barrel. --- .../angular-app-base-site-resolver.ts | 50 ++++++----- .../site-context/base-site-resolver.bench.ts | 83 +++++++++++++++++-- .../ssr/site-context/base-site-resolver.ts | 21 +++++ projects/storefrontapp/src/server.ts | 22 +++-- 4 files changed, 145 insertions(+), 31 deletions(-) diff --git a/core-libs/setup/ssr/site-context/angular-app-base-site-resolver.ts b/core-libs/setup/ssr/site-context/angular-app-base-site-resolver.ts index 5f2fa6d20a7..941f1e8c5c6 100644 --- a/core-libs/setup/ssr/site-context/angular-app-base-site-resolver.ts +++ b/core-libs/setup/ssr/site-context/angular-app-base-site-resolver.ts @@ -78,6 +78,7 @@ import { performance } from 'perf_hooks'; import { BaseSiteResolver, BaseSiteResolverConfig, + ConcurrencyLimitError, OccUnavailableError, } from './base-site-resolver'; @@ -86,6 +87,9 @@ export class AngularAppBaseSiteResolver implements BaseSiteResolver { protected readonly occPrefix: string; protected readonly timeoutMs: number; protected readonly defaultBaseSite: string | null; + protected readonly maxConcurrentOccCalls: number; + /** Renders currently in flight; the basis for the concurrency cap. */ + protected inFlight = 0; protected initPromise: Promise | null = null; @@ -94,6 +98,7 @@ export class AngularAppBaseSiteResolver implements BaseSiteResolver { this.occPrefix = config.occPrefix ?? '/occ/v2'; this.timeoutMs = config.timeoutMs ?? 3000; this.defaultBaseSite = config.defaultBaseSite ?? null; + this.maxConcurrentOccCalls = config.maxConcurrentOccCalls ?? 1; } async initialize(): Promise { @@ -110,7 +115,19 @@ export class AngularAppBaseSiteResolver implements BaseSiteResolver { async resolve(requestUrl: string): Promise { await this.initialize(); - return this.resolveViaAngular(requestUrl); + // Load shedding: refuse fast before starting a render. Protects the Node + // process from unbounded concurrent Angular boots and guards the + // platform-server singleton (two overlapping renders collide). See the + // config JSDoc for why the default cap is 1. + if (this.inFlight >= this.maxConcurrentOccCalls) { + throw new ConcurrencyLimitError(); + } + this.inFlight++; + try { + return await this.resolveViaAngular(requestUrl); + } finally { + this.inFlight--; + } } async destroy(): Promise { @@ -127,25 +144,18 @@ export class AngularAppBaseSiteResolver implements BaseSiteResolver { const { importProvidersFrom, Component, inject } = await import('@angular/core'); const { StoreModule } = await import('@ngrx/store'); const { EffectsModule } = await import('@ngrx/effects'); - // Import specific symbols via deep relative paths rather than the - // '../../../core/public_api' barrel: importing a lib's own public_api from - // inside that lib risks circular deps and pulls the whole barrel per call. - const { SiteContextModule } = await import( - '../../../core/src/site-context/site-context.module' - ); - const { BaseOccModule } = await import( - '../../../core/src/occ/base-occ.module' - ); - const { WindowRef } = await import('../../../core/src/window/window-ref'); - const { BaseSiteService } = await import( - '../../../core/src/site-context/facade/base-site.service' - ); - const { JavaRegExpConverter } = await import( - '../../../core/src/util/java-reg-exp-converter/java-reg-exp-converter' - ); - const { provideConfig } = await import( - '../../../core/src/config/config-providers' - ); + // setup → core is a cross-lib dependency, so the core public_api barrel is + // the supported import path (the circular-dep rule only forbids importing a + // lib's OWN public_api from inside that lib). Deep 'core/src/...' imports + // break encapsulation and do not resolve against the published package. + const { + SiteContextModule, + BaseOccModule, + WindowRef, + BaseSiteService, + JavaRegExpConverter, + provideConfig, + } = await import('../../../core/public_api'); const { firstValueFrom } = await import('rxjs'); // Root component selector must match the host element in the server document diff --git a/core-libs/setup/ssr/site-context/base-site-resolver.bench.ts b/core-libs/setup/ssr/site-context/base-site-resolver.bench.ts index 9061617da6a..a55fe637b76 100644 --- a/core-libs/setup/ssr/site-context/base-site-resolver.bench.ts +++ b/core-libs/setup/ssr/site-context/base-site-resolver.bench.ts @@ -34,7 +34,11 @@ import 'reflect-metadata'; import * as http from 'node:http'; import { performance } from 'node:perf_hooks'; -import { BaseSiteResolver, BaseSiteResolverConfig } from './base-site-resolver'; +import { + BaseSiteResolver, + BaseSiteResolverConfig, + ConcurrencyLimitError, +} from './base-site-resolver'; // ─── config ──────────────────────────────────────────────────────────────── @@ -101,8 +105,51 @@ function startMockOccServer(port: number, delayMs: number): http.Server { return server; } +async function startEphemeralMockOccServer( + delayMs: number +): Promise<{ server: http.Server; port: number }> { + const server = http.createServer((_req, res) => { + setTimeout(() => { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(MOCK_BODY); + }, delayMs); + }); + await new Promise((resolve) => server.listen(0, resolve)); + const { port } = server.address() as { port: number }; + return { server, port }; +} + // ─── scenarios ────────────────────────────────────────────────────────────── +async function scenarioConcurrencyCap( + cap: number, + extra: number +): Promise<{ resolved: number; shed: number }> { + // A slow mock keeps the first render open long enough that the extra calls + // arrive while inFlight is at the cap, so they are shed fail-fast. + const mock = await startEphemeralMockOccServer(200); + const resolver = makeResolver({ + occBaseUrl: `http://localhost:${mock.port}`, + timeoutMs: 5000, + maxConcurrentOccCalls: cap, + }); + await resolver.initialize(); + + const outcomes = await Promise.allSettled( + Array.from({ length: cap + extra }, () => resolver.resolve(REQUEST_URL)) + ); + const resolved = outcomes.filter((o) => o.status === 'fulfilled').length; + const shed = outcomes.filter( + (o) => + o.status === 'rejected' && + (o as PromiseRejectedResult).reason instanceof ConcurrencyLimitError + ).length; + + await resolver.destroy(); + await new Promise((resolve) => mock.server.close(() => resolve())); + return { resolved, shed }; +} + async function scenarioPerCall( resolver: BaseSiteResolver, iterations: number @@ -166,8 +213,19 @@ async function main(): Promise { console.log(` Req URL : ${REQUEST_URL}`); console.log(`${'═'.repeat(72)}\n`); + // ── concurrency cap (mock — no CX_BASE_URL needed) ─────────────────────── + console.log('Scenario concurrency-cap (cap 1, fire 5 concurrent)\n'); + const capResult = await scenarioConcurrencyCap(1, 4); + const capPass = capResult.resolved === 1 && capResult.shed === 4; + console.log( + ` resolved: ${capResult.resolved} shed(ConcurrencyLimitError): ${capResult.shed} cap: ${capPass ? 'PASS' : 'FAIL — expected 1 resolved / 4 shed'}` + ); + if (!capPass) { + process.exitCode = 1; + } + // ── slow OCC (mock — no CX_BASE_URL needed) ────────────────────────────── - console.log('Scenario slow-occ (mock 4 s OCC, resolver timeout 3 s)\n'); + console.log('\nScenario slow-occ (mock 4 s OCC, resolver timeout 3 s)\n'); { const port = MOCK_OCC_PORT ?? 9999; const mockServer = startMockOccServer(port, 4000); @@ -197,13 +255,26 @@ async function main(): Promise { const perCallSamples = await scenarioPerCall(resolver, 100); printStats('per-call', perCallSamples); - // ── concurrent (also stresses platform-server singleton) ───────────────── - console.log('\nScenario concurrent (10 × resolve, 3 batches)\n'); - const { batchSamples, failures } = await scenarioConcurrent(resolver, 10, 3); + // ── concurrent — probes the platform-server singleton ──────────────────── + // Raise the cap so renders actually overlap; the default cap of 1 would + // shed them fail-fast before they reach the singleton. Rejections here are + // the platform-singleton collisions the ADR flags as an unknown. + console.log('\nScenario concurrent (10 × resolve, 3 batches, cap raised)\n'); + const concurrentResolver = makeResolver({ + occBaseUrl: OCC_BASE_URL, + timeoutMs: 3000, + maxConcurrentOccCalls: 10, + }); + const { batchSamples, failures } = await scenarioConcurrent( + concurrentResolver, + 10, + 3 + ); printStats('concurrent-batch', batchSamples); console.log( - ` rejected resolves: ${failures} / 30 ${failures > 0 ? '⚠ platform-singleton collisions under concurrency' : ''}` + ` rejected resolves: ${failures} / 30 ${failures > 0 ? '⚠ platform-singleton collisions under overlap' : '(no collisions observed)'}` ); + await concurrentResolver.destroy(); await resolver.destroy(); } diff --git a/core-libs/setup/ssr/site-context/base-site-resolver.ts b/core-libs/setup/ssr/site-context/base-site-resolver.ts index ad8cbebc398..01667e85daa 100644 --- a/core-libs/setup/ssr/site-context/base-site-resolver.ts +++ b/core-libs/setup/ssr/site-context/base-site-resolver.ts @@ -24,6 +24,17 @@ export interface BaseSiteResolverConfig { * (SiteContextConfig). When omitted, resolve() returns null on no match. */ defaultBaseSite?: string; + /** + * Max number of renders allowed in flight at once. Above this, resolve() + * fails fast with `ConcurrencyLimitError` instead of queueing. + * + * Default: 1. renderApplication() calls platformServer() — a process-level + * singleton — so two overlapping renders collide (createPlatform throws when + * one already exists). A cap of 1 serialises via fail-fast: the first render + * proceeds, any concurrent request is shed. A cap > 1 would NOT help here — it + * would just let the extra renders reach the colliding singleton. + */ + maxConcurrentOccCalls?: number; } export interface BaseSiteResolver { @@ -44,6 +55,8 @@ export interface BaseSiteResolver { * cleanly but no urlPattern matched the URL. * * Throws: + * - `ConcurrencyLimitError` when the in-flight render limit is exceeded + * (load shedding — protects the process and the platform singleton), or * - `OccUnavailableError` when the underlying render times out or fails, * so the Express handler can map it to a 503 instead of silently * serving default content. @@ -54,6 +67,14 @@ export interface BaseSiteResolver { destroy(): Promise; } +/** Thrown when the in-flight render limit is exceeded (load shedding). */ +export class ConcurrencyLimitError extends Error { + constructor(message = 'base-site render concurrency limit exceeded') { + super(message); + this.name = 'ConcurrencyLimitError'; + } +} + /** * Thrown by resolve() when the base-site render/fetch cannot complete * (OCC unreachable or timed out). The Express handler maps this to diff --git a/projects/storefrontapp/src/server.ts b/projects/storefrontapp/src/server.ts index 2cc3d40e91a..1bb98445fd6 100644 --- a/projects/storefrontapp/src/server.ts +++ b/projects/storefrontapp/src/server.ts @@ -27,7 +27,10 @@ import { getRequestUrl } from '../../../core-libs/setup/ssr/express-utils/expres // Approach (b): createApplication() — cacheless. Each resolve() boots a // minimal Angular app (HttpClient only) and fetches base-sites from OCC. import { AngularAppBaseSiteResolver } from '../../../core-libs/setup/ssr/site-context/angular-app-base-site-resolver'; -import { OccUnavailableError } from '../../../core-libs/setup/ssr/site-context/base-site-resolver'; +import { + ConcurrencyLimitError, + OccUnavailableError, +} from '../../../core-libs/setup/ssr/site-context/base-site-resolver'; const baseSiteResolver = new AngularAppBaseSiteResolver({ occBaseUrl: buildProcess.env.CX_BASE_URL, timeoutMs: 3000, @@ -98,10 +101,19 @@ export async function app(): Promise { const content = getLlmsTxt(baseSiteId); res.type('text/plain').send(content); } catch (err) { - // resolve() throws OccUnavailableError on render timeout/failure — map to - // 503 + Retry-After rather than serving default content on a failed OCC. - if (err instanceof OccUnavailableError) { - res.set('Retry-After', '5').status(503).type('text/plain').send(''); + // resolve() throws on failure — map to 503 + Retry-After rather than + // serving default content: + // • ConcurrencyLimitError — request shed under load, + // • OccUnavailableError — render timed out or failed. + if ( + err instanceof ConcurrencyLimitError || + err instanceof OccUnavailableError + ) { + res + .set('Retry-After', '5') + .status(503) + .type('text/plain') + .send('Service Unavailable'); return; } next(err);