@teamscala/service-boot 0.23.493 → 0.23.495
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 +1 -7
- package/src/build-client-assets/hydrate-bundle.ts +38 -6
- package/src/build-client-assets/source-candidates.test.ts +2 -2
- package/src/build-client-assets/sourcemaps-upload.test.ts +2 -5
- package/src/build-client-assets/styles-build.test.ts +2 -2
- package/src/install-harness.ts +1 -2
- package/src/install-logging.ts +1 -2
- package/src/mount-server-packages.ts +38 -6
- package/src/register-registry-page-route/graph-cache.ts +1 -1
- package/src/register-registry-page-route/meta-title.ts +1 -1
- package/src/resource-not-found.ts +0 -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.495",
|
|
10
10
|
"sideEffects": false,
|
|
11
11
|
"imports": {
|
|
12
12
|
"#service-boot/*": "./src/*",
|
|
@@ -18,16 +18,13 @@
|
|
|
18
18
|
"dependencies": {
|
|
19
19
|
"@orpc/server": "*",
|
|
20
20
|
"@teamscala/agreement-generation": "*",
|
|
21
|
-
"@teamscala/api-resend": "*",
|
|
22
21
|
"@teamscala/auth": "*",
|
|
23
22
|
"@teamscala/cache": "*",
|
|
24
23
|
"@teamscala/chat-completion": "*",
|
|
25
24
|
"@teamscala/db": "*",
|
|
26
25
|
"@teamscala/db-validation": "*",
|
|
27
26
|
"@teamscala/document-signing": "*",
|
|
28
|
-
"@teamscala/email": "*",
|
|
29
27
|
"@teamscala/email-client": "*",
|
|
30
|
-
"@teamscala/email-template": "*",
|
|
31
28
|
"@teamscala/encryption": "*",
|
|
32
29
|
"@teamscala/event-log": "*",
|
|
33
30
|
"@teamscala/file-lock": "*",
|
|
@@ -38,14 +35,11 @@
|
|
|
38
35
|
"@teamscala/http": "*",
|
|
39
36
|
"@teamscala/logger": "*",
|
|
40
37
|
"@teamscala/mcp-multi-session": "*",
|
|
41
|
-
"@teamscala/notify-cache": "*",
|
|
42
38
|
"@teamscala/object-storage": "*",
|
|
43
39
|
"@teamscala/orpc": "*",
|
|
44
40
|
"@teamscala/os": "*",
|
|
45
41
|
"@teamscala/publish-registry": "*",
|
|
46
|
-
"@teamscala/realtime-utils": "*",
|
|
47
42
|
"@teamscala/resolver-engine": "*",
|
|
48
|
-
"@teamscala/safe-fallback": "*",
|
|
49
43
|
"@teamscala/timing": "*",
|
|
50
44
|
"@teamscala/tool-executor": "*",
|
|
51
45
|
"@teamscala/watchdog": "*",
|
|
@@ -55,7 +55,7 @@
|
|
|
55
55
|
// be silently bypassed; a valid local-export chunk carries a
|
|
56
56
|
// class/function/const/var/let/import, a valid re-export carries `from`).
|
|
57
57
|
|
|
58
|
-
import { mkdir, readdir,
|
|
58
|
+
import { mkdir, readdir, rm } from "node:fs/promises";
|
|
59
59
|
import { join } from "node:path";
|
|
60
60
|
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
61
61
|
import { fileHash, removeStaleHashedFiles } from "./hashed-output.ts";
|
|
@@ -79,7 +79,6 @@ export async function buildHydrateBundle(
|
|
|
79
79
|
): Promise<HydrateBundleResult | null> {
|
|
80
80
|
const entry = join(cwd, "src", "entry-client.ts");
|
|
81
81
|
const outDir = join(cwd, "dist", "client");
|
|
82
|
-
const tmpFile = join(outDir, `${HYDRATE_BASE}.js`);
|
|
83
82
|
|
|
84
83
|
if (!(await Bun.file(entry).exists())) {
|
|
85
84
|
log.warn(
|
|
@@ -123,7 +122,26 @@ export async function buildHydrateBundle(
|
|
|
123
122
|
entrypoints: [entry],
|
|
124
123
|
target: "browser",
|
|
125
124
|
outdir: outDir,
|
|
126
|
-
|
|
125
|
+
// THE BUNDLER OWNS THE HASH, because it also writes the references.
|
|
126
|
+
// Under `splitting: true` + `publicPath`, Bun bakes the entry's
|
|
127
|
+
// build-time name into every chunk that imports it. Naming the entry
|
|
128
|
+
// `hydrate.js` here and RENAMING it to `hydrate-<hash>.js` afterwards
|
|
129
|
+
// moved the file but could not rewrite the specifiers already emitted
|
|
130
|
+
// into the chunks — so every chunk imported `/assets/hydrate.js`, which
|
|
131
|
+
// no longer existed. 404 on the module, the importing block dies, and
|
|
132
|
+
// the page still returns 200 with every guard green.
|
|
133
|
+
//
|
|
134
|
+
// MEASURED 2026-09-08 on quiz.scala.business: `AssessmentFlowBlock`
|
|
135
|
+
// failed with "Failed to fetch dynamically imported module"; crawling
|
|
136
|
+
// the served graph (142 level-1 + 76 level-2 assets) found exactly one
|
|
137
|
+
// broken URL — `/assets/hydrate.js` — imported by the entry itself and
|
|
138
|
+
// by most chunks. Hash applied OUTSIDE the generator is two artefacts
|
|
139
|
+
// that must agree with nothing making them (`one-generator-pattern`).
|
|
140
|
+
naming: {
|
|
141
|
+
entry: `${HYDRATE_BASE}-[hash].[ext]`,
|
|
142
|
+
chunk: "chunk-[hash].[ext]",
|
|
143
|
+
asset: "[name]-[hash].[ext]",
|
|
144
|
+
},
|
|
127
145
|
minifyWhitespace: process.env.NODE_ENV === "production",
|
|
128
146
|
minifySyntax: process.env.NODE_ENV === "production",
|
|
129
147
|
minifyIdentifiers: false,
|
|
@@ -232,10 +250,24 @@ export async function buildHydrateBundle(
|
|
|
232
250
|
);
|
|
233
251
|
}
|
|
234
252
|
|
|
235
|
-
|
|
236
|
-
|
|
253
|
+
// Bun emitted the hashed entry itself, so the name it wrote into every
|
|
254
|
+
// chunk IS the name on disk. Read it back from the build's own output
|
|
255
|
+
// list rather than reconstructing it — a reconstructed name is a second
|
|
256
|
+
// copy of a fact the bundler already owns, which is the defect this
|
|
257
|
+
// change removes (`a-mutating-verb-reads-back-what-it-claims`).
|
|
258
|
+
const entryOutput = result.outputs.find(
|
|
259
|
+
(o) => o.kind === "entry-point",
|
|
260
|
+
);
|
|
261
|
+
if (!entryOutput) {
|
|
262
|
+
throw new Error(
|
|
263
|
+
"[build-client-assets] Bun.build reported success but emitted no entry-point output — refusing to ship a bundle with no entry.",
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
const hashedName = entryOutput.path.split("/").pop() as string;
|
|
237
267
|
const hashedPath = join(outDir, hashedName);
|
|
238
|
-
|
|
268
|
+
const hash =
|
|
269
|
+
/^hydrate-([A-Za-z0-9]+)\.js$/.exec(hashedName)?.[1] ??
|
|
270
|
+
(await fileHash(hashedPath));
|
|
239
271
|
removeStaleHashedFiles(outDir, HYDRATE_BASE, "js", hashedName);
|
|
240
272
|
|
|
241
273
|
// Splitting emits one chunk per lazy import() alongside the entry. Keep the
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* kind:ui service's stylesheet (2026-09-01, operator-visible).
|
|
11
11
|
*/
|
|
12
12
|
import { describe, expect, test } from "bun:test";
|
|
13
|
-
import { mkdir, rm
|
|
13
|
+
import { mkdir, rm } from "node:fs/promises";
|
|
14
14
|
import { join } from "node:path";
|
|
15
15
|
import { tmpdir } from "node:os";
|
|
16
16
|
import { scanSourceCandidates } from "./source-candidates.ts";
|
|
@@ -23,7 +23,7 @@ async function makeSourceTree(tag: string, files: Record<string, string>) {
|
|
|
23
23
|
for (const [rel, content] of Object.entries(files)) {
|
|
24
24
|
const p = join(root, rel);
|
|
25
25
|
await mkdir(p.slice(0, p.lastIndexOf("/")), { recursive: true });
|
|
26
|
-
await
|
|
26
|
+
await Bun.file(p).write(content);
|
|
27
27
|
}
|
|
28
28
|
return root;
|
|
29
29
|
}
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* boundaries the verification layer depends on.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
import { mkdtemp, mkdir, readdir, rm
|
|
17
|
+
import { mkdtemp, mkdir, readdir, rm } from "node:fs/promises";
|
|
18
18
|
import { tmpdir } from "node:os";
|
|
19
19
|
import { join } from "node:path";
|
|
20
20
|
import { afterAll, afterEach, describe, expect, test } from "bun:test";
|
|
@@ -36,10 +36,7 @@ async function makeDir(): Promise<string> {
|
|
|
36
36
|
async function seedMaps(outDir: string, names: string[]): Promise<void> {
|
|
37
37
|
await mkdir(outDir, { recursive: true });
|
|
38
38
|
for (const name of names) {
|
|
39
|
-
await
|
|
40
|
-
join(outDir, name),
|
|
41
|
-
JSON.stringify({ version: 3, sources: ["a.ts"], mappings: "AAAA" }),
|
|
42
|
-
);
|
|
39
|
+
await Bun.file(join(outDir, name)).write(JSON.stringify({ version: 3, sources: ["a.ts"], mappings: "AAAA" }));
|
|
43
40
|
}
|
|
44
41
|
}
|
|
45
42
|
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* explicitly, never resolved and never thrown on.
|
|
15
15
|
*/
|
|
16
16
|
import { describe, expect, test } from "bun:test";
|
|
17
|
-
import { mkdir, rm
|
|
17
|
+
import { mkdir, rm } from "node:fs/promises";
|
|
18
18
|
import { join } from "node:path";
|
|
19
19
|
import { tmpdir } from "node:os";
|
|
20
20
|
import { bunResolveStylesheet } from "./styles-build.ts";
|
|
@@ -25,7 +25,7 @@ async function makeTree(tag: string, files: Record<string, string>) {
|
|
|
25
25
|
for (const [rel, content] of Object.entries(files)) {
|
|
26
26
|
const p = join(root, rel);
|
|
27
27
|
await mkdir(p.slice(0, p.lastIndexOf("/")), { recursive: true });
|
|
28
|
-
await
|
|
28
|
+
await Bun.file(p).write(content);
|
|
29
29
|
}
|
|
30
30
|
return root;
|
|
31
31
|
}
|
package/src/install-harness.ts
CHANGED
|
@@ -2,14 +2,13 @@
|
|
|
2
2
|
* @system server-boot
|
|
3
3
|
* @status handwritten
|
|
4
4
|
* @edit edit directly
|
|
5
|
-
* @indivisible-unit owner=unassigned reason=81 lines; pre-2026-08-27 exemption, unreviewed
|
|
6
5
|
*
|
|
7
6
|
* Install harness — sandbox-mounts each declared package and captures
|
|
8
7
|
* registry.require() calls via AsyncLocalStorage. Validates all required
|
|
9
8
|
* rows exist before returning. Throws HarnessInstallError with full
|
|
10
9
|
* context on failure. Per Constitution Rule 2 step 3.
|
|
11
10
|
*
|
|
12
|
-
* Marked
|
|
11
|
+
* Marked: HarnessInstallError is the error class
|
|
13
12
|
* validatePackageDependencies (might) throw — they form one canonical
|
|
14
13
|
* "install harness validation" unit (the validator + the failure-mode
|
|
15
14
|
* class it produces).
|
package/src/install-logging.ts
CHANGED
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
* @system server-boot
|
|
3
3
|
* @status handwritten
|
|
4
4
|
* @edit edit directly
|
|
5
|
-
* @indivisible-unit owner=unassigned reason=188 lines; pre-2026-08-27 exemption, unreviewed
|
|
6
5
|
*
|
|
7
6
|
* One-call install of the entire logging pipeline. After installLogging()
|
|
8
7
|
* runs, every error in the service — process-level, HTTP middleware, Hono
|
|
@@ -11,7 +10,7 @@
|
|
|
11
10
|
* loga (dev) with full context. Application code never imports the logger
|
|
12
11
|
* or wraps anything in try/catch for telemetry purposes.
|
|
13
12
|
*
|
|
14
|
-
* Marked
|
|
13
|
+
* Marked: installLogging (early bootstrap, hardcoded
|
|
15
14
|
* defaults) and applyLoggerConfig (late-stage, DB-driven) are the two
|
|
16
15
|
* phases of the same one-call install — the service boot pipeline calls
|
|
17
16
|
* them in sequence with the same serviceSlug.
|
|
@@ -65,6 +65,10 @@ export async function mountServerPackages(
|
|
|
65
65
|
const mountRegistry = createMountRegistry();
|
|
66
66
|
const mountCtx = createMountContext(serviceSlug, config, app, mountRegistry, getSharedDb);
|
|
67
67
|
const serverPackages = (config.server_packages ?? []) as string[];
|
|
68
|
+
/** Packages that loaded but whose mount threw — unavailable, not fatal.
|
|
69
|
+
* Named in the summary so a degraded boot is legible without grepping for
|
|
70
|
+
* the per-package error line. */
|
|
71
|
+
const degraded: string[] = [];
|
|
68
72
|
|
|
69
73
|
if (serverPackages.length === 0) {
|
|
70
74
|
log.info(`[scala-install:${serviceSlug}] No server_packages declared`);
|
|
@@ -125,11 +129,34 @@ export async function mountServerPackages(
|
|
|
125
129
|
}
|
|
126
130
|
} catch (err) {
|
|
127
131
|
if (err instanceof BootFatalError) throw err;
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
132
|
+
// A PLUGIN THAT FAILS TO MOUNT IS UNAVAILABLE, NOT FATAL. `crash-isolation`
|
|
133
|
+
// states the law directly — "a failure in one package does not crash the
|
|
134
|
+
// host" — and names the plugin failure domain: "a broken plugin degrades
|
|
135
|
+
// to 'that plugin unavailable'". This arm used to wrap ANY mount error in
|
|
136
|
+
// a BootFatalError, so one plugin's missing config killed a whole service.
|
|
137
|
+
//
|
|
138
|
+
// MEASURED 2026-09-07: scala-agents-ui, the operator's fleet monitoring UI,
|
|
139
|
+
// booted through db, auth, routes and every other server_package, then died
|
|
140
|
+
// at @teamscala/opencode because OPENCODE_SERVER_URL was absent. systemd
|
|
141
|
+
// retried five times and gave up, agents.scala.business served 502, and the
|
|
142
|
+
// operator lost sight of his fleet — over one optional integration missing
|
|
143
|
+
// one variable, on a page that needs no opencode connection to render.
|
|
144
|
+
//
|
|
145
|
+
// THE ASYMMETRY DECIDES IT. A service that boots without one plugin still
|
|
146
|
+
// serves every route that plugin did not own, and the gap is loud in this
|
|
147
|
+
// log line and counted in the summary below. A service that refuses to boot
|
|
148
|
+
// serves NOTHING — including the surface that would have explained why.
|
|
149
|
+
//
|
|
150
|
+
// NOT-FOUND STAYS FATAL, deliberately: that arm is above and untouched. A
|
|
151
|
+
// package that LOADS and then throws is a broken plugin, which is this
|
|
152
|
+
// case. A package that cannot be resolved at all means the declared
|
|
153
|
+
// dependency set was never installed — an install-integrity failure, where
|
|
154
|
+
// booting a half-installed service would serve wrong results while looking
|
|
155
|
+
// healthy.
|
|
156
|
+
degraded.push(pkg);
|
|
157
|
+
log.error(
|
|
158
|
+
`[scala-install:${serviceSlug}] server_package "${pkg}" UNAVAILABLE — mount failed; ` +
|
|
159
|
+
`the service continues without it: ${err instanceof Error ? err.message : err}`,
|
|
133
160
|
);
|
|
134
161
|
}
|
|
135
162
|
}
|
|
@@ -137,7 +164,12 @@ export async function mountServerPackages(
|
|
|
137
164
|
log.info(
|
|
138
165
|
`[scala-install:${serviceSlug}] Registered ${mountRegistry.handlers.size} handler(s), ` +
|
|
139
166
|
`${mountRegistry.wsRoutes.size} ws route(s), ` +
|
|
140
|
-
`${mountRegistry.bootHooks.size} boot hook(s)
|
|
167
|
+
`${mountRegistry.bootHooks.size} boot hook(s)` +
|
|
168
|
+
// NAMED, NEVER COUNTED. A bare "1 degraded" sends the reader hunting for
|
|
169
|
+
// which one; the name is the whole value of the line.
|
|
170
|
+
(degraded.length > 0
|
|
171
|
+
? `, ${degraded.length} server_package(s) UNAVAILABLE: ${degraded.join(", ")}`
|
|
172
|
+
: ""),
|
|
141
173
|
);
|
|
142
174
|
|
|
143
175
|
return mountRegistry;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @system ui-service-boot
|
|
3
|
-
* @status handwritten
|
|
3
|
+
* @status handwritten
|
|
4
4
|
* @edit edit directly
|
|
5
5
|
*
|
|
6
6
|
* The two SWR loader-graph caches (static-shell + parameterised routes) for the
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @system ui-service-boot
|
|
3
|
-
* @status handwritten
|
|
3
|
+
* @status handwritten
|
|
4
4
|
* @edit edit directly
|
|
5
5
|
*
|
|
6
6
|
* Meta/title helpers for the route registrar: interpolate the route's
|
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
* @system service-boot
|
|
3
3
|
* @status handwritten
|
|
4
4
|
* @edit edit directly
|
|
5
|
-
* @indivisible-unit owner=unassigned reason=299 lines; pre-2026-08-27 exemption, unreviewed
|
|
6
5
|
*
|
|
7
6
|
* One cohesive unit — the branded resource-not-found / holding page: the
|
|
8
7
|
* PrimaryResourceMissingError trigger, the org-logo resolver the page
|