@decocms/tanstack 8.0.0 → 8.1.0-next.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.
Files changed (49) hide show
  1. package/package.json +12 -5
  2. package/src/__conformance__/observability.test.ts +221 -0
  3. package/src/hooks/CdnSegmentMarker.tsx +38 -0
  4. package/src/hooks/DecoPageRenderer.tsx +91 -29
  5. package/src/hooks/DecoRootLayout.tsx +139 -58
  6. package/src/hooks/DraftPreviewIndicator.test.tsx +60 -0
  7. package/src/hooks/DraftPreviewIndicator.tsx +64 -0
  8. package/src/hooks/NavigationProgress.test.tsx +58 -0
  9. package/src/hooks/NavigationProgress.tsx +45 -3
  10. package/src/hooks/PreviewProviders.test.tsx +42 -0
  11. package/src/hooks/PreviewProviders.tsx +21 -2
  12. package/src/hooks/deferredObserverTiming.test.tsx +91 -0
  13. package/src/hooks/index.ts +1 -0
  14. package/src/index.ts +30 -13
  15. package/src/routes/cmsRoute.test.ts +168 -1
  16. package/src/routes/cmsRoute.ts +148 -39
  17. package/src/routes/pageUrl.test.ts +6 -6
  18. package/src/routes/pageUrl.ts +5 -4
  19. package/src/routes/withSiteGlobals.test.ts +37 -0
  20. package/src/routes/withSiteGlobals.ts +45 -24
  21. package/src/sdk/backgroundTasks.test.ts +68 -0
  22. package/src/sdk/backgroundTasks.ts +38 -0
  23. package/src/sdk/cdnSegment.test.ts +67 -0
  24. package/src/sdk/cdnSegment.ts +71 -0
  25. package/src/sdk/deferredSectionLoader.test.ts +1 -1
  26. package/src/sdk/draft.test.ts +341 -0
  27. package/src/sdk/draft.ts +277 -0
  28. package/src/sdk/draftShared.ts +16 -0
  29. package/src/sdk/kvHydration.test.ts +156 -0
  30. package/src/sdk/kvHydration.ts +82 -12
  31. package/src/sdk/kvRedirects.test.ts +132 -0
  32. package/src/sdk/kvRedirects.ts +125 -0
  33. package/src/sdk/router.ts +70 -61
  34. package/src/sdk/serverFnFetch.ts +59 -0
  35. package/src/sdk/speculationRules.test.ts +82 -0
  36. package/src/sdk/speculationRules.ts +136 -0
  37. package/src/sdk/startEntry.ts +31 -0
  38. package/src/sdk/workerEntry.cacheStorage.test.ts +147 -0
  39. package/src/sdk/workerEntry.test.ts +554 -16
  40. package/src/sdk/workerEntry.trackingCache.test.ts +328 -0
  41. package/src/sdk/workerEntry.ts +915 -68
  42. package/src/v8/__conformance__/delivery.test.ts +100 -0
  43. package/src/v8/binding.ts +70 -0
  44. package/src/v8/kvLoader.bundle.test.ts +42 -0
  45. package/src/v8/kvLoader.test.ts +74 -0
  46. package/src/v8/kvLoader.ts +43 -0
  47. package/src/vite/plugin.js +180 -8
  48. package/src/vite/plugin.test.js +126 -1
  49. package/src/vite/plugin.test.ts +125 -1
@@ -213,3 +213,159 @@ describe("maybePollRevision", () => {
213
213
  expect(getCalls()).toBe(callsAfterHydrate + 1);
214
214
  });
215
215
  });
216
+
217
+ // ---------------------------------------------------------------------------
218
+ // Bundles that ship no decofile (`decoVitePlugin({ fastDeploy: true })`)
219
+ //
220
+ // With the decofile stubbed out of the server bundle, KV is the ONLY source of
221
+ // content. The "warn and serve bundled" recovery would serve an EMPTY site —
222
+ // 200s with no pages, which get edge-cached and outlive the KV failure that
223
+ // caused them. These assert it fails loudly instead.
224
+ //
225
+ // The signal is the build-time `__DECO_BLOCKS_STUBBED__` define, NOT an empty
226
+ // in-memory map: draft overrides can make a stubbed bundle look populated, and
227
+ // a legitimately-empty decofile would look stubbed. Both are covered below.
228
+ // ---------------------------------------------------------------------------
229
+ declare global {
230
+ // eslint-disable-next-line no-var
231
+ var __DECO_BLOCKS_STUBBED__: boolean | undefined;
232
+ }
233
+
234
+ /** Simulates a build made with `fastDeploy: true`. */
235
+ function withStubbedBundle() {
236
+ globalThis.__DECO_BLOCKS_STUBBED__ = true;
237
+ }
238
+
239
+ describe("ensureBlocksHydrated when the bundle ships no decofile", () => {
240
+ beforeEach(() => {
241
+ globalThis.__DECO_BLOCKS_STUBBED__ = undefined;
242
+ });
243
+
244
+ it("throws when the snapshot is missing instead of serving an empty site", async () => {
245
+ withStubbedBundle();
246
+ setBlocks({});
247
+ const { kv } = makeKV(); // no decofile:<id> seeded
248
+ const env = { DECO_KV: kv, DECO_FAST_DEPLOY: "1", DECO_DEPLOYMENT_ID: ID };
249
+
250
+ await expect(ensureBlocksHydrated(env)).rejects.toThrow(/not found and this bundle ships no/);
251
+ });
252
+
253
+ it("throws when fast-deploy is off at runtime instead of serving {}", async () => {
254
+ withStubbedBundle();
255
+ setBlocks({});
256
+ const { kv } = makeKV();
257
+
258
+ await expect(ensureBlocksHydrated({})).rejects.toThrow(/fast-deploy is off/);
259
+ await expect(ensureBlocksHydrated({ DECO_KV: kv })).rejects.toThrow(/fast-deploy is off/);
260
+ });
261
+
262
+ it("throws when the snapshot exists but is empty", async () => {
263
+ // A seed that wrote `{}` would otherwise take the success branch, latch,
264
+ // and serve the empty edge-cached site this guard exists to prevent.
265
+ withStubbedBundle();
266
+ setBlocks({});
267
+ const { kv } = makeKV({ [SNAP]: "{}", [REV]: computeRevision({}) });
268
+
269
+ await expect(
270
+ ensureBlocksHydrated({ DECO_KV: kv, DECO_FAST_DEPLOY: "1", DECO_DEPLOYMENT_ID: ID }),
271
+ ).rejects.toThrow(/is empty and this bundle ships no/);
272
+ });
273
+
274
+ it("throws when KV itself fails", async () => {
275
+ withStubbedBundle();
276
+ setBlocks({});
277
+ const kv = {
278
+ get: () => Promise.reject(new Error("KV unreachable")),
279
+ put: () => Promise.resolve(),
280
+ delete: () => Promise.resolve(),
281
+ } as unknown as KVNamespace;
282
+
283
+ await expect(
284
+ ensureBlocksHydrated({ DECO_KV: kv, DECO_FAST_DEPLOY: "1", DECO_DEPLOYMENT_ID: ID }),
285
+ ).rejects.toThrow(/KV unreachable/);
286
+ });
287
+
288
+ it("throws when no deployment id resolves", async () => {
289
+ withStubbedBundle();
290
+ setBlocks({});
291
+ const { kv } = makeKV();
292
+
293
+ await expect(ensureBlocksHydrated({ DECO_KV: kv, DECO_FAST_DEPLOY: "1" })).rejects.toThrow(
294
+ /no deployment id/,
295
+ );
296
+ });
297
+
298
+ it("is not rescued by a populated in-memory map (draft-preview overrides)", async () => {
299
+ // `bindRequestDraft` composes overrides into loadBlocks() BEFORE hydration.
300
+ // Inferring "has a fallback" from the map would latch here and then serve
301
+ // the empty base decofile for the isolate's life.
302
+ withStubbedBundle();
303
+ setBlocks({ "pages-Draft": { name: "override" } });
304
+ const { kv } = makeKV();
305
+
306
+ await expect(
307
+ ensureBlocksHydrated({ DECO_KV: kv, DECO_FAST_DEPLOY: "1", DECO_DEPLOYMENT_ID: ID }),
308
+ ).rejects.toThrow(/not found and this bundle ships no/);
309
+ });
310
+
311
+ it("does not latch hydration on the fatal path, so the next request retries", async () => {
312
+ withStubbedBundle();
313
+ setBlocks({});
314
+ const blocks = { Site: { name: "from-kv" } };
315
+ const store = new Map<string, string>();
316
+ const kv: KVNamespace = {
317
+ get: (k) => Promise.resolve(store.get(k) ?? null),
318
+ put: (k, v) => {
319
+ store.set(k, v);
320
+ return Promise.resolve();
321
+ },
322
+ delete: (k) => {
323
+ store.delete(k);
324
+ return Promise.resolve();
325
+ },
326
+ };
327
+ const env = { DECO_KV: kv, DECO_FAST_DEPLOY: "1", DECO_DEPLOYMENT_ID: ID };
328
+
329
+ // First request: snapshot absent → fatal.
330
+ await expect(ensureBlocksHydrated(env)).rejects.toThrow();
331
+
332
+ // Operator seeds it; the isolate must pick it up rather than stay empty.
333
+ store.set(SNAP, JSON.stringify(blocks));
334
+ store.set(REV, computeRevision(blocks));
335
+
336
+ await expect(ensureBlocksHydrated(env)).resolves.toBeUndefined();
337
+ expect(loadBlocks()).toEqual(blocks);
338
+ });
339
+ });
340
+
341
+ describe("ensureBlocksHydrated when the bundle DOES ship a decofile (default)", () => {
342
+ beforeEach(() => {
343
+ globalThis.__DECO_BLOCKS_STUBBED__ = undefined;
344
+ });
345
+
346
+ it("warns and serves bundled when KV fails", async () => {
347
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
348
+ const kv = {
349
+ get: () => Promise.reject(new Error("KV unreachable")),
350
+ put: () => Promise.resolve(),
351
+ delete: () => Promise.resolve(),
352
+ } as unknown as KVNamespace;
353
+
354
+ await expect(
355
+ ensureBlocksHydrated({ DECO_KV: kv, DECO_FAST_DEPLOY: "1", DECO_DEPLOYMENT_ID: ID }),
356
+ ).resolves.toBeUndefined();
357
+ expect(loadBlocks()).toEqual(BUNDLED);
358
+ expect(warn).toHaveBeenCalled();
359
+ });
360
+
361
+ it("does not 5xx a site whose bundled decofile is legitimately empty", async () => {
362
+ // New site / missing blocks.gen.json. Before the build-time flag this
363
+ // looked identical to a stubbed bundle and started failing requests.
364
+ setBlocks({});
365
+ const { kv } = makeKV(); // no snapshot seeded either
366
+
367
+ await expect(
368
+ ensureBlocksHydrated({ DECO_KV: kv, DECO_FAST_DEPLOY: "1", DECO_DEPLOYMENT_ID: ID }),
369
+ ).resolves.toBeUndefined();
370
+ });
371
+ });
@@ -24,7 +24,7 @@
24
24
  * `DecofileProvider` pattern from the deco-cx/deco Fresh runtime.
25
25
  */
26
26
 
27
- import { getDeploymentId, getRevision, setBlocks } from "@decocms/blocks/cms";
27
+ import { DEPLOYMENT_ID_ENV, getDeploymentId, getRevision, setBlocks } from "@decocms/blocks/cms";
28
28
  import { KVBlockSource } from "../cms/kvBlockSource";
29
29
  import type { KVNamespace } from "@decocms/blocks/cms";
30
30
  import { setSpanAttribute } from "@decocms/blocks/sdk/observability";
@@ -95,15 +95,59 @@ export function getFastDeployKV(env: Env): KVNamespace | null {
95
95
  return getKV(env);
96
96
  }
97
97
 
98
+ // Build-time constant injected by `decoVitePlugin({ fastDeploy: true })`: true
99
+ // when `blocks.gen` was stubbed out of THIS server bundle. Declared with a
100
+ // `typeof` guard so it's inert wherever the define isn't applied (this
101
+ // package's own tsc output, the Next.js build, tests).
102
+ declare const __DECO_BLOCKS_STUBBED__: boolean | undefined;
103
+
104
+ /**
105
+ * Does this bundle ship no decofile of its own?
106
+ *
107
+ * When true, KV is the ONLY source of content, so the "warn and serve bundled"
108
+ * recovery below would serve an EMPTY site — 200s with no pages, which then get
109
+ * edge-cached and outlive the KV failure that caused them. Those cases fail
110
+ * loudly instead.
111
+ *
112
+ * This is deliberately a BUILD-time answer rather than "is the in-memory map
113
+ * empty". The runtime check is not equivalent in three ways:
114
+ * - `loadBlocks()` composes request-scoped draft/preview overrides, and
115
+ * `bindRequestDraft` runs before hydration — a draft-preview first request
116
+ * would make a stubbed bundle look populated, latch, and then serve the
117
+ * empty base decofile for the isolate's life.
118
+ * - A legitimately-empty decofile (new site, missing `blocks.gen.json`) would
119
+ * look stubbed, turning a previously-warning default site into 5xx.
120
+ * - An empty snapshot in KV can't be distinguished from a healthy one.
121
+ */
122
+ function bundleHasNoDecofile(): boolean {
123
+ return typeof __DECO_BLOCKS_STUBBED__ !== "undefined" && __DECO_BLOCKS_STUBBED__ === true;
124
+ }
125
+
98
126
  /**
99
127
  * Cold-start hydration. Awaits the KV snapshot once per isolate and swaps it
100
128
  * into the in-memory block map. Concurrent first requests share a single
101
- * in-flight load. On any error (KV outage, bad JSON) we keep the bundled
102
- * snapshot and mark hydration done — recovery happens via `maybePollRevision`
103
- * once KV is reachable again (its revision will differ from the bundled one).
129
+ * in-flight load.
130
+ *
131
+ * On failure (KV outage, bad JSON, snapshot not seeded) the behavior depends on
132
+ * whether a bundled snapshot exists:
133
+ *
134
+ * - Bundled present (default): warn, serve bundled, mark hydration done —
135
+ * `maybePollRevision` recovers once KV is reachable again.
136
+ * - Bundled stubbed out (`fastDeploy`): throw. There is no content to serve, so
137
+ * a 5xx is the only honest answer; hydration is left unlatched so the next
138
+ * request retries instead of pinning the isolate to an empty decofile.
104
139
  */
105
140
  export function ensureBlocksHydrated(env: Env, _ctx?: ExecutionContextLike): Promise<void> {
106
- if (!isFastDeployEnabled(env)) return Promise.resolve();
141
+ if (!isFastDeployEnabled(env)) {
142
+ // A stubbed bundle with fast-deploy off would serve an empty 200 the edge caches.
143
+ return bundleHasNoDecofile()
144
+ ? Promise.reject(
145
+ new Error(
146
+ `[CMS/KV] this bundle ships no decofile but fast-deploy is off (set ${FAST_DEPLOY_ENV}=1 and bind DECO_KV)`,
147
+ ),
148
+ )
149
+ : Promise.resolve();
150
+ }
107
151
  if (G.__deco!.kvHydrated) return Promise.resolve();
108
152
  if (G.__deco!.kvHydration) return G.__deco!.kvHydration;
109
153
 
@@ -114,28 +158,54 @@ export function ensureBlocksHydrated(env: Env, _ctx?: ExecutionContextLike): Pro
114
158
  // content). Never read another deployment's key.
115
159
  const deploymentId = getDeploymentId(env);
116
160
  if (!deploymentId) {
161
+ if (bundleHasNoDecofile()) {
162
+ return Promise.reject(
163
+ new Error(
164
+ `[CMS/KV] no deployment id (set ${DEPLOYMENT_ID_ENV}) and this bundle ships no decofile`,
165
+ ),
166
+ );
167
+ }
117
168
  setSpanAttribute("deco.block.source", "bundled");
118
169
  G.__deco!.kvHydrated = true;
119
170
  return Promise.resolve();
120
171
  }
121
172
 
122
173
  const load = (async () => {
174
+ // Held rather than rethrown inline so the `finally` below can decide
175
+ // whether to latch — latching on a fatal path would serve an empty
176
+ // decofile for the isolate's entire life.
177
+ let fatal: Error | null = null;
123
178
  try {
124
179
  const snapshot = await new KVBlockSource(kv, deploymentId).loadSnapshot();
125
- if (snapshot) {
126
- setBlocks(snapshot.blocks);
180
+ // An empty snapshot is treated as a failure, not a success: with no
181
+ // bundled decofile it would otherwise latch and serve exactly the empty,
182
+ // edge-cached site this guard exists to prevent. A site legitimately has
183
+ // blocks; `{}` means the seed wrote nothing.
184
+ const empty = !snapshot || Object.keys(snapshot.blocks).length === 0;
185
+ if (!empty) {
186
+ setBlocks(snapshot!.blocks);
127
187
  setSpanAttribute("deco.block.source", "kv");
128
- } else {
188
+ } else if (!bundleHasNoDecofile()) {
129
189
  setSpanAttribute("deco.block.source", "bundled");
190
+ } else {
191
+ fatal = new Error(
192
+ `[CMS/KV] decofile:${deploymentId} ${snapshot ? "is empty" : "not found"} and this ` +
193
+ `bundle ships no decofile — seed it before activating this version`,
194
+ );
130
195
  }
131
196
  } catch (e) {
132
- // Non-fatal: serve the bundled snapshot. The poll loop recovers later.
133
- console.warn("[CMS/KV] cold-start hydration failed, using bundled snapshot:", e);
134
- setSpanAttribute("deco.block.source", "bundled");
197
+ if (!bundleHasNoDecofile()) {
198
+ // Non-fatal: serve the bundled snapshot. The poll loop recovers later.
199
+ console.warn("[CMS/KV] cold-start hydration failed, using bundled snapshot:", e);
200
+ setSpanAttribute("deco.block.source", "bundled");
201
+ } else {
202
+ fatal = e instanceof Error ? e : new Error(String(e));
203
+ }
135
204
  } finally {
136
- G.__deco!.kvHydrated = true;
205
+ G.__deco!.kvHydrated = !fatal;
137
206
  G.__deco!.kvHydration = null;
138
207
  }
208
+ if (fatal) throw fatal;
139
209
  })();
140
210
 
141
211
  G.__deco!.kvHydration = load;
@@ -0,0 +1,132 @@
1
+ import {
2
+ loadRedirects,
3
+ matchExactRedirect,
4
+ matchPatternRedirect,
5
+ } from "@decocms/blocks/sdk/redirects";
6
+ import { beforeEach, describe, expect, it, vi } from "vitest";
7
+ import { clearRedirectCache, lookupExactRedirect } from "./kvRedirects";
8
+
9
+ /** Minimal KV binding that records every read. */
10
+ function makeKV(values: Record<string, string> = {}) {
11
+ const get = vi.fn((key: string) => Promise.resolve(values[key] ?? null));
12
+ return { get, put: vi.fn(), delete: vi.fn() };
13
+ }
14
+
15
+ const ID = "sha-abc";
16
+ const enabled = (kv: unknown) => ({
17
+ DECO_FAST_DEPLOY: "1",
18
+ DECO_KV: kv,
19
+ DECO_DEPLOYMENT_ID: ID,
20
+ });
21
+
22
+ beforeEach(() => {
23
+ clearRedirectCache();
24
+ vi.useRealTimers();
25
+ });
26
+
27
+ describe("lookupExactRedirect", () => {
28
+ it("reads redirect:<id>:<path> and returns the rule", async () => {
29
+ const kv = makeKV({ [`redirect:${ID}:/old`]: '{"to":"/new","status":301}' });
30
+ await expect(lookupExactRedirect(enabled(kv), "/old")).resolves.toEqual({
31
+ from: "/old",
32
+ to: "/new",
33
+ status: 301,
34
+ });
35
+ });
36
+
37
+ it("returns null for a missing key", async () => {
38
+ await expect(lookupExactRedirect(enabled(makeKV()), "/nope")).resolves.toBeNull();
39
+ });
40
+
41
+ it("caches a HIT so the same path costs one KV read", async () => {
42
+ const kv = makeKV({ [`redirect:${ID}:/old`]: '{"to":"/new","status":302}' });
43
+ await lookupExactRedirect(enabled(kv), "/old");
44
+ await lookupExactRedirect(enabled(kv), "/old");
45
+ expect(kv.get).toHaveBeenCalledTimes(1);
46
+ });
47
+
48
+ it("caches a MISS too — that is the common case on every request", async () => {
49
+ const kv = makeKV();
50
+ await lookupExactRedirect(enabled(kv), "/nope");
51
+ await lookupExactRedirect(enabled(kv), "/nope");
52
+ expect(kv.get).toHaveBeenCalledTimes(1);
53
+ });
54
+
55
+ it("re-reads after the TTL, which is how a redirect edit propagates", async () => {
56
+ // The rules live outside the decofile, so a redirect-only sync leaves the
57
+ // revision identical and the poller has nothing to react to. The TTL is
58
+ // the only propagation path.
59
+ const kv = makeKV();
60
+ await lookupExactRedirect(enabled(kv), "/soon");
61
+ vi.useFakeTimers();
62
+ vi.setSystemTime(Date.now() + 61_000);
63
+ await lookupExactRedirect(enabled(kv), "/soon");
64
+ expect(kv.get).toHaveBeenCalledTimes(2);
65
+ });
66
+
67
+ it("is inert when fast-deploy is off", async () => {
68
+ const kv = makeKV({ [`redirect:${ID}:/old`]: '{"to":"/new","status":301}' });
69
+ const env = { DECO_KV: kv, DECO_DEPLOYMENT_ID: ID }; // no DECO_FAST_DEPLOY
70
+ await expect(lookupExactRedirect(env, "/old")).resolves.toBeNull();
71
+ expect(kv.get).not.toHaveBeenCalled();
72
+ });
73
+
74
+ it("never reads another deployment's keys when the id is unresolvable", async () => {
75
+ const kv = makeKV();
76
+ await lookupExactRedirect({ DECO_FAST_DEPLOY: "1", DECO_KV: kv }, "/old");
77
+ expect(kv.get).not.toHaveBeenCalled();
78
+ });
79
+
80
+ it("treats a KV failure as no-redirect, never a throw", async () => {
81
+ // A KV outage must not 5xx a page that would otherwise render fine.
82
+ const kv = { get: vi.fn(() => Promise.reject(new Error("KV down"))) };
83
+ await expect(lookupExactRedirect(enabled(kv), "/old")).resolves.toBeNull();
84
+ });
85
+
86
+ it("treats a malformed value as no-redirect", async () => {
87
+ const kv = makeKV({ [`redirect:${ID}:/old`]: "not json" });
88
+ await expect(lookupExactRedirect(enabled(kv), "/old")).resolves.toBeNull();
89
+ });
90
+
91
+ it("defaults an unknown status to 302 rather than inventing a permanent redirect", async () => {
92
+ const kv = makeKV({ [`redirect:${ID}:/old`]: '{"to":"/new"}' });
93
+ await expect(lookupExactRedirect(enabled(kv), "/old")).resolves.toMatchObject({ status: 302 });
94
+ });
95
+ });
96
+
97
+ describe("precedence: exact (KV) over glob (memory)", () => {
98
+ it("an exact rule in KV wins over a glob still in the decofile", async () => {
99
+ // This is why matchRedirect was split in two. Doing "matchRedirect, then
100
+ // KV" would return /store/sale here, inverting the precedence every
101
+ // non-fast-deploy path has.
102
+ const map = loadRedirects({
103
+ r: {
104
+ __resolveType: "website/loaders/redirects.ts",
105
+ redirects: [{ from: "/shop/*", to: "/store/*" }],
106
+ },
107
+ });
108
+ const kv = makeKV({ [`redirect:${ID}:/shop/sale`]: '{"to":"/promo","status":301}' });
109
+
110
+ const hit =
111
+ matchExactRedirect("/shop/sale", map) ??
112
+ (await lookupExactRedirect(enabled(kv), "/shop/sale")) ??
113
+ matchPatternRedirect("/shop/sale", map);
114
+
115
+ expect(hit).toMatchObject({ to: "/promo", status: 301 });
116
+ });
117
+
118
+ it("falls through to the glob when KV has no exact rule", async () => {
119
+ const map = loadRedirects({
120
+ r: {
121
+ __resolveType: "website/loaders/redirects.ts",
122
+ redirects: [{ from: "/shop/*", to: "/store/*" }],
123
+ },
124
+ });
125
+ const hit =
126
+ matchExactRedirect("/shop/other", map) ??
127
+ (await lookupExactRedirect(enabled(makeKV()), "/shop/other")) ??
128
+ matchPatternRedirect("/shop/other", map);
129
+
130
+ expect(hit).toMatchObject({ to: "/store/other" });
131
+ });
132
+ });
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Request-time lookup for KV-keyed exact redirects.
3
+ *
4
+ * A bulk-migration site can carry tens of thousands of rules. Inside the
5
+ * decofile they are resident in every isolate twice — the parsed snapshot graph
6
+ * and the `RedirectMap` built from it — for data consulted at most once per
7
+ * request and usually matching nothing. `splitExactRedirects` moves them to one
8
+ * `redirect:<deployment-id>:<path>` key each at sync time, so the list is never
9
+ * loaded and this looks up only the path actually being requested.
10
+ *
11
+ * Globs stay in the decofile and in memory: they must be scanned in order
12
+ * against the path, so they can never be a key lookup. There are tens of them.
13
+ *
14
+ * Inert unless fast-deploy is on — without a `DECO_KV` binding there is nowhere
15
+ * to look, and the rules are still in the decofile where `matchRedirect` finds
16
+ * them. Same on-or-off switch as the rest of fast-deploy, so a site can't end
17
+ * up with redirects in neither place.
18
+ */
19
+
20
+ import { getDeploymentId, redirectKey, type StoredRedirect } from "@decocms/blocks/cms";
21
+ import type { Redirect } from "@decocms/blocks/sdk/redirects";
22
+ import { getFastDeployKV } from "./kvHydration";
23
+
24
+ /**
25
+ * How long a lookup (hit OR miss) is trusted inside one isolate.
26
+ *
27
+ * Deliberately a TTL rather than the decofile revision: the rules now live in
28
+ * their own keys, so a redirect-only content sync leaves the decofile byte
29
+ * identical and the revision poller sees nothing to react to. The TTL is the
30
+ * propagation delay for a redirect edit.
31
+ *
32
+ * Ceiling: if that delay ever needs to be near-zero, the upgrade is a
33
+ * `index:redirects-revision:<id>` key polled next to `index:revision:<id>` —
34
+ * not a shorter TTL, which just multiplies KV reads.
35
+ */
36
+ const TTL_MS = 60_000;
37
+
38
+ /** Bounded so a crawler walking unique URLs cannot grow the isolate unbounded —
39
+ * the whole point of this module is to stop holding an unbounded list. */
40
+ const MAX_ENTRIES = 2_000;
41
+
42
+ interface CacheEntry {
43
+ value: StoredRedirect | null;
44
+ expiresAt: number;
45
+ }
46
+
47
+ // globalThis-backed so every Vite server-function split-module copy shares one
48
+ // cache (same reason as `loader.ts` and `kvHydration.ts`).
49
+ const G = globalThis as unknown as { __decoRedirectCache?: Map<string, CacheEntry> };
50
+ function cache(): Map<string, CacheEntry> {
51
+ G.__decoRedirectCache ??= new Map();
52
+ return G.__decoRedirectCache;
53
+ }
54
+
55
+ /** Insertion-ordered LRU touch + evict. */
56
+ function remember(key: string, value: StoredRedirect | null): void {
57
+ const c = cache();
58
+ c.delete(key);
59
+ c.set(key, { value, expiresAt: Date.now() + TTL_MS });
60
+ while (c.size > MAX_ENTRIES) c.delete(c.keys().next().value!);
61
+ }
62
+
63
+ /** Cached entry, or `undefined` when absent/expired. */
64
+ function peek(key: string): CacheEntry | undefined {
65
+ const c = cache();
66
+ const entry = c.get(key);
67
+ if (!entry) return undefined;
68
+ if (Date.now() >= entry.expiresAt) {
69
+ c.delete(key);
70
+ return undefined;
71
+ }
72
+ c.delete(key);
73
+ c.set(key, entry);
74
+ return entry;
75
+ }
76
+
77
+ /** Test seam — the cache is process-global, so suites must be able to reset it. */
78
+ export function clearRedirectCache(): void {
79
+ G.__decoRedirectCache = new Map();
80
+ }
81
+
82
+ /**
83
+ * Look up one exact redirect for `normalizedPath` (already through
84
+ * `normalizePath` — the key contract).
85
+ *
86
+ * Returns `null` for "no redirect", including every case where the lookup
87
+ * cannot run (fast-deploy off, no deployment id, KV error). A KV failure must
88
+ * not 5xx a page that would otherwise render: the worst outcome is the request
89
+ * proceeds and the URL serves its normal response.
90
+ */
91
+ export async function lookupExactRedirect(
92
+ env: Record<string, unknown>,
93
+ normalizedPath: string,
94
+ ): Promise<Redirect | null> {
95
+ const kv = getFastDeployKV(env);
96
+ if (!kv) return null;
97
+
98
+ const deploymentId = getDeploymentId(env);
99
+ if (!deploymentId) return null;
100
+
101
+ const key = redirectKey(deploymentId, normalizedPath);
102
+ const cached = peek(key);
103
+ const stored = cached !== undefined ? cached.value : await read(kv, key);
104
+ if (cached === undefined) remember(key, stored);
105
+ if (!stored) return null;
106
+
107
+ return { from: normalizedPath, to: stored.to, status: stored.status };
108
+ }
109
+
110
+ async function read(
111
+ kv: { get(key: string): Promise<string | null> },
112
+ key: string,
113
+ ): Promise<StoredRedirect | null> {
114
+ try {
115
+ const raw = await kv.get(key);
116
+ if (raw === null) return null;
117
+ const parsed = JSON.parse(raw) as StoredRedirect;
118
+ // A malformed value is a miss, not a throw — never let bad data in one key
119
+ // take down every request for that path.
120
+ if (!parsed || typeof parsed.to !== "string") return null;
121
+ return { to: parsed.to, status: parsed.status === 301 ? 301 : 302 };
122
+ } catch {
123
+ return null;
124
+ }
125
+ }