@decocms/tanstack 7.77.11 → 7.77.13
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 +56 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decocms/tanstack",
|
|
3
|
-
"version": "7.77.
|
|
3
|
+
"version": "7.77.13",
|
|
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.13",
|
|
31
|
+
"@decocms/blocks-admin": "7.77.13",
|
|
32
|
+
"@decocms/blocks-cli": "7.77.13",
|
|
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";
|
|
@@ -261,6 +264,16 @@ export interface DecoWorkerEntryOptions {
|
|
|
261
264
|
*/
|
|
262
265
|
renderJson?: boolean;
|
|
263
266
|
|
|
267
|
+
/**
|
|
268
|
+
* `?renderJson` strips the request-derived keys the section mixins inject
|
|
269
|
+
* (`device`, `isMobile`, `currentSearchParam`) from every section. A section
|
|
270
|
+
* whose own loader returns one of them as content (Fresh: `device:
|
|
271
|
+
* ctx.device`, which production's `?renderJson` carries) can keep it here:
|
|
272
|
+
* resolveType suffix (matched like `renderJson.sectionsToIgnore`) → keys to
|
|
273
|
+
* preserve. Example: `{ "Product/ProductDetails.tsx": ["device"] }`.
|
|
274
|
+
*/
|
|
275
|
+
renderJsonKeepKeys?: Record<string, readonly string[]>;
|
|
276
|
+
|
|
264
277
|
/**
|
|
265
278
|
* Serve the raw resolved page as JSON on `?asJson` (legacy admin-preview
|
|
266
279
|
* path). Set to `false` to disable it site-wide.
|
|
@@ -615,6 +628,23 @@ export interface DecoWorkerEntryOptions {
|
|
|
615
628
|
* @default `Deco/<version> (+https://deco.cx)` — see DECO_USER_AGENT.
|
|
616
629
|
*/
|
|
617
630
|
outboundUserAgent?: string | false;
|
|
631
|
+
|
|
632
|
+
/**
|
|
633
|
+
* How CMS redirects (`website/loaders/redirect(s).ts`, `redirectsFromCsv`)
|
|
634
|
+
* match and answer — see `@decocms/blocks/sdk/redirects`.
|
|
635
|
+
*
|
|
636
|
+
* - `"legacy"` (default): case- and trailing-slash-insensitive match,
|
|
637
|
+
* temporary → 302, the request's query dropped from `Location`. What every
|
|
638
|
+
* `@decocms/*` site has answered so far.
|
|
639
|
+
* - `"fresh"`: what the Fresh site a migration came from answered — source
|
|
640
|
+
* matched byte for byte (`pathname + search` first), temporary → 307, the
|
|
641
|
+
* request's query appended to `Location` unless `discardQueryParameters`.
|
|
642
|
+
*
|
|
643
|
+
* Both drop self-redirects and never double-encode `Location`.
|
|
644
|
+
*
|
|
645
|
+
* @default { semantics: "legacy" }
|
|
646
|
+
*/
|
|
647
|
+
redirects?: { semantics?: RedirectSemantics };
|
|
618
648
|
}
|
|
619
649
|
|
|
620
650
|
// ---------------------------------------------------------------------------
|
|
@@ -1153,6 +1183,7 @@ export function createDecoWorkerEntry(
|
|
|
1153
1183
|
extraBypassPaths = [],
|
|
1154
1184
|
fingerprintedAssetPattern = FINGERPRINTED_ASSET_RE,
|
|
1155
1185
|
stripTrackingParams: shouldStripTracking = true,
|
|
1186
|
+
redirects: redirectsOpt,
|
|
1156
1187
|
previewShell: customPreviewShell,
|
|
1157
1188
|
cacheVersionEnv = "BUILD_HASH",
|
|
1158
1189
|
securityHeaders: securityHeadersOpt,
|
|
@@ -1168,6 +1199,10 @@ export function createDecoWorkerEntry(
|
|
|
1168
1199
|
speculationRules: speculationRulesOpt,
|
|
1169
1200
|
} = options;
|
|
1170
1201
|
|
|
1202
|
+
// Before the first request builds the redirect map — the map keys its rules
|
|
1203
|
+
// for the semantics in force when it is built.
|
|
1204
|
+
setRedirectSemantics(redirectsOpt?.semantics ?? "legacy");
|
|
1205
|
+
|
|
1171
1206
|
// Speculation Rules — store the site-wide config in the shared singleton so
|
|
1172
1207
|
// DecoRootLayout (SSR) emits the tag in <head>. Inert unless the site opts in.
|
|
1173
1208
|
setSpeculationRules(speculationRulesOpt);
|
|
@@ -2313,7 +2348,9 @@ export function createDecoWorkerEntry(
|
|
|
2313
2348
|
// ?asJson and commerce proxy so every request path respects redirects.
|
|
2314
2349
|
// Revision-keyed so cross-bundle setBlocks() calls are detected even when
|
|
2315
2350
|
// onChange listeners don't fire across Vite split-bundle module instances.
|
|
2316
|
-
|
|
2351
|
+
// The semantics are part of the key: a map keys its rules for the
|
|
2352
|
+
// semantics in force when it is built.
|
|
2353
|
+
const currentRevision = `${getRevision()}:${getRedirectSemantics()}`;
|
|
2317
2354
|
if (_redirectMapRevision !== currentRevision) {
|
|
2318
2355
|
_redirectMap = loadRedirects(loadBlocks());
|
|
2319
2356
|
_redirectMapRevision = currentRevision;
|
|
@@ -2323,14 +2360,16 @@ export function createDecoWorkerEntry(
|
|
|
2323
2360
|
// from the decofile, so the in-memory exact hit is the non-fast-deploy
|
|
2324
2361
|
// case. Going straight to `matchRedirect` and only then to KV would let a
|
|
2325
2362
|
// glob win over an exact rule, inverting the precedence.
|
|
2363
|
+
// Query-scoped rules (`/x?map=ft`) live in memory only, so the KV lookup
|
|
2364
|
+
// stays one read, keyed by pathname.
|
|
2326
2365
|
const cmsRedirect =
|
|
2327
|
-
matchExactRedirect(url.pathname, _redirectMap
|
|
2328
|
-
(await lookupExactRedirect(env as Record<string, unknown>,
|
|
2366
|
+
matchExactRedirect(url.pathname, _redirectMap!, url.search) ??
|
|
2367
|
+
(await lookupExactRedirect(env as Record<string, unknown>, url.pathname)) ??
|
|
2329
2368
|
matchPatternRedirect(url.pathname, _redirectMap!);
|
|
2330
2369
|
if (cmsRedirect) {
|
|
2331
2370
|
return new Response(null, {
|
|
2332
2371
|
status: cmsRedirect.status,
|
|
2333
|
-
headers: { Location:
|
|
2372
|
+
headers: { Location: redirectLocation(cmsRedirect, url.search) },
|
|
2334
2373
|
});
|
|
2335
2374
|
}
|
|
2336
2375
|
|
|
@@ -2398,6 +2437,16 @@ export function createDecoWorkerEntry(
|
|
|
2398
2437
|
const getSectionModule = (component: string) => ({
|
|
2399
2438
|
renderJson: getSectionOptions(component)?.renderJson,
|
|
2400
2439
|
});
|
|
2440
|
+
const keepEntries = Object.entries(options.renderJsonKeepKeys ?? {});
|
|
2441
|
+
const keepInjectedKeys =
|
|
2442
|
+
keepEntries.length === 0
|
|
2443
|
+
? undefined
|
|
2444
|
+
: (component: string) => {
|
|
2445
|
+
const keys = keepEntries.flatMap(([suffix, k]) =>
|
|
2446
|
+
component.endsWith(suffix) ? k : [],
|
|
2447
|
+
);
|
|
2448
|
+
return keys.length > 0 ? keys : undefined;
|
|
2449
|
+
};
|
|
2401
2450
|
|
|
2402
2451
|
const jsonHeaders = {
|
|
2403
2452
|
...corsHeaders,
|
|
@@ -2435,7 +2484,7 @@ export function createDecoWorkerEntry(
|
|
|
2435
2484
|
}
|
|
2436
2485
|
const [serialized] = serializeRenderJson(
|
|
2437
2486
|
[{ component: resolved.component, props: resolved.props }],
|
|
2438
|
-
{ getSectionModule, sectionsToIgnore: ignoreSuffixes },
|
|
2487
|
+
{ getSectionModule, sectionsToIgnore: ignoreSuffixes, keepInjectedKeys },
|
|
2439
2488
|
);
|
|
2440
2489
|
if (!serialized) {
|
|
2441
2490
|
return Response.json(
|
|
@@ -2466,6 +2515,7 @@ export function createDecoWorkerEntry(
|
|
|
2466
2515
|
sectionsToIgnore: ignoreSuffixes,
|
|
2467
2516
|
deferred: page.deferredSections.map((d) => ({ component: d.component, index: d.index })),
|
|
2468
2517
|
lazyUrlFor,
|
|
2518
|
+
keepInjectedKeys,
|
|
2469
2519
|
},
|
|
2470
2520
|
);
|
|
2471
2521
|
|