@tokenoftrust/storefront-runner 2.4.26 → 2.4.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/storefront-runner",
3
- "version": "2.4.26",
3
+ "version": "2.4.27",
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",
@@ -0,0 +1,223 @@
1
+ /**
2
+ * A tenant's publication topology as a developer's workspace sees it: one ROOT repository serving
3
+ * `/`, plus any MOUNTED spaces — each its own repository serving one mount (`/blog`, `/docs`).
4
+ *
5
+ * The platform declares the topology; the workspace only records what it was told, in
6
+ * `.tot/publication-spaces.json` (written by `tot clone`, never committed — the root repo's git
7
+ * excludes it, and the `spaces/` folder each mounted space is cloned into). Every command that needs
8
+ * to know which repository owns a URL or a file reads it through this module, so `tot validate`,
9
+ * `tot dev`, `tot clone` and the change router answer the same way.
10
+ *
11
+ * Mount matching mirrors packages/public-runtime/src/publication-space.ts
12
+ * (`publicationMountMatchesPath`): a mount owns itself and everything below it, segment-wise and
13
+ * case-sensitively; the longest matching mount wins and `/` owns what nothing else claims.
14
+ * apps/storefront/src/config/publicationSpaceOwnership.test.ts pins the two together.
15
+ *
16
+ * Dependency-free: the published CLI ships it as-is.
17
+ */
18
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, appendFileSync } from "node:fs";
19
+ import { dirname, join } from "node:path";
20
+
21
+ /** Where `tot clone` puts each mounted space, relative to the root checkout: `spaces/<spaceId>/`. */
22
+ export const SPACES_DIR = "spaces";
23
+ /** The workspace's record of the declared topology, relative to the root checkout. */
24
+ export const TOPOLOGY_FILE = ".tot/publication-spaces.json";
25
+ export const TOPOLOGY_VERSION = 1;
26
+
27
+ const SPACE_ID = /^[a-z0-9](?:[a-z0-9._-]{0,126}[a-z0-9])?$/;
28
+ const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
29
+
30
+ /**
31
+ * @typedef {{ forge: string, repo: string, defaultBranch: string }} SpaceSource
32
+ * @typedef {{ id: string, mount: string, visibility: "public"|"internal", source: SpaceSource,
33
+ * aliases?: string[], collections?: Record<string, string> }} TopologySpace
34
+ * @typedef {{ version: 1, tenant: string, spaces: TopologySpace[] }} Topology
35
+ */
36
+
37
+ /** A mount in canonical form: leading `/`, no trailing `/` except the root itself. Null when not a mount. */
38
+ export function normalizeMount(value) {
39
+ if (typeof value !== "string" || !value.startsWith("/") || /[?#\\]/.test(value)) return null;
40
+ const trimmed = value.replace(/\/+$/, "");
41
+ if (!trimmed) return "/";
42
+ if (trimmed.split("/").slice(1).some((seg) => !seg || seg === "." || seg === "..")) return null;
43
+ return trimmed;
44
+ }
45
+
46
+ /** A request path reduced to what routing reads: no origin, query or fragment; NFC; single slashes. */
47
+ export function normalizeRequestPath(value) {
48
+ let path = String(value ?? "").trim();
49
+ try {
50
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(path)) path = new URL(path).pathname;
51
+ } catch {
52
+ return null;
53
+ }
54
+ path = path.replace(/[?#].*$/, "");
55
+ if (!path.startsWith("/")) path = `/${path}`;
56
+ try {
57
+ path = decodeURIComponent(path);
58
+ } catch {
59
+ return null;
60
+ }
61
+ path = path.normalize("NFC").replace(/\/{2,}/g, "/");
62
+ return path;
63
+ }
64
+
65
+ /** Does `mount` own `path` (both normalized)? A mount owns itself and every path below it. */
66
+ export function mountMatchesPath(mount, path) {
67
+ if (mount === "/") return true;
68
+ return path === mount || path === `${mount}/` || path.startsWith(`${mount}/`);
69
+ }
70
+
71
+ /**
72
+ * Parse a recorded topology. Throws with the reason on anything malformed — a half-read topology
73
+ * would route a change to the wrong repository.
74
+ * @param {unknown} input
75
+ * @returns {Topology}
76
+ */
77
+ export function parseTopology(input) {
78
+ const raw = /** @type {any} */ (input);
79
+ if (!isObject(raw) || raw.version !== TOPOLOGY_VERSION || typeof raw.tenant !== "string" || !raw.tenant ||
80
+ !Array.isArray(raw.spaces)) {
81
+ throw new Error(`${TOPOLOGY_FILE} is not a version-${TOPOLOGY_VERSION} topology`);
82
+ }
83
+ const ids = new Set();
84
+ const mounts = new Set();
85
+ const spaces = raw.spaces.map((space, index) => {
86
+ const at = `${TOPOLOGY_FILE} spaces[${index}]`;
87
+ if (!isObject(space) || typeof space.id !== "string" || !SPACE_ID.test(space.id)) throw new Error(`${at}: invalid id`);
88
+ const mount = normalizeMount(space.mount);
89
+ if (!mount) throw new Error(`${at}: invalid mount`);
90
+ if (space.visibility !== "public" && space.visibility !== "internal") throw new Error(`${at}: invalid visibility`);
91
+ const source = space.source;
92
+ if (!isObject(source) || typeof source.forge !== "string" || typeof source.repo !== "string" || !source.repo ||
93
+ typeof source.defaultBranch !== "string" || !source.defaultBranch) throw new Error(`${at}: invalid source`);
94
+ if (ids.has(space.id)) throw new Error(`${at}: duplicate id ${space.id}`);
95
+ if (mounts.has(mount)) throw new Error(`${at}: duplicate mount ${mount}`);
96
+ ids.add(space.id);
97
+ mounts.add(mount);
98
+ const aliases = Array.isArray(space.aliases) ? space.aliases.map(normalizeMount).filter(Boolean) : [];
99
+ return {
100
+ id: space.id,
101
+ mount,
102
+ visibility: space.visibility,
103
+ source: { forge: source.forge, repo: source.repo, defaultBranch: source.defaultBranch },
104
+ ...(aliases.length ? { aliases } : {}),
105
+ ...(isObject(space.collections) ? { collections: { ...space.collections } } : {}),
106
+ };
107
+ });
108
+ if (spaces.filter((space) => space.mount === "/").length !== 1) {
109
+ throw new Error(`${TOPOLOGY_FILE}: exactly one space must own "/"`);
110
+ }
111
+ return { version: TOPOLOGY_VERSION, tenant: raw.tenant, spaces };
112
+ }
113
+
114
+ /**
115
+ * Build a topology from the platform's declared-space list (`GET /api/publication-spaces`, whose
116
+ * rows carry `id`, `visibility`, `canonicalMount`, `source` and `routes`).
117
+ * @param {string} tenant
118
+ * @param {unknown} rows
119
+ * @returns {Topology}
120
+ */
121
+ export function topologyFromDeclaredSpaces(tenant, rows) {
122
+ if (!Array.isArray(rows)) throw new Error("the platform returned no publication-space list");
123
+ return parseTopology({
124
+ version: TOPOLOGY_VERSION,
125
+ tenant,
126
+ spaces: rows.map((row) => ({
127
+ id: row?.id,
128
+ mount: row?.routes?.canonicalMount ?? row?.canonicalMount,
129
+ visibility: row?.visibility,
130
+ source: row?.source,
131
+ aliases: Array.isArray(row?.routes?.aliases) ? row.routes.aliases.map((alias) => alias?.from) : undefined,
132
+ collections: row?.routes?.collections,
133
+ })),
134
+ });
135
+ }
136
+
137
+ /** The root space (owns `/`; `parseTopology` guarantees exactly one). @param {Topology} topology */
138
+ export function rootSpace(topology) {
139
+ return /** @type {TopologySpace} */ (topology.spaces.find((space) => space.mount === "/"));
140
+ }
141
+
142
+ /** The mounted spaces, longest mount first. @param {Topology} topology */
143
+ export function mountedSpaces(topology) {
144
+ return topology.spaces.filter((space) => space.mount !== "/").sort((a, b) => b.mount.length - a.mount.length);
145
+ }
146
+
147
+ /**
148
+ * Which space owns a URL path: the longest canonical mount (or alias) that owns it, else the root.
149
+ * Returns null for a path that cannot be read as a URL path.
150
+ * @param {Topology} topology
151
+ * @param {string} urlPath
152
+ * @returns {{ space: TopologySpace, via: "mount"|"alias"|"root" } | null}
153
+ */
154
+ export function owningSpace(topology, urlPath) {
155
+ const path = normalizeRequestPath(urlPath);
156
+ if (path === null) return null;
157
+ /** @type {{ space: TopologySpace, via: "mount"|"alias", mount: string } | null} */
158
+ let best = null;
159
+ for (const space of topology.spaces) {
160
+ if (space.mount === "/") continue;
161
+ /** @type {Array<[string, "mount"|"alias"]>} */
162
+ const claims = [[space.mount, "mount"], ...(space.aliases ?? []).map((alias) => /** @type {[string, "alias"]} */ ([alias, "alias"]))];
163
+ for (const [mount, via] of claims) {
164
+ if (mountMatchesPath(mount, path) && (!best || mount.length > best.mount.length)) best = { space, via, mount };
165
+ }
166
+ }
167
+ if (best) return { space: best.space, via: best.via };
168
+ return { space: rootSpace(topology), via: "root" };
169
+ }
170
+
171
+ /** A mounted space's working tree, relative to the root checkout. @param {TopologySpace} space */
172
+ export function spaceDir(space) {
173
+ return space.mount === "/" ? "" : `${SPACES_DIR}/${space.id}`;
174
+ }
175
+
176
+ /**
177
+ * Which space a workspace file belongs to, from its path relative to the root checkout:
178
+ * `spaces/<id>/...` is that space's, everything else is the root's. A file under `spaces/` naming
179
+ * no declared space belongs to none (null) — it must not be routed anywhere.
180
+ * @param {Topology} topology
181
+ * @param {string} relPath
182
+ * @returns {{ space: TopologySpace, path: string } | null}
183
+ */
184
+ export function spaceForWorkspaceFile(topology, relPath) {
185
+ const rel = String(relPath).replace(/\\/g, "/").replace(/^\.\//, "");
186
+ if (rel === SPACES_DIR || rel.startsWith(`${SPACES_DIR}/`)) {
187
+ const [, id, ...rest] = rel.split("/");
188
+ const space = topology.spaces.find((candidate) => candidate.mount !== "/" && candidate.id === id);
189
+ return space ? { space, path: rest.join("/") } : null;
190
+ }
191
+ return { space: rootSpace(topology), path: rel };
192
+ }
193
+
194
+ /** Read a workspace's recorded topology, or null when the workspace records none. Throws when malformed. */
195
+ export function readWorkspaceTopology(workspace) {
196
+ const file = join(workspace, TOPOLOGY_FILE);
197
+ if (!existsSync(file)) return null;
198
+ return parseTopology(JSON.parse(readFileSync(file, "utf8")));
199
+ }
200
+
201
+ /**
202
+ * Record a topology in a root checkout, and keep it — and the cloned spaces — out of the root
203
+ * repository: both are listed in the checkout's own `.git/info/exclude`, never in a committed file.
204
+ */
205
+ export function writeWorkspaceTopology(workspace, topology) {
206
+ const file = join(workspace, TOPOLOGY_FILE);
207
+ mkdirSync(dirname(file), { recursive: true });
208
+ writeFileSync(file, `${JSON.stringify(parseTopology(topology), null, 2)}\n`);
209
+ excludeFromRootRepo(workspace, [`/${TOPOLOGY_FILE}`, `/${SPACES_DIR}/`]);
210
+ }
211
+
212
+ /** Append patterns to `<workspace>/.git/info/exclude`, once each. No-op outside a git checkout. */
213
+ export function excludeFromRootRepo(workspace, patterns) {
214
+ const gitDir = join(workspace, ".git");
215
+ if (!existsSync(gitDir)) return;
216
+ const file = join(gitDir, "info", "exclude");
217
+ mkdirSync(dirname(file), { recursive: true });
218
+ const current = existsSync(file) ? readFileSync(file, "utf8") : "";
219
+ const lines = new Set(current.split("\n").map((line) => line.trim()));
220
+ const missing = patterns.filter((pattern) => !lines.has(pattern));
221
+ if (!missing.length) return;
222
+ appendFileSync(file, `${current && !current.endsWith("\n") ? "\n" : ""}${missing.join("\n")}\n`);
223
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * `tot dev`'s mounted publication spaces: run `publication-spaces-dev.ts` (compose the tenant's
3
+ * declared spaces from their local working trees through the publish-path composition, then seed
4
+ * the dev server's local state) alongside the dev server, and again whenever a space's working tree
5
+ * changes — so `/docs`, `/blog`, ... render locally from the same route plan visitors get, and a
6
+ * save in a space shows on the next reload.
7
+ *
8
+ * The composition is the private control plane, so it runs only where the platform tree is present
9
+ * (the storefront monorepo). The public runner ships without it; there a checkout that has cloned
10
+ * spaces is told which paths will not render, instead of silently serving 404s under each mount.
11
+ */
12
+ import { spawn } from "node:child_process";
13
+ import { existsSync } from "node:fs";
14
+ import { join } from "node:path";
15
+ import { startWatch } from "./checkout-watch.mjs";
16
+
17
+ export const SEEDER = "scripts/dev/publication-spaces-dev.ts";
18
+
19
+ /** Can this tree compose publication spaces? (The public runner cannot: no control plane.) */
20
+ export function publicationSpacesComposable(repoRoot) {
21
+ return existsSync(join(repoRoot, SEEDER)) &&
22
+ existsSync(join(repoRoot, "packages", "private-controlplane")) &&
23
+ existsSync(join(repoRoot, "apps", "storefront", "node_modules", ".bin", "tsx"));
24
+ }
25
+
26
+ /** The seeder argv for a tenant (and the developer's checkout, in workspace mode). Pure. */
27
+ export function seederArgs(repoRoot, tenant, workspace) {
28
+ return [join(repoRoot, SEEDER), "--tenant", tenant, ...(workspace ? ["--workspace", workspace] : [])];
29
+ }
30
+
31
+ /** Read the seeder's one-line JSON verdict from its stdout; null when it printed none. Pure. */
32
+ export function parseSeederVerdict(stdout) {
33
+ const line = String(stdout).trim().split("\n").filter(Boolean).pop();
34
+ if (!line) return null;
35
+ try {
36
+ const parsed = JSON.parse(line);
37
+ return parsed && typeof parsed.status === "string" ? parsed : null;
38
+ } catch {
39
+ return null;
40
+ }
41
+ }
42
+
43
+ /** The developer-facing line for a verdict. Pure. */
44
+ export function describeVerdict(verdict, { first }) {
45
+ if (!verdict) return " ⚠ mounted publication spaces could not be composed — their paths will 404 locally.";
46
+ switch (verdict.status) {
47
+ case "seeded": {
48
+ const mounts = verdict.spaces.map((space) => `${space.mount}/ (${space.spaceId})`).join(", ");
49
+ return first ? ` ✓ mounted spaces render locally: ${mounts}` : ` ↻ re-composed mounted spaces: ${mounts} — reload to see it`;
50
+ }
51
+ case "incomplete":
52
+ return ` ⚠ mounted spaces not composed — ${verdict.missing.join("; ")}. Their paths 404 locally until cloned (tot clone).`;
53
+ case "refused":
54
+ return ` ✗ the publish-path composition refused ${verdict.spaceId ?? "this topology"}: ${verdict.code} — a publish would refuse it too.`;
55
+ default:
56
+ return null;
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Compose + seed once now, then again on every change under `watchDirs()`. Runs are serialized: a
62
+ * save during a run schedules exactly one more. Returns `{ close }`.
63
+ * @param {{ repoRoot: string, tenant: string, workspace: string|null, log?: (line: string) => void,
64
+ * spawnImpl?: typeof spawn, watch?: typeof startWatch, watchDirs?: (verdict: any) => string[] }} input
65
+ */
66
+ export function startPublicationSpacesLoop({
67
+ repoRoot, tenant, workspace, log = console.log, spawnImpl = spawn, watch = startWatch,
68
+ watchDirs = (verdict) => verdict?.watch ?? [],
69
+ }) {
70
+ const appDir = join(repoRoot, "apps", "storefront");
71
+ const tsx = join(appDir, "node_modules", ".bin", "tsx");
72
+ const watchers = [];
73
+ let running = false;
74
+ let again = false;
75
+ let closed = false;
76
+ let first = true;
77
+ let watching = false;
78
+
79
+ const run = () => {
80
+ if (closed) return;
81
+ if (running) { again = true; return; }
82
+ running = true;
83
+ let stdout = "";
84
+ let stderr = "";
85
+ const child = spawnImpl(tsx, seederArgs(repoRoot, tenant, workspace), { cwd: appDir, stdio: ["ignore", "pipe", "pipe"] });
86
+ child.stdout?.on("data", (chunk) => { stdout += chunk; });
87
+ child.stderr?.on("data", (chunk) => { stderr += chunk; });
88
+ child.on("close", () => {
89
+ running = false;
90
+ const verdict = parseSeederVerdict(stdout);
91
+ if (!verdict && stderr.trim()) log(` ⚠ ${stderr.trim().split("\n").pop()}`);
92
+ const line = describeVerdict(verdict, { first });
93
+ if (line && !(verdict?.status === "none")) log(line);
94
+ first = false;
95
+ if (!watching && verdict && verdict.status !== "none") {
96
+ watching = true;
97
+ for (const dir of watchDirs(verdict)) {
98
+ try {
99
+ watchers.push(watch(dir, true, () => run()));
100
+ } catch {
101
+ log(` ⚠ could not watch ${dir} — re-run tot dev to pick up its changes.`);
102
+ }
103
+ }
104
+ }
105
+ if (again) { again = false; run(); }
106
+ });
107
+ child.on("error", (error) => {
108
+ running = false;
109
+ log(` ⚠ mounted publication spaces could not be composed: ${error.message}`);
110
+ });
111
+ };
112
+
113
+ run();
114
+ return {
115
+ close() {
116
+ closed = true;
117
+ for (const watcher of watchers) {
118
+ try { watcher.close(); } catch { /* best effort */ }
119
+ }
120
+ },
121
+ };
122
+ }
@@ -68,6 +68,8 @@ import { busyPortMessage, classifyBusyPort, lockPort, unlockPort } from "./dev/p
68
68
  import { resync, startWatch } from "./dev/checkout-watch.mjs";
69
69
  import { standaloneGraftPlan } from "./dev/standalone-graft-plan.mjs";
70
70
  import { findMissingCtaIds } from "./tenant/validate.mjs";
71
+ import { publicationSpacesComposable, startPublicationSpacesLoop } from "./dev/publication-spaces-loop.mjs";
72
+ import { SPACES_DIR } from "../packages/cli/src/publication-spaces.mjs";
71
73
 
72
74
  const here = dirname(fileURLToPath(import.meta.url));
73
75
  const repoRoot = resolve(here, ".."); // scripts/ -> repo root
@@ -415,14 +417,13 @@ console.log(` Private local preview — nothing is published. Server binds all
415
417
  }
416
418
 
417
419
  // --- boot astro dev (Vite HMR); --host so IPv4 `localhost`/127.0.0.1 both work -
418
- // In standalone mode we MUST keep astro in the FOREGROUND, attached to this
419
- // process: Astro 7 auto-detects an agent-run terminal and forks the dev server to
420
- // a detached daemon (`astro dev stop/status`), which would make THIS process exit
421
- // immediately — firing our cleanup and tearing down the graft symlinks while the
422
- // daemon keeps serving the (restored) in-tree content. Setting ASTRO_DEV_BACKGROUND
423
- // tells Astro "you are already the managed child → run foreground" (see astro's
424
- // cli/dev/{index,background}.js), so our SIGINT/exit lifecycle and symlink cleanup
425
- // stay correct. Monorepo mode is left untouched (current behavior).
420
+ // astro MUST stay in the FOREGROUND, attached to this process: Astro 7 auto-detects
421
+ // an agent-run terminal and forks the dev server to a detached daemon (`astro dev
422
+ // stop/status`), which would make THIS process exit immediately — firing our cleanup
423
+ // (tearing down the graft, stopping the publication-space loop) while the daemon
424
+ // keeps serving. Setting ASTRO_DEV_BACKGROUND tells Astro "you are already the
425
+ // managed child → run foreground" (see astro's cli/dev/{index,background}.js), so
426
+ // our SIGINT/exit lifecycle stays correct in both modes.
426
427
  // TOT_DEV_TENANT tells the inline-AI edit loop (astro.config.mjs's
427
428
  // inlineAiEditPlugin, scripts/dev/ai-edit.mjs) which tenant's content/theme to
428
429
  // read+write: one `tot dev <tenant>` run always serves exactly one tenant, so
@@ -433,9 +434,7 @@ console.log(` Private local preview — nothing is published. Server binds all
433
434
  // MUST read+write it, NOT the in-tree graft, or browser edits are silently lost
434
435
  // on the next resync. MONOREPO: the in-tree tenants/<id>/ dir.
435
436
  const devSourceRoot = resolveDevSourceRoot(workspacePath, repoRoot, tenant);
436
- const spawnEnv = workspacePath
437
- ? { ...process.env, ASTRO_DEV_BACKGROUND: "1", TOT_DEV_TENANT: tenant, TOT_DEV_SOURCE_ROOT: devSourceRoot }
438
- : { ...process.env, TOT_DEV_TENANT: tenant, TOT_DEV_SOURCE_ROOT: devSourceRoot };
437
+ const spawnEnv = { ...process.env, ASTRO_DEV_BACKGROUND: "1", TOT_DEV_TENANT: tenant, TOT_DEV_SOURCE_ROOT: devSourceRoot };
439
438
  // LAUNCHER-AGNOSTIC astro spawn: the published runner tree may have been
440
439
  // installed by pnpm OR npm (npm ships with every Node — the runner must not
441
440
  // require pnpm at RUNTIME), so resolve astro's real entry through Node's own
@@ -453,12 +452,31 @@ const child = spawn(
453
452
  { cwd: appDir, stdio: "inherit", env: spawnEnv },
454
453
  );
455
454
 
455
+ // --- mounted publication spaces (/docs, /blog, ...) -----------------------------
456
+ // Composed from their local working trees through the publish-path composition and seeded into
457
+ // the dev server's local state while astro boots, then again on every save in a space (see
458
+ // scripts/dev/publication-spaces-loop.mjs). The public runner has no composition, so there a
459
+ // checkout with cloned spaces is told its mounted paths will not render rather than left to 404.
460
+ let spacesLoop = null;
461
+ if (publicationSpacesComposable(repoRoot)) {
462
+ spacesLoop = startPublicationSpacesLoop({
463
+ repoRoot,
464
+ tenant,
465
+ workspace: workspacePath ? resolve(workspacePath) : null,
466
+ });
467
+ } else if (workspacePath && existsSync(join(resolve(workspacePath), SPACES_DIR))) {
468
+ console.warn(
469
+ ` ⚠ this checkout has mounted publication spaces (${SPACES_DIR}/) — the public preview engine renders the root only, so their paths 404 here.`,
470
+ );
471
+ }
472
+
456
473
  // Clean up grafts + the port lock on every exit path so we never leave
457
474
  // symlinks in the tree, or a lock a LATER run would misread as still live.
458
475
  function shutdown(signal) {
459
476
  child.kill(signal);
460
477
  }
461
478
  function cleanupAll() {
479
+ spacesLoop?.close();
462
480
  cleanupGrafts();
463
481
  unlockPort(resolvedPort);
464
482
  }