@teamscala/service-boot 0.23.443 → 0.23.444

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.443",
9
+ "version": "0.23.444",
10
10
  "sideEffects": false,
11
11
  "imports": {
12
12
  "#service-boot/*": "./src/*",
@@ -51,7 +51,7 @@ describe("verifyClientBuildAssets", () => {
51
51
  if (!result.ok) expect(result.reason).toContain("never ran or its output is gone");
52
52
  });
53
53
 
54
- test("the first-boot tombstone shape (unhashed fallback paths, no files) refuses", async () => {
54
+ test("the first-boot tombstone shape (unhashed paths, no files) refuses", async () => {
55
55
  await writeManifest(join(dir, "dist", "client"), {
56
56
  stylesPath: "/assets/styles.css",
57
57
  hydratePath: "/assets/hydrate.js",
@@ -61,6 +61,18 @@ describe("verifyClientBuildAssets", () => {
61
61
  if (!result.ok) expect(result.reason).toContain("unbuilt assets");
62
62
  });
63
63
 
64
+ test("a path-less tombstone (the shape build-client-assets now writes) refuses as unbuilt", async () => {
65
+ await writeManifest(join(dir, "dist", "client"), {
66
+ inputFingerprint: "failed:2026-09-01T00:00:00.000Z",
67
+ buildFailed: true,
68
+ buildFailedReason: "hydrate build failed: something",
69
+ buildFailedAt: "2026-09-01T00:00:00.000Z",
70
+ } as never);
71
+ const result = await verifyClientBuildAssets(dir);
72
+ expect(result.ok).toBe(false);
73
+ if (!result.ok) expect(result.reason).toContain("never ran or its output is gone");
74
+ });
75
+
64
76
  test("a manifest naming a file that was deleted refuses (stale-bundle shape)", async () => {
65
77
  await writeManifest(join(dir, "dist", "client"), {
66
78
  stylesPath: "/assets/styles-abc.css",
@@ -21,8 +21,13 @@ import type { SourceMapUploadResult } from "./sourcemaps-upload.ts";
21
21
  export const MANIFEST_FILE = "asset-manifest.json";
22
22
 
23
23
  export interface AssetManifest {
24
- stylesPath: string;
25
- hydratePath: string;
24
+ // Optional because a TOMBSTONE from a build that failed with no prior
25
+ // success carries NO paths — there are no real assets to name, and inventing
26
+ // URLs nothing built is the deleted fallback class (no-shims).
27
+ // `readManifest` returns null unless both are present, so a path-less
28
+ // tombstone reads as unbuilt everywhere (verification, readiness, render).
29
+ stylesPath?: string;
30
+ hydratePath?: string;
26
31
  // Content fingerprint of the CLIENT-BUNDLE DEPENDENCY CLOSURE (source entrypoints + @teamscala/* JS/CSS in the closure + third-party closure-dep
27
32
  // versions), hashed by CONTENT — never mtime (cache-key-hashes-source- inputs-only). The boot/build freshness check rebuilds iff this differs
28
33
  // from the current closure. mtime comparison is fundamentally broken on Railway: deploy-build bakes the bundle at image-build time, and under the
@@ -53,15 +58,6 @@ export interface AssetManifest {
53
58
  sourceMaps?: SourceMapUploadResult;
54
59
  }
55
60
 
56
- // Last-resort paths for a tombstone when no prior successful manifest exists
57
- // (first-ever boot fails). Points at the unhashed build names so an operator
58
- // inspecting the manifest sees a real (if 404-ing) target rather than a
59
- // mystery sentinel.
60
- export const FALLBACK_MANIFEST: { stylesPath: string; hydratePath: string } = {
61
- stylesPath: "/assets/styles.css",
62
- hydratePath: "/assets/hydrate.js",
63
- };
64
-
65
61
  /**
66
62
  * Read ONLY the tombstone state of the current manifest — the runtime
67
63
  * (read-html-template, ui-service) calls this to decide whether to surface a
@@ -125,10 +121,9 @@ export async function writeManifest(
125
121
  * Verify the CURRENT manifest against the files it names — the capability
126
122
  * probe the readiness flip gates on (`a-component-may-not-report-a-state-it-
127
123
  * has-not-verified`). A manifest is servable only when BOTH referenced assets
128
- * exist on disk. A manifest whose last build failed with no prior success
129
- * carries the unhashed last-resort paths (FALLBACK_MANIFEST) — names nothing
130
- * ever builds — and an absent/unreadable manifest carries nothing; all three
131
- * must read as NOT built, never as a legitimate bundle. Measured 2026-09-01:
124
+ * exist on disk. An absent or unreadable manifest, a first-boot tombstone
125
+ * carrying no paths, and any manifest naming files that do not exist must all
126
+ * read as NOT built, never as a legitimate bundle. Measured 2026-09-01:
132
127
  * proposals.scala.business served a client-facing proposal unstyled while
133
128
  * Railway reported the deploy SUCCESS, because "client build never ran" was
134
129
  * indistinguishable from "client build fine" at every layer that reports.
@@ -138,7 +133,7 @@ export async function verifyClientBuildAssets(
138
133
  ): Promise<{ ok: true } | { ok: false; reason: string }> {
139
134
  const outDir = join(cwd, "dist", "client");
140
135
  const manifest = await readManifest(outDir);
141
- if (!manifest) {
136
+ if (!manifest || !manifest.stylesPath || !manifest.hydratePath) {
142
137
  return {
143
138
  ok: false,
144
139
  reason: `no readable asset manifest at ${join(outDir, MANIFEST_FILE)} — the client build never ran or its output is gone`,
@@ -60,7 +60,6 @@ import { CLIENT_BUILD_LOCK, getClientBuildLockTimeoutMs } from "./build-client-a
60
60
  import { buildHydrateBundle } from "./build-client-assets/hydrate-bundle.ts";
61
61
  import { computeInputFingerprint } from "./build-client-assets/input-fingerprint.ts";
62
62
  import {
63
- FALLBACK_MANIFEST,
64
63
  readManifest,
65
64
  writeManifest,
66
65
  } from "./build-client-assets/manifest.ts";
@@ -85,7 +84,9 @@ export async function buildClientAssets(serviceSlug: string): Promise<void> {
85
84
  // permanently lost the release's maps — client stacks stayed minified
86
85
  // until the next real input change forced a rebuild.
87
86
  const recorded = existingManifest.sourceMaps?.status;
88
- const release = releaseFromHydratePath(existingManifest.hydratePath);
87
+ const release = existingManifest.hydratePath
88
+ ? releaseFromHydratePath(existingManifest.hydratePath)
89
+ : null;
89
90
  if (recorded !== "uploaded" && release) {
90
91
  log.info(
91
92
  `[build-client-assets:${serviceSlug}] manifest up-to-date (skip); source maps previously ${recorded ?? "unrecorded"} — retrying upload for release ${release}`,
@@ -143,10 +144,19 @@ async function performBuild(
143
144
  try {
144
145
  await mkdir(outDir, { recursive: true });
145
146
  await writeManifest(outDir, {
146
- // Preserve last-good paths so SSR serves the prior bundle
147
- // rather than a 404 hydrate — degraded but not dead.
148
- stylesPath: existingManifest?.stylesPath ?? FALLBACK_MANIFEST.stylesPath,
149
- hydratePath: existingManifest?.hydratePath ?? FALLBACK_MANIFEST.hydratePath,
147
+ // NO invented paths: when a prior successful build exists its real
148
+ // paths are preserved (SSR keeps serving the prior bundle — degraded
149
+ // but coherent); when none exists the tombstone carries NO asset
150
+ // paths at all. Writing the unhashed build names here was the last
151
+ // remnant of the deleted fallback class — paths that name files
152
+ // nothing built must not exist on disk any more than in served HTML.
153
+ // A path-less tombstone reads as unbuilt everywhere (readManifest →
154
+ // null → verification refuses → readiness never flips), and its
155
+ // buildFailedReason below is the operator's diagnostic — the failure
156
+ // is read via readClientBuildStatus, which never needed the paths.
157
+ ...(existingManifest?.stylesPath && existingManifest?.hydratePath
158
+ ? { stylesPath: existingManifest.stylesPath, hydratePath: existingManifest.hydratePath }
159
+ : {}),
150
160
  // Failure sentinel: the next boot's fingerprint check reads
151
161
  // this and cannot match any real fingerprint → forces a retry.
152
162
  inputFingerprint: `failed:${new Date().toISOString()}`,