@decocms/blocks 7.31.6 → 7.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/blocks",
3
- "version": "7.31.6",
3
+ "version": "7.32.0",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=24"
@@ -53,6 +53,7 @@
53
53
  "./sdk/djb2": "./src/sdk/djb2.ts",
54
54
  "./sdk/encoding": "./src/sdk/encoding.ts",
55
55
  "./sdk/env": "./src/sdk/env.ts",
56
+ "./sdk/fetchCache": "./src/sdk/fetchCache.ts",
56
57
  "./sdk/flags": "./src/sdk/flags.ts",
57
58
  "./sdk/http": "./src/sdk/http.ts",
58
59
  "./sdk/instrumentedFetch": "./src/sdk/instrumentedFetch.ts",
@@ -156,9 +156,9 @@ describe("recordCacheMetric — cache_layer label", () => {
156
156
  expect(counters).toHaveLength(1);
157
157
  expect(counters[0]?.name).toBe(MetricNames.CACHE_REQUESTS);
158
158
  expect(counters[0]?.labels).toMatchObject({
159
- "deco.cache.profile": "product",
160
- "deco.cache.status": "HIT",
161
- "deco.cache.layer": "edge",
159
+ "profile": "product",
160
+ "status": "HIT",
161
+ "layer": "edge",
162
162
  });
163
163
  });
164
164
 
@@ -169,7 +169,7 @@ describe("recordCacheMetric — cache_layer label", () => {
169
169
  recordCacheMetric(false, "search", "MISS", "edge");
170
170
 
171
171
  expect(counters[0]?.name).toBe(MetricNames.CACHE_REQUESTS);
172
- expect(counters[0]?.labels?.["deco.cache.status"]).toBe("MISS");
172
+ expect(counters[0]?.labels?.["status"]).toBe("MISS");
173
173
  });
174
174
 
175
175
  it("supports the legacy 3-arg signature for backward compat", () => {
@@ -179,20 +179,36 @@ describe("recordCacheMetric — cache_layer label", () => {
179
179
  recordCacheMetric(true, "static");
180
180
 
181
181
  expect(counters[0]?.labels).toEqual({
182
- "deco.cache.status": "HIT",
183
- "deco.cache.profile": "static",
182
+ "status": "HIT",
183
+ "profile": "static",
184
184
  });
185
185
  });
186
186
 
187
- it("distinguishes cachedLoader vs edge vs vtex-swr layers", () => {
187
+ it("distinguishes cachedLoader vs edge vs swr layers", () => {
188
188
  const { adapter, counters } = captureMeter();
189
189
  configureMeter(adapter);
190
190
 
191
191
  recordCacheMetric(true, "loader-x", "HIT", "cachedLoader");
192
- recordCacheMetric(true, "vtex-product", "HIT", "vtex-swr");
192
+ recordCacheMetric(true, undefined, "HIT", "swr", "vtex");
193
+
194
+ expect(counters[0]?.labels?.["layer"]).toBe("cachedLoader");
195
+ expect(counters[1]?.labels?.["layer"]).toBe("swr");
196
+ });
193
197
 
194
- expect(counters[0]?.labels?.["deco.cache.layer"]).toBe("cachedLoader");
195
- expect(counters[1]?.labels?.["deco.cache.layer"]).toBe("vtex-swr");
198
+ it("keeps provider (swr backend) on a separate label from profile (edge page-type)", () => {
199
+ const { adapter, counters } = captureMeter();
200
+ configureMeter(adapter);
201
+
202
+ // edge: profile carries the page-type; no provider.
203
+ recordCacheMetric(true, "product", "HIT", "edge");
204
+ // swr: provider carries the backend; profile left unset so a
205
+ // `sum by (profile)` panel never blends page-types with backend names.
206
+ recordCacheMetric(true, undefined, "HIT", "swr", "magento");
207
+
208
+ expect(counters[0]?.labels?.["profile"]).toBe("product");
209
+ expect(counters[0]?.labels?.["provider"]).toBeUndefined();
210
+ expect(counters[1]?.labels?.["profile"]).toBeUndefined();
211
+ expect(counters[1]?.labels?.["provider"]).toBe("magento");
196
212
  });
197
213
  });
198
214
 
@@ -263,6 +263,8 @@ export const MetricNames = {
263
263
  // Single cache counter dimensioned by `deco.cache.status` — follows the OTel
264
264
  // semconv pattern (cf. nfs.server.repcache.requests + .status). deco-cx/deco
265
265
  // uses the same name so both frameworks aggregate together.
266
+ // Labels on this counter use short keys (status/profile/layer/provider) —
267
+ // the metric name already provides the deco.cache.* namespace.
266
268
  CACHE_REQUESTS: "deco.cache.requests",
267
269
  RESOLVE_DURATION: "deco.cms.resolve.duration",
268
270
  LOADER_DURATION: "deco.loader.duration",
@@ -288,7 +290,7 @@ export const METRIC_METADATA: Record<string, { description: string; unit: string
288
290
  unit: "s",
289
291
  },
290
292
  [MetricNames.CACHE_REQUESTS]: {
291
- description: "Cache lookups, dimensioned by deco.cache.status (hit/stale/miss/bypass).",
293
+ description: "Cache lookups, dimensioned by status (hit/stale/miss/bypass).",
292
294
  unit: "{request}",
293
295
  },
294
296
  [MetricNames.RESOLVE_DURATION]: {
@@ -398,7 +400,7 @@ export function recordRequestMetric(
398
400
  // - `status_class`: 5-element enum (2xx / 3xx / 4xx / 5xx / unknown).
399
401
  // - `outcome`: CF outcome enum (~7 values).
400
402
  // - `cache_decision`: 5-element enum.
401
- // - `cache_layer`: 3-element enum (edge / cachedLoader / vtex-swr).
403
+ // - `cache_layer`: 3-element enum (edge / cachedLoader / swr).
402
404
  // - `region`: ~250 CF colo codes worldwide.
403
405
  // Total combinations are bounded — safe for unbounded series on
404
406
  // ClickHouse but operators should still avoid grouping by `region`
@@ -457,10 +459,15 @@ export type CacheDecision = "HIT" | "STALE-HIT" | "STALE-ERROR" | "MISS" | "BYPA
457
459
  * - `edge` — Cloudflare Cache API (HTML pages, server-fn responses)
458
460
  * - `cachedLoader` — In-memory per-isolate via `sdk/cachedLoader.ts`
459
461
  * (loader-level SWR, dedup, in-flight)
460
- * - `vtex-swr` — Apps-side in-memory cache shared by VTEX clients
461
- * (intelligent-search, cross-selling, etc.)
462
+ * - `swr` — Apps-side in-memory SWR fetch cache shared by commerce
463
+ * clients (VTEX intelligent-search, Magento GraphQL,
464
+ * Shopify, etc.) via `sdk/fetchCache.ts`. Provider-agnostic;
465
+ * the specific backend rides on `deco.cache.provider`
466
+ * (e.g. `vtex` / `magento` / `shopify`), a separate label
467
+ * from `deco.cache.profile` (page-type, set by `edge`).
468
+ * Renamed from the old `vtex-swr` once the util was shared.
462
469
  */
463
- export type CacheLayer = "edge" | "cachedLoader" | "vtex-swr";
470
+ export type CacheLayer = "edge" | "cachedLoader" | "swr";
464
471
 
465
472
  /**
466
473
  * Record a cache hit/miss metric. Also stamps the decision on the active
@@ -471,18 +478,28 @@ export type CacheLayer = "edge" | "cachedLoader" | "vtex-swr";
471
478
  * Backward-compatible signature:
472
479
  * recordCacheMetric(hit, profile?, decision?)
473
480
  * recordCacheMetric(hit, profile?, decision?, layer?)
481
+ * recordCacheMetric(hit, profile?, decision?, layer?, provider?)
474
482
  *
475
483
  * `decision` is optional — when omitted, the metric still records HIT
476
484
  * vs MISS but dashboards can't distinguish SWR/SIE paths. Pass it
477
485
  * whenever known. `layer` defaults to `edge` when called from
478
- * workerEntry; cachedLoader / vtex-swr call sites should pass their
486
+ * workerEntry; cachedLoader / swr call sites should pass their
479
487
  * value explicitly.
488
+ *
489
+ * `profile` and `provider` are DISTINCT dimensions and must not be
490
+ * conflated: `profile` is the page/route type (`product` / `listing` /
491
+ * `search`, set by the `edge` layer) or loader name (`cachedLoader`);
492
+ * `provider` is the commerce backend (`vtex` / `magento` / `shopify`, set
493
+ * by the `swr` layer). Keeping them on separate labels means a
494
+ * `sum by (profile)` panel never blends page-types with backend
495
+ * names. The `swr` layer passes `provider` and leaves `profile` unset.
480
496
  */
481
497
  export function recordCacheMetric(
482
498
  hit: boolean,
483
499
  profile?: string,
484
500
  decision?: CacheDecision,
485
501
  layer?: CacheLayer,
502
+ provider?: string,
486
503
  ) {
487
504
  // Stamp on the active span FIRST so the attribute survives even if the
488
505
  // meter is a no-op (e.g. on tests, or in dev without DECO_METRICS).
@@ -491,19 +508,21 @@ export function recordCacheMetric(
491
508
  if (decision) active.setAttribute?.("deco.cache.status", decision);
492
509
  if (profile) active.setAttribute?.("deco.cache.profile", profile);
493
510
  if (layer) active.setAttribute?.("deco.cache.layer", layer);
511
+ if (provider) active.setAttribute?.("deco.cache.provider", provider);
494
512
  }
495
513
 
496
514
  const m = getState().meter;
497
515
  if (!m) return;
498
- // Single counter dimensioned by deco.cache.status — unified with deco-cx/deco
499
- // (follows the semconv nfs.server.repcache.requests + .status pattern). status
500
- // is the decision when known (HIT/STALE-HIT/STALE-ERROR/MISS/BYPASS), else the
501
- // hit boolean. Same key on span + metric.
516
+ // Single counter dimensioned by status — the metric name deco.cache.requests
517
+ // already scopes the label, so the deco.cache. prefix is redundant on labels.
518
+ // Span attribute keeps the full deco.cache.status key (spans mix attrs from
519
+ // many sources, so the namespace is needed there).
502
520
  const labels: Labels = {
503
- "deco.cache.status": decision ?? (hit ? "HIT" : "MISS"),
521
+ status: decision ?? (hit ? "HIT" : "MISS"),
504
522
  };
505
- if (profile) labels["deco.cache.profile"] = profile;
506
- if (layer) labels["deco.cache.layer"] = layer;
523
+ if (profile) labels["profile"] = profile;
524
+ if (layer) labels["layer"] = layer;
525
+ if (provider) labels["provider"] = provider;
507
526
  m.counterInc(MetricNames.CACHE_REQUESTS, 1, labels);
508
527
  }
509
528
 
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Coverage for the shared SWR fetch cache — both the caching semantics
3
+ * (fresh HIT / dedup / SWR stale-serve / cold MISS) AND the telemetry it now
4
+ * emits (`deco.cache.requests` with `layer: "swr"` + `profile: <provider>`).
5
+ * The whole point of this module is that instrumentation is automatic, so the
6
+ * metric assertions are as load-bearing as the behavioral ones.
7
+ */
8
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
9
+ import { configureMeter, type MeterAdapter } from "../middleware/observability";
10
+ import { createFetchCache } from "./fetchCache";
11
+
12
+ interface Counter {
13
+ name: string;
14
+ value: number;
15
+ labels?: Record<string, unknown>;
16
+ }
17
+
18
+ function captureMeter(): { adapter: MeterAdapter; counters: Counter[] } {
19
+ const counters: Counter[] = [];
20
+ const adapter: MeterAdapter = {
21
+ counterInc(name, value, labels) {
22
+ counters.push({ name, value: value ?? 1, labels });
23
+ },
24
+ histogramRecord() {},
25
+ };
26
+ return { adapter, counters };
27
+ }
28
+
29
+ const KNOBS = {
30
+ maxEntries: 100,
31
+ freshTtlMs: { success: 1000, notFound: 500, serverError: 0 },
32
+ staleIfErrorMs: 10_000,
33
+ inflightBackstopMs: 15_000,
34
+ };
35
+
36
+ function jsonResponse(body: unknown, status = 200): Response {
37
+ return new Response(JSON.stringify(body), {
38
+ status,
39
+ headers: { "content-type": "application/json" },
40
+ });
41
+ }
42
+
43
+ let counters: Counter[];
44
+
45
+ beforeEach(() => {
46
+ const cap = captureMeter();
47
+ counters = cap.counters;
48
+ configureMeter(cap.adapter);
49
+ });
50
+
51
+ afterEach(() => {
52
+ configureMeter({ counterInc: () => {} });
53
+ vi.useRealTimers();
54
+ });
55
+
56
+ const statuses = () =>
57
+ counters
58
+ .filter((c) => c.name === "deco.cache.requests")
59
+ .map((c) => c.labels?.["status"]);
60
+
61
+ describe("createFetchCache — behavior", () => {
62
+ it("cold call is a MISS, second call is a HIT with the cached body", async () => {
63
+ const cache = createFetchCache({ provider: "vtex", ...KNOBS });
64
+ let calls = 0;
65
+ const doFetch = () => {
66
+ calls++;
67
+ return Promise.resolve(jsonResponse({ ok: true }));
68
+ };
69
+
70
+ const a = await cache.fetchWithCache("k", doFetch);
71
+ const b = await cache.fetchWithCache("k", doFetch);
72
+
73
+ expect(a).toEqual({ ok: true });
74
+ expect(b).toEqual({ ok: true });
75
+ expect(calls).toBe(1); // second served from cache
76
+ expect(statuses()).toEqual(["MISS", "HIT"]);
77
+ });
78
+
79
+ it("dedups concurrent calls for the same key (one upstream, join = HIT)", async () => {
80
+ const cache = createFetchCache({ provider: "vtex", ...KNOBS });
81
+ let resolve!: (r: Response) => void;
82
+ let calls = 0;
83
+ const doFetch = () => {
84
+ calls++;
85
+ return new Promise<Response>((r) => {
86
+ resolve = r;
87
+ });
88
+ };
89
+
90
+ const p1 = cache.fetchWithCache("k", doFetch);
91
+ const p2 = cache.fetchWithCache("k", doFetch);
92
+ resolve(jsonResponse({ n: 1 }));
93
+ const [a, b] = await Promise.all([p1, p2]);
94
+
95
+ expect(a).toEqual({ n: 1 });
96
+ expect(b).toEqual({ n: 1 });
97
+ expect(calls).toBe(1); // only one upstream call
98
+ expect(statuses()).toEqual(["MISS", "HIT"]);
99
+ });
100
+
101
+ it("serves stale within the SIE window (STALE-HIT) and revalidates in background", async () => {
102
+ vi.useFakeTimers();
103
+ const cache = createFetchCache({ provider: "vtex", ...KNOBS });
104
+ let calls = 0;
105
+ const doFetch = () => {
106
+ calls++;
107
+ return Promise.resolve(jsonResponse({ v: calls }));
108
+ };
109
+
110
+ await cache.fetchWithCache("k", doFetch); // MISS, caches { v: 1 }
111
+ vi.setSystemTime(Date.now() + 1500); // past 1000ms fresh TTL, within SIE
112
+
113
+ const stale = await cache.fetchWithCache("k", doFetch); // serves last-good
114
+ expect(stale).toEqual({ v: 1 }); // stale body returned synchronously
115
+ await vi.runAllTimersAsync(); // let background refresh settle
116
+
117
+ expect(statuses()).toEqual(["MISS", "STALE-HIT"]);
118
+ expect(calls).toBe(2); // background refresh ran
119
+ });
120
+
121
+ it("past the SIE window the entry is dropped and refetched (MISS again)", async () => {
122
+ vi.useFakeTimers();
123
+ const cache = createFetchCache({ provider: "vtex", ...KNOBS });
124
+ const doFetch = () => Promise.resolve(jsonResponse({ ok: 1 }));
125
+
126
+ await cache.fetchWithCache("k", doFetch); // MISS
127
+ vi.setSystemTime(Date.now() + 1000 + 10_000 + 1); // past fresh + SIE
128
+ await cache.fetchWithCache("k", doFetch); // too stale -> cold MISS
129
+
130
+ expect(statuses()).toEqual(["MISS", "MISS"]);
131
+ });
132
+
133
+ it("isolates providers — distinct instances never share entries", async () => {
134
+ const vtex = createFetchCache({ provider: "vtex", ...KNOBS });
135
+ const magento = createFetchCache({ provider: "magento", ...KNOBS });
136
+ await vtex.fetchWithCache("k", () => Promise.resolve(jsonResponse({ p: "vtex" })));
137
+ const fromMagento = await magento.fetchWithCache("k", () =>
138
+ Promise.resolve(jsonResponse({ p: "magento" })),
139
+ );
140
+ expect(fromMagento).toEqual({ p: "magento" }); // magento MISS, not vtex's entry
141
+ });
142
+ });
143
+
144
+ describe("createFetchCache — telemetry labels", () => {
145
+ it("stamps layer=swr and provider=<provider> on every emit (profile stays unset)", async () => {
146
+ const cache = createFetchCache({ provider: "magento", ...KNOBS });
147
+ await cache.fetchWithCache("k", () => Promise.resolve(jsonResponse({})));
148
+ const emitted = counters.filter((c) => c.name === "deco.cache.requests");
149
+ expect(emitted.length).toBe(1);
150
+ expect(emitted[0]?.labels?.["layer"]).toBe("swr");
151
+ expect(emitted[0]?.labels?.["provider"]).toBe("magento");
152
+ // profile is reserved for the edge layer's page-type — must NOT be set here.
153
+ expect(emitted[0]?.labels?.["profile"]).toBeUndefined();
154
+ expect(emitted[0]?.labels?.["status"]).toBe("MISS");
155
+ });
156
+
157
+ it("is a no-op with no meter configured (never throws)", async () => {
158
+ configureMeter({ counterInc: () => {} });
159
+ const cache = createFetchCache({ provider: "vtex", ...KNOBS });
160
+ await expect(
161
+ cache.fetchWithCache("k", () => Promise.resolve(jsonResponse({}))),
162
+ ).resolves.toEqual({});
163
+ });
164
+ });
@@ -0,0 +1,266 @@
1
+ /**
2
+ * Shared SWR in-memory fetch cache for commerce API responses.
3
+ *
4
+ * Provides in-flight deduplication + stale-while-revalidate + stale-if-error
5
+ * for server-side GET requests, keyed by full URL string. Ported from the
6
+ * VTEX app's `fetchCache.ts` (itself inspired by deco-cx/deco
7
+ * `runtime/fetch/fetchCache.ts`) and generalized so every commerce app
8
+ * (VTEX, Magento, Shopify, ...) shares ONE implementation instead of each
9
+ * rolling its own uninstrumented copy.
10
+ *
11
+ * **Why this lives in `@decocms/blocks/sdk` (the chokepoint):** cache hit/miss
12
+ * telemetry only becomes automatic when a single shared util emits it. Each
13
+ * cache instance calls {@link recordCacheMetric} with `layer: "swr"` on every
14
+ * decision branch, so any app that routes upstream GETs through
15
+ * {@link createFetchCache} lands hit/miss/stale in ClickHouse for free — no
16
+ * per-app wiring. The metric is a no-op when no meter is configured (dev,
17
+ * tests), so there is zero cost off the instrumented path.
18
+ *
19
+ * **Provider isolation:** every {@link createFetchCache} call owns its own
20
+ * `store`/`inflight` Maps, so VTEX / Magento / Shopify caches never collide in
21
+ * a shared isolate. The `provider` string rides on the `provider` metric label so
22
+ * dashboards can slice hit ratio per backend.
23
+ *
24
+ * Note on why the SWR cache — not `createInstrumentedFetch` — must emit the
25
+ * hit/miss counter: on a HIT the cache returns BEFORE invoking `doFetch`, so
26
+ * the instrumented fetch (and its `http.client.request.duration` histogram)
27
+ * never runs. Hit ratio can only be measured here.
28
+ */
29
+
30
+ import { type CacheDecision, recordCacheMetric } from "../middleware/observability";
31
+
32
+ /** Fresh-TTL (ms) by HTTP status class. See {@link FetchCacheConfig}. */
33
+ export interface FreshTtlByStatus {
34
+ /** 2xx — how long a success body stays fresh. */
35
+ success: number;
36
+ /** 404 — how long a not-found stays fresh (kept short on purpose). */
37
+ notFound: number;
38
+ /** 5xx — a server error is never treated as a good cache hit (use 0). */
39
+ serverError: number;
40
+ }
41
+
42
+ export interface FetchCacheConfig {
43
+ /**
44
+ * Backend name — `vtex` / `magento` / `shopify` / ... Emitted as
45
+ * `deco.cache.profile` on the `deco.cache.requests` metric so dashboards can
46
+ * break down SWR hit ratio per provider. Also namespaces the instance's
47
+ * in-memory Maps (each instance is already isolated, but this keeps the
48
+ * telemetry label meaningful).
49
+ */
50
+ provider: string;
51
+ /** Max distinct cache keys kept in memory; oldest `createdAt` evicted first. */
52
+ maxEntries: number;
53
+ /** Fresh-TTL (ms) by status class. */
54
+ freshTtlMs: FreshTtlByStatus;
55
+ /**
56
+ * Stale-if-error window (ms): how long PAST the freshness TTL a last-good 2xx
57
+ * entry may still be served while the origin is failing. Set to 0 to disable
58
+ * stale serving.
59
+ */
60
+ staleIfErrorMs: number;
61
+ /**
62
+ * Inflight-slot backstop timeout (ms). Bounds how long a single hung
63
+ * `fetch()` can hold a dedup entry alive. MUST stay ABOVE any per-call
64
+ * timeout the underlying fetch owns — this is a last-resort leak backstop,
65
+ * not the primary timeout.
66
+ */
67
+ inflightBackstopMs: number;
68
+ }
69
+
70
+ interface CacheEntry {
71
+ body: unknown;
72
+ status: number;
73
+ createdAt: number;
74
+ refreshing: boolean;
75
+ }
76
+
77
+ export interface FetchCacheOptions {
78
+ /** Custom fresh TTL in ms. If provided, overrides status-based TTL. */
79
+ ttl?: number;
80
+ /**
81
+ * Stale-if-error window in ms for this call. Defaults to the instance's
82
+ * {@link FetchCacheConfig.staleIfErrorMs}. Set to 0 to disable stale serving.
83
+ */
84
+ sieMs?: number;
85
+ }
86
+
87
+ export interface FetchCache {
88
+ /**
89
+ * Wrap a GET fetch call with SWR caching + in-flight dedup. Returns the
90
+ * parsed JSON body, or `null` for cacheable non-2xx responses (e.g. 404).
91
+ * 5xx responses throw so the caller can handle them explicitly.
92
+ */
93
+ fetchWithCache<T>(
94
+ cacheKey: string,
95
+ doFetch: () => Promise<Response>,
96
+ opts?: FetchCacheOptions,
97
+ ): Promise<T | null>;
98
+ /** Drop all entries + inflight slots (mainly for tests). */
99
+ clear(): void;
100
+ /** Diagnostics: current entry / inflight counts. */
101
+ getStats(): { entries: number; inflight: number };
102
+ }
103
+
104
+ /**
105
+ * Race a Promise against a timeout so callers' `.finally()` always runs.
106
+ * Critical for evicting the inflight Map entry when a `fetch()` hangs —
107
+ * without this, a never-settling Promise leaks the Map slot forever and every
108
+ * subsequent request for the same key joins the zombie Promise.
109
+ */
110
+ function withTimeout<T>(work: Promise<T>, ms: number, label: string): Promise<T> {
111
+ let timer: ReturnType<typeof setTimeout> | undefined;
112
+ const timeout = new Promise<never>((_, reject) => {
113
+ timer = setTimeout(() => {
114
+ reject(new Error(`${label} timed out after ${ms}ms`));
115
+ }, ms);
116
+ });
117
+ return Promise.race([work, timeout]).finally(() => {
118
+ clearTimeout(timer);
119
+ });
120
+ }
121
+
122
+ /**
123
+ * Create an isolated SWR fetch cache instance for one provider.
124
+ *
125
+ * @example
126
+ * const cache = createFetchCache({ provider: "vtex", ...VTEX_KNOBS });
127
+ * const body = await cache.fetchWithCache(url, () => fetch(url));
128
+ */
129
+ export function createFetchCache(config: FetchCacheConfig): FetchCache {
130
+ const { provider, maxEntries, freshTtlMs, staleIfErrorMs, inflightBackstopMs } = config;
131
+
132
+ const store = new Map<string, CacheEntry>();
133
+ const inflight = new Map<string, Promise<CacheEntry>>();
134
+
135
+ function freshTtlForStatus(status: number): number {
136
+ if (status >= 200 && status < 300) return freshTtlMs.success;
137
+ if (status === 404) return freshTtlMs.notFound;
138
+ if (status >= 500) return freshTtlMs.serverError;
139
+ return 0;
140
+ }
141
+
142
+ function evictIfNeeded() {
143
+ // ponytail: Map preserves insertion order ≈ createdAt order — holds as long
144
+ // as updates always re-insert via store.set(key, fresh), never mutate in-place.
145
+ while (store.size > maxEntries) store.delete(store.keys().next().value!);
146
+ }
147
+
148
+ // Single attempt on purpose — the underlying fetch (resilience layer) owns
149
+ // retries/backoff/breaker. Retrying here would multiply upstream calls and
150
+ // re-enter the breaker too fast.
151
+ async function executeFetch(url: string, doFetch: () => Promise<Response>): Promise<CacheEntry> {
152
+ const response = await doFetch();
153
+ if (response.status >= 500) {
154
+ throw new Error(`fetchWithCache: ${response.status} ${response.statusText} — ${url}`);
155
+ }
156
+ const body = response.ok ? await response.json() : null;
157
+ return { body, status: response.status, createdAt: Date.now(), refreshing: false };
158
+ }
159
+
160
+ function emit(decision: CacheDecision) {
161
+ // `hit` is true for every branch that avoids a foreground upstream fetch
162
+ // (fresh, dedup-join, stale-serve); MISS is the only non-hit. The backend
163
+ // rides on the dedicated `provider` label (NOT `profile`, which the edge
164
+ // layer reserves for page-type) so dashboards don't blend the two.
165
+ recordCacheMetric(decision !== "MISS", undefined, decision, "swr", provider);
166
+ }
167
+
168
+ function fetchWithCache<T>(
169
+ cacheKey: string,
170
+ doFetch: () => Promise<Response>,
171
+ opts?: FetchCacheOptions,
172
+ ): Promise<T | null> {
173
+ const now = Date.now();
174
+ const entry = store.get(cacheKey);
175
+
176
+ if (entry) {
177
+ const maxAge = opts?.ttl ?? freshTtlForStatus(entry.status);
178
+ const age = now - entry.createdAt;
179
+ const isStale = age > maxAge;
180
+
181
+ if (!isStale) {
182
+ emit("HIT");
183
+ return Promise.resolve(entry.body as T | null);
184
+ }
185
+
186
+ // Beyond the stale-if-error window the last-good entry is too old to keep
187
+ // serving during an outage: drop it and fall through to a foreground
188
+ // refetch (cold path, counted as MISS there). On a healthy origin this is
189
+ // never reached because the background refresh keeps resetting createdAt.
190
+ const sieMs = opts?.sieMs ?? staleIfErrorMs;
191
+ const tooStale = age > maxAge + sieMs;
192
+
193
+ if (!tooStale) {
194
+ if (!entry.refreshing) {
195
+ entry.refreshing = true;
196
+ // Background refresh: no retry — stale data is already being served.
197
+ withTimeout(
198
+ executeFetch(cacheKey, doFetch),
199
+ inflightBackstopMs,
200
+ `fetchCache stale-refresh ${cacheKey}`,
201
+ )
202
+ .then((fresh) => {
203
+ const ttl = opts?.ttl ?? freshTtlForStatus(fresh.status);
204
+ const existingWasSuccess = entry.status >= 200 && entry.status < 300;
205
+ const freshIsError = fresh.status >= 400;
206
+ const wouldDowngrade = existingWasSuccess && freshIsError;
207
+ if (ttl > 0 && !wouldDowngrade) {
208
+ store.set(cacheKey, fresh);
209
+ } else {
210
+ entry.refreshing = false;
211
+ }
212
+ })
213
+ .catch(() => {
214
+ entry.refreshing = false;
215
+ });
216
+ }
217
+ // Serve last-good within the SIE window: this is the stale-while-
218
+ // revalidate serve.
219
+ emit("STALE-HIT");
220
+ return Promise.resolve(entry.body as T | null);
221
+ }
222
+
223
+ store.delete(cacheKey);
224
+ // fall through to the cold path below with the dead entry removed
225
+ }
226
+
227
+ const existing = inflight.get(cacheKey);
228
+ if (existing) {
229
+ // Joined an in-flight upstream fetch — no upstream call of our own.
230
+ emit("HIT");
231
+ return existing.then((e) => e.body as T | null);
232
+ }
233
+
234
+ // Cold miss: run the loader. Wrap with a timeout so `.finally()` always
235
+ // evicts the inflight slot even if `executeFetch` never settles.
236
+ emit("MISS");
237
+ const promise = withTimeout(
238
+ executeFetch(cacheKey, doFetch),
239
+ inflightBackstopMs,
240
+ `fetchCache ${cacheKey}`,
241
+ )
242
+ .then((fresh) => {
243
+ const ttl = opts?.ttl ?? freshTtlForStatus(fresh.status);
244
+ if (ttl > 0) {
245
+ store.set(cacheKey, fresh);
246
+ evictIfNeeded();
247
+ }
248
+ return fresh;
249
+ })
250
+ .finally(() => inflight.delete(cacheKey));
251
+
252
+ inflight.set(cacheKey, promise);
253
+ return promise.then((e) => e.body as T | null);
254
+ }
255
+
256
+ return {
257
+ fetchWithCache,
258
+ clear() {
259
+ store.clear();
260
+ inflight.clear();
261
+ },
262
+ getStats() {
263
+ return { entries: store.size, inflight: inflight.size };
264
+ },
265
+ };
266
+ }
@@ -118,6 +118,23 @@ export function canonicalUrl(request: Request, baseUrl?: string): string {
118
118
  return `${origin}${path}`;
119
119
  }
120
120
 
121
+ /**
122
+ * Extract the pathname from an absolute or relative URL, stripping query
123
+ * string and hash. Falls back to manual slicing when `new URL()` would
124
+ * throw (relative URL without a base). Used by operation routers that
125
+ * need to match against URL structure without caring about the host.
126
+ */
127
+ export function extractPathname(url: string): string {
128
+ try {
129
+ return new URL(url).pathname;
130
+ } catch {
131
+ const qs = url.indexOf("?");
132
+ const hash = url.indexOf("#");
133
+ const end = [qs, hash].filter((i) => i >= 0).sort((a, b) => a - b)[0];
134
+ return end === undefined ? url : url.slice(0, end);
135
+ }
136
+ }
137
+
121
138
  /**
122
139
  * Check if a URL has any tracking/UTM parameters.
123
140
  */