@decocms/blocks 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/blocks",
3
- "version": "7.67.0",
3
+ "version": "7.68.0",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=24"
@@ -123,6 +123,7 @@ export interface KVNamespace {
123
123
  // index:revision:<id> DJB2 hex revision of that snapshot (polled)
124
124
  // index:live pointer to the currently-live <id> (set post-deploy)
125
125
  // index:deployments JSON [{id, ts}] (newest last) — GC bookkeeping
126
+ // redirect:<id>:<path> ONE key per exact redirect (see below)
126
127
  // ---------------------------------------------------------------------------
127
128
 
128
129
  /** KV key holding the full decofile JSON for deployment `id`. */
@@ -144,6 +145,42 @@ export const LIVE_KEY = "index:live";
144
145
  * deployment snapshots so the sync script can GC all but the last N. */
145
146
  export const DEPLOYMENTS_KEY = "index:deployments";
146
147
 
148
+ // ---------------------------------------------------------------------------
149
+ // Exact redirects — one KV key each, deliberately NOT inside the decofile.
150
+ //
151
+ // A bulk-migration site can carry tens of thousands of rules. Inside the
152
+ // decofile they are resident in every isolate twice over: once in the parsed
153
+ // snapshot graph, once in the `RedirectMap` built from it — megabytes of a
154
+ // 128MB budget, for data that is read at most once per request and usually
155
+ // never. As their own keys they are looked up on demand and the list is never
156
+ // loaded at all.
157
+ //
158
+ // Only EXACT rules move. Glob rules (`/old/*`) cannot be addressed by key and
159
+ // must be scanned in order, so they stay in the decofile — there are tens of
160
+ // them, not thousands.
161
+ // ---------------------------------------------------------------------------
162
+
163
+ /** Key prefix holding deployment `id`'s exact redirects (one key per path). */
164
+ export function redirectPrefix(id: string): string {
165
+ return `redirect:${id}:`;
166
+ }
167
+
168
+ /**
169
+ * KV key for one exact redirect. `path` MUST already be normalized by
170
+ * `normalizePath` (`@decocms/blocks/sdk/redirects`) on both sides — the writer
171
+ * and the request-time lookup — or a rule is written under a key nothing ever
172
+ * asks for.
173
+ */
174
+ export function redirectKey(id: string, path: string): string {
175
+ return `${redirectPrefix(id)}${path}`;
176
+ }
177
+
178
+ /** Value stored at a `redirect:<id>:<path>` key. */
179
+ export interface StoredRedirect {
180
+ to: string;
181
+ status: 301 | 302;
182
+ }
183
+
147
184
  // ---------------------------------------------------------------------------
148
185
  // Deployment id resolution
149
186
  //
package/src/cms/index.ts CHANGED
@@ -3,7 +3,7 @@ export type {
3
3
  SectionMetaEntry,
4
4
  } from "./applySectionConventions";
5
5
  export { applySectionConventions } from "./applySectionConventions";
6
- export type { BlockSnapshot, BlockSource, KVNamespace } from "./blockSource";
6
+ export type { BlockSnapshot, BlockSource, KVNamespace, StoredRedirect } from "./blockSource";
7
7
  export {
8
8
  BUILD_HASH_ENV,
9
9
  BundledBlockSource,
@@ -12,6 +12,8 @@ export {
12
12
  DEPLOYMENTS_KEY,
13
13
  getDeploymentId,
14
14
  LIVE_KEY,
15
+ redirectKey,
16
+ redirectPrefix,
15
17
  revisionKey,
16
18
  snapshotKey,
17
19
  } from "./blockSource";
@@ -184,11 +184,23 @@ export function addRedirects(map: RedirectMap, redirects: Redirect[]): void {
184
184
  * typically few patterns exist).
185
185
  */
186
186
  export function matchRedirect(pathname: string, map: RedirectMap): Redirect | null {
187
- const normalized = normalizePath(pathname);
187
+ return matchExactRedirect(pathname, map) ?? matchPatternRedirect(pathname, map);
188
+ }
188
189
 
189
- const exactMatch = map.exact.get(normalized);
190
- if (exactMatch) return exactMatch;
190
+ /**
191
+ * Exact half of `matchRedirect`. Split out because the KV-keyed path has to
192
+ * interleave a third source between the two halves: in-memory exact, then the
193
+ * `redirect:<id>:<path>` KV lookup, then patterns. Collapsing that to
194
+ * "matchRedirect, then KV" would let a glob win over an exact rule, inverting
195
+ * the precedence every other path has.
196
+ */
197
+ export function matchExactRedirect(pathname: string, map: RedirectMap): Redirect | null {
198
+ return map.exact.get(normalizePath(pathname)) ?? null;
199
+ }
191
200
 
201
+ /** Pattern half of `matchRedirect` — ordered prefix scan, `*` suffix carried over. */
202
+ export function matchPatternRedirect(pathname: string, map: RedirectMap): Redirect | null {
203
+ const normalized = normalizePath(pathname);
192
204
  for (const { prefix, redirect } of map.patterns) {
193
205
  if (normalized.startsWith(prefix)) {
194
206
  const suffix = normalized.slice(prefix.length);
@@ -196,15 +208,111 @@ export function matchRedirect(pathname: string, map: RedirectMap): Redirect | nu
196
208
  return { ...redirect, to };
197
209
  }
198
210
  }
199
-
200
211
  return null;
201
212
  }
202
213
 
214
+ // -------------------------------------------------------------------------
215
+ // Splitting exact rules out of the decofile (KV-keyed redirects)
216
+ // -------------------------------------------------------------------------
217
+
218
+ /** One exact rule, ready to be written to its own KV key. */
219
+ export interface ExactRedirect {
220
+ /** Normalized path — the KV key suffix. */
221
+ path: string;
222
+ to: string;
223
+ status: 301 | 302;
224
+ }
225
+
226
+ export interface SplitRedirectsResult {
227
+ /** The blocks map with every EXACT rule removed. Glob rules stay (they can't
228
+ * be addressed by key), and a redirect block left with no entries at all is
229
+ * dropped entirely rather than left as an empty husk. */
230
+ blocks: Record<string, unknown>;
231
+ /** The extracted exact rules, deduped by path (last wins, matching
232
+ * `loadRedirects`, which is last-write-wins over insertion order). */
233
+ exact: ExactRedirect[];
234
+ }
235
+
236
+ /**
237
+ * Split a decofile into "blocks without exact redirects" + "the exact rules".
238
+ *
239
+ * A bulk-migration site can carry tens of thousands of rules. Left inside the
240
+ * decofile they sit in every isolate twice — the parsed snapshot graph and the
241
+ * `RedirectMap` built from it — for data that is consulted at most once per
242
+ * request and usually matches nothing. Moved to one KV key each, the list is
243
+ * never loaded.
244
+ *
245
+ * Read-only over `blocks`: the returned map shares every untouched value and
246
+ * only clones the redirect blocks it had to rewrite.
247
+ */
248
+ const MAX_KV_PATH_BYTES = 400;
249
+ const utf8 = new TextEncoder();
250
+
251
+ export function splitExactRedirects(blocks: Record<string, unknown>): SplitRedirectsResult {
252
+ const exact = new Map<string, ExactRedirect>();
253
+ const out: Record<string, unknown> = {};
254
+
255
+ for (const [key, block] of Object.entries(blocks)) {
256
+ const obj = block && typeof block === "object" ? (block as Record<string, unknown>) : null;
257
+ const resolveType = obj?.__resolveType as string | undefined;
258
+ if (!obj || !resolveType || !REDIRECT_RESOLVE_TYPES.has(resolveType)) {
259
+ out[key] = block;
260
+ continue;
261
+ }
262
+
263
+ const raw = (obj.redirects ?? obj.redirect) as
264
+ | BlockRedirectEntry[]
265
+ | BlockRedirectEntry
266
+ | undefined;
267
+ if (!raw) {
268
+ out[key] = block;
269
+ continue;
270
+ }
271
+
272
+ const kept: BlockRedirectEntry[] = [];
273
+ for (const entry of Array.isArray(raw) ? raw : [raw]) {
274
+ if (!entry?.from || !entry.to) continue;
275
+ const from = normalizePath(entry.from);
276
+ // Globs must be scanned in order against the request path, so they can
277
+ // never be a key lookup — they stay in the decofile.
278
+ // Paths too long for a KV key (512 bytes, minus `redirect:<id>:`) stay
279
+ // in memory too — loadRedirects still serves them from the exact map.
280
+ if (from.includes("*") || utf8.encode(from).length > MAX_KV_PATH_BYTES) {
281
+ kept.push(entry);
282
+ continue;
283
+ }
284
+ exact.set(from, {
285
+ path: from,
286
+ to: entry.to,
287
+ status: entry.type === "permanent" ? 301 : 302,
288
+ });
289
+ }
290
+
291
+ // Nothing left to scan ⇒ drop the block rather than ship an empty husk.
292
+ if (kept.length === 0) continue;
293
+ // Rebuild without `redirect` — a block may have carried the singular form,
294
+ // and leaving it would reintroduce the rule we just extracted.
295
+ const { redirect: _dropped, ...rest } = obj;
296
+ out[key] = { ...rest, redirects: kept };
297
+ }
298
+
299
+ return { blocks: out, exact: [...exact.values()] };
300
+ }
301
+
203
302
  // -------------------------------------------------------------------------
204
303
  // Helpers
205
304
  // -------------------------------------------------------------------------
206
305
 
207
- function normalizePath(path: string): string {
306
+ /**
307
+ * Canonical redirect-path form: origin stripped, leading slash forced, trailing
308
+ * slash dropped, lower-cased.
309
+ *
310
+ * Exported because it is the KV key contract for `redirect:<id>:<path>` — the
311
+ * sync script that WRITES the keys and the worker that READS them must agree
312
+ * byte for byte, or a rule is stored under a key nothing ever asks for. Never
313
+ * inline a different normalization on either side.
314
+ */
315
+ export function normalizePath(path: string): string {
208
316
  let p = path.trim();
209
317
 
210
318
  // If the "from" is a full URL, extract just the pathname
@@ -0,0 +1,154 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ loadRedirects,
4
+ matchExactRedirect,
5
+ matchPatternRedirect,
6
+ matchRedirect,
7
+ splitExactRedirects,
8
+ } from "./redirects";
9
+
10
+ /**
11
+ * Exact rules move to their own KV keys; globs stay in the decofile because
12
+ * they must be scanned in order and can never be a key lookup.
13
+ *
14
+ * The invariant that matters: whatever leaves the decofile must be recoverable
15
+ * from the extracted list, and whatever stays must still match the same way.
16
+ */
17
+ describe("splitExactRedirects", () => {
18
+ it("extracts exact rules and drops them from the block", () => {
19
+ const { blocks, exact } = splitExactRedirects({
20
+ r: {
21
+ __resolveType: "website/loaders/redirects.ts",
22
+ redirects: [
23
+ { from: "/old", to: "/new", type: "permanent" },
24
+ { from: "/tmp", to: "/other" },
25
+ ],
26
+ },
27
+ });
28
+
29
+ expect(exact).toEqual([
30
+ { path: "/old", to: "/new", status: 301 },
31
+ { path: "/tmp", to: "/other", status: 302 },
32
+ ]);
33
+ // Block had nothing but exact rules ⇒ gone entirely, not an empty husk.
34
+ expect(blocks.r).toBeUndefined();
35
+ });
36
+
37
+ it("keeps a path too long for a KV key in the decofile, still matched in memory", () => {
38
+ const long = `/${"x".repeat(500)}`;
39
+ const { blocks, exact } = splitExactRedirects({
40
+ r: {
41
+ __resolveType: "website/loaders/redirects.ts",
42
+ redirects: [{ from: long, to: "/new", type: "permanent" }],
43
+ },
44
+ });
45
+
46
+ expect(exact).toEqual([]);
47
+ expect(matchExactRedirect(long, loadRedirects(blocks))?.to).toBe("/new");
48
+ });
49
+
50
+ it("keeps glob rules in the decofile", () => {
51
+ const { blocks, exact } = splitExactRedirects({
52
+ r: {
53
+ __resolveType: "website/loaders/redirects.ts",
54
+ redirects: [
55
+ { from: "/old/*", to: "/new/*" },
56
+ { from: "/exact", to: "/x" },
57
+ ],
58
+ },
59
+ });
60
+
61
+ expect(exact.map((e) => e.path)).toEqual(["/exact"]);
62
+ expect((blocks.r as { redirects: unknown[] }).redirects).toEqual([
63
+ { from: "/old/*", to: "/new/*" },
64
+ ]);
65
+ });
66
+
67
+ it("normalizes the path to the KV key contract", () => {
68
+ const { exact } = splitExactRedirects({
69
+ r: {
70
+ __resolveType: "website/loaders/redirect.ts",
71
+ redirect: { from: "https://site.com/Old/", to: "/new" },
72
+ },
73
+ });
74
+ // Origin stripped, trailing slash dropped, lower-cased — whatever the
75
+ // writer keys by, the request-time lookup must produce byte for byte.
76
+ expect(exact).toEqual([{ path: "/old", to: "/new", status: 302 }]);
77
+ });
78
+
79
+ it("drops the singular `redirect` field when rebuilding a block", () => {
80
+ // Otherwise the extracted rule would be silently reintroduced.
81
+ const { blocks } = splitExactRedirects({
82
+ r: {
83
+ __resolveType: "website/loaders/redirects.ts",
84
+ redirect: { from: "/a", to: "/b" },
85
+ redirects: [{ from: "/glob/*", to: "/g" }],
86
+ },
87
+ });
88
+ expect(blocks.r).not.toHaveProperty("redirect");
89
+ });
90
+
91
+ it("leaves non-redirect blocks untouched, by identity", () => {
92
+ const page = { __resolveType: "website/pages/Page.tsx", sections: [] };
93
+ const { blocks } = splitExactRedirects({ page });
94
+ expect(blocks.page).toBe(page);
95
+ });
96
+
97
+ it("dedupes by path, last rule winning — same as loadRedirects", () => {
98
+ const input = {
99
+ a: { __resolveType: "website/loaders/redirects.ts", redirects: [{ from: "/x", to: "/1" }] },
100
+ b: { __resolveType: "website/loaders/redirects.ts", redirects: [{ from: "/x", to: "/2" }] },
101
+ };
102
+ expect(splitExactRedirects(input).exact).toEqual([{ path: "/x", to: "/2", status: 302 }]);
103
+ expect(loadRedirects(input).exact.get("/x")?.to).toBe("/2");
104
+ });
105
+
106
+ it("round-trips: nothing matchable is lost", () => {
107
+ const input = {
108
+ r: {
109
+ __resolveType: "website/loaders/redirects.ts",
110
+ redirects: [
111
+ { from: "/a", to: "/1", type: "permanent" },
112
+ { from: "/b/*", to: "/2/*" },
113
+ ],
114
+ },
115
+ };
116
+ const before = loadRedirects(input);
117
+ const { blocks, exact } = splitExactRedirects(input);
118
+ const after = loadRedirects(blocks);
119
+
120
+ // Exact left the decofile...
121
+ expect(matchRedirect("/a", after)).toBeNull();
122
+ // ...into the extracted list, unchanged.
123
+ expect(exact).toContainEqual({ path: "/a", to: "/1", status: 301 });
124
+ // Globs still match exactly as before.
125
+ expect(matchRedirect("/b/deep", after)).toEqual(matchRedirect("/b/deep", before));
126
+ });
127
+ });
128
+
129
+ describe("matchExactRedirect / matchPatternRedirect", () => {
130
+ const map = loadRedirects({
131
+ r: {
132
+ __resolveType: "website/loaders/redirects.ts",
133
+ redirects: [
134
+ { from: "/shop/*", to: "/store/*" },
135
+ { from: "/shop/sale", to: "/promo", type: "permanent" },
136
+ ],
137
+ },
138
+ });
139
+
140
+ it("compose back into matchRedirect unchanged", () => {
141
+ for (const path of ["/shop/sale", "/shop/deep", "/nothing"]) {
142
+ expect(matchExactRedirect(path, map) ?? matchPatternRedirect(path, map)).toEqual(
143
+ matchRedirect(path, map),
144
+ );
145
+ }
146
+ });
147
+
148
+ it("splits the two halves so KV can be interleaved between them", () => {
149
+ // The whole reason for the split: with exact rules in KV, "matchRedirect
150
+ // then KV" would let /shop/* win over the exact /shop/sale.
151
+ expect(matchExactRedirect("/shop/sale", map)?.to).toBe("/promo");
152
+ expect(matchPatternRedirect("/shop/sale", map)?.to).toBe("/store/sale");
153
+ });
154
+ });