@tokenoftrust/storefront-runner 2.2.85 → 2.2.87

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.
@@ -1,379 +1,5 @@
1
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
- // A tenant's public site is served CDN -> static object (ADR 0013), so a redirect cannot be a
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
- /** Exact, version-bound whole-site redirects materialized from content/redirects.json. */
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
- !isManifestRedirectPath(redirect.from) ||
224
- !isManifestRedirectDestination(redirect.to)) return null;
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 redirectPath = pathname === "/" || pathname.split("/").pop()?.includes(".")
430
- ? pathname
431
- : pathname.endsWith("/") ? pathname : `${pathname}/`;
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: `${redirect.to}${query}`, "cache-control": "public, max-age=300" },
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
+ }
@@ -0,0 +1,109 @@
1
+ // @ts-check
2
+ /**
3
+ * Records which pid `tot dev` last bound a given port under, so a later
4
+ * invocation that finds the port busy can tell a STALE occupant (our own
5
+ * earlier `tot dev` process, no longer running — the socket just hasn't been
6
+ * released yet, or the process crashed without a clean shutdown) from a
7
+ * genuinely live one (a different, healthy server — ours or someone else's).
8
+ *
9
+ * Without this, `firstFreePort` silently drifts to the next port either way —
10
+ * fine for "someone else's server is using 4321", actively misleading after a
11
+ * crash: the developer's browser is still pointed at the old port, and nothing
12
+ * tells them the server they expect there isn't the one that just started
13
+ * (fb-1791304494289-ussj8l). Dependency-free (node:fs/os/path only) so it
14
+ * copies into the standalone runner like port-check.mjs.
15
+ */
16
+ import { mkdirSync, readFileSync, writeFileSync, rmSync } from "node:fs";
17
+ import { homedir } from "node:os";
18
+ import { join } from "node:path";
19
+
20
+ export const PORT_LOCK_DIR = join(homedir(), ".tot", "run", "dev-ports");
21
+
22
+ /** Record that `pid` (default: this process) is bound to `port`. Best-effort. */
23
+ export function lockPort(port, { pid = process.pid, dir = PORT_LOCK_DIR } = {}) {
24
+ try {
25
+ mkdirSync(dir, { recursive: true });
26
+ writeFileSync(join(dir, String(port)), String(pid));
27
+ } catch {
28
+ /* best-effort — a failed lock must never block `tot dev` from starting */
29
+ }
30
+ }
31
+
32
+ /** Release a port lock (best-effort; safe to call more than once). */
33
+ export function unlockPort(port, { dir = PORT_LOCK_DIR } = {}) {
34
+ try {
35
+ rmSync(join(dir, String(port)), { force: true });
36
+ } catch {
37
+ /* best-effort */
38
+ }
39
+ }
40
+
41
+ /** The pid last recorded as bound to `port`, or null when no lock is on file / unreadable. */
42
+ export function readPortLock(port, { dir = PORT_LOCK_DIR } = {}) {
43
+ try {
44
+ const pid = Number(readFileSync(join(dir, String(port)), "utf8").trim());
45
+ return Number.isInteger(pid) && pid > 0 ? pid : null;
46
+ } catch {
47
+ return null;
48
+ }
49
+ }
50
+
51
+ /** Is a pid a live process? (signal 0 probe; EPERM = alive but not ours.) */
52
+ export function processAlive(pid) {
53
+ try {
54
+ process.kill(pid, 0);
55
+ return true;
56
+ } catch (e) {
57
+ return /** @type {NodeJS.ErrnoException} */ (e)?.code === "EPERM";
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Why is `requestedPort` unavailable?
63
+ * - `{ kind: "ours", pid }` — an EARLIER `tot dev` session still holds it and is
64
+ * still running. It may be wedged (a renderer crash leaves the server process
65
+ * alive and bound), so the developer must be told which process to stop.
66
+ * - `{ kind: "stale", pid }` — our own recorded occupant is gone; the socket is
67
+ * lingering past a crash/kill.
68
+ * - `{ kind: "other" }` — busy with no lock of ours: someone else's server.
69
+ * - `null` — the port isn't busy right now.
70
+ * @param {number} requestedPort
71
+ * @param {{ isFree: (port:number)=>Promise<boolean>, pidAlive?: (pid:number)=>boolean, dir?: string }} deps
72
+ * @returns {Promise<{kind:"ours"|"stale", pid:number}|{kind:"other"}|null>}
73
+ */
74
+ export async function classifyBusyPort(requestedPort, { isFree, pidAlive = processAlive, dir = PORT_LOCK_DIR }) {
75
+ if (await isFree(requestedPort)) return null;
76
+ const pid = readPortLock(requestedPort, { dir });
77
+ if (pid == null) return { kind: "other" };
78
+ return { kind: pidAlive(pid) ? "ours" : "stale", pid };
79
+ }
80
+
81
+ /**
82
+ * The line `tot dev` prints when it starts on `resolvedPort` because
83
+ * `requestedPort` was busy. Every case involving an earlier `tot dev` session
84
+ * says so and names the process, so a browser tab left on the old port is never
85
+ * silently a different server.
86
+ * @param {number} requestedPort
87
+ * @param {number} resolvedPort
88
+ * @param {{kind:"ours"|"stale", pid:number}|{kind:"other"}|null} busy
89
+ */
90
+ export function busyPortMessage(requestedPort, resolvedPort, busy) {
91
+ const tab = ` A browser tab on :${requestedPort} is NOT this session — use the URL below.`;
92
+ if (busy?.kind === "ours") {
93
+ return (
94
+ ` ⚠ port ${requestedPort} is held by an earlier \`tot dev\` session that is still running ` +
95
+ `(pid ${busy.pid}) — starting on ${resolvedPort} instead.\n` +
96
+ ` If that session crashed or stopped reloading, stop it (\`kill ${busy.pid}\`) and restart to get ${requestedPort} back.\n` +
97
+ tab
98
+ );
99
+ }
100
+ if (busy?.kind === "stale") {
101
+ return (
102
+ ` ⚠ port ${requestedPort} is still held after an earlier \`tot dev\` session (pid ${busy.pid}) exited ` +
103
+ `— starting on ${resolvedPort} instead.\n` +
104
+ ` Find the holder with \`lsof -nP -iTCP:${requestedPort} -sTCP:LISTEN\` and stop it to get ${requestedPort} back.\n` +
105
+ tab
106
+ );
107
+ }
108
+ return ` ⚠ port ${requestedPort} was busy — starting on ${resolvedPort} instead.`;
109
+ }
@@ -0,0 +1,35 @@
1
+ // @ts-check
2
+ /**
3
+ * Pure planning for `tot dev --workspace` grafts: which checkout-relative paths
4
+ * get copied into the in-tree `tenants/<id>/` layout the app globs, and whether
5
+ * each is a file or a directory tree. Decision-only — scripts/tot-dev.mjs does
6
+ * the actual fs copy/backup/watch effects per planned entry.
7
+ */
8
+
9
+ /**
10
+ * @param {Array<{workspace?: unknown, kind?: unknown}>} mappings the checkout's
11
+ * own `.tot/config.json` `mappings` array (content/, public/, theme.json, …)
12
+ * @returns {{ workspace: string, isDir: boolean }[]}
13
+ */
14
+ export function standaloneGraftPlan(mappings) {
15
+ const plan = [];
16
+ const seen = new Set();
17
+ for (const map of Array.isArray(mappings) ? mappings : []) {
18
+ const workspace = String(map?.workspace ?? "").replace(/\/+$/, "");
19
+ if (!workspace || seen.has(workspace)) continue;
20
+ seen.add(workspace);
21
+ plan.push({ workspace, isDir: map?.kind !== "file" });
22
+ }
23
+ // `.tot/config.json` is never itself one of the declared `mappings` (those map
24
+ // content/public/theme.json) but IS the tenant's own declared platform config
25
+ // — siteType/compliance/capabilities/features (see
26
+ // apps/storefront/src/config/devTenantSeed.ts TOT_CONFIG_TENANT_FIELDS and
27
+ // resolver.ts declaredConfigs()) — the same file
28
+ // scripts/tenant/materialize-tenant-content.mjs copies for a Gitea-sourced
29
+ // tenant. Graft it unconditionally so a regulated store's mandated notices
30
+ // resolve from the SAME declared config locally as they would once published.
31
+ if (!seen.has(".tot/config.json")) {
32
+ plan.push({ workspace: ".tot/config.json", isDir: false });
33
+ }
34
+ return plan;
35
+ }