@teamscala/service-boot 0.23.412 → 0.23.414

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 CHANGED
@@ -6,7 +6,7 @@
6
6
  },
7
7
  "name": "@teamscala/service-boot",
8
8
  "type": "module",
9
- "version": "0.23.412",
9
+ "version": "0.23.414",
10
10
  "sideEffects": false,
11
11
  "imports": {
12
12
  "#service-boot/*": "./src/*",
@@ -170,6 +170,62 @@
170
170
  "types": "./src/build-client-assets/input-hashing.d.ts",
171
171
  "default": "./src/build-client-assets/input-hashing.ts"
172
172
  },
173
+ "./build-client-assets/input-fingerprint": {
174
+ "types": "./src/build-client-assets/input-fingerprint.d.ts",
175
+ "default": "./src/build-client-assets/input-fingerprint.ts"
176
+ },
177
+ "./build-client-assets/input-fingerprint.ts": {
178
+ "types": "./src/build-client-assets/input-fingerprint.d.ts",
179
+ "default": "./src/build-client-assets/input-fingerprint.ts"
180
+ },
181
+ "./build-client-assets/hashed-output": {
182
+ "types": "./src/build-client-assets/hashed-output.d.ts",
183
+ "default": "./src/build-client-assets/hashed-output.ts"
184
+ },
185
+ "./build-client-assets/hashed-output.ts": {
186
+ "types": "./src/build-client-assets/hashed-output.d.ts",
187
+ "default": "./src/build-client-assets/hashed-output.ts"
188
+ },
189
+ "./build-client-assets/hydrate-bundle": {
190
+ "types": "./src/build-client-assets/hydrate-bundle.d.ts",
191
+ "default": "./src/build-client-assets/hydrate-bundle.ts"
192
+ },
193
+ "./build-client-assets/hydrate-bundle.ts": {
194
+ "types": "./src/build-client-assets/hydrate-bundle.d.ts",
195
+ "default": "./src/build-client-assets/hydrate-bundle.ts"
196
+ },
197
+ "./build-client-assets/sourcemaps-upload": {
198
+ "types": "./src/build-client-assets/sourcemaps-upload.d.ts",
199
+ "default": "./src/build-client-assets/sourcemaps-upload.ts"
200
+ },
201
+ "./build-client-assets/sourcemaps-upload.ts": {
202
+ "types": "./src/build-client-assets/sourcemaps-upload.d.ts",
203
+ "default": "./src/build-client-assets/sourcemaps-upload.ts"
204
+ },
205
+ "./build-client-assets/styles-build": {
206
+ "types": "./src/build-client-assets/styles-build.d.ts",
207
+ "default": "./src/build-client-assets/styles-build.ts"
208
+ },
209
+ "./build-client-assets/styles-build.ts": {
210
+ "types": "./src/build-client-assets/styles-build.d.ts",
211
+ "default": "./src/build-client-assets/styles-build.ts"
212
+ },
213
+ "./build-client-assets/manifest": {
214
+ "types": "./src/build-client-assets/manifest.d.ts",
215
+ "default": "./src/build-client-assets/manifest.ts"
216
+ },
217
+ "./build-client-assets/manifest.ts": {
218
+ "types": "./src/build-client-assets/manifest.d.ts",
219
+ "default": "./src/build-client-assets/manifest.ts"
220
+ },
221
+ "./build-client-assets/build-lock": {
222
+ "types": "./src/build-client-assets/build-lock.d.ts",
223
+ "default": "./src/build-client-assets/build-lock.ts"
224
+ },
225
+ "./build-client-assets/build-lock.ts": {
226
+ "types": "./src/build-client-assets/build-lock.d.ts",
227
+ "default": "./src/build-client-assets/build-lock.ts"
228
+ },
173
229
  "./build-client-assets/source-candidates": {
174
230
  "types": "./src/build-client-assets/source-candidates.d.ts",
175
231
  "default": "./src/build-client-assets/source-candidates.ts"
@@ -830,6 +886,62 @@
830
886
  "types": "./src/register-registry-page-route/resolve-page-data.d.ts",
831
887
  "default": "./src/register-registry-page-route/resolve-page-data.ts"
832
888
  },
889
+ "./register-registry-page-route/resolve-request-graph": {
890
+ "types": "./src/register-registry-page-route/resolve-request-graph.d.ts",
891
+ "default": "./src/register-registry-page-route/resolve-request-graph.ts"
892
+ },
893
+ "./register-registry-page-route/resolve-request-graph.ts": {
894
+ "types": "./src/register-registry-page-route/resolve-request-graph.d.ts",
895
+ "default": "./src/register-registry-page-route/resolve-request-graph.ts"
896
+ },
897
+ "./register-registry-page-route/resolve-panel-page": {
898
+ "types": "./src/register-registry-page-route/resolve-panel-page.d.ts",
899
+ "default": "./src/register-registry-page-route/resolve-panel-page.ts"
900
+ },
901
+ "./register-registry-page-route/resolve-panel-page.ts": {
902
+ "types": "./src/register-registry-page-route/resolve-panel-page.d.ts",
903
+ "default": "./src/register-registry-page-route/resolve-panel-page.ts"
904
+ },
905
+ "./register-registry-page-route/stamp-session-context": {
906
+ "types": "./src/register-registry-page-route/stamp-session-context.d.ts",
907
+ "default": "./src/register-registry-page-route/stamp-session-context.ts"
908
+ },
909
+ "./register-registry-page-route/stamp-session-context.ts": {
910
+ "types": "./src/register-registry-page-route/stamp-session-context.d.ts",
911
+ "default": "./src/register-registry-page-route/stamp-session-context.ts"
912
+ },
913
+ "./register-registry-page-route/run-content-gates": {
914
+ "types": "./src/register-registry-page-route/run-content-gates.d.ts",
915
+ "default": "./src/register-registry-page-route/run-content-gates.ts"
916
+ },
917
+ "./register-registry-page-route/run-content-gates.ts": {
918
+ "types": "./src/register-registry-page-route/run-content-gates.d.ts",
919
+ "default": "./src/register-registry-page-route/run-content-gates.ts"
920
+ },
921
+ "./register-registry-page-route/apply-page-branding": {
922
+ "types": "./src/register-registry-page-route/apply-page-branding.d.ts",
923
+ "default": "./src/register-registry-page-route/apply-page-branding.ts"
924
+ },
925
+ "./register-registry-page-route/apply-page-branding.ts": {
926
+ "types": "./src/register-registry-page-route/apply-page-branding.d.ts",
927
+ "default": "./src/register-registry-page-route/apply-page-branding.ts"
928
+ },
929
+ "./register-registry-page-route/warn-uncovered-blocks": {
930
+ "types": "./src/register-registry-page-route/warn-uncovered-blocks.d.ts",
931
+ "default": "./src/register-registry-page-route/warn-uncovered-blocks.ts"
932
+ },
933
+ "./register-registry-page-route/warn-uncovered-blocks.ts": {
934
+ "types": "./src/register-registry-page-route/warn-uncovered-blocks.d.ts",
935
+ "default": "./src/register-registry-page-route/warn-uncovered-blocks.ts"
936
+ },
937
+ "./register-registry-page-route/prefetch-org-id": {
938
+ "types": "./src/register-registry-page-route/prefetch-org-id.d.ts",
939
+ "default": "./src/register-registry-page-route/prefetch-org-id.ts"
940
+ },
941
+ "./register-registry-page-route/prefetch-org-id.ts": {
942
+ "types": "./src/register-registry-page-route/prefetch-org-id.d.ts",
943
+ "default": "./src/register-registry-page-route/prefetch-org-id.ts"
944
+ },
833
945
  "./register-static-assets": {
834
946
  "types": "./src/register-static-assets.d.ts",
835
947
  "default": "./src/register-static-assets.ts"
@@ -0,0 +1,55 @@
1
+ /**
2
+ * @system server-handlers
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * The host-wide client-build lock of the client-asset build: serialises the
7
+ * heavy boot-time client build across ALL kind:ui services
8
+ * (client-asset-builds-serialize-via-lock), with a DB-driven TTL that
9
+ * fail-softs to a hardcoded default.
10
+ *
11
+ * Split out of build-client-assets.ts (962 lines) alongside its build
12
+ * siblings. Byte-for-byte move from the parent — imports adjusted, nothing
13
+ * else.
14
+ */
15
+
16
+ import { join } from "node:path";
17
+ import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
18
+ import { userHome } from "@teamscala/os/host-paths";
19
+ import * as v from "valibot";
20
+
21
+ // Single shared host lock serialising the heavy boot-time client build across
22
+ // ALL kind:ui services (client-asset-builds-serialize-via-lock). A publish of a
23
+ // widely-consumed package makes the dep-sync restart every consumer at
24
+ // once; without this lock each one rebuilds its hydrate bundle + Tailwind CSS
25
+ // simultaneously and N concurrent builds saturate the host (load 36, each ~15s
26
+ // build starved to ~10min, services offline — 2026-06-13). The build sibling of
27
+ // `restart-drivers-serialize-via-lock`'s `.scala-pm2-restart.lock`.
28
+ const CLIENT_BUILD_LOCK = join(userHome(), ".scala-client-build.lock");
29
+ // Generous: serialised builds drain well within this. On timeout we build
30
+ // UNLOCKED (fail-open) — the lock is a thundering-herd optimisation, never a
31
+ // correctness gate, and must never block a boot. TTL is DB-driven
32
+ // (config/build-client-assets), promoted from the former hardcoded constant.
33
+ const DEFAULT_CLIENT_BUILD_LOCK_TIMEOUT_MS = 600_000;
34
+ let cachedClientBuildLockTimeoutMs: number | undefined;
35
+
36
+ export async function getClientBuildLockTimeoutMs(): Promise<number> {
37
+ if (cachedClientBuildLockTimeoutMs !== undefined) return cachedClientBuildLockTimeoutMs;
38
+ // Fail-soft to the hardcoded default: the deploy-build bake runs this in a
39
+ // standalone subprocess with NO registered Prisma client (a pre-DB build
40
+ // context), so loadRegistryConfig throws "Prisma client not registered". The
41
+ // lock is a thundering-herd optimisation, never a correctness gate ("must
42
+ // never block a boot"), and the TTL has a safe hardcoded default — so a
43
+ // registry-read failure must degrade to the default, never abort the bake.
44
+ try {
45
+ const row = await loadRegistryConfig("config", "build-client-assets", v.looseObject({}));
46
+ cachedClientBuildLockTimeoutMs =
47
+ (row?.client_build_lock_timeout_ms as number | undefined) ??
48
+ DEFAULT_CLIENT_BUILD_LOCK_TIMEOUT_MS;
49
+ } catch {
50
+ cachedClientBuildLockTimeoutMs = DEFAULT_CLIENT_BUILD_LOCK_TIMEOUT_MS;
51
+ }
52
+ return cachedClientBuildLockTimeoutMs;
53
+ }
54
+
55
+ export { CLIENT_BUILD_LOCK };
@@ -0,0 +1,39 @@
1
+ /**
2
+ * @system server-handlers
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * Content-hashed output housekeeping for the client-asset build: the
7
+ * short content hash stamped into hashed filenames, and removal of stale
8
+ * hashed files from a previous build so dist/client/ never accumulates
9
+ * dead outputs.
10
+ *
11
+ * Split out of build-client-assets.ts (962 lines) alongside its build
12
+ * siblings. Byte-for-byte move from the parent except createHash →
13
+ * Bun.CryptoHasher (bun-native-crypto guard; identical digest output).
14
+ */
15
+
16
+ import { readdir, rm } from "node:fs/promises";
17
+ import { join } from "node:path";
18
+
19
+ export async function fileHash(path: string): Promise<string> {
20
+ const buf = await Bun.file(path).text();
21
+ return new Bun.CryptoHasher("sha256").update(buf).digest("hex").slice(0, 10);
22
+ }
23
+
24
+ export async function removeStaleHashedFiles(
25
+ outDir: string,
26
+ base: string,
27
+ ext: string,
28
+ keep: string,
29
+ ): Promise<void> {
30
+ if (!(await Bun.file(outDir).exists())) return;
31
+ const re = new RegExp(`^${base}-[a-f0-9]+\\.${ext}$`);
32
+ for (const name of (await readdir(outDir, "utf-8"))) {
33
+ if (re.test(name) && name !== keep) {
34
+ try {
35
+ await rm(join(outDir, name));
36
+ } catch {}
37
+ }
38
+ }
39
+ }
@@ -0,0 +1,298 @@
1
+ /**
2
+ * @system server-handlers
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * The hydrate JS bundle unit of the client-asset build: Bun.build over
7
+ * src/entry-client.ts with the resilient-block-imports plugin (dead block
8
+ * imports degrade at runtime instead of crash-looping the service), the
9
+ * post-build dead-facade gate, stale-chunk removal, and source-map upload.
10
+ *
11
+ * Split out of build-client-assets.ts (962 lines) alongside its build
12
+ * siblings. Byte-for-byte move from the parent — imports adjusted, nothing
13
+ * else.
14
+ */
15
+
16
+ import { mkdir, readdir, rename, rm } from "node:fs/promises";
17
+ import { join } from "node:path";
18
+ import { getAppLogger } from "@teamscala/logger/app-loggers";
19
+ import { fileHash, removeStaleHashedFiles } from "./hashed-output.ts";
20
+ import { uploadSourceMaps } from "./sourcemaps-upload.ts";
21
+
22
+ const HYDRATE_BASE = "hydrate";
23
+
24
+ export async function buildHydrateBundle(
25
+ cwd: string,
26
+ log: ReturnType<typeof getAppLogger>,
27
+ ): Promise<string | null> {
28
+ const entry = join(cwd, "src", "entry-client.ts");
29
+ const outDir = join(cwd, "dist", "client");
30
+ const tmpFile = join(outDir, `${HYDRATE_BASE}.js`);
31
+
32
+ if (!(await Bun.file(entry).exists())) {
33
+ log.warn(
34
+ `[build-client-assets] src/entry-client.ts not found at ${entry} — skipping JS bundle`,
35
+ );
36
+ return null;
37
+ }
38
+
39
+ await mkdir(outDir, { recursive: true });
40
+
41
+ // Resilience (block-and-provider-error-isolation applied to the BUILD): a
42
+ // page-manifest.ts lazy import of a block whose component no longer resolves
43
+ // (a block_schema deactivation or a removed package export whose manifest
44
+ // regen never propagated) MUST NOT fail the whole bundle and crash-loop the
45
+ // service. Seven UI services went down at once this way on 2026-06-13. Such
46
+ // an import is EXTERNALIZED instead: the build succeeds, the service boots,
47
+ // and at runtime that one lazy import rejects into the block's
48
+ // BlockErrorBoundary — the dead block degrades, the page + service stay up.
49
+ // Only UNRESOLVABLE @teamgbg specifiers are touched (resolvable ones fall
50
+ // through to Bun's normal resolution unchanged). Surfaced via warn so it is
51
+ // never silent; the `no-dead-block-imports-in-manifest` guard still flags the
52
+ // stale manifest so it gets regenerated.
53
+ const externalizedBlocks: string[] = [];
54
+ const resilientBlockPlugin: Bun.BunPlugin = {
55
+ name: "resilient-block-imports",
56
+ setup(build: { onResolve: (opts: { filter: RegExp }, cb: (args: { path: string }) => { path: string; external: boolean } | undefined) => void }) {
57
+ // Externalize an unresolvable @teamscala/* block import (a deactivated
58
+ // block_schema whose manifest regen never propagated) or a broken
59
+ // third-party internal (@fullcalendar/core/internal.js during a version
60
+ // mismatch) so it degrades ONE block at runtime instead of crash-looping
61
+ // the service (2026-07-21 portal outage: @fullcalendar 7.x + plugins 6.x
62
+ // → unresolvable internal.js → 21 crash-loop restarts).
63
+ //
64
+ // The filter is deliberately NARROW — only the package prefixes where
65
+ // unresolvable imports actually occur — NOT every bare import. The prior
66
+ // broad /^(?![./])/ filter intercepted resolvable shared packages too,
67
+ // and onResolve pass-through (return undefined) on a shared
68
+ // sideEffects:false package (@tanstack/react-query, imported across many
69
+ // lazy block chunks) tripped Bun's code-splitter: it emitted a dead
70
+ // `export{...}`-with-no-`from` facade and tree-shook away the entire
71
+ // query-core implementation → a fatal hydration SyntaxError that froze
72
+ // agents.scala.business while every component reported healthy
73
+ // (2026-07-22; see the dead-facade gate + minifyIdentifiers:false
74
+ // below). Intercepts of node builtins (stream/web) likewise pulled them
75
+ // into the browser build. The narrow filter leaves @tanstack/* + builtins
76
+ // to Bun's native (correct) resolution. A future unresolvable import
77
+ // OUTSIDE these prefixes fails loud via the manifest guard + build
78
+ // tombstone (no-dead-block-imports-in-manifest) — never silent.
79
+ // Entry-point imports are excluded — those must fail loudly (silently
80
+ // externalizing a core import turns a build error into a mystery runtime
81
+ // crash; containment must not become a swallow).
82
+ build.onResolve({ filter: /^(@teamgbg\/|@fullcalendar\/)/ }, (args) => {
83
+ if (args.importer === entry) return undefined;
84
+ try {
85
+ Bun.resolveSync(args.path, cwd);
86
+ return undefined;
87
+ } catch {
88
+ if (!externalizedBlocks.includes(args.path)) externalizedBlocks.push(args.path);
89
+ return { path: args.path, external: true };
90
+ }
91
+ });
92
+ },
93
+ };
94
+
95
+ let result: Awaited<ReturnType<typeof Bun.build>>;
96
+ try {
97
+ result = await Bun.build({
98
+ entrypoints: [entry],
99
+ target: "browser",
100
+ outdir: outDir,
101
+ naming: `${HYDRATE_BASE}.js`,
102
+ // minifyIdentifiers is DISABLED (whitespace + syntax/dead-code minify
103
+ // kept): under code-splitting, identifier mangling interacting with a
104
+ // shared sideEffects:false package (@tanstack/react-query) trips Bun's
105
+ // splitter to tree-shake away the entire implementation and emit a dead
106
+ // re-export facade — a fatal hydration SyntaxError that froze
107
+ // agents.scala.business while every component reported healthy
108
+ // (2026-07-22; reproduces in stable 1.3.14 + canary 1.4.0, so an upstream
109
+ // fix is not near). Whitespace + syntax minify retain the bulk of the
110
+ // size win; only name-mangling is dropped. The post-build dead-facade
111
+ // gate below fails the build LOUD if this or any future regression slips
112
+ // a fatal facade through on any UI service.
113
+ minifyWhitespace: process.env.NODE_ENV === "production",
114
+ minifySyntax: process.env.NODE_ENV === "production",
115
+ minifyIdentifiers: false,
116
+ // Bun plugin type
117
+ plugins: [resilientBlockPlugin],
118
+ // Code-splitting honours `lazy-blocks-per-page`: page-manifest.ts wraps
119
+ // every block in `lazy(() => import())`, so splitting emits the heavy
120
+ // Plate editor + each lazy block as a SEPARATE chunk loaded on demand
121
+ // instead of inlining all of them into one ~12.5MB entry. The initial
122
+ // download drops ~10× (entry ~1.2MB; chunks lazy). splitting:false was
123
+ // drift that defeated the lazy manifest. SSR safety is intact — the
124
+ // page-server awaits stream.allReady before flushing (render-page-html),
125
+ // so every block is server-rendered and hydration is consistent.
126
+ splitting: true,
127
+ // External source maps so minified client stacks resolve to source in
128
+ // GlitchTip (uploaded post-build by uploadSourceMaps via the official
129
+ // glitchtip-cli debug-ID flow). Maps are deleted after upload — never
130
+ // served; the debug IDs injected into the bundles are all the SDK needs.
131
+ sourcemap: "external",
132
+ // Chunks are referenced by absolute /assets/ URL (served by
133
+ // register-static-assets' /assets/* handler), never a relative path that
134
+ // could mis-resolve under a record route like /?id=<uuid>.
135
+ publicPath: "/assets/",
136
+ external: ["*.css"],
137
+ });
138
+ } catch (e) {
139
+ // Bun.build can THROW (not return {success:false}) on certain failure
140
+ // modes — e.g. entrypoint resolves to a path Bun can't read, or an
141
+ // internal panic. Without this catch the boot path swallows the
142
+ // useful diagnostic and the operator sees only "Bundle failed".
143
+ // AggregateError carries the per-file resolution list in .errors —
144
+ // without serializing it the operator sees only "Bundle failed"
145
+ // (cost a full Railway round-trip per guess on 2026-06-05).
146
+ const subErrors =
147
+ e instanceof AggregateError
148
+ ? e.errors.map((se) => (se instanceof Error ? se.message : String(se)))
149
+ : [];
150
+ log.error(`[build-client-assets] Bun.build threw`, {
151
+ message: e instanceof Error ? e.message : String(e),
152
+ errors: subErrors,
153
+ stack: e instanceof Error ? e.stack : undefined,
154
+ entry,
155
+ outDir,
156
+ });
157
+ throw new Error(
158
+ `[build-client-assets] Bun.build threw: ${e instanceof Error ? e.message : String(e)}${subErrors.length ? `\n${subErrors.join("\n")}` : ""}`,
159
+ );
160
+ }
161
+
162
+ if (!result.success) {
163
+ // Bun.build's `.logs` is the verbose-build-message list. Each log is an
164
+ // object with at minimum `{ level, message, position }`. Some failures
165
+ // surface with an empty logs array — happens when Bun bails before
166
+ // resolution (e.g. missing entrypoint after symlink-walk). Always emit
167
+ // the full JSON form via the logger so service-runtime's structured
168
+ // error trail captures every field, not just a `.message`-joined string.
169
+ log.error(`[build-client-assets] Bun.build failed`, {
170
+ logs: result.logs.map((l) => ({
171
+ level: (l as { level?: string }).level,
172
+ message: l.message,
173
+ position: (l as { position?: unknown }).position,
174
+ })),
175
+ outputs: result.outputs.length,
176
+ entry,
177
+ outDir,
178
+ });
179
+ const messages = result.logs.length
180
+ ? result.logs
181
+ .map(
182
+ (l) => `[${(l as { level?: string }).level ?? "?"}] ${l.message}`,
183
+ )
184
+ .join("\n")
185
+ : "(no log entries — likely entrypoint resolution failure)";
186
+ throw new Error(`[build-client-assets] Bun.build failed:\n${messages}`);
187
+ }
188
+
189
+ // dead-facade prevention gate (agents.scala.business 2026-07-22): a Bun
190
+ // splitter+tree-shake bug (stable 1.3.14 + canary 1.4.0) can emit a "dead
191
+ // facade" chunk — `export { ... }` with NO `from` clause and NO local binding
192
+ // definitions — a fatal SyntaxError at module load that kills React hydration
193
+ // SILENTLY (the whole dashboard froze while every component reported healthy).
194
+ // The narrow resilientBlockPlugin filter + minifyIdentifiers:false above
195
+ // prevent the known trigger (@tanstack/react-query under split); this gate
196
+ // makes the CLASS unshippable — any dead facade from ANY package on ANY UI
197
+ // service fails the build LOUD here instead of freezing the page. Detection
198
+ // is POSITION-INDEPENDENT — a named export{} ANYWHERE in the comment-stripped
199
+ // body, with no `from` clause and no local declaration/import backing the
200
+ // names, is the facade (Bun could prefix it with a directive or statement, not
201
+ // only emit it at the very start — a positional assumption is a known way to
202
+ // be silently bypassed; a valid local-export chunk carries a
203
+ // class/function/const/var/let/import, a valid re-export carries `from`).
204
+ const deadFacades: string[] = [];
205
+ for (const out of result.outputs) {
206
+ if (out.kind !== "chunk") continue;
207
+ let text: string;
208
+ try {
209
+ text = await Bun.file(out.path).text();
210
+ } catch {
211
+ continue;
212
+ }
213
+ const stripped = text
214
+ .replace(/\/\/#.*$/gms, "")
215
+ .replace(/\/\/[^\n]*/g, "")
216
+ .replace(/\/\*[\s\S]*?\*\//g, "")
217
+ .trim();
218
+ if (
219
+ /export\s*\{[^}]*\}/.test(stripped) &&
220
+ !/\bfrom\s*["']/.test(stripped) &&
221
+ !/\b(?:class|function|const|var|let|import)\b/.test(stripped)
222
+ ) {
223
+ deadFacades.push(out.path.split("/").pop() ?? out.path);
224
+ }
225
+ }
226
+ if (deadFacades.length > 0) {
227
+ log.error(`[build-client-assets] dead-facade gate: refusing to ship fatal bundle`, {
228
+ deadFacades,
229
+ 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.",
230
+ });
231
+ throw new Error(
232
+ `[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(", ")}`,
233
+ );
234
+ }
235
+
236
+ const hash = await fileHash(tmpFile);
237
+ const hashedName = `${HYDRATE_BASE}-${hash}.js`;
238
+ const hashedPath = join(outDir, hashedName);
239
+ await rename(tmpFile, hashedPath);
240
+ removeStaleHashedFiles(outDir, HYDRATE_BASE, "js", hashedName);
241
+
242
+ // Splitting emits one chunk per lazy import() alongside the entry. Keep the
243
+ // chunks THIS build produced; drop stale ones so dist/client/ doesn't
244
+ // accumulate hundreds of orphaned chunks per rebuild.
245
+ const currentChunks = new Set(
246
+ result.outputs
247
+ .filter((o) => o.kind === "chunk")
248
+ .map((o) => o.path.split("/").pop() as string),
249
+ );
250
+ await removeStaleChunks(outDir, currentChunks);
251
+
252
+ // Bundle telemetry → logger (single-logger-entry-point → GlitchTip): the
253
+ // initial-download size + lazy-chunk footprint are visible per build, so a
254
+ // regression (splitting silently off, or a heavy dep landing in the entry)
255
+ // surfaces as a number instead of a mystery "slow page".
256
+ const entryBytes = (await Bun.file(hashedPath).stat()).size;
257
+ let chunkBytes = 0;
258
+ for (const name of currentChunks) {
259
+ try {
260
+ chunkBytes += (await Bun.file(join(outDir, name)).stat()).size;
261
+ } catch {}
262
+ }
263
+ log.info(
264
+ `[build-client-assets] hydrate built → ${hashedName} (entry ${(entryBytes / 1024).toFixed(0)}KB + ${currentChunks.size} lazy chunks ${(chunkBytes / 1048576).toFixed(1)}MB)`,
265
+ { entryBytes, chunkCount: currentChunks.size, chunkBytes },
266
+ );
267
+ if (externalizedBlocks.length > 0) {
268
+ // The service boots; these blocks degrade at runtime (BlockErrorBoundary)
269
+ // instead of crash-looping the whole service. The stale manifest still
270
+ // needs regenerating — surfaced here AND by no-dead-block-imports-in-manifest.
271
+ log.warn(
272
+ `[build-client-assets] ${externalizedBlocks.length} unresolvable block import(s) externalized (degraded, not fatal) — regenerate this service's page-manifest: ${externalizedBlocks.join(", ")}`,
273
+ { externalizedBlocks },
274
+ );
275
+ }
276
+ // Upload source maps to GlitchTip (best-effort, dev-host-only) + delete the
277
+ // .map files so source never ships publicly. See uploadSourceMaps.
278
+ await uploadSourceMaps(outDir, hash, log);
279
+ return `/assets/${hashedName}`;
280
+ }
281
+
282
+ // Remove hashed code-split chunks no longer referenced by the current entry.
283
+ // Keyed off the build's own output list (the only authoritative current set),
284
+ // never a name pattern guess — a build emits N `chunk-*.js`; the previous
285
+ // build's are orphaned the moment the entry's hash changes.
286
+ async function removeStaleChunks(
287
+ outDir: string,
288
+ keep: Set<string>,
289
+ ): Promise<void> {
290
+ if (!(await Bun.file(outDir).exists())) return;
291
+ for (const name of (await readdir(outDir, "utf-8"))) {
292
+ if (/^chunk-[A-Za-z0-9]+\.js$/.test(name) && !keep.has(name)) {
293
+ try {
294
+ await rm(join(outDir, name));
295
+ } catch {}
296
+ }
297
+ }
298
+ }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * @system server-handlers
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * The content fingerprint of the client-bundle dependency closure — the
7
+ * cache key that decides whether the boot-time client-asset build can be
8
+ * skipped. Composes the input-hashing module's closure walkers.
9
+ *
10
+ * Split out of build-client-assets.ts (962 lines) alongside its build
11
+ * siblings. Byte-for-byte move from the parent except createHash →
12
+ * Bun.CryptoHasher (bun-native-crypto guard; identical digest output).
13
+ */
14
+
15
+ import { join } from "node:path";
16
+ import {
17
+ hashResolvedPackageStylesheets,
18
+ hashTeamgbgJsGraph,
19
+ } from "./input-hashing.ts";
20
+
21
+ // Content fingerprint of the client-bundle dependency CLOSURE — hashed by
22
+ // CONTENT, never mtime (cache-key-hashes-source-inputs-only). Rebuild iff this
23
+ // differs from the fingerprint stored in the existing manifest. Scoped to the
24
+ // CLOSURE (not all of node_modules) so a @teamscala/* publish that is not in this
25
+ // service's client bundle cannot trigger a rebuild — the publish→rebuild storm
26
+ // is architecturally impossible (`no-uncontrolled-repetition-or-cascade`).
27
+ //
28
+ // Inputs (everything that can change the emitted bytes):
29
+ // src/entry-client.ts — the bundle entrypoint.
30
+ // src/page-manifest.ts — the generated block→component map the bundle is built
31
+ // from; a codegen regen (block added/removed) changes it,
32
+ // and the hydrate bundle MUST rebuild or new blocks render
33
+ // empty → hydration mismatch.
34
+ // src/app.css — the Tailwind entry.
35
+ // @teamscala/* JS closure (hashTeamgbjJsGraph) — the CONTENT of every @teamscala/*
36
+ // module reachable from the client entrypoints. Content
37
+ // (not version) so a mid-install stale bake (bun.lock
38
+ // ahead of node_modules — the lock can name a new version
39
+ // while the prior bytes are still on disk) is detected on
40
+ // the next boot and self-heals (scala-agents 2026-07-12).
41
+ // @teamscala/* CSS closure (hashResolvedPackageStylesheets) — the CONTENT of
42
+ // every package stylesheet app.css @imports (transitively).
43
+ // third-party closure-boundary versions (hashTeamgbgJsGraph) — the resolved
44
+ // VERSION of each third-party package the client closure
45
+ // imports (react, etc.). Third-party modules are not
46
+ // content-walked (large; pinned by bun.lock + change only
47
+ // on a version bump), but a version bump of a closure dep
48
+ // MUST invalidate, so the installed package.json#version
49
+ // is folded. This is the closure-scoped replacement for
50
+ // hashing all of bun.lock: react bumping in the closure
51
+ // still rebuilds, while a backend @teamscala/* publish
52
+ // outside the closure does not.
53
+ // NB: bun.lock is INTENTIONALLY NOT an input. It encodes every resolved
54
+ // @teamscala/* version (backend packages included), so it invalidates on every
55
+ // publish — the false-invalidation source that made every UI service rebuild
56
+ // on a backend publish. The closure inputs above are a strict improvement:
57
+ // they catch every change that affects THIS bundle AND nothing that doesn't.
58
+ export async function computeInputFingerprint(cwd: string): Promise<string> {
59
+ // Keyed on each input's RELATIVE label + CONTENT only — never the absolute
60
+ // path (that would make the fingerprint cwd-dependent and defeat the skip
61
+ // across two checkouts of the same service). bun.lock is DELIBERATELY
62
+ // EXCLUDED — it encodes EVERY resolved @teamscala/* version, so folding it in
63
+ // invalidates on every publish including packages NOT in this service's
64
+ // client bundle (the publish→rebuild storm — `no-uncontrolled-repetition-
65
+ // or-cascade`). The closure content hashes below (hashTeamgbgJsGraph +
66
+ // hashResolvedPackageStylesheets) + the third-party closure-boundary
67
+ // versions cover every dependency change that ACTUALLY affects this bundle,
68
+ // so a non-closure publish changes nothing here → zero rebuild. Rebuild
69
+ // iff this fingerprint differs from the one stored in the manifest.
70
+ const sources: Array<[label: string, path: string]> = [
71
+ ["src/entry-client.ts", join(cwd, "src", "entry-client.ts")],
72
+ ["src/page-manifest.ts", join(cwd, "src", "page-manifest.ts")],
73
+ ["src/app.css", join(cwd, "src", "app.css")],
74
+ ];
75
+ const h = new Bun.CryptoHasher("sha256");
76
+ for (const [label, path] of sources) {
77
+ // Missing input contributes a stable sentinel rather than throwing — a
78
+ // backend service has no app.css, and that absence is itself part of the
79
+ // fingerprint. `(await Bun.file().text())` is parenthesised so a missing
80
+ // file's sync TypeError is caught (bun-async-file-operations).
81
+ try {
82
+ h.update(label).update("\0").update(await Bun.file(path).text()).update("\0");
83
+ } catch {
84
+ h.update(label).update("\0<absent>\0");
85
+ }
86
+ }
87
+ // Fold the resolved CONTENT of every @teamscala/* stylesheet app.css @imports
88
+ // (client-asset-cache-key-embeds-package-content). bun.lock pins versions; the
89
+ // build reads bytes. When they diverge (boot built mid-install), the lock-only
90
+ // fingerprint false-matches and skips forever — hashing the bytes closes it.
91
+ await hashResolvedPackageStylesheets(cwd, h);
92
+ // Fold in the resolved CONTENT of every @teamscala/* JS/TS module reachable
93
+ // from the client entrypoints (hashTeamgbgJsGraph). bun.lock pins versions;
94
+ // the build reads BYTES — and a boot can build MID-INSTALL (the package-
95
+ // poller restarts the service after writing the new bun.lock but before
96
+ // node_modules extraction finishes), so Bun.build faithfully bakes the OLD
97
+ // @teamgbg JS while the lock-derived fingerprint is stored as "current".
98
+ // Every later restart then recomputes the same lock fingerprint → skip →
99
+ // STALE bundle served forever (scala-agents 2026-07-12: hydrate-4dad7478bd
100
+ // carried zero of portal-ui@2.4.41's markers across restarts). Bun.build
101
+ // itself is NOT caching — an isolated repro proves it reads fresh content
102
+ // across processes — so the fix is to make the fingerprint cover the actual
103
+ // @teamgbg JS bytes (the JS analogue of hashResolvedPackageStylesheets,
104
+ // which already closes this exact race for CSS). A stale bake then hashes
105
+ // differently from the freshly-installed bytes → rebuild → self-heals.
106
+ await hashTeamgbgJsGraph(cwd, h);
107
+ return h.digest("hex").slice(0, 16);
108
+ }