@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.
@@ -0,0 +1,115 @@
1
+ /**
2
+ * @system server-handlers
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * The asset-manifest unit of the client-asset build: the AssetManifest shape,
7
+ * its read/write, the last-failure tombstone status read the runtime surfaces,
8
+ * and the first-boot fallback paths.
9
+ *
10
+ * Split out of build-client-assets.ts (962 lines) alongside its build
11
+ * siblings — the file held six concerns (manifest, hashed output, hydrate
12
+ * bundle, source-map upload, stylesheet build, fingerprint, lock, orchestration).
13
+ *
14
+ * Byte-for-byte move from the parent — imports adjusted, nothing else.
15
+ */
16
+
17
+ import { join } from "node:path";
18
+
19
+ export const MANIFEST_FILE = "asset-manifest.json";
20
+
21
+ export interface AssetManifest {
22
+ stylesPath: string;
23
+ hydratePath: string;
24
+ // Content fingerprint of the CLIENT-BUNDLE DEPENDENCY CLOSURE (source
25
+ // entrypoints + @teamscala/* JS/CSS in the closure + third-party closure-dep
26
+ // versions), hashed by CONTENT — never mtime (cache-key-hashes-source-
27
+ // inputs-only). The boot/build freshness check rebuilds iff this differs
28
+ // from the current closure. mtime comparison is fundamentally broken on
29
+ // Railway: deploy-build bakes the bundle at image-build time, and under the
30
+ // hoisted linker + warm `bun-install` cache the installed @teamscala/* files
31
+ // are hardlinks carrying OLD extraction mtimes while the baked manifest's
32
+ // mtime is newer — so `source.mtime > manifest.mtime` is false and the
33
+ // build wrongly skips, baking (then serving forever) a STALE client bundle
34
+ // even though a newer @teamscala/* version is installed. Observed 2026-06-14:
35
+ // scala-gpt shipped ui-foundation < 85 in the bundle while a newer version
36
+ // was installed, so a published+installed fix never reached the browser.
37
+ // Content hashing of the closure is immune to mtime games. bun.lock is
38
+ // excluded by design — see computeInputFingerprint.
39
+ inputFingerprint?: string;
40
+ // TOMBSTONE fields — present iff the last build FAILED (no-silent-reverts).
41
+ // `buildFailed: true` + reason + timestamp are written by performBuild
42
+ // before it re-throws. The next boot's fingerprint check sees the
43
+ // `failed:<iso>` sentinel and cannot match → forces a retry (no silent
44
+ // skip on the stale manifest). read-html-template reads these to stamp
45
+ // the served HTML with the failure status (operator-visible signal that
46
+ // the bundle is stale, not fresh).
47
+ buildFailed?: boolean;
48
+ buildFailedReason?: string;
49
+ buildFailedAt?: string;
50
+ }
51
+
52
+ // Last-resort paths for a tombstone when no prior successful manifest exists
53
+ // (first-ever boot fails). Points at the unhashed build names so an operator
54
+ // inspecting the manifest sees a real (if 404-ing) target rather than a
55
+ // mystery sentinel.
56
+ export const FALLBACK_MANIFEST: { stylesPath: string; hydratePath: string } = {
57
+ stylesPath: "/assets/styles.css",
58
+ hydratePath: "/assets/hydrate.js",
59
+ };
60
+
61
+ /**
62
+ * Read ONLY the tombstone state of the current manifest — the runtime
63
+ * (read-html-template, ui-service) calls this to decide whether to surface a
64
+ * build-failure signal. Returns null when the manifest is fresh (no
65
+ * `buildFailed`). Decoupled from the full AssetManifest read so the runtime
66
+ * never depends on the build internals.
67
+ */
68
+ export async function readClientBuildStatus(
69
+ cwd = process.cwd(),
70
+ ): Promise<{ failed: true; reason?: string; at?: string } | { failed: false }> {
71
+ const path = join(cwd, "dist", "client", MANIFEST_FILE);
72
+ if (!(await Bun.file(path).exists())) return { failed: false };
73
+ try {
74
+ const parsed = (await Bun.file(path).json()) as Partial<AssetManifest>;
75
+ if (parsed.buildFailed === true) {
76
+ return {
77
+ failed: true,
78
+ reason: parsed.buildFailedReason,
79
+ at: parsed.buildFailedAt,
80
+ };
81
+ }
82
+ } catch {}
83
+ return { failed: false };
84
+ }
85
+
86
+ export async function readManifest(outDir: string): Promise<AssetManifest | null> {
87
+ const path = join(outDir, MANIFEST_FILE);
88
+ if (!(await Bun.file(path).exists())) return null;
89
+ try {
90
+ const parsed = await Bun.file(path).json() as Partial<AssetManifest>;
91
+ if (parsed.stylesPath && parsed.hydratePath) {
92
+ return {
93
+ stylesPath: parsed.stylesPath,
94
+ hydratePath: parsed.hydratePath,
95
+ inputFingerprint: parsed.inputFingerprint,
96
+ buildFailed: parsed.buildFailed,
97
+ buildFailedReason: parsed.buildFailedReason,
98
+ buildFailedAt: parsed.buildFailedAt,
99
+ };
100
+ }
101
+ return null;
102
+ } catch {
103
+ return null;
104
+ }
105
+ }
106
+
107
+ export async function writeManifest(
108
+ outDir: string,
109
+ manifest: AssetManifest,
110
+ ): Promise<void> {
111
+ await Bun.write(
112
+ join(outDir, MANIFEST_FILE),
113
+ `${JSON.stringify(manifest, null, 2)}\n`,
114
+ );
115
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * @system server-handlers
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * Upload source maps to GlitchTip via the official `glitchtip-cli` debug-ID
7
+ * flow, then delete the .map files so source never ships publicly.
8
+ *
9
+ * Split out of build-client-assets.ts (962 lines) alongside its build
10
+ * siblings. Byte-for-byte move from the parent — imports adjusted, nothing
11
+ * else.
12
+ */
13
+
14
+ import { existsSync } from "node:fs";
15
+ import { readdir, rm } from "node:fs/promises";
16
+ import { join } from "node:path";
17
+ import { getAppLogger } from "@teamscala/logger/app-loggers";
18
+ import { userHome } from "@teamscala/os/host-paths";
19
+ import { spawnSync } from "@teamscala/os/spawn";
20
+
21
+ /**
22
+ * Upload source maps to GlitchTip via the official `glitchtip-cli` debug-ID
23
+ * flow (the documented first-class mechanism — `sourcemaps inject` then
24
+ * `sourcemaps upload --release`; see telemetry.md `client-errors-report-to-glitchtip`
25
+ * + https://glitchtip.com/documentation/cli). Debug IDs injected into the served
26
+ * bundles + uploaded maps are the matching mechanism — robust vs URL-prefix
27
+ * guessing. Best-effort + dev-host-only: skips silently when the CLI or
28
+ * SENTRY_AUTH_TOKEN is absent (Railway builds don't install the CLI; prod source
29
+ * maps are a follow-up). The .map files are deleted after upload — source never
30
+ * ships publicly; the debug IDs in the served bundles are all the SDK needs.
31
+ */
32
+ export async function uploadSourceMaps(
33
+ outDir: string,
34
+ release: string,
35
+ log: ReturnType<typeof getAppLogger>,
36
+ ): Promise<void> {
37
+ const token = process.env.SENTRY_AUTH_TOKEN;
38
+ const sentryUrl = process.env.SENTRY_URL;
39
+ if (!token || !sentryUrl) return; // best-effort: no token/URL → skip
40
+ const org = process.env.SENTRY_ORG ?? "scala";
41
+ const project = process.env.SENTRY_PROJECT ?? "scala-hub";
42
+ const cli = join(userHome(), ".local", "bin", "glitchtip-cli");
43
+ const headers = { Authorization: `Bearer ${token}` };
44
+ try {
45
+ // Inject debug IDs into the bundles + maps (if glitchtip-cli is present).
46
+ // The CLI is optional — only used for the inject step.
47
+ if (existsSync(cli)) {
48
+ spawnSync({
49
+ name: "glitchtip-cli-sourcemaps-inject",
50
+ command: [cli, "sourcemaps", "inject", outDir],
51
+ env: { ...process.env, SENTRY_AUTH_TOKEN: token },
52
+ timeoutMs: 60_000,
53
+ captureStderr: true,
54
+ });
55
+ }
56
+ // Upload source maps via the direct release-files API (NOT the CLI's
57
+ // chunk-upload — that hits a host-mismatch behind reverse proxies: the
58
+ // server returns a stale internal URL the CLI refuses to send the token to).
59
+ // The direct API posts to SENTRY_URL (which we control) — no server-returned
60
+ // URL, no host mismatch.
61
+ await fetch(`${sentryUrl}/api/0/organizations/${org}/releases/`, {
62
+ method: "POST",
63
+ headers: { ...headers, "Content-Type": "application/json" },
64
+ body: JSON.stringify({ version: release, projects: [project] }),
65
+ });
66
+ let uploaded = 0;
67
+ for (const name of await readdir(outDir, "utf-8")) {
68
+ if (!name.endsWith(".map")) continue;
69
+ try {
70
+ const mapBytes = await Bun.file(join(outDir, name)).bytes();
71
+ const form = new FormData();
72
+ form.append("name", `~/assets/${name.replace(".map", "")}`);
73
+ form.append("file", new Blob([mapBytes]), name);
74
+ const res = await fetch(
75
+ `${sentryUrl}/api/0/projects/${org}/${project}/releases/${release}/files/`,
76
+ { method: "POST", headers, body: form },
77
+ );
78
+ if (res.ok) uploaded++;
79
+ } catch {}
80
+ }
81
+ log.info("[build-client-assets] source maps uploaded to GlitchTip", {
82
+ release,
83
+ project,
84
+ uploaded,
85
+ });
86
+ } catch (err) {
87
+ log.warn("[build-client-assets] source-map upload error (best-effort, skipped)", {
88
+ release,
89
+ error: err instanceof Error ? err.message : String(err),
90
+ });
91
+ }
92
+ // Delete the .map files — never serve source. The debug IDs injected into the
93
+ // served bundles are all the SDK needs; the maps live in GlitchTip keyed by them.
94
+ for (const name of await readdir(outDir, "utf-8")) {
95
+ if (name.endsWith(".map")) {
96
+ try {
97
+ await rm(join(outDir, name));
98
+ } catch {}
99
+ }
100
+ }
101
+ }
@@ -0,0 +1,179 @@
1
+ /**
2
+ * @system server-handlers
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * The Tailwind stylesheet unit of the client-asset build: Bun-native
7
+ * stylesheet resolution, recursive inlining of nested @imports (the
8
+ * 2026-07-05 dropped-leaf outage), stripping of remote (CSP-forbidden)
9
+ * @imports, and the content-hashed styles-<hash>.css build.
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 { readFile } from "node:fs/promises";
17
+ import { dirname, join, resolve } from "node:path";
18
+ import { getAppLogger } from "@teamscala/logger/app-loggers";
19
+ import { compile } from "tailwindcss";
20
+ import { fileHash, removeStaleHashedFiles } from "./hashed-output.ts";
21
+ import { scanSourceCandidates } from "./source-candidates.ts";
22
+ import { mkdir, rename } from "node:fs/promises";
23
+
24
+ const STYLES_BASE = "styles";
25
+
26
+ async function resolveStylesheetRaw(
27
+ id: string,
28
+ base: string,
29
+ cwd: string,
30
+ ): Promise<{ path: string; base: string; content: string }> {
31
+ if (id === "tailwindcss") {
32
+ const r = await Bun.resolve("tailwindcss/index.css", cwd);
33
+ return { path: r, base: dirname(r), content: await readFile(r, "utf-8") };
34
+ }
35
+ try {
36
+ const r = await Bun.resolve(id, base);
37
+ return { path: r, base: dirname(r), content: await readFile(r, "utf-8") };
38
+ } catch {}
39
+ if (!id.startsWith(".")) {
40
+ try {
41
+ const pkgPath = await Bun.resolve(id + "/package.json", cwd);
42
+ const pkg = JSON.parse(await readFile(pkgPath, "utf-8"));
43
+ const entry =
44
+ pkg.exports?.["."]?.style || pkg.style || pkg.main;
45
+ if (entry) {
46
+ const r = resolve(dirname(pkgPath), entry);
47
+ return { path: r, base: dirname(r), content: await readFile(r, "utf-8") };
48
+ }
49
+ } catch {}
50
+ }
51
+ const r = resolve(base, id);
52
+ return { path: r, base: dirname(r), content: await readFile(r, "utf-8") };
53
+ }
54
+
55
+ // Tailwind v4's compile() invokes `loadStylesheet` for DIRECT @imports in the
56
+ // entry (app.css) but does NOT re-invoke it for @imports WITHIN the loaded
57
+ // content — so a package styles.css that itself `@import "./Leaf.css"` (a
58
+ // nested RELATIVE import) has its leaf silently dropped from the prod bundle.
59
+ // This dropped every block-specific class reachable only via a nested
60
+ // relative @import (project-list-view/ProjectListView.css's
61
+ // .nested-list-table-host, audit-block's nested css, etc.) while direct
62
+ // package @imports (generic-ui/blk-utilities → .blk-*) survived — the
63
+ // 2026-07-05 #6 outage (broken projects UI). Inline nested @imports here so
64
+ // the content returned to Tailwind is flat; the compile() loadStylesheet
65
+ // callback then never sees a nested @import it would drop. Cycle-safe via
66
+ // `seen` (mirrors hashResolvedPackageStylesheet's transitive walk).
67
+ const CSS_IMPORT_RE = /@import\s+(?:url\()?\s*["']([^"']+)["']\s*\)?[^;]*;?/g;
68
+ async function inlineCssImports(
69
+ css: string,
70
+ base: string,
71
+ cwd: string,
72
+ seen: Set<string>,
73
+ ): Promise<string> {
74
+ let result = "";
75
+ let lastIdx = 0;
76
+ for (const m of css.matchAll(CSS_IMPORT_RE)) {
77
+ const spec = m[1].trim();
78
+ result += css.slice(lastIdx, m.index);
79
+ lastIdx = (m.index ?? 0) + m[0].length;
80
+ // Keep the tailwindcss entry verbatim (Tailwind compile owns it —
81
+ // including any trailing `source(none)` etc. — via m[0]).
82
+ if (spec === "tailwindcss" || spec.startsWith("tailwindcss/")) {
83
+ result += m[0];
84
+ continue;
85
+ }
86
+ if (seen.has(spec)) continue; // cycle guard
87
+ seen.add(spec);
88
+ try {
89
+ const resolved = await resolveStylesheetRaw(spec, base, cwd);
90
+ result += await inlineCssImports(resolved.content, resolved.base, cwd, seen);
91
+ } catch {
92
+ result += m[0]; // unresolved — keep literal; CSS/Tailwind will surface it
93
+ }
94
+ }
95
+ result += css.slice(lastIdx);
96
+ return result;
97
+ }
98
+
99
+ async function bunResolveStylesheet(
100
+ id: string,
101
+ base: string,
102
+ cwd: string,
103
+ ): Promise<{ path: string; base: string; content: string }> {
104
+ const resolved = await resolveStylesheetRaw(id, base, cwd);
105
+ if (id === "tailwindcss" || id.startsWith("tailwindcss/")) return resolved;
106
+ const content = await inlineCssImports(resolved.content, resolved.base, cwd, new Set([resolved.path]));
107
+ return { ...resolved, content };
108
+ }
109
+
110
+ // The app owns its fonts via an explicit, CSP-allowlisted <link> in index.html
111
+ // (Google Fonts — `style-src ... fonts.googleapis.com`, `font-src ...
112
+ // fonts.gstatic.com`). Third-party package CSS MUST NOT inject its own remote
113
+ // `@import url(http…)`: @puckeditor/core/dist/index.css opens with
114
+ // `@import "https://rsms.me/inter/inter.css"`, an arbitrary CDN our CSP does
115
+ // NOT allow. The Tailwind compile inlines that mid-output, where it was either
116
+ // silently dropped (CSS spec: @import must precede all rules) OR — once hoisted —
117
+ // blocked by CSP, leaving a console error on every page; either way the font
118
+ // never loaded and the site fell back to a system font (scala-docs 2026-06-13).
119
+ // So every remote @import is STRIPPED from the compiled bundle: a package can
120
+ // never inject a CDN font/stylesheet, and the only font source is the app-owned
121
+ // index.html link. Fixed once at the single CSS chokepoint; guarded by
122
+ // `css-build-strips-remote-imports`.
123
+ const REMOTE_IMPORT_RE =
124
+ /^[ \t]*@import\s+(?:url\(\s*)?["']https?:\/\/[^"')]+["']\s*\)?[^;]*;[ \t]*\n?/gm;
125
+
126
+ export function stripRemoteImports(css: string): string {
127
+ return css.replace(REMOTE_IMPORT_RE, "");
128
+ }
129
+
130
+
131
+ export async function buildTailwindCss(
132
+ cwd: string,
133
+ log: ReturnType<typeof getAppLogger>,
134
+ ): Promise<string | null> {
135
+ const entry = join(cwd, "src", "app.css");
136
+ const outDir = join(cwd, "dist", "client");
137
+ const tmpFile = join(outDir, `${STYLES_BASE}.css`);
138
+
139
+ if (!(await Bun.file(entry).exists())) {
140
+ log.warn(
141
+ `[build-client-assets] src/app.css not found at ${entry} — skipping CSS build`,
142
+ );
143
+ return null;
144
+ }
145
+
146
+ await mkdir(outDir, { recursive: true });
147
+
148
+ const cssSource = await readFile(entry, "utf-8");
149
+ const compiler = await compile(cssSource, {
150
+ base: cwd,
151
+ async loadStylesheet(id, base) {
152
+ return bunResolveStylesheet(id, base, cwd);
153
+ },
154
+ async loadModule(id, base) {
155
+ const r = await Bun.resolve(id, base);
156
+ const mod = await import(r);
157
+ return { path: r, base: dirname(r), module: mod.default ?? mod };
158
+ },
159
+ });
160
+
161
+ // Scan every resolved @source for candidate class names — WITHOUT this the
162
+ // build emits no content-driven utilities (the unstyled-editor bug above).
163
+ const candidates = await scanSourceCandidates(compiler.sources, log);
164
+ const cssOutput = stripRemoteImports(compiler.build(candidates));
165
+ log.info(
166
+ `[build-client-assets] tailwind: ${candidates.length} candidate(s) from ${compiler.sources.length} source(s)`,
167
+ );
168
+
169
+ await Bun.write(tmpFile, cssOutput);
170
+
171
+ const hash = await fileHash(tmpFile);
172
+ const hashedName = `${STYLES_BASE}-${hash}.css`;
173
+ const hashedPath = join(outDir, hashedName);
174
+ await rename(tmpFile, hashedPath);
175
+ removeStaleHashedFiles(outDir, STYLES_BASE, "css", hashedName);
176
+
177
+ log.info(`[build-client-assets] styles.css built → ${hashedName} (in-process Bun compile)`);
178
+ return `/assets/${hashedName}`;
179
+ }
@@ -15,10 +15,8 @@ import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
15
15
  import { tmpdir } from "node:os";
16
16
  import { dirname, join } from "node:path";
17
17
  import { afterAll, describe, expect, test } from "bun:test";
18
- import {
19
- computeInputFingerprint,
20
- stripRemoteImports,
21
- } from "./build-client-assets.ts";
18
+ import { computeInputFingerprint } from "./build-client-assets/input-fingerprint.ts";
19
+ import { stripRemoteImports } from "./build-client-assets/styles-build.ts";
22
20
 
23
21
  describe("stripRemoteImports", () => {
24
22
  test("removes a remote @import (Puck's rsms.me font)", () => {