@decocms/blocks 7.31.7 → 7.32.1
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 +2 -1
- package/src/middleware/observability.test.ts +26 -10
- package/src/middleware/observability.ts +32 -13
- package/src/sdk/fetchCache.test.ts +164 -0
- package/src/sdk/fetchCache.ts +266 -0
- package/src/sdk/urlUtils.ts +17 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decocms/blocks",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.32.1",
|
|
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
|
-
"
|
|
160
|
-
"
|
|
161
|
-
"
|
|
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?.["
|
|
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
|
-
"
|
|
183
|
-
"
|
|
182
|
+
"status": "HIT",
|
|
183
|
+
"profile": "static",
|
|
184
184
|
});
|
|
185
185
|
});
|
|
186
186
|
|
|
187
|
-
it("distinguishes cachedLoader vs edge vs
|
|
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, "
|
|
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
|
-
|
|
195
|
-
|
|
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
|
|
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 /
|
|
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
|
-
* - `
|
|
461
|
-
* (intelligent-search,
|
|
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" | "
|
|
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 /
|
|
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
|
|
499
|
-
//
|
|
500
|
-
//
|
|
501
|
-
//
|
|
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
|
-
|
|
521
|
+
status: decision ?? (hit ? "HIT" : "MISS"),
|
|
504
522
|
};
|
|
505
|
-
if (profile) labels["
|
|
506
|
-
if (layer) labels["
|
|
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
|
+
}
|
package/src/sdk/urlUtils.ts
CHANGED
|
@@ -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
|
*/
|