bursa / 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
// Production build: npm patch + cold-cache shadow release + gensym normalizer +
// workbox generateSW (rooms/versioned 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 (dev/docs/CLJS.md).
import { spawnSync } from "node:child_process";
import { cpSync, existsSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } 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");
const assets = join(dist, "assets");

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 VERSION = JSON.parse(readFileSync(join(client, "version.json"), "utf8")).version;

// 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 });
{
  // ardz already exports these; the config namespace reads them as goog-defines
  const defines = { "bursa.config/APP-VERSION": VERSION };
  const env = {
    VITE_APP_SALT: "bursa.config/VITE-APP-SALT",
    VITE_STORAGE_NS: "bursa.config/VITE-STORAGE-NS",
    VITE_RELAY_MULTIADDR: "bursa.config/VITE-RELAY-MULTIADDR",
    VITE_TURN_CREDS_URL: "bursa.config/VITE-TURN-CREDS-URL",
    VITE_DISCOVERY_TOPIC: "bursa.config/VITE-DISCOVERY-TOPIC",
    VITE_MAILBOX_URL: "bursa.config/VITE-MAILBOX-URL",
    VITE_MAILBOX_CREDS_URL: "bursa.config/VITE-MAILBOX-CREDS-URL",
    VITE_ID_BRIDGE_URL: "bursa.config/VITE-ID-BRIDGE-URL",
  };
  for (const [k, define] of Object.entries(env)) {
    if (process.env[k]) defines[define] = process.env[k];
  }
  const body = Object.entries(defines)
    .map(([k, v]) => `${k} ${JSON.stringify(v)}`)
    .join(" ");
  run(join(client, "node_modules", ".bin", "shadow-cljs"), [
    "release",
    "app",
    "--config-merge",
    `{:closure-defines {${body}}}`,
  ]);
}
for (const f of ["manifest.edn", "module-loader.edn", "module-loader.json"]) {
  rmSync(join(assets, 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 name of readdirSync(assets)) {
    if (!name.endsWith(".js")) continue;
    const p = join(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 });

// 3b. dist/version.json lets a running app ask "what version is live right
//     now?" — deliberately NOT precached (see globPatterns below), so the fetch
//     hits the network
writeFileSync(join(dist, "version.json"), JSON.stringify({ version: VERSION }));

// 4. service worker — ROOMS/VERSIONED stack semantics: the new worker waits
//    (skipWaiting:false) until the in-app banner posts SKIP_WAITING, and claims
//    open pages when it activates (clientsClaim) so controllerchange fires and
//    the one-tap update can reload into the new version.
const { generateSW } = await import("workbox-build");
const { count, size } = await generateSW({
  globDirectory: dist,
  // NO .json: version.json must never be precached, or the update banner can
  // never see a newer version than the one it is running
  globPatterns: ["**/*.{js,css,html,png,svg,webmanifest}"],
  swDest: join(dist, "sw.js"),
  navigateFallback: "index.html",
  clientsClaim: true,
  skipWaiting: false,
  maximumFileSizeToCacheInBytes: 4 * 1024 * 1024, // the room chunk exceeds workbox's 2 MiB default
  sourcemap: false, // maps embed absolute local paths — never ship them
});
console.log(`sw.js: precaching ${count} files, ${(size / 1024).toFixed(0)} KiB`);

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

const walk = (dir) =>
  readdirSync(dir).flatMap((n) => {
    const p = join(dir, n);
    return statSync(p).isDirectory() ? walk(p) : [p];
  });

// every URL in the shipped index.html must be relative (the build serves at
// https://chat.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/";
for (const f of walk(dist)) {
  if (readFileSync(f, "latin1").includes(NEEDLE)) fail(`local path leaked into ${f}`);
}
// and no source maps (they embed those paths by construction)
for (const f of walk(dist)) {
  if (f.endsWith(".map")) fail(`source map shipped: ${f}`);
}

const mainJs = readFileSync(join(assets, "main.js"), "utf8");
const roomJs = readFileSync(join(assets, "room.js"), "utf8");
const socialJs = readFileSync(join(assets, "social.js"), "utf8");

// the lobby/room split must be real: the p2p stack belongs to the room chunk
// only, or first paint drags 400 KB of libp2p+OrbitDB behind it
if (mainJs.includes("/orbitdb/")) fail("@orbitdb/core leaked into the initial chunk (main.js)");
if (mainJs.includes("/p2p-circuit")) fail("the libp2p transport stack leaked into the initial chunk");
if (!roomJs.includes("/orbitdb/")) fail("the room chunk does not contain @orbitdb/core");
if (!roomJs.includes("/sueta/2/msg/1.0")) fail("the room chunk does not contain the directed-stream protocol");

// bursa's own chunk gate, banca's `wpay-ord` gate transplanted: the artifact
// and matching stack (which drags wallet-kit, and social-kit's canon behind
// it) must live in room.js and never reach first paint. `bursa-ord` is the
// quote's signature domain — a wire constant that exists exactly once, in
// lib/artifacts, which makes it the cheapest honest tracer for the whole
// trading stack.
if (mainJs.includes("bursa-ord")) fail("the trading stack leaked into the initial chunk (main.js)");
if (!roomJs.includes("bursa-ord")) fail("the room chunk does not contain the trading stack");

// The social boundary — back in its STRONG form, and pinned to facts.
//
// This gate used to say: `"fsync"` must be absent from main.js, and that was
// all it could say. The room chunk was excused in a comment reading "bursa
// cannot [forbid it there]: every signature in the suite is Ed25519 over
// social-kit's canon(), the trading stack signs, and importing canon carries
// the module that also contains the social engine."
//
// That was never a fact about bursa. It was a fact about DISTS. An npm dist is
// a bundle linked WHOLE, so wallet-kit's dist importing social-kit's dist ROOT
// for `canon` alone dragged friends, presence, invites and selfsync in behind
// it — and since the trading stack imports wallet-kit, shadow put the lot in
// their lowest common ancestor, :room. Both kits are consumed as SOURCE now
// (client/deps.edn's rule), so the signing path's real dependency is exactly
// `ardegazu.social.canon` — one namespace — and the rest of the kit follows
// bursa.social into :social where it belongs. Measured at the change: the four
// `"fsync"` occurrences the dist put in room.js are four in social.js and ZERO
// in room.js, and room.js shed 28,338 B gz. The original claim is achievable
// again, so it is asserted again.
//
// ASSERT FACTS, NEVER NAMES. `attachSocial` used to be the marker for "the
// agent is constructed here" — but that was the DIST's ESM export name, an
// artifact of how the kit was linked, not a fact about this app, and it does
// not survive source consumption (the var is `attach-social` in
// `ardegazu.social.app-boot`). The three markers below are CSS/wire literals
// belonging to ONE namespace each, and :simple never rewrites a string literal:
//
//   `.soc-home`  ardegazu.social.join's HOME-CSS — the lobby's "back to the
//                hub" chip, and the ONLY piece of the kit first paint may
//                carry (app/rooms.cljs requires `ardegazu.social.join`, which
//                depends on nothing but `ardegazu.social.text`).
//   `.soc-chip`  ardegazu.social.app-boot's CHIP-CSS — present exactly where
//                the agent is constructed.
//   `"fsync"`    ardegazu.social.selfsync's wire tag, reachable only through
//                app-boot.
for (const [marker, what] of [['"fsync"', "the social agent's self-sync"],
                              [".soc-chip", "the social agent's chrome"]]) {
  if (mainJs.includes(marker)) fail(`${what} leaked into the initial chunk (main.js)`);
  if (roomJs.includes(marker)) fail(`${what} leaked into the room chunk (room.js)`);
  if (!socialJs.includes(marker)) fail(`the social chunk is missing ${what} — is the agent still attached there?`);
}
if (!mainJs.includes(".soc-home")) fail("the lobby's hub chip (ardegazu.social.join) is missing from the initial chunk");
if (socialJs.includes(".soc-home")) fail("ardegazu.social.join was duplicated into the social chunk");

// …and no chunk may name one of our own kits as an npm package: a kit that
// still arrives as a dist is a second, differently-compiled copy of code this
// build already contains from source (client/deps.edn's rule). shadow prefixes
// every bundled npm module with its path, so the dist's own module symbols —
// `module$…$ardegazu_id_kit$dist$index` and friends — are what the string would
// come from. Before the conversion there were 80 such symbols across the three
// chunks; the honest number is zero.
for (const [name, js] of [["main.js", mainJs], ["room.js", roomJs], ["social.js", socialJs]]) {
  for (const kit of ["ardegazu_id_kit", "ardegazu_social_kit", "ardegazu_wallet_kit", "ardegazu_rooms_kit", "ardegazu_banca"]) {
    if (js.includes(`${kit}$dist`)) fail(`${name} links ${kit}'s dist — that kit must be compiled from source`);
  }
}

// the `events` resolution must have taken effect: @orbitdb/core (+ lru,
// abstract-level) import `node:events`, and a browser needs the REAL
// EventEmitter, not an empty shim. A wrong resolution here fails at runtime
// inside OrbitDB, not at build time — so grep for the implementation itself.
// Both markers are literals of the npm `events` package only: node's builtin is
// never bundled, and an empty external shim has neither. :simple renames locals
// but never string literals or property names, so they survive the release build.
for (const marker of ["MaxListenersExceededWarning", "_eventsCount"]) {
  if (!roomJs.includes(marker)) {
    fail(`the npm \`events\` EventEmitter implementation is missing from the room chunk (no ${marker})`);
  }
  if (mainJs.includes(marker)) fail(`the \`events\` polyfill leaked into the initial chunk (${marker})`);
}
// the npm-shim assertions (scripts/patch-npm.mjs + the @libp2p/config shim)
if (!roomJs.includes("libp2p-config-shim")) fail("the @libp2p/config shim is not in the room chunk");
if (roomJs.includes("loadOrCreateSelfKey()") && roomJs.includes("keychain/dist")) {
  fail("the real @libp2p/config was bundled — the shim alias did not take effect");
}
if (!existsSync(join(client, "node_modules/@libp2p/crypto/dist/src/ciphers/aes-gcm.browser.js"))) {
  fail("@libp2p/crypto's ciphers leaf module moved — revisit scripts/patch-npm.mjs");
}

// version.json is emitted and NOT precached
const sw = readFileSync(join(dist, "sw.js"), "utf8");
if (!existsSync(join(dist, "version.json"))) fail("version.json was not emitted");
if (sw.includes("version.json")) fail("version.json is in the service worker precache manifest");
if (!sw.includes("index.html")) fail("index.html is not in the service worker precache manifest");
// prompt-mode semantics: the worker must WAIT and must claim clients
if (!sw.includes("SKIP_WAITING")) fail("sw.js has no SKIP_WAITING listener — the update banner cannot work");
if (sw.includes("self.skipWaiting()") && !sw.includes("SKIP_WAITING")) fail("sw.js skips waiting unconditionally");
if (!sw.includes("clientsClaim")) fail("sw.js does not clientsClaim");

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

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