@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.
- package/package.json +57 -1
- package/src/build-client-assets/build-lock.ts +55 -0
- package/src/build-client-assets/hashed-output.ts +39 -0
- package/src/build-client-assets/hydrate-bundle.ts +298 -0
- package/src/build-client-assets/input-fingerprint.ts +108 -0
- package/src/build-client-assets/manifest.ts +115 -0
- package/src/build-client-assets/sourcemaps-upload.ts +101 -0
- package/src/build-client-assets/styles-build.ts +179 -0
- package/src/build-client-assets.test.ts +2 -4
- package/src/build-client-assets.ts +20 -777
- package/src/read-html-template.ts +1 -1
|
@@ -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
|
|
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`).
|
|
30
|
-
*
|
|
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 {
|
|
36
|
-
import {
|
|
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 {
|
|
43
|
-
import {
|
|
44
|
-
import {
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
import
|
|
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();
|