@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 +1 -1
- package/src/cms/blockSource.ts +37 -0
- package/src/cms/index.ts +3 -1
- package/src/sdk/redirects.ts +113 -5
- package/src/sdk/splitExactRedirects.test.ts +154 -0
package/package.json
CHANGED
package/src/cms/blockSource.ts
CHANGED
|
@@ -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";
|
package/src/sdk/redirects.ts
CHANGED
|
@@ -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
|
-
|
|
187
|
+
return matchExactRedirect(pathname, map) ?? matchPatternRedirect(pathname, map);
|
|
188
|
+
}
|
|
188
189
|
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
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
|
+
});
|