game2 / 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
// Production build: npm patch + cold-cache shadow release + gensym normalizer +
// workbox generateSW (live/game stack), then the shipping gates.
//
// Cold cache is canon: a warm .shadow-cljs incremental build assigns Closure
// property renames from a different pool than a cold build — never ship a
// warm build.
import { spawnSync } from "node:child_process";
import { cpSync, existsSync, readdirSync, readFileSync, rmSync, statSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";

const client = dirname(dirname(fileURLToPath(import.meta.url)));
const dist = join(client, "dist");

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);
}

// 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 release
rmSync(join(client, ".shadow-cljs"), { recursive: true, force: true });
rmSync(dist, { recursive: true, force: true });
const args = ["release", "app"];
const bridge = process.env.VITE_ID_BRIDGE_URL;
if (bridge) {
  args.push(
    "--config-merge",
    `{:closure-defines {game2.config/ID-BRIDGE-URL ${JSON.stringify(bridge)}}}`,
  );
}
run(join(client, "node_modules", ".bin", "shadow-cljs"), args);
for (const f of ["manifest.edn", "module-loader.edn", "module-loader.json"]) {
  rmSync(join(dist, "assets", f), { force: true });
}

// 2. deterministic gensyms (canon copy adapted: dist/assets)
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.
{
  const { writeFileSync } = await import("node:fs");
  let flipped = 0;
  for (const name of readdirSync(join(dist, "assets"))) {
    if (!name.endsWith(".js")) continue;
    const p = join(dist, "assets", 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. static shell: hand-written index.html + css + icons + webmanifest
cpSync(join(client, "public"), dist, { recursive: true });

// 4. service worker — live/game stack semantics (skipWaiting + clientsClaim,
//    navigateFallback, NO .json in the precache globs)
const { generateSW } = await import("workbox-build");
const { count, size } = await generateSW({
  globDirectory: dist,
  globPatterns: ["**/*.{js,css,html,png,svg,webmanifest}"],
  swDest: join(dist, "sw.js"),
  navigateFallback: "index.html",
  clientsClaim: true,
  skipWaiting: true,
  sourcemap: false, // maps embed absolute local paths — never ship them
});
console.log(`sw.js: precaching ${count} files, ${(size / 1024).toFixed(0)} KiB`);

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

// every URL in the shipped index.html must be relative (the build serves at
// https://game2.ardegazu.ro/ AND /ipfs/<cid>/ alike)
const html = readFileSync(join(dist, "index.html"), "utf8");
for (const m of html.matchAll(/(?:src|href)="([^"]+)"/g)) {
  const u = m[1];
  if (u.startsWith("data:")) continue;
  if (u.startsWith("/") || /^[a-z]+:\/\//i.test(u)) fail(`absolute URL in index.html: ${u}`);
}

// no absolute local filesystem paths anywhere in dist (split literal so this
// script never matches itself)
const NEEDLE = "/Us" + "ers/";
const walk = (dir) =>
  readdirSync(dir).flatMap((n) => {
    const p = join(dir, n);
    return statSync(p).isDirectory() ? walk(p) : [p];
  });
for (const f of walk(dist)) {
  if (readFileSync(f, "latin1").includes(NEEDLE)) fail(`local path leaked into ${f}`);
}

// the social-kit lazy boundary must be real: the co-sign engine's wire
// literal exists ONLY in the lazy-loaded chunk, never in the initial one
const mainJs = readFileSync(join(dist, "assets", "main.js"), "utf8");
const socialJs = readFileSync(join(dist, "assets", "social.js"), "utf8");
if (mainJs.includes('"lbq"')) fail("social-kit code leaked into the initial chunk (main.js)");
if (!socialJs.includes('"lbq"')) fail("social chunk does not contain the social-kit engine");

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

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