@logbrew/sdk 0.1.3 → 0.1.4
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/README.md +336 -2
- package/examples/real-user-smoke.cjs +15 -1
- package/examples/real-user-smoke.mjs +15 -1
- package/index.cjs +1358 -28
- package/index.d.cts +461 -6
- package/index.d.ts +461 -6
- package/index.js +13 -0
- package/issue-stack.cjs +149 -0
- package/opentelemetry.cjs +1080 -0
- package/package.json +28 -1
- package/release-artifacts-build.cjs +227 -0
- package/release-artifacts-common.js +82 -0
- package/release-artifacts-symbolication.js +550 -0
- package/release-artifacts-upload.js +380 -0
- package/release-artifacts.js +766 -0
- package/support-ticket.cjs +175 -0
- package/trace-context.cjs +217 -0
- package/vite-release-artifacts.cjs +152 -0
- package/vite-release-artifacts.d.cts +44 -0
- package/vite-release-artifacts.d.ts +44 -0
- package/vite-release-artifacts.js +6 -0
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
/* global AbortController, clearTimeout */
|
|
2
|
+
|
|
3
|
+
import { Buffer } from "node:buffer";
|
|
4
|
+
import crypto from "node:crypto";
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import net from "node:net";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
byteSize,
|
|
11
|
+
normalizeProjectId,
|
|
12
|
+
printJson,
|
|
13
|
+
readJsonObject,
|
|
14
|
+
requireBuildDir,
|
|
15
|
+
safeResolve,
|
|
16
|
+
sha256File,
|
|
17
|
+
stableJson
|
|
18
|
+
} from "./release-artifacts-common.js";
|
|
19
|
+
|
|
20
|
+
const DEFAULT_UPLOAD_TOKEN_ENV = "LOGBREW_RELEASE_ARTIFACT_TOKEN";
|
|
21
|
+
const NON_RETRYABLE_UPLOAD_STATUSES = new Set([400, 401, 403, 413]);
|
|
22
|
+
const RETRYABLE_UPLOAD_STATUSES = new Set([408, 429]);
|
|
23
|
+
const SCRIPT_VERSION = "0.1.0";
|
|
24
|
+
|
|
25
|
+
function parseOptions(args) {
|
|
26
|
+
const spec = {
|
|
27
|
+
"build-dir": "string",
|
|
28
|
+
manifest: "string",
|
|
29
|
+
endpoint: "string",
|
|
30
|
+
"token-env": "string",
|
|
31
|
+
"dry-run": "boolean",
|
|
32
|
+
"allow-hosted": "boolean",
|
|
33
|
+
"max-retries": "string",
|
|
34
|
+
"retry-delay": "string",
|
|
35
|
+
timeout: "string"
|
|
36
|
+
};
|
|
37
|
+
const options = {};
|
|
38
|
+
const positionals = [];
|
|
39
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
40
|
+
const arg = args[index];
|
|
41
|
+
if (!arg.startsWith("--")) {
|
|
42
|
+
positionals.push(arg);
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
const name = arg.slice(2);
|
|
46
|
+
const kind = spec[name];
|
|
47
|
+
if (!kind) {
|
|
48
|
+
throw new Error(`unknown option: --${name}`);
|
|
49
|
+
}
|
|
50
|
+
if (kind === "boolean") {
|
|
51
|
+
options[name] = true;
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
const value = args[index + 1];
|
|
55
|
+
if (value === undefined || value.startsWith("--")) {
|
|
56
|
+
throw new Error(`missing value for --${name}`);
|
|
57
|
+
}
|
|
58
|
+
options[name] = value;
|
|
59
|
+
index += 1;
|
|
60
|
+
}
|
|
61
|
+
if (positionals.length > 0) {
|
|
62
|
+
throw new Error(`unexpected positional argument: ${positionals[0]}`);
|
|
63
|
+
}
|
|
64
|
+
return options;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function requireOption(options, name) {
|
|
68
|
+
const value = options[name];
|
|
69
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
70
|
+
throw new Error(`--${name} is required`);
|
|
71
|
+
}
|
|
72
|
+
return value.trim();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function parseNonNegativeInteger(value, label) {
|
|
76
|
+
const trimmed = value.trim();
|
|
77
|
+
if (!/^\d+$/u.test(trimmed)) {
|
|
78
|
+
throw new Error(`${label} must be a non-negative integer`);
|
|
79
|
+
}
|
|
80
|
+
return Number.parseInt(trimmed, 10);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function parseNonNegativeNumber(value, label) {
|
|
84
|
+
const trimmed = value.trim();
|
|
85
|
+
const parsed = Number(trimmed);
|
|
86
|
+
if (!Number.isFinite(parsed) || parsed < 0) {
|
|
87
|
+
throw new Error(`${label} must be a non-negative number`);
|
|
88
|
+
}
|
|
89
|
+
return parsed;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function endpointWithoutQuery(endpoint) {
|
|
93
|
+
const parsed = new URL(endpoint);
|
|
94
|
+
return `${parsed.protocol}//${parsed.host}${parsed.pathname || "/"}`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function parseEndpoint(endpoint) {
|
|
98
|
+
let parsed;
|
|
99
|
+
try {
|
|
100
|
+
parsed = new URL(endpoint);
|
|
101
|
+
} catch (error) {
|
|
102
|
+
throw new Error(`upload endpoint is not a valid URL: ${error.message}`, { cause: error });
|
|
103
|
+
}
|
|
104
|
+
if (!["http:", "https:"].includes(parsed.protocol)) {
|
|
105
|
+
throw new Error("release artifact upload proof endpoint must use http or https");
|
|
106
|
+
}
|
|
107
|
+
return parsed;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function isLoopbackEndpoint(parsed) {
|
|
111
|
+
const hostname = parsed.hostname.toLowerCase();
|
|
112
|
+
return (
|
|
113
|
+
hostname === "localhost" ||
|
|
114
|
+
hostname === "[::1]" ||
|
|
115
|
+
hostname === "::1" ||
|
|
116
|
+
(net.isIP(hostname) !== 0 && (hostname.startsWith("127.") || hostname === "::1"))
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function requireUploadEndpoint(endpoint, allowHosted) {
|
|
121
|
+
const parsed = parseEndpoint(endpoint);
|
|
122
|
+
if (isLoopbackEndpoint(parsed)) {
|
|
123
|
+
return parsed;
|
|
124
|
+
}
|
|
125
|
+
if (!allowHosted) {
|
|
126
|
+
throw new Error("release artifact hosted upload requires explicit --allow-hosted; use loopback endpoints for local proof");
|
|
127
|
+
}
|
|
128
|
+
if (parsed.protocol !== "https:") {
|
|
129
|
+
throw new Error("hosted release artifact upload endpoints must use https");
|
|
130
|
+
}
|
|
131
|
+
if (parsed.username || parsed.password) {
|
|
132
|
+
throw new Error("hosted release artifact upload endpoints must not include embedded auth values");
|
|
133
|
+
}
|
|
134
|
+
if (parsed.search || parsed.hash) {
|
|
135
|
+
throw new Error("hosted release artifact upload endpoints must not include query strings or fragments");
|
|
136
|
+
}
|
|
137
|
+
return parsed;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function quoteMultipartValue(value) {
|
|
141
|
+
return value.replaceAll("\\", "\\\\").replaceAll('"', '\\"');
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function encodeMultipart(manifest, files) {
|
|
145
|
+
const boundary = `logbrew-${crypto.randomUUID().replaceAll("-", "")}`;
|
|
146
|
+
const chunks = [];
|
|
147
|
+
const appendPart = (name, filename, contentType, bytes) => {
|
|
148
|
+
chunks.push(Buffer.from(`--${boundary}\r\n`, "ascii"));
|
|
149
|
+
chunks.push(
|
|
150
|
+
Buffer.from(
|
|
151
|
+
`Content-Disposition: form-data; name="${quoteMultipartValue(name)}"; filename="${quoteMultipartValue(filename)}"\r\n`,
|
|
152
|
+
"utf8"
|
|
153
|
+
)
|
|
154
|
+
);
|
|
155
|
+
chunks.push(Buffer.from(`Content-Type: ${contentType}\r\n\r\n`, "ascii"));
|
|
156
|
+
chunks.push(Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes));
|
|
157
|
+
chunks.push(Buffer.from("\r\n", "ascii"));
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
appendPart("manifest", "manifest.json", "application/json", Buffer.from(stableJson(manifest), "utf8"));
|
|
161
|
+
for (const [name, filePath] of files) {
|
|
162
|
+
appendPart(name, path.basename(filePath), "application/octet-stream", fs.readFileSync(filePath));
|
|
163
|
+
}
|
|
164
|
+
chunks.push(Buffer.from(`--${boundary}--\r\n`, "ascii"));
|
|
165
|
+
return { body: Buffer.concat(chunks), boundary };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function classifyUploadStatus(status) {
|
|
169
|
+
if (status >= 200 && status < 300) {
|
|
170
|
+
return "uploaded";
|
|
171
|
+
}
|
|
172
|
+
if (status === 401 || status === 403) {
|
|
173
|
+
return "auth_failed";
|
|
174
|
+
}
|
|
175
|
+
if (NON_RETRYABLE_UPLOAD_STATUSES.has(status)) {
|
|
176
|
+
return "validation_failed";
|
|
177
|
+
}
|
|
178
|
+
if (RETRYABLE_UPLOAD_STATUSES.has(status) || status >= 500) {
|
|
179
|
+
return "retryable_error";
|
|
180
|
+
}
|
|
181
|
+
return "upload_failed";
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function sleep(seconds) {
|
|
185
|
+
return new Promise((resolve) => {
|
|
186
|
+
setTimeout(resolve, seconds * 1000);
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async function postMultipart(endpoint, token, body, boundary, timeoutSeconds) {
|
|
191
|
+
const controller = new AbortController();
|
|
192
|
+
const timeout = setTimeout(() => controller.abort(), timeoutSeconds * 1000);
|
|
193
|
+
try {
|
|
194
|
+
const response = await fetch(endpoint, {
|
|
195
|
+
method: "POST",
|
|
196
|
+
headers: {
|
|
197
|
+
Authorization: `Bearer ${token}`,
|
|
198
|
+
"Content-Type": `multipart/form-data; boundary=${boundary}`,
|
|
199
|
+
"User-Agent": `logbrew-release-artifact-verifier/${SCRIPT_VERSION}`
|
|
200
|
+
},
|
|
201
|
+
body,
|
|
202
|
+
signal: controller.signal
|
|
203
|
+
});
|
|
204
|
+
await response.arrayBuffer();
|
|
205
|
+
return response.status;
|
|
206
|
+
} finally {
|
|
207
|
+
clearTimeout(timeout);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function uploadWithRetries({ endpoint, token, body, boundary, maxRetries, retryDelaySeconds, timeoutSeconds }) {
|
|
212
|
+
const attempts = [];
|
|
213
|
+
for (let attempt = 1; attempt <= maxRetries + 1; attempt += 1) {
|
|
214
|
+
let result;
|
|
215
|
+
try {
|
|
216
|
+
const httpStatus = await postMultipart(endpoint, token, body, boundary, timeoutSeconds);
|
|
217
|
+
result = classifyUploadStatus(httpStatus);
|
|
218
|
+
attempts.push({ attempt, httpStatus, result });
|
|
219
|
+
} catch {
|
|
220
|
+
result = "retryable_error";
|
|
221
|
+
attempts.push({ attempt, result });
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (result === "uploaded") {
|
|
225
|
+
break;
|
|
226
|
+
}
|
|
227
|
+
if (result !== "retryable_error" || attempt > maxRetries) {
|
|
228
|
+
break;
|
|
229
|
+
}
|
|
230
|
+
if (retryDelaySeconds > 0) {
|
|
231
|
+
await sleep(retryDelaySeconds);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
const finalResult = attempts.at(-1)?.result ?? "upload_failed";
|
|
235
|
+
return {
|
|
236
|
+
status: finalResult,
|
|
237
|
+
attempts,
|
|
238
|
+
retryCount: Math.max(0, attempts.length - 1)
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function requireReadyJavaScriptManifest(manifest, requireProjectId) {
|
|
243
|
+
if (manifest.artifactType !== "javascript_source_map_manifest") {
|
|
244
|
+
throw new Error("only javascript_source_map_manifest uploads are supported by this verifier");
|
|
245
|
+
}
|
|
246
|
+
if (!manifest.validation || manifest.validation.status !== "ready") {
|
|
247
|
+
throw new Error("manifest validation status must be ready before upload");
|
|
248
|
+
}
|
|
249
|
+
if (!Array.isArray(manifest.artifacts) || manifest.artifacts.length === 0) {
|
|
250
|
+
throw new Error("manifest must contain at least one JavaScript release artifact");
|
|
251
|
+
}
|
|
252
|
+
normalizeProjectId(
|
|
253
|
+
manifest.projectId,
|
|
254
|
+
requireProjectId
|
|
255
|
+
? "hosted release artifact uploads require manifest projectId as a UUID"
|
|
256
|
+
: "manifest projectId must be a UUID when provided",
|
|
257
|
+
requireProjectId,
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function requireArtifactFile(artifact, buildDir, section, requiredFields) {
|
|
262
|
+
const payload = artifact[section];
|
|
263
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
|
|
264
|
+
throw new Error(`artifact is missing ${section}`);
|
|
265
|
+
}
|
|
266
|
+
for (const field of requiredFields) {
|
|
267
|
+
if (payload[field] === undefined || payload[field] === "") {
|
|
268
|
+
throw new Error(`${section} is missing ${field}`);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
const filePath = safeResolve(path.join(buildDir, String(payload.path)), buildDir);
|
|
272
|
+
if (!filePath || !fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) {
|
|
273
|
+
throw new Error(`${section} file is missing: ${payload.path}`);
|
|
274
|
+
}
|
|
275
|
+
if (byteSize(filePath) !== Number(payload.byteSize)) {
|
|
276
|
+
throw new Error(`${section} byte size changed after manifest creation: ${payload.path}`);
|
|
277
|
+
}
|
|
278
|
+
if (sha256File(filePath) !== String(payload.artifactSha256)) {
|
|
279
|
+
throw new Error(`${section} sha256 changed after manifest creation: ${payload.path}`);
|
|
280
|
+
}
|
|
281
|
+
return filePath;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function collectUploadFiles(manifest, buildDir, requireProjectId) {
|
|
285
|
+
requireReadyJavaScriptManifest(manifest, requireProjectId);
|
|
286
|
+
const files = [];
|
|
287
|
+
for (const [index, artifact] of manifest.artifacts.entries()) {
|
|
288
|
+
if (!artifact || typeof artifact !== "object" || Array.isArray(artifact)) {
|
|
289
|
+
throw new Error("artifact entries must be JSON objects");
|
|
290
|
+
}
|
|
291
|
+
files.push([
|
|
292
|
+
`minified_source_${index}`,
|
|
293
|
+
requireArtifactFile(artifact, buildDir, "minifiedSource", ["path", "artifactSha256", "byteSize"])
|
|
294
|
+
]);
|
|
295
|
+
files.push([
|
|
296
|
+
`source_map_${index}`,
|
|
297
|
+
requireArtifactFile(artifact, buildDir, "sourceMap", ["path", "artifactSha256", "byteSize"])
|
|
298
|
+
]);
|
|
299
|
+
}
|
|
300
|
+
return files;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function buildUploadReport({ endpoint, manifest, files, dryRun }) {
|
|
304
|
+
return {
|
|
305
|
+
uploader: { name: "logbrew-js-release-artifact-upload-verifier", version: SCRIPT_VERSION },
|
|
306
|
+
endpoint: endpointWithoutQuery(endpoint),
|
|
307
|
+
dryRun,
|
|
308
|
+
release: manifest.release,
|
|
309
|
+
environment: manifest.environment,
|
|
310
|
+
service: manifest.service,
|
|
311
|
+
artifactType: manifest.artifactType,
|
|
312
|
+
artifactCount: manifest.artifacts.length,
|
|
313
|
+
filePartCount: files.length
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function exitCodeForUploadStatus(status) {
|
|
318
|
+
return {
|
|
319
|
+
uploaded: 0,
|
|
320
|
+
dry_run: 0,
|
|
321
|
+
auth_missing: 2,
|
|
322
|
+
auth_failed: 3,
|
|
323
|
+
validation_failed: 4
|
|
324
|
+
}[status] ?? 5;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
export async function runUploadJs(args) {
|
|
328
|
+
const options = parseOptions(args);
|
|
329
|
+
try {
|
|
330
|
+
const endpoint = requireOption(options, "endpoint");
|
|
331
|
+
const parsedEndpoint = requireUploadEndpoint(endpoint, Boolean(options["allow-hosted"]));
|
|
332
|
+
const buildDir = requireBuildDir(requireOption(options, "build-dir"));
|
|
333
|
+
const manifestPath = path.resolve(requireOption(options, "manifest"));
|
|
334
|
+
if (!fs.existsSync(manifestPath)) {
|
|
335
|
+
throw new Error(`manifest file does not exist: ${options.manifest}`);
|
|
336
|
+
}
|
|
337
|
+
const manifest = readJsonObject(manifestPath, "manifest");
|
|
338
|
+
const files = collectUploadFiles(manifest, buildDir, !isLoopbackEndpoint(parsedEndpoint));
|
|
339
|
+
const dryRun = Boolean(options["dry-run"]);
|
|
340
|
+
const report = buildUploadReport({ endpoint, manifest, files, dryRun });
|
|
341
|
+
|
|
342
|
+
if (dryRun) {
|
|
343
|
+
printJson({ ...report, status: "dry_run", attempts: [], retryCount: 0 });
|
|
344
|
+
return 0;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const tokenEnv = (options["token-env"] ?? DEFAULT_UPLOAD_TOKEN_ENV).trim();
|
|
348
|
+
if (tokenEnv === "") {
|
|
349
|
+
throw new Error("--token-env must not be empty");
|
|
350
|
+
}
|
|
351
|
+
const token = (process.env[tokenEnv] ?? "").trim();
|
|
352
|
+
if (!token) {
|
|
353
|
+
printJson({ ...report, status: "auth_missing", attempts: [], retryCount: 0, auth: { tokenEnv } });
|
|
354
|
+
return exitCodeForUploadStatus("auth_missing");
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const maxRetries = parseNonNegativeInteger(options["max-retries"] ?? "2", "--max-retries");
|
|
358
|
+
const retryDelaySeconds = parseNonNegativeNumber(options["retry-delay"] ?? "0.25", "--retry-delay");
|
|
359
|
+
const timeoutSeconds = parseNonNegativeNumber(options.timeout ?? "5", "--timeout");
|
|
360
|
+
const { body, boundary } = encodeMultipart(manifest, files);
|
|
361
|
+
const uploadReport = await uploadWithRetries({
|
|
362
|
+
endpoint,
|
|
363
|
+
token,
|
|
364
|
+
body,
|
|
365
|
+
boundary,
|
|
366
|
+
maxRetries,
|
|
367
|
+
retryDelaySeconds,
|
|
368
|
+
timeoutSeconds
|
|
369
|
+
});
|
|
370
|
+
printJson({ ...report, ...uploadReport });
|
|
371
|
+
return exitCodeForUploadStatus(uploadReport.status);
|
|
372
|
+
} catch (error) {
|
|
373
|
+
printJson({
|
|
374
|
+
status: "validation_failed",
|
|
375
|
+
uploader: { name: "logbrew-js-release-artifact-upload-verifier", version: SCRIPT_VERSION },
|
|
376
|
+
validation: { errors: [error.message] }
|
|
377
|
+
});
|
|
378
|
+
return exitCodeForUploadStatus("validation_failed");
|
|
379
|
+
}
|
|
380
|
+
}
|