@decocms/tanstack 7.77.11 → 7.77.12
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 +4 -4
- package/src/sdk/kvRedirects.test.ts +39 -3
- package/src/sdk/kvRedirects.ts +28 -12
- package/src/sdk/workerEntry.test.ts +121 -6
- package/src/sdk/workerEntry.ts +34 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decocms/tanstack",
|
|
3
|
-
"version": "7.77.
|
|
3
|
+
"version": "7.77.12",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Deco framework binding for TanStack Start + Cloudflare Workers",
|
|
6
6
|
"repository": {
|
|
@@ -27,9 +27,9 @@
|
|
|
27
27
|
"lint:unused": "knip"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
|
-
"@decocms/blocks": "7.77.
|
|
31
|
-
"@decocms/blocks-admin": "7.77.
|
|
32
|
-
"@decocms/blocks-cli": "7.77.
|
|
30
|
+
"@decocms/blocks": "7.77.12",
|
|
31
|
+
"@decocms/blocks-admin": "7.77.12",
|
|
32
|
+
"@decocms/blocks-cli": "7.77.12",
|
|
33
33
|
"@deco-cx/warp-node": "^0.3.16",
|
|
34
34
|
"fast-json-patch": "^3.1.0",
|
|
35
35
|
"ws": "^8.18.0"
|
|
@@ -2,8 +2,9 @@ import {
|
|
|
2
2
|
loadRedirects,
|
|
3
3
|
matchExactRedirect,
|
|
4
4
|
matchPatternRedirect,
|
|
5
|
+
setRedirectSemantics,
|
|
5
6
|
} from "@decocms/blocks/sdk/redirects";
|
|
6
|
-
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
7
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
7
8
|
import { clearRedirectCache, lookupExactRedirect } from "./kvRedirects";
|
|
8
9
|
|
|
9
10
|
/** Minimal KV binding that records every read. */
|
|
@@ -39,7 +40,7 @@ describe("lookupExactRedirect", () => {
|
|
|
39
40
|
});
|
|
40
41
|
|
|
41
42
|
it("caches a HIT so the same path costs one KV read", async () => {
|
|
42
|
-
const kv = makeKV({ [`redirect:${ID}:/old`]: '{"to":"/new","status":
|
|
43
|
+
const kv = makeKV({ [`redirect:${ID}:/old`]: '{"to":"/new","status":307}' });
|
|
43
44
|
await lookupExactRedirect(enabled(kv), "/old");
|
|
44
45
|
await lookupExactRedirect(enabled(kv), "/old");
|
|
45
46
|
expect(kv.get).toHaveBeenCalledTimes(1);
|
|
@@ -88,10 +89,45 @@ describe("lookupExactRedirect", () => {
|
|
|
88
89
|
await expect(lookupExactRedirect(enabled(kv), "/old")).resolves.toBeNull();
|
|
89
90
|
});
|
|
90
91
|
|
|
91
|
-
it("
|
|
92
|
+
it("answers an unknown status as temporary rather than inventing a permanent redirect", async () => {
|
|
92
93
|
const kv = makeKV({ [`redirect:${ID}:/old`]: '{"to":"/new"}' });
|
|
93
94
|
await expect(lookupExactRedirect(enabled(kv), "/old")).resolves.toMatchObject({ status: 302 });
|
|
94
95
|
});
|
|
96
|
+
|
|
97
|
+
it("looks the request path up under its folded key", async () => {
|
|
98
|
+
const kv = makeKV({ [`redirect:${ID}:/old`]: '{"from":"/Old/","to":"/new","status":307}' });
|
|
99
|
+
await expect(lookupExactRedirect(enabled(kv), "/OLD/")).resolves.toMatchObject({
|
|
100
|
+
to: "/new",
|
|
101
|
+
status: 302,
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe("lookupExactRedirect — semantics", () => {
|
|
107
|
+
afterEach(() => setRedirectSemantics("legacy"));
|
|
108
|
+
const value = JSON.stringify([
|
|
109
|
+
{ from: "/meia/p", to: "/a", status: 307 },
|
|
110
|
+
{ from: "/Meia/p/", to: "/b", status: 307 },
|
|
111
|
+
]);
|
|
112
|
+
|
|
113
|
+
it("legacy serves the last rule under the key, temporary as 302", async () => {
|
|
114
|
+
const kv = makeKV({ [`redirect:${ID}:/meia/p`]: value });
|
|
115
|
+
await expect(lookupExactRedirect(enabled(kv), "/meia/p")).resolves.toMatchObject({
|
|
116
|
+
to: "/b",
|
|
117
|
+
status: 302,
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("fresh serves the rule written byte for byte, temporary as 307", async () => {
|
|
122
|
+
setRedirectSemantics("fresh");
|
|
123
|
+
const kv = makeKV({ [`redirect:${ID}:/meia/p`]: value });
|
|
124
|
+
await expect(lookupExactRedirect(enabled(kv), "/meia/p")).resolves.toMatchObject({
|
|
125
|
+
to: "/a",
|
|
126
|
+
status: 307,
|
|
127
|
+
});
|
|
128
|
+
clearRedirectCache();
|
|
129
|
+
await expect(lookupExactRedirect(enabled(kv), "/MEIA/p")).resolves.toBeNull();
|
|
130
|
+
});
|
|
95
131
|
});
|
|
96
132
|
|
|
97
133
|
describe("precedence: exact (KV) over glob (memory)", () => {
|
package/src/sdk/kvRedirects.ts
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
20
|
import { getDeploymentId, redirectKey, type StoredRedirect } from "@decocms/blocks/cms";
|
|
21
|
-
import type
|
|
21
|
+
import { normalizePath, type Redirect, selectExactRedirect } from "@decocms/blocks/sdk/redirects";
|
|
22
22
|
import { getFastDeployKV } from "./kvHydration";
|
|
23
23
|
|
|
24
24
|
/**
|
|
@@ -39,8 +39,10 @@ const TTL_MS = 60_000;
|
|
|
39
39
|
* the whole point of this module is to stop holding an unbounded list. */
|
|
40
40
|
const MAX_ENTRIES = 2_000;
|
|
41
41
|
|
|
42
|
+
type StoredValue = StoredRedirect | StoredRedirect[];
|
|
43
|
+
|
|
42
44
|
interface CacheEntry {
|
|
43
|
-
value:
|
|
45
|
+
value: StoredValue | null;
|
|
44
46
|
expiresAt: number;
|
|
45
47
|
}
|
|
46
48
|
|
|
@@ -53,7 +55,7 @@ function cache(): Map<string, CacheEntry> {
|
|
|
53
55
|
}
|
|
54
56
|
|
|
55
57
|
/** Insertion-ordered LRU touch + evict. */
|
|
56
|
-
function remember(key: string, value:
|
|
58
|
+
function remember(key: string, value: StoredValue | null): void {
|
|
57
59
|
const c = cache();
|
|
58
60
|
c.delete(key);
|
|
59
61
|
c.set(key, { value, expiresAt: Date.now() + TTL_MS });
|
|
@@ -80,8 +82,10 @@ export function clearRedirectCache(): void {
|
|
|
80
82
|
}
|
|
81
83
|
|
|
82
84
|
/**
|
|
83
|
-
* Look up one exact redirect for `
|
|
84
|
-
* `normalizePath`
|
|
85
|
+
* Look up one exact redirect for the request's `pathname`. The key is
|
|
86
|
+
* `normalizePath(pathname)` (the key contract, the same in both semantics);
|
|
87
|
+
* which of the rules stored there answers — if any — is the semantics' call
|
|
88
|
+
* (`selectExactRedirect`).
|
|
85
89
|
*
|
|
86
90
|
* Returns `null` for "no redirect", including every case where the lookup
|
|
87
91
|
* cannot run (fast-deploy off, no deployment id, KV error). A KV failure must
|
|
@@ -90,7 +94,7 @@ export function clearRedirectCache(): void {
|
|
|
90
94
|
*/
|
|
91
95
|
export async function lookupExactRedirect(
|
|
92
96
|
env: Record<string, unknown>,
|
|
93
|
-
|
|
97
|
+
pathname: string,
|
|
94
98
|
): Promise<Redirect | null> {
|
|
95
99
|
const kv = getFastDeployKV(env);
|
|
96
100
|
if (!kv) return null;
|
|
@@ -98,28 +102,40 @@ export async function lookupExactRedirect(
|
|
|
98
102
|
const deploymentId = getDeploymentId(env);
|
|
99
103
|
if (!deploymentId) return null;
|
|
100
104
|
|
|
101
|
-
const key = redirectKey(deploymentId,
|
|
105
|
+
const key = redirectKey(deploymentId, normalizePath(pathname));
|
|
102
106
|
const cached = peek(key);
|
|
103
107
|
const stored = cached !== undefined ? cached.value : await read(kv, key);
|
|
104
108
|
if (cached === undefined) remember(key, stored);
|
|
105
109
|
if (!stored) return null;
|
|
106
110
|
|
|
107
|
-
return
|
|
111
|
+
return selectExactRedirect(stored, pathname);
|
|
108
112
|
}
|
|
109
113
|
|
|
110
114
|
async function read(
|
|
111
115
|
kv: { get(key: string): Promise<string | null> },
|
|
112
116
|
key: string,
|
|
113
|
-
): Promise<
|
|
117
|
+
): Promise<StoredValue | null> {
|
|
114
118
|
try {
|
|
115
119
|
const raw = await kv.get(key);
|
|
116
120
|
if (raw === null) return null;
|
|
117
|
-
const parsed = JSON.parse(raw) as
|
|
121
|
+
const parsed = JSON.parse(raw) as unknown;
|
|
118
122
|
// A malformed value is a miss, not a throw — never let bad data in one key
|
|
119
123
|
// take down every request for that path.
|
|
120
|
-
|
|
121
|
-
|
|
124
|
+
const rules = (Array.isArray(parsed) ? parsed : [parsed])
|
|
125
|
+
.map(toStored)
|
|
126
|
+
.filter((r): r is StoredRedirect => r !== null);
|
|
127
|
+
if (rules.length === 0) return null;
|
|
128
|
+
return rules.length === 1 ? rules[0] : rules;
|
|
122
129
|
} catch {
|
|
123
130
|
return null;
|
|
124
131
|
}
|
|
125
132
|
}
|
|
133
|
+
|
|
134
|
+
function toStored(value: unknown): StoredRedirect | null {
|
|
135
|
+
const parsed = value as Partial<StoredRedirect> | null;
|
|
136
|
+
if (!parsed || typeof parsed !== "object" || typeof parsed.to !== "string") return null;
|
|
137
|
+
const stored: StoredRedirect = { to: parsed.to, status: parsed.status === 301 ? 301 : 307 };
|
|
138
|
+
if (typeof parsed.from === "string") stored.from = parsed.from;
|
|
139
|
+
if (parsed.discardQueryParameters === true) stored.discardQueryParameters = true;
|
|
140
|
+
return stored;
|
|
141
|
+
}
|
|
@@ -230,7 +230,7 @@ describe("CMS redirects", () => {
|
|
|
230
230
|
expect(res.headers.get("Location")).toBe("/new");
|
|
231
231
|
});
|
|
232
232
|
|
|
233
|
-
it("redirects ?asJson requests too (does not fall through to page resolver)", async () => {
|
|
233
|
+
it("fresh: redirects ?asJson requests too, keeping the query (does not fall through to page resolver)", async () => {
|
|
234
234
|
setBlocks({
|
|
235
235
|
"redirect-1": {
|
|
236
236
|
__resolveType: "website/loaders/redirect.ts",
|
|
@@ -239,6 +239,7 @@ describe("CMS redirects", () => {
|
|
|
239
239
|
});
|
|
240
240
|
const worker = createDecoWorkerEntry(MOCK_SERVER_ENTRY, {
|
|
241
241
|
observability: false,
|
|
242
|
+
redirects: { semantics: "fresh" },
|
|
242
243
|
});
|
|
243
244
|
const res = await worker.fetch(
|
|
244
245
|
new Request("https://example.com/old?asJson"),
|
|
@@ -246,10 +247,83 @@ describe("CMS redirects", () => {
|
|
|
246
247
|
MOCK_CTX,
|
|
247
248
|
);
|
|
248
249
|
expect(res.status).toBe(301);
|
|
249
|
-
|
|
250
|
+
// The query travels with the redirect, so the client that follows it
|
|
251
|
+
// still gets JSON for the page that moved.
|
|
252
|
+
expect(res.headers.get("Location")).toBe("/new?asJson");
|
|
250
253
|
});
|
|
251
254
|
|
|
252
|
-
it("
|
|
255
|
+
it("fresh: appends the request's query string to Location unless the rule discards it", async () => {
|
|
256
|
+
setBlocks({
|
|
257
|
+
"redirect-1": {
|
|
258
|
+
__resolveType: "website/loaders/redirects.ts",
|
|
259
|
+
redirects: [
|
|
260
|
+
{ from: "/old", to: "/new", type: "permanent" },
|
|
261
|
+
{ from: "/strip", to: "/clean?ref=1", type: "permanent", discardQueryParameters: true },
|
|
262
|
+
],
|
|
263
|
+
},
|
|
264
|
+
});
|
|
265
|
+
const worker = createDecoWorkerEntry(MOCK_SERVER_ENTRY, {
|
|
266
|
+
observability: false,
|
|
267
|
+
redirects: { semantics: "fresh" },
|
|
268
|
+
});
|
|
269
|
+
const kept = await worker.fetch(
|
|
270
|
+
new Request("https://example.com/old?utm_source=qa&gclid=x"),
|
|
271
|
+
EMPTY_ENV,
|
|
272
|
+
MOCK_CTX,
|
|
273
|
+
);
|
|
274
|
+
expect(kept.headers.get("Location")).toBe("/new?gclid=x&utm_source=qa");
|
|
275
|
+
|
|
276
|
+
const dropped = await worker.fetch(
|
|
277
|
+
new Request("https://example.com/strip?utm_source=qa"),
|
|
278
|
+
EMPTY_ENV,
|
|
279
|
+
MOCK_CTX,
|
|
280
|
+
);
|
|
281
|
+
expect(dropped.headers.get("Location")).toBe("/clean?ref=1");
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it("fresh: matches a rule scoped to a query before the bare path, and case-sensitively", async () => {
|
|
285
|
+
setBlocks({
|
|
286
|
+
"redirect-1": {
|
|
287
|
+
__resolveType: "website/loaders/redirects.ts",
|
|
288
|
+
redirects: [
|
|
289
|
+
{ from: "/kit/p", to: "/camisa/p", type: "permanent" },
|
|
290
|
+
{ from: "/kit/p?skuId=4432", to: "/roupas/kits", type: "permanent" },
|
|
291
|
+
{ from: "/Calca/p", to: "/calca/p", type: "permanent" },
|
|
292
|
+
],
|
|
293
|
+
},
|
|
294
|
+
});
|
|
295
|
+
const worker = createDecoWorkerEntry(MOCK_SERVER_ENTRY, {
|
|
296
|
+
observability: false,
|
|
297
|
+
redirects: { semantics: "fresh" },
|
|
298
|
+
});
|
|
299
|
+
const scoped = await worker.fetch(
|
|
300
|
+
new Request("https://example.com/kit/p?skuId=4432"),
|
|
301
|
+
EMPTY_ENV,
|
|
302
|
+
MOCK_CTX,
|
|
303
|
+
);
|
|
304
|
+
expect(scoped.headers.get("Location")).toBe("/roupas/kits?skuId=4432");
|
|
305
|
+
|
|
306
|
+
const bare = await worker.fetch(new Request("https://example.com/kit/p"), EMPTY_ENV, MOCK_CTX);
|
|
307
|
+
expect(bare.headers.get("Location")).toBe("/camisa/p");
|
|
308
|
+
|
|
309
|
+
// `/Calca/p -> /calca/p` is a redirect, not a loop: the lower-case URL
|
|
310
|
+
// has no rule and renders.
|
|
311
|
+
const upper = await worker.fetch(
|
|
312
|
+
new Request("https://example.com/Calca/p"),
|
|
313
|
+
EMPTY_ENV,
|
|
314
|
+
MOCK_CTX,
|
|
315
|
+
);
|
|
316
|
+
expect(upper.status).toBe(301);
|
|
317
|
+
expect(upper.headers.get("Location")).toBe("/calca/p");
|
|
318
|
+
const lower = await worker.fetch(
|
|
319
|
+
new Request("https://example.com/calca/p"),
|
|
320
|
+
EMPTY_ENV,
|
|
321
|
+
MOCK_CTX,
|
|
322
|
+
);
|
|
323
|
+
expect(lower.status).toBe(200);
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it("fresh: URI-encodes the Location header for non-ASCII destinations", async () => {
|
|
253
327
|
setBlocks({
|
|
254
328
|
"redirect-1": {
|
|
255
329
|
__resolveType: "website/loaders/redirect.ts",
|
|
@@ -258,13 +332,14 @@ describe("CMS redirects", () => {
|
|
|
258
332
|
});
|
|
259
333
|
const worker = createDecoWorkerEntry(MOCK_SERVER_ENTRY, {
|
|
260
334
|
observability: false,
|
|
335
|
+
redirects: { semantics: "fresh" },
|
|
261
336
|
});
|
|
262
337
|
const res = await worker.fetch(new Request("https://example.com/promo"), EMPTY_ENV, MOCK_CTX);
|
|
263
|
-
expect(res.status).toBe(
|
|
338
|
+
expect(res.status).toBe(307);
|
|
264
339
|
expect(res.headers.get("Location")).toBe("/promo%C3%A7%C3%A3o");
|
|
265
340
|
});
|
|
266
341
|
|
|
267
|
-
it("returns a
|
|
342
|
+
it("fresh: returns a 307 redirect for a temporary redirect block", async () => {
|
|
268
343
|
setBlocks({
|
|
269
344
|
"redirect-1": {
|
|
270
345
|
__resolveType: "website/loaders/redirect.ts",
|
|
@@ -273,9 +348,10 @@ describe("CMS redirects", () => {
|
|
|
273
348
|
});
|
|
274
349
|
const worker = createDecoWorkerEntry(MOCK_SERVER_ENTRY, {
|
|
275
350
|
observability: false,
|
|
351
|
+
redirects: { semantics: "fresh" },
|
|
276
352
|
});
|
|
277
353
|
const res = await worker.fetch(new Request("https://example.com/promo"), EMPTY_ENV, MOCK_CTX);
|
|
278
|
-
expect(res.status).toBe(
|
|
354
|
+
expect(res.status).toBe(307);
|
|
279
355
|
expect(res.headers.get("Location")).toBe("/sale");
|
|
280
356
|
});
|
|
281
357
|
|
|
@@ -316,6 +392,45 @@ describe("CMS redirects", () => {
|
|
|
316
392
|
expect(res2.status).toBe(301);
|
|
317
393
|
expect(res2.headers.get("Location")).toBe("/v2");
|
|
318
394
|
});
|
|
395
|
+
|
|
396
|
+
it("legacy (default): folds case and trailing slash, drops the query, 302 for temporary", async () => {
|
|
397
|
+
setBlocks({
|
|
398
|
+
"redirect-1": {
|
|
399
|
+
__resolveType: "website/loaders/redirects.ts",
|
|
400
|
+
redirects: [
|
|
401
|
+
{ from: "/baixeoapp", to: "/baixe-o-app", type: "permanent" },
|
|
402
|
+
{ from: "/promo", to: "/promoção", type: "temporary" },
|
|
403
|
+
{ from: "/Calca/p", to: "/calca/p", type: "permanent" },
|
|
404
|
+
],
|
|
405
|
+
},
|
|
406
|
+
});
|
|
407
|
+
const worker = createDecoWorkerEntry(MOCK_SERVER_ENTRY, {
|
|
408
|
+
observability: false,
|
|
409
|
+
});
|
|
410
|
+
const folded = await worker.fetch(
|
|
411
|
+
new Request("https://example.com/BaixeoApp/?utm_source=a&asJson"),
|
|
412
|
+
EMPTY_ENV,
|
|
413
|
+
MOCK_CTX,
|
|
414
|
+
);
|
|
415
|
+
expect(folded.status).toBe(301);
|
|
416
|
+
expect(folded.headers.get("Location")).toBe("/baixe-o-app");
|
|
417
|
+
|
|
418
|
+
const temporary = await worker.fetch(
|
|
419
|
+
new Request("https://example.com/promo"),
|
|
420
|
+
EMPTY_ENV,
|
|
421
|
+
MOCK_CTX,
|
|
422
|
+
);
|
|
423
|
+
expect(temporary.status).toBe(302);
|
|
424
|
+
expect(temporary.headers.get("Location")).toBe("/promo%C3%A7%C3%A3o");
|
|
425
|
+
|
|
426
|
+
// Folded, this rule would redirect /calca/p to itself forever — dropped.
|
|
427
|
+
const loop = await worker.fetch(
|
|
428
|
+
new Request("https://example.com/calca/p"),
|
|
429
|
+
EMPTY_ENV,
|
|
430
|
+
MOCK_CTX,
|
|
431
|
+
);
|
|
432
|
+
expect(loop.status).toBe(200);
|
|
433
|
+
});
|
|
319
434
|
});
|
|
320
435
|
|
|
321
436
|
describe("degraded origin (anti-cache-poisoning)", () => {
|
package/src/sdk/workerEntry.ts
CHANGED
|
@@ -78,11 +78,14 @@ import {
|
|
|
78
78
|
import { setRuntimeEnv } from "@decocms/blocks/sdk/otelAdapters";
|
|
79
79
|
import { parseTraceparent } from "@decocms/blocks/sdk/otelHttpTracer";
|
|
80
80
|
import {
|
|
81
|
+
getRedirectSemantics,
|
|
81
82
|
loadRedirects,
|
|
82
83
|
matchExactRedirect,
|
|
83
84
|
matchPatternRedirect,
|
|
84
|
-
normalizePath,
|
|
85
85
|
type RedirectMap,
|
|
86
|
+
type RedirectSemantics,
|
|
87
|
+
redirectLocation,
|
|
88
|
+
setRedirectSemantics,
|
|
86
89
|
} from "@decocms/blocks/sdk/redirects";
|
|
87
90
|
import { RequestContext } from "@decocms/blocks/sdk/requestContext";
|
|
88
91
|
import { createResponseCache } from "@decocms/blocks/sdk/responseCache";
|
|
@@ -615,6 +618,23 @@ export interface DecoWorkerEntryOptions {
|
|
|
615
618
|
* @default `Deco/<version> (+https://deco.cx)` — see DECO_USER_AGENT.
|
|
616
619
|
*/
|
|
617
620
|
outboundUserAgent?: string | false;
|
|
621
|
+
|
|
622
|
+
/**
|
|
623
|
+
* How CMS redirects (`website/loaders/redirect(s).ts`, `redirectsFromCsv`)
|
|
624
|
+
* match and answer — see `@decocms/blocks/sdk/redirects`.
|
|
625
|
+
*
|
|
626
|
+
* - `"legacy"` (default): case- and trailing-slash-insensitive match,
|
|
627
|
+
* temporary → 302, the request's query dropped from `Location`. What every
|
|
628
|
+
* `@decocms/*` site has answered so far.
|
|
629
|
+
* - `"fresh"`: what the Fresh site a migration came from answered — source
|
|
630
|
+
* matched byte for byte (`pathname + search` first), temporary → 307, the
|
|
631
|
+
* request's query appended to `Location` unless `discardQueryParameters`.
|
|
632
|
+
*
|
|
633
|
+
* Both drop self-redirects and never double-encode `Location`.
|
|
634
|
+
*
|
|
635
|
+
* @default { semantics: "legacy" }
|
|
636
|
+
*/
|
|
637
|
+
redirects?: { semantics?: RedirectSemantics };
|
|
618
638
|
}
|
|
619
639
|
|
|
620
640
|
// ---------------------------------------------------------------------------
|
|
@@ -1153,6 +1173,7 @@ export function createDecoWorkerEntry(
|
|
|
1153
1173
|
extraBypassPaths = [],
|
|
1154
1174
|
fingerprintedAssetPattern = FINGERPRINTED_ASSET_RE,
|
|
1155
1175
|
stripTrackingParams: shouldStripTracking = true,
|
|
1176
|
+
redirects: redirectsOpt,
|
|
1156
1177
|
previewShell: customPreviewShell,
|
|
1157
1178
|
cacheVersionEnv = "BUILD_HASH",
|
|
1158
1179
|
securityHeaders: securityHeadersOpt,
|
|
@@ -1168,6 +1189,10 @@ export function createDecoWorkerEntry(
|
|
|
1168
1189
|
speculationRules: speculationRulesOpt,
|
|
1169
1190
|
} = options;
|
|
1170
1191
|
|
|
1192
|
+
// Before the first request builds the redirect map — the map keys its rules
|
|
1193
|
+
// for the semantics in force when it is built.
|
|
1194
|
+
setRedirectSemantics(redirectsOpt?.semantics ?? "legacy");
|
|
1195
|
+
|
|
1171
1196
|
// Speculation Rules — store the site-wide config in the shared singleton so
|
|
1172
1197
|
// DecoRootLayout (SSR) emits the tag in <head>. Inert unless the site opts in.
|
|
1173
1198
|
setSpeculationRules(speculationRulesOpt);
|
|
@@ -2313,7 +2338,9 @@ export function createDecoWorkerEntry(
|
|
|
2313
2338
|
// ?asJson and commerce proxy so every request path respects redirects.
|
|
2314
2339
|
// Revision-keyed so cross-bundle setBlocks() calls are detected even when
|
|
2315
2340
|
// onChange listeners don't fire across Vite split-bundle module instances.
|
|
2316
|
-
|
|
2341
|
+
// The semantics are part of the key: a map keys its rules for the
|
|
2342
|
+
// semantics in force when it is built.
|
|
2343
|
+
const currentRevision = `${getRevision()}:${getRedirectSemantics()}`;
|
|
2317
2344
|
if (_redirectMapRevision !== currentRevision) {
|
|
2318
2345
|
_redirectMap = loadRedirects(loadBlocks());
|
|
2319
2346
|
_redirectMapRevision = currentRevision;
|
|
@@ -2323,14 +2350,16 @@ export function createDecoWorkerEntry(
|
|
|
2323
2350
|
// from the decofile, so the in-memory exact hit is the non-fast-deploy
|
|
2324
2351
|
// case. Going straight to `matchRedirect` and only then to KV would let a
|
|
2325
2352
|
// glob win over an exact rule, inverting the precedence.
|
|
2353
|
+
// Query-scoped rules (`/x?map=ft`) live in memory only, so the KV lookup
|
|
2354
|
+
// stays one read, keyed by pathname.
|
|
2326
2355
|
const cmsRedirect =
|
|
2327
|
-
matchExactRedirect(url.pathname, _redirectMap
|
|
2328
|
-
(await lookupExactRedirect(env as Record<string, unknown>,
|
|
2356
|
+
matchExactRedirect(url.pathname, _redirectMap!, url.search) ??
|
|
2357
|
+
(await lookupExactRedirect(env as Record<string, unknown>, url.pathname)) ??
|
|
2329
2358
|
matchPatternRedirect(url.pathname, _redirectMap!);
|
|
2330
2359
|
if (cmsRedirect) {
|
|
2331
2360
|
return new Response(null, {
|
|
2332
2361
|
status: cmsRedirect.status,
|
|
2333
|
-
headers: { Location:
|
|
2362
|
+
headers: { Location: redirectLocation(cmsRedirect, url.search) },
|
|
2334
2363
|
});
|
|
2335
2364
|
}
|
|
2336
2365
|
|