@decocms/tanstack 7.77.10 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/tanstack",
3
- "version": "7.77.10",
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.10",
31
- "@decocms/blocks-admin": "7.77.10",
32
- "@decocms/blocks-cli": "7.77.10",
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":302}' });
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("defaults an unknown status to 302 rather than inventing a permanent redirect", async () => {
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)", () => {
@@ -18,7 +18,7 @@
18
18
  */
19
19
 
20
20
  import { getDeploymentId, redirectKey, type StoredRedirect } from "@decocms/blocks/cms";
21
- import type { Redirect } from "@decocms/blocks/sdk/redirects";
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: StoredRedirect | null;
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: StoredRedirect | null): void {
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 `normalizedPath` (already through
84
- * `normalizePath` — the key contract).
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
- normalizedPath: string,
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, normalizedPath);
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 { from: normalizedPath, to: stored.to, status: stored.status };
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<StoredRedirect | null> {
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 StoredRedirect;
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
- if (!parsed || typeof parsed.to !== "string") return null;
121
- return { to: parsed.to, status: parsed.status === 301 ? 301 : 302 };
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
- expect(res.headers.get("Location")).toBe("/new");
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("URI-encodes the Location header for non-ASCII destinations", async () => {
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(302);
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 302 redirect for a temporary redirect block", async () => {
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(302);
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)", () => {
@@ -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
- const currentRevision = getRevision();
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>, normalizePath(url.pathname))) ??
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: encodeURI(cmsRedirect.to) },
2362
+ headers: { Location: redirectLocation(cmsRedirect, url.search) },
2334
2363
  });
2335
2364
  }
2336
2365