@teamscala/service-boot 0.23.412 → 0.23.413

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.
@@ -22,793 +22,36 @@
22
22
  * resolved versions of the third-party deps the closure imports) is hashed by
23
23
  * CONTENT and stored in the manifest; the rebuild is skipped only when the
24
24
  * current closure hashes to the same fingerprint (cache-key-hashes-source-
25
- * inputs-only — never mtime; see computeInputFingerprint for why mtime silently
25
+ * inputs-only — never mtime; see input-fingerprint.ts for why mtime silently
26
26
  * served stale bundles on Railway). bun.lock is deliberately NOT hashed — it
27
27
  * encodes every @teamscala/* version including backend packages outside this
28
28
  * service's client bundle, so folding it in would rebuild on every publish
29
- * (the publish→rebuild storm; `no-uncontrolled-repetition-or-cascade`). First
30
- * pm2 restart after a real closure change pays the full build cost; unchanged
29
+ * (the publish→rebuild storm; `no-uncontrolled-repetition-or-cascade`). A
30
+ * restart after a real closure change pays the full build cost; unchanged
31
31
  * restarts skip. Stale hashed outputs from a prior build are removed in the
32
32
  * same pass so dist/client/ doesn't accumulate dead files.
33
+ *
34
+ * This module is the ORCHESTRATION unit: fingerprint check → host-wide lock
35
+ * → parallel hydrate/CSS builds → manifest write (or failure tombstone). The
36
+ * individual concerns live as siblings under ./build-client-assets/
37
+ * (input-hashing, source-candidates, input-fingerprint, manifest,
38
+ * hashed-output, hydrate-bundle, sourcemaps-upload, styles-build, build-lock)
39
+ * — split when the parent held them all at 962 lines.
33
40
  */
34
41
 
35
- import { createHash } from "node:crypto";
36
- import { existsSync } from "node:fs";
37
- import { mkdir, readFile, readdir, rename, rm } from "node:fs/promises";
38
- import { dirname, join, resolve } from "node:path";
39
- import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
42
+ import { mkdir } from "node:fs/promises";
43
+ import { join } from "node:path";
40
44
  import { withFileLock } from "@teamscala/file-lock/with-lock";
41
45
  import { getAppLogger } from "@teamscala/logger/app-loggers";
42
- import { userHome } from "@teamscala/os/host-paths";
43
- import { spawnSync } from "@teamscala/os/spawn";
44
- import { compile } from "tailwindcss";
45
- // Input hashing lives in its own module — build-client-assets.ts held six
46
- // distinct concerns at 1221 lines (single-purpose-file measures export-name
47
- // cohesion, so it never flagged this). computeInputFingerprint below still
48
- // composes these two; the rest of that unit is private to the module.
46
+ import { CLIENT_BUILD_LOCK, getClientBuildLockTimeoutMs } from "./build-client-assets/build-lock.ts";
47
+ import { buildHydrateBundle } from "./build-client-assets/hydrate-bundle.ts";
48
+ import { computeInputFingerprint } from "./build-client-assets/input-fingerprint.ts";
49
49
  import {
50
- hashResolvedPackageStylesheets,
51
- hashTeamgbgJsGraph,
52
- } from "./build-client-assets/input-hashing.ts";
53
- import { scanSourceCandidates } from "./build-client-assets/source-candidates.ts";
54
- import * as v from "valibot";
55
-
56
- const HYDRATE_BASE = "hydrate";
57
- const STYLES_BASE = "styles";
58
- const MANIFEST_FILE = "asset-manifest.json";
59
-
60
- /**
61
- * Read ONLY the tombstone state of the current manifest — the runtime
62
- * (read-html-template, ui-service) calls this to decide whether to surface a
63
- * build-failure signal. Returns null when the manifest is fresh (no
64
- * `buildFailed`). Decoupled from the full AssetManifest read so the runtime
65
- * never depends on the build internals.
66
- */
67
- export async function readClientBuildStatus(
68
- cwd = process.cwd(),
69
- ): Promise<{ failed: true; reason?: string; at?: string } | { failed: false }> {
70
- const path = join(cwd, "dist", "client", MANIFEST_FILE);
71
- if (!(await Bun.file(path).exists())) return { failed: false };
72
- try {
73
- const parsed = (await Bun.file(path).json()) as Partial<AssetManifest>;
74
- if (parsed.buildFailed === true) {
75
- return {
76
- failed: true,
77
- reason: parsed.buildFailedReason,
78
- at: parsed.buildFailedAt,
79
- };
80
- }
81
- } catch {}
82
- return { failed: false };
83
- }
84
-
85
- // Single shared host lock serialising the heavy boot-time client build across
86
- // ALL kind:ui services (client-asset-builds-serialize-via-lock). A publish of a
87
- // widely-consumed package makes the dep-sync restart every consumer at
88
- // once; without this lock each one rebuilds its hydrate bundle + Tailwind CSS
89
- // simultaneously and N concurrent builds saturate the host (load 36, each ~15s
90
- // build starved to ~10min, services offline — 2026-06-13). The build sibling of
91
- // `restart-drivers-serialize-via-lock`'s `.scala-pm2-restart.lock`.
92
- const CLIENT_BUILD_LOCK = join(userHome(), ".scala-client-build.lock");
93
- // Generous: serialised builds drain well within this. On timeout we build
94
- // UNLOCKED (fail-open) — the lock is a thundering-herd optimisation, never a
95
- // correctness gate, and must never block a boot. TTL is DB-driven
96
- // (config/build-client-assets), promoted from the former hardcoded constant.
97
- const DEFAULT_CLIENT_BUILD_LOCK_TIMEOUT_MS = 600_000;
98
- let cachedClientBuildLockTimeoutMs: number | undefined;
99
- async function getClientBuildLockTimeoutMs(): Promise<number> {
100
- if (cachedClientBuildLockTimeoutMs !== undefined) return cachedClientBuildLockTimeoutMs;
101
- // Fail-soft to the hardcoded default: the deploy-build bake runs this in a
102
- // standalone subprocess with NO registered Prisma client (a pre-DB build
103
- // context), so loadRegistryConfig throws "Prisma client not registered". The
104
- // lock is a thundering-herd optimisation, never a correctness gate ("must
105
- // never block a boot"), and the TTL has a safe hardcoded default — so a
106
- // registry-read failure must degrade to the default, never abort the bake.
107
- try {
108
- const row = await loadRegistryConfig("config", "build-client-assets", v.looseObject({}));
109
- cachedClientBuildLockTimeoutMs =
110
- (row?.client_build_lock_timeout_ms as number | undefined) ??
111
- DEFAULT_CLIENT_BUILD_LOCK_TIMEOUT_MS;
112
- } catch {
113
- cachedClientBuildLockTimeoutMs = DEFAULT_CLIENT_BUILD_LOCK_TIMEOUT_MS;
114
- }
115
- return cachedClientBuildLockTimeoutMs;
116
- }
117
-
118
- interface AssetManifest {
119
- stylesPath: string;
120
- hydratePath: string;
121
- // Content fingerprint of the CLIENT-BUNDLE DEPENDENCY CLOSURE (source
122
- // entrypoints + @teamscala/* JS/CSS in the closure + third-party closure-dep
123
- // versions), hashed by CONTENT — never mtime (cache-key-hashes-source-
124
- // inputs-only). The boot/build freshness check rebuilds iff this differs
125
- // from the current closure. mtime comparison is fundamentally broken on
126
- // Railway: deploy-build bakes the bundle at image-build time, and under the
127
- // hoisted linker + warm `bun-install` cache the installed @teamscala/* files
128
- // are hardlinks carrying OLD extraction mtimes while the baked manifest's
129
- // mtime is newer — so `source.mtime > manifest.mtime` is false and the
130
- // build wrongly skips, baking (then serving forever) a STALE client bundle
131
- // even though a newer @teamscala/* version is installed. Observed 2026-06-14:
132
- // scala-gpt shipped ui-foundation < 85 in the bundle while a newer version
133
- // was installed, so a published+installed fix never reached the browser.
134
- // Content hashing of the closure is immune to mtime games. bun.lock is
135
- // excluded by design — see computeInputFingerprint.
136
- inputFingerprint?: string;
137
- // TOMBSTONE fields — present iff the last build FAILED (no-silent-reverts).
138
- // `buildFailed: true` + reason + timestamp are written by performBuild
139
- // before it re-throws. The next boot's fingerprint check sees the
140
- // `failed:<iso>` sentinel and cannot match → forces a retry (no silent
141
- // skip on the stale manifest). read-html-template reads these to stamp
142
- // the served HTML with the failure status (operator-visible signal that
143
- // the bundle is stale, not fresh).
144
- buildFailed?: boolean;
145
- buildFailedReason?: string;
146
- buildFailedAt?: string;
147
- }
148
-
149
- // Last-resort paths for a tombstone when no prior successful manifest exists
150
- // (first-ever boot fails). Points at the unhashed build names so an operator
151
- // inspecting the manifest sees a real (if 404-ing) target rather than a
152
- // mystery sentinel.
153
- const FALLBACK_MANIFEST: { stylesPath: string; hydratePath: string } = {
154
- stylesPath: "/assets/styles.css",
155
- hydratePath: "/assets/hydrate.js",
156
- };
157
-
158
- async function fileHash(path: string): Promise<string> {
159
- const buf = await Bun.file(path).text();
160
- return createHash("sha256").update(buf).digest("hex").slice(0, 10);
161
- }
162
-
163
- async function readManifest(outDir: string): Promise<AssetManifest | null> {
164
- const path = join(outDir, MANIFEST_FILE);
165
- if (!(await Bun.file(path).exists())) return null;
166
- try {
167
- const parsed = await Bun.file(path).json() as Partial<AssetManifest>;
168
- if (parsed.stylesPath && parsed.hydratePath) {
169
- return {
170
- stylesPath: parsed.stylesPath,
171
- hydratePath: parsed.hydratePath,
172
- inputFingerprint: parsed.inputFingerprint,
173
- buildFailed: parsed.buildFailed,
174
- buildFailedReason: parsed.buildFailedReason,
175
- buildFailedAt: parsed.buildFailedAt,
176
- };
177
- }
178
- return null;
179
- } catch {
180
- return null;
181
- }
182
- }
183
-
184
- async function writeManifest(
185
- outDir: string,
186
- manifest: AssetManifest,
187
- ): Promise<void> {
188
- await Bun.write(
189
- join(outDir, MANIFEST_FILE),
190
- `${JSON.stringify(manifest, null, 2)}\n`,
191
- );
192
- }
193
-
194
- async function removeStaleHashedFiles(
195
- outDir: string,
196
- base: string,
197
- ext: string,
198
- keep: string,
199
- ): Promise<void> {
200
- if (!(await Bun.file(outDir).exists())) return;
201
- const re = new RegExp(`^${base}-[a-f0-9]+\\.${ext}$`);
202
- for (const name of (await readdir(outDir, "utf-8"))) {
203
- if (re.test(name) && name !== keep) {
204
- try {
205
- await rm(join(outDir, name));
206
- } catch {}
207
- }
208
- }
209
- }
210
-
211
- async function buildHydrateBundle(
212
- cwd: string,
213
- log: ReturnType<typeof getAppLogger>,
214
- ): Promise<string | null> {
215
- const entry = join(cwd, "src", "entry-client.ts");
216
- const outDir = join(cwd, "dist", "client");
217
- const tmpFile = join(outDir, `${HYDRATE_BASE}.js`);
218
-
219
- if (!(await Bun.file(entry).exists())) {
220
- log.warn(
221
- `[build-client-assets] src/entry-client.ts not found at ${entry} — skipping JS bundle`,
222
- );
223
- return null;
224
- }
225
-
226
- await mkdir(outDir, { recursive: true });
227
-
228
- // Resilience (block-and-provider-error-isolation applied to the BUILD): a
229
- // page-manifest.ts lazy import of a block whose component no longer resolves
230
- // (a block_schema deactivation or a removed package export whose manifest
231
- // regen never propagated) MUST NOT fail the whole bundle and crash-loop the
232
- // service. Seven UI services went down at once this way on 2026-06-13. Such
233
- // an import is EXTERNALIZED instead: the build succeeds, the service boots,
234
- // and at runtime that one lazy import rejects into the block's
235
- // BlockErrorBoundary — the dead block degrades, the page + service stay up.
236
- // Only UNRESOLVABLE @teamgbg specifiers are touched (resolvable ones fall
237
- // through to Bun's normal resolution unchanged). Surfaced via warn so it is
238
- // never silent; the `no-dead-block-imports-in-manifest` guard still flags the
239
- // stale manifest so it gets regenerated.
240
- const externalizedBlocks: string[] = [];
241
- const resilientBlockPlugin: Bun.BunPlugin = {
242
- name: "resilient-block-imports",
243
- setup(build: { onResolve: (opts: { filter: RegExp }, cb: (args: { path: string }) => { path: string; external: boolean } | undefined) => void }) {
244
- // Externalize an unresolvable @teamscala/* block import (a deactivated
245
- // block_schema whose manifest regen never propagated) or a broken
246
- // third-party internal (@fullcalendar/core/internal.js during a version
247
- // mismatch) so it degrades ONE block at runtime instead of crash-looping
248
- // the service (2026-07-21 portal outage: @fullcalendar 7.x + plugins 6.x
249
- // → unresolvable internal.js → 21 crash-loop restarts).
250
- //
251
- // The filter is deliberately NARROW — only the package prefixes where
252
- // unresolvable imports actually occur — NOT every bare import. The prior
253
- // broad /^(?![./])/ filter intercepted resolvable shared packages too,
254
- // and onResolve pass-through (return undefined) on a shared
255
- // sideEffects:false package (@tanstack/react-query, imported across many
256
- // lazy block chunks) tripped Bun's code-splitter: it emitted a dead
257
- // `export{...}`-with-no-`from` facade and tree-shook away the entire
258
- // query-core implementation → a fatal hydration SyntaxError that froze
259
- // agents.scala.business while every component reported healthy
260
- // (2026-07-22; see the dead-facade gate + minifyIdentifiers:false
261
- // below). Intercepts of node builtins (stream/web) likewise pulled them
262
- // into the browser build. The narrow filter leaves @tanstack/* + builtins
263
- // to Bun's native (correct) resolution. A future unresolvable import
264
- // OUTSIDE these prefixes fails loud via the manifest guard + build
265
- // tombstone (no-dead-block-imports-in-manifest) — never silent.
266
- // Entry-point imports are excluded — those must fail loudly (silently
267
- // externalizing a core import turns a build error into a mystery runtime
268
- // crash; containment must not become a swallow).
269
- build.onResolve({ filter: /^(@teamgbg\/|@fullcalendar\/)/ }, (args) => {
270
- if (args.importer === entry) return undefined;
271
- try {
272
- Bun.resolveSync(args.path, cwd);
273
- return undefined;
274
- } catch {
275
- if (!externalizedBlocks.includes(args.path)) externalizedBlocks.push(args.path);
276
- return { path: args.path, external: true };
277
- }
278
- });
279
- },
280
- };
281
-
282
- let result: Awaited<ReturnType<typeof Bun.build>>;
283
- try {
284
- result = await Bun.build({
285
- entrypoints: [entry],
286
- target: "browser",
287
- outdir: outDir,
288
- naming: `${HYDRATE_BASE}.js`,
289
- // minifyIdentifiers is DISABLED (whitespace + syntax/dead-code minify
290
- // kept): under code-splitting, identifier mangling interacting with a
291
- // shared sideEffects:false package (@tanstack/react-query) trips Bun's
292
- // splitter to tree-shake away the entire implementation and emit a dead
293
- // re-export facade — a fatal hydration SyntaxError that froze
294
- // agents.scala.business while every component reported healthy
295
- // (2026-07-22; reproduces in stable 1.3.14 + canary 1.4.0, so an upstream
296
- // fix is not near). Whitespace + syntax minify retain the bulk of the
297
- // size win; only name-mangling is dropped. The post-build dead-facade
298
- // gate below fails the build LOUD if this or any future regression slips
299
- // a fatal facade through on any UI service.
300
- minifyWhitespace: process.env.NODE_ENV === "production",
301
- minifySyntax: process.env.NODE_ENV === "production",
302
- minifyIdentifiers: false,
303
- // Bun plugin type
304
- plugins: [resilientBlockPlugin],
305
- // Code-splitting honours `lazy-blocks-per-page`: page-manifest.ts wraps
306
- // every block in `lazy(() => import())`, so splitting emits the heavy
307
- // Plate editor + each lazy block as a SEPARATE chunk loaded on demand
308
- // instead of inlining all of them into one ~12.5MB entry. The initial
309
- // download drops ~10× (entry ~1.2MB; chunks lazy). splitting:false was
310
- // drift that defeated the lazy manifest. SSR safety is intact — the
311
- // page-server awaits stream.allReady before flushing (render-page-html),
312
- // so every block is server-rendered and hydration is consistent.
313
- splitting: true,
314
- // External source maps so minified client stacks resolve to source in
315
- // GlitchTip (uploaded post-build by uploadSourceMaps via the official
316
- // glitchtip-cli debug-ID flow). Maps are deleted after upload — never
317
- // served; the debug IDs injected into the bundles are all the SDK needs.
318
- sourcemap: "external",
319
- // Chunks are referenced by absolute /assets/ URL (served by
320
- // register-static-assets' /assets/* handler), never a relative path that
321
- // could mis-resolve under a record route like /?id=<uuid>.
322
- publicPath: "/assets/",
323
- external: ["*.css"],
324
- });
325
- } catch (e) {
326
- // Bun.build can THROW (not return {success:false}) on certain failure
327
- // modes — e.g. entrypoint resolves to a path Bun can't read, or an
328
- // internal panic. Without this catch the boot path swallows the
329
- // useful diagnostic and the operator sees only "Bundle failed".
330
- // AggregateError carries the per-file resolution list in .errors —
331
- // without serializing it the operator sees only "Bundle failed"
332
- // (cost a full Railway round-trip per guess on 2026-06-05).
333
- const subErrors =
334
- e instanceof AggregateError
335
- ? e.errors.map((se) => (se instanceof Error ? se.message : String(se)))
336
- : [];
337
- log.error(`[build-client-assets] Bun.build threw`, {
338
- message: e instanceof Error ? e.message : String(e),
339
- errors: subErrors,
340
- stack: e instanceof Error ? e.stack : undefined,
341
- entry,
342
- outDir,
343
- });
344
- throw new Error(
345
- `[build-client-assets] Bun.build threw: ${e instanceof Error ? e.message : String(e)}${subErrors.length ? `\n${subErrors.join("\n")}` : ""}`,
346
- );
347
- }
348
-
349
- if (!result.success) {
350
- // Bun.build's `.logs` is the verbose-build-message list. Each log is an
351
- // object with at minimum `{ level, message, position }`. Some failures
352
- // surface with an empty logs array — happens when Bun bails before
353
- // resolution (e.g. missing entrypoint after symlink-walk). Always emit
354
- // the full JSON form via the logger so service-runtime's structured
355
- // error trail captures every field, not just a `.message`-joined string.
356
- log.error(`[build-client-assets] Bun.build failed`, {
357
- logs: result.logs.map((l) => ({
358
- level: (l as { level?: string }).level,
359
- message: l.message,
360
- position: (l as { position?: unknown }).position,
361
- })),
362
- outputs: result.outputs.length,
363
- entry,
364
- outDir,
365
- });
366
- const messages = result.logs.length
367
- ? result.logs
368
- .map(
369
- (l) => `[${(l as { level?: string }).level ?? "?"}] ${l.message}`,
370
- )
371
- .join("\n")
372
- : "(no log entries — likely entrypoint resolution failure)";
373
- throw new Error(`[build-client-assets] Bun.build failed:\n${messages}`);
374
- }
375
-
376
- // dead-facade prevention gate (agents.scala.business 2026-07-22): a Bun
377
- // splitter+tree-shake bug (stable 1.3.14 + canary 1.4.0) can emit a "dead
378
- // facade" chunk — `export { ... }` with NO `from` clause and NO local binding
379
- // definitions — a fatal SyntaxError at module load that kills React hydration
380
- // SILENTLY (the whole dashboard froze while every component reported healthy).
381
- // The narrow resilientBlockPlugin filter + minifyIdentifiers:false above
382
- // prevent the known trigger (@tanstack/react-query under split); this gate
383
- // makes the CLASS unshippable — any dead facade from ANY package on ANY UI
384
- // service fails the build LOUD here instead of freezing the page. Detection
385
- // is POSITION-INDEPENDENT — a named export{} ANYWHERE in the comment-stripped
386
- // body, with no `from` clause and no local declaration/import backing the
387
- // names, is the facade (Bun could prefix it with a directive or statement, not
388
- // only emit it at the very start — a positional assumption is a known way to
389
- // be silently bypassed; a valid local-export chunk carries a
390
- // class/function/const/var/let/import, a valid re-export carries `from`).
391
- const deadFacades: string[] = [];
392
- for (const out of result.outputs) {
393
- if (out.kind !== "chunk") continue;
394
- let text: string;
395
- try {
396
- text = await Bun.file(out.path).text();
397
- } catch {
398
- continue;
399
- }
400
- const stripped = text
401
- .replace(/\/\/#.*$/gms, "")
402
- .replace(/\/\/[^\n]*/g, "")
403
- .replace(/\/\*[\s\S]*?\*\//g, "")
404
- .trim();
405
- if (
406
- /export\s*\{[^}]*\}/.test(stripped) &&
407
- !/\bfrom\s*["']/.test(stripped) &&
408
- !/\b(?:class|function|const|var|let|import)\b/.test(stripped)
409
- ) {
410
- deadFacades.push(out.path.split("/").pop() ?? out.path);
411
- }
412
- }
413
- if (deadFacades.length > 0) {
414
- log.error(`[build-client-assets] dead-facade gate: refusing to ship fatal bundle`, {
415
- deadFacades,
416
- hint: "A dead facade (export{} with no from/source) is a fatal hydration SyntaxError. Investigate a shared sideEffects:false package under splitting, or a Bun splitter+minifyIdentifiers regression.",
417
- });
418
- throw new Error(
419
- `[build-client-assets] Bun.build emitted ${deadFacades.length} dead facade chunk(s) (export{} with no from/source — a fatal hydration SyntaxError). Refusing to ship a broken bundle. Files: ${deadFacades.join(", ")}`,
420
- );
421
- }
422
-
423
- const hash = await fileHash(tmpFile);
424
- const hashedName = `${HYDRATE_BASE}-${hash}.js`;
425
- const hashedPath = join(outDir, hashedName);
426
- await rename(tmpFile, hashedPath);
427
- removeStaleHashedFiles(outDir, HYDRATE_BASE, "js", hashedName);
428
-
429
- // Splitting emits one chunk per lazy import() alongside the entry. Keep the
430
- // chunks THIS build produced; drop stale ones so dist/client/ doesn't
431
- // accumulate hundreds of orphaned chunks per rebuild.
432
- const currentChunks = new Set(
433
- result.outputs
434
- .filter((o) => o.kind === "chunk")
435
- .map((o) => o.path.split("/").pop() as string),
436
- );
437
- await removeStaleChunks(outDir, currentChunks);
438
-
439
- // Bundle telemetry → logger (single-logger-entry-point → GlitchTip): the
440
- // initial-download size + lazy-chunk footprint are visible per build, so a
441
- // regression (splitting silently off, or a heavy dep landing in the entry)
442
- // surfaces as a number instead of a mystery "slow page".
443
- const entryBytes = (await Bun.file(hashedPath).stat()).size;
444
- let chunkBytes = 0;
445
- for (const name of currentChunks) {
446
- try {
447
- chunkBytes += (await Bun.file(join(outDir, name)).stat()).size;
448
- } catch {}
449
- }
450
- log.info(
451
- `[build-client-assets] hydrate built → ${hashedName} (entry ${(entryBytes / 1024).toFixed(0)}KB + ${currentChunks.size} lazy chunks ${(chunkBytes / 1048576).toFixed(1)}MB)`,
452
- { entryBytes, chunkCount: currentChunks.size, chunkBytes },
453
- );
454
- if (externalizedBlocks.length > 0) {
455
- // The service boots; these blocks degrade at runtime (BlockErrorBoundary)
456
- // instead of crash-looping the whole service. The stale manifest still
457
- // needs regenerating — surfaced here AND by no-dead-block-imports-in-manifest.
458
- log.warn(
459
- `[build-client-assets] ${externalizedBlocks.length} unresolvable block import(s) externalized (degraded, not fatal) — regenerate this service's page-manifest: ${externalizedBlocks.join(", ")}`,
460
- { externalizedBlocks },
461
- );
462
- }
463
- // Upload source maps to GlitchTip (best-effort, dev-host-only) + delete the
464
- // .map files so source never ships publicly. See uploadSourceMaps.
465
- await uploadSourceMaps(outDir, hash, log);
466
- return `/assets/${hashedName}`;
467
- }
468
-
469
- /**
470
- * Upload source maps to GlitchTip via the official `glitchtip-cli` debug-ID
471
- * flow (the documented first-class mechanism — `sourcemaps inject` then
472
- * `sourcemaps upload --release`; see telemetry.md `client-errors-report-to-glitchtip`
473
- * + https://glitchtip.com/documentation/cli). Debug IDs injected into the served
474
- * bundles + uploaded maps are the matching mechanism — robust vs URL-prefix
475
- * guessing. Best-effort + dev-host-only: skips silently when the CLI or
476
- * SENTRY_AUTH_TOKEN is absent (Railway builds don't install the CLI; prod source
477
- * maps are a follow-up). The .map files are deleted after upload — source never
478
- * ships publicly; the debug IDs in the served bundles are all the SDK needs.
479
- */
480
- async function uploadSourceMaps(
481
- outDir: string,
482
- release: string,
483
- log: ReturnType<typeof getAppLogger>,
484
- ): Promise<void> {
485
- const token = process.env.SENTRY_AUTH_TOKEN;
486
- const sentryUrl = process.env.SENTRY_URL;
487
- if (!token || !sentryUrl) return; // best-effort: no token/URL → skip
488
- const org = process.env.SENTRY_ORG ?? "scala";
489
- const project = process.env.SENTRY_PROJECT ?? "scala-hub";
490
- const cli = join(userHome(), ".local", "bin", "glitchtip-cli");
491
- const headers = { Authorization: `Bearer ${token}` };
492
- try {
493
- // Inject debug IDs into the bundles + maps (if glitchtip-cli is present).
494
- // The CLI is optional — only used for the inject step.
495
- if (existsSync(cli)) {
496
- spawnSync({
497
- name: "glitchtip-cli-sourcemaps-inject",
498
- command: [cli, "sourcemaps", "inject", outDir],
499
- env: { ...process.env, SENTRY_AUTH_TOKEN: token },
500
- timeoutMs: 60_000,
501
- captureStderr: true,
502
- });
503
- }
504
- // Upload source maps via the direct release-files API (NOT the CLI's
505
- // chunk-upload — that hits a host-mismatch behind reverse proxies: the
506
- // server returns a stale internal URL the CLI refuses to send the token to).
507
- // The direct API posts to SENTRY_URL (which we control) — no server-returned
508
- // URL, no host mismatch.
509
- await fetch(`${sentryUrl}/api/0/organizations/${org}/releases/`, {
510
- method: "POST",
511
- headers: { ...headers, "Content-Type": "application/json" },
512
- body: JSON.stringify({ version: release, projects: [project] }),
513
- });
514
- let uploaded = 0;
515
- for (const name of await readdir(outDir, "utf-8")) {
516
- if (!name.endsWith(".map")) continue;
517
- try {
518
- const mapBytes = await Bun.file(join(outDir, name)).bytes();
519
- const form = new FormData();
520
- form.append("name", `~/assets/${name.replace(".map", "")}`);
521
- form.append("file", new Blob([mapBytes]), name);
522
- const res = await fetch(
523
- `${sentryUrl}/api/0/projects/${org}/${project}/releases/${release}/files/`,
524
- { method: "POST", headers, body: form },
525
- );
526
- if (res.ok) uploaded++;
527
- } catch {}
528
- }
529
- log.info("[build-client-assets] source maps uploaded to GlitchTip", {
530
- release,
531
- project,
532
- uploaded,
533
- });
534
- } catch (err) {
535
- log.warn("[build-client-assets] source-map upload error (best-effort, skipped)", {
536
- release,
537
- error: err instanceof Error ? err.message : String(err),
538
- });
539
- }
540
- // Delete the .map files — never serve source. The debug IDs injected into the
541
- // served bundles are all the SDK needs; the maps live in GlitchTip keyed by them.
542
- for (const name of await readdir(outDir, "utf-8")) {
543
- if (name.endsWith(".map")) {
544
- try {
545
- await rm(join(outDir, name));
546
- } catch {}
547
- }
548
- }
549
- }
550
-
551
- // Remove hashed code-split chunks no longer referenced by the current entry.
552
- // Keyed off the build's own output list (the only authoritative current set),
553
- // never a name pattern guess — a build emits N `chunk-*.js`; the previous
554
- // build's are orphaned the moment the entry's hash changes.
555
- async function removeStaleChunks(
556
- outDir: string,
557
- keep: Set<string>,
558
- ): Promise<void> {
559
- if (!(await Bun.file(outDir).exists())) return;
560
- for (const name of (await readdir(outDir, "utf-8"))) {
561
- if (/^chunk-[A-Za-z0-9]+\.js$/.test(name) && !keep.has(name)) {
562
- try {
563
- await rm(join(outDir, name));
564
- } catch {}
565
- }
566
- }
567
- }
568
-
569
- async function resolveStylesheetRaw(
570
- id: string,
571
- base: string,
572
- cwd: string,
573
- ): Promise<{ path: string; base: string; content: string }> {
574
- if (id === "tailwindcss") {
575
- const r = await Bun.resolve("tailwindcss/index.css", cwd);
576
- return { path: r, base: dirname(r), content: await readFile(r, "utf-8") };
577
- }
578
- try {
579
- const r = await Bun.resolve(id, base);
580
- return { path: r, base: dirname(r), content: await readFile(r, "utf-8") };
581
- } catch {}
582
- if (!id.startsWith(".")) {
583
- try {
584
- const pkgPath = await Bun.resolve(id + "/package.json", cwd);
585
- const pkg = JSON.parse(await readFile(pkgPath, "utf-8"));
586
- const entry =
587
- pkg.exports?.["."]?.style || pkg.style || pkg.main;
588
- if (entry) {
589
- const r = resolve(dirname(pkgPath), entry);
590
- return { path: r, base: dirname(r), content: await readFile(r, "utf-8") };
591
- }
592
- } catch {}
593
- }
594
- const r = resolve(base, id);
595
- return { path: r, base: dirname(r), content: await readFile(r, "utf-8") };
596
- }
597
-
598
- // Tailwind v4's compile() invokes `loadStylesheet` for DIRECT @imports in the
599
- // entry (app.css) but does NOT re-invoke it for @imports WITHIN the loaded
600
- // content — so a package styles.css that itself `@import "./Leaf.css"` (a
601
- // nested RELATIVE import) has its leaf silently dropped from the prod bundle.
602
- // This dropped every block-specific class reachable only via a nested
603
- // relative @import (project-list-view/ProjectListView.css's
604
- // .nested-list-table-host, audit-block's nested css, etc.) while direct
605
- // package @imports (generic-ui/blk-utilities → .blk-*) survived — the
606
- // 2026-07-05 #6 outage (broken projects UI). Inline nested @imports here so
607
- // the content returned to Tailwind is flat; the compile() loadStylesheet
608
- // callback then never sees a nested @import it would drop. Cycle-safe via
609
- // `seen` (mirrors hashResolvedPackageStylesheet's transitive walk).
610
- const CSS_IMPORT_RE = /@import\s+(?:url\()?\s*["']([^"']+)["']\s*\)?[^;]*;?/g;
611
- async function inlineCssImports(
612
- css: string,
613
- base: string,
614
- cwd: string,
615
- seen: Set<string>,
616
- ): Promise<string> {
617
- let result = "";
618
- let lastIdx = 0;
619
- for (const m of css.matchAll(CSS_IMPORT_RE)) {
620
- const spec = m[1].trim();
621
- result += css.slice(lastIdx, m.index);
622
- lastIdx = (m.index ?? 0) + m[0].length;
623
- // Keep the tailwindcss entry verbatim (Tailwind compile owns it —
624
- // including any trailing `source(none)` etc. — via m[0]).
625
- if (spec === "tailwindcss" || spec.startsWith("tailwindcss/")) {
626
- result += m[0];
627
- continue;
628
- }
629
- if (seen.has(spec)) continue; // cycle guard
630
- seen.add(spec);
631
- try {
632
- const resolved = await resolveStylesheetRaw(spec, base, cwd);
633
- result += await inlineCssImports(resolved.content, resolved.base, cwd, seen);
634
- } catch {
635
- result += m[0]; // unresolved — keep literal; CSS/Tailwind will surface it
636
- }
637
- }
638
- result += css.slice(lastIdx);
639
- return result;
640
- }
641
-
642
- async function bunResolveStylesheet(
643
- id: string,
644
- base: string,
645
- cwd: string,
646
- ): Promise<{ path: string; base: string; content: string }> {
647
- const resolved = await resolveStylesheetRaw(id, base, cwd);
648
- if (id === "tailwindcss" || id.startsWith("tailwindcss/")) return resolved;
649
- const content = await inlineCssImports(resolved.content, resolved.base, cwd, new Set([resolved.path]));
650
- return { ...resolved, content };
651
- }
652
-
653
- // The app owns its fonts via an explicit, CSP-allowlisted <link> in index.html
654
- // (Google Fonts — `style-src ... fonts.googleapis.com`, `font-src ...
655
- // fonts.gstatic.com`). Third-party package CSS MUST NOT inject its own remote
656
- // `@import url(http…)`: @puckeditor/core/dist/index.css opens with
657
- // `@import "https://rsms.me/inter/inter.css"`, an arbitrary CDN our CSP does
658
- // NOT allow. The Tailwind compile inlines that mid-output, where it was either
659
- // silently dropped (CSS spec: @import must precede all rules) OR — once hoisted —
660
- // blocked by CSP, leaving a console error on every page; either way the font
661
- // never loaded and the site fell back to a system font (scala-docs 2026-06-13).
662
- // So every remote @import is STRIPPED from the compiled bundle: a package can
663
- // never inject a CDN font/stylesheet, and the only font source is the app-owned
664
- // index.html link. Fixed once at the single CSS chokepoint; guarded by
665
- // `css-build-strips-remote-imports`.
666
- const REMOTE_IMPORT_RE =
667
- /^[ \t]*@import\s+(?:url\(\s*)?["']https?:\/\/[^"')]+["']\s*\)?[^;]*;[ \t]*\n?/gm;
668
-
669
- export function stripRemoteImports(css: string): string {
670
- return css.replace(REMOTE_IMPORT_RE, "");
671
- }
672
-
673
-
674
- async function buildTailwindCss(
675
- cwd: string,
676
- log: ReturnType<typeof getAppLogger>,
677
- ): Promise<string | null> {
678
- const entry = join(cwd, "src", "app.css");
679
- const outDir = join(cwd, "dist", "client");
680
- const tmpFile = join(outDir, `${STYLES_BASE}.css`);
681
-
682
- if (!(await Bun.file(entry).exists())) {
683
- log.warn(
684
- `[build-client-assets] src/app.css not found at ${entry} — skipping CSS build`,
685
- );
686
- return null;
687
- }
688
-
689
- await mkdir(outDir, { recursive: true });
690
-
691
- const cssSource = await readFile(entry, "utf-8");
692
- const compiler = await compile(cssSource, {
693
- base: cwd,
694
- async loadStylesheet(id, base) {
695
- return bunResolveStylesheet(id, base, cwd);
696
- },
697
- async loadModule(id, base) {
698
- const r = await Bun.resolve(id, base);
699
- const mod = await import(r);
700
- return { path: r, base: dirname(r), module: mod.default ?? mod };
701
- },
702
- });
703
-
704
- // Scan every resolved @source for candidate class names — WITHOUT this the
705
- // build emits no content-driven utilities (the unstyled-editor bug above).
706
- const candidates = await scanSourceCandidates(compiler.sources, log);
707
- const cssOutput = stripRemoteImports(compiler.build(candidates));
708
- log.info(
709
- `[build-client-assets] tailwind: ${candidates.length} candidate(s) from ${compiler.sources.length} source(s)`,
710
- );
711
-
712
- await Bun.write(tmpFile, cssOutput);
713
-
714
- const hash = await fileHash(tmpFile);
715
- const hashedName = `${STYLES_BASE}-${hash}.css`;
716
- const hashedPath = join(outDir, hashedName);
717
- await rename(tmpFile, hashedPath);
718
- removeStaleHashedFiles(outDir, STYLES_BASE, "css", hashedName);
719
-
720
- log.info(`[build-client-assets] styles.css built → ${hashedName} (in-process Bun compile)`);
721
- return `/assets/${hashedName}`;
722
- }
723
-
724
- // Content fingerprint of the client-bundle dependency CLOSURE — hashed by
725
- // CONTENT, never mtime (cache-key-hashes-source-inputs-only). Rebuild iff this
726
- // differs from the fingerprint stored in the existing manifest. Scoped to the
727
- // CLOSURE (not all of node_modules) so a @teamscala/* publish that is not in this
728
- // service's client bundle cannot trigger a rebuild — the publish→rebuild storm
729
- // is architecturally impossible (`no-uncontrolled-repetition-or-cascade`).
730
- //
731
- // Inputs (everything that can change the emitted bytes):
732
- // src/entry-client.ts — the bundle entrypoint.
733
- // src/page-manifest.ts — the generated block→component map the bundle is built
734
- // from; a codegen regen (block added/removed) changes it,
735
- // and the hydrate bundle MUST rebuild or new blocks render
736
- // empty → hydration mismatch.
737
- // src/app.css — the Tailwind entry.
738
- // @teamscala/* JS closure (hashTeamgbjJsGraph) — the CONTENT of every @teamscala/*
739
- // module reachable from the client entrypoints. Content
740
- // (not version) so a mid-install stale bake (bun.lock
741
- // ahead of node_modules — the lock can name a new version
742
- // while the prior bytes are still on disk) is detected on
743
- // the next boot and self-heals (scala-agents 2026-07-12).
744
- // @teamscala/* CSS closure (hashResolvedPackageStylesheets) — the CONTENT of
745
- // every package stylesheet app.css @imports (transitively).
746
- // third-party closure-boundary versions (hashTeamgbgJsGraph) — the resolved
747
- // VERSION of each third-party package the client closure
748
- // imports (react, etc.). Third-party modules are not
749
- // content-walked (large; pinned by bun.lock + change only
750
- // on a version bump), but a version bump of a closure dep
751
- // MUST invalidate, so the installed package.json#version
752
- // is folded. This is the closure-scoped replacement for
753
- // hashing all of bun.lock: react bumping in the closure
754
- // still rebuilds, while a backend @teamscala/* publish
755
- // outside the closure does not.
756
- // NB: bun.lock is INTENTIONALLY NOT an input. It encodes every resolved
757
- // @teamscala/* version (backend packages included), so it invalidates on every
758
- // publish — the false-invalidation source that made every UI service rebuild
759
- // on a backend publish. The closure inputs above are a strict improvement:
760
- // they catch every change that affects THIS bundle AND nothing that doesn't.
761
- export async function computeInputFingerprint(cwd: string): Promise<string> {
762
- // Keyed on each input's RELATIVE label + CONTENT only — never the absolute
763
- // path (that would make the fingerprint cwd-dependent and defeat the skip
764
- // across two checkouts of the same service). bun.lock is DELIBERATELY
765
- // EXCLUDED — it encodes EVERY resolved @teamscala/* version, so folding it in
766
- // invalidates on every publish including packages NOT in this service's
767
- // client bundle (the publish→rebuild storm — `no-uncontrolled-repetition-
768
- // or-cascade`). The closure content hashes below (hashTeamgbgJsGraph +
769
- // hashResolvedPackageStylesheets) + the third-party closure-boundary
770
- // versions cover every dependency change that ACTUALLY affects this bundle,
771
- // so a non-closure publish changes nothing here → zero rebuild. Rebuild
772
- // iff this fingerprint differs from the one stored in the manifest.
773
- const sources: Array<[label: string, path: string]> = [
774
- ["src/entry-client.ts", join(cwd, "src", "entry-client.ts")],
775
- ["src/page-manifest.ts", join(cwd, "src", "page-manifest.ts")],
776
- ["src/app.css", join(cwd, "src", "app.css")],
777
- ];
778
- const h = createHash("sha256");
779
- for (const [label, path] of sources) {
780
- // Missing input contributes a stable sentinel rather than throwing — a
781
- // backend service has no app.css, and that absence is itself part of the
782
- // fingerprint. `(await Bun.file().text())` is parenthesised so a missing
783
- // file's sync TypeError is caught (bun-async-file-operations).
784
- try {
785
- h.update(label).update("\0").update(await Bun.file(path).text()).update("\0");
786
- } catch {
787
- h.update(label).update("\0<absent>\0");
788
- }
789
- }
790
- // Fold the resolved CONTENT of every @teamscala/* stylesheet app.css @imports
791
- // (client-asset-cache-key-embeds-package-content). bun.lock pins versions; the
792
- // build reads bytes. When they diverge (boot built mid-install), the lock-only
793
- // fingerprint false-matches and skips forever — hashing the bytes closes it.
794
- await hashResolvedPackageStylesheets(cwd, h);
795
- // Fold in the resolved CONTENT of every @teamscala/* JS/TS module reachable
796
- // from the client entrypoints (hashTeamgbgJsGraph). bun.lock pins versions;
797
- // the build reads BYTES — and a boot can build MID-INSTALL (the package-
798
- // poller restarts the service after writing the new bun.lock but before
799
- // node_modules extraction finishes), so Bun.build faithfully bakes the OLD
800
- // @teamgbg JS while the lock-derived fingerprint is stored as "current".
801
- // Every later restart then recomputes the same lock fingerprint → skip →
802
- // STALE bundle served forever (scala-agents 2026-07-12: hydrate-4dad7478bd
803
- // carried zero of portal-ui@2.4.41's markers across restarts). Bun.build
804
- // itself is NOT caching — an isolated repro proves it reads fresh content
805
- // across processes — so the fix is to make the fingerprint cover the actual
806
- // @teamgbg JS bytes (the JS analogue of hashResolvedPackageStylesheets,
807
- // which already closes this exact race for CSS). A stale bake then hashes
808
- // differently from the freshly-installed bytes → rebuild → self-heals.
809
- await hashTeamgbgJsGraph(cwd, h);
810
- return h.digest("hex").slice(0, 16);
811
- }
50
+ FALLBACK_MANIFEST,
51
+ readManifest,
52
+ writeManifest,
53
+ } from "./build-client-assets/manifest.ts";
54
+ import { buildTailwindCss } from "./build-client-assets/styles-build.ts";
812
55
 
813
56
  export async function buildClientAssets(serviceSlug: string): Promise<void> {
814
57
  const log = getAppLogger();