@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.
- package/package.json +12 -5
- package/src/__conformance__/observability.test.ts +221 -0
- package/src/hooks/CdnSegmentMarker.tsx +38 -0
- package/src/hooks/DecoPageRenderer.tsx +91 -29
- package/src/hooks/DecoRootLayout.tsx +139 -58
- package/src/hooks/DraftPreviewIndicator.test.tsx +60 -0
- package/src/hooks/DraftPreviewIndicator.tsx +64 -0
- package/src/hooks/NavigationProgress.test.tsx +58 -0
- package/src/hooks/NavigationProgress.tsx +45 -3
- package/src/hooks/PreviewProviders.test.tsx +42 -0
- package/src/hooks/PreviewProviders.tsx +21 -2
- package/src/hooks/deferredObserverTiming.test.tsx +91 -0
- package/src/hooks/index.ts +1 -0
- package/src/index.ts +30 -13
- package/src/routes/cmsRoute.test.ts +168 -1
- package/src/routes/cmsRoute.ts +148 -39
- package/src/routes/pageUrl.test.ts +6 -6
- package/src/routes/pageUrl.ts +5 -4
- package/src/routes/withSiteGlobals.test.ts +37 -0
- package/src/routes/withSiteGlobals.ts +45 -24
- package/src/sdk/backgroundTasks.test.ts +68 -0
- package/src/sdk/backgroundTasks.ts +38 -0
- package/src/sdk/cdnSegment.test.ts +67 -0
- package/src/sdk/cdnSegment.ts +71 -0
- package/src/sdk/deferredSectionLoader.test.ts +1 -1
- package/src/sdk/draft.test.ts +341 -0
- package/src/sdk/draft.ts +277 -0
- package/src/sdk/draftShared.ts +16 -0
- package/src/sdk/kvHydration.test.ts +156 -0
- package/src/sdk/kvHydration.ts +82 -12
- package/src/sdk/kvRedirects.test.ts +132 -0
- package/src/sdk/kvRedirects.ts +125 -0
- package/src/sdk/router.ts +70 -61
- package/src/sdk/serverFnFetch.ts +59 -0
- package/src/sdk/speculationRules.test.ts +82 -0
- package/src/sdk/speculationRules.ts +136 -0
- package/src/sdk/startEntry.ts +31 -0
- package/src/sdk/workerEntry.cacheStorage.test.ts +147 -0
- package/src/sdk/workerEntry.test.ts +554 -16
- package/src/sdk/workerEntry.trackingCache.test.ts +328 -0
- package/src/sdk/workerEntry.ts +915 -68
- package/src/v8/__conformance__/delivery.test.ts +100 -0
- package/src/v8/binding.ts +70 -0
- package/src/v8/kvLoader.bundle.test.ts +42 -0
- package/src/v8/kvLoader.test.ts +74 -0
- package/src/v8/kvLoader.ts +43 -0
- package/src/vite/plugin.js +180 -8
- package/src/vite/plugin.test.js +126 -1
- 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
|
+
});
|
package/src/sdk/kvHydration.ts
CHANGED
|
@@ -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.
|
|
102
|
-
*
|
|
103
|
-
*
|
|
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))
|
|
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
|
-
|
|
126
|
-
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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 =
|
|
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
|
+
}
|