@realuptime/errors 0.4.0

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,60 @@
1
+ import type { WireEvent } from "./types.ts";
2
+ /**
3
+ * PII scrub-by-default, client-side (docs/errors-plan.md, "PII
4
+ * scrub-by-default, specified"). Runs BEFORE serialization so the sensitive
5
+ * value never transits realuptime's wire; the server runs the same rules
6
+ * again as a second net (packages/db/error-scrub.ts), and the shared
7
+ * vectors in scrub-vectors.json pin all implementations -- this one, the
8
+ * Python SDK's, and the server's -- to byte-identical behavior.
9
+ *
10
+ * The rules, in the order they run over every scrubbed string:
11
+ *
12
+ * 1. JWT three-dot shape (`eyJ...` header, two more base64url segments).
13
+ * 2. Prefixed keys: sk_/pk_/rk_/ghp_/gho_/ghs_/xox?_/rua_/rue_/ru_live_
14
+ * followed by 8+ token characters.
15
+ * 3. Card-shaped digit runs: 13-19 digits, optional space/dash
16
+ * separators, passing Luhn. A digit run that fails Luhn is an order
17
+ * id, not a card, and stays.
18
+ * 4. Long hex runs (32+).
19
+ * 5. Long base64-ish runs (40+, no slash -- file paths stay readable).
20
+ *
21
+ * Scrubbed strings: the message, every request header value, the request
22
+ * path and route, every breadcrumb message and breadcrumb data value
23
+ * (Phase 3), any source-context lines a frame carries (a hardcoded secret
24
+ * in source code must not ride a stack trace), and every v2 field a
25
+ * customer can put their own bytes in -- the user id, every tag value,
26
+ * every custom-context value, every device value, and every captured local
27
+ * variable. NOT scrubbed: frame file paths, function names and line/column
28
+ * numbers (they are the product), exceptionType, release, environment, and
29
+ * tag/context KEYS (a key is a label the integrator chose, and scrubbing it
30
+ * would make an issue's tag filter depend on whether the value beside it
31
+ * happened to look like a card number).
32
+ *
33
+ * REMOVAL (value replaced whole) unless the field is allow-listed:
34
+ *
35
+ * - Headers: Authorization, Proxy-Authorization, Cookie, Set-Cookie,
36
+ * case-insensitive, allow-listed by lowercased header name.
37
+ * - Identity (v2, REA-182): `user.email` and `user.username`, allow-listed
38
+ * by those exact dotted names. `user.id` is NOT removed: an opaque
39
+ * identifier in the customer's own key space is the field that makes
40
+ * "how many users hit this" answerable, and it is not contact data.
41
+ *
42
+ * `allowFields` opts a field back in BY NAME, per field and explicit in
43
+ * code, never a global switch -- and an allowed field's value still passes
44
+ * the pattern scrub, so opting one back in can never smuggle a raw token
45
+ * through.
46
+ */
47
+ export declare const SCRUBBED = "[scrubbed]";
48
+ /** Identity fields removed whole unless allow-listed by these exact names.
49
+ * `user.id` is deliberately absent: see the module doc. */
50
+ export declare const REMOVED_USER_FIELDS: readonly ["user.email", "user.username"];
51
+ /** Applies the five pattern rules to one string. Pure; exported so the
52
+ * vector tests can drive it directly. */
53
+ export declare function scrubString(value: string): string;
54
+ /** Scrubs one event. Returns a new object; the input is never mutated (the
55
+ * caller may still be holding it, e.g. inside an exception handler).
56
+ *
57
+ * v2 fields are touched only when PRESENT: a v1 event goes through here and
58
+ * comes out with exactly the v1 key set it went in with, which is what
59
+ * makes the vector table's v1 expectations still hold byte-for-byte. */
60
+ export declare function scrubEvent(event: WireEvent, allowFields?: readonly string[]): WireEvent;
package/dist/scrub.js ADDED
@@ -0,0 +1,172 @@
1
+ /**
2
+ * PII scrub-by-default, client-side (docs/errors-plan.md, "PII
3
+ * scrub-by-default, specified"). Runs BEFORE serialization so the sensitive
4
+ * value never transits realuptime's wire; the server runs the same rules
5
+ * again as a second net (packages/db/error-scrub.ts), and the shared
6
+ * vectors in scrub-vectors.json pin all implementations -- this one, the
7
+ * Python SDK's, and the server's -- to byte-identical behavior.
8
+ *
9
+ * The rules, in the order they run over every scrubbed string:
10
+ *
11
+ * 1. JWT three-dot shape (`eyJ...` header, two more base64url segments).
12
+ * 2. Prefixed keys: sk_/pk_/rk_/ghp_/gho_/ghs_/xox?_/rua_/rue_/ru_live_
13
+ * followed by 8+ token characters.
14
+ * 3. Card-shaped digit runs: 13-19 digits, optional space/dash
15
+ * separators, passing Luhn. A digit run that fails Luhn is an order
16
+ * id, not a card, and stays.
17
+ * 4. Long hex runs (32+).
18
+ * 5. Long base64-ish runs (40+, no slash -- file paths stay readable).
19
+ *
20
+ * Scrubbed strings: the message, every request header value, the request
21
+ * path and route, every breadcrumb message and breadcrumb data value
22
+ * (Phase 3), any source-context lines a frame carries (a hardcoded secret
23
+ * in source code must not ride a stack trace), and every v2 field a
24
+ * customer can put their own bytes in -- the user id, every tag value,
25
+ * every custom-context value, every device value, and every captured local
26
+ * variable. NOT scrubbed: frame file paths, function names and line/column
27
+ * numbers (they are the product), exceptionType, release, environment, and
28
+ * tag/context KEYS (a key is a label the integrator chose, and scrubbing it
29
+ * would make an issue's tag filter depend on whether the value beside it
30
+ * happened to look like a card number).
31
+ *
32
+ * REMOVAL (value replaced whole) unless the field is allow-listed:
33
+ *
34
+ * - Headers: Authorization, Proxy-Authorization, Cookie, Set-Cookie,
35
+ * case-insensitive, allow-listed by lowercased header name.
36
+ * - Identity (v2, REA-182): `user.email` and `user.username`, allow-listed
37
+ * by those exact dotted names. `user.id` is NOT removed: an opaque
38
+ * identifier in the customer's own key space is the field that makes
39
+ * "how many users hit this" answerable, and it is not contact data.
40
+ *
41
+ * `allowFields` opts a field back in BY NAME, per field and explicit in
42
+ * code, never a global switch -- and an allowed field's value still passes
43
+ * the pattern scrub, so opting one back in can never smuggle a raw token
44
+ * through.
45
+ */
46
+ export const SCRUBBED = "[scrubbed]";
47
+ /** Headers whose value is removed whole unless the field is allow-listed. */
48
+ const REMOVED_HEADERS = new Set(["authorization", "proxy-authorization", "cookie", "set-cookie"]);
49
+ /** Identity fields removed whole unless allow-listed by these exact names.
50
+ * `user.id` is deliberately absent: see the module doc. */
51
+ export const REMOVED_USER_FIELDS = ["user.email", "user.username"];
52
+ const JWT_RE = /\beyJ[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{4,}\b/g;
53
+ const PREFIXED_KEY_RE = /\b(?:sk|pk|rk|ghp|gho|ghs|xox[a-z]|rua|rue|ru_live)_[A-Za-z0-9_-]{8,}/g;
54
+ const CARD_RE = /(?<!\d)(?:\d[ -]?){12,18}\d(?!\d)/g;
55
+ const HEX_RE = /\b[0-9a-fA-F]{32,}\b/g;
56
+ const BASE64_RE = /(?<![A-Za-z0-9+_=-])[A-Za-z0-9+_-]{40,}={0,2}/g;
57
+ function luhnValid(digits) {
58
+ let sum = 0;
59
+ let double = false;
60
+ for (let i = digits.length - 1; i >= 0; i--) {
61
+ let d = digits.charCodeAt(i) - 48;
62
+ if (double) {
63
+ d *= 2;
64
+ if (d > 9)
65
+ d -= 9;
66
+ }
67
+ sum += d;
68
+ double = !double;
69
+ }
70
+ return sum % 10 === 0;
71
+ }
72
+ /** Applies the five pattern rules to one string. Pure; exported so the
73
+ * vector tests can drive it directly. */
74
+ export function scrubString(value) {
75
+ let out = value.replace(JWT_RE, SCRUBBED);
76
+ out = out.replace(PREFIXED_KEY_RE, SCRUBBED);
77
+ out = out.replace(CARD_RE, (run) => {
78
+ const digits = run.replace(/[ -]/g, "");
79
+ return digits.length >= 13 && digits.length <= 19 && luhnValid(digits) ? SCRUBBED : run;
80
+ });
81
+ out = out.replace(HEX_RE, SCRUBBED);
82
+ out = out.replace(BASE64_RE, SCRUBBED);
83
+ return out;
84
+ }
85
+ function scrubRequest(request, allowFields) {
86
+ const out = { ...request };
87
+ if (typeof out.path === "string")
88
+ out.path = scrubString(out.path);
89
+ if (typeof out.route === "string")
90
+ out.route = scrubString(out.route);
91
+ if (out.headers) {
92
+ const headers = {};
93
+ for (const [name, value] of Object.entries(out.headers)) {
94
+ const lower = name.toLowerCase();
95
+ if (REMOVED_HEADERS.has(lower) && !allowFields.has(lower)) {
96
+ headers[name] = SCRUBBED;
97
+ }
98
+ else {
99
+ headers[name] = scrubString(value);
100
+ }
101
+ }
102
+ out.headers = headers;
103
+ }
104
+ return out;
105
+ }
106
+ function scrubBreadcrumb(crumb) {
107
+ let data = null;
108
+ if (crumb.data) {
109
+ data = {};
110
+ for (const [name, value] of Object.entries(crumb.data))
111
+ data[name] = scrubString(value);
112
+ }
113
+ return { ...crumb, message: scrubString(crumb.message), data };
114
+ }
115
+ /** v2: identity. `id` passes the pattern rules; `email` and `username` are
116
+ * removed whole unless the customer opted that exact dotted name in. An
117
+ * opted-in value still passes the pattern rules. */
118
+ function scrubUser(user, allowFields) {
119
+ const out = { ...user };
120
+ if (typeof out.id === "string")
121
+ out.id = scrubString(out.id);
122
+ for (const field of REMOVED_USER_FIELDS) {
123
+ const key = field.slice("user.".length);
124
+ const value = out[key];
125
+ if (typeof value !== "string")
126
+ continue;
127
+ out[key] = allowFields.has(field) ? scrubString(value) : SCRUBBED;
128
+ }
129
+ return out;
130
+ }
131
+ /** v2: a flat string map (tags, custom context, device, frame locals).
132
+ * Values pass the pattern rules; keys are left alone (see the module doc). */
133
+ function scrubStringMap(map) {
134
+ const out = {};
135
+ for (const [name, value] of Object.entries(map)) {
136
+ out[name] = typeof value === "string" ? scrubString(value) : value;
137
+ }
138
+ return out;
139
+ }
140
+ function scrubDevice(device) {
141
+ return scrubStringMap(device);
142
+ }
143
+ /** Scrubs one event. Returns a new object; the input is never mutated (the
144
+ * caller may still be holding it, e.g. inside an exception handler).
145
+ *
146
+ * v2 fields are touched only when PRESENT: a v1 event goes through here and
147
+ * comes out with exactly the v1 key set it went in with, which is what
148
+ * makes the vector table's v1 expectations still hold byte-for-byte. */
149
+ export function scrubEvent(event, allowFields = []) {
150
+ const allowed = new Set(allowFields.map((f) => f.toLowerCase()));
151
+ return {
152
+ ...event,
153
+ message: scrubString(event.message),
154
+ request: event.request ? scrubRequest(event.request, allowed) : event.request,
155
+ breadcrumbs: event.breadcrumbs ? event.breadcrumbs.map(scrubBreadcrumb) : event.breadcrumbs,
156
+ frames: event.frames
157
+ ? event.frames.map((frame) => frame.contextLine != null || frame.preContext?.length || frame.postContext?.length || frame.vars
158
+ ? {
159
+ ...frame,
160
+ ...(frame.contextLine != null ? { contextLine: scrubString(frame.contextLine) } : {}),
161
+ ...(frame.preContext ? { preContext: frame.preContext.map(scrubString) } : {}),
162
+ ...(frame.postContext ? { postContext: frame.postContext.map(scrubString) } : {}),
163
+ ...(frame.vars ? { vars: scrubStringMap(frame.vars) } : {}),
164
+ }
165
+ : frame)
166
+ : event.frames,
167
+ ...(event.user ? { user: scrubUser(event.user, allowed) } : {}),
168
+ ...(event.tags ? { tags: scrubStringMap(event.tags) } : {}),
169
+ ...(event.context ? { context: scrubStringMap(event.context) } : {}),
170
+ ...(event.device ? { device: scrubDevice(event.device) } : {}),
171
+ };
172
+ }
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Source-map upload core (REA-254), shared by the CLI command
3
+ * (`realuptime errors sourcemaps upload`, packages/cli/commands/errors.ts)
4
+ * and the bundler plugins beside this file (`vite.ts`, `webpack.ts`).
5
+ *
6
+ * It wraps exactly one endpoint, `POST /api/errors/v1/sourcemaps/<key>`
7
+ * (apps/web/app/api/errors/v1/sourcemaps/[key]/route.ts), and nothing else.
8
+ * The endpoint is authenticated by the project's INGEST key, so the only
9
+ * secret a CI step needs is the DSN the app already has; this module derives
10
+ * the upload URL from that DSN (or from a bare key plus an origin).
11
+ *
12
+ * Honesty rules, same as the rest of the SDK:
13
+ * - Local refusals happen BEFORE any byte is sent (a file over the per-file
14
+ * cap, more files than a release may hold, no `.map` files found), and
15
+ * the message names the file. A half-uploaded release is a symbolication
16
+ * lottery, so this module never starts an upload it already knows the
17
+ * server will refuse.
18
+ * - Every file is content-addressed: its sha256 is computed locally and
19
+ * reported in the progress output, so two runs over the same build are
20
+ * visibly the same upload. Re-uploading is idempotent server-side (a
21
+ * same-named file for the same release is replaced, never duplicated); the
22
+ * summary reports `stored` and `replaced` exactly as the server answered,
23
+ * never a local guess.
24
+ * - Zero dependencies: Node builtins only (enforced by the errors-js
25
+ * dependency-hygiene test).
26
+ */
27
+ /** Mirrors packages/db/error-sourcemaps.ts; the server is the authority and
28
+ * the upload response carries its live `limits`, these are the local
29
+ * pre-flight copies. */
30
+ export declare const MAX_SOURCEMAP_BYTES: number;
31
+ export declare const MAX_ARTIFACTS_PER_RELEASE = 50;
32
+ export declare const MAX_ARTIFACTS_PER_UPLOAD = 10;
33
+ export declare const DEFAULT_API_ORIGIN = "https://realuptime.io";
34
+ export declare class SourcemapUploadError extends Error {
35
+ /** Non-2xx answers carry the server's own status and RU- code verbatim. */
36
+ readonly httpStatus?: number;
37
+ readonly code?: string;
38
+ constructor(message: string, opts?: {
39
+ httpStatus?: number;
40
+ code?: string;
41
+ });
42
+ }
43
+ export interface UploadTarget {
44
+ /** The project DSN, e.g. `https://realuptime.io/api/errors/v1/ingest/rue_...`. */
45
+ dsn?: string;
46
+ /** A bare ingest key, used with `origin` when no DSN is at hand. */
47
+ ingestKey?: string;
48
+ /** Origin for the bare-key form; defaults to https://realuptime.io. */
49
+ origin?: string;
50
+ }
51
+ /**
52
+ * `https://host/api/errors/v1/ingest/<key>` -> `https://host/api/errors/v1/sourcemaps/<key>`.
53
+ * A DSN wins over a bare key when both are given; either is enough.
54
+ */
55
+ export declare function resolveSourcemapUploadUrl(target: UploadTarget): string;
56
+ export interface SourcemapFile {
57
+ /** The artifact name sent to the server: the path relative to the
58
+ * directory it was collected from, with `urlPrefix` (if any) in front.
59
+ * Symbolication matches this against a frame's file by path suffix
60
+ * (packages/db/error-sourcemaps.ts `artifactMatchesFrameFile`). */
61
+ name: string;
62
+ /** Absolute path on disk, for messages. */
63
+ path: string;
64
+ content: string;
65
+ bytes: number;
66
+ sha256: string;
67
+ }
68
+ /** Joins a URL-ish prefix and a relative file path with exactly one slash
69
+ * and forward slashes throughout (artifact names are compared against URL
70
+ * paths, never OS paths). */
71
+ export declare function artifactName(relativePath: string, urlPrefix?: string): string;
72
+ /**
73
+ * Collects `.map` files from each given path: a directory is walked
74
+ * recursively (node_modules and .git skipped) and each map is named relative
75
+ * to THAT directory; a file is taken as-is and named by its basename
76
+ * (relative to its own directory). Names are de-duplicated; the first
77
+ * occurrence wins and later duplicates are reported back so the caller can
78
+ * say so rather than silently dropping one.
79
+ */
80
+ export declare function collectSourcemapFiles(paths: readonly string[], options?: {
81
+ cwd?: string;
82
+ urlPrefix?: string;
83
+ }): {
84
+ files: SourcemapFile[];
85
+ duplicates: string[];
86
+ missing: string[];
87
+ };
88
+ /** Local pre-flight: the refusals the server would answer anyway, caught
89
+ * before a single batch leaves, so a bad set never half-uploads. Returns the
90
+ * first problem as a message, or null. */
91
+ export declare function preflightSourcemapFiles(files: readonly SourcemapFile[]): string | null;
92
+ export declare function batchSourcemapFiles<T>(files: readonly T[], size?: number): T[][];
93
+ export interface UploadProgress {
94
+ batch: number;
95
+ batches: number;
96
+ files: readonly SourcemapFile[];
97
+ /** Filled in after the batch's response arrives. */
98
+ stored?: number;
99
+ replaced?: number;
100
+ }
101
+ export interface UploadSummary {
102
+ release: string;
103
+ uploaded: number;
104
+ /** Summed across batches, exactly as the server answered each one. */
105
+ stored: number;
106
+ replaced: number;
107
+ /** The endpoint, with the key masked (this lands in CI logs). */
108
+ url: string;
109
+ /** The server's post-upload view of the release's files. */
110
+ releaseFiles: {
111
+ name: string;
112
+ size: number;
113
+ }[];
114
+ limits: {
115
+ maxFilesPerRelease: number;
116
+ maxBytesPerFile: number;
117
+ } | null;
118
+ files: {
119
+ name: string;
120
+ bytes: number;
121
+ sha256: string;
122
+ }[];
123
+ }
124
+ export interface UploadOptions {
125
+ url: string;
126
+ release: string;
127
+ files: readonly SourcemapFile[];
128
+ fetchImpl?: typeof fetch;
129
+ onProgress?: (progress: UploadProgress) => void;
130
+ }
131
+ /**
132
+ * Uploads in batches of MAX_ARTIFACTS_PER_UPLOAD, sequentially (the per-key
133
+ * upload budget is 10/min and the server takes a per-release lock; parallel
134
+ * batches would only race that). Stops at the first refusal and throws with
135
+ * the server's own message and code; an earlier batch that succeeded is
136
+ * reported in the thrown error's message so the caller knows the release is
137
+ * now partially updated rather than untouched.
138
+ */
139
+ export declare function uploadSourcemaps(options: UploadOptions): Promise<UploadSummary>;
140
+ /** One human line per progress event, for the CLI's stderr and the plugins'
141
+ * logger. Hashes are shortened to 12 chars; full hashes are in the summary. */
142
+ export declare function formatProgressLine(progress: UploadProgress): string;
@@ -0,0 +1,262 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readdirSync, readFileSync, statSync } from "node:fs";
3
+ import { isAbsolute, join, relative, resolve, sep } from "node:path";
4
+ /**
5
+ * Source-map upload core (REA-254), shared by the CLI command
6
+ * (`realuptime errors sourcemaps upload`, packages/cli/commands/errors.ts)
7
+ * and the bundler plugins beside this file (`vite.ts`, `webpack.ts`).
8
+ *
9
+ * It wraps exactly one endpoint, `POST /api/errors/v1/sourcemaps/<key>`
10
+ * (apps/web/app/api/errors/v1/sourcemaps/[key]/route.ts), and nothing else.
11
+ * The endpoint is authenticated by the project's INGEST key, so the only
12
+ * secret a CI step needs is the DSN the app already has; this module derives
13
+ * the upload URL from that DSN (or from a bare key plus an origin).
14
+ *
15
+ * Honesty rules, same as the rest of the SDK:
16
+ * - Local refusals happen BEFORE any byte is sent (a file over the per-file
17
+ * cap, more files than a release may hold, no `.map` files found), and
18
+ * the message names the file. A half-uploaded release is a symbolication
19
+ * lottery, so this module never starts an upload it already knows the
20
+ * server will refuse.
21
+ * - Every file is content-addressed: its sha256 is computed locally and
22
+ * reported in the progress output, so two runs over the same build are
23
+ * visibly the same upload. Re-uploading is idempotent server-side (a
24
+ * same-named file for the same release is replaced, never duplicated); the
25
+ * summary reports `stored` and `replaced` exactly as the server answered,
26
+ * never a local guess.
27
+ * - Zero dependencies: Node builtins only (enforced by the errors-js
28
+ * dependency-hygiene test).
29
+ */
30
+ /** Mirrors packages/db/error-sourcemaps.ts; the server is the authority and
31
+ * the upload response carries its live `limits`, these are the local
32
+ * pre-flight copies. */
33
+ export const MAX_SOURCEMAP_BYTES = 2 * 1024 * 1024;
34
+ export const MAX_ARTIFACTS_PER_RELEASE = 50;
35
+ export const MAX_ARTIFACTS_PER_UPLOAD = 10;
36
+ export const DEFAULT_API_ORIGIN = "https://realuptime.io";
37
+ const INGEST_KEY_SHAPE = /^rue_[0-9a-f]{32,128}$/;
38
+ export class SourcemapUploadError extends Error {
39
+ /** Non-2xx answers carry the server's own status and RU- code verbatim. */
40
+ httpStatus;
41
+ code;
42
+ constructor(message, opts = {}) {
43
+ super(message);
44
+ this.name = "SourcemapUploadError";
45
+ this.httpStatus = opts.httpStatus;
46
+ this.code = opts.code;
47
+ }
48
+ }
49
+ /**
50
+ * `https://host/api/errors/v1/ingest/<key>` -> `https://host/api/errors/v1/sourcemaps/<key>`.
51
+ * A DSN wins over a bare key when both are given; either is enough.
52
+ */
53
+ export function resolveSourcemapUploadUrl(target) {
54
+ if (target.dsn) {
55
+ let url;
56
+ try {
57
+ url = new URL(target.dsn);
58
+ }
59
+ catch {
60
+ throw new SourcemapUploadError("The DSN is not a valid URL.");
61
+ }
62
+ const match = /^(.*\/api\/errors\/v1)\/ingest\/(rue_[0-9a-f]+)\/?$/.exec(url.pathname);
63
+ if (!match) {
64
+ throw new SourcemapUploadError("The DSN does not look like a realuptime errors DSN (expected .../api/errors/v1/ingest/rue_...).");
65
+ }
66
+ return `${url.origin}${match[1]}/sourcemaps/${match[2]}`;
67
+ }
68
+ if (target.ingestKey) {
69
+ if (!INGEST_KEY_SHAPE.test(target.ingestKey)) {
70
+ throw new SourcemapUploadError("The ingest key does not look like a realuptime errors ingest key (rue_...).");
71
+ }
72
+ const origin = (target.origin ?? DEFAULT_API_ORIGIN).replace(/\/+$/, "");
73
+ return `${origin}/api/errors/v1/sourcemaps/${target.ingestKey}`;
74
+ }
75
+ throw new SourcemapUploadError("No ingest credential: pass a DSN (REALUPTIME_ERRORS_DSN) or an ingest key (REALUPTIME_ERRORS_INGEST_KEY).");
76
+ }
77
+ function walkMaps(dir, out) {
78
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
79
+ const full = join(dir, entry.name);
80
+ if (entry.isDirectory()) {
81
+ if (entry.name === "node_modules" || entry.name === ".git")
82
+ continue;
83
+ walkMaps(full, out);
84
+ }
85
+ else if (entry.isFile() && entry.name.endsWith(".map")) {
86
+ out.push(full);
87
+ }
88
+ }
89
+ }
90
+ /** Joins a URL-ish prefix and a relative file path with exactly one slash
91
+ * and forward slashes throughout (artifact names are compared against URL
92
+ * paths, never OS paths). */
93
+ export function artifactName(relativePath, urlPrefix) {
94
+ const rel = relativePath.split(sep).join("/").replace(/^\.\//, "").replace(/^\/+/, "");
95
+ if (!urlPrefix)
96
+ return rel;
97
+ const prefix = urlPrefix.replace(/^~\/?/, "").replace(/\/+$/, "");
98
+ return prefix.length > 0 ? `${prefix}/${rel}` : rel;
99
+ }
100
+ /**
101
+ * Collects `.map` files from each given path: a directory is walked
102
+ * recursively (node_modules and .git skipped) and each map is named relative
103
+ * to THAT directory; a file is taken as-is and named by its basename
104
+ * (relative to its own directory). Names are de-duplicated; the first
105
+ * occurrence wins and later duplicates are reported back so the caller can
106
+ * say so rather than silently dropping one.
107
+ */
108
+ export function collectSourcemapFiles(paths, options = {}) {
109
+ const cwd = options.cwd ?? process.cwd();
110
+ const files = [];
111
+ const duplicates = [];
112
+ const missing = [];
113
+ const seen = new Set();
114
+ for (const given of paths) {
115
+ const abs = isAbsolute(given) ? given : resolve(cwd, given);
116
+ let stat;
117
+ try {
118
+ stat = statSync(abs);
119
+ }
120
+ catch {
121
+ missing.push(given);
122
+ continue;
123
+ }
124
+ const candidates = [];
125
+ if (stat.isDirectory()) {
126
+ const found = [];
127
+ walkMaps(abs, found);
128
+ for (const full of found.sort())
129
+ candidates.push({ full, base: abs });
130
+ }
131
+ else {
132
+ candidates.push({ full: abs, base: resolve(abs, "..") });
133
+ }
134
+ for (const { full, base } of candidates) {
135
+ const name = artifactName(relative(base, full), options.urlPrefix);
136
+ if (seen.has(name)) {
137
+ duplicates.push(name);
138
+ continue;
139
+ }
140
+ seen.add(name);
141
+ const content = readFileSync(full, "utf8");
142
+ files.push({
143
+ name,
144
+ path: full,
145
+ content,
146
+ bytes: Buffer.byteLength(content, "utf8"),
147
+ sha256: createHash("sha256").update(content).digest("hex"),
148
+ });
149
+ }
150
+ }
151
+ return { files, duplicates, missing };
152
+ }
153
+ /** Local pre-flight: the refusals the server would answer anyway, caught
154
+ * before a single batch leaves, so a bad set never half-uploads. Returns the
155
+ * first problem as a message, or null. */
156
+ export function preflightSourcemapFiles(files) {
157
+ if (files.length === 0)
158
+ return "No .map files found in the given paths.";
159
+ if (files.length > MAX_ARTIFACTS_PER_RELEASE) {
160
+ return `${files.length} source maps found; a release holds at most ${MAX_ARTIFACTS_PER_RELEASE}. Narrow the paths (or pass only the maps for your entry chunks).`;
161
+ }
162
+ for (const file of files) {
163
+ if (file.bytes > MAX_SOURCEMAP_BYTES) {
164
+ return `${file.name} is ${file.bytes} bytes; the per-file limit is ${MAX_SOURCEMAP_BYTES}. Nothing was uploaded.`;
165
+ }
166
+ }
167
+ return null;
168
+ }
169
+ export function batchSourcemapFiles(files, size = MAX_ARTIFACTS_PER_UPLOAD) {
170
+ const batches = [];
171
+ for (let i = 0; i < files.length; i += size)
172
+ batches.push(files.slice(i, i + size));
173
+ return batches;
174
+ }
175
+ /**
176
+ * Uploads in batches of MAX_ARTIFACTS_PER_UPLOAD, sequentially (the per-key
177
+ * upload budget is 10/min and the server takes a per-release lock; parallel
178
+ * batches would only race that). Stops at the first refusal and throws with
179
+ * the server's own message and code; an earlier batch that succeeded is
180
+ * reported in the thrown error's message so the caller knows the release is
181
+ * now partially updated rather than untouched.
182
+ */
183
+ export async function uploadSourcemaps(options) {
184
+ const fetchImpl = options.fetchImpl ?? fetch;
185
+ const batches = batchSourcemapFiles(options.files);
186
+ let stored = 0;
187
+ let replaced = 0;
188
+ let releaseFiles = [];
189
+ let limits = null;
190
+ for (let index = 0; index < batches.length; index++) {
191
+ const batch = batches[index];
192
+ const progress = { batch: index + 1, batches: batches.length, files: batch };
193
+ options.onProgress?.(progress);
194
+ let response;
195
+ try {
196
+ response = await fetchImpl(options.url, {
197
+ method: "POST",
198
+ headers: { "content-type": "application/json" },
199
+ body: JSON.stringify({
200
+ release: options.release,
201
+ files: batch.map((file) => ({ name: file.name, content: file.content })),
202
+ }),
203
+ });
204
+ }
205
+ catch (cause) {
206
+ const reason = cause instanceof Error ? cause.message : String(cause);
207
+ throw new SourcemapUploadError(`Could not reach the upload endpoint: ${reason}${partialNote(index, batches.length)}`);
208
+ }
209
+ const text = await response.text();
210
+ let body = {};
211
+ try {
212
+ body = text.length > 0 ? JSON.parse(text) : {};
213
+ }
214
+ catch {
215
+ body = {};
216
+ }
217
+ if (!response.ok) {
218
+ const message = typeof body.error === "string" ? body.error : `Upload failed with status ${response.status}.`;
219
+ const retry = response.headers.get("retry-after");
220
+ throw new SourcemapUploadError(`${message}${retry ? ` Retry after ${retry}s.` : ""}${partialNote(index, batches.length)}`, {
221
+ httpStatus: response.status,
222
+ code: typeof body.code === "string" ? body.code : undefined,
223
+ });
224
+ }
225
+ const batchStored = typeof body.stored === "number" ? body.stored : batch.length;
226
+ const batchReplaced = typeof body.replaced === "number" ? body.replaced : 0;
227
+ stored += batchStored;
228
+ replaced += batchReplaced;
229
+ if (Array.isArray(body.files))
230
+ releaseFiles = body.files;
231
+ if (body.limits && typeof body.limits === "object")
232
+ limits = body.limits;
233
+ progress.stored = batchStored;
234
+ progress.replaced = batchReplaced;
235
+ options.onProgress?.(progress);
236
+ }
237
+ return {
238
+ release: options.release,
239
+ // The key is a secret; the summary lands in CI logs, so it is masked.
240
+ url: options.url.replace(/rue_[0-9a-f]+/, "rue_..."),
241
+ uploaded: options.files.length,
242
+ stored,
243
+ replaced,
244
+ releaseFiles,
245
+ limits,
246
+ files: options.files.map((file) => ({ name: file.name, bytes: file.bytes, sha256: file.sha256 })),
247
+ };
248
+ }
249
+ function partialNote(failedIndex, batches) {
250
+ if (failedIndex === 0)
251
+ return " Nothing was uploaded.";
252
+ return ` Batches 1-${failedIndex} of ${batches} were already stored; the release is partially updated. Re-run to finish (re-uploads replace, never duplicate).`;
253
+ }
254
+ /** One human line per progress event, for the CLI's stderr and the plugins'
255
+ * logger. Hashes are shortened to 12 chars; full hashes are in the summary. */
256
+ export function formatProgressLine(progress) {
257
+ const names = progress.files.map((file) => `${file.name} (sha256 ${file.sha256.slice(0, 12)}, ${file.bytes} bytes)`);
258
+ if (progress.stored === undefined) {
259
+ return `uploading batch ${progress.batch}/${progress.batches}: ${names.join(", ")}`;
260
+ }
261
+ return `batch ${progress.batch}/${progress.batches} ok: stored ${progress.stored}, replaced ${progress.replaced}`;
262
+ }