@tokenoftrust/storefront-runner 2.2.85 → 2.2.86
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/agency-kit/tools/lib/redirect-declaration.mjs +627 -0
- package/package.json +1 -1
- package/packages/public-runtime/src/publication-redirects.d.mts +23 -4
- package/packages/public-runtime/src/publication-redirects.mjs +4 -378
- package/packages/public-runtime/src/static-bundle.ts +59 -11
- package/packages/public-runtime/tests/fixtures/redirect-match-cases.json +34 -0
|
@@ -0,0 +1,627 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
// The pure core that turns a tenant's DECLARED redirects into a substrate-neutral redirect set
|
|
3
|
+
// the publish pipeline can emit.
|
|
4
|
+
//
|
|
5
|
+
// This module is the one copy of the redirect contract: the declaration format, the edge budget
|
|
6
|
+
// and the reference matcher. It lives in the agency kit so a store developer's `redirect-map.mjs`
|
|
7
|
+
// validates a map exactly as a publish will, with nothing but the kit installed; the platform
|
|
8
|
+
// imports this file rather than carrying a copy. It imports nothing.
|
|
9
|
+
//
|
|
10
|
+
// A tenant's public site is served CDN -> static object (ADR 0013), so a redirect cannot be a
|
|
11
|
+
// request-time decision in the Worker: it must exist in the published artifact. Both substrates
|
|
12
|
+
// express it the same way — a precomputed set consulted by the single permitted edge function —
|
|
13
|
+
// so the set below is substrate-neutral and the adapters only serialize it.
|
|
14
|
+
//
|
|
15
|
+
// Redirects are DECLARED, never derived from a tenant id. Three declaration sources normalize into
|
|
16
|
+
// the one `redirect-308` contract `PublicationSpaceAliasV1` already defines:
|
|
17
|
+
// - publication-space `routes.aliases` entries whose behavior is `redirect-308`;
|
|
18
|
+
// - a content collection's `redirects.json` (old slug -> new slug), which becomes
|
|
19
|
+
// `/<collection>/<old>/` -> `/<collection>/<new>/`;
|
|
20
|
+
// - the tenant-wide `content/redirects.json` (relative path -> relative path or an explicit
|
|
21
|
+
// HTTPS destination), for legacy URLs that belong to no single collection.
|
|
22
|
+
// Adding a tenant is a declaration, not a code change.
|
|
23
|
+
//
|
|
24
|
+
// Two kinds of entry:
|
|
25
|
+
// - EXACT: `/old/` -> `/new/` (or an explicit `https:` URL). Matches one path.
|
|
26
|
+
// - RULE: `/blogs/news/*` -> `/blog/*`. Matches every path under the directory prefix, the
|
|
27
|
+
// directory itself included. A `*` at the end of the target receives the rest of the request
|
|
28
|
+
// path; a target without `*` sends the whole family to one page. A rule's target is always a
|
|
29
|
+
// same-origin path, and `*` is only ever the trailing segment of either side.
|
|
30
|
+
// Precedence at the edge: an exact entry beats any rule; among rules the longest prefix wins. The
|
|
31
|
+
// query string is forwarded to a same-origin target and dropped for an external one.
|
|
32
|
+
// `resolveRedirect` below is the reference matcher; the Cloudflare static plane
|
|
33
|
+
// (`packages/public-runtime/src/static-bundle.ts`) and the CloudFront function
|
|
34
|
+
// (`scripts/publish/cloudfront/live-pointer-rewrite.mjs`) implement the same semantics in their own
|
|
35
|
+
// runtimes and are held to it by the shared cases in
|
|
36
|
+
// `packages/public-runtime/tests/fixtures/redirect-match-cases.json`.
|
|
37
|
+
//
|
|
38
|
+
// 308 rather than 301 throughout: both pass ranking signal, and 308 additionally preserves the
|
|
39
|
+
// request method, so a redirected non-GET is not silently rewritten. The declared alias contract
|
|
40
|
+
// already specifies 308; collection renames join it rather than introducing a second status.
|
|
41
|
+
//
|
|
42
|
+
// Fails closed. An ambiguous declaration (one `from`, two targets), a cycle, a rule whose target
|
|
43
|
+
// another rule would redirect again, or a set that exceeds the substrate's ceiling REFUSES rather
|
|
44
|
+
// than emitting a partial set — a redirect that silently does not exist is the failure this
|
|
45
|
+
// mechanism exists to prevent.
|
|
46
|
+
|
|
47
|
+
/** The status every materialized redirect carries. */
|
|
48
|
+
export const REDIRECT_STATUS = 308;
|
|
49
|
+
|
|
50
|
+
/** Bump only when the meaning of a whole-site delivery manifest changes without changing its values. */
|
|
51
|
+
export const WHOLE_SITE_DELIVERY_CONTRACT = "whole-site-delivery-v1";
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* CloudFront KeyValueStore quotas (AWS CloudFront quota table, read 2026-10-06). The CloudFront
|
|
55
|
+
* plane stores each entry as its own key, `redirect:<namespace>:<from>` -> `<to>`, so these — not
|
|
56
|
+
* the function's 10 KB code limit — bound the set. The Cloudflare plane reads the same set from
|
|
57
|
+
* the bundle manifest in R2, which has no tighter ceiling.
|
|
58
|
+
*/
|
|
59
|
+
export const KVS_STORE_LIMIT_BYTES = 5 * 1024 * 1024;
|
|
60
|
+
export const KVS_KEY_LIMIT_BYTES = 512;
|
|
61
|
+
export const KVS_VALUE_LIMIT_BYTES = 1024;
|
|
62
|
+
/** Room kept for the store's own keys (`live`, `preview`, `promotions`). */
|
|
63
|
+
export const KVS_RESERVED_BYTES = 64 * 1024;
|
|
64
|
+
/**
|
|
65
|
+
* Bytes available to one redirect set. A publish stages the new namespace before it prunes the
|
|
66
|
+
* old one, so two complete sets share the store for the length of a flip.
|
|
67
|
+
*/
|
|
68
|
+
export const REDIRECT_MAP_BUDGET_BYTES = Math.floor((KVS_STORE_LIMIT_BYTES - KVS_RESERVED_BYTES) / 2);
|
|
69
|
+
/** `redirect:` + a sha256 hex namespace + `:` — the key bytes every entry carries before its path. */
|
|
70
|
+
export const REDIRECT_KEY_OVERHEAD_BYTES = "redirect:".length + 64 + 1;
|
|
71
|
+
/**
|
|
72
|
+
* The CloudFront function reads one key per distinct rule depth on a request no exact entry
|
|
73
|
+
* matched, so the number of distinct depths is the per-request cost of rules. Bounded here.
|
|
74
|
+
*/
|
|
75
|
+
export const REDIRECT_RULE_MAX_DEPTHS = 8;
|
|
76
|
+
|
|
77
|
+
const encoder = new TextEncoder();
|
|
78
|
+
/** @param {string} value */
|
|
79
|
+
const byteLength = (value) => encoder.encode(value).byteLength;
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* @typedef {Object} DeclaredRedirect
|
|
83
|
+
* @property {string} from root-absolute request path, or a `/<dir>/*` rule
|
|
84
|
+
* @property {string} to root-absolute destination path (a rule may end in `/*`)
|
|
85
|
+
* @property {string} source which declaration produced it, for the report
|
|
86
|
+
*/
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* @typedef {Object} RedirectSet
|
|
90
|
+
* @property {boolean} ok
|
|
91
|
+
* @property {{from: string, to: string}[]} redirects normalized, deduped, chain-collapsed
|
|
92
|
+
* @property {string[]} violations why it refused; empty when ok
|
|
93
|
+
* @property {number} bytes storage the set occupies at the edge (KVS keys + values)
|
|
94
|
+
* @property {number} budget bytes available to the set
|
|
95
|
+
* @property {number} rules how many entries are `/*` rules
|
|
96
|
+
*/
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Normalize a declared path to the form the edge function compares against: root-absolute, no
|
|
100
|
+
* query or fragment, with a trailing slash unless the last segment names a file.
|
|
101
|
+
* Returns null when the value cannot be a same-origin request path.
|
|
102
|
+
* @param {string} raw
|
|
103
|
+
* @returns {string | null}
|
|
104
|
+
*/
|
|
105
|
+
export function normalizeRedirectPath(raw) {
|
|
106
|
+
if (typeof raw !== "string") return null;
|
|
107
|
+
const trimmed = raw.trim();
|
|
108
|
+
if (!trimmed.startsWith("/")) return null;
|
|
109
|
+
// Protocol-relative (`//host/...`) would leave the origin.
|
|
110
|
+
if (trimmed.startsWith("//")) return null;
|
|
111
|
+
const withoutSuffix = trimmed.split("?")[0].split("#")[0];
|
|
112
|
+
if (withoutSuffix.includes("..") || withoutSuffix.includes("\\")) return null;
|
|
113
|
+
const collapsed = withoutSuffix.replace(/\/{2,}/g, "/");
|
|
114
|
+
if (collapsed === "/") return "/";
|
|
115
|
+
// The last NON-EMPTY segment: a trailing slash otherwise hides the filename from this test.
|
|
116
|
+
const segments = collapsed.split("/").filter(Boolean);
|
|
117
|
+
const last = segments[segments.length - 1] ?? "";
|
|
118
|
+
const namesFile = last.includes(".");
|
|
119
|
+
if (namesFile) return collapsed.replace(/\/$/, "");
|
|
120
|
+
return collapsed.endsWith("/") ? collapsed : `${collapsed}/`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Is this normalized entry a `/<dir>/*` rule rather than an exact path? */
|
|
124
|
+
export function isRedirectRule(/** @type {string} */ from) {
|
|
125
|
+
return typeof from === "string" && from.endsWith("/*");
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The directory a rule covers: `/blogs/news/*` -> `/blogs/news/`. Null unless `raw` is a
|
|
130
|
+
* root-absolute directory followed by exactly one trailing `*`.
|
|
131
|
+
* @param {string} raw
|
|
132
|
+
* @returns {string | null}
|
|
133
|
+
*/
|
|
134
|
+
export function ruleDirectory(raw) {
|
|
135
|
+
if (typeof raw !== "string") return null;
|
|
136
|
+
const trimmed = raw.trim();
|
|
137
|
+
if (!trimmed.endsWith("/*") || trimmed.indexOf("*") !== trimmed.length - 1) return null;
|
|
138
|
+
const dir = trimmed.slice(0, -1);
|
|
139
|
+
if (!dir.startsWith("/") || dir.startsWith("//")) return null;
|
|
140
|
+
if (/[?#\\]/.test(dir) || dir.split("/").includes("..")) return null;
|
|
141
|
+
return dir.replace(/\/{2,}/g, "/");
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Normalize a declared SOURCE: an exact path, or a `/<dir>/*` rule. A rule over the whole site
|
|
146
|
+
* (`/*`) is refused — it would shadow every page, which is a domain move, not a redirect.
|
|
147
|
+
* @param {string} raw
|
|
148
|
+
* @returns {string | null}
|
|
149
|
+
*/
|
|
150
|
+
export function normalizeRedirectSource(raw) {
|
|
151
|
+
if (typeof raw !== "string") return null;
|
|
152
|
+
if (!raw.includes("*")) return normalizeRedirectPath(raw);
|
|
153
|
+
const dir = ruleDirectory(raw);
|
|
154
|
+
if (dir === null || dir === "/") return null;
|
|
155
|
+
return `${dir}*`;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Normalize a same-origin path or a deliberately external HTTPS destination.
|
|
160
|
+
*
|
|
161
|
+
* A source is always same-origin; a TARGET may leave the origin, but only when the declaration
|
|
162
|
+
* says so in full: `https:` only, no credentials, no query, no fragment. Anything else — including
|
|
163
|
+
* every shape that merely looks absolute — is not a usable destination and refuses.
|
|
164
|
+
* @param {string} raw
|
|
165
|
+
* @returns {string | null}
|
|
166
|
+
*/
|
|
167
|
+
function normalizeRedirectDestination(/** @type {string} */ raw) {
|
|
168
|
+
if (typeof raw !== "string" || raw.includes("*")) return null;
|
|
169
|
+
const sameOrigin = normalizeRedirectPath(raw);
|
|
170
|
+
if (sameOrigin !== null) return sameOrigin;
|
|
171
|
+
try {
|
|
172
|
+
const url = new URL(raw);
|
|
173
|
+
if (url.protocol !== "https:" || url.username || url.password || url.search || url.hash) {
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
return url.toString().replace(/\/$/, url.pathname === "/" ? "/" : "");
|
|
177
|
+
} catch {
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* A rule's target: a same-origin path, or a same-origin directory ending in `*` that receives the
|
|
184
|
+
* rest of the request path. Never off-origin: a captured remainder must never pick a host.
|
|
185
|
+
* @param {string} raw
|
|
186
|
+
* @returns {string | null}
|
|
187
|
+
*/
|
|
188
|
+
function normalizeRuleTarget(raw) {
|
|
189
|
+
if (typeof raw !== "string") return null;
|
|
190
|
+
if (!raw.includes("*")) return normalizeRedirectPath(raw);
|
|
191
|
+
const dir = ruleDirectory(raw);
|
|
192
|
+
return dir === null ? null : `${dir}*`;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* The path form both edges compare a request against: a directory request gains its trailing
|
|
197
|
+
* slash, a file request is left alone.
|
|
198
|
+
* @param {string} pathname
|
|
199
|
+
* @returns {string}
|
|
200
|
+
*/
|
|
201
|
+
export function redirectRequestPath(pathname) {
|
|
202
|
+
if (pathname === "/") return pathname;
|
|
203
|
+
const last = pathname.split("/").pop() ?? "";
|
|
204
|
+
if (last.includes(".")) return pathname;
|
|
205
|
+
return pathname.endsWith("/") ? pathname : `${pathname}/`;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Where a rule sends a request whose path continues `capture` past the rule's directory. Null
|
|
210
|
+
* when the result would not be a same-origin path — a capture such as `/evil.example` under a
|
|
211
|
+
* root target would otherwise become `//evil.example`.
|
|
212
|
+
* @param {string} to
|
|
213
|
+
* @param {string} capture
|
|
214
|
+
* @returns {string | null}
|
|
215
|
+
*/
|
|
216
|
+
export function ruleLocation(to, capture) {
|
|
217
|
+
const location = to.endsWith("/*") ? `${to.slice(0, -1)}${capture}` : to;
|
|
218
|
+
return location.startsWith("/") && !location.startsWith("//") && !location.includes("\\")
|
|
219
|
+
? location
|
|
220
|
+
: null;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* The reference matcher: which entry answers `pathname`, and where it sends it.
|
|
225
|
+
* Exact entries are compared against the request as sent and in its trailing-slash form; a rule
|
|
226
|
+
* matches when the trailing-slash form starts with its directory, and the longest directory wins.
|
|
227
|
+
* @param {readonly {from: string, to: string}[]} redirects
|
|
228
|
+
* @param {string} pathname the request path, without query
|
|
229
|
+
* @returns {{from: string, to: string, location: string} | null}
|
|
230
|
+
*/
|
|
231
|
+
export function resolveRedirect(redirects, pathname) {
|
|
232
|
+
const path = redirectRequestPath(pathname);
|
|
233
|
+
/** @type {{from: string, to: string} | null} */
|
|
234
|
+
let rule = null;
|
|
235
|
+
for (const entry of redirects ?? []) {
|
|
236
|
+
if (isRedirectRule(entry.from)) {
|
|
237
|
+
const dir = entry.from.slice(0, -1);
|
|
238
|
+
if (path.startsWith(dir) && (rule === null || dir.length > rule.from.length - 1)) rule = entry;
|
|
239
|
+
} else if (entry.from === pathname || entry.from === path) {
|
|
240
|
+
return { from: entry.from, to: entry.to, location: entry.to };
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
if (rule === null) return null;
|
|
244
|
+
const location = ruleLocation(rule.to, path.slice(rule.from.length - 1));
|
|
245
|
+
return location === null ? null : { from: rule.from, to: rule.to, location };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* The distinct directory depths of a set's rules, deepest first: `/blogs/news/*` is depth 2.
|
|
250
|
+
* The CloudFront function probes exactly these prefixes, so it reads nothing for a set without
|
|
251
|
+
* rules and one key per depth for a set with them.
|
|
252
|
+
* @param {readonly {from: string}[]} redirects
|
|
253
|
+
* @returns {number[]}
|
|
254
|
+
*/
|
|
255
|
+
export function redirectRuleDepths(redirects) {
|
|
256
|
+
const depths = new Set();
|
|
257
|
+
for (const { from } of redirects ?? []) {
|
|
258
|
+
if (isRedirectRule(from)) depths.add(from.split("/").filter(Boolean).length - 1);
|
|
259
|
+
}
|
|
260
|
+
return [...depths].sort((a, b) => b - a);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Turn a content collection's `redirects.json` (old slug -> new slug) into declared redirects
|
|
265
|
+
* under that collection's route prefix.
|
|
266
|
+
* @param {string} collection e.g. "blog"
|
|
267
|
+
* @param {Record<string, unknown>} rows
|
|
268
|
+
* @returns {DeclaredRedirect[]}
|
|
269
|
+
*/
|
|
270
|
+
export function redirectsFromCollection(collection, rows) {
|
|
271
|
+
if (!rows || typeof rows !== "object") return [];
|
|
272
|
+
/** @type {DeclaredRedirect[]} */
|
|
273
|
+
const out = [];
|
|
274
|
+
for (const [oldSlug, newSlug] of Object.entries(rows)) {
|
|
275
|
+
if (typeof newSlug !== "string" || !oldSlug || !newSlug) continue;
|
|
276
|
+
out.push({
|
|
277
|
+
from: `/${collection}/${oldSlug}/`,
|
|
278
|
+
to: `/${collection}/${newSlug}/`,
|
|
279
|
+
source: `content/${collection}/redirects.json`,
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
return out;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Turn the tenant-wide `content/redirects.json` map into root-relative declarations.
|
|
287
|
+
* Its keys and same-origin values are repository paths without a leading slash; an
|
|
288
|
+
* explicit HTTPS destination remains absolute and is validated by `buildRedirectSet`.
|
|
289
|
+
* A key ending in `/*` declares a rule (`"blogs/news/*": "blog/*"`).
|
|
290
|
+
* @param {Record<string, unknown>} rows
|
|
291
|
+
* @returns {DeclaredRedirect[]}
|
|
292
|
+
*/
|
|
293
|
+
export function redirectsFromSiteMap(rows) {
|
|
294
|
+
if (!rows || typeof rows !== "object" || Array.isArray(rows)) {
|
|
295
|
+
throw new TypeError(
|
|
296
|
+
"content/redirects.json must be an object mapping relative paths to destinations",
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
/** @type {DeclaredRedirect[]} */
|
|
300
|
+
const out = [];
|
|
301
|
+
for (const [from, rawTo] of Object.entries(rows)) {
|
|
302
|
+
if (
|
|
303
|
+
!from || from.startsWith("/") || typeof rawTo !== "string" || !rawTo || rawTo.startsWith("/")
|
|
304
|
+
) {
|
|
305
|
+
throw new TypeError(
|
|
306
|
+
"content/redirects.json keys and same-origin destinations must be non-empty paths without a leading slash",
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
const rootedFrom = `/${from}`;
|
|
310
|
+
const isExternal = /^[A-Za-z][A-Za-z0-9+.-]*:/.test(rawTo);
|
|
311
|
+
const rootedTo = isExternal ? rawTo : `/${rawTo}`;
|
|
312
|
+
const rule = rootedFrom.includes("*");
|
|
313
|
+
out.push({
|
|
314
|
+
from: normalizeRedirectSource(rootedFrom) ?? rootedFrom,
|
|
315
|
+
to: isExternal ? rawTo : (rule ? normalizeRuleTarget(rootedTo) : normalizeRedirectPath(rootedTo)) ?? rootedTo,
|
|
316
|
+
source: SITE_REDIRECTS_PATH,
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
return out;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* A content collection's redirect declaration, as it appears in a composed bundle's file list.
|
|
324
|
+
* `content/blog/redirects.json` declares redirects for the `blog` collection.
|
|
325
|
+
*/
|
|
326
|
+
export const COLLECTION_REDIRECTS_PATH = /^content\/([A-Za-z0-9][A-Za-z0-9_-]*)\/redirects\.json$/;
|
|
327
|
+
|
|
328
|
+
/** The tenant-wide redirect declaration, for legacy URLs that belong to no single collection. */
|
|
329
|
+
export const SITE_REDIRECTS_PATH = "content/redirects.json";
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Does this composed file declare redirects the composition lifts into `routes.aliases`?
|
|
333
|
+
* @param {string} path
|
|
334
|
+
* @returns {boolean}
|
|
335
|
+
*/
|
|
336
|
+
export function isRedirectDeclarationPath(path) {
|
|
337
|
+
return path === SITE_REDIRECTS_PATH || COLLECTION_REDIRECTS_PATH.test(path ?? "");
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Declared redirects for every content collection present in a composed revision.
|
|
342
|
+
*
|
|
343
|
+
* Reads the COMPOSED files rather than a working tree, so the redirects are bound to the same
|
|
344
|
+
* revision as the pages they point at — a rename and its redirect can never publish apart.
|
|
345
|
+
*
|
|
346
|
+
* @param {{path: string, json: unknown}[]} entries composed files, already parsed
|
|
347
|
+
* @returns {DeclaredRedirect[]}
|
|
348
|
+
*/
|
|
349
|
+
export function declaredRedirectsFromContent(entries) {
|
|
350
|
+
/** @type {DeclaredRedirect[]} */
|
|
351
|
+
const out = [];
|
|
352
|
+
for (const entry of entries ?? []) {
|
|
353
|
+
if (entry?.path === SITE_REDIRECTS_PATH) {
|
|
354
|
+
out.push(...redirectsFromSiteMap(/** @type {Record<string, unknown>} */ (entry.json)));
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
const match = COLLECTION_REDIRECTS_PATH.exec(entry?.path ?? "");
|
|
358
|
+
if (!match) continue;
|
|
359
|
+
out.push(
|
|
360
|
+
...redirectsFromCollection(
|
|
361
|
+
/** @type {string} */ (match[1]),
|
|
362
|
+
/** @type {Record<string, unknown>} */ (entry.json),
|
|
363
|
+
),
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
return out;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Turn publication-space aliases into declared redirects. Only `redirect-308` aliases produce a
|
|
371
|
+
* redirect; a `render` alias is served, not redirected.
|
|
372
|
+
* @param {{from?: string, behavior?: string, to?: string}[]} aliases
|
|
373
|
+
* @param {string} [source]
|
|
374
|
+
* @returns {DeclaredRedirect[]}
|
|
375
|
+
*/
|
|
376
|
+
export function redirectsFromAliases(aliases, source = "publication-space routes.aliases") {
|
|
377
|
+
if (!Array.isArray(aliases)) return [];
|
|
378
|
+
/** @type {DeclaredRedirect[]} */
|
|
379
|
+
const out = [];
|
|
380
|
+
for (const alias of aliases) {
|
|
381
|
+
if (!alias || alias.behavior !== "redirect-308") continue;
|
|
382
|
+
if (typeof alias.from !== "string" || typeof alias.to !== "string") continue;
|
|
383
|
+
out.push({ from: alias.from, to: alias.to, source });
|
|
384
|
+
}
|
|
385
|
+
return out;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* `resolveRedirect` over a whole set at once: exact entries in a map, rules in a list (a set has
|
|
390
|
+
* few rules and may have thousands of exact entries).
|
|
391
|
+
* @param {readonly {from: string, to: string}[]} entries
|
|
392
|
+
* @returns {(pathname: string) => {from: string, to: string, location: string} | null}
|
|
393
|
+
*/
|
|
394
|
+
export function redirectResolver(entries) {
|
|
395
|
+
/** @type {Map<string, string>} */
|
|
396
|
+
const exact = new Map();
|
|
397
|
+
/** @type {{from: string, to: string}[]} */
|
|
398
|
+
const rules = [];
|
|
399
|
+
for (const entry of entries ?? []) {
|
|
400
|
+
if (isRedirectRule(entry.from)) rules.push(entry);
|
|
401
|
+
else exact.set(entry.from, entry.to);
|
|
402
|
+
}
|
|
403
|
+
return (pathname) => {
|
|
404
|
+
for (const from of [pathname, redirectRequestPath(pathname)]) {
|
|
405
|
+
const to = exact.get(from);
|
|
406
|
+
if (to !== undefined) return { from, to, location: to };
|
|
407
|
+
}
|
|
408
|
+
return resolveRedirect(rules, pathname);
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Follow a location through the declared set until nothing redirects it again, so a chain
|
|
414
|
+
* (a -> b, b -> c, or a -> a path a rule covers) resolves to one hop. A chain costs an extra round
|
|
415
|
+
* trip on every visit and dilutes the signal the redirect exists to preserve. A cycle is
|
|
416
|
+
* unresolvable and refuses.
|
|
417
|
+
* @param {(pathname: string) => string | null} resolve
|
|
418
|
+
* @param {string} from
|
|
419
|
+
* @param {string} to
|
|
420
|
+
* @returns {{to: string} | {cycle: string[]}}
|
|
421
|
+
*/
|
|
422
|
+
function collapseChain(resolve, from, to) {
|
|
423
|
+
const seen = [from];
|
|
424
|
+
let location = to;
|
|
425
|
+
while (location.startsWith("/")) {
|
|
426
|
+
const next = resolve(location);
|
|
427
|
+
if (next === null) break;
|
|
428
|
+
if (seen.includes(location)) return { cycle: [...seen, location] };
|
|
429
|
+
seen.push(location);
|
|
430
|
+
location = next;
|
|
431
|
+
}
|
|
432
|
+
if (seen.includes(location)) return { cycle: [...seen, location] };
|
|
433
|
+
return { to: location };
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* The KVS bytes one entry occupies on the CloudFront plane: its key and its value.
|
|
438
|
+
* @param {{from: string, to: string}} entry
|
|
439
|
+
*/
|
|
440
|
+
function entryBytes(entry) {
|
|
441
|
+
return { key: REDIRECT_KEY_OVERHEAD_BYTES + byteLength(entry.from), value: byteLength(entry.to) };
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Build the substrate-neutral redirect set from every declared source.
|
|
446
|
+
* @param {DeclaredRedirect[]} declared
|
|
447
|
+
* @param {number} [budget]
|
|
448
|
+
* @returns {RedirectSet}
|
|
449
|
+
*/
|
|
450
|
+
export function buildRedirectSet(declared, budget = REDIRECT_MAP_BUDGET_BYTES) {
|
|
451
|
+
/** @type {string[]} */
|
|
452
|
+
const violations = [];
|
|
453
|
+
/** @type {Map<string, string>} */
|
|
454
|
+
const direct = new Map();
|
|
455
|
+
/** @type {Map<string, string>} */
|
|
456
|
+
const origin = new Map();
|
|
457
|
+
|
|
458
|
+
for (const entry of declared) {
|
|
459
|
+
const from = normalizeRedirectSource(entry.from);
|
|
460
|
+
if (from === null) {
|
|
461
|
+
violations.push(
|
|
462
|
+
`${entry.source}: redirect source is not a usable request path (a rule is "<dir>/*", never the whole site)`,
|
|
463
|
+
);
|
|
464
|
+
continue;
|
|
465
|
+
}
|
|
466
|
+
const rule = isRedirectRule(from);
|
|
467
|
+
const to = rule ? normalizeRuleTarget(entry.to) : normalizeRedirectDestination(entry.to);
|
|
468
|
+
if (to === null) {
|
|
469
|
+
violations.push(
|
|
470
|
+
rule
|
|
471
|
+
? `${entry.source}: rule "${from}" needs a same-origin target path, optionally ending in "/*"`
|
|
472
|
+
: `${entry.source}: redirect target is not a usable destination path`,
|
|
473
|
+
);
|
|
474
|
+
continue;
|
|
475
|
+
}
|
|
476
|
+
if (from === to) {
|
|
477
|
+
violations.push(`${entry.source}: "${from}" redirects to itself`);
|
|
478
|
+
continue;
|
|
479
|
+
}
|
|
480
|
+
const existing = direct.get(from);
|
|
481
|
+
if (existing !== undefined && existing !== to) {
|
|
482
|
+
violations.push(
|
|
483
|
+
`"${from}" is declared twice with different targets ("${existing}" in ${origin.get(from)}, "${to}" in ${entry.source})`,
|
|
484
|
+
);
|
|
485
|
+
continue;
|
|
486
|
+
}
|
|
487
|
+
direct.set(from, to);
|
|
488
|
+
origin.set(from, entry.source);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
const declaredEntries = [...direct].map(([from, to]) => ({ from, to }));
|
|
492
|
+
const rules = declaredEntries.filter((entry) => isRedirectRule(entry.from));
|
|
493
|
+
|
|
494
|
+
// A capturing rule's target directory must not sit under a rule's directory: every capture would
|
|
495
|
+
// then take a second hop, and a rule into its own directory never terminates. (A fixed target
|
|
496
|
+
// is collapsed through the rules below like any other chain.)
|
|
497
|
+
for (const rule of rules) {
|
|
498
|
+
if (!rule.to.endsWith("/*")) continue;
|
|
499
|
+
const target = rule.to.slice(0, -1);
|
|
500
|
+
for (const other of rules) {
|
|
501
|
+
if (target.startsWith(other.from.slice(0, -1))) {
|
|
502
|
+
violations.push(
|
|
503
|
+
`rule "${rule.from}" -> "${rule.to}" lands under rule "${other.from}", so every match would redirect twice`,
|
|
504
|
+
);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// An exact entry under a capturing rule's TARGET would make that capture a second hop
|
|
510
|
+
// (`/blogs/news/old/` -> `/blog/old/` -> `/blog/new/`). The composed one-hop entry is added
|
|
511
|
+
// beside it, so every request still takes exactly one redirect. A rule into the site root
|
|
512
|
+
// composes nothing: every exact entry is under `/`, and the paths it would add (`/pages/<any
|
|
513
|
+
// legacy path>/`) are not URLs anyone published.
|
|
514
|
+
/** @type {Map<string, string>} */
|
|
515
|
+
const composed = new Map();
|
|
516
|
+
for (const rule of rules) {
|
|
517
|
+
if (!rule.to.endsWith("/*") || rule.to === "/*") continue;
|
|
518
|
+
const target = rule.to.slice(0, -1);
|
|
519
|
+
const dir = rule.from.slice(0, -1);
|
|
520
|
+
for (const entry of declaredEntries) {
|
|
521
|
+
if (isRedirectRule(entry.from) || !entry.from.startsWith(target)) continue;
|
|
522
|
+
const from = `${dir}${entry.from.slice(target.length)}`;
|
|
523
|
+
if (!direct.has(from)) composed.set(from, entry.to);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
const entries = [...declaredEntries, ...[...composed].map(([from, to]) => ({ from, to }))];
|
|
527
|
+
const resolver = redirectResolver(entries);
|
|
528
|
+
/** @param {string} pathname */
|
|
529
|
+
const resolve = (pathname) => resolver(pathname)?.location ?? null;
|
|
530
|
+
|
|
531
|
+
/** @type {{from: string, to: string}[]} */
|
|
532
|
+
const redirects = [];
|
|
533
|
+
for (const entry of [...entries].sort((a, b) => a.from.localeCompare(b.from))) {
|
|
534
|
+
if (isRedirectRule(entry.from) && entry.to.endsWith("/*")) {
|
|
535
|
+
redirects.push(entry);
|
|
536
|
+
continue;
|
|
537
|
+
}
|
|
538
|
+
const resolved = collapseChain(resolve, entry.from, entry.to);
|
|
539
|
+
if ("cycle" in resolved) {
|
|
540
|
+
violations.push(`redirect cycle: ${resolved.cycle.join(" -> ")}`);
|
|
541
|
+
continue;
|
|
542
|
+
}
|
|
543
|
+
redirects.push({ from: entry.from, to: resolved.to });
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
const depths = redirectRuleDepths(redirects);
|
|
547
|
+
if (depths.length > REDIRECT_RULE_MAX_DEPTHS) {
|
|
548
|
+
violations.push(
|
|
549
|
+
`rules span ${depths.length} directory depths; the edge reads one key per depth, so at most ${REDIRECT_RULE_MAX_DEPTHS} are allowed`,
|
|
550
|
+
);
|
|
551
|
+
}
|
|
552
|
+
let bytes = 0;
|
|
553
|
+
for (const entry of redirects) {
|
|
554
|
+
const size = entryBytes(entry);
|
|
555
|
+
if (size.key > KVS_KEY_LIMIT_BYTES || size.value > KVS_VALUE_LIMIT_BYTES) {
|
|
556
|
+
violations.push(
|
|
557
|
+
`"${entry.from}" is too long for one edge key-value entry (key ${size.key}/${KVS_KEY_LIMIT_BYTES} bytes, target ${size.value}/${KVS_VALUE_LIMIT_BYTES} bytes)`,
|
|
558
|
+
);
|
|
559
|
+
}
|
|
560
|
+
bytes += size.key + size.value;
|
|
561
|
+
}
|
|
562
|
+
if (bytes > budget) {
|
|
563
|
+
violations.push(
|
|
564
|
+
`redirect set is ${bytes} bytes, over the ${budget}-byte edge budget (${redirects.length} redirects) — collapse a URL family into one "<dir>/*" rule`,
|
|
565
|
+
);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
return {
|
|
569
|
+
ok: violations.length === 0,
|
|
570
|
+
redirects,
|
|
571
|
+
violations,
|
|
572
|
+
bytes,
|
|
573
|
+
budget,
|
|
574
|
+
rules: redirects.filter((entry) => isRedirectRule(entry.from)).length,
|
|
575
|
+
};
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* The declared set as publication-space aliases — the shape `routes.aliases` already carries and
|
|
580
|
+
* `publicationRedirects()` (scripts/publish/lib/publication-space-release.mjs) already consumes on
|
|
581
|
+
* its way into the live pointer. Emitting this rather than a parallel structure is what keeps
|
|
582
|
+
* redirects ONE contract: the edge function, the pointer value, and the declaration all agree
|
|
583
|
+
* because there is only one of them.
|
|
584
|
+
*
|
|
585
|
+
* An alias is a mount: it redirects its path and everything under it, appending the remainder to
|
|
586
|
+
* the target. A capturing rule (`/x/*` -> `/y/*`) is exactly that mount (`/x` -> `/y`). A rule
|
|
587
|
+
* that sends a family to one page, or captures into the site root, has no mount form and throws.
|
|
588
|
+
*
|
|
589
|
+
* Returns an empty array when the set refused, so a caller that ignores `ok` cannot publish a
|
|
590
|
+
* half-built topology.
|
|
591
|
+
* @param {RedirectSet} set
|
|
592
|
+
* @returns {{from: string, behavior: "redirect-308", to: string}[]}
|
|
593
|
+
*/
|
|
594
|
+
export function aliasesFromRedirectSet(set) {
|
|
595
|
+
if (!set.ok) return [];
|
|
596
|
+
return set.redirects.map(({ from, to }) => {
|
|
597
|
+
if (!isRedirectRule(from)) {
|
|
598
|
+
return { from, behavior: /** @type {"redirect-308"} */ ("redirect-308"), to };
|
|
599
|
+
}
|
|
600
|
+
if (!to.endsWith("/*") || to === "/*") {
|
|
601
|
+
throw new TypeError(`rule "${from}" -> "${to}" has no publication-space alias form`);
|
|
602
|
+
}
|
|
603
|
+
return {
|
|
604
|
+
from: from.slice(0, -2),
|
|
605
|
+
behavior: /** @type {"redirect-308"} */ ("redirect-308"),
|
|
606
|
+
to: to.slice(0, -2),
|
|
607
|
+
};
|
|
608
|
+
});
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/**
|
|
612
|
+
* @param {RedirectSet} set
|
|
613
|
+
* @returns {string}
|
|
614
|
+
*/
|
|
615
|
+
export function formatRedirectSetReport(set) {
|
|
616
|
+
const lines = [];
|
|
617
|
+
lines.push(
|
|
618
|
+
`== Redirect materialization == ${set.redirects.length} redirect(s) (${set.rules} rule(s)), ${set.bytes}/${set.budget} bytes`,
|
|
619
|
+
);
|
|
620
|
+
for (const violation of set.violations) lines.push(` REFUSE: ${violation}`);
|
|
621
|
+
lines.push(
|
|
622
|
+
set.ok
|
|
623
|
+
? `OK — every declared redirect materializes at ${REDIRECT_STATUS}.`
|
|
624
|
+
: `REFUSED — ${set.violations.length} violation(s); nothing emitted.`,
|
|
625
|
+
);
|
|
626
|
+
return lines.join("\n");
|
|
627
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/storefront-runner",
|
|
3
|
-
"version": "2.2.
|
|
3
|
+
"version": "2.2.86",
|
|
4
4
|
"license": "SEE LICENSE IN LICENSE",
|
|
5
5
|
"description": "World-shareable storefront runner: multi-tenant renderer on Astro/Cloudflare. No control plane.",
|
|
6
6
|
"packageManager": "pnpm@11.9.0",
|
|
@@ -1,14 +1,19 @@
|
|
|
1
|
-
/** Types for the
|
|
1
|
+
/** Types for the redirect contract (agency-kit/tools/lib/redirect-declaration.mjs), re-exported here. */
|
|
2
2
|
|
|
3
3
|
export const REDIRECT_STATUS: 308;
|
|
4
|
-
export const
|
|
5
|
-
export const
|
|
4
|
+
export const KVS_STORE_LIMIT_BYTES: number;
|
|
5
|
+
export const KVS_KEY_LIMIT_BYTES: number;
|
|
6
|
+
export const KVS_VALUE_LIMIT_BYTES: number;
|
|
7
|
+
export const KVS_RESERVED_BYTES: number;
|
|
6
8
|
export const REDIRECT_MAP_BUDGET_BYTES: number;
|
|
9
|
+
export const REDIRECT_KEY_OVERHEAD_BYTES: number;
|
|
10
|
+
export const REDIRECT_RULE_MAX_DEPTHS: number;
|
|
7
11
|
export const COLLECTION_REDIRECTS_PATH: RegExp;
|
|
8
12
|
export const SITE_REDIRECTS_PATH: "content/redirects.json";
|
|
9
13
|
export const WHOLE_SITE_DELIVERY_CONTRACT: string;
|
|
10
14
|
|
|
11
15
|
export interface DeclaredRedirect {
|
|
16
|
+
/** An exact request path, or a `/<dir>/*` rule. */
|
|
12
17
|
from: string;
|
|
13
18
|
to: string;
|
|
14
19
|
/** Which declaration produced it, named in any refusal. */
|
|
@@ -21,9 +26,24 @@ export interface RedirectSet {
|
|
|
21
26
|
violations: string[];
|
|
22
27
|
bytes: number;
|
|
23
28
|
budget: number;
|
|
29
|
+
/** How many entries are `/<dir>/*` rules. */
|
|
30
|
+
rules: number;
|
|
24
31
|
}
|
|
25
32
|
|
|
26
33
|
export function normalizeRedirectPath(raw: string): string | null;
|
|
34
|
+
export function normalizeRedirectSource(raw: string): string | null;
|
|
35
|
+
export function ruleDirectory(raw: string): string | null;
|
|
36
|
+
export function isRedirectRule(from: string): boolean;
|
|
37
|
+
export function redirectRequestPath(pathname: string): string;
|
|
38
|
+
export function ruleLocation(to: string, capture: string): string | null;
|
|
39
|
+
export function resolveRedirect(
|
|
40
|
+
redirects: readonly { from: string; to: string }[],
|
|
41
|
+
pathname: string,
|
|
42
|
+
): { from: string; to: string; location: string } | null;
|
|
43
|
+
export function redirectResolver(
|
|
44
|
+
entries: readonly { from: string; to: string }[],
|
|
45
|
+
): (pathname: string) => { from: string; to: string; location: string } | null;
|
|
46
|
+
export function redirectRuleDepths(redirects: readonly { from: string }[]): number[];
|
|
27
47
|
export function redirectsFromCollection(
|
|
28
48
|
collection: string,
|
|
29
49
|
rows: Record<string, unknown>,
|
|
@@ -38,7 +58,6 @@ export function redirectsFromAliases(
|
|
|
38
58
|
source?: string,
|
|
39
59
|
): DeclaredRedirect[];
|
|
40
60
|
export function buildRedirectSet(declared: DeclaredRedirect[], budget?: number): RedirectSet;
|
|
41
|
-
export function serializeRedirectMap(redirects: { from: string; to: string }[]): string;
|
|
42
61
|
export function aliasesFromRedirectSet(
|
|
43
62
|
set: RedirectSet,
|
|
44
63
|
): { from: string; behavior: "redirect-308"; to: string }[];
|
|
@@ -1,379 +1,5 @@
|
|
|
1
1
|
// @ts-check
|
|
2
|
-
// The
|
|
3
|
-
// the publish
|
|
4
|
-
//
|
|
5
|
-
|
|
6
|
-
// request-time decision in the Worker: it must exist in the published artifact. Both substrates
|
|
7
|
-
// express it the same way — a precomputed map consulted by the single permitted edge function —
|
|
8
|
-
// so the set below is substrate-neutral and the adapters only serialize it.
|
|
9
|
-
//
|
|
10
|
-
// Redirects are DECLARED, never derived from a tenant id. Three declaration sources normalize into
|
|
11
|
-
// the one `redirect-308` contract `PublicationSpaceAliasV1` already defines:
|
|
12
|
-
// - publication-space `routes.aliases` entries whose behavior is `redirect-308`;
|
|
13
|
-
// - a content collection's `redirects.json` (old slug -> new slug), which becomes
|
|
14
|
-
// `/<collection>/<old>/` -> `/<collection>/<new>/`;
|
|
15
|
-
// - the tenant-wide `content/redirects.json` (relative path -> relative path or an explicit
|
|
16
|
-
// HTTPS destination), for legacy URLs that belong to no single collection.
|
|
17
|
-
// Adding a tenant is a declaration, not a code change.
|
|
18
|
-
//
|
|
19
|
-
// 308 rather than 301 throughout: both pass ranking signal, and 308 additionally preserves the
|
|
20
|
-
// request method, so a redirected non-GET is not silently rewritten. The declared alias contract
|
|
21
|
-
// already specifies 308; collection renames join it rather than introducing a second status.
|
|
22
|
-
//
|
|
23
|
-
// Fails closed. An ambiguous declaration (one `from`, two targets), a cycle, or a set that exceeds
|
|
24
|
-
// the substrate's ceiling REFUSES rather than emitting a partial map — a redirect that silently
|
|
25
|
-
// does not exist is the failure this mechanism exists to prevent.
|
|
26
|
-
|
|
27
|
-
/** The status every materialized redirect carries. */
|
|
28
|
-
export const REDIRECT_STATUS = 308;
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* CloudFront Functions cap total code size at 10 KB, and the map is baked into the one
|
|
32
|
-
* viewer-request function the public path already carries. The emitter refuses above the budget
|
|
33
|
-
* left for it rather than shipping a function that cannot publish.
|
|
34
|
-
*/
|
|
35
|
-
export const EDGE_FUNCTION_CODE_LIMIT_BYTES = 10240;
|
|
36
|
-
/** Bump only when the meaning of a whole-site delivery manifest changes without changing its values. */
|
|
37
|
-
export const WHOLE_SITE_DELIVERY_CONTRACT = "whole-site-delivery-v1";
|
|
38
|
-
/** Reserved for the function's own logic, so the map gets the rest. */
|
|
39
|
-
export const EDGE_FUNCTION_RESERVED_BYTES = 2048;
|
|
40
|
-
/** Bytes available to the serialized map. */
|
|
41
|
-
export const REDIRECT_MAP_BUDGET_BYTES =
|
|
42
|
-
EDGE_FUNCTION_CODE_LIMIT_BYTES - EDGE_FUNCTION_RESERVED_BYTES;
|
|
43
|
-
|
|
44
|
-
/** JSON is not quite JS: these are legal in a JSON string but terminate a line to a JS parser. */
|
|
45
|
-
const LINE_SEPARATOR = "
";
|
|
46
|
-
const PARAGRAPH_SEPARATOR = "
";
|
|
47
|
-
|
|
48
|
-
/**
|
|
49
|
-
* @typedef {Object} DeclaredRedirect
|
|
50
|
-
* @property {string} from root-absolute request path
|
|
51
|
-
* @property {string} to root-absolute destination path
|
|
52
|
-
* @property {string} source which declaration produced it, for the report
|
|
53
|
-
*/
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* @typedef {Object} RedirectSet
|
|
57
|
-
* @property {boolean} ok
|
|
58
|
-
* @property {{from: string, to: string}[]} redirects normalized, deduped, chain-collapsed
|
|
59
|
-
* @property {string[]} violations why it refused; empty when ok
|
|
60
|
-
* @property {number} bytes serialized size of the map
|
|
61
|
-
* @property {number} budget bytes available to the map
|
|
62
|
-
*/
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* Normalize a declared path to the form the edge function compares against: root-absolute, no
|
|
66
|
-
* query or fragment, with a trailing slash unless the last segment names a file.
|
|
67
|
-
* Returns null when the value cannot be a same-origin request path.
|
|
68
|
-
* @param {string} raw
|
|
69
|
-
* @returns {string | null}
|
|
70
|
-
*/
|
|
71
|
-
export function normalizeRedirectPath(raw) {
|
|
72
|
-
if (typeof raw !== "string") return null;
|
|
73
|
-
const trimmed = raw.trim();
|
|
74
|
-
if (!trimmed.startsWith("/")) return null;
|
|
75
|
-
// Protocol-relative (`//host/...`) would leave the origin.
|
|
76
|
-
if (trimmed.startsWith("//")) return null;
|
|
77
|
-
const withoutSuffix = trimmed.split("?")[0].split("#")[0];
|
|
78
|
-
if (withoutSuffix.includes("..")) return null;
|
|
79
|
-
const collapsed = withoutSuffix.replace(/\/{2,}/g, "/");
|
|
80
|
-
if (collapsed === "/") return "/";
|
|
81
|
-
// The last NON-EMPTY segment: a trailing slash otherwise hides the filename from this test.
|
|
82
|
-
const segments = collapsed.split("/").filter(Boolean);
|
|
83
|
-
const last = segments[segments.length - 1] ?? "";
|
|
84
|
-
const namesFile = last.includes(".");
|
|
85
|
-
if (namesFile) return collapsed.replace(/\/$/, "");
|
|
86
|
-
return collapsed.endsWith("/") ? collapsed : `${collapsed}/`;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
/**
|
|
90
|
-
* Turn a content collection's `redirects.json` (old slug -> new slug) into declared redirects
|
|
91
|
-
* under that collection's route prefix.
|
|
92
|
-
* @param {string} collection e.g. "blog"
|
|
93
|
-
* @param {Record<string, unknown>} rows
|
|
94
|
-
* @returns {DeclaredRedirect[]}
|
|
95
|
-
*/
|
|
96
|
-
export function redirectsFromCollection(collection, rows) {
|
|
97
|
-
if (!rows || typeof rows !== "object") return [];
|
|
98
|
-
/** @type {DeclaredRedirect[]} */
|
|
99
|
-
const out = [];
|
|
100
|
-
for (const [oldSlug, newSlug] of Object.entries(rows)) {
|
|
101
|
-
if (typeof newSlug !== "string" || !oldSlug || !newSlug) continue;
|
|
102
|
-
out.push({
|
|
103
|
-
from: `/${collection}/${oldSlug}/`,
|
|
104
|
-
to: `/${collection}/${newSlug}/`,
|
|
105
|
-
source: `content/${collection}/redirects.json`,
|
|
106
|
-
});
|
|
107
|
-
}
|
|
108
|
-
return out;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
/**
|
|
112
|
-
* Turn the tenant-wide `content/redirects.json` map into root-relative declarations.
|
|
113
|
-
* Its keys and same-origin values are repository paths without a leading slash; an
|
|
114
|
-
* explicit HTTPS destination remains absolute and is validated by `buildRedirectSet`.
|
|
115
|
-
* @param {Record<string, unknown>} rows
|
|
116
|
-
* @returns {DeclaredRedirect[]}
|
|
117
|
-
*/
|
|
118
|
-
export function redirectsFromSiteMap(rows) {
|
|
119
|
-
if (!rows || typeof rows !== "object" || Array.isArray(rows)) {
|
|
120
|
-
throw new TypeError(
|
|
121
|
-
"content/redirects.json must be an object mapping relative paths to destinations",
|
|
122
|
-
);
|
|
123
|
-
}
|
|
124
|
-
/** @type {DeclaredRedirect[]} */
|
|
125
|
-
const out = [];
|
|
126
|
-
for (const [from, rawTo] of Object.entries(rows)) {
|
|
127
|
-
if (
|
|
128
|
-
!from || from.startsWith("/") || typeof rawTo !== "string" || !rawTo || rawTo.startsWith("/")
|
|
129
|
-
) {
|
|
130
|
-
throw new TypeError(
|
|
131
|
-
"content/redirects.json keys and same-origin destinations must be non-empty paths without a leading slash",
|
|
132
|
-
);
|
|
133
|
-
}
|
|
134
|
-
const rootedFrom = `/${from}`;
|
|
135
|
-
const isExternal = /^[A-Za-z][A-Za-z0-9+.-]*:/.test(rawTo);
|
|
136
|
-
const rootedTo = isExternal ? rawTo : `/${rawTo}`;
|
|
137
|
-
out.push({
|
|
138
|
-
from: normalizeRedirectPath(rootedFrom) ?? rootedFrom,
|
|
139
|
-
to: isExternal ? rawTo : normalizeRedirectPath(rootedTo) ?? rootedTo,
|
|
140
|
-
source: SITE_REDIRECTS_PATH,
|
|
141
|
-
});
|
|
142
|
-
}
|
|
143
|
-
return out;
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
/**
|
|
147
|
-
* A content collection's redirect declaration, as it appears in a composed bundle's file list.
|
|
148
|
-
* `content/blog/redirects.json` declares redirects for the `blog` collection.
|
|
149
|
-
*/
|
|
150
|
-
export const COLLECTION_REDIRECTS_PATH = /^content\/([A-Za-z0-9][A-Za-z0-9_-]*)\/redirects\.json$/;
|
|
151
|
-
|
|
152
|
-
/** The tenant-wide redirect declaration, for legacy URLs that belong to no single collection. */
|
|
153
|
-
export const SITE_REDIRECTS_PATH = "content/redirects.json";
|
|
154
|
-
|
|
155
|
-
/**
|
|
156
|
-
* Does this composed file declare redirects the composition lifts into `routes.aliases`?
|
|
157
|
-
* @param {string} path
|
|
158
|
-
* @returns {boolean}
|
|
159
|
-
*/
|
|
160
|
-
export function isRedirectDeclarationPath(path) {
|
|
161
|
-
return path === SITE_REDIRECTS_PATH || COLLECTION_REDIRECTS_PATH.test(path ?? "");
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
/**
|
|
165
|
-
* Declared redirects for every content collection present in a composed revision.
|
|
166
|
-
*
|
|
167
|
-
* Reads the COMPOSED files rather than a working tree, so the redirects are bound to the same
|
|
168
|
-
* revision as the pages they point at — a rename and its redirect can never publish apart.
|
|
169
|
-
*
|
|
170
|
-
* @param {{path: string, json: unknown}[]} entries composed files, already parsed
|
|
171
|
-
* @returns {DeclaredRedirect[]}
|
|
172
|
-
*/
|
|
173
|
-
export function declaredRedirectsFromContent(entries) {
|
|
174
|
-
/** @type {DeclaredRedirect[]} */
|
|
175
|
-
const out = [];
|
|
176
|
-
for (const entry of entries ?? []) {
|
|
177
|
-
if (entry?.path === SITE_REDIRECTS_PATH) {
|
|
178
|
-
out.push(...redirectsFromSiteMap(/** @type {Record<string, unknown>} */ (entry.json)));
|
|
179
|
-
continue;
|
|
180
|
-
}
|
|
181
|
-
const match = COLLECTION_REDIRECTS_PATH.exec(entry?.path ?? "");
|
|
182
|
-
if (!match) continue;
|
|
183
|
-
out.push(
|
|
184
|
-
...redirectsFromCollection(
|
|
185
|
-
/** @type {string} */ (match[1]),
|
|
186
|
-
/** @type {Record<string, unknown>} */ (entry.json),
|
|
187
|
-
),
|
|
188
|
-
);
|
|
189
|
-
}
|
|
190
|
-
return out;
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
/**
|
|
194
|
-
* Normalize a same-origin path or a deliberately external HTTPS destination.
|
|
195
|
-
*
|
|
196
|
-
* A source is always same-origin; a TARGET may leave the origin, but only when the declaration
|
|
197
|
-
* says so in full: `https:` only, no credentials, no query, no fragment. Anything else — including
|
|
198
|
-
* every shape that merely looks absolute — is not a usable destination and refuses.
|
|
199
|
-
* @param {string} raw
|
|
200
|
-
* @returns {string | null}
|
|
201
|
-
*/
|
|
202
|
-
function normalizeRedirectDestination(/** @type {string} */ raw) {
|
|
203
|
-
const sameOrigin = normalizeRedirectPath(raw);
|
|
204
|
-
if (sameOrigin !== null) return sameOrigin;
|
|
205
|
-
try {
|
|
206
|
-
const url = new URL(raw);
|
|
207
|
-
if (url.protocol !== "https:" || url.username || url.password || url.search || url.hash) {
|
|
208
|
-
return null;
|
|
209
|
-
}
|
|
210
|
-
return url.toString().replace(/\/$/, url.pathname === "/" ? "/" : "");
|
|
211
|
-
} catch {
|
|
212
|
-
return null;
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
/**
|
|
217
|
-
* Turn publication-space aliases into declared redirects. Only `redirect-308` aliases produce a
|
|
218
|
-
* redirect; a `render` alias is served, not redirected.
|
|
219
|
-
* @param {{from?: string, behavior?: string, to?: string}[]} aliases
|
|
220
|
-
* @param {string} [source]
|
|
221
|
-
* @returns {DeclaredRedirect[]}
|
|
222
|
-
*/
|
|
223
|
-
export function redirectsFromAliases(aliases, source = "publication-space routes.aliases") {
|
|
224
|
-
if (!Array.isArray(aliases)) return [];
|
|
225
|
-
/** @type {DeclaredRedirect[]} */
|
|
226
|
-
const out = [];
|
|
227
|
-
for (const alias of aliases) {
|
|
228
|
-
if (!alias || alias.behavior !== "redirect-308") continue;
|
|
229
|
-
if (typeof alias.from !== "string" || typeof alias.to !== "string") continue;
|
|
230
|
-
out.push({ from: alias.from, to: alias.to, source });
|
|
231
|
-
}
|
|
232
|
-
return out;
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
/**
|
|
236
|
-
* Follow `from -> to` through the declared map so a rename chain (a -> b, b -> c) resolves to a
|
|
237
|
-
* single hop (a -> c). A chain costs an extra round trip on every visit and dilutes the signal the
|
|
238
|
-
* redirect exists to preserve. A cycle is unresolvable and refuses.
|
|
239
|
-
* @param {Map<string, string>} direct
|
|
240
|
-
* @param {string} from
|
|
241
|
-
* @returns {{to: string} | {cycle: string[]}}
|
|
242
|
-
*/
|
|
243
|
-
function collapseChain(direct, from) {
|
|
244
|
-
const seen = [from];
|
|
245
|
-
let to = /** @type {string} */ (direct.get(from));
|
|
246
|
-
while (direct.has(to)) {
|
|
247
|
-
if (seen.includes(to)) return { cycle: [...seen, to] };
|
|
248
|
-
seen.push(to);
|
|
249
|
-
to = /** @type {string} */ (direct.get(to));
|
|
250
|
-
}
|
|
251
|
-
return { to };
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
/**
|
|
255
|
-
* Build the substrate-neutral redirect set from every declared source.
|
|
256
|
-
* @param {DeclaredRedirect[]} declared
|
|
257
|
-
* @param {number} [budget]
|
|
258
|
-
* @returns {RedirectSet}
|
|
259
|
-
*/
|
|
260
|
-
export function buildRedirectSet(declared, budget = REDIRECT_MAP_BUDGET_BYTES) {
|
|
261
|
-
/** @type {string[]} */
|
|
262
|
-
const violations = [];
|
|
263
|
-
/** @type {Map<string, string>} */
|
|
264
|
-
const direct = new Map();
|
|
265
|
-
/** @type {Map<string, string>} */
|
|
266
|
-
const origin = new Map();
|
|
267
|
-
|
|
268
|
-
for (const entry of declared) {
|
|
269
|
-
const from = normalizeRedirectPath(entry.from);
|
|
270
|
-
const to = normalizeRedirectDestination(entry.to);
|
|
271
|
-
if (from === null) {
|
|
272
|
-
violations.push(`${entry.source}: redirect source is not a usable request path`);
|
|
273
|
-
continue;
|
|
274
|
-
}
|
|
275
|
-
if (to === null) {
|
|
276
|
-
violations.push(`${entry.source}: redirect target is not a usable destination path`);
|
|
277
|
-
continue;
|
|
278
|
-
}
|
|
279
|
-
if (from === to) {
|
|
280
|
-
violations.push(`${entry.source}: "${from}" redirects to itself`);
|
|
281
|
-
continue;
|
|
282
|
-
}
|
|
283
|
-
const existing = direct.get(from);
|
|
284
|
-
if (existing !== undefined && existing !== to) {
|
|
285
|
-
violations.push(
|
|
286
|
-
`"${from}" is declared twice with different targets ("${existing}" in ${origin.get(from)}, "${to}" in ${entry.source})`,
|
|
287
|
-
);
|
|
288
|
-
continue;
|
|
289
|
-
}
|
|
290
|
-
direct.set(from, to);
|
|
291
|
-
origin.set(from, entry.source);
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
/** @type {{from: string, to: string}[]} */
|
|
295
|
-
const redirects = [];
|
|
296
|
-
for (const from of [...direct.keys()].sort()) {
|
|
297
|
-
const resolved = collapseChain(direct, from);
|
|
298
|
-
if ("cycle" in resolved) {
|
|
299
|
-
violations.push(`redirect cycle: ${resolved.cycle.join(" -> ")}`);
|
|
300
|
-
continue;
|
|
301
|
-
}
|
|
302
|
-
redirects.push({ from, to: resolved.to });
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
const bytes = serializeRedirectMap(redirects).length;
|
|
306
|
-
if (bytes > budget) {
|
|
307
|
-
violations.push(
|
|
308
|
-
`redirect map is ${bytes} bytes, over the ${budget}-byte edge budget (${redirects.length} redirects) — move the map to a key-value store before declaring more`,
|
|
309
|
-
);
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
return { ok: violations.length === 0, redirects, violations, bytes, budget };
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
/**
|
|
316
|
-
* The map exactly as an edge function embeds it: compact JSON, keys sorted, so the same declared
|
|
317
|
-
* input always produces byte-identical output and the published artifact stays deterministic.
|
|
318
|
-
*
|
|
319
|
-
* The output is embedded as a JS literal in generated edge-function source, and its keys and values
|
|
320
|
-
* are tenant-declared strings. `JSON.stringify` escapes quotes and backslashes, which closes the
|
|
321
|
-
* obvious break-out; the escapes below close the rest of the JSON-is-not-quite-JS gap — the two
|
|
322
|
-
* separator code points are legal in JSON strings but are line terminators to a JS parser, and `<`
|
|
323
|
-
* is neutralized so the same bytes stay inert if the map is ever inlined into markup.
|
|
324
|
-
* @param {{from: string, to: string}[]} redirects
|
|
325
|
-
* @returns {string}
|
|
326
|
-
*/
|
|
327
|
-
export function serializeRedirectMap(redirects) {
|
|
328
|
-
/** @type {Record<string, string>} */
|
|
329
|
-
const map = {};
|
|
330
|
-
for (const { from, to } of [...redirects].sort((a, b) => a.from.localeCompare(b.from))) {
|
|
331
|
-
map[from] = to;
|
|
332
|
-
}
|
|
333
|
-
return JSON.stringify(map)
|
|
334
|
-
.split(LINE_SEPARATOR)
|
|
335
|
-
.join("\\u2028")
|
|
336
|
-
.split(PARAGRAPH_SEPARATOR)
|
|
337
|
-
.join("\\u2029")
|
|
338
|
-
.split("<")
|
|
339
|
-
.join("\\u003c");
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
/**
|
|
343
|
-
* The declared set as publication-space aliases — the shape `routes.aliases` already carries and
|
|
344
|
-
* `publicationRedirects()` (scripts/publish/lib/publication-space-release.mjs) already consumes on
|
|
345
|
-
* its way into the live pointer. Emitting this rather than a parallel structure is what keeps
|
|
346
|
-
* redirects ONE contract: the edge function, the pointer value, and the declaration all agree
|
|
347
|
-
* because there is only one of them.
|
|
348
|
-
*
|
|
349
|
-
* Returns an empty array when the set refused, so a caller that ignores `ok` cannot publish a
|
|
350
|
-
* half-built topology.
|
|
351
|
-
* @param {RedirectSet} set
|
|
352
|
-
* @returns {{from: string, behavior: "redirect-308", to: string}[]}
|
|
353
|
-
*/
|
|
354
|
-
export function aliasesFromRedirectSet(set) {
|
|
355
|
-
if (!set.ok) return [];
|
|
356
|
-
return set.redirects.map(({ from, to }) => ({
|
|
357
|
-
from,
|
|
358
|
-
behavior: /** @type {"redirect-308"} */ ("redirect-308"),
|
|
359
|
-
to,
|
|
360
|
-
}));
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
/**
|
|
364
|
-
* @param {RedirectSet} set
|
|
365
|
-
* @returns {string}
|
|
366
|
-
*/
|
|
367
|
-
export function formatRedirectSetReport(set) {
|
|
368
|
-
const lines = [];
|
|
369
|
-
lines.push(
|
|
370
|
-
`== Redirect materialization == ${set.redirects.length} redirect(s), ${set.bytes}/${set.budget} bytes`,
|
|
371
|
-
);
|
|
372
|
-
for (const violation of set.violations) lines.push(` REFUSE: ${violation}`);
|
|
373
|
-
lines.push(
|
|
374
|
-
set.ok
|
|
375
|
-
? `OK — every declared redirect materializes at ${REDIRECT_STATUS}.`
|
|
376
|
-
: `REFUSED — ${set.violations.length} violation(s); nothing emitted.`,
|
|
377
|
-
);
|
|
378
|
-
return lines.join("\n");
|
|
379
|
-
}
|
|
2
|
+
// The redirect contract — declaration format, edge budget, reference matcher — is the agency kit's
|
|
3
|
+
// pure module, so a store developer validates a map with the kit alone and a publish applies the
|
|
4
|
+
// same code. This subpath is how the platform imports it.
|
|
5
|
+
export * from "../../../agency-kit/tools/lib/redirect-declaration.mjs";
|
|
@@ -169,7 +169,10 @@ export interface StaticBundleManifest {
|
|
|
169
169
|
htmlCacheControl?: string;
|
|
170
170
|
/** T6 classification: relPath -> {hash, kind}. Absent on a pre-T6 manifest — every lookup then falls back to the old versions/<digest>/<rel> key (see `resolveStaticBundleObjectKey`). */
|
|
171
171
|
files?: Record<string, StaticBundleFileEntry>;
|
|
172
|
-
/**
|
|
172
|
+
/**
|
|
173
|
+
* Version-bound whole-site redirects materialized from content/redirects.json: exact paths and
|
|
174
|
+
* `/<dir>/*` rules (see `publication-redirects.mjs` for the declaration contract).
|
|
175
|
+
*/
|
|
173
176
|
redirects?: Array<{ from: string; to: string }>;
|
|
174
177
|
}
|
|
175
178
|
|
|
@@ -179,6 +182,50 @@ function isManifestRedirectPath(value: string): boolean {
|
|
|
179
182
|
!value.split("/").includes("..");
|
|
180
183
|
}
|
|
181
184
|
|
|
185
|
+
/** A `/<dir>/*` rule (or rule target): `*` only as the whole last segment. */
|
|
186
|
+
function isManifestRedirectRule(value: string): boolean {
|
|
187
|
+
return value.endsWith("/*") && value.indexOf("*") === value.length - 1 &&
|
|
188
|
+
isManifestRedirectPath(value.slice(0, -1));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Where a rule sends a request whose path continues `capture` past the rule's directory; null
|
|
193
|
+
* unless the result is a same-origin path. Same semantics as `ruleLocation` in
|
|
194
|
+
* `publication-redirects.mjs` and the CloudFront function — the three are held together by
|
|
195
|
+
* `tests/fixtures/redirect-match-cases.json`.
|
|
196
|
+
*/
|
|
197
|
+
function ruleLocation(to: string, capture: string): string | null {
|
|
198
|
+
const location = to.endsWith("/*") ? `${to.slice(0, -1)}${capture}` : to;
|
|
199
|
+
return location.startsWith("/") && !location.startsWith("//") && !location.includes("\\")
|
|
200
|
+
? location
|
|
201
|
+
: null;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* The redirect the manifest declares for `pathname`, as a location, or null. An exact entry wins
|
|
206
|
+
* outright; otherwise the rule with the longest directory that the trailing-slash form of the path
|
|
207
|
+
* starts with decides — and when its location is unusable there is no redirect.
|
|
208
|
+
*/
|
|
209
|
+
function staticBundleRedirectLocation(
|
|
210
|
+
redirects: StaticBundleManifest["redirects"],
|
|
211
|
+
pathname: string,
|
|
212
|
+
): string | null {
|
|
213
|
+
if (!redirects?.length) return null;
|
|
214
|
+
const path = pathname === "/" || pathname.split("/").pop()?.includes(".")
|
|
215
|
+
? pathname
|
|
216
|
+
: pathname.endsWith("/") ? pathname : `${pathname}/`;
|
|
217
|
+
let rule: { from: string; to: string } | null = null;
|
|
218
|
+
for (const redirect of redirects) {
|
|
219
|
+
if (redirect.from.endsWith("/*")) {
|
|
220
|
+
const dir = redirect.from.slice(0, -1);
|
|
221
|
+
if (path.startsWith(dir) && (rule === null || dir.length > rule.from.length - 1)) rule = redirect;
|
|
222
|
+
} else if (redirect.from === pathname || redirect.from === path) {
|
|
223
|
+
return redirect.to;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return rule === null ? null : ruleLocation(rule.to, path.slice(rule.from.length - 1));
|
|
227
|
+
}
|
|
228
|
+
|
|
182
229
|
function isManifestRedirectDestination(value: string): boolean {
|
|
183
230
|
if (isManifestRedirectPath(value)) return true;
|
|
184
231
|
try {
|
|
@@ -219,9 +266,13 @@ function parseManifest(raw: string): StaticBundleManifest | null {
|
|
|
219
266
|
redirects = [];
|
|
220
267
|
for (const value of m.redirects) {
|
|
221
268
|
const redirect = value as { from?: unknown; to?: unknown };
|
|
222
|
-
if (!redirect || typeof redirect.from !== "string" || typeof redirect.to !== "string"
|
|
223
|
-
|
|
224
|
-
|
|
269
|
+
if (!redirect || typeof redirect.from !== "string" || typeof redirect.to !== "string") return null;
|
|
270
|
+
const valid = isManifestRedirectRule(redirect.from)
|
|
271
|
+
? redirect.from !== "/*" && (isManifestRedirectRule(redirect.to) ||
|
|
272
|
+
(!redirect.to.includes("*") && isManifestRedirectPath(redirect.to)))
|
|
273
|
+
: !redirect.from.includes("*") && isManifestRedirectPath(redirect.from) &&
|
|
274
|
+
!redirect.to.includes("*") && isManifestRedirectDestination(redirect.to);
|
|
275
|
+
if (!valid) return null;
|
|
225
276
|
redirects.push({ from: redirect.from, to: redirect.to });
|
|
226
277
|
}
|
|
227
278
|
}
|
|
@@ -426,15 +477,12 @@ export async function serveStaticBundle(
|
|
|
426
477
|
return failClosed(deps, `missing/unreadable manifest for digest ${digest}`);
|
|
427
478
|
}
|
|
428
479
|
|
|
429
|
-
const
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
const redirect = manifest.redirects?.find(({ from }) => from === pathname || from === redirectPath);
|
|
433
|
-
if (redirect) {
|
|
434
|
-
const query = redirect.to.startsWith("/") ? search : "";
|
|
480
|
+
const redirectTo = staticBundleRedirectLocation(manifest.redirects, pathname);
|
|
481
|
+
if (redirectTo !== null) {
|
|
482
|
+
const query = redirectTo.startsWith("/") ? search : "";
|
|
435
483
|
return new Response(null, {
|
|
436
484
|
status: 308,
|
|
437
|
-
headers: { location: `${
|
|
485
|
+
headers: { location: `${redirectTo}${query}`, "cache-control": "public, max-age=300" },
|
|
438
486
|
});
|
|
439
487
|
}
|
|
440
488
|
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"description": "One redirect set and the location every edge must answer for each request path. Run against the reference matcher (publication-redirects.mjs resolveRedirect), the Cloudflare static plane (static-bundle.ts serveStaticBundle) and the CloudFront viewer function (live-pointer-rewrite.mjs). null means no redirect.",
|
|
3
|
+
"redirects": [
|
|
4
|
+
{ "from": "/blogs/*", "to": "/blog/" },
|
|
5
|
+
{ "from": "/blogs/news/*", "to": "/blog/*" },
|
|
6
|
+
{ "from": "/blogs/news/special/", "to": "/blog/featured/" },
|
|
7
|
+
{ "from": "/collections/*", "to": "/shop/" },
|
|
8
|
+
{ "from": "/my-account/", "to": "https://app.example.com/signin" },
|
|
9
|
+
{ "from": "/pages/*", "to": "/*" },
|
|
10
|
+
{ "from": "/sitemap_index.xml", "to": "/sitemap.xml" }
|
|
11
|
+
],
|
|
12
|
+
"cases": [
|
|
13
|
+
{ "path": "/blogs/news/hello", "location": "/blog/hello/" },
|
|
14
|
+
{ "path": "/blogs/news/hello/", "location": "/blog/hello/" },
|
|
15
|
+
{ "path": "/blogs/news/", "location": "/blog/" },
|
|
16
|
+
{ "path": "/blogs/news", "location": "/blog/" },
|
|
17
|
+
{ "path": "/blogs/news/feed.atom", "location": "/blog/feed.atom" },
|
|
18
|
+
{ "path": "/blogs/news/special/", "location": "/blog/featured/" },
|
|
19
|
+
{ "path": "/blogs/news/special", "location": "/blog/featured/" },
|
|
20
|
+
{ "path": "/blogs/other/post/", "location": "/blog/" },
|
|
21
|
+
{ "path": "/blogs/", "location": "/blog/" },
|
|
22
|
+
{ "path": "/pages/about", "location": "/about/" },
|
|
23
|
+
{ "path": "/pages/policies/terms/", "location": "/policies/terms/" },
|
|
24
|
+
{ "path": "/pages//evil.example/", "location": null },
|
|
25
|
+
{ "path": "/collections/vapes/", "location": "/shop/" },
|
|
26
|
+
{ "path": "/collections/vapes/products/x", "location": "/shop/" },
|
|
27
|
+
{ "path": "/sitemap_index.xml", "location": "/sitemap.xml" },
|
|
28
|
+
{ "path": "/my-account", "location": "https://app.example.com/signin" },
|
|
29
|
+
{ "path": "/blog/hello/", "location": null },
|
|
30
|
+
{ "path": "/blogsx/", "location": null },
|
|
31
|
+
{ "path": "/pages.html", "location": null },
|
|
32
|
+
{ "path": "/", "location": null }
|
|
33
|
+
]
|
|
34
|
+
}
|