@decocms/tanstack 7.67.0 → 7.68.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/tanstack",
3
- "version": "7.67.0",
3
+ "version": "7.68.0",
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.67.0",
31
- "@decocms/blocks-admin": "7.67.0",
32
- "@decocms/blocks-cli": "7.67.0",
30
+ "@decocms/blocks": "7.68.0",
31
+ "@decocms/blocks-admin": "7.68.0",
32
+ "@decocms/blocks-cli": "7.68.0",
33
33
  "@deco-cx/warp-node": "^0.3.16",
34
34
  "fast-json-patch": "^3.1.0",
35
35
  "ws": "^8.18.0"
@@ -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
+ }
@@ -76,7 +76,13 @@ import {
76
76
  } from "@decocms/blocks/sdk/otel";
77
77
  import { setRuntimeEnv } from "@decocms/blocks/sdk/otelAdapters";
78
78
  import { parseTraceparent } from "@decocms/blocks/sdk/otelHttpTracer";
79
- import { loadRedirects, matchRedirect, type RedirectMap } from "@decocms/blocks/sdk/redirects";
79
+ import {
80
+ loadRedirects,
81
+ matchExactRedirect,
82
+ matchPatternRedirect,
83
+ normalizePath,
84
+ type RedirectMap,
85
+ } from "@decocms/blocks/sdk/redirects";
80
86
  import { RequestContext } from "@decocms/blocks/sdk/requestContext";
81
87
  import { createResponseCache } from "@decocms/blocks/sdk/responseCache";
82
88
  import { cleanPathForCacheKey } from "@decocms/blocks/sdk/urlUtils";
@@ -96,6 +102,7 @@ import {
96
102
  requestCarriesDraft,
97
103
  } from "./draft";
98
104
  import { ensureBlocksHydrated, maybePollRevision } from "./kvHydration";
105
+ import { lookupExactRedirect } from "./kvRedirects";
99
106
  import { DECO_POWERED_BY, installDefaultUserAgent } from "./outboundHeaders";
100
107
  import { type SpeculationRulesConfig, setSpeculationRules } from "./speculationRules";
101
108
 
@@ -2192,7 +2199,15 @@ export function createDecoWorkerEntry(
2192
2199
  _redirectMap = loadRedirects(loadBlocks());
2193
2200
  _redirectMapRevision = currentRevision;
2194
2201
  }
2195
- const cmsRedirect = matchRedirect(url.pathname, _redirectMap!);
2202
+ // Exact before glob, across BOTH sources. On a fast-deploy site the exact
2203
+ // rules live under their own `redirect:<id>:<path>` KV keys and are absent
2204
+ // from the decofile, so the in-memory exact hit is the non-fast-deploy
2205
+ // case. Going straight to `matchRedirect` and only then to KV would let a
2206
+ // glob win over an exact rule, inverting the precedence.
2207
+ const cmsRedirect =
2208
+ matchExactRedirect(url.pathname, _redirectMap!) ??
2209
+ (await lookupExactRedirect(env as Record<string, unknown>, normalizePath(url.pathname))) ??
2210
+ matchPatternRedirect(url.pathname, _redirectMap!);
2196
2211
  if (cmsRedirect) {
2197
2212
  return new Response(null, {
2198
2213
  status: cmsRedirect.status,