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