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..7b062b4819f --- /dev/null +++ b/core-libs/setup/ssr/site-context/angular-app-base-site-resolver.ts @@ -0,0 +1,188 @@ +/* + * SPDX-FileCopyrightText: 2026 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +// ── Approach (b): createApplication() ──────────────────────────────────────── +// This ENTIRE FILE is approach (b). Implements BaseSiteResolver by booting a +// minimal Angular app (HttpClient only) via createApplication() and fetching +// basesites through the Angular HTTP stack — reused across requests. + +/** + * SPIKE — not production code. + * Approach (b): Angular createApplication() — DI context without HTML render. + * + * Boots a minimal Angular application (HttpClient only, no NgRx, no components) + * to make the OCC basesites call through the Angular HTTP stack. + * The ApplicationRef is created once at initialize() and reused across requests. + * + * Key finding during spike: BaseSiteService.getAll() uses NgRx Store + Effects, + * so reusing the full Spartacus service graph would require StoreModule + EffectsModule. + * This implementation uses HttpClient directly from the injector to isolate the + * "Angular HTTP overhead vs plain fetch()" question — which is the real comparison point. + * Full NgRx stack overhead is noted in the ADR as an additional concern. + */ + +/* webpackIgnore: true */ +import type { HttpClient } from '@angular/common/http'; +import type { ApplicationRef, PlatformRef } from '@angular/core'; +import { performance } from 'perf_hooks'; +import { BaseSiteResolver, BaseSiteResolverConfig } from './base-site-resolver'; + +interface OccBaseSite { + uid?: string; + urlPatterns?: string[]; +} + +const EXTRACT_JAVA_REGEXP_MODIFIERS = /^(\(\?([a-z]+)\))?(.*)/; + +function toJsRegExp(javaSyntax: string): RegExp | null { + const parts = javaSyntax.match(EXTRACT_JAVA_REGEXP_MODIFIERS); + if (!parts) { + return null; + } + const [, , modifiers, jsSyntax] = parts; + try { + return new RegExp(jsSyntax, modifiers); + } catch { + return null; + } +} + +function matchesSite(site: OccBaseSite, url: string): boolean { + return (site.urlPatterns ?? []).some((p) => toJsRegExp(p)?.test(url) ?? false); +} + +export class AngularAppBaseSiteResolver implements BaseSiteResolver { + private readonly occUrl: string; + private readonly timeoutMs: number; + private readonly cacheTtlMs: number; + + private appRef: ApplicationRef | null = null; + private platformRef: PlatformRef | null = null; + private httpClient: HttpClient | null = null; + private cachedSites: OccBaseSite[] | null = null; + private cachedAt = 0; + private initPromise: Promise | null = null; + private fetchPromise: Promise | null = null; + + constructor(config: BaseSiteResolverConfig) { + const prefix = config.occPrefix ?? '/occ/v2'; + this.occUrl = `${config.occBaseUrl}${prefix}/basesites?fields=FULL`; + this.timeoutMs = config.timeoutMs ?? 3000; + this.cacheTtlMs = config.cacheTtlMs ?? 60_000; + } + + async initialize(): Promise { + if (!this.initPromise) { + this.initPromise = this.boot(); + } + return this.initPromise; + } + + async resolve(requestUrl: string): Promise { + // Wait for boot() to finish so we never fetch before the cache is warm. + await this.initialize(); + if (!this.cachedSites || Date.now() - this.cachedAt >= this.cacheTtlMs) { + // Dedupe concurrent refreshes: share one in-flight fetch across callers. + await (this.fetchPromise ??= this.fetchSites().finally(() => { + this.fetchPromise = null; + })); + } + if (!this.cachedSites) { + return null; + } + const matched = this.cachedSites.find((site) => matchesSite(site, requestUrl)); + return matched?.uid ?? null; + } + + async destroy(): Promise { + this.appRef?.destroy(); + this.platformRef?.destroy(); + this.appRef = null; + this.platformRef = null; + this.httpClient = null; + this.cachedSites = null; + this.initPromise = null; + this.fetchPromise = null; + } + + private async boot(): Promise { + const t0 = performance.now(); + + // Dynamic import — Angular platform is heavy; we don't want it in module scope. + // createApplication() is exported from @angular/platform-browser, NOT @angular/core. + const { createApplication } = await import('@angular/platform-browser'); + const { platformServer } = await import('@angular/platform-server'); + const { provideHttpClient, withFetch, HttpClient } = await import( + '@angular/common/http' + ); + + // On the SERVER there is no ambient Angular platform, so createApplication() + // throws NG0401 (Missing Platform) unless we pass a BootstrapContext holding a + // server platform. The SSR engine normally creates one per render; here we + // create a standalone, long-lived one for this resolver. + this.platformRef = platformServer(); + + const bootPromise = createApplication( + { providers: [provideHttpClient(withFetch())] }, + { platformRef: this.platformRef } + ); + + const timeoutPromise = new Promise((_, reject) => + setTimeout( + () => reject(new Error(`createApplication() timed out after ${this.timeoutMs} ms`)), + this.timeoutMs + ) + ); + + this.appRef = await Promise.race([bootPromise, timeoutPromise]); + const elapsed = (performance.now() - t0).toFixed(1); + console.log(`[create-application] Angular app created in ${elapsed} ms`); + + // Store HttpClient so fetchSites() can reuse it (enables TTL-based refresh). + this.httpClient = this.appRef.injector.get(HttpClient); + + // Share this warm-up fetch via fetchPromise so a resolve() arriving during + // boot() joins it instead of firing a second identical OCC call. + await (this.fetchPromise ??= this.fetchSites().finally(() => { + this.fetchPromise = null; + })); + } + + private async fetchSites(): Promise { + if (!this.httpClient) { + console.error('[create-application] HttpClient not available — was initialize() called?'); + return; + } + + const t0 = performance.now(); + // Wrap Observable in a race with AbortController-equivalent (timer + unsubscribe). + const { firstValueFrom, timeout } = await import('rxjs'); + + try { + const body = await firstValueFrom( + this.httpClient + .get<{ baseSites?: OccBaseSite[] }>(this.occUrl) + .pipe(timeout(this.timeoutMs)) + ); + const sites: OccBaseSite[] = (body.baseSites ?? []).map((s) => ({ + uid: s.uid, + urlPatterns: s.urlPatterns, + })); + this.cachedSites = sites; + this.cachedAt = Date.now(); + const elapsed = (performance.now() - t0).toFixed(1); + console.log( + `[create-application] OCC basesites fetched in ${elapsed} ms (${sites.length} sites)` + ); + } catch (err) { + const elapsed = (performance.now() - t0).toFixed(1); + console.error( + `[create-application] OCC basesites fetch failed after ${elapsed} ms:`, + err + ); + } + } +} diff --git a/core-libs/setup/ssr/site-context/angular-native-base-site-service.ts b/core-libs/setup/ssr/site-context/angular-native-base-site-service.ts new file mode 100644 index 00000000000..c33ecdcc255 --- /dev/null +++ b/core-libs/setup/ssr/site-context/angular-native-base-site-service.ts @@ -0,0 +1,123 @@ +/* + * SPDX-FileCopyrightText: 2026 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * SPIKE — not production code. + * ── Approach (c): Angular-native ── baseSite detection inside the Angular SSR pipeline. + * + * Architecture: + * provideAiSeoBaseSiteDetection() → registered in app.config.server.ts + * └─ AiSeoBaseSiteService (APP_INITIALIZER) + * ├─ waits for ConfigInitializerService.getStable('context') + * │ (SiteContextConfigInitializer already makes the OCC call — no duplication) + * └─ reads context.baseSite[0] → stores in service state + * + * Key design decisions: + * - NO second OCC call: reuses result of the existing SiteContextConfigInitializer + * - NO coupling to SiteContextConfigInitializer directly: reads through + * ConfigInitializerService.getStable() + SiteContextConfig (both stable public API) + * - Extensible: AI_SEO_BASE_SITE_RESOLVER_FN token allows swapping the detection logic + * without changing the service (dependency inversion hook for the future) + * + * Non-render handlers (/robots.txt, /llms.txt etc.): + * In approach (c) these are Angular routes, NOT Express routes. + * Express catch-all passes them to Angular SSR. Components read baseSiteId from + * AiSeoBaseSiteService and set the response Content-Type accordingly. + */ + +import { + APP_INITIALIZER, + EnvironmentProviders, + Injectable, + InjectionToken, + makeEnvironmentProviders, + inject, +} from '@angular/core'; +import { lastValueFrom } from 'rxjs'; +import { ConfigInitializerService } from '@spartacus/core'; +import { SiteContextConfig } from '@spartacus/core'; + +/** + * Extensibility hook: provide a custom function to resolve baseSiteId. + * Default: reads from ConfigInitializerService after 'context' scope is stable. + * Override to use a completely independent OCC call, env var, or any other source. + */ +export const AI_SEO_BASE_SITE_RESOLVER_FN = new InjectionToken< + () => Promise +>('AI_SEO_BASE_SITE_RESOLVER_FN'); + +@Injectable() +export class AiSeoBaseSiteService { + private baseSiteId: string | null = null; + + private readonly resolverFn = inject(AI_SEO_BASE_SITE_RESOLVER_FN); + + async initialize(): Promise { + try { + this.baseSiteId = await this.resolverFn(); + console.log(`[approach-c] Resolved baseSiteId: ${this.baseSiteId ?? '(null)'}`); + } catch (err) { + console.error('[approach-c] Failed to resolve baseSiteId:', err); + } + } + + getBaseSiteId(): string | null { + return this.baseSiteId; + } +} + +/** + * Default resolver: waits for SiteContextConfigInitializer to finish + * (via ConfigInitializerService.getStable('context')) and reads the result. + * No additional OCC call — reuses the one already made by Spartacus. + */ +function defaultBaseSiteResolverFactory( + configInitializerService: ConfigInitializerService, + siteContextConfig: SiteContextConfig +): () => Promise { + return async () => { + await lastValueFrom(configInitializerService.getStable('context')); + const baseSiteId = + (siteContextConfig.context as Record)?.['baseSite']?.[0] ?? null; + return baseSiteId; + }; +} + +/** + * Registers the Angular-native base-site detection feature. + * + * Add to app.config.server.ts providers array: + * provideAiSeoBaseSiteDetection() + * + * To override the detection logic (e.g. for an independent OCC call): + * provideAiSeoBaseSiteDetection({ + * resolverFn: () => myCustomResolver() + * }) + */ +export function provideAiSeoBaseSiteDetection(options?: { + /** + * Custom resolver function. When provided, replaces the default + * ConfigInitializerService-based resolution entirely. + */ + resolverFn?: () => Promise; +}): EnvironmentProviders { + return makeEnvironmentProviders([ + AiSeoBaseSiteService, + options?.resolverFn + ? { provide: AI_SEO_BASE_SITE_RESOLVER_FN, useValue: options.resolverFn } + : { + provide: AI_SEO_BASE_SITE_RESOLVER_FN, + useFactory: defaultBaseSiteResolverFactory, + deps: [ConfigInitializerService, SiteContextConfig], + }, + { + provide: APP_INITIALIZER, + useFactory: (service: AiSeoBaseSiteService) => () => service.initialize(), + deps: [AiSeoBaseSiteService], + multi: true, + }, + ]); +} 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..ec7d499120b --- /dev/null +++ b/core-libs/setup/ssr/site-context/base-site-resolver.bench.ts @@ -0,0 +1,230 @@ +/* + * 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 + * + * Switch approach by commenting/uncommenting ONE resolver block below (same as server.ts). + * + * 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: use mock slow OCC server) + * + * Scenarios: + * cold-start — time for initialize() including OCC fetch + * warm-resolve — time for resolve() after initialize() (cache hit), 100 iterations + * concurrent — 10 parallel resolve() calls, 3 batches + * slow-occ — initialize() against a mock server that delays 4 s response + * + * Note: Approach (c) Angular-native runs inside the Angular SSR pipeline and cannot + * be benchmarked here. Measure it via Angular SSR render time with approach (c) active. + */ + +/* webpackIgnore: true */ +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; + +// ── Approach (a): Pure Node ─────────────────────────────────────────────────── +import { PureNodeBaseSiteResolver } from './pure-node-base-site-resolver'; +const APPROACH_LABEL = 'pure-node'; +function makeResolver(config: BaseSiteResolverConfig): BaseSiteResolver { + return new PureNodeBaseSiteResolver(config); +} + +// ── Approach (b): createApplication() ──────────────────────────────────────── +// import { AngularAppBaseSiteResolver } from './angular-app-base-site-resolver'; +// const APPROACH_LABEL = 'create-application'; +// 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 slow OCC server ─────────────────────────────────────────────────── + +function startMockOccServer(port: number, delayMs: number): http.Server { + const mockBody = JSON.stringify({ + baseSites: [ + { + uid: 'mock-site', + urlPatterns: ['(?i)^https?://.*'], + }, + ], + }); + + const server = http.createServer((_req, res) => { + setTimeout(() => { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(mockBody); + }, delayMs); + }); + + server.listen(port); + return server; +} + +// ─── scenarios ────────────────────────────────────────────────────────────── + +async function scenarioColdStart(resolver: BaseSiteResolver): Promise { + await resolver.destroy(); + const t0 = performance.now(); + await resolver.initialize(); + return performance.now() - t0; +} + +async function scenarioWarmResolve( + 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 { + await resolver.initialize(); + const samples: number[] = []; + for (let b = 0; b < batches; b++) { + const t0 = performance.now(); + await Promise.all( + Array.from({ length: concurrency }, () => resolver.resolve(REQUEST_URL)) + ); + samples.push(performance.now() - t0); + } + return samples; +} + +async function scenarioSlowOcc( + occBaseUrl: string, + _serverDelayMs: number, + resolverTimeoutMs: number +): Promise<{ timedOut: boolean; elapsed: number }> { + const resolver = makeResolver({ + occBaseUrl, + timeoutMs: resolverTimeoutMs, + }); + const t0 = performance.now(); + await resolver.initialize(); + const elapsed = performance.now() - t0; + const timedOut = elapsed >= resolverTimeoutMs * 0.9; + await resolver.destroy(); + return { timedOut, 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`); + + if (!OCC_BASE_URL) { + console.error('ERROR: CX_BASE_URL is not set. Export it before running the benchmark.'); + process.exit(1); + } + + const resolver = makeResolver({ + occBaseUrl: OCC_BASE_URL, + timeoutMs: 3000, + cacheTtlMs: 60_000, + }); + + // ── Scenario 1: cold start ───────────────────────────────────────────────── + console.log('Scenario 1: cold-start (5 iterations)\n'); + const coldSamples: number[] = []; + for (let i = 0; i < 5; i++) { + const ms = await scenarioColdStart(resolver); + coldSamples.push(ms); + console.log(` run ${i + 1}: ${ms.toFixed(1)} ms`); + } + printStats('cold-start', coldSamples); + + // ── Scenario 2: warm resolve ─────────────────────────────────────────────── + console.log('\nScenario 2: warm resolve (100 iterations)\n'); + const warmSamples = await scenarioWarmResolve(resolver, 100); + printStats('warm-resolve', warmSamples); + + // ── Scenario 3: concurrent ───────────────────────────────────────────────── + console.log('\nScenario 3: concurrent (10 × resolve, 3 batches)\n'); + const concurrentSamples = await scenarioConcurrent(resolver, 10, 3); + printStats('concurrent-batch', concurrentSamples); + + await resolver.destroy(); + + // ── Scenario 4: slow OCC ─────────────────────────────────────────────────── + console.log('\nScenario 4: slow OCC (mock server 4 s delay, resolver timeout 3 s)\n'); + if (MOCK_OCC_PORT) { + const mockServer = startMockOccServer(MOCK_OCC_PORT, 4000); + const mockBaseUrl = `http://localhost:${MOCK_OCC_PORT}`; + const result = await scenarioSlowOcc(mockBaseUrl, 4000, 3000); + console.log( + ` elapsed: ${result.elapsed.toFixed(1)} ms timed-out: ${result.timedOut}` + ); + mockServer.close(); + } else { + console.log( + ' Skipped — set MOCK_OCC_PORT= to enable (e.g. MOCK_OCC_PORT=9999)' + ); + } + + console.log(`\n${'═'.repeat(72)}`); + console.log(' Done. Copy numbers into ADR section 6 (Performance numbers).'); + 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..3ca727c9dc4 --- /dev/null +++ b/core-libs/setup/ssr/site-context/base-site-resolver.ts @@ -0,0 +1,41 @@ +/* + * SPDX-FileCopyrightText: 2026 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * SPIKE — not production code. + * Branch: spike/base-site-detection-approaches + * + * Shared contract implemented by all base-site resolver approaches. + * To switch approaches: comment/uncomment the relevant import block in server.ts. + */ + +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; + /** How long to keep the cached base-sites list. Default: 60_000 ms */ + cacheTtlMs?: number; +} + +export interface BaseSiteResolver { + /** + * One-time warm-up: fetch base-sites from OCC and cache them. + * Call once at server startup before handling any requests. + */ + initialize(): Promise; + + /** + * Resolve the baseSiteId for the given absolute request URL. + * Returns null when no site matches or when OCC is unreachable. + */ + resolve(requestUrl: string): Promise; + + /** Release resources. */ + destroy(): Promise; +} diff --git a/core-libs/setup/ssr/site-context/pure-node-base-site-resolver.ts b/core-libs/setup/ssr/site-context/pure-node-base-site-resolver.ts new file mode 100644 index 00000000000..69fb5d5324a --- /dev/null +++ b/core-libs/setup/ssr/site-context/pure-node-base-site-resolver.ts @@ -0,0 +1,146 @@ +/* + * SPDX-FileCopyrightText: 2026 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +// ── Approach (a): Pure Node ────────────────────────────────────────────────── +// This ENTIRE FILE is approach (a). Implements BaseSiteResolver with plain +// fetch() + in-memory cache + a ported JavaRegExpConverter — zero Angular. + +/** + * SPIKE — not production code. + * Approach (a): Pure Node — no Angular involved. + * + * Fetches all base sites from OCC once at initialize() and caches them. + * Per-request work is a simple regex match against the cached list. + * + * Replicates JavaRegExpConverter.toJsRegExp() from: + * core-libs/core/src/util/java-reg-exp-converter/java-reg-exp-converter.ts + */ + +/* webpackIgnore: true */ +import { performance } from 'perf_hooks'; +import { BaseSiteResolver, BaseSiteResolverConfig } from './base-site-resolver'; + +interface OccBaseSite { + uid?: string; + urlPatterns?: string[]; +} + +interface CachedSites { + sites: OccBaseSite[]; + fetchedAt: number; +} + +/** + * Converts a Java-syntax regexp string to a JavaScript RegExp. + * Handles Java inline modifiers like (?i), (?u), (?iu) etc. + * Returns null when the pattern cannot be converted. + * + * Logic ported verbatim from JavaRegExpConverter to avoid Angular dependency. + */ +function toJsRegExp(javaSyntax: string): RegExp | null { + const parts = javaSyntax.match(/^(\(\?([a-z]+)\))?(.*)/); + if (!parts) { + return null; + } + const [, , modifiers, jsSyntax] = parts; + try { + return new RegExp(jsSyntax, modifiers); + } catch { + return null; + } +} + +function matchesSite(site: OccBaseSite, url: string): boolean { + return (site.urlPatterns ?? []).some( + (pattern) => toJsRegExp(pattern)?.test(url) ?? false + ); +} + +export class PureNodeBaseSiteResolver implements BaseSiteResolver { + private readonly occUrl: string; + private readonly timeoutMs: number; + private readonly cacheTtlMs: number; + private cache: CachedSites | null = null; + /** Pending initialize() promise — avoids concurrent OCC calls on startup. */ + private initPromise: Promise | null = null; + + constructor(_config: BaseSiteResolverConfig) { + const prefix = _config.occPrefix ?? '/occ/v2'; + this.occUrl = `${_config.occBaseUrl}${prefix}/basesites?fields=FULL`; + this.timeoutMs = _config.timeoutMs ?? 3000; + this.cacheTtlMs = _config.cacheTtlMs ?? 60_000; + } + + async initialize(): Promise { + if (!this.initPromise) { + this.initPromise = this.fetchAndCache(); + } + return this.initPromise; + } + + async resolve(requestUrl: string): Promise { + const sites = await this.getSites(); + if (!sites) { + return null; + } + const matched = sites.find((site) => matchesSite(site, requestUrl)); + return matched?.uid ?? null; + } + + async destroy(): Promise { + this.cache = null; + this.initPromise = null; + } + + private async getSites(): Promise { + if (this.cache && Date.now() - this.cache.fetchedAt < this.cacheTtlMs) { + return this.cache.sites; + } + await this.fetchAndCache(); + return this.cache?.sites ?? null; + } + + private async fetchAndCache(): Promise { + const t0 = performance.now(); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), this.timeoutMs); + + try { + const response = await fetch(this.occUrl, { + signal: controller.signal, + headers: { Accept: 'application/json' }, + }); + if (!response.ok) { + throw new Error(`OCC basesites responded ${response.status}`); + } + const body = (await response.json()) as { baseSites?: OccBaseSite[] }; + const sites: OccBaseSite[] = (body.baseSites ?? []).map((s) => ({ + uid: s.uid, + urlPatterns: s.urlPatterns, + })); + this.cache = { sites, fetchedAt: Date.now() }; + const elapsed = (performance.now() - t0).toFixed(1); + console.log( + `[pure-node] OCC basesites fetched in ${elapsed} ms (${sites.length} sites)` + ); + } catch (err) { + const elapsed = (performance.now() - t0).toFixed(1); + if ((err as Error).name === 'AbortError') { + console.error( + `[pure-node] OCC basesites timed out after ${this.timeoutMs} ms` + ); + } else { + console.error( + `[pure-node] OCC basesites fetch failed after ${elapsed} ms:`, + err + ); + } + // Leave cache stale if it exists; resolve() will return null on first call. + } finally { + clearTimeout(timer); + } + } +} diff --git a/projects/storefrontapp/src/app/app.config.server.ts b/projects/storefrontapp/src/app/app.config.server.ts index e9fe2316464..c4c3180dbea 100644 --- a/projects/storefrontapp/src/app/app.config.server.ts +++ b/projects/storefrontapp/src/app/app.config.server.ts @@ -14,10 +14,24 @@ import { TestConfigServerModule } from '@spartacus/setup/ssr'; import { appConfig } from './app.config'; import { AppServerModule } from './app.module.server'; +// ── Approach (c): Angular-native ───────────────────────────────────────────── +// SPIKE: Angular-native base-site detection — DISABLED (active variant is (b)). +// Registers AiSeoBaseSiteService as an APP_INITIALIZER that waits for +// SiteContextConfigInitializer to resolve context.baseSite, then exposes it +// via getBaseSiteId(). Angular routes for /robots.txt, /llms.txt etc. inject +// AiSeoBaseSiteService to serve per-site content. +// Re-enable both the imports and the providers below to test approach (c). +// import { provideAiSeoBaseSiteDetection } from '../../../../core-libs/setup/ssr/site-context/angular-native-base-site-service'; +// import { provideLlmsTxtRoute } from './spike-ai-seo/llms-txt.component'; + const serverConfig: ApplicationConfig = { providers: [ provideServerRendering(), + // ── Approach (c) — disabled; uncomment together with the imports above ── + // provideAiSeoBaseSiteDetection(), + // provideLlmsTxtRoute(), + importProvidersFrom(AppServerModule), importProvidersFrom( diff --git a/projects/storefrontapp/src/app/spike-ai-seo/llms-txt.component.ts b/projects/storefrontapp/src/app/spike-ai-seo/llms-txt.component.ts new file mode 100644 index 00000000000..0e35aafd407 --- /dev/null +++ b/projects/storefrontapp/src/app/spike-ai-seo/llms-txt.component.ts @@ -0,0 +1,92 @@ +/* + * SPDX-FileCopyrightText: 2026 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +// ── Approach (c): Angular-native ───────────────────────────────────────────── +// SPIKE — not production code. +// +// Angular route component that serves llms.txt inside the Angular SSR pipeline. +// This is the (c) counterpart to the Express handler used by approaches (a)/(b): +// instead of an Express route resolving baseSiteId before Angular, the request +// falls through the Express catch-all into Angular render, matches this route, +// and reads baseSiteId from AiSeoBaseSiteService (already resolved by the +// SiteContextConfigInitializer via APP_INITIALIZER — no extra OCC call). +// +// Known trade-off (see ADR Option 3 findings): the CommonEngine renders the +// full HTML shell around this template, so the response body is NOT clean +// text/plain even though we set the Content-Type header via the RESPONSE token. +// Producing a pure text/plain body from an Angular route is awkward — this is a +// documented weakness of approach (c) for non-render handlers, not fixed here. + +import { + APP_INITIALIZER, + Component, + EnvironmentProviders, + Injector, + inject, + makeEnvironmentProviders, +} from '@angular/core'; +import { Router } from '@angular/router'; +import { RESPONSE } from '@spartacus/setup/ssr'; +import { AiSeoBaseSiteService } from '../../../../../core-libs/setup/ssr/site-context/angular-native-base-site-service'; + +@Component({ + selector: 'cx-spike-llms-txt', + standalone: true, + template: '{{ content }}', +}) +export class LlmsTxtComponent { + private readonly baseSiteService = inject(AiSeoBaseSiteService); + // Server-only: RESPONSE is null on the browser. + private readonly response = inject(RESPONSE, { optional: true }); + + readonly content: string; + + constructor() { + const baseSiteId = this.baseSiteService.getBaseSiteId(); + this.response?.setHeader('Content-Type', 'text/plain'); + this.content = getLlmsTxt(baseSiteId); + } +} + +/** + * Registers the /llms.txt Angular route BEFORE the Spartacus CMS wildcard route. + * + * Spartacus adds `{ path: '**', ... }` via APP_INITIALIZER (addCmsRoute) with + * router.config.push() — i.e. at the END of the config. Angular matches routes + * in order, so unshifting our route to the FRONT guarantees it wins over `**`. + * The APP_INITIALIZER ordering relative to addCmsRoute is irrelevant: push→end, + * unshift→front. + * + * Lives in the storefrontapp layer (not the core-lib service) so the reference + * to LlmsTxtComponent stays within the app — core-libs must not import the demo app. + */ +export function provideLlmsTxtRoute(): EnvironmentProviders { + return makeEnvironmentProviders([ + { + provide: APP_INITIALIZER, + useFactory: (injector: Injector) => () => { + const router = injector.get(Router); + router.config.unshift({ + path: 'llms.txt', + component: LlmsTxtComponent, + }); + }, + deps: [Injector], + multi: true, + }, + ]); +} + +/** + * SPIKE stub — returns per-site llms.txt content. + * Mirrors getLlmsTxt() in server.ts. In production this would read from config / CMS. + */ +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`; +} diff --git a/projects/storefrontapp/src/server.ts b/projects/storefrontapp/src/server.ts index 4eb68d319c1..8a61661d31d 100644 --- a/projects/storefrontapp/src/server.ts +++ b/projects/storefrontapp/src/server.ts @@ -4,6 +4,11 @@ * SPDX-License-Identifier: Apache-2.0 */ +// SPIKE — base-site detection approaches +// Switch approach by commenting/uncommenting ONE block below. +// 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 +23,29 @@ import { fileURLToPath } from 'node:url'; import { dirname, join, resolve } from 'path'; import bootstrap from './main.server'; +// ── Approach (a): Pure Node ─────────────────────────────────────────────────── +// Plain fetch() + AbortController + in-memory cache. No Angular overhead. +// import { PureNodeBaseSiteResolver } from '../../../core-libs/setup/ssr/site-context/pure-node-base-site-resolver'; +// const baseSiteResolver = new PureNodeBaseSiteResolver({ +// occBaseUrl: buildProcess.env.CX_BASE_URL, +// timeoutMs: 3000, +// cacheTtlMs: 60_000, +// }); + +// ── Approach (b): createApplication() ──────────────────────────────────────── +// Boots a minimal Angular app (HttpClient only) once at startup; caches base sites. +import { AngularAppBaseSiteResolver } from '../../../core-libs/setup/ssr/site-context/angular-app-base-site-resolver'; +const baseSiteResolver = new AngularAppBaseSiteResolver({ + occBaseUrl: buildProcess.env.CX_BASE_URL, + timeoutMs: 3000, + cacheTtlMs: 60_000, +}); + +// ── Approach (c): Angular-native ───────────────────────────────────────────── +// baseSiteId is resolved inside the Angular SSR pipeline via AiSeoBaseSiteService. +// Enable provideAiSeoBaseSiteDetection() in app.config.server.ts. +// Comment out baseSiteResolver usages and the llms.txt handler below. + const ssrOptions: SsrOptimizationOptions = { timeout: Number( process.env['SSR_TIMEOUT'] ?? defaultSsrOptimizationOptions.timeout @@ -27,8 +55,24 @@ const ssrOptions: SsrOptimizationOptions = { const ngExpressEngine = NgExpressEngineDecorator.get(engine, ssrOptions); +/** + * Returns the full absolute URL for the given Express request. + * Mirrors the logic in express-utils/express-request-url.ts. + */ +function getFullUrl(req: express.Request): string { + const proto = req.get('X-Forwarded-Proto') ?? req.protocol; + const host = req.get('X-Forwarded-Host') ?? req.get('host') ?? 'localhost'; + return `${proto}://${host}${req.originalUrl}`; +} + // The Express app is exported so that it can be used by serverless Functions. -export function app(): express.Express { +// SPIKE: app() is now async to allow resolver.initialize() to warm up before serving. +export async function app(): Promise { + // ── Approach (a) ── + // ── Approach (b) ── + // shared wiring — warm up cache once before serving requests + await baseSiteResolver.initialize(); + const server = express(); const serverDistFolder = dirname(fileURLToPath(import.meta.url)); const browserDistFolder = resolve(serverDistFolder, '../browser'); @@ -48,6 +92,16 @@ export function app(): express.Express { server.set('view engine', 'html'); server.set('views', browserDistFolder); + // ── Approach (a) ── + // ── Approach (b) ── + // shared wiring — SPIKE debug, remove before merge + // Logs the resolved baseSiteId per request via baseSiteResolver.resolve(). + server.use(async (req, _res, next) => { + const id = await baseSiteResolver.resolve(getFullUrl(req)); + console.log(`[spike] ${req.path} → ${id ?? '(null)'}`); + next(); + }); + // Serve static files from /browser server.get( /.*\..*/, @@ -56,7 +110,33 @@ export function app(): express.Express { }) ); - // All regular routes use the Universal engine + // ── Approach (a) ── + // ── Approach (b) ── + // shared wiring — non-render handler consuming baseSiteResolver + // SPIKE: llms.txt — example non-render handler using approach (a) or (b). + // 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 + // This is the case where path-based multi-site IS resolvable — the site info is + // in the nested URL, so approach (a) achieves the goal. + // + // ── Approach (c): DISABLED for (c) ── + // When testing (c): comment this block out so the request falls through to the + // Angular catch-all below and is handled by the Angular route (LlmsTxtComponent) + // + AiSeoBaseSiteService. Active variant here is (b), so the handler is enabled. + server.get(/\/llms\.txt$/, async (req, res) => { + const baseSiteId = await baseSiteResolver.resolve(getFullUrl(req)); + const content = getLlmsTxt(baseSiteId); + res.type('text/plain').send(content); + }); + + // ── Approach (a) ── + // ── Approach (b) ── + // ── Approach (c) ── + // shared wiring — Angular Universal render (all regular routes). + // For approach (c) this also serves AI-SEO routes (/llms.txt etc.) that fall + // through here and are handled by Angular routes + AiSeoBaseSiteService. server.get(/.*/, (req, res) => { res.render(indexHtml, { req, @@ -69,11 +149,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 -- @@ -85,3 +165,19 @@ function run() { } run(); + +/** + * SPIKE stub — returns per-site llms.txt content. + * In production this would read from config / CMS. + * + * ── Approach (a) ── + * ── Approach (b) ── + * Consumed by the Express llms.txt handler above. Comment both out when + * switching to approach (c). + */ +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`; +}