From acb28488141e929dba8d293aa9345d0357926682 Mon Sep 17 00:00:00 2001 From: I777870 Date: Thu, 6 Aug 2026 14:46:53 +0200 Subject: [PATCH 1/3] spike(CXSPA-13856): pure-node base-site resolver Resolve SSR baseSiteId in plain Node, no Angular. Cacheless by design: every resolve() fetches OCC base sites fresh and matches the request URL against each site's urlPatterns, falling back to the app-configured default (context.baseSite[0]) on no match. No cache because SSR runs as multiple instances that die/restart independently, so a per-process cache would drift between nodes and yield inconsistent results across the fleet; the OCC call is cheap, so consistency beats the saved call. Reliability (spec factor #1 - never hang/DoS the SSR process): - concurrency cap (default 10) sheds load fast, throwing ConcurrencyLimitError before touching OCC; - AbortController timeout (default 3000ms) throws OccUnavailableError. Framework provides createBaseSiteRequestHandler(): it resolves the baseSiteId from a trust-proxy-aware request URL and maps both typed errors to 503 + Retry-After. The app wires only the route and a render callback; the framework carries no HTTP or site knowledge. --- .../site-context/base-site-request-handler.ts | 78 +++++ .../site-context/base-site-resolver.bench.ts | 293 ++++++++++++++++++ .../ssr/site-context/base-site-resolver.ts | 69 +++++ .../pure-node-base-site-resolver.ts | 143 +++++++++ .../ssr/site-context/tsconfig.bench.json | 18 ++ .../src/app/spartacus/base-site.config.ts | 30 ++ .../spartacus-b2c-configuration.providers.ts | 16 +- projects/storefrontapp/src/server.ts | 32 ++ 8 files changed, 664 insertions(+), 15 deletions(-) create mode 100644 core-libs/setup/ssr/site-context/base-site-request-handler.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/pure-node-base-site-resolver.ts create mode 100644 core-libs/setup/ssr/site-context/tsconfig.bench.json create mode 100644 projects/storefrontapp/src/app/spartacus/base-site.config.ts diff --git a/core-libs/setup/ssr/site-context/base-site-request-handler.ts b/core-libs/setup/ssr/site-context/base-site-request-handler.ts new file mode 100644 index 00000000000..a92b2a8454a --- /dev/null +++ b/core-libs/setup/ssr/site-context/base-site-request-handler.ts @@ -0,0 +1,78 @@ +/* + * SPDX-FileCopyrightText: 2026 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * SPIKE — not production code. + * Approach (a): Pure Node — no Angular involved. + * + * Framework-provided Express handler that resolves the baseSiteId for the + * incoming request and hands it to an app-supplied `render` callback. The app + * owns the route and the response content; the framework owns resolution and + * the failure-to-HTTP mapping, so every consumer gets the same reliability + * behaviour for free. + */ + +import { RequestHandler } from 'express'; +import { getRequestUrl } from '../express-utils/express-request-url'; +import { + BaseSiteResolver, + ConcurrencyLimitError, + OccUnavailableError, +} from './base-site-resolver'; + +export interface BaseSiteRequestHandlerOptions { + /** Resolver used to derive the baseSiteId from the request URL. */ + resolver: BaseSiteResolver; + /** + * Produces the response body from the resolved baseSiteId. Receives null when + * no urlPattern matched and no default baseSite is configured. + */ + render: (baseSiteId: string | null) => string | Promise; + /** Response content type. Default: 'text/plain'. */ + contentType?: string; + /** `Retry-After` header value (seconds) sent with the 503. Default: 5. */ + retryAfterSeconds?: number; +} + +/** + * Builds an Express handler that: + * - resolves the baseSiteId from a trust-proxy-aware request URL, + * - renders the body via the supplied callback and sends it, + * - maps resolver failures to `503 Service Unavailable` + `Retry-After`: + * - `ConcurrencyLimitError` — the request was shed under load, + * - `OccUnavailableError` — OCC was unreachable or timed out. + * Any other error is forwarded to the next error handler. + */ +export function createBaseSiteRequestHandler( + options: BaseSiteRequestHandlerOptions +): RequestHandler { + const { + resolver, + render, + contentType = 'text/plain', + retryAfterSeconds = 5, + } = options; + + return async (req, res, next) => { + try { + const baseSiteId = await resolver.resolve(getRequestUrl(req)); + const body = await render(baseSiteId); + res.type(contentType).send(body); + } catch (err) { + if ( + err instanceof ConcurrencyLimitError || + err instanceof OccUnavailableError + ) { + res + .status(503) + .set('Retry-After', String(retryAfterSeconds)) + .send('Service Unavailable'); + } else { + next(err); + } + } + }; +} 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..69098a2d2e9 --- /dev/null +++ b/core-libs/setup/ssr/site-context/base-site-resolver.bench.ts @@ -0,0 +1,293 @@ +/* + * SPDX-FileCopyrightText: 2026 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * SPIKE — performance benchmark harness. + * + * How to run — from the Spartacus workspace root (the directory that contains + * `core-libs/` and `projects/`; in this spike the worktree root + * `spartacus-approach-a/`): + * + * Mock-only scenarios (concurrency-cap + slow-occ) — no backend needed: + * npx ts-node \ + * --project core-libs/setup/ssr/site-context/tsconfig.bench.json \ + * core-libs/setup/ssr/site-context/base-site-resolver.bench.ts + * + * Full run incl. per-call + concurrent against a real OCC backend: + * NODE_TLS_REJECT_UNAUTHORIZED=0 \ + * CX_BASE_URL=https://your-backend:9002 \ + * BENCH_REQUEST_URL=https://your-backend:9002/electronics-spa/en/USD/ \ + * npx ts-node \ + * --project core-libs/setup/ssr/site-context/tsconfig.bench.json \ + * core-libs/setup/ssr/site-context/base-site-resolver.bench.ts + * + * Notes: + * - `--project tsconfig.bench.json` scopes the compile to the 3 resolver files + * (CommonJS, skipLibCheck) so ts-node runs without the full app tsconfig. + * - `NODE_TLS_REJECT_UNAUTHORIZED=0` is only for self-signed test backends; + * never set it in production. + * + * Switch approach by swapping the resolver import block below (same as server.ts). + * + * The resolver is cacheless: every resolve() performs a fresh OCC fetch. There + * is therefore no "cold vs warm" distinction — per-call latency IS the OCC + * round-trip plus regex matching. + * + * Required env vars: + * CX_BASE_URL=https://your-backend.com (real OCC backend) + * BENCH_REQUEST_URL=https://your-storefront.com/en/ (URL to resolve) + * MOCK_OCC_PORT=9999 (optional: mock slow OCC port) + * + * Scenarios: + * concurrency-cap — fire (cap + extra) simultaneous resolves at a slow mock; + * asserts the extras fail fast with ConcurrencyLimitError + * slow-occ — resolve() against a mock that delays past the timeout; + * asserts it throws OccUnavailableError within the timeout + * per-call — resolve() latency over N iterations (each a full OCC call) + * concurrent — 10 parallel resolve() calls, 3 batches + * + * concurrency-cap and slow-occ use local mocks — run without CX_BASE_URL. + * per-call and concurrent require a real OCC backend (CX_BASE_URL). + */ + +/* webpackIgnore: true */ +import * as http from 'node:http'; +import { performance } from 'node:perf_hooks'; +import { + BaseSiteResolver, + BaseSiteResolverConfig, + ConcurrencyLimitError, + OccUnavailableError, +} 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 { PureNodeBaseSiteResolver } from './pure-node-base-site-resolver'; +const APPROACH_LABEL = 'pure-node'; +function makeResolver(config: BaseSiteResolverConfig): BaseSiteResolver { + return new PureNodeBaseSiteResolver(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?://.*'] }], +}); + +interface MockOccServer { + server: http.Server; + getCallCount: () => number; +} + +function startMockOccServer(port: number, delayMs: number): MockOccServer { + let callCount = 0; + const server = http.createServer((_req, res) => { + callCount++; + setTimeout(() => { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(MOCK_BODY); + }, delayMs); + }); + server.listen(port); + return { server, getCallCount: () => callCount }; +} + +async function startEphemeralMockOccServer( + delayMs: number +): Promise { + let callCount = 0; + const server = http.createServer((_req, res) => { + callCount++; + 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, getCallCount: () => callCount }; +} + +// ─── scenarios ────────────────────────────────────────────────────────────── + +async function scenarioConcurrencyCap( + cap: number, + extra: number +): Promise<{ resolved: number; shed: number; occCallCount: number }> { + // Mock holds each call open long enough that all resolves overlap, filling + // the in-flight counter before any completes. + const mock = await startEphemeralMockOccServer(200); + const resolver = makeResolver({ + occBaseUrl: `http://localhost:${mock.port}`, + timeoutMs: 3000, + maxConcurrentOccCalls: cap, + }); + + 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 new Promise((resolve) => mock.server.close(() => resolve())); + return { resolved, shed, occCallCount: mock.getCallCount() }; +} + +async function scenarioSlowOcc( + occBaseUrl: string, + resolverTimeoutMs: number +): Promise<{ threw: boolean; elapsed: number }> { + const resolver = makeResolver({ occBaseUrl, timeoutMs: resolverTimeoutMs }); + const t0 = performance.now(); + let threw = false; + try { + await resolver.resolve(REQUEST_URL); + } catch (err) { + threw = err instanceof OccUnavailableError; + } + return { threw, elapsed: performance.now() - t0 }; +} + +async function scenarioPerCall( + resolver: BaseSiteResolver, + iterations: number +): Promise { + 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 { + 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; +} + +// ─── main ──────────────────────────────────────────────────────────────────── + +async function main(): Promise { + console.log(`\n${'═'.repeat(72)}`); + console.log(` BASE-SITE RESOLVER BENCHMARK (cacheless)`); + 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`); + + // ── Scenario: concurrency cap (mock — no CX_BASE_URL needed) ────────────── + console.log('Scenario: concurrency-cap (cap 10, fire 15 concurrent)\n'); + const capResult = await scenarioConcurrencyCap(10, 5); + const capPass = capResult.resolved === 10 && capResult.shed === 5; + console.log( + ` resolved: ${capResult.resolved} shed(ConcurrencyLimitError): ${capResult.shed} OCC calls: ${capResult.occCallCount} cap: ${capPass ? 'PASS' : 'FAIL — expected 10 resolved / 5 shed'}` + ); + if (!capPass) { + process.exitCode = 1; + } + + // ── Scenario: slow OCC (mock — no CX_BASE_URL needed) ───────────────────── + console.log('\nScenario: slow-occ (mock 4 s delay, resolver timeout 3 s)\n'); + { + const port = MOCK_OCC_PORT ?? 9999; + const { server: mockServer } = startMockOccServer(port, 4000); + const result = await scenarioSlowOcc(`http://localhost:${port}`, 3000); + const slowPass = result.threw && result.elapsed < 3500; + console.log( + ` resolve: ${result.elapsed.toFixed(1)} ms threw OccUnavailableError: ${result.threw ? 'PASS' : 'FAIL — expected timeout error'}` + ); + if (!slowPass) { + 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 }); + + // ── Scenario: per-call latency (each = full OCC fetch) ────────────────── + console.log('\nScenario: per-call (100 iterations, each a fresh OCC call)\n'); + const perCallSamples = await scenarioPerCall(resolver, 100); + printStats('per-call', perCallSamples); + + // ── Scenario: concurrent ──────────────────────────────────────────────── + console.log('\nScenario: concurrent (10 × resolve, 3 batches)\n'); + const concurrentSamples = await scenarioConcurrent(resolver, 10, 3); + printStats('concurrent-batch', concurrentSamples); + } + + 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..90ea359e32f --- /dev/null +++ b/core-libs/setup/ssr/site-context/base-site-resolver.ts @@ -0,0 +1,69 @@ +/* + * 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; + /** + * Max number of OCC calls allowed in flight at once. When this many resolves + * are already fetching, further calls fail fast with `ConcurrencyLimitError` + * instead of queueing. Bounds pressure on the Node event loop and sockets. + * Default: 10 (matches OptimizedSsrEngine's `concurrency` default). + */ + maxConcurrentOccCalls?: number; + /** + * baseSiteId returned by resolve() when no urlPattern matches the request URL. + * Supplied by the consuming app (its configured `context.baseSite[0]`), so the + * framework stays app-agnostic. When omitted, resolve() returns null on no match. + */ + defaultBaseSite?: string; +} + +/** Thrown when OCC is unreachable, errored, or timed out. */ +export class OccUnavailableError extends Error { + constructor(message = 'OCC base-sites request failed') { + super(message); + this.name = 'OccUnavailableError'; + } +} + +/** Thrown when the in-flight OCC call limit is exceeded (load shedding). */ +export class ConcurrencyLimitError extends Error { + constructor(message = 'OCC call concurrency limit exceeded') { + super(message); + this.name = 'ConcurrencyLimitError'; + } +} + +export interface BaseSiteResolver { + /** + * Resolve the baseSiteId for the given absolute request URL. + * + * Returns the matched site's uid, or the configured `defaultBaseSite` when no + * urlPattern matches (null when no match and no default). Both are normal + * resolution results. + * + * Throws (does not return) on failure: + * - `ConcurrencyLimitError` when the in-flight OCC call limit is exceeded. + * - `OccUnavailableError` when the OCC call fails or times out. + * + * The consuming app maps these to an HTTP response (e.g. 503); the framework + * itself carries no HTTP knowledge. + */ + resolve(requestUrl: string): 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..843679e313e --- /dev/null +++ b/core-libs/setup/ssr/site-context/pure-node-base-site-resolver.ts @@ -0,0 +1,143 @@ +/* + * SPDX-FileCopyrightText: 2026 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * SPIKE — not production code. + * Approach (a): Pure Node — no Angular involved. + * + * Cacheless by design: every resolve() fetches base sites from OCC fresh, then + * matches the request URL against each site's urlPatterns, falling back to the + * configured default baseSite when nothing matches. No caching because SSR runs + * as multiple instances that die/restart independently — a per-process cache + * would drift between nodes and yield inconsistent results across the fleet. + * The OCC /basesites call is cheap, so the consistency win beats the saved call. + * + * 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, + ConcurrencyLimitError, + OccUnavailableError, +} from './base-site-resolver'; + +interface OccBaseSite { + uid?: string; + urlPatterns?: string[]; +} + +/** + * 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 maxConcurrentOccCalls: number; + private readonly defaultBaseSite: string | null; + /** OCC calls currently in flight; the basis for the concurrency cap. */ + private inFlight = 0; + + constructor(_config: BaseSiteResolverConfig) { + const prefix = _config.occPrefix ?? '/occ/v2'; + this.occUrl = `${_config.occBaseUrl}${prefix}/basesites?fields=FULL`; + this.timeoutMs = _config.timeoutMs ?? 3000; + this.maxConcurrentOccCalls = _config.maxConcurrentOccCalls ?? 10; + this.defaultBaseSite = _config.defaultBaseSite ?? null; + } + + async resolve(requestUrl: string): Promise { + // Load shedding: refuse fast before touching OCC. This protects the Node + // process (spec factor #1 — never hang/DoS the SSR server). It intentionally + // does NOT protect the OCC backend from sustained load: with the cap at N, + // up to N calls can still hit OCC concurrently. Guarding OCC's own capacity + // is the backend's concern (its own scaling/rate limits), out of scope here. + if (this.inFlight >= this.maxConcurrentOccCalls) { + throw new ConcurrencyLimitError(); + } + this.inFlight++; + try { + const sites = await this.fetchSites(); + const matched = sites.find((site) => matchesSite(site, requestUrl)); + return matched?.uid ?? this.defaultBaseSite; + } finally { + this.inFlight--; + } + } + + private async fetchSites(): 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 OccUnavailableError( + `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, + })); + const elapsed = (performance.now() - t0).toFixed(1); + console.log( + `[pure-node] OCC basesites fetched in ${elapsed} ms (${sites.length} sites)` + ); + return 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` + ); + throw new OccUnavailableError( + `OCC basesites timed out after ${this.timeoutMs} ms` + ); + } + console.error( + `[pure-node] OCC basesites fetch failed after ${elapsed} ms:`, + err + ); + throw err instanceof OccUnavailableError + ? err + : new OccUnavailableError((err as Error).message); + } finally { + clearTimeout(timer); + } + } +} 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..f27dc0d8928 --- /dev/null +++ b/core-libs/setup/ssr/site-context/tsconfig.bench.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "module": "CommonJS", + "moduleResolution": "node", + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "target": "ES2020", + "lib": ["ES2020"], + "skipLibCheck": true, + "outDir": "/tmp/bench-a", + "rootDir": "." + }, + "include": [ + "base-site-resolver.bench.ts", + "base-site-resolver.ts", + "pure-node-base-site-resolver.ts" + ] +} diff --git a/projects/storefrontapp/src/app/spartacus/base-site.config.ts b/projects/storefrontapp/src/app/spartacus/base-site.config.ts new file mode 100644 index 00000000000..292f0a615f6 --- /dev/null +++ b/projects/storefrontapp/src/app/spartacus/base-site.config.ts @@ -0,0 +1,30 @@ +/* + * SPDX-FileCopyrightText: 2026 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { environment } from '../../environments/environment'; + +const defaultBaseSite = [ + 'electronics-spa', + 'electronics-spa-standalone', + 'electronics', + 'electronics-standalone', + 'apparel-de', + 'apparel-uk', + 'apparel-uk-spa', + 'apparel-uk-standalone', +]; + +/** + * Configured base sites, in priority order. Consumed by Angular via + * `provideConfig({ context: { baseSite } })` and by the pure-Node SSR resolver + * in `server.ts`, so both read the same source of truth. + */ +export const baseSite = environment.epdVisualization + ? ['electronics-epdvisualization-spa'].concat(defaultBaseSite) + : defaultBaseSite; + +/** Default baseSite used when a request URL carries no site information. */ +export const defaultBaseSiteId = baseSite[0]; diff --git a/projects/storefrontapp/src/app/spartacus/spartacus-b2c-configuration.providers.ts b/projects/storefrontapp/src/app/spartacus/spartacus-b2c-configuration.providers.ts index 765238da65c..68eea999586 100644 --- a/projects/storefrontapp/src/app/spartacus/spartacus-b2c-configuration.providers.ts +++ b/projects/storefrontapp/src/app/spartacus/spartacus-b2c-configuration.providers.ts @@ -6,21 +6,7 @@ import { makeEnvironmentProviders } from '@angular/core'; import { provideConfig } from '@spartacus/core'; -import { environment } from '../../environments/environment'; - -const defaultBaseSite = [ - 'electronics-spa', - 'electronics-spa-standalone', - 'electronics', - 'electronics-standalone', - 'apparel-de', - 'apparel-uk', - 'apparel-uk-spa', - 'apparel-uk-standalone', -]; -const baseSite = environment.epdVisualization - ? ['electronics-epdvisualization-spa'].concat(defaultBaseSite) - : defaultBaseSite; +import { baseSite } from './base-site.config'; export const spartacusB2cConfigurationProviders = makeEnvironmentProviders([ provideConfig({ diff --git a/projects/storefrontapp/src/server.ts b/projects/storefrontapp/src/server.ts index b9ef896fe55..d6874c3cf26 100644 --- a/projects/storefrontapp/src/server.ts +++ b/projects/storefrontapp/src/server.ts @@ -18,6 +18,9 @@ import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname, join, resolve } from 'path'; import bootstrap from './main.server'; +import { defaultBaseSiteId } from './app/spartacus/base-site.config'; +import { PureNodeBaseSiteResolver } from '../../../core-libs/setup/ssr/site-context/pure-node-base-site-resolver'; +import { createBaseSiteRequestHandler } from '../../../core-libs/setup/ssr/site-context/base-site-request-handler'; const ssrOptions: SsrOptimizationOptions = { timeout: Number( @@ -28,6 +31,17 @@ const ssrOptions: SsrOptimizationOptions = { const ngExpressEngine = NgExpressEngineDecorator.get(engine, ssrOptions); +// Pure-Node SSR base-site resolver (approach a). Per request it fetches the +// base sites from OCC and matches the request URL against each site's +// `urlPatterns`, falling back to the app-configured default +// (`context.baseSite[0]`) when nothing matches. Cacheless by design (see the +// resolver's JSDoc). A concurrency cap (default 10) sheds load under pressure. +const baseSiteResolver = new PureNodeBaseSiteResolver({ + occBaseUrl: buildProcess.env.CX_BASE_URL, + timeoutMs: 3000, + defaultBaseSite: defaultBaseSiteId, +}); + // The Express app is exported so that it can be used by serverless Functions. export function app(): express.Express { const server = express(); @@ -66,6 +80,17 @@ export function app(): express.Express { }) ); + // Serves a per-site llms.txt. The framework handler resolves the baseSiteId + // (and maps overload / OCC outages to 503); the app supplies only the route + // and the body via `getLlmsTxt`. + server.get( + /\/llms\.txt$/, + createBaseSiteRequestHandler({ + resolver: baseSiteResolver, + render: getLlmsTxt, + }) + ); + // All regular routes use the Universal engine server.get(/.*/, (req, res) => { res.render(indexHtml, { @@ -95,3 +120,10 @@ function run() { } run(); + +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 84af6c3dc056d606cca95d88a07c5e8e3642a47c Mon Sep 17 00:00:00 2001 From: I777870 Date: Fri, 7 Aug 2026 11:30:19 +0200 Subject: [PATCH 2/3] refactor(ssr): address review on base-site resolver - use protected instead of private for resolver fields and methods - rename misleading _config constructor param to config - apply contentType to the 503 error response in the request handler - drop stale "switch approaches" note from the resolver contract --- .../site-context/base-site-request-handler.ts | 1 + .../ssr/site-context/base-site-resolver.ts | 6 ++--- .../pure-node-base-site-resolver.ts | 24 +++++++++---------- 3 files changed, 16 insertions(+), 15 deletions(-) diff --git a/core-libs/setup/ssr/site-context/base-site-request-handler.ts b/core-libs/setup/ssr/site-context/base-site-request-handler.ts index a92b2a8454a..28d0b58b66d 100644 --- a/core-libs/setup/ssr/site-context/base-site-request-handler.ts +++ b/core-libs/setup/ssr/site-context/base-site-request-handler.ts @@ -69,6 +69,7 @@ export function createBaseSiteRequestHandler( res .status(503) .set('Retry-After', String(retryAfterSeconds)) + .type(contentType) .send('Service Unavailable'); } else { next(err); 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 90ea359e32f..00387542d00 100644 --- a/core-libs/setup/ssr/site-context/base-site-resolver.ts +++ b/core-libs/setup/ssr/site-context/base-site-resolver.ts @@ -6,10 +6,10 @@ /** * SPIKE — not production code. - * Branch: spike/base-site-detection-approaches + * Approach (a): Pure Node — no Angular involved. * - * Shared contract implemented by all base-site resolver approaches. - * To switch approaches: comment/uncomment the relevant import block in server.ts. + * Shared contract implemented by the base-site resolver. Each spike approach + * lives on its own branch/worktree and provides its own implementation. */ export interface BaseSiteResolverConfig { 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 index 843679e313e..bf8fcaf1f45 100644 --- 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 @@ -60,19 +60,19 @@ function matchesSite(site: OccBaseSite, url: string): boolean { } export class PureNodeBaseSiteResolver implements BaseSiteResolver { - private readonly occUrl: string; - private readonly timeoutMs: number; - private readonly maxConcurrentOccCalls: number; - private readonly defaultBaseSite: string | null; + protected readonly occUrl: string; + protected readonly timeoutMs: number; + protected readonly maxConcurrentOccCalls: number; + protected readonly defaultBaseSite: string | null; /** OCC calls currently in flight; the basis for the concurrency cap. */ - private inFlight = 0; + protected inFlight = 0; - constructor(_config: BaseSiteResolverConfig) { - const prefix = _config.occPrefix ?? '/occ/v2'; - this.occUrl = `${_config.occBaseUrl}${prefix}/basesites?fields=FULL`; - this.timeoutMs = _config.timeoutMs ?? 3000; - this.maxConcurrentOccCalls = _config.maxConcurrentOccCalls ?? 10; - this.defaultBaseSite = _config.defaultBaseSite ?? null; + constructor(config: BaseSiteResolverConfig) { + const prefix = config.occPrefix ?? '/occ/v2'; + this.occUrl = `${config.occBaseUrl}${prefix}/basesites?fields=FULL`; + this.timeoutMs = config.timeoutMs ?? 3000; + this.maxConcurrentOccCalls = config.maxConcurrentOccCalls ?? 10; + this.defaultBaseSite = config.defaultBaseSite ?? null; } async resolve(requestUrl: string): Promise { @@ -94,7 +94,7 @@ export class PureNodeBaseSiteResolver implements BaseSiteResolver { } } - private async fetchSites(): Promise { + protected async fetchSites(): Promise { const t0 = performance.now(); const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), this.timeoutMs); From 7c1b26416d16952054b30364170ea04eb74a12ad Mon Sep 17 00:00:00 2001 From: I777870 Date: Thu, 13 Aug 2026 13:43:19 +0200 Subject: [PATCH 3/3] spike(CXSPA-13856): add TTL cache and OCC URL resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CX_BASE_URL is not a Node runtime env var on CCv2; read OCC base URL from build-time environment.occBaseUrl first, then occ-backend-base-url meta tag in browser/index.csr.html (substituted by the deploy script). Missing URL degrades gracefully — warning logged, AI-SEO handlers disabled, core SSR unaffected (previously crashed on startup). Resolver gains 60 s TTL cache with initPromise dedup to avoid thundering-herd on cache miss; concurrency cap guards the miss path. --- core-libs/setup/ssr/public_api.ts | 1 + .../site-context/base-site-request-handler.ts | 1 + .../site-context/base-site-resolver.bench.ts | 6 +- .../ssr/site-context/base-site-resolver.ts | 7 ++ core-libs/setup/ssr/site-context/index.ts | 10 +++ .../site-context/occ-base-url-extractor.ts | 53 ++++++++++++ .../pure-node-base-site-resolver.ts | 57 ++++++++----- projects/storefrontapp/src/server.ts | 81 +++++++++++++------ 8 files changed, 170 insertions(+), 46 deletions(-) create mode 100644 core-libs/setup/ssr/site-context/index.ts create mode 100644 core-libs/setup/ssr/site-context/occ-base-url-extractor.ts diff --git a/core-libs/setup/ssr/public_api.ts b/core-libs/setup/ssr/public_api.ts index 7eed5321124..5fbd9fc26ee 100644 --- a/core-libs/setup/ssr/public_api.ts +++ b/core-libs/setup/ssr/public_api.ts @@ -14,3 +14,4 @@ export * from './optimized-engine/index'; export * from './providers/index'; export * from './testing/index'; export * from './tokens/express.tokens'; +export * from './site-context/index'; diff --git a/core-libs/setup/ssr/site-context/base-site-request-handler.ts b/core-libs/setup/ssr/site-context/base-site-request-handler.ts index 28d0b58b66d..4028ea879c9 100644 --- a/core-libs/setup/ssr/site-context/base-site-request-handler.ts +++ b/core-libs/setup/ssr/site-context/base-site-request-handler.ts @@ -46,6 +46,7 @@ export interface BaseSiteRequestHandlerOptions { * - `OccUnavailableError` — OCC was unreachable or timed out. * Any other error is forwarded to the next error handler. */ + export function createBaseSiteRequestHandler( options: BaseSiteRequestHandlerOptions ): RequestHandler { 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 69098a2d2e9..be72f5a4ecb 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 @@ -32,8 +32,10 @@ * * Switch approach by swapping the resolver import block below (same as server.ts). * - * The resolver is cacheless: every resolve() performs a fresh OCC fetch. There - * is therefore no "cold vs warm" distinction — per-call latency IS the OCC + * The resolver caches base sites for `cacheTtlMs` (default 60 s). Concurrent + * cache-miss requests share a single OCC fetch (`initPromise` dedup). The + * per-call scenario therefore measures cache-warm latency (sub-millisecond + * regex match) after the first call; cache-miss latency equals the OCC * round-trip plus regex matching. * * Required env vars: 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 00387542d00..91dff102270 100644 --- a/core-libs/setup/ssr/site-context/base-site-resolver.ts +++ b/core-libs/setup/ssr/site-context/base-site-resolver.ts @@ -32,6 +32,13 @@ export interface BaseSiteResolverConfig { * framework stays app-agnostic. When omitted, resolve() returns null on no match. */ defaultBaseSite?: string; + /** + * TTL for the in-memory baseSites cache in ms. Cache improves per-request + * latency once warm (sub-millisecond regex match vs ~130 ms OCC round-trip). + * Concurrent cache-miss requests share a single OCC fetch (initPromise dedup). + * 0 = cacheless. Default: 60_000 (1 minute). + */ + cacheTtlMs?: number; } /** Thrown when OCC is unreachable, errored, or timed out. */ diff --git a/core-libs/setup/ssr/site-context/index.ts b/core-libs/setup/ssr/site-context/index.ts new file mode 100644 index 00000000000..d462956f28e --- /dev/null +++ b/core-libs/setup/ssr/site-context/index.ts @@ -0,0 +1,10 @@ +/* + * SPDX-FileCopyrightText: 2026 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './base-site-resolver'; +export * from './base-site-request-handler'; +export * from './occ-base-url-extractor'; +export * from './pure-node-base-site-resolver'; diff --git a/core-libs/setup/ssr/site-context/occ-base-url-extractor.ts b/core-libs/setup/ssr/site-context/occ-base-url-extractor.ts new file mode 100644 index 00000000000..642365d872b --- /dev/null +++ b/core-libs/setup/ssr/site-context/occ-base-url-extractor.ts @@ -0,0 +1,53 @@ +/* + * SPDX-FileCopyrightText: 2026 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * SPIKE — approach (a): Pure Node — no Angular involved. + * + * Fallback utility: extracts the OCC backend base URL from an index.html + * string. Use when CX_BASE_URL is not available as a Node.js environment + * variable (e.g. Model T hosting injects it only as an index.html meta tag + * placeholder via the deployment script). + */ + +const OCC_BASE_URL_META_TAG_NAME = 'occ-backend-base-url'; + +/** + * Sentinel written by the deployment script before the real URL is substituted. + * A meta tag carrying this value must be treated as absent. + */ +const OCC_BASE_URL_META_TAG_PLACEHOLDER = 'OCC_BACKEND_BASE_URL_VALUE'; + +/** + * Extracts the OCC backend base URL from an index.html string. + * + * Returns null when: + * - no `` tag is present, + * - the tag content equals the unsubstituted deployment placeholder. + * + * Handles both attribute orderings: + * `` + * `` + */ +export function extractOccBaseUrlFromHtml(html: string): string | null { + const patterns = [ + new RegExp( + `]+name=["']${OCC_BASE_URL_META_TAG_NAME}["'][^>]+content=["']([^"']+)["'][^>]*>`, + 'i' + ), + new RegExp( + `]+content=["']([^"']+)["'][^>]+name=["']${OCC_BASE_URL_META_TAG_NAME}["'][^>]*>`, + 'i' + ), + ]; + for (const pattern of patterns) { + const url = html.match(pattern)?.[1]?.trim(); + if (url && url !== OCC_BASE_URL_META_TAG_PLACEHOLDER) { + return url; + } + } + return null; +} 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 index bf8fcaf1f45..c72758c19df 100644 --- 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 @@ -5,22 +5,14 @@ */ /** - * SPIKE — not production code. - * Approach (a): Pure Node — no Angular involved. - * - * Cacheless by design: every resolve() fetches base sites from OCC fresh, then - * matches the request URL against each site's urlPatterns, falling back to the - * configured default baseSite when nothing matches. No caching because SSR runs - * as multiple instances that die/restart independently — a per-process cache - * would drift between nodes and yield inconsistent results across the fleet. - * The OCC /basesites call is cheap, so the consistency win beats the saved call. + * SPIKE — approach (a): Pure Node — no Angular involved. * * 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 { performance } from 'node:perf_hooks'; import { BaseSiteResolver, BaseSiteResolverConfig, @@ -63,32 +55,59 @@ export class PureNodeBaseSiteResolver implements BaseSiteResolver { protected readonly occUrl: string; protected readonly timeoutMs: number; protected readonly maxConcurrentOccCalls: number; + protected readonly cacheTtlMs: number; protected readonly defaultBaseSite: string | null; - /** OCC calls currently in flight; the basis for the concurrency cap. */ + + /** Requests currently waiting for an OCC call to complete (cache miss path). */ protected inFlight = 0; + /** Shared in-flight OCC fetch — deduplicates concurrent cache-miss requests. */ + protected initPromise: Promise | null = null; + /** Cached baseSites list. */ + protected cachedSites: OccBaseSite[] | null = null; + /** Timestamp (ms) when the cache was last populated. */ + protected cachedAt = 0; constructor(config: BaseSiteResolverConfig) { const prefix = config.occPrefix ?? '/occ/v2'; this.occUrl = `${config.occBaseUrl}${prefix}/basesites?fields=FULL`; this.timeoutMs = config.timeoutMs ?? 3000; this.maxConcurrentOccCalls = config.maxConcurrentOccCalls ?? 10; + this.cacheTtlMs = config.cacheTtlMs ?? 60_000; this.defaultBaseSite = config.defaultBaseSite ?? null; } async resolve(requestUrl: string): Promise { - // Load shedding: refuse fast before touching OCC. This protects the Node - // process (spec factor #1 — never hang/DoS the SSR server). It intentionally - // does NOT protect the OCC backend from sustained load: with the cap at N, - // up to N calls can still hit OCC concurrently. Guarding OCC's own capacity - // is the backend's concern (its own scaling/rate limits), out of scope here. + const sites = await this.getSites(); + const matched = sites.find((site) => matchesSite(site, requestUrl)); + return matched?.uid ?? this.defaultBaseSite; + } + + private async getSites(): Promise { + // Cache hit: sub-millisecond regex match, no OCC call. + if (this.cachedSites && Date.now() - this.cachedAt < this.cacheTtlMs) { + return this.cachedSites; + } + + // Load shedding: cap requests waiting for an OCC cache refresh. if (this.inFlight >= this.maxConcurrentOccCalls) { throw new ConcurrencyLimitError(); } this.inFlight++; + try { - const sites = await this.fetchSites(); - const matched = sites.find((site) => matchesSite(site, requestUrl)); - return matched?.uid ?? this.defaultBaseSite; + // Dedup: concurrent cache-miss requests share a single OCC fetch. + if (!this.initPromise) { + this.initPromise = this.fetchSites() + .then((sites) => { + this.cachedSites = sites; + this.cachedAt = Date.now(); + return sites; + }) + .finally(() => { + this.initPromise = null; + }); + } + return await this.initPromise; } finally { this.inFlight--; } diff --git a/projects/storefrontapp/src/server.ts b/projects/storefrontapp/src/server.ts index d6874c3cf26..f8dcaeab039 100644 --- a/projects/storefrontapp/src/server.ts +++ b/projects/storefrontapp/src/server.ts @@ -14,13 +14,15 @@ import { getOriginValidationMiddleware, } from '@spartacus/setup/ssr'; import express from 'express'; -import { readFileSync } from 'node:fs'; +import { readFileSync, existsSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname, join, resolve } from 'path'; import bootstrap from './main.server'; import { defaultBaseSiteId } from './app/spartacus/base-site.config'; +import { environment } from './environments/environment'; import { PureNodeBaseSiteResolver } from '../../../core-libs/setup/ssr/site-context/pure-node-base-site-resolver'; import { createBaseSiteRequestHandler } from '../../../core-libs/setup/ssr/site-context/base-site-request-handler'; +import { extractOccBaseUrlFromHtml } from '../../../core-libs/setup/ssr/site-context/occ-base-url-extractor'; const ssrOptions: SsrOptimizationOptions = { timeout: Number( @@ -31,24 +33,19 @@ const ssrOptions: SsrOptimizationOptions = { const ngExpressEngine = NgExpressEngineDecorator.get(engine, ssrOptions); -// Pure-Node SSR base-site resolver (approach a). Per request it fetches the -// base sites from OCC and matches the request URL against each site's -// `urlPatterns`, falling back to the app-configured default -// (`context.baseSite[0]`) when nothing matches. Cacheless by design (see the -// resolver's JSDoc). A concurrency cap (default 10) sheds load under pressure. -const baseSiteResolver = new PureNodeBaseSiteResolver({ - occBaseUrl: buildProcess.env.CX_BASE_URL, - timeoutMs: 3000, - defaultBaseSite: defaultBaseSiteId, -}); +const _serverDistFolder = dirname(fileURLToPath(import.meta.url)); +const _indexHtmlContent = readFileSync( + join(_serverDistFolder, 'index.server.html'), + 'utf-8' +); +const { occBaseUrl, resolver: baseSiteResolver } = + createBaseSiteResolver(_serverDistFolder); // The Express app is exported so that it can be used by serverless Functions. export function app(): express.Express { const server = express(); - const serverDistFolder = dirname(fileURLToPath(import.meta.url)); - const browserDistFolder = resolve(serverDistFolder, '../browser'); - const indexHtml = join(serverDistFolder, 'index.server.html'); - const indexHtmlContent = readFileSync(indexHtml, 'utf-8'); + const browserDistFolder = resolve(_serverDistFolder, '../browser'); + const indexHtml = join(_serverDistFolder, 'index.server.html'); server.set('trust proxy', 'loopback'); @@ -83,13 +80,15 @@ export function app(): express.Express { // Serves a per-site llms.txt. The framework handler resolves the baseSiteId // (and maps overload / OCC outages to 503); the app supplies only the route // and the body via `getLlmsTxt`. - server.get( - /\/llms\.txt$/, - createBaseSiteRequestHandler({ - resolver: baseSiteResolver, - render: getLlmsTxt, - }) - ); + if (baseSiteResolver) { + server.get( + /\/llms\.txt$/, + createBaseSiteRequestHandler({ + resolver: baseSiteResolver, + render: getLlmsTxt, + }) + ); + } // All regular routes use the Universal engine server.get(/.*/, (req, res) => { @@ -99,7 +98,7 @@ export function app(): express.Express { }); }); - server.use(defaultExpressErrorHandlers(indexHtmlContent)); + server.use(defaultExpressErrorHandlers(_indexHtmlContent)); return server; } @@ -121,9 +120,41 @@ function run() { run(); +function createBaseSiteResolver(serverDistFolder: string): { + occBaseUrl: string | null; + resolver: PureNodeBaseSiteResolver | null; +} { + const browserDistFolder = resolve(serverDistFolder, '../browser'); + const browserIndexPath = existsSync(join(browserDistFolder, 'index.csr.html')) + ? join(browserDistFolder, 'index.csr.html') + : join(browserDistFolder, 'index.html'); + const occBaseUrl = + environment.occBaseUrl || + extractOccBaseUrlFromHtml(readFileSync(browserIndexPath, 'utf-8')) || + null; + + if (!occBaseUrl) { + /* eslint-disable-next-line no-console */ + console.warn( + '[base-site-resolver] OCC base URL not configured — AI-SEO handlers disabled. ' + + 'Set CX_BASE_URL or substitute the occ-backend-base-url meta tag in index.server.html.' + ); + return { occBaseUrl: null, resolver: null }; + } + + return { + occBaseUrl, + resolver: new PureNodeBaseSiteResolver({ + occBaseUrl, + timeoutMs: 3000, + defaultBaseSite: defaultBaseSiteId, + }), + }; +} + 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> General LLM rules — applies to all sites on this origin.\n> OCC base URL: ${occBaseUrl}\n`; } - return `# llms.txt\n> Site: ${baseSiteId}\n`; + return `# llms.txt\n> Site: ${baseSiteId}\n> OCC base URL: ${occBaseUrl}\n`; }