@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
package/package.json
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
},
|
|
7
7
|
"name": "@teamscala/service-boot",
|
|
8
8
|
"type": "module",
|
|
9
|
-
"version": "0.23.
|
|
9
|
+
"version": "0.23.413",
|
|
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"
|
|
@@ -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
|
+
}
|