@tokenoftrust/storefront-runner 2.0.35 → 2.0.37

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.0.35",
3
+ "version": "2.0.37",
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",
@@ -31,11 +31,12 @@
31
31
  * reference files that don't exist. The Worker-plane build never sets this, so
32
32
  * R2 shadowing semantics are unchanged there.
33
33
  */
34
- import { cp, mkdir, rm } from "node:fs/promises";
35
- import { existsSync, readdirSync, statSync } from "node:fs";
34
+ import { rm, rename } from "node:fs/promises";
35
+ import { existsSync, readdirSync, statSync, mkdirSync, copyFileSync } from "node:fs";
36
36
  import { join, resolve, dirname } from "node:path";
37
37
  import { fileURLToPath } from "node:url";
38
38
  import { listTenantDirIds, tenantDirSegments } from "../lib/tenant-dirs.mjs";
39
+ import { copyTreeSkippingUnreadable, formatSkippedAssets } from "../lib/tenant-asset-copy.mjs";
39
40
 
40
41
  const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
41
42
  const src = resolve(repoRoot, "tenants");
@@ -45,7 +46,14 @@ const dest = process.env.TENANT_ASSETS_DEST
45
46
 
46
47
  if (!existsSync(src)) process.exit(0);
47
48
 
48
- await rm(dest, { recursive: true, force: true });
49
+ // Build into a staging dir and swap it in at the end, rather than wiping `dest` up
50
+ // front. The old order meant any throw mid-copy left the destination EMPTY: every
51
+ // tenant asset 404'd, pages/*.css included, while HTML kept returning 200 because
52
+ // pages render from content/ — an unstyled site with no signal
53
+ // (fb-1789008798249-sc4tgy). Staging means the previous good mirror survives a
54
+ // failure instead of being replaced by nothing.
55
+ const staging = `${dest}.staging-${process.pid}`;
56
+ await rm(staging, { recursive: true, force: true });
49
57
 
50
58
  const versionedEnv = new Set(
51
59
  (process.env.TENANT_ASSETS_VERSIONED ?? "")
@@ -65,8 +73,17 @@ const isVersioned = (id) =>
65
73
  // is `/tenants/<id>/…` for every tenant, whichever tier its source sits in.
66
74
  const ids = listTenantDirIds(src, readdirSync, (p) => statSync(p).isDirectory(), join);
67
75
 
76
+ const copyDeps = {
77
+ readdir: (dir) => readdirSync(dir, { withFileTypes: true }),
78
+ mkdir: (dir) => mkdirSync(dir, { recursive: true }),
79
+ copyFile: (from, to) => copyFileSync(from, to),
80
+ join,
81
+ };
82
+
68
83
  let copied = 0;
69
84
  let skipped = 0;
85
+ /** @type {{path: string, reason: string}[]} */
86
+ const unreadable = [];
70
87
  for (const id of ids) {
71
88
  const pub = join(tenantDir(id), "public");
72
89
  if (!existsSync(pub)) continue;
@@ -75,12 +92,23 @@ for (const id of ids) {
75
92
  skipped++;
76
93
  continue;
77
94
  }
78
- await mkdir(join(dest, id), { recursive: true });
79
- await cp(pub, join(dest, id), { recursive: true });
95
+ // Per tenant AND per file: one tenant's unreadable entry must not cost another
96
+ // tenant its assets, and one missing image must not cost its own tenant the
97
+ // stylesheet beside it.
98
+ const result = copyTreeSkippingUnreadable(pub, join(staging, id), copyDeps);
99
+ for (const s of result.skipped) unreadable.push({ path: `${id}/${s.path}`, reason: s.reason });
80
100
  copied++;
81
101
  }
82
102
 
103
+ // Swap staging in only once the copy is done.
104
+ await rm(dest, { recursive: true, force: true });
105
+ await rename(staging, dest);
106
+
83
107
  const skipNote = skipped ? ` (skipped ${skipped} versioned → R2)` : "";
84
108
  console.log(
85
109
  `copy-tenant-assets: mirrored ${copied} tenant public/ tree(s) → ${dest}${skipNote}`,
86
110
  );
111
+ // Loud, and AFTER the summary so it is the last thing on screen. Exit stays 0: a
112
+ // missing blog image is not a reason to stop the dev loop, but it is a reason the
113
+ // operator must not have to infer from an unstyled page.
114
+ for (const line of formatSkippedAssets(unreadable)) console.warn(line);
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Tenant public/ mirroring that cannot lose the whole tree to one bad file.
3
+ *
4
+ * The failure this exists to prevent (fb-1789008798249-sc4tgy): copy-tenant-assets
5
+ * wiped its destination and then ran a single recursive `cp`. One unreadable entry —
6
+ * a blog image referenced in the tree but missing on disk, a dangling symlink — threw
7
+ * mid-copy, so the destination was left EMPTY. Every tenant asset then 404'd,
8
+ * `pages/*.css` included, while HTML routes still returned 200 because pages render
9
+ * from content/ rather than public/. The result is a silently unstyled site with no
10
+ * signal beyond one advisory line, which is close to the worst shape a dev-loop bug
11
+ * can take: it looks like a CSS problem in the tenant's own content.
12
+ *
13
+ * So: walk the tree entry by entry, skip what cannot be read, and report it. A
14
+ * missing image costs that image, never the stylesheet next to it.
15
+ *
16
+ * Injected fs so the walk is testable without a real tree.
17
+ */
18
+
19
+ /**
20
+ * @typedef {object} CopyTreeDeps
21
+ * @property {(dir: string) => {name: string, isDirectory: () => boolean, isFile: () => boolean,
22
+ * isSymbolicLink: () => boolean}[]} readdir entries with types
23
+ * @property {(dir: string) => void} mkdir recursive
24
+ * @property {(from: string, to: string) => void} copyFile
25
+ * @property {(...parts: string[]) => string} join
26
+ */
27
+
28
+ /**
29
+ * Copy `from` into `to`, skipping any entry that throws.
30
+ * @param {string} from @param {string} to @param {CopyTreeDeps} deps
31
+ * @returns {{copied: number, skipped: {path: string, reason: string}[]}}
32
+ */
33
+ export function copyTreeSkippingUnreadable(from, to, deps) {
34
+ const { readdir, mkdir, copyFile, join } = deps;
35
+ let copied = 0;
36
+ /** @type {{path: string, reason: string}[]} */
37
+ const skipped = [];
38
+
39
+ /** @param {string} src @param {string} dst @param {string} rel */
40
+ const walk = (src, dst, rel) => {
41
+ /** @type {ReturnType<CopyTreeDeps['readdir']>} */
42
+ let entries;
43
+ try {
44
+ entries = readdir(src);
45
+ } catch (err) {
46
+ // An unreadable DIRECTORY costs only its own subtree, not its siblings.
47
+ skipped.push({ path: rel || '.', reason: reasonOf(err) });
48
+ return;
49
+ }
50
+ try {
51
+ mkdir(dst);
52
+ } catch (err) {
53
+ skipped.push({ path: rel || '.', reason: reasonOf(err) });
54
+ return;
55
+ }
56
+ for (const entry of entries) {
57
+ const childRel = rel ? join(rel, entry.name) : entry.name;
58
+ const childSrc = join(src, entry.name);
59
+ const childDst = join(dst, entry.name);
60
+ if (entry.isDirectory()) {
61
+ walk(childSrc, childDst, childRel);
62
+ continue;
63
+ }
64
+ try {
65
+ // copyFile follows symlinks, so a DANGLING link throws here and is skipped —
66
+ // which is the common shape of "referenced but missing".
67
+ copyFile(childSrc, childDst);
68
+ copied++;
69
+ } catch (err) {
70
+ skipped.push({ path: childRel, reason: reasonOf(err) });
71
+ }
72
+ }
73
+ };
74
+
75
+ walk(from, to, '');
76
+ return { copied, skipped };
77
+ }
78
+
79
+ /** @param {unknown} err */
80
+ function reasonOf(err) {
81
+ const e = /** @type {{code?: string, message?: string}} */ (err);
82
+ return e?.code || e?.message || String(err);
83
+ }
84
+
85
+ /**
86
+ * The operator-facing banner for skipped files. Loud on purpose: the old behaviour
87
+ * degraded to an unstyled site behind one advisory line, so a skip has to be
88
+ * impossible to scroll past — while still leaving exit 0, because a missing blog
89
+ * image must not stop the dev loop.
90
+ * @param {{path: string, reason: string}[]} skipped
91
+ * @param {number} [limit] how many paths to name before summarising
92
+ * @returns {string[]} lines
93
+ */
94
+ export function formatSkippedAssets(skipped, limit = 10) {
95
+ if (skipped.length === 0) return [];
96
+ const lines = [
97
+ '',
98
+ `⚠ copy-tenant-assets skipped ${skipped.length} unreadable file(s) — those URLs will 404:`,
99
+ ];
100
+ for (const { path, reason } of skipped.slice(0, limit)) lines.push(` ${path} (${reason})`);
101
+ if (skipped.length > limit) lines.push(` … and ${skipped.length - limit} more`);
102
+ lines.push(' Everything else copied, so the rest of the tenant\'s assets still serve.');
103
+ lines.push('');
104
+ return lines;
105
+ }