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 | // 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 = { "portofel.config/APP-VERSION": VERSION };
const env = {
VITE_APP_SALT: "portofel.config/VITE-APP-SALT",
VITE_STORAGE_NS: "portofel.config/VITE-STORAGE-NS",
VITE_RELAY_MULTIADDR: "portofel.config/VITE-RELAY-MULTIADDR",
VITE_TURN_CREDS_URL: "portofel.config/VITE-TURN-CREDS-URL",
VITE_DISCOVERY_TOPIC: "portofel.config/VITE-DISCOVERY-TOPIC",
VITE_MAILBOX_URL: "portofel.config/VITE-MAILBOX-URL",
VITE_MAILBOX_CREDS_URL: "portofel.config/VITE-MAILBOX-CREDS-URL",
VITE_ID_BRIDGE_URL: "portofel.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");
// the social-kit lazy boundary must be real: the kit's self-sync wire literal
// exists ONLY in the lazy-loaded chunk, never in the initial or room one
if (mainJs.includes('"fsync"')) fail("social-kit code leaked into the initial chunk (main.js)");
if (roomJs.includes('"fsync"')) fail("social-kit code leaked into the room chunk (room.js)");
if (!socialJs.includes('"fsync"')) fail("the social chunk does not contain the social-kit engine");
// 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);
|