@teamscala/service-boot 0.23.436 → 0.23.438
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 -1
- package/src/build-client-assets/hydrate-bundle.ts +17 -6
- package/src/build-client-assets/manifest.ts +9 -0
- package/src/build-client-assets/sourcemaps-upload.test.ts +190 -0
- package/src/build-client-assets/sourcemaps-upload.ts +231 -53
- package/src/build-client-assets.ts +30 -5
- package/src/discover-blocks.ts +2 -1
- package/src/is-react-element-type.test.ts +46 -0
- package/src/is-react-element-type.ts +20 -0
package/package.json
CHANGED
|
@@ -59,14 +59,24 @@ import { mkdir, readdir, rename, 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";
|
|
62
|
-
import {
|
|
62
|
+
import {
|
|
63
|
+
uploadSourceMaps,
|
|
64
|
+
type SourceMapUploadResult,
|
|
65
|
+
} from "./sourcemaps-upload.ts";
|
|
63
66
|
|
|
64
67
|
const HYDRATE_BASE = "hydrate";
|
|
65
68
|
|
|
69
|
+
export interface HydrateBundleResult {
|
|
70
|
+
/** Served entry path (`/assets/hydrate-<hash>.js`), or null when no entry exists. */
|
|
71
|
+
hydratePath: string | null;
|
|
72
|
+
/** The VERIFIED source-map upload outcome for this build's release. */
|
|
73
|
+
sourceMaps: SourceMapUploadResult;
|
|
74
|
+
}
|
|
75
|
+
|
|
66
76
|
export async function buildHydrateBundle(
|
|
67
77
|
cwd: string,
|
|
68
78
|
log: ReturnType<typeof getAppLogger>,
|
|
69
|
-
): Promise<
|
|
79
|
+
): Promise<HydrateBundleResult | null> {
|
|
70
80
|
const entry = join(cwd, "src", "entry-client.ts");
|
|
71
81
|
const outDir = join(cwd, "dist", "client");
|
|
72
82
|
const tmpFile = join(outDir, `${HYDRATE_BASE}.js`);
|
|
@@ -264,10 +274,11 @@ export async function buildHydrateBundle(
|
|
|
264
274
|
{ externalizedBlocks },
|
|
265
275
|
);
|
|
266
276
|
}
|
|
267
|
-
// Upload source maps to GlitchTip (best-effort
|
|
268
|
-
//
|
|
269
|
-
|
|
270
|
-
|
|
277
|
+
// Upload source maps to GlitchTip (best-effort) + RETAIN the .map files
|
|
278
|
+
// outside the served root so a failed upload retries next boot — source
|
|
279
|
+
// never ships publicly. See uploadSourceMaps.
|
|
280
|
+
const sourceMaps = await uploadSourceMaps(cwd, outDir, hash, log);
|
|
281
|
+
return { hydratePath: `/assets/${hashedName}`, sourceMaps };
|
|
271
282
|
}
|
|
272
283
|
|
|
273
284
|
// Remove hashed code-split chunks no longer referenced by the current entry.
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import { join } from "node:path";
|
|
18
|
+
import type { SourceMapUploadResult } from "./sourcemaps-upload.ts";
|
|
18
19
|
|
|
19
20
|
export const MANIFEST_FILE = "asset-manifest.json";
|
|
20
21
|
|
|
@@ -42,6 +43,13 @@ export interface AssetManifest {
|
|
|
42
43
|
buildFailed?: boolean;
|
|
43
44
|
buildFailedReason?: string;
|
|
44
45
|
buildFailedAt?: string;
|
|
46
|
+
// VERIFIED outcome of the last source-map upload attempt for THIS build's
|
|
47
|
+
// release (`uploaded` = every response checked 2xx; `failed` names each
|
|
48
|
+
// rejection in `failures`; `skipped` = no SENTRY_AUTH_TOKEN/SENTRY_URL).
|
|
49
|
+
// The fingerprint-skip path reads it: any status other than `uploaded`
|
|
50
|
+
// re-runs the upload from the retained map cache on every boot until it
|
|
51
|
+
// verifies — an unuploaded release must not silently persist forever.
|
|
52
|
+
sourceMaps?: SourceMapUploadResult;
|
|
45
53
|
}
|
|
46
54
|
|
|
47
55
|
// Last-resort paths for a tombstone when no prior successful manifest exists
|
|
@@ -93,6 +101,7 @@ export async function readManifest(outDir: string): Promise<AssetManifest | null
|
|
|
93
101
|
buildFailed: parsed.buildFailed,
|
|
94
102
|
buildFailedReason: parsed.buildFailedReason,
|
|
95
103
|
buildFailedAt: parsed.buildFailedAt,
|
|
104
|
+
sourceMaps: parsed.sourceMaps,
|
|
96
105
|
};
|
|
97
106
|
}
|
|
98
107
|
return null;
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system server-handlers
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* Behaviour tests for the source-map upload unit. The pre-2026-09 flow
|
|
7
|
+
* deleted the maps after an upload whose responses were never checked, so a
|
|
8
|
+
* missing token or a 4xx permanently lost a release's maps while the boot
|
|
9
|
+
* log kept saying "uploaded" — client stacks stayed unattributable in
|
|
10
|
+
* GlitchTip. These tests assert the OUTCOME, not the call: the served root
|
|
11
|
+
* never keeps a .map, failures are named and retain the maps for retry, and
|
|
12
|
+
* only verified acceptance reports "uploaded".
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { mkdtemp, mkdir, readdir, rm, writeFile } from "node:fs/promises";
|
|
16
|
+
import { tmpdir } from "node:os";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
import { afterAll, afterEach, describe, expect, test } from "bun:test";
|
|
19
|
+
import {
|
|
20
|
+
releaseFromHydratePath,
|
|
21
|
+
sourceMapsCacheDir,
|
|
22
|
+
uploadCachedSourceMaps,
|
|
23
|
+
uploadSourceMaps,
|
|
24
|
+
type SourceMapUploadResult,
|
|
25
|
+
} from "./sourcemaps-upload.ts";
|
|
26
|
+
|
|
27
|
+
const dirs: string[] = [];
|
|
28
|
+
const realFetch = globalThis.fetch;
|
|
29
|
+
const createdFetchCalls: Array<{ url: string; name: string | null }> = [];
|
|
30
|
+
|
|
31
|
+
async function makeDir(): Promise<string> {
|
|
32
|
+
const dir = await mkdtemp(join(tmpdir(), "sourcemaps-up-"));
|
|
33
|
+
dirs.push(dir);
|
|
34
|
+
return dir;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function seedMaps(outDir: string, names: string[]): Promise<void> {
|
|
38
|
+
await mkdir(outDir, { recursive: true });
|
|
39
|
+
for (const name of names) {
|
|
40
|
+
await writeFile(
|
|
41
|
+
join(outDir, name),
|
|
42
|
+
JSON.stringify({ version: 3, sources: ["a.ts"], mappings: "AAAA" }),
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const noopLog = {
|
|
48
|
+
info: () => {},
|
|
49
|
+
warn: () => {},
|
|
50
|
+
error: () => {},
|
|
51
|
+
} as unknown as ReturnType<typeof import("@teamscala/logger/app-loggers").getAppLogger>;
|
|
52
|
+
|
|
53
|
+
function mockFetch(
|
|
54
|
+
releaseStatus: number,
|
|
55
|
+
fileStatus: number,
|
|
56
|
+
): void {
|
|
57
|
+
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
58
|
+
const url = String(input);
|
|
59
|
+
if (url.endsWith("/releases/")) {
|
|
60
|
+
return new Response("{}", { status: releaseStatus });
|
|
61
|
+
}
|
|
62
|
+
let name: string | null = null;
|
|
63
|
+
if (init?.body instanceof FormData) {
|
|
64
|
+
name = (init.body.get("name") as string | null) ?? null;
|
|
65
|
+
}
|
|
66
|
+
createdFetchCalls.push({ url, name });
|
|
67
|
+
return new Response("{}", { status: fileStatus });
|
|
68
|
+
}) as typeof fetch;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
afterEach(() => {
|
|
72
|
+
globalThis.fetch = realFetch;
|
|
73
|
+
delete process.env.SENTRY_AUTH_TOKEN;
|
|
74
|
+
delete process.env.SENTRY_URL;
|
|
75
|
+
createdFetchCalls.length = 0;
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
afterAll(async () => {
|
|
79
|
+
globalThis.fetch = realFetch;
|
|
80
|
+
for (const d of dirs) await rm(d, { recursive: true, force: true });
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe("uploadSourceMaps", () => {
|
|
84
|
+
test("absent env → skipped, but maps STILL leave the served root and are retained", async () => {
|
|
85
|
+
const cwd = await makeDir();
|
|
86
|
+
const outDir = join(cwd, "dist", "client");
|
|
87
|
+
await seedMaps(outDir, ["hydrate-abc123.js.map", "chunk-xyz789.js.map"]);
|
|
88
|
+
const result = await uploadSourceMaps(cwd, outDir, "abc123", noopLog);
|
|
89
|
+
expect(result.status).toBe("skipped");
|
|
90
|
+
expect(result.total).toBe(2);
|
|
91
|
+
expect((await readdir(outDir)).filter((n) => n.endsWith(".map"))).toEqual([]);
|
|
92
|
+
const cache = sourceMapsCacheDir(cwd, "abc123");
|
|
93
|
+
const retained = (await readdir(cache)).filter((n) => n.endsWith(".map"));
|
|
94
|
+
expect(retained.sort()).toEqual(["chunk-xyz789.js.map", "hydrate-abc123.js.map"].sort());
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("token present + all 2xx → uploaded, verified count, artifact names map 1:1 to served assets", async () => {
|
|
98
|
+
process.env.SENTRY_AUTH_TOKEN = "test-token";
|
|
99
|
+
process.env.SENTRY_URL = "https://glitchtip.test";
|
|
100
|
+
const cwd = await makeDir();
|
|
101
|
+
const outDir = join(cwd, "dist", "client");
|
|
102
|
+
await seedMaps(outDir, ["hydrate-abc123.js.map"]);
|
|
103
|
+
mockFetch(201, 201);
|
|
104
|
+
const result = await uploadSourceMaps(cwd, outDir, "abc123", noopLog);
|
|
105
|
+
expect(result.status).toBe("uploaded");
|
|
106
|
+
expect(result.uploaded).toBe(1);
|
|
107
|
+
expect(result.total).toBe(1);
|
|
108
|
+
expect(result.failures).toEqual([]);
|
|
109
|
+
expect(createdFetchCalls[0]?.name).toBe("~/assets/hydrate-abc123.js");
|
|
110
|
+
expect((await readdir(outDir)).filter((n) => n.endsWith(".map"))).toEqual([]);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("file upload rejected → failed, EACH failure names map + status, maps retained for retry", async () => {
|
|
114
|
+
process.env.SENTRY_AUTH_TOKEN = "test-token";
|
|
115
|
+
process.env.SENTRY_URL = "https://glitchtip.test";
|
|
116
|
+
const cwd = await makeDir();
|
|
117
|
+
const outDir = join(cwd, "dist", "client");
|
|
118
|
+
await seedMaps(outDir, ["hydrate-abc123.js.map", "chunk-xyz789.js.map"]);
|
|
119
|
+
mockFetch(201, 403);
|
|
120
|
+
const result = await uploadSourceMaps(cwd, outDir, "abc123", noopLog);
|
|
121
|
+
expect(result.status).toBe("failed");
|
|
122
|
+
expect(result.uploaded).toBe(0);
|
|
123
|
+
expect(result.total).toBe(2);
|
|
124
|
+
expect(result.failures.length).toBe(2);
|
|
125
|
+
expect(result.failures[0]).toContain("hydrate-abc123.js.map");
|
|
126
|
+
expect(result.failures[0]).toContain("403");
|
|
127
|
+
expect((await readdir(outDir)).filter((n) => n.endsWith(".map"))).toEqual([]);
|
|
128
|
+
const retained = await readdir(sourceMapsCacheDir(cwd, "abc123"));
|
|
129
|
+
expect(retained.filter((n) => n.endsWith(".map")).length).toBe(2);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("release create 208 (already exists) still uploads", async () => {
|
|
133
|
+
process.env.SENTRY_AUTH_TOKEN = "test-token";
|
|
134
|
+
process.env.SENTRY_URL = "https://glitchtip.test";
|
|
135
|
+
const cwd = await makeDir();
|
|
136
|
+
const outDir = join(cwd, "dist", "client");
|
|
137
|
+
await seedMaps(outDir, ["hydrate-abc123.js.map"]);
|
|
138
|
+
mockFetch(208, 201);
|
|
139
|
+
const result = await uploadSourceMaps(cwd, outDir, "abc123", noopLog);
|
|
140
|
+
expect(result.status).toBe("uploaded");
|
|
141
|
+
expect(result.uploaded).toBe(1);
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe("uploadCachedSourceMaps (the fingerprint-skip retry path)", () => {
|
|
146
|
+
test("no cached maps → failed, naming the release and the reason", async () => {
|
|
147
|
+
process.env.SENTRY_AUTH_TOKEN = "test-token";
|
|
148
|
+
process.env.SENTRY_URL = "https://glitchtip.test";
|
|
149
|
+
const cwd = await makeDir();
|
|
150
|
+
const result = await uploadCachedSourceMaps(cwd, "deadbeef", noopLog);
|
|
151
|
+
expect(result.status).toBe("failed");
|
|
152
|
+
expect(result.failures[0]).toContain("deadbeef");
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("cached maps retained by a failed pass upload successfully on retry", async () => {
|
|
156
|
+
process.env.SENTRY_AUTH_TOKEN = "test-token";
|
|
157
|
+
process.env.SENTRY_URL = "https://glitchtip.test";
|
|
158
|
+
const cwd = await makeDir();
|
|
159
|
+
const outDir = join(cwd, "dist", "client");
|
|
160
|
+
await seedMaps(outDir, ["hydrate-abc123.js.map"]);
|
|
161
|
+
// First pass fails (500) and retains the maps in the cache.
|
|
162
|
+
mockFetch(201, 500);
|
|
163
|
+
const failed: SourceMapUploadResult = await uploadSourceMaps(cwd, outDir, "abc123", noopLog);
|
|
164
|
+
expect(failed.status).toBe("failed");
|
|
165
|
+
// Retry pass (what the skip path runs on next boot) succeeds from cache.
|
|
166
|
+
mockFetch(201, 201);
|
|
167
|
+
const retried = await uploadCachedSourceMaps(cwd, "abc123", noopLog);
|
|
168
|
+
expect(retried.status).toBe("uploaded");
|
|
169
|
+
expect(retried.uploaded).toBe(1);
|
|
170
|
+
expect(createdFetchCalls[0]?.name).toBe("~/assets/hydrate-abc123.js");
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("absent env → skipped without touching the network", async () => {
|
|
174
|
+
const cwd = await makeDir();
|
|
175
|
+
const result = await uploadCachedSourceMaps(cwd, "abc123", noopLog);
|
|
176
|
+
expect(result.status).toBe("skipped");
|
|
177
|
+
expect(createdFetchCalls).toEqual([]);
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
describe("releaseFromHydratePath", () => {
|
|
182
|
+
test("extracts the bare content hash from a manifest hydratePath", () => {
|
|
183
|
+
expect(releaseFromHydratePath("/assets/hydrate-9df3aab732.js")).toBe("9df3aab732");
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test("non-hydrate paths yield null (no proximity guess)", () => {
|
|
187
|
+
expect(releaseFromHydratePath("/assets/hydrate.js")).toBeNull();
|
|
188
|
+
expect(releaseFromHydratePath("/assets/styles-abc.css")).toBeNull();
|
|
189
|
+
});
|
|
190
|
+
});
|
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
* @edit edit directly
|
|
5
5
|
*
|
|
6
6
|
* Upload source maps to GlitchTip via the official `glitchtip-cli` debug-ID
|
|
7
|
-
* flow, then
|
|
7
|
+
* flow, then RETAIN the maps outside the served root so a failed upload is
|
|
8
|
+
* retryable on a later boot.
|
|
8
9
|
*
|
|
9
10
|
* Split out of build-client-assets.ts (962 lines) alongside its build
|
|
10
11
|
* siblings. Byte-for-byte move from the parent — imports adjusted, nothing
|
|
@@ -17,89 +18,266 @@
|
|
|
17
18
|
* `sourcemaps upload --release`; see telemetry.md `client-errors-report-to-glitchtip`
|
|
18
19
|
* + https://glitchtip.com/documentation/cli). Debug IDs injected into the served
|
|
19
20
|
* bundles + uploaded maps are the matching mechanism — robust vs URL-prefix
|
|
20
|
-
* guessing.
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
21
|
+
* guessing.
|
|
22
|
+
*
|
|
23
|
+
* THE MAPS OUTLIVE THE UPLOAD ATTEMPT: `.map` files are MOVED out of the
|
|
24
|
+
* served root (`dist/client/` — /assets/* would serve source publicly) into
|
|
25
|
+
* the retained cache `dist/sourcemaps/<release>/` BEFORE anything else, and
|
|
26
|
+
* the upload runs from the cache. A failed or skipped upload therefore
|
|
27
|
+
* leaves the maps on disk for the next boot's retry (the fingerprint-skip
|
|
28
|
+
* path in build-client-assets.ts re-runs the upload whenever the manifest
|
|
29
|
+
* does not record `uploaded`) instead of destroying the only copy of a
|
|
30
|
+
* release's maps on an unverified claim (`a-component-may-not-report-a-
|
|
31
|
+
* state-it-has-not-verified`; measured: the pre-2026-09 flow deleted the
|
|
32
|
+
* maps after an upload whose responses were never checked, so a missing
|
|
33
|
+
* token or a 4xx permanently lost a release's maps while the boot log kept
|
|
34
|
+
* saying "uploaded").
|
|
35
|
+
*
|
|
36
|
+
* Every upstream response is verified: the release-create call must return
|
|
37
|
+
* 2xx/208 and each file upload must return 2xx, else the failure is named
|
|
38
|
+
* (status + body head) in the result. Best-effort at the SERVICE level — a
|
|
39
|
+
* failed upload never blocks boot — but never at the CLAIM level: the
|
|
40
|
+
* result says what actually happened and the manifest records it.
|
|
24
41
|
*/
|
|
25
42
|
|
|
26
|
-
import { readdir, rm } from "node:fs/promises";
|
|
43
|
+
import { mkdir, readdir, rename, rm } from "node:fs/promises";
|
|
27
44
|
import { join } from "node:path";
|
|
28
45
|
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
29
46
|
import { userHome } from "@teamscala/os/host-paths";
|
|
30
47
|
import { spawnSync } from "@teamscala/os/spawn";
|
|
31
48
|
|
|
32
|
-
export
|
|
49
|
+
export type SourceMapUploadStatus = "uploaded" | "failed" | "skipped";
|
|
50
|
+
|
|
51
|
+
export interface SourceMapUploadResult {
|
|
52
|
+
status: SourceMapUploadStatus;
|
|
53
|
+
/** Maps verified accepted by GlitchTip (2xx), never assumed. */
|
|
54
|
+
uploaded: number;
|
|
55
|
+
/** Maps present for this upload pass (moved to the retained cache). */
|
|
56
|
+
total: number;
|
|
57
|
+
/** One named failure per non-accepted upload (status + body head). */
|
|
58
|
+
failures: string[];
|
|
59
|
+
at: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const UPLOAD_TIMEOUT_MS = 30_000;
|
|
63
|
+
|
|
64
|
+
/** The retained, never-served home of a release's source maps. */
|
|
65
|
+
export function sourceMapsCacheDir(cwd: string, release: string): string {
|
|
66
|
+
return join(cwd, "dist", "sourcemaps", release);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The release (bare content hash) a manifest's hydratePath names. */
|
|
70
|
+
export function releaseFromHydratePath(hydratePath: string): string | null {
|
|
71
|
+
return /hydrate-([a-z0-9]+)\.js/.exec(hydratePath)?.[1] ?? null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Move every `.map` in `outDir` into the retained cache for `release` and
|
|
76
|
+
* return their names. The served root ends the pass with zero `.map` files:
|
|
77
|
+
* any not moved (stale maps from an older build whose release cache already
|
|
78
|
+
* holds a copy) are deleted — source never ships via /assets/*.
|
|
79
|
+
*/
|
|
80
|
+
async function moveMapsToCache(
|
|
81
|
+
cwd: string,
|
|
33
82
|
outDir: string,
|
|
34
83
|
release: string,
|
|
35
84
|
log: ReturnType<typeof getAppLogger>,
|
|
36
|
-
): Promise<
|
|
85
|
+
): Promise<string[]> {
|
|
86
|
+
const cacheDir = sourceMapsCacheDir(cwd, release);
|
|
87
|
+
const moved: string[] = [];
|
|
88
|
+
for (const name of await readdir(outDir, "utf-8")) {
|
|
89
|
+
if (!name.endsWith(".map")) continue;
|
|
90
|
+
try {
|
|
91
|
+
await mkdir(cacheDir, { recursive: true });
|
|
92
|
+
await rename(join(outDir, name), join(cacheDir, name));
|
|
93
|
+
moved.push(name);
|
|
94
|
+
} catch (error) {
|
|
95
|
+
// The move IS the serve-safety guarantee — if it failed, delete the
|
|
96
|
+
// map rather than leave source in the served root.
|
|
97
|
+
log.warn("sourcemap cache move failed — map deleted (never serve source)", {
|
|
98
|
+
name,
|
|
99
|
+
error: error instanceof Error ? error.message : String(error),
|
|
100
|
+
});
|
|
101
|
+
await rm(join(outDir, name), { force: true });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
for (const name of await readdir(outDir, "utf-8")) {
|
|
105
|
+
if (name.endsWith(".map")) {
|
|
106
|
+
log.warn("stale sourcemap removed from served root (older release)", { name });
|
|
107
|
+
await rm(join(outDir, name), { force: true });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return moved;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function authEnv(): { token: string; url: string } | null {
|
|
37
114
|
const token = process.env.SENTRY_AUTH_TOKEN;
|
|
38
|
-
const
|
|
39
|
-
if (!token || !
|
|
115
|
+
const url = process.env.SENTRY_URL;
|
|
116
|
+
if (!token || !url) return null;
|
|
117
|
+
return { token, url };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Upload the maps ALREADY RETAINED in the cache for `release`. */
|
|
121
|
+
export async function uploadCachedSourceMaps(
|
|
122
|
+
cwd: string,
|
|
123
|
+
release: string,
|
|
124
|
+
log: ReturnType<typeof getAppLogger>,
|
|
125
|
+
): Promise<SourceMapUploadResult> {
|
|
126
|
+
const at = new Date().toISOString();
|
|
127
|
+
const auth = authEnv();
|
|
128
|
+
const cacheDir = sourceMapsCacheDir(cwd, release);
|
|
129
|
+
if (!auth) {
|
|
130
|
+
return { status: "skipped", uploaded: 0, total: 0, failures: [], at };
|
|
131
|
+
}
|
|
132
|
+
let names: string[] = [];
|
|
133
|
+
try {
|
|
134
|
+
names = (await readdir(cacheDir, "utf-8")).filter((n) => n.endsWith(".map"));
|
|
135
|
+
} catch {
|
|
136
|
+
names = [];
|
|
137
|
+
}
|
|
138
|
+
if (names.length === 0) {
|
|
139
|
+
return {
|
|
140
|
+
status: "failed",
|
|
141
|
+
uploaded: 0,
|
|
142
|
+
total: 0,
|
|
143
|
+
failures: [
|
|
144
|
+
`no cached source maps for release ${release} at ${cacheDir} — the build that produced them predates retention or they were removed; next bundle rebuild regenerates them`,
|
|
145
|
+
],
|
|
146
|
+
at,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
return uploadMapsFromCache(cacheDir, names, release, auth, log, at);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Upload source maps for the bundle just built into `outDir`. */
|
|
153
|
+
export async function uploadSourceMaps(
|
|
154
|
+
cwd: string,
|
|
155
|
+
outDir: string,
|
|
156
|
+
release: string,
|
|
157
|
+
log: ReturnType<typeof getAppLogger>,
|
|
158
|
+
): Promise<SourceMapUploadResult> {
|
|
159
|
+
const at = new Date().toISOString();
|
|
160
|
+
const auth = authEnv();
|
|
161
|
+
// Inject debug IDs into the served bundles + maps (if glitchtip-cli is
|
|
162
|
+
// present). Runs on outDir BEFORE the cache move so both the served
|
|
163
|
+
// bundle and its retained map carry the same debug id. The CLI is
|
|
164
|
+
// optional — only used for the inject step.
|
|
165
|
+
const cli = join(userHome(), ".local", "bin", "glitchtip-cli");
|
|
166
|
+
if (await Bun.file(cli).exists()) {
|
|
167
|
+
spawnSync({
|
|
168
|
+
name: "glitchtip-cli-sourcemaps-inject",
|
|
169
|
+
command: [cli, "sourcemaps", "inject", outDir],
|
|
170
|
+
env: { ...process.env, SENTRY_AUTH_TOKEN: auth?.token ?? "" },
|
|
171
|
+
timeoutMs: 60_000,
|
|
172
|
+
captureStderr: true,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
// The move is unconditional — the served root must never hold `.map`
|
|
176
|
+
// files, whether or not an upload will follow.
|
|
177
|
+
const names = await moveMapsToCache(cwd, outDir, release, log);
|
|
178
|
+
if (!auth) {
|
|
179
|
+
// Best-effort by design, but VISIBLE: one warn per boot names the
|
|
180
|
+
// missing env instead of a silent skip that reads as success.
|
|
181
|
+
log.warn(
|
|
182
|
+
`[build-client-assets] source maps NOT uploaded — SENTRY_AUTH_TOKEN/SENTRY_URL absent (maps retained at ${sourceMapsCacheDir(cwd, release)} for retry once configured)`,
|
|
183
|
+
{ release, mapsRetained: names.length },
|
|
184
|
+
);
|
|
185
|
+
return { status: "skipped", uploaded: 0, total: names.length, failures: [], at };
|
|
186
|
+
}
|
|
187
|
+
return uploadMapsFromCache(
|
|
188
|
+
sourceMapsCacheDir(cwd, release),
|
|
189
|
+
names,
|
|
190
|
+
release,
|
|
191
|
+
auth,
|
|
192
|
+
log,
|
|
193
|
+
at,
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async function uploadMapsFromCache(
|
|
198
|
+
cacheDir: string,
|
|
199
|
+
names: string[],
|
|
200
|
+
release: string,
|
|
201
|
+
auth: { token: string; url: string },
|
|
202
|
+
log: ReturnType<typeof getAppLogger>,
|
|
203
|
+
at: string,
|
|
204
|
+
): Promise<SourceMapUploadResult> {
|
|
40
205
|
const org = process.env.SENTRY_ORG ?? "scala";
|
|
41
206
|
const project = process.env.SENTRY_PROJECT ?? "scala-hub";
|
|
42
|
-
const
|
|
43
|
-
const
|
|
207
|
+
const headers = { Authorization: `Bearer ${auth.token}` };
|
|
208
|
+
const failures: string[] = [];
|
|
209
|
+
let uploaded = 0;
|
|
44
210
|
try {
|
|
45
|
-
//
|
|
46
|
-
// The
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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/`, {
|
|
211
|
+
// Create the release (2xx or 208 already-exists are both "exists now").
|
|
212
|
+
// The direct API posts to SENTRY_URL (which we control) — not the CLI's
|
|
213
|
+
// chunk-upload, which hits a host-mismatch behind reverse proxies: the
|
|
214
|
+
// server returns a stale internal URL the CLI refuses to send the token
|
|
215
|
+
// to.
|
|
216
|
+
const releaseRes = await fetch(`${auth.url}/api/0/organizations/${org}/releases/`, {
|
|
62
217
|
method: "POST",
|
|
218
|
+
signal: AbortSignal.timeout(UPLOAD_TIMEOUT_MS),
|
|
63
219
|
headers: { ...headers, "Content-Type": "application/json" },
|
|
64
220
|
body: JSON.stringify({ version: release, projects: [project] }),
|
|
65
221
|
});
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
222
|
+
if (!(releaseRes.ok || releaseRes.status === 208)) {
|
|
223
|
+
const body = (await releaseRes.text()).slice(0, 300);
|
|
224
|
+
failures.push(
|
|
225
|
+
`release create ${release} → HTTP ${releaseRes.status}: ${body}`,
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
for (const name of names) {
|
|
69
229
|
try {
|
|
70
|
-
const mapBytes = await Bun.file(join(
|
|
230
|
+
const mapBytes = await Bun.file(join(cacheDir, name)).bytes();
|
|
71
231
|
const form = new FormData();
|
|
72
|
-
form.append("name", `~/assets/${name.replace(
|
|
232
|
+
form.append("name", `~/assets/${name.replace(/\.map$/, "")}`);
|
|
73
233
|
form.append("file", new Blob([mapBytes]), name);
|
|
74
234
|
const res = await fetch(
|
|
75
|
-
`${
|
|
76
|
-
{
|
|
235
|
+
`${auth.url}/api/0/projects/${org}/${project}/releases/${release}/files/`,
|
|
236
|
+
{
|
|
237
|
+
method: "POST",
|
|
238
|
+
signal: AbortSignal.timeout(UPLOAD_TIMEOUT_MS),
|
|
239
|
+
headers,
|
|
240
|
+
body: form,
|
|
241
|
+
},
|
|
77
242
|
);
|
|
78
|
-
if (res.ok)
|
|
243
|
+
if (res.ok) {
|
|
244
|
+
uploaded++;
|
|
245
|
+
} else {
|
|
246
|
+
const body = (await res.text()).slice(0, 300);
|
|
247
|
+
failures.push(`${name} → HTTP ${res.status}: ${body}`);
|
|
248
|
+
}
|
|
79
249
|
} catch (error) {
|
|
80
|
-
|
|
250
|
+
failures.push(
|
|
251
|
+
`${name} → ${error instanceof Error ? error.message : String(error)}`,
|
|
252
|
+
);
|
|
81
253
|
}
|
|
82
254
|
}
|
|
83
|
-
|
|
255
|
+
} catch (err) {
|
|
256
|
+
failures.push(
|
|
257
|
+
err instanceof Error ? err.message : String(err),
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
const status: SourceMapUploadStatus = failures.length === 0 && uploaded === names.length
|
|
261
|
+
? "uploaded"
|
|
262
|
+
: "failed";
|
|
263
|
+
if (status === "uploaded") {
|
|
264
|
+
log.info("[build-client-assets] source maps uploaded to GlitchTip (verified)", {
|
|
84
265
|
release,
|
|
85
266
|
project,
|
|
86
267
|
uploaded,
|
|
268
|
+
total: names.length,
|
|
87
269
|
});
|
|
88
|
-
}
|
|
89
|
-
|
|
270
|
+
} else {
|
|
271
|
+
// Name every failure — a report that cannot answer "what do I do" is
|
|
272
|
+
// unfinished. Each failure carries its map name and HTTP status/body.
|
|
273
|
+
log.error("[build-client-assets] source map upload INCOMPLETE — maps retained for retry", {
|
|
90
274
|
release,
|
|
91
|
-
|
|
275
|
+
project,
|
|
276
|
+
uploaded,
|
|
277
|
+
total: names.length,
|
|
278
|
+
failures,
|
|
279
|
+
cacheDir,
|
|
92
280
|
});
|
|
93
281
|
}
|
|
94
|
-
|
|
95
|
-
// served bundles are all the SDK needs; the maps live in GlitchTip keyed by them.
|
|
96
|
-
for (const name of await readdir(outDir, "utf-8")) {
|
|
97
|
-
if (name.endsWith(".map")) {
|
|
98
|
-
try {
|
|
99
|
-
await rm(join(outDir, name));
|
|
100
|
-
} catch (error) {
|
|
101
|
-
getAppLogger().warn("sourcemaps-upload.ts: bare catch around upstream call — logged, continuing (protective-layers-fail-explicitly)", error);
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
}
|
|
282
|
+
return { status, uploaded, total: names.length, failures, at };
|
|
105
283
|
}
|
|
@@ -64,6 +64,10 @@ import {
|
|
|
64
64
|
readManifest,
|
|
65
65
|
writeManifest,
|
|
66
66
|
} from "./build-client-assets/manifest.ts";
|
|
67
|
+
import {
|
|
68
|
+
releaseFromHydratePath,
|
|
69
|
+
uploadCachedSourceMaps,
|
|
70
|
+
} from "./build-client-assets/sourcemaps-upload.ts";
|
|
67
71
|
import { buildTailwindCss } from "./build-client-assets/styles-build.ts";
|
|
68
72
|
|
|
69
73
|
export async function buildClientAssets(serviceSlug: string): Promise<void> {
|
|
@@ -74,7 +78,23 @@ export async function buildClientAssets(serviceSlug: string): Promise<void> {
|
|
|
74
78
|
const existingManifest = await readManifest(outDir);
|
|
75
79
|
const fingerprint = await computeInputFingerprint(cwd);
|
|
76
80
|
if (existingManifest && existingManifest.inputFingerprint === fingerprint) {
|
|
77
|
-
|
|
81
|
+
// The bundle is fresh, but a fresh bundle with an UNVERIFIED source-map
|
|
82
|
+
// upload must not stay that way forever: retry the upload from the
|
|
83
|
+
// retained map cache on every boot until it verifies. Without this, one
|
|
84
|
+
// failed boot-time upload (GlitchTip down, token missing at that boot)
|
|
85
|
+
// permanently lost the release's maps — client stacks stayed minified
|
|
86
|
+
// until the next real input change forced a rebuild.
|
|
87
|
+
const recorded = existingManifest.sourceMaps?.status;
|
|
88
|
+
const release = releaseFromHydratePath(existingManifest.hydratePath);
|
|
89
|
+
if (recorded !== "uploaded" && release) {
|
|
90
|
+
log.info(
|
|
91
|
+
`[build-client-assets:${serviceSlug}] manifest up-to-date (skip); source maps previously ${recorded ?? "unrecorded"} — retrying upload for release ${release}`,
|
|
92
|
+
);
|
|
93
|
+
const sourceMaps = await uploadCachedSourceMaps(cwd, release, log);
|
|
94
|
+
await writeManifest(outDir, { ...existingManifest, sourceMaps });
|
|
95
|
+
} else {
|
|
96
|
+
log.info(`[build-client-assets:${serviceSlug}] manifest up-to-date (skip)`);
|
|
97
|
+
}
|
|
78
98
|
return;
|
|
79
99
|
}
|
|
80
100
|
if (existingManifest && existingManifest.inputFingerprint !== fingerprint) {
|
|
@@ -177,10 +197,10 @@ async function performBuild(
|
|
|
177
197
|
);
|
|
178
198
|
}
|
|
179
199
|
|
|
180
|
-
const
|
|
200
|
+
const hydrate = hydrateResult.value;
|
|
181
201
|
const stylesPath = stylesResult.value;
|
|
182
|
-
if (!hydratePath || !stylesPath) {
|
|
183
|
-
const reason = `bundle build returned empty path (hydrate=${hydratePath}, styles=${stylesPath})`;
|
|
202
|
+
if (!hydrate?.hydratePath || !stylesPath) {
|
|
203
|
+
const reason = `bundle build returned empty path (hydrate=${hydrate?.hydratePath ?? "<null>"}, styles=${stylesPath})`;
|
|
184
204
|
await writeTombstone(reason);
|
|
185
205
|
throw new Error(
|
|
186
206
|
`[build-client-assets:${serviceSlug}] ${reason} — tombstone manifest written.`,
|
|
@@ -188,7 +208,12 @@ async function performBuild(
|
|
|
188
208
|
}
|
|
189
209
|
|
|
190
210
|
await mkdir(outDir, { recursive: true });
|
|
191
|
-
await writeManifest(outDir, {
|
|
211
|
+
await writeManifest(outDir, {
|
|
212
|
+
stylesPath,
|
|
213
|
+
hydratePath: hydrate.hydratePath,
|
|
214
|
+
inputFingerprint,
|
|
215
|
+
sourceMaps: hydrate.sourceMaps,
|
|
216
|
+
});
|
|
192
217
|
|
|
193
218
|
log.info(
|
|
194
219
|
`[build-client-assets:${serviceSlug}] boot-time build complete in ${Date.now() - t0}ms`,
|
package/src/discover-blocks.ts
CHANGED
|
@@ -25,6 +25,7 @@ import { join } from "node:path";
|
|
|
25
25
|
import { getPrisma } from "@teamscala/db/prisma-registry";
|
|
26
26
|
import { BlockSchemaConfigSchema } from "@teamscala/db-validation/registry-schemas/block-schema";
|
|
27
27
|
import * as v from "valibot";
|
|
28
|
+
import { isReactElementType } from "./is-react-element-type.ts";
|
|
28
29
|
import type { RendererComponentEntry } from "./types.ts";
|
|
29
30
|
|
|
30
31
|
export interface DiscoveredBlocks {
|
|
@@ -159,7 +160,7 @@ export async function discoverBlocks(
|
|
|
159
160
|
),
|
|
160
161
|
])) as Record<string, unknown>;
|
|
161
162
|
const component = mod[componentExport] ?? mod.default;
|
|
162
|
-
if (
|
|
163
|
+
if (!isReactElementType(component)) {
|
|
163
164
|
skipped.push(
|
|
164
165
|
`${row.slug} (${componentExport} not a component in ${componentPath})`,
|
|
165
166
|
);
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system ui-service-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* Pins the three React idioms a `typeof === "function"` check silently rejects.
|
|
7
|
+
* The memo case is the measured one: PresenceAvatars rendered blank on every
|
|
8
|
+
* scala-projects page carrying it, because memo() returns an object.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { expect, test } from "bun:test";
|
|
12
|
+
import { forwardRef, lazy, memo } from "react";
|
|
13
|
+
import { isReactElementType } from "./is-react-element-type.ts";
|
|
14
|
+
|
|
15
|
+
function Plain() {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
test("accepts a plain function component", () => {
|
|
20
|
+
expect(isReactElementType(Plain)).toBe(true);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("accepts memo() — the PresenceAvatars shape that rendered blank", () => {
|
|
24
|
+
expect(isReactElementType(memo(Plain))).toBe(true);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("accepts forwardRef()", () => {
|
|
28
|
+
expect(isReactElementType(forwardRef(() => null))).toBe(true);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("accepts lazy()", () => {
|
|
32
|
+
expect(isReactElementType(lazy(async () => ({ default: Plain })))).toBe(true);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("still rejects what genuinely cannot render", () => {
|
|
36
|
+
expect(isReactElementType(undefined)).toBe(false);
|
|
37
|
+
expect(isReactElementType(null)).toBe(false);
|
|
38
|
+
expect(isReactElementType({})).toBe(false);
|
|
39
|
+
expect(isReactElementType("PresenceAvatarsBlock")).toBe(false);
|
|
40
|
+
expect(isReactElementType(42)).toBe(false);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("rejects a plain object wearing a non-symbol $$typeof", () => {
|
|
44
|
+
// A data blob with the key but not the symbol must not pass as renderable.
|
|
45
|
+
expect(isReactElementType({ $$typeof: "react.memo" })).toBe(false);
|
|
46
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system ui-service-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* Is a resolved module export something React can render?
|
|
7
|
+
*
|
|
8
|
+
* `typeof x === "function"` is NOT that question. memo(), forwardRef() and
|
|
9
|
+
* lazy() all return OBJECTS carrying a `$$typeof` symbol, so a shape check on
|
|
10
|
+
* `function` rejects three of React's own idioms as "not a component".
|
|
11
|
+
* Measured 2026-09-01: scala-projects skipped PresenceAvatars for exactly this
|
|
12
|
+
* reason — the block is `memo(function PresenceAvatarsBlock(...))` — and every
|
|
13
|
+
* page rendering it came out blank with no error beyond one boot WARN.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export function isReactElementType(value: unknown): boolean {
|
|
17
|
+
if (typeof value === "function") return true;
|
|
18
|
+
if (typeof value !== "object" || value === null) return false;
|
|
19
|
+
return typeof (value as { $$typeof?: unknown }).$$typeof === "symbol";
|
|
20
|
+
}
|