@teamscala/service-boot 0.23.536 → 0.23.537
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 +306 -304
- package/src/apply-logger-config.ts +2 -1
- package/src/build-client-assets/hydrate-bundle.ts +3 -2
- package/src/build-client-assets/manifest.ts +0 -38
- package/src/build-client-assets/sourcemaps-upload.test.ts +2 -4
- package/src/build-client-assets/sourcemaps-upload.ts +244 -138
- package/src/build-client-assets.ts +9 -46
- package/src/load-service-install-row.ts +1 -1
- package/src/mount-server-packages.ts +6 -12
- package/src/orpc-contract-rows.json +34 -17
- package/src/orpc-contract-rows.test.ts +1450 -0
- package/src/pg-boss-mount.ts +124 -15
- package/src/read-html-template.ts +1 -25
- package/src/register-page-server-client.ts +2 -2
- package/src/register-registry-page-route/live-loader-config.test.ts +0 -12
- package/src/register-registry-page-route/live-loader-config.ts +1 -1
- package/src/register-registry-page-route/resolve-request-graph.ts +1 -1
- package/src/register-registry-page-route.ts +3 -3
- package/src/two-phase-full-app.ts +3 -3
- package/src/ui-service.ts +11 -36
- package/src/orpc-openapi-gate.test.ts +0 -751
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import { setMinLevel } from "@teamscala/logger/creator";
|
|
7
7
|
import { configureFileSink } from "@teamscala/logger/file-sink";
|
|
8
|
+
import { substituteHostPaths } from "@teamscala/os/host-paths";
|
|
8
9
|
|
|
9
10
|
export function applyLoggerConfig(config: {
|
|
10
11
|
minLevel?: "debug" | "info" | "warn" | "error";
|
|
@@ -16,7 +17,7 @@ export function applyLoggerConfig(config: {
|
|
|
16
17
|
setMinLevel(config.minLevel);
|
|
17
18
|
}
|
|
18
19
|
configureFileSink({
|
|
19
|
-
logDir: config.logDir,
|
|
20
|
+
logDir: config.logDir ? substituteHostPaths(config.logDir) : undefined,
|
|
20
21
|
maxFileSize: config.maxFileSize,
|
|
21
22
|
maxFiles: config.maxFiles,
|
|
22
23
|
});
|
|
@@ -8,6 +8,7 @@ import { join } from "node:path";
|
|
|
8
8
|
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
9
9
|
import { removeStaleHashedFiles } from "./hashed-output.ts";
|
|
10
10
|
import {
|
|
11
|
+
ASSET_PUBLIC_PREFIX,
|
|
11
12
|
uploadSourceMaps,
|
|
12
13
|
type SourceMapUploadResult,
|
|
13
14
|
} from "./sourcemaps-upload.ts";
|
|
@@ -71,7 +72,7 @@ export async function buildHydrateBundle(
|
|
|
71
72
|
plugins: [resilientBlockPlugin],
|
|
72
73
|
splitting: true,
|
|
73
74
|
sourcemap: "external",
|
|
74
|
-
publicPath:
|
|
75
|
+
publicPath: ASSET_PUBLIC_PREFIX,
|
|
75
76
|
external: ["*.css"],
|
|
76
77
|
});
|
|
77
78
|
} catch (e) {
|
|
@@ -195,7 +196,7 @@ export async function buildHydrateBundle(
|
|
|
195
196
|
.map((o) => o.path.split("/").pop() as string),
|
|
196
197
|
);
|
|
197
198
|
const sourceMaps = await uploadSourceMaps(cwd, outDir, hash, log, currentOutputs);
|
|
198
|
-
return { hydratePath:
|
|
199
|
+
return { hydratePath: `${ASSET_PUBLIC_PREFIX}${hashedName}`, sourceMaps };
|
|
199
200
|
}
|
|
200
201
|
|
|
201
202
|
async function removeStaleChunks(
|
|
@@ -4,57 +4,19 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { join } from "node:path";
|
|
7
|
-
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
8
7
|
import type { SourceMapUploadResult } from "./sourcemaps-upload.ts";
|
|
9
8
|
|
|
10
9
|
export const MANIFEST_FILE = "asset-manifest.json";
|
|
11
10
|
|
|
12
11
|
export interface AssetManifest {
|
|
13
|
-
// Optional because a TOMBSTONE from a build that failed with no prior
|
|
14
|
-
// success carries NO paths — there are no real assets to name, and inventing
|
|
15
|
-
// URLs nothing built is the deleted fallback class (no-shims).
|
|
16
|
-
// `readManifest` returns null unless both are present, so a path-less
|
|
17
|
-
// tombstone reads as unbuilt everywhere (verification, readiness, render).
|
|
18
12
|
stylesPath?: string;
|
|
19
13
|
hydratePath?: string;
|
|
20
|
-
// TOMBSTONE fields — present iff the last build FAILED (no-silent-reverts).
|
|
21
|
-
// `buildFailed: true` + reason + timestamp are written by performBuild
|
|
22
|
-
// before it re-throws; every boot rebuilds unconditionally, so the failed
|
|
23
|
-
// build is retried by construction. read-html-template reads these to stamp
|
|
24
|
-
// the served HTML with the failure status (operator-visible signal that
|
|
25
|
-
// the bundle is stale, not fresh).
|
|
26
14
|
buildFailed?: boolean;
|
|
27
15
|
buildFailedReason?: string;
|
|
28
16
|
buildFailedAt?: string;
|
|
29
|
-
// VERIFIED outcome of the last source-map upload attempt for THIS build's
|
|
30
|
-
// release (`uploaded` = every response checked 2xx; `failed` names each
|
|
31
|
-
// rejection in `failures`; `skipped` = no SENTRY_AUTH_TOKEN/SENTRY_URL).
|
|
32
|
-
// Every boot rebuilds and re-attempts the upload, so a failed upload is
|
|
33
|
-
// retried until it verifies — an unuploaded release must not silently
|
|
34
|
-
// persist forever.
|
|
35
17
|
sourceMaps?: SourceMapUploadResult;
|
|
36
18
|
}
|
|
37
19
|
|
|
38
|
-
export async function readClientBuildStatus(
|
|
39
|
-
cwd = process.cwd(),
|
|
40
|
-
): Promise<{ failed: true; reason?: string; at?: string } | { failed: false }> {
|
|
41
|
-
const path = join(cwd, "dist", "client", MANIFEST_FILE);
|
|
42
|
-
if (!(await Bun.file(path).exists())) return { failed: false };
|
|
43
|
-
try {
|
|
44
|
-
const parsed = (await Bun.file(path).json()) as Partial<AssetManifest>;
|
|
45
|
-
if (parsed.buildFailed === true) {
|
|
46
|
-
return {
|
|
47
|
-
failed: true,
|
|
48
|
-
reason: parsed.buildFailedReason,
|
|
49
|
-
at: parsed.buildFailedAt,
|
|
50
|
-
};
|
|
51
|
-
}
|
|
52
|
-
} catch (error) {
|
|
53
|
-
getAppLogger().warn("asset manifest unreadable — reporting not-failed", error);
|
|
54
|
-
}
|
|
55
|
-
return { failed: false };
|
|
56
|
-
}
|
|
57
|
-
|
|
58
20
|
export async function readManifest(outDir: string): Promise<AssetManifest | null> {
|
|
59
21
|
const path = join(outDir, MANIFEST_FILE);
|
|
60
22
|
if (!(await Bun.file(path).exists())) return null;
|
|
@@ -44,8 +44,7 @@ afterAll(async () => {
|
|
|
44
44
|
});
|
|
45
45
|
|
|
46
46
|
afterEach(() => {
|
|
47
|
-
delete process.env.
|
|
48
|
-
delete process.env.SENTRY_URL;
|
|
47
|
+
delete process.env.SENTRY_DSN;
|
|
49
48
|
});
|
|
50
49
|
|
|
51
50
|
describe("uploadSourceMaps — skip path (absent env)", () => {
|
|
@@ -81,8 +80,7 @@ describe("uploadSourceMaps — skip path (absent env)", () => {
|
|
|
81
80
|
|
|
82
81
|
describe("uploadCachedSourceMaps — the fingerprint-skip retry path", () => {
|
|
83
82
|
test("no cached maps → failed, naming the release and the reason", async () => {
|
|
84
|
-
process.env.
|
|
85
|
-
process.env.SENTRY_URL = "https://glitchtip.test";
|
|
83
|
+
process.env.SENTRY_DSN = "https://cred@glitchtip.test/scala";
|
|
86
84
|
const cwd = await makeDir();
|
|
87
85
|
const result = await uploadCachedSourceMaps(cwd, "deadbeef", noopLog);
|
|
88
86
|
expect(result.status).toBe("failed");
|
|
@@ -3,12 +3,11 @@
|
|
|
3
3
|
* @status handwritten
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
|
|
7
6
|
import { mkdir, readdir, rename, rm } from "node:fs/promises";
|
|
8
7
|
import { join } from "node:path";
|
|
8
|
+
import { resilientFetch } from "@teamscala/http/resilient-fetch";
|
|
9
9
|
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
10
|
-
import {
|
|
11
|
-
import { spawnSync } from "@teamscala/os/spawn";
|
|
10
|
+
import { createSafeFallback } from "@teamscala/safe-fallback/create-safe-fallback";
|
|
12
11
|
|
|
13
12
|
export type SourceMapUploadStatus = "uploaded" | "failed" | "skipped";
|
|
14
13
|
|
|
@@ -20,6 +19,8 @@ export interface SourceMapUploadResult {
|
|
|
20
19
|
at: string;
|
|
21
20
|
}
|
|
22
21
|
|
|
22
|
+
export const ASSET_PUBLIC_PREFIX = "/assets/";
|
|
23
|
+
|
|
23
24
|
const UPLOAD_TIMEOUT_MS = 30_000;
|
|
24
25
|
|
|
25
26
|
export function sourceMapsCacheDir(cwd: string, release: string): string {
|
|
@@ -30,6 +31,183 @@ export function releaseFromHydratePath(hydratePath: string): string | null {
|
|
|
30
31
|
return /hydrate-([a-z0-9]+)\.js/.exec(hydratePath)?.[1] ?? null;
|
|
31
32
|
}
|
|
32
33
|
|
|
34
|
+
export function sourceMapArtifactName(mapFileName: string): string {
|
|
35
|
+
return `~${ASSET_PUBLIC_PREFIX}${mapFileName.replace(/\.map$/, "")}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface ReleaseTarget {
|
|
39
|
+
origin: string;
|
|
40
|
+
credential: string;
|
|
41
|
+
project: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function sentryTarget(dsn: string | undefined = process.env.SENTRY_DSN): ReleaseTarget | null {
|
|
45
|
+
const value = dsn?.trim();
|
|
46
|
+
if (!value) return null;
|
|
47
|
+
const schemeEnd = value.indexOf("://");
|
|
48
|
+
if (schemeEnd <= 0) return null;
|
|
49
|
+
const remainder = value.slice(schemeEnd + 3);
|
|
50
|
+
const at = remainder.indexOf("@");
|
|
51
|
+
if (at <= 0) return null;
|
|
52
|
+
const hostAndProject = remainder.slice(at + 1);
|
|
53
|
+
const slash = hostAndProject.lastIndexOf("/");
|
|
54
|
+
if (slash <= 0 || slash === hostAndProject.length - 1) return null;
|
|
55
|
+
return {
|
|
56
|
+
origin: `${value.slice(0, schemeEnd)}://${hostAndProject.slice(0, slash)}`,
|
|
57
|
+
credential: remainder.slice(0, at),
|
|
58
|
+
project: hostAndProject.slice(slash + 1),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export class SourceMapTransportError extends Error {
|
|
63
|
+
readonly cause: unknown;
|
|
64
|
+
|
|
65
|
+
constructor(message: string, cause: unknown) {
|
|
66
|
+
super(message);
|
|
67
|
+
this.name = "SourceMapTransportError";
|
|
68
|
+
this.cause = cause;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function describe(error: unknown): string {
|
|
73
|
+
return error instanceof Error ? error.message : String(error);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function releaseFilesUrl(target: ReleaseTarget, release: string): string {
|
|
77
|
+
const org = process.env.SENTRY_ORG ?? "scala";
|
|
78
|
+
return `${target.origin}/api/0/projects/${encodeURIComponent(org)}/${encodeURIComponent(target.project)}/releases/${encodeURIComponent(release)}/files/`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function readReleaseFiles(target: ReleaseTarget, release: string): Promise<Response> {
|
|
82
|
+
return resilientFetch("temps", releaseFilesUrl(target, release), {
|
|
83
|
+
method: "GET",
|
|
84
|
+
signal: AbortSignal.timeout(UPLOAD_TIMEOUT_MS),
|
|
85
|
+
headers: { Authorization: `Bearer ${target.credential}` },
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function postReleaseFile(
|
|
90
|
+
target: ReleaseTarget,
|
|
91
|
+
release: string,
|
|
92
|
+
artifactName: string,
|
|
93
|
+
data: ArrayBuffer,
|
|
94
|
+
): Promise<Response> {
|
|
95
|
+
const form = new FormData();
|
|
96
|
+
form.append("name", artifactName);
|
|
97
|
+
form.append("file", new Blob([data]), artifactName.slice(artifactName.lastIndexOf("/") + 1));
|
|
98
|
+
return resilientFetch("temps", releaseFilesUrl(target, release), {
|
|
99
|
+
method: "POST",
|
|
100
|
+
body: form,
|
|
101
|
+
signal: AbortSignal.timeout(UPLOAD_TIMEOUT_MS),
|
|
102
|
+
headers: { Authorization: `Bearer ${target.credential}` },
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const readBackFallback = createSafeFallback<{ names: string[] }>({
|
|
107
|
+
name: "sourcemaps:read-back",
|
|
108
|
+
onFailure: "fail-closed-error",
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const transportFallback = createSafeFallback<Response>({
|
|
112
|
+
name: "sourcemaps:transport",
|
|
113
|
+
onFailure: "fail-closed-error",
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
const mapTransferFallback = createSafeFallback<void>({
|
|
117
|
+
name: "sourcemaps:map-transfer",
|
|
118
|
+
onFailure: "fail-closed-warn",
|
|
119
|
+
degradedResult: undefined,
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
function isTransportFailure(error: unknown): boolean {
|
|
123
|
+
return error instanceof SourceMapTransportError;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const transportCallParams = { isExpectedOutcome: isTransportFailure };
|
|
127
|
+
|
|
128
|
+
async function postMap(
|
|
129
|
+
target: ReleaseTarget,
|
|
130
|
+
release: string,
|
|
131
|
+
artifactName: string,
|
|
132
|
+
data: ArrayBuffer,
|
|
133
|
+
): Promise<string | null> {
|
|
134
|
+
const response = await postTransportCall(
|
|
135
|
+
`POST ${artifactName}`,
|
|
136
|
+
() => postReleaseFile(target, release, artifactName, data),
|
|
137
|
+
);
|
|
138
|
+
if (response.ok) return null;
|
|
139
|
+
return `${artifactName} → HTTP ${response.status}: ${(await response.text()).slice(0, 300)}`;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function releaseFileNames(target: ReleaseTarget, release: string): Promise<string[]> {
|
|
143
|
+
const response = await postTransportCall(
|
|
144
|
+
"GET release files",
|
|
145
|
+
() => readReleaseFiles(target, release),
|
|
146
|
+
);
|
|
147
|
+
if (!response.ok) {
|
|
148
|
+
throw new SourceMapTransportError(
|
|
149
|
+
`GET release files → HTTP ${response.status}: ${(await response.text()).slice(0, 300)}`,
|
|
150
|
+
null,
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
return readBackFallback.call(async () => {
|
|
154
|
+
const body: unknown = await response.json();
|
|
155
|
+
if (!Array.isArray(body)) throw new Error("release file list is not an array");
|
|
156
|
+
return {
|
|
157
|
+
names: body.flatMap((row) =>
|
|
158
|
+
row && typeof row === "object" && typeof (row as { name?: unknown }).name === "string"
|
|
159
|
+
? [(row as { name: string }).name]
|
|
160
|
+
: [],
|
|
161
|
+
),
|
|
162
|
+
};
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function postTransportCall(
|
|
167
|
+
label: string,
|
|
168
|
+
operation: () => Promise<Response>,
|
|
169
|
+
): Promise<Response> {
|
|
170
|
+
return transportFallback
|
|
171
|
+
.call(
|
|
172
|
+
async () => {
|
|
173
|
+
const response = await operation();
|
|
174
|
+
if (response.status >= 500) {
|
|
175
|
+
throw new SourceMapTransportError(`${label} → HTTP ${response.status}`, null);
|
|
176
|
+
}
|
|
177
|
+
return response;
|
|
178
|
+
},
|
|
179
|
+
transportCallParams,
|
|
180
|
+
)
|
|
181
|
+
.catch((error: unknown) => {
|
|
182
|
+
throw new SourceMapTransportError(
|
|
183
|
+
error instanceof SourceMapTransportError
|
|
184
|
+
? `${label} → ${error.message}`
|
|
185
|
+
: `${label} → ${describe(error)}`,
|
|
186
|
+
error,
|
|
187
|
+
);
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function retainOrDeleteMap(
|
|
192
|
+
outDir: string,
|
|
193
|
+
name: string,
|
|
194
|
+
cacheDir: string,
|
|
195
|
+
keep: boolean,
|
|
196
|
+
log: ReturnType<typeof getAppLogger>,
|
|
197
|
+
): Promise<void> {
|
|
198
|
+
await mapTransferFallback
|
|
199
|
+
.call(async () => {
|
|
200
|
+
if (keep) {
|
|
201
|
+
await rename(join(outDir, name), join(cacheDir, name));
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
await rm(join(outDir, name), { force: true });
|
|
205
|
+
})
|
|
206
|
+
.catch(async () => {
|
|
207
|
+
await rm(join(outDir, name), { force: true }).catch(() => {});
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
33
211
|
async function moveMapsToCache(
|
|
34
212
|
cwd: string,
|
|
35
213
|
outDir: string,
|
|
@@ -39,37 +217,28 @@ async function moveMapsToCache(
|
|
|
39
217
|
): Promise<string[]> {
|
|
40
218
|
const cacheDir = sourceMapsCacheDir(cwd, release);
|
|
41
219
|
const moved: string[] = [];
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
} catch (error) {
|
|
58
|
-
log.warn("sourcemap cache move failed — map deleted (never serve source)", {
|
|
59
|
-
name,
|
|
60
|
-
error: error instanceof Error ? error.message : String(error),
|
|
61
|
-
});
|
|
62
|
-
await rm(join(outDir, name), { force: true });
|
|
63
|
-
}
|
|
220
|
+
const entries = await readdir(outDir, "utf-8");
|
|
221
|
+
const maps = entries.filter((name) => name.endsWith(".map"));
|
|
222
|
+
if (maps.length === 0) return moved;
|
|
223
|
+
await mkdir(cacheDir, { recursive: true });
|
|
224
|
+
for (const name of maps) {
|
|
225
|
+
const keep = currentOutputs.has(name.replace(/\.map$/, ""));
|
|
226
|
+
await retainOrDeleteMap(outDir, name, cacheDir, keep, log).then(
|
|
227
|
+
() => {
|
|
228
|
+
if (keep) moved.push(name);
|
|
229
|
+
else log.warn("stale sourcemap removed from served root (older release)", { name });
|
|
230
|
+
},
|
|
231
|
+
(error: unknown) => {
|
|
232
|
+
log.warn("sourcemap cache move failed", { name, error: describe(error) });
|
|
233
|
+
},
|
|
234
|
+
);
|
|
64
235
|
}
|
|
65
236
|
return moved;
|
|
66
237
|
}
|
|
67
238
|
|
|
68
|
-
function
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
if (!token || !url) return null;
|
|
72
|
-
return { token, url };
|
|
239
|
+
async function readCachedMapNames(cacheDir: string): Promise<string[]> {
|
|
240
|
+
const entries = await readdir(cacheDir, "utf-8");
|
|
241
|
+
return entries.filter((name) => name.endsWith(".map"));
|
|
73
242
|
}
|
|
74
243
|
|
|
75
244
|
export async function uploadCachedSourceMaps(
|
|
@@ -78,14 +247,14 @@ export async function uploadCachedSourceMaps(
|
|
|
78
247
|
log: ReturnType<typeof getAppLogger>,
|
|
79
248
|
): Promise<SourceMapUploadResult> {
|
|
80
249
|
const at = new Date().toISOString();
|
|
81
|
-
const auth = authEnv();
|
|
82
250
|
const cacheDir = sourceMapsCacheDir(cwd, release);
|
|
83
|
-
|
|
251
|
+
const target = sentryTarget();
|
|
252
|
+
if (!target) {
|
|
84
253
|
return { status: "skipped", uploaded: 0, total: 0, failures: [], at };
|
|
85
254
|
}
|
|
86
255
|
let names: string[] = [];
|
|
87
256
|
try {
|
|
88
|
-
names =
|
|
257
|
+
names = await readCachedMapNames(cacheDir);
|
|
89
258
|
} catch {
|
|
90
259
|
names = [];
|
|
91
260
|
}
|
|
@@ -95,12 +264,12 @@ export async function uploadCachedSourceMaps(
|
|
|
95
264
|
uploaded: 0,
|
|
96
265
|
total: 0,
|
|
97
266
|
failures: [
|
|
98
|
-
`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`,
|
|
267
|
+
`no cached source maps for release ${release} at ${cacheDir} — the build that produced them predates retention or they were removed; the next bundle rebuild regenerates them`,
|
|
99
268
|
],
|
|
100
269
|
at,
|
|
101
270
|
};
|
|
102
271
|
}
|
|
103
|
-
return uploadMapsFromCache(cacheDir, names, release,
|
|
272
|
+
return uploadMapsFromCache(cacheDir, names, release, target, log, at);
|
|
104
273
|
}
|
|
105
274
|
|
|
106
275
|
export async function uploadSourceMaps(
|
|
@@ -111,137 +280,74 @@ export async function uploadSourceMaps(
|
|
|
111
280
|
currentOutputs: ReadonlySet<string> = new Set(),
|
|
112
281
|
): Promise<SourceMapUploadResult> {
|
|
113
282
|
const at = new Date().toISOString();
|
|
114
|
-
const auth = authEnv();
|
|
115
|
-
// Inject debug IDs into the served bundles + maps (if glitchtip-cli is
|
|
116
|
-
// present). Runs on outDir BEFORE the cache move so both the served
|
|
117
|
-
// bundle and its retained map carry the same debug id. The CLI is
|
|
118
|
-
// optional — only used for the inject step.
|
|
119
|
-
const cli = join(userHome(), ".local", "bin", "glitchtip-cli");
|
|
120
|
-
if (await Bun.file(cli).exists()) {
|
|
121
|
-
spawnSync({
|
|
122
|
-
name: "glitchtip-cli-sourcemaps-inject",
|
|
123
|
-
command: [cli, "sourcemaps", "inject", outDir],
|
|
124
|
-
env: { ...process.env, SENTRY_AUTH_TOKEN: auth?.token ?? "" },
|
|
125
|
-
timeoutMs: 60_000,
|
|
126
|
-
captureStderr: true,
|
|
127
|
-
});
|
|
128
|
-
}
|
|
129
|
-
// The move is unconditional — the served root must never hold `.map`
|
|
130
|
-
// files, whether or not an upload will follow.
|
|
131
283
|
const names = await moveMapsToCache(cwd, outDir, release, currentOutputs, log);
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
284
|
+
const cacheDir = sourceMapsCacheDir(cwd, release);
|
|
285
|
+
if (names.length === 0) {
|
|
286
|
+
log.warn("[build-client-assets] no source maps to upload for this build", { release, cacheDir });
|
|
287
|
+
return { status: "skipped", uploaded: 0, total: 0, failures: [], at };
|
|
288
|
+
}
|
|
289
|
+
const target = sentryTarget();
|
|
290
|
+
if (!target) {
|
|
135
291
|
log.warn(
|
|
136
|
-
`[build-client-assets] source maps NOT uploaded —
|
|
292
|
+
`[build-client-assets] source maps NOT uploaded — SENTRY_DSN absent (maps retained at ${cacheDir} for retry once configured)`,
|
|
137
293
|
{ release, mapsRetained: names.length },
|
|
138
294
|
);
|
|
139
295
|
return { status: "skipped", uploaded: 0, total: names.length, failures: [], at };
|
|
140
296
|
}
|
|
141
|
-
return uploadMapsFromCache(
|
|
142
|
-
sourceMapsCacheDir(cwd, release),
|
|
143
|
-
names,
|
|
144
|
-
release,
|
|
145
|
-
auth,
|
|
146
|
-
log,
|
|
147
|
-
at,
|
|
148
|
-
);
|
|
297
|
+
return uploadMapsFromCache(cacheDir, names, release, target, log, at);
|
|
149
298
|
}
|
|
150
299
|
|
|
151
300
|
async function uploadMapsFromCache(
|
|
152
301
|
cacheDir: string,
|
|
153
302
|
names: string[],
|
|
154
303
|
release: string,
|
|
155
|
-
|
|
304
|
+
target: ReleaseTarget,
|
|
156
305
|
log: ReturnType<typeof getAppLogger>,
|
|
157
306
|
at: string,
|
|
158
307
|
): Promise<SourceMapUploadResult> {
|
|
159
|
-
const
|
|
160
|
-
const project = process.env.SENTRY_PROJECT ?? "scala-hub";
|
|
161
|
-
const headers = { Authorization: `Bearer ${auth.token}` };
|
|
308
|
+
const expected = names.map(sourceMapArtifactName);
|
|
162
309
|
const failures: string[] = [];
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
failures.push(
|
|
175
|
-
`release create ${release} → HTTP ${releaseRes.status}: ${body}`,
|
|
176
|
-
);
|
|
177
|
-
}
|
|
178
|
-
} catch (err) {
|
|
179
|
-
failures.push(
|
|
180
|
-
`release create ${release} → ${err instanceof Error ? err.message : String(err)}`,
|
|
181
|
-
);
|
|
310
|
+
for (const name of names) {
|
|
311
|
+
const artifactName = sourceMapArtifactName(name);
|
|
312
|
+
const data = await Bun.file(join(cacheDir, name))
|
|
313
|
+
.arrayBuffer()
|
|
314
|
+
.catch((error: unknown) => {
|
|
315
|
+
failures.push(`${artifactName} → cached map unreadable: ${describe(error)}`);
|
|
316
|
+
return null;
|
|
317
|
+
});
|
|
318
|
+
if (data === null) continue;
|
|
319
|
+
const failure = await postMap(target, release, artifactName, data);
|
|
320
|
+
if (failure) failures.push(failure);
|
|
182
321
|
}
|
|
183
|
-
|
|
184
|
-
// protocol, debug-id matching). The plain release-files POST endpoints
|
|
185
|
-
// return 405 on GlitchTip — measured live 2026-09-01 against
|
|
186
|
-
// glitchtip.scala.business: every map failed HTTP 405 while the same 57
|
|
187
|
-
// maps uploaded clean through the CLI, which is why client stacks had
|
|
188
|
-
// never resolved despite the pipeline "running". The CLI reads
|
|
189
|
-
// SENTRY_URL/SENTRY_AUTH_TOKEN from its env; verify its own summary line
|
|
190
|
-
// rather than assuming the exit code alone.
|
|
191
|
-
const cli = join(userHome(), ".local", "bin", "glitchtip-cli");
|
|
192
|
-
if (!(await Bun.file(cli).exists())) {
|
|
322
|
+
const stored = await releaseFileNames(target, release).catch((error: unknown) => {
|
|
193
323
|
failures.push(
|
|
194
|
-
|
|
324
|
+
`${describe(error)} (release ${release}, ${releaseFilesUrl(target, release)})`,
|
|
195
325
|
);
|
|
196
|
-
|
|
197
|
-
return { status: "failed", uploaded: 0, total: names.length, failures, at };
|
|
198
|
-
}
|
|
199
|
-
const proc = spawnSync({
|
|
200
|
-
name: "glitchtip-cli-sourcemaps-upload",
|
|
201
|
-
command: [
|
|
202
|
-
cli,
|
|
203
|
-
"sourcemaps",
|
|
204
|
-
"upload",
|
|
205
|
-
"--org",
|
|
206
|
-
org,
|
|
207
|
-
"--project",
|
|
208
|
-
project,
|
|
209
|
-
"--release",
|
|
210
|
-
release,
|
|
211
|
-
"--url-prefix",
|
|
212
|
-
"~",
|
|
213
|
-
cacheDir,
|
|
214
|
-
],
|
|
215
|
-
env: { ...process.env, SENTRY_AUTH_TOKEN: auth.token, SENTRY_URL: auth.url },
|
|
216
|
-
timeoutMs: 600_000,
|
|
217
|
-
captureStderr: true,
|
|
326
|
+
return null;
|
|
218
327
|
});
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
const
|
|
224
|
-
|
|
328
|
+
if (stored === null) {
|
|
329
|
+
logNamedFailures(log, release, target.project, expected.length, 0, failures, cacheDir);
|
|
330
|
+
return { status: "failed", uploaded: 0, total: expected.length, failures, at };
|
|
331
|
+
}
|
|
332
|
+
const missing = expected.filter((name) => !stored.includes(name));
|
|
333
|
+
for (const name of missing) {
|
|
225
334
|
failures.push(
|
|
226
|
-
|
|
335
|
+
`${name} → absent from release ${release} after upload; temps stores the name verbatim and resolves stacks by it, so a map stored under another name cannot symbolicate`,
|
|
227
336
|
);
|
|
228
337
|
}
|
|
229
|
-
const
|
|
230
|
-
|
|
231
|
-
: "failed";
|
|
338
|
+
const uploaded = expected.length - missing.length;
|
|
339
|
+
const status: SourceMapUploadStatus = failures.length === 0 ? "uploaded" : "failed";
|
|
232
340
|
if (status === "uploaded") {
|
|
233
|
-
log.info("[build-client-assets] source maps uploaded to
|
|
341
|
+
log.info("[build-client-assets] source maps uploaded to temps (read back)", {
|
|
234
342
|
release,
|
|
235
|
-
project,
|
|
343
|
+
project: target.project,
|
|
236
344
|
uploaded,
|
|
237
|
-
total:
|
|
345
|
+
total: expected.length,
|
|
238
346
|
});
|
|
239
347
|
} else {
|
|
240
|
-
|
|
241
|
-
// unfinished. Each failure carries its cause and the named remedy.
|
|
242
|
-
logNamedFailures(log, release, project, names.length, uploaded, failures, cacheDir);
|
|
348
|
+
logNamedFailures(log, release, target.project, expected.length, uploaded, failures, cacheDir);
|
|
243
349
|
}
|
|
244
|
-
return { status, uploaded, total:
|
|
350
|
+
return { status, uploaded, total: expected.length, failures, at };
|
|
245
351
|
}
|
|
246
352
|
|
|
247
353
|
function logNamedFailures(
|
|
@@ -261,4 +367,4 @@ function logNamedFailures(
|
|
|
261
367
|
failures,
|
|
262
368
|
cacheDir,
|
|
263
369
|
});
|
|
264
|
-
}
|
|
370
|
+
}
|