backtrace-console 0.0.3 → 0.0.5

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,278 @@
1
+ const path = require("node:path");
2
+ const {
3
+ DEFAULT_QUERY_URL,
4
+ DEFAULT_BACKTRACE_PROJECT,
5
+ DEFAULT_BACKTRACE_FORMAT,
6
+ DEFAULT_WORKDIR,
7
+ DEFAULT_PROXY,
8
+ DEFAULT_SELECT,
9
+ DEFAULT_LIMIT,
10
+ DEFAULT_DOWNLOAD_CONCURRENCY,
11
+ DEFAULT_RETRIES,
12
+ DEFAULT_STORAGE_DIR,
13
+ } = require("./constants");
14
+ const { nowRange, formatBatchName } = require("./utils");
15
+
16
+ function normalizeBacktraceQueryUrl(value) {
17
+ const rawValue = String(value || "").trim();
18
+ const fallbackUrl = new URL(DEFAULT_QUERY_URL);
19
+ const url = rawValue ? new URL(rawValue) : new URL(DEFAULT_QUERY_URL);
20
+ if (!url.pathname || url.pathname === "/") {
21
+ url.pathname = "/api/query";
22
+ }
23
+ if (!url.searchParams.get("project")) {
24
+ url.searchParams.set("project", process.env.BACKTRACE_PROJECT || DEFAULT_BACKTRACE_PROJECT);
25
+ }
26
+ if (!url.searchParams.get("format")) {
27
+ url.searchParams.set("format", process.env.BACKTRACE_FORMAT || DEFAULT_BACKTRACE_FORMAT);
28
+ }
29
+ if (!url.searchParams.get("token") && fallbackUrl.searchParams.get("token")) {
30
+ url.searchParams.set("token", fallbackUrl.searchParams.get("token"));
31
+ }
32
+ return url.toString();
33
+ }
34
+
35
+ function normalizeOptionalInteger(value, fallback) {
36
+ if (value === undefined || value === null || value === "") {
37
+ return fallback;
38
+ }
39
+ return Number(value);
40
+ }
41
+
42
+ // 顶层工具只暴露固定命令集合,便于尽早做参数校验。
43
+ const SUPPORTED_COMMANDS = ["list", "fingerprint", "collect-all", "summarize-fingerprint-errors"];
44
+
45
+ // 输出旧入口 codex_demo.js 和库调用共用的帮助信息。
46
+ function printUsage() {
47
+ console.log(
48
+ [
49
+ "Usage:",
50
+ " backtrace <command> [options]",
51
+ "",
52
+ "Commands:",
53
+ " list list local fingerprint directories and meta information",
54
+ " fingerprint query all grouped fingerprints and print them",
55
+ " collect-all download all objects only, do not generate reports",
56
+ " summarize-fingerprint-errors summarize and deduplicate error lines for one local fingerprint directory",
57
+ " fix-plan generate a repair plan from the smallest AboveLand.LOG under one fingerprint",
58
+ "",
59
+ "Environment:",
60
+ " BACKTRACE_USERNAME required Backtrace console login username",
61
+ " BACKTRACE_PASSWORD required Backtrace console login password",
62
+ ` BACKTRACE_PROJECT default: ${DEFAULT_BACKTRACE_PROJECT}`,
63
+ ` BACKTRACE_FORMAT default: ${DEFAULT_BACKTRACE_FORMAT}`,
64
+ "",
65
+ "Options:",
66
+ ` --query-url <url> default base url: ${DEFAULT_QUERY_URL}`,
67
+ ` --workdir <dir> default: ${DEFAULT_WORKDIR}`,
68
+ " --fingerprint <value[,value2,...]> optional query filter; collect supports multiple values",
69
+ " --from <unixTs> default: 1",
70
+ " --to <unixTs> default: now",
71
+ ` --limit <n> default: ${DEFAULT_LIMIT}`,
72
+ " --offset <n> default: 0",
73
+ ` --select <a,b,c> default: ${DEFAULT_SELECT.join(",")}`,
74
+ ` --proxy <url> default: ${DEFAULT_PROXY}`,
75
+ ` --retries <n> default: ${DEFAULT_RETRIES} (0 means infinite retry)`,
76
+ ` --download-concurrency <n> default: ${DEFAULT_DOWNLOAD_CONCURRENCY}`,
77
+ " --storage-dir <dir> default: ./fingerprints",
78
+ " --report-dir <dir> deprecated alias of --storage-dir",
79
+ " --logs-dir <dir> deprecated alias of --storage-dir",
80
+ " --help show this message",
81
+ ].join("\n"),
82
+ );
83
+ }
84
+
85
+ // 对标准化选项做一次集中校验,
86
+ // 后续流程就可以默认命令前置条件和数值范围都已成立。
87
+ function validateOptions(options) {
88
+ if (!SUPPORTED_COMMANDS.includes(options.command)) {
89
+ throw new Error(`Command must be one of: ${SUPPORTED_COMMANDS.join(", ")}`);
90
+ }
91
+ if (options.command === "summarize-fingerprint-errors" && !options.fingerprint) {
92
+ throw new Error("--fingerprint is required for summarize-fingerprint-errors");
93
+ }
94
+ if (!["list", "summarize-fingerprint-errors"].includes(options.command) && (!options.username || !options.password)) {
95
+ throw new Error("BACKTRACE_USERNAME and BACKTRACE_PASSWORD are required");
96
+ }
97
+ if (!Number.isInteger(options.limit) || options.limit <= 0) {
98
+ throw new Error("--limit must be a positive integer");
99
+ }
100
+ if (!Number.isInteger(options.offset) || options.offset < 0) {
101
+ throw new Error("--offset must be a non-negative integer");
102
+ }
103
+ if (!Number.isInteger(options.retries) || options.retries < 0) {
104
+ throw new Error("--retries must be a non-negative integer; use 0 for infinite retry");
105
+ }
106
+ if (!Number.isInteger(options.downloadConcurrency) || options.downloadConcurrency <= 0) {
107
+ throw new Error("--download-concurrency must be a positive integer");
108
+ }
109
+ if (options.select.length === 0) {
110
+ throw new Error("--select must contain at least one field");
111
+ }
112
+ return options;
113
+ }
114
+
115
+ // 把旧版 Node 入口传入的 argv 解析为已校验的运行时选项。
116
+ function parseArgs(argv) {
117
+ const range = nowRange();
118
+ const batchName = formatBatchName(new Date());
119
+ // 这里先填满默认值,后续只覆盖用户显式传入的字段。
120
+ const options = {
121
+ command: "",
122
+ queryUrl: normalizeBacktraceQueryUrl(DEFAULT_QUERY_URL),
123
+ workdir: DEFAULT_WORKDIR,
124
+ objectId: "",
125
+ fingerprint: "",
126
+ from: range.from,
127
+ to: range.to,
128
+ limit: DEFAULT_LIMIT,
129
+ offset: 0,
130
+ select: [...DEFAULT_SELECT],
131
+ proxy: DEFAULT_PROXY,
132
+ retries: DEFAULT_RETRIES,
133
+ downloadConcurrency: DEFAULT_DOWNLOAD_CONCURRENCY,
134
+ batchName,
135
+ storageDir: path.resolve(DEFAULT_STORAGE_DIR),
136
+ reportDir: path.resolve(DEFAULT_STORAGE_DIR),
137
+ logsDir: path.resolve(DEFAULT_STORAGE_DIR),
138
+ username: process.env.BACKTRACE_USERNAME || "",
139
+ password: process.env.BACKTRACE_PASSWORD || "",
140
+ };
141
+
142
+ let index = 0;
143
+ if (argv[0] && !argv[0].startsWith("-")) {
144
+ // 第一个非 flag 参数视为主命令。
145
+ options.command = argv[0];
146
+ index = 1;
147
+ }
148
+
149
+ // 按顺序解析参数,因为这里采用的是简单的成对参数约定。
150
+ for (; index < argv.length; index += 1) {
151
+ const arg = argv[index];
152
+
153
+ if (arg === "--query-url") {
154
+ options.queryUrl = argv[index + 1] || "";
155
+ index += 1;
156
+ continue;
157
+ }
158
+ if (arg === "--workdir") {
159
+ options.workdir = argv[index + 1] || "";
160
+ index += 1;
161
+ continue;
162
+ }
163
+ if (arg === "--object-id") {
164
+ options.objectId = argv[index + 1] || "";
165
+ index += 1;
166
+ continue;
167
+ }
168
+ if (arg === "--fingerprint") {
169
+ options.fingerprint = argv[index + 1] || "";
170
+ index += 1;
171
+ continue;
172
+ }
173
+ if (arg === "--from") {
174
+ options.from = argv[index + 1] || options.from;
175
+ index += 1;
176
+ continue;
177
+ }
178
+ if (arg === "--to") {
179
+ options.to = argv[index + 1] || options.to;
180
+ index += 1;
181
+ continue;
182
+ }
183
+ if (arg === "--limit") {
184
+ options.limit = Number(argv[index + 1] || String(DEFAULT_LIMIT));
185
+ index += 1;
186
+ continue;
187
+ }
188
+ if (arg === "--offset") {
189
+ options.offset = Number(argv[index + 1] || "0");
190
+ index += 1;
191
+ continue;
192
+ }
193
+ if (arg === "--select") {
194
+ options.select = (argv[index + 1] || DEFAULT_SELECT.join(","))
195
+ .split(",")
196
+ .map((item) => item.trim())
197
+ .filter(Boolean);
198
+ index += 1;
199
+ continue;
200
+ }
201
+ if (arg === "--proxy") {
202
+ options.proxy = argv[index + 1] || "";
203
+ index += 1;
204
+ continue;
205
+ }
206
+ if (arg === "--retries") {
207
+ options.retries = Number(argv[index + 1] || String(DEFAULT_RETRIES));
208
+ index += 1;
209
+ continue;
210
+ }
211
+ if (arg === "--download-concurrency") {
212
+ options.downloadConcurrency = Number(argv[index + 1] || String(DEFAULT_DOWNLOAD_CONCURRENCY));
213
+ index += 1;
214
+ continue;
215
+ }
216
+ if (arg === "--storage-dir" || arg === "--report-dir" || arg === "--logs-dir") {
217
+ options.storageDir = path.resolve(argv[index + 1] || DEFAULT_STORAGE_DIR);
218
+ options.reportDir = options.storageDir;
219
+ options.logsDir = options.storageDir;
220
+ index += 1;
221
+ continue;
222
+ }
223
+ if (arg === "--help" || arg === "-h") {
224
+ printUsage();
225
+ process.exit(0);
226
+ }
227
+ }
228
+
229
+ options.queryUrl = normalizeBacktraceQueryUrl(options.queryUrl || DEFAULT_QUERY_URL);
230
+ options.proxy = String(options.proxy || DEFAULT_PROXY || "").trim();
231
+
232
+ return validateOptions(options);
233
+ }
234
+
235
+ // 给直接调用库的场景构造选项,跳过原始 argv 解析。
236
+ function createOptions(overrides = {}) {
237
+ const range = nowRange();
238
+ const batchName = overrides.batchName || formatBatchName(new Date());
239
+ // 支持调用方直接传数组,也支持传逗号分隔字符串。
240
+ const selectValue = Array.isArray(overrides.select)
241
+ ? overrides.select
242
+ : String(overrides.select || DEFAULT_SELECT.join(","))
243
+ .split(",")
244
+ .map((item) => item.trim())
245
+ .filter(Boolean);
246
+
247
+ const options = {
248
+ command: overrides.command || "",
249
+ queryUrl: normalizeBacktraceQueryUrl(overrides.queryUrl || DEFAULT_QUERY_URL),
250
+ workdir: overrides.workdir || DEFAULT_WORKDIR,
251
+ objectId: overrides.objectId ? String(overrides.objectId) : "",
252
+ fingerprint: overrides.fingerprint ? String(overrides.fingerprint) : "",
253
+ from: String(overrides.from || range.from),
254
+ to: String(overrides.to || range.to),
255
+ limit: normalizeOptionalInteger(overrides.limit, DEFAULT_LIMIT),
256
+ offset: normalizeOptionalInteger(overrides.offset, 0),
257
+ select: selectValue.length > 0 ? selectValue : [...DEFAULT_SELECT],
258
+ proxy: String(overrides.proxy || DEFAULT_PROXY || "").trim(),
259
+ retries: normalizeOptionalInteger(overrides.retries, DEFAULT_RETRIES),
260
+ downloadConcurrency: normalizeOptionalInteger(overrides.downloadConcurrency, DEFAULT_DOWNLOAD_CONCURRENCY),
261
+ batchName,
262
+ storageDir: path.resolve(overrides.storageDir || overrides.reportDir || overrides.logsDir || DEFAULT_STORAGE_DIR),
263
+ reportDir: path.resolve(overrides.storageDir || overrides.reportDir || overrides.logsDir || DEFAULT_STORAGE_DIR),
264
+ logsDir: path.resolve(overrides.storageDir || overrides.reportDir || overrides.logsDir || DEFAULT_STORAGE_DIR),
265
+ username: overrides.username || process.env.BACKTRACE_USERNAME || "",
266
+ password: overrides.password || process.env.BACKTRACE_PASSWORD || "",
267
+ };
268
+
269
+ return validateOptions(options);
270
+ }
271
+
272
+ module.exports = {
273
+ SUPPORTED_COMMANDS,
274
+ printUsage,
275
+ parseArgs,
276
+ createOptions,
277
+ validateOptions,
278
+ };
@@ -0,0 +1,117 @@
1
+ const fs = require("node:fs/promises");
2
+ const fsNative = require("node:fs");
3
+ const path = require("node:path");
4
+ const { logStep } = require("./utils");
5
+
6
+ function formatBytes(bytes) {
7
+ const numeric = Number(bytes || 0);
8
+ if (!Number.isFinite(numeric) || numeric <= 0) return "0 B";
9
+ const units = ["B", "KB", "MB", "GB"];
10
+ let value = numeric;
11
+ let unitIndex = 0;
12
+ while (value >= 1024 && unitIndex < units.length - 1) {
13
+ value /= 1024;
14
+ unitIndex += 1;
15
+ }
16
+ return `${value.toFixed(value >= 10 || unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`;
17
+ }
18
+
19
+ function formatAttachmentSpeed(bytes, elapsedMs) {
20
+ const seconds = Math.max(0.001, Number(elapsedMs || 0) / 1000);
21
+ return `${formatBytes(Number(bytes || 0) / seconds)}/s`;
22
+ }
23
+
24
+ function formatAttachmentProgress(downloadedBytes, totalBytes) {
25
+ if (!totalBytes) return formatBytes(downloadedBytes);
26
+ const percent = Math.min(100, Math.max(0, (downloadedBytes / totalBytes) * 100));
27
+ return `${formatBytes(downloadedBytes)} / ${formatBytes(totalBytes)} (${percent.toFixed(1)}%)`;
28
+ }
29
+
30
+ function formatAttachmentStatus(downloadedBytes, totalBytes, elapsedMs) {
31
+ const progress = formatAttachmentProgress(downloadedBytes, totalBytes);
32
+ const transferred = `${formatBytes(downloadedBytes)} transferred`;
33
+ if (!elapsedMs) return `${progress}, ${transferred}`;
34
+ return [progress, transferred, formatAttachmentSpeed(downloadedBytes, elapsedMs)].join(", ");
35
+ }
36
+
37
+ async function downloadAttachment(session, queryUrl, objectHexId, targetDir, attachment, buildSiblingUrl) {
38
+ const savedPath = path.join(targetDir, attachment.name);
39
+ const existing = await fs.stat(savedPath).catch(() => null);
40
+ if (existing && existing.isFile()) {
41
+ logStep("download", "attachment already exists, skipping", savedPath);
42
+ return { ...attachment, savedPath, skipped: true, sizeBytes: existing.size || 0 };
43
+ }
44
+ const source = new URL(queryUrl);
45
+ const url = buildSiblingUrl(queryUrl, "/api/get");
46
+ const project = source.searchParams.get("project");
47
+ if (project) url.searchParams.set("project", project);
48
+ url.searchParams.set("object", objectHexId);
49
+ url.searchParams.set("attachment_id", attachment.id);
50
+ url.searchParams.set("attachment_inline", "false");
51
+ logStep("download", "downloading attachment", { objectId: objectHexId, attachment: attachment.name });
52
+ const response = await session.requestStream(url.toString(), {}, `download ${objectHexId}/${attachment.name}`);
53
+ const totalBytes = Number(response.headers.get("content-length") || attachment.size || 0);
54
+ const fileStream = fsNative.createWriteStream(savedPath);
55
+ const startedAt = Date.now();
56
+ let downloadedBytes = 0;
57
+ let progressTimer = null;
58
+ const printProgress = () => {
59
+ const elapsedMs = Math.max(1, Date.now() - startedAt);
60
+ logStep("download", `file ${formatAttachmentStatus(downloadedBytes, totalBytes, elapsedMs)}`, {
61
+ objectId: objectHexId,
62
+ attachment: attachment.name,
63
+ });
64
+ };
65
+ try {
66
+ printProgress();
67
+ progressTimer = setInterval(printProgress, 1000);
68
+ for await (const chunk of response.body) {
69
+ downloadedBytes += chunk.length;
70
+ if (!fileStream.write(chunk)) {
71
+ await new Promise((resolve) => fileStream.once("drain", resolve));
72
+ }
73
+ }
74
+ await new Promise((resolve, reject) => {
75
+ fileStream.end((error) => (error ? reject(error) : resolve()));
76
+ });
77
+ } catch (error) {
78
+ if (progressTimer) clearInterval(progressTimer);
79
+ fileStream.destroy();
80
+ await fs.unlink(savedPath).catch(() => {});
81
+ throw error;
82
+ }
83
+ if (progressTimer) clearInterval(progressTimer);
84
+ printProgress();
85
+ logStep("download", "attachment saved", savedPath);
86
+ return { ...attachment, savedPath, sizeBytes: downloadedBytes };
87
+ }
88
+
89
+ async function downloadAttachmentWithRetry(session, queryUrl, objectHexId, targetDir, attachment, buildSiblingUrl, maxAttempts = 5) {
90
+ let lastError = null;
91
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
92
+ try {
93
+ return await downloadAttachment(session, queryUrl, objectHexId, targetDir, attachment, buildSiblingUrl);
94
+ } catch (error) {
95
+ lastError = error;
96
+ logStep("download", "attachment download failed", {
97
+ objectId: objectHexId,
98
+ attachment: attachment.name,
99
+ attempt,
100
+ maxAttempts,
101
+ message: error instanceof Error ? error.message : String(error),
102
+ });
103
+ }
104
+ }
105
+ logStep("download", "attachment skipped after retries", {
106
+ objectId: objectHexId,
107
+ attachment: attachment.name,
108
+ maxAttempts,
109
+ message: lastError instanceof Error ? lastError.message : String(lastError || ""),
110
+ });
111
+ return { ...attachment, savedPath: "", skipped: true, failed: true };
112
+ }
113
+
114
+ module.exports = {
115
+ downloadAttachment: downloadAttachment,
116
+ downloadAttachmentWithRetry: downloadAttachmentWithRetry,
117
+ };
@@ -0,0 +1,229 @@
1
+ const { logStep, retry } = require("./utils");
2
+
3
+ // 进程级 token 缓存,key = loginUrl,有效期 1 小时
4
+ const TOKEN_TTL_MS = 60 * 60 * 1000;
5
+ const tokenCache = new Map(); // loginUrl -> { token, expiresAt }
6
+
7
+ function getCachedToken(loginUrl) {
8
+ const entry = tokenCache.get(loginUrl);
9
+ if (!entry) return null;
10
+ if (Date.now() >= entry.expiresAt) {
11
+ tokenCache.delete(loginUrl);
12
+ return null;
13
+ }
14
+ return entry.token;
15
+ }
16
+
17
+ function setCachedToken(loginUrl, token) {
18
+ tokenCache.set(loginUrl, { token, expiresAt: Date.now() + TOKEN_TTL_MS });
19
+ }
20
+
21
+ function clearCachedToken(loginUrl) {
22
+ tokenCache.delete(loginUrl);
23
+ }
24
+
25
+ let undiciModule = null;
26
+
27
+ function getUndici() {
28
+ if (!undiciModule) {
29
+ undiciModule = require("undici");
30
+ }
31
+ return undiciModule;
32
+ }
33
+
34
+ function createProxyDispatcher(proxyUrl) {
35
+ const normalizedProxy = String(proxyUrl || "").trim();
36
+ if (!normalizedProxy) {
37
+ return null;
38
+ }
39
+ try {
40
+ const { ProxyAgent } = getUndici();
41
+ return new ProxyAgent(normalizedProxy);
42
+ } catch (error) {
43
+ throw new Error(`Proxy support requires dependency "undici": ${error instanceof Error ? error.message : String(error)}`);
44
+ }
45
+ }
46
+
47
+ function withDispatcher(options, dispatcher) {
48
+ if (!dispatcher) {
49
+ return options || {};
50
+ }
51
+ return { ...(options || {}), dispatcher };
52
+ }
53
+
54
+ function buildSiblingUrl(sourceUrl, pathname) {
55
+ const url = new URL(sourceUrl);
56
+ url.pathname = pathname;
57
+ url.search = "";
58
+ return url;
59
+ }
60
+
61
+ function buildLoginUrl(queryUrl) {
62
+ return buildSiblingUrl(queryUrl, "/api/login").toString();
63
+ }
64
+
65
+ function withSessionToken(urlString, sessionToken) {
66
+ const url = new URL(urlString);
67
+ url.searchParams.set("token", sessionToken);
68
+ return url;
69
+ }
70
+
71
+ class AuthenticationError extends Error {
72
+ constructor(message) {
73
+ super(message);
74
+ this.name = "AuthenticationError";
75
+ }
76
+ }
77
+
78
+ class BacktraceSession {
79
+ constructor(options) {
80
+ this.username = options.username;
81
+ this.password = options.password;
82
+ this.retries = options.retries;
83
+ this.proxy = String(options.proxy || "").trim();
84
+ this.dispatcher = createProxyDispatcher(this.proxy);
85
+ this.token = "";
86
+ this.loginUrl = buildLoginUrl(options.queryUrl);
87
+ }
88
+
89
+ async login() {
90
+ const cached = getCachedToken(this.loginUrl);
91
+ if (cached) {
92
+ this.token = cached;
93
+ logStep("auth", "using cached token");
94
+ return this.token;
95
+ }
96
+ const form = new URLSearchParams();
97
+ form.set("username", this.username);
98
+ form.set("password", this.password);
99
+ logStep("auth", "logging in", { loginUrl: this.loginUrl, username: this.username });
100
+ const payload = await retry("backtrace login", this.retries, async () => {
101
+ const response = await fetch(this.loginUrl, {
102
+ method: "POST",
103
+ headers: { "content-type": "application/x-www-form-urlencoded" },
104
+ body: form.toString(),
105
+ ...(this.dispatcher ? { dispatcher: this.dispatcher } : {}),
106
+ });
107
+ const text = await response.text();
108
+ let data = null;
109
+ try {
110
+ data = text ? JSON.parse(text) : null;
111
+ } catch (error) {
112
+ data = null;
113
+ }
114
+ if (!response.ok) {
115
+ throw new Error(`Login failed: ${response.status} ${response.statusText}`);
116
+ }
117
+ if (!data || !data.token) {
118
+ throw new Error("Login response did not contain a token");
119
+ }
120
+ return data;
121
+ });
122
+ this.token = payload.token;
123
+ setCachedToken(this.loginUrl, this.token);
124
+ logStep("auth", "login succeeded");
125
+ return this.token;
126
+ }
127
+
128
+ async getToken() {
129
+ if (!this.token) {
130
+ await this.login();
131
+ }
132
+ return this.token;
133
+ }
134
+
135
+ async refreshToken() {
136
+ logStep("auth", "refreshing session token");
137
+ clearCachedToken(this.loginUrl);
138
+ this.token = "";
139
+ return this.login();
140
+ }
141
+
142
+ buildHeaders(extraHeaders, token) {
143
+ return { ...(extraHeaders || {}), Cookie: `token=${token}` };
144
+ }
145
+
146
+ parseResponseBody(text) {
147
+ try {
148
+ return text ? JSON.parse(text) : null;
149
+ } catch (error) {
150
+ return text;
151
+ }
152
+ }
153
+
154
+ isAuthFailure(response, parsedBody) {
155
+ const normalizedBody = typeof parsedBody === "string" ? parsedBody.toLowerCase() : JSON.stringify(parsedBody || {}).toLowerCase();
156
+ return response.status === 401
157
+ || response.status === 403
158
+ || normalizedBody.includes("invalid token")
159
+ || normalizedBody.includes("authentication")
160
+ || normalizedBody.includes("not authorized");
161
+ }
162
+
163
+ async request(urlString, options, label, mode) {
164
+ return retry(label, this.retries, async () => {
165
+ let token = await this.getToken();
166
+ let authAttempt = 0;
167
+ while (authAttempt < 2) {
168
+ const url = withSessionToken(urlString, token);
169
+ const response = await fetch(url.toString(), withDispatcher({ ...options, headers: this.buildHeaders(options?.headers, token) }, this.dispatcher));
170
+ if (mode === "buffer" && response.ok) {
171
+ return Buffer.from(await response.arrayBuffer());
172
+ }
173
+ const text = await response.text();
174
+ const parsed = this.parseResponseBody(text);
175
+ if (response.ok) {
176
+ return parsed;
177
+ }
178
+ if (this.isAuthFailure(response, parsed) && authAttempt === 0) {
179
+ await this.refreshToken();
180
+ token = await this.getToken();
181
+ authAttempt += 1;
182
+ continue;
183
+ }
184
+ throw new Error(`Request failed: ${response.status} ${response.statusText}`);
185
+ }
186
+ throw new AuthenticationError("Authentication failed after token refresh");
187
+ });
188
+ }
189
+
190
+ async requestJson(urlString, options, label) {
191
+ return this.request(urlString, options, label, "json");
192
+ }
193
+
194
+ async requestBuffer(urlString, options, label) {
195
+ return this.request(urlString, options, label, "buffer");
196
+ }
197
+
198
+ async requestStream(urlString, options, label) {
199
+ return retry(label, this.retries, async () => {
200
+ let token = await this.getToken();
201
+ let authAttempt = 0;
202
+ while (authAttempt < 2) {
203
+ const url = withSessionToken(urlString, token);
204
+ const response = await fetch(url.toString(), withDispatcher({ ...options, headers: this.buildHeaders(options?.headers, token) }, this.dispatcher));
205
+ if (response.ok) {
206
+ return response;
207
+ }
208
+ const text = await response.text();
209
+ const parsed = this.parseResponseBody(text);
210
+ if (this.isAuthFailure(response, parsed) && authAttempt === 0) {
211
+ await this.refreshToken();
212
+ token = await this.getToken();
213
+ authAttempt += 1;
214
+ continue;
215
+ }
216
+ throw new Error(`Request failed: ${response.status} ${response.statusText}`);
217
+ }
218
+ throw new AuthenticationError("Authentication failed after token refresh");
219
+ });
220
+ }
221
+ }
222
+
223
+ module.exports = {
224
+ AuthenticationError,
225
+ BacktraceSession,
226
+ buildSiblingUrl,
227
+ buildLoginUrl,
228
+ withSessionToken,
229
+ };