home / client / scripts / build.mjs
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
// Production build: npm patch + cold-cache shadow releases (:app and :bridge), the gensym
// normalizer, the __esModule flip, the static shell, workbox generateSW in the
// rooms/versioned shape — then the shipping gates, of which the rule-4 /id/
// trinity gate is the one that matters most in this repo.
//
// Cold cache is canon: a warm .shadow-cljs incremental build assigns Closure
// property renames from a different pool than a cold build.
import { spawnSync } from "node:child_process";
import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { CLIENT as client, appDefines, appVersion, catalogJson } from "./defines.mjs";

const dist = join(client, "dist");
const shadow = join(client, "node_modules", ".bin", "shadow-cljs");

function run(cmd, args) {
  const r = spawnSync(cmd, args, { cwd: client, stdio: "inherit" });
  if (r.status !== 0) process.exit(r.status ?? 1);
}
function fail(msg) {
  console.error(`BUILD GATE FAILED: ${msg}`);
  process.exit(1);
}
const walk = (dir) =>
  readdirSync(dir).flatMap((n) => {
    const p = join(dir, n);
    return statSync(p).isDirectory() ? walk(p) : [p];
  });

// ---- 0. the npm source rewrites shadow needs --------------------------------
// With their own gates (patch-npm's header): the `export * as` desugaring, and
// the `__esModule` strip that keeps an ESM-namespace spread from poisoning a
// data object. The second one is what step 2b below does to a finished dist —
// it lives at the source too because `shadow-cljs watch` has no finished dist
// to post-process, and a dev pipeline that disagrees with the release one is
// how this broke.
run(process.execPath, [join(client, "scripts", "patch-npm.mjs")]);

// ---- 1. cold-cache releases -------------------------------------------------

rmSync(join(client, ".shadow-cljs"), { recursive: true, force: true });
rmSync(dist, { recursive: true, force: true });

run(shadow, ["release", "app", ...appDefines()]);
// the bridge is a build of its own, not a module of :app — see shadow-cljs.edn
run(shadow, ["release", "bridge"]);

const ASSET_DIRS = [join(dist, "assets"), join(dist, "id", "assets")];
for (const d of ASSET_DIRS) {
  for (const f of ["manifest.edn", "module-loader.edn", "module-loader.json"]) {
    rmSync(join(d, f), { force: true });
  }
}

// ---- 2. deterministic gensyms ----------------------------------------------

run(process.execPath, [join(client, "scripts", "normalize-gensyms.mjs")]);

// ---- 2b. shadow's CJS-converted npm modules mark `__esModule` ENUMERABLE, so
//      an `import * as ns` + object spread copies it into data objects —
//      multiformats does exactly that (`bases = {...base32, ...}`) and then
//      `Object.values(bases)[0].or(...)` explodes on the poisoned entry. Real
//      ESM namespaces never enumerate the marker; flip it non-enumerable
//      (interop `mod.__esModule` READS are untouched). Deterministic rewrite,
//      asserted so a shadow upgrade that changes the emit shape gets noticed.
{
  let flipped = 0;
  for (const d of ASSET_DIRS) {
    for (const name of readdirSync(d)) {
      if (!name.endsWith(".js")) continue;
      const p = join(d, name);
      const src = readFileSync(p, "utf8");
      const out = src.replaceAll("__esModule:{enumerable:!0", () => {
        flipped++;
        return "__esModule:{enumerable:!1";
      });
      if (out !== src) writeFileSync(p, out);
    }
  }
  if (flipped === 0) fail("__esModule markers not found — shadow emit changed, revisit this rewrite");
  console.log(`esmodule-markers: ${flipped} flipped non-enumerable`);
}

// ---- 3. the static shell ---------------------------------------------------
// public/ carries index.html, id/index.html, style.css, the icons and the
// hand-written webmanifest.
cpSync(join(client, "public"), dist, { recursive: true });

// home is the ONLY consumer of ardegazu-theme (house rule 10: the cdn mirror at
// cdn.ardegazu.ro/theme/v1/theme.css tracks this package's major). Vite bundled
// it because main.ts did `import "ardegazu-theme/theme.css"`; shadow does not
// bundle CSS, so the file is copied verbatim from the sha-pinned npm dep and
// linked from index.html BEFORE style.css — the same cascade order the import
// order gave it.
{
  const theme = join(client, "node_modules", "ardegazu-theme", "theme.css");
  if (!existsSync(theme)) fail("ardegazu-theme/theme.css missing — npm install");
  cpSync(theme, join(dist, "theme.css"));
  const css = readFileSync(theme, "utf8");
  for (const token of [":root", "--paper", "--rose", "prefers-color-scheme", ".armed", ".soc-flash"]) {
    if (!css.includes(token)) fail(`theme.css looks wrong: missing ${token}`);
  }
  console.log(`theme.css: ${css.length} bytes from the pinned kit`);
}

// ---- 4. the live-data channels ---------------------------------------------
// Neither is ever precached (the workbox globPatterns omit .json) — they are
// how a running hub learns about a new version and a grown catalog.
writeFileSync(join(dist, "version.json"), JSON.stringify({ version: appVersion() }));
writeFileSync(join(dist, "catalog.json"), catalogJson());

// ---- 5. the service worker -------------------------------------------------
// rooms/versioned stack: skipWaiting FALSE (the version banner owns activation)
// + clientsClaim TRUE (else controllerchange never fires on first-visit pages
// and the one-tap banner can't reload into the new version).
//
// RULE 4 — /id/ is the identity bridge every app in the suite embeds, and a
// stale-cached bridge freezes the suite's identity protocol. Three redundant
// exclusions, ALL of which the gate below asserts against the emitted sw.js:
//   1. globIgnores ["id/**"]            -> never precached
//   2. a NetworkOnly runtime route on /\/id(\/|$)/, FIRST in runtimeCaching
//   3. navigateFallbackDenylist          -> no SPA fallback
const { generateSW } = await import("workbox-build");
const { count, size } = await generateSW({
  globDirectory: dist,
  // no .json here on purpose: version.json + catalog.json must never be
  // precached — they are the live-data channels
  globPatterns: ["**/*.{js,css,html,png,svg,webmanifest}"],
  globIgnores: ["id/**"], // /id/ exclusion 1
  maximumFileSizeToCacheInBytes: 4194304,
  swDest: join(dist, "sw.js"),
  navigateFallback: "index.html", // relative — the build serves under /ipfs/<cid>/ too
  navigateFallbackDenylist: [/\/id(\/|$)/], // /id/ exclusion 2
  runtimeCaching: [
    // /id/ exclusion 3 — MUST stay first: the bridge always hits the network
    { urlPattern: /\/id(\/|$)/, handler: "NetworkOnly" },
    {
      urlPattern: ({ url }) => url.pathname.endsWith("/catalog.json"),
      handler: "StaleWhileRevalidate",
      options: { cacheName: "catalog" },
    },
  ],
  clientsClaim: true,
  skipWaiting: false,
  sourcemap: false, // maps embed absolute local paths — never ship them
});
console.log(`sw.js: precaching ${count} files, ${(size / 1024).toFixed(0)} KiB`);

// ---- gates -----------------------------------------------------------------

// ---- GATE: rule 4, the /id/ trinity, asserted against the emitted sw.js ----
{
  const sw = readFileSync(join(dist, "sw.js"), "utf8");

  // the precache manifest, as urls
  const m = /precacheAndRoute\(\[(.*?)\]\s*,/s.exec(sw);
  if (!m) fail("rule 4: no precacheAndRoute([...]) in sw.js — cannot verify the precache manifest");
  const precached = [...m[1].matchAll(/url:"([^"]+)"/g)].map((x) => x[1]);
  if (precached.length === 0) fail("rule 4: the precache manifest parsed as empty — the gate is blind");

  // 1. nothing under id/ is precached
  const idEntries = precached.filter((u) => u === "id" || u.startsWith("id/") || u.includes("/id/"));
  if (idEntries.length > 0) fail(`rule 4: /id/ files are PRECACHED: ${idEntries.join(", ")}`);

  // 1b. no .json is precached — version.json and catalog.json are live channels
  const jsonEntries = precached.filter((u) => u.endsWith(".json"));
  if (jsonEntries.length > 0) fail(`rule 4: .json files are PRECACHED: ${jsonEntries.join(", ")}`);

  // 2. the NetworkOnly route on /id/ exists, and is the FIRST of the
  //    runtimeCaching routes — ahead of every caching handler, whatever they
  //    are. generateSW emits the navigateFallback NavigationRoute first (it
  //    carries the denylist checked below), so that one is excluded by name;
  //    everything else must come after /id/.
  // a ZERO-WIDTH match: a consuming 60-char window would swallow the NEXT
  // registerRoute( and the gate would silently see fewer routes than there are
  const routes = [...sw.matchAll(/registerRoute\((?=(.{0,80}))/gs)].map((x) => ({ at: x.index, head: x[1] }));
  const runtime = routes.filter((r) => !r.head.includes("NavigationRoute"));
  if (routes.length !== 3 || runtime.length !== 2) {
    fail(`rule 4: expected 3 routes (navigation + /id/ + catalog.json), parsed ${routes.length}` +
      ` of which ${runtime.length} runtime — the route set changed, revisit this gate:` +
      ` ${JSON.stringify(routes.map((r) => r.head.slice(0, 40)))}`);
  }
  const idRoute = runtime[0];
  if (!/^\/\\\/id\(\\\/\|\$\)\/,new [A-Za-z_$][\w$]*\.NetworkOnly/.test(idRoute.head)) {
    fail("rule 4: the FIRST runtime route is not the /id/ NetworkOnly route — it is: " +
      JSON.stringify(idRoute.head));
  }
  if (!runtime[1].head.includes("catalog.json")) {
    fail("rule 4: the second runtime route is not the catalog.json one: " + JSON.stringify(runtime[1].head));
  }
  console.log(`rule-4 gate: runtime routes, in order = ${runtime.map((r) => r.head.split(",new ")[0].slice(0, 46)).join("  |  ")}`);

  // 3. the navigation fallback never claims an /id/ navigation
  if (!/denylist:\[\/\\\/id\(\\\/\|\$\)\/\]/.test(sw)) {
    fail("rule 4: no navigateFallback denylist for /\\/id(\\/|$)/ in sw.js");
  }

  // 4. prompt-mode activation: no unconditional skipWaiting, and the
  //    SKIP_WAITING listener the update banner posts to MUST exist
  if (/(^|[^.\w])self\.skipWaiting\(\)\s*[,;]/.test(sw.replace(/"SKIP_WAITING"===[^&]*&&self\.skipWaiting\(\)/, ""))) {
    fail("sw.js calls self.skipWaiting() unconditionally — skipWaiting must stay false");
  }
  if (!sw.includes("SKIP_WAITING")) {
    fail("sw.js has no SKIP_WAITING message listener — the update banner could never activate the new worker");
  }
  if (!/clientsClaim\(\)/.test(sw)) fail("sw.js does not call clientsClaim() — the banner's reload would never fire");

  console.log(`rule-4 gate OK: ${precached.length} precached entries, none under id/, none .json;` +
    " /id/ NetworkOnly is the first runtime route; navigate denylist present;" +
    " SKIP_WAITING listener present; clientsClaim on; no unconditional skipWaiting");
  console.log("rule-4 gate: precache manifest =", JSON.stringify(precached));
}

// Every ASSET url in BOTH shipped pages must be relative and must resolve from
// where that page sits — one build serves at https://ardegazu.ro/, at
// /ipfs/<cid>/, and (for the bridge) one path level deeper. Prose links to other
// origins are fine; a root-relative path is never fine (it breaks on gateways),
// and a script/link/img pointing off-origin is never fine either (the pages are
// self-contained).
for (const page of ["index.html", join("id", "index.html")]) {
  const html = readFileSync(join(dist, page), "utf8");
  const base = page.includes("id") ? join(dist, "id") : dist;

  // nothing anywhere may be root-relative or protocol-relative
  for (const mm of html.matchAll(/(?:src|href)="([^"]+)"/g)) {
    if (/^\/\//.test(mm[1])) fail(`protocol-relative URL in ${page}: ${mm[1]}`);
    if (mm[1].startsWith("/")) fail(`root-relative URL in ${page}: ${mm[1]} (breaks under /ipfs/<cid>/)`);
  }

  // asset-bearing tags only
  const assets = [];
  for (const mm of html.matchAll(/<(script|link|img|source)\b[^>]*?\s(?:src|href)="([^"]+)"/gi)) {
    const u = mm[2];
    if (u.startsWith("data:")) continue;
    if (/^[a-z]+:/i.test(u)) fail(`off-origin ${mm[1]} in ${page}: ${u} — the pages must be self-contained`);
    assets.push(u);
  }
  if (assets.length === 0) fail(`${page}: no asset refs found — the gate is blind`);
  for (const u of assets) {
    const clean = u.split(/[?#]/)[0];
    if (!existsSync(join(base, clean))) fail(`${page}: ${u} does not resolve to a file in dist`);
  }
  console.log(`${page}: relative asset refs OK -> ${assets.join(" ")}`);
}

// no absolute local filesystem paths anywhere in dist (split literal so this
// script never matches itself)
{
  const NEEDLE = "/Us" + "ers/";
  for (const f of walk(dist)) {
    if (readFileSync(f, "latin1").includes(NEEDLE)) fail(`local path leaked into ${f}`);
  }
  console.log("no local paths in dist");
}

// The social-kit lazy boundary must be real. It is a MODULE boundary now, not
// an npm-subpath one: the kit is ClojureScript on this build's classpath, so
// shadow places each of its namespaces in the lowest common ancestor of the
// modules that use it, and DCE drops the ones nothing here calls. Marker
// strings, both from the kit's own wire vocabulary:
//   SOC_ROOT  the mailbox client's mint error — root-export code that both
//             :lb (receipts) and :social (envelopes) reach, so :share is its
//             lowest common ancestor. Hoisting it into :main is the regression
//             this catches (point :lb at :main and it moves).
//   SOC_NET   the libp2p transport the node dials — ardegazu.social.net, and
//             nothing else in the kit, names it.
//
// SOC_ROOT used to be the co-sign frame kind "lbq", and that string is now in
// NO module at all — which is the point of source consumption, not a leak.
// "lbq" lives in ardegazu.social.gamelb, the GAME side of the leaderboard,
// which the hub never calls: it was in the bundle only because importing the
// kit's dist root linked every namespace whether the hub used it or not. A gate
// that asserted the presence of dead code cannot survive real DCE.
{
  const readAsset = (n) => readFileSync(join(dist, "assets", n), "utf8");
  const SOC_ROOT = "mailbox mint: no token";
  const SOC_NET = "/webrtc";
  const main = readAsset("main.js");
  const share = readAsset("share.js");
  const social = readAsset("social.js");
  if (main.includes(SOC_ROOT)) fail("social-kit's root export leaked into the initial module (main.js)");
  if (!share.includes(SOC_ROOT)) fail(`the :share module does not contain social-kit's root (${SOC_ROOT})`);
  if (main.includes(SOC_NET) || share.includes(SOC_NET)) {
    fail("libp2p / ardegazu.social.net leaked out of the :social module");
  }
  if (!social.includes(SOC_NET)) fail("the :social module does not contain the libp2p transport");
  console.log("social-kit boundary OK: root in share.js, libp2p only in social.js");
}

// the bridge must be self-contained: nothing it loads may sit outside dist/id/
{
  const html = readFileSync(join(dist, "id", "index.html"), "utf8");
  for (const mm of html.matchAll(/(?:src|href)="([^"]+)"/g)) {
    if (mm[1].startsWith("..")) fail(`rule 4: /id/ loads ${mm[1]} from OUTSIDE dist/id/ — it would be precached`);
  }
  if (!existsSync(join(dist, "id", "assets", "bridge.js"))) fail("dist/id/assets/bridge.js missing");
  console.log("bridge self-containment OK: every /id/ ref stays under dist/id/");
}

// the two live-data files exist and catalog.json is MINIFIED
{
  const v = JSON.parse(readFileSync(join(dist, "version.json"), "utf8"));
  if (typeof v.version !== "number") fail("dist/version.json has no numeric version");
  const raw = readFileSync(join(dist, "catalog.json"), "utf8");
  if (/\n\s\s/.test(raw)) fail("dist/catalog.json is not minified");
  const parsed = JSON.parse(raw);
  if (parsed.v !== 1 || !Array.isArray(parsed.apps)) fail("dist/catalog.json is not a v1 catalog");
  const src = readFileSync(join(client, "catalog.json"), "utf8");
  if (raw !== JSON.stringify(JSON.parse(src))) fail("dist/catalog.json is not client/catalog.json minified");
  console.log(`version.json: v${v.version}; catalog.json: ${raw.length} bytes minified` +
    ` (from ${src.length}), ${parsed.apps.length} apps + ${(parsed.bots ?? []).length} bots`);
}

// chunk report — the numbers the release note quotes
{
  const { gzipSync } = await import("node:zlib");
  const rows = [];
  for (const f of walk(dist)) {
    if (!/\.(js|css)$/.test(f)) continue;
    const b = readFileSync(f);
    rows.push([f.slice(dist.length + 1), b.length, gzipSync(b).length]);
  }
  rows.sort((a, b) => b[1] - a[1]);
  for (const [n, raw, gz] of rows) {
    console.log(`  ${n.padEnd(28)} raw=${String(raw).padStart(8)} gz=${String(gz).padStart(7)}`);
  }
}

console.log("build OK:", dist);

static mirror of HEAD · about · clone: git clone https://git.ardegazu.ro/home.git