backtrace-console 0.0.1 → 0.0.2

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/app.js CHANGED
@@ -10,13 +10,14 @@ var backtraceRouter = require('./routes/backtrace');
10
10
  var app = express();
11
11
 
12
12
  app.use(logger('dev'));
13
- app.use(express.json());
14
- app.use(express.urlencoded({ extended: false }));
13
+ app.use(express.json({ limit: '50mb' }));
14
+ app.use(express.urlencoded({ extended: false, limit: '50mb' }));
15
15
  app.use(cookieParser());
16
16
  app.use(express.static(path.join(__dirname, 'public')));
17
17
 
18
18
  app.use('/', indexRouter);
19
19
  app.use('/users', usersRouter);
20
+
20
21
  app.use('/api/backtrace', backtraceRouter);
21
22
 
22
23
  module.exports = app;
@@ -0,0 +1,101 @@
1
+ #!/usr/bin/env node
2
+
3
+ 'use strict';
4
+
5
+ require('dotenv').config();
6
+
7
+ var path = require('node:path');
8
+ var fs = require('node:fs');
9
+ var { spawn } = require('node:child_process');
10
+
11
+ var ROOT = path.resolve(__dirname, '..');
12
+ var PID_FILE = path.join(ROOT, '.backtrace-server.pid');
13
+ var LOG_FILE = path.join(ROOT, '.backtrace-server.log');
14
+ var WWW = path.join(__dirname, 'www');
15
+
16
+ var cmd = process.argv[2];
17
+
18
+ if (cmd === 'run') {
19
+ // 前台运行,直接 require www(同进程)
20
+ require('./www');
21
+ return;
22
+ }
23
+
24
+ if (cmd === 'start') {
25
+ // 检查是否已在运行
26
+ if (fs.existsSync(PID_FILE)) {
27
+ var existingPid = parseInt(fs.readFileSync(PID_FILE, 'utf8').trim(), 10);
28
+ if (existingPid && isRunning(existingPid)) {
29
+ console.log('[backtrace] server already running, pid=' + existingPid);
30
+ process.exit(0);
31
+ }
32
+ fs.unlinkSync(PID_FILE);
33
+ }
34
+
35
+ var logFd = fs.openSync(LOG_FILE, 'a');
36
+ var child = spawn(process.execPath, [WWW], {
37
+ detached: true,
38
+ stdio: ['ignore', logFd, logFd],
39
+ env: process.env,
40
+ cwd: ROOT,
41
+ });
42
+ child.unref();
43
+ fs.writeFileSync(PID_FILE, String(child.pid), 'utf8');
44
+ console.log('[backtrace] server started, pid=' + child.pid + ', log=' + LOG_FILE);
45
+ process.exit(0);
46
+ }
47
+
48
+ if (cmd === 'stop') {
49
+ if (!fs.existsSync(PID_FILE)) {
50
+ console.log('[backtrace] server is not running (no pid file)');
51
+ process.exit(0);
52
+ }
53
+ var pid = parseInt(fs.readFileSync(PID_FILE, 'utf8').trim(), 10);
54
+ if (!pid || !isRunning(pid)) {
55
+ console.log('[backtrace] server is not running (pid=' + pid + ')');
56
+ fs.unlinkSync(PID_FILE);
57
+ process.exit(0);
58
+ }
59
+ try {
60
+ process.kill(pid, 'SIGTERM');
61
+ fs.unlinkSync(PID_FILE);
62
+ console.log('[backtrace] server stopped, pid=' + pid);
63
+ } catch (err) {
64
+ console.error('[backtrace] failed to stop server:', err.message);
65
+ process.exit(1);
66
+ }
67
+ process.exit(0);
68
+ }
69
+
70
+ if (cmd === 'status') {
71
+ if (!fs.existsSync(PID_FILE)) {
72
+ console.log('[backtrace] server is not running');
73
+ process.exit(0);
74
+ }
75
+ var statusPid = parseInt(fs.readFileSync(PID_FILE, 'utf8').trim(), 10);
76
+ if (statusPid && isRunning(statusPid)) {
77
+ console.log('[backtrace] server is running, pid=' + statusPid);
78
+ } else {
79
+ console.log('[backtrace] server is not running (stale pid=' + statusPid + ')');
80
+ fs.unlinkSync(PID_FILE);
81
+ }
82
+ process.exit(0);
83
+ }
84
+
85
+ console.log([
86
+ 'Usage:',
87
+ ' backtrace-server run 前台启动服务器',
88
+ ' backtrace-server start 后台守护进程启动',
89
+ ' backtrace-server stop 停止后台进程',
90
+ ' backtrace-server status 查看运行状态',
91
+ ].join('\n'));
92
+ process.exit(1);
93
+
94
+ function isRunning(pid) {
95
+ try {
96
+ process.kill(pid, 0);
97
+ return true;
98
+ } catch (_) {
99
+ return false;
100
+ }
101
+ }
package/bin/www CHANGED
@@ -3,6 +3,7 @@
3
3
  /**
4
4
  * Module dependencies.
5
5
  */
6
+ require("dotenv").config();
6
7
 
7
8
  var app = require('../app');
8
9
  var debug = require('debug')('backtrace-console:server');
@@ -29,6 +30,8 @@ server.listen(port);
29
30
  server.on('error', onError);
30
31
  server.on('listening', onListening);
31
32
 
33
+ require('../lib/scheduler').start();
34
+
32
35
  /**
33
36
  * Normalize a port into a number, string, or false.
34
37
  */
@@ -8,6 +8,8 @@ const DEFAULT_LIMIT = Number(process.env.BACKTRACE_LIMIT || 20);
8
8
  const DEFAULT_DOWNLOAD_CONCURRENCY = Number(process.env.BACKTRACE_DOWNLOAD_CONCURRENCY || 4);
9
9
  const DEFAULT_RETRIES = Number(process.env.BACKTRACE_RETRIES || 0);
10
10
  const DEFAULT_STORAGE_DIR = process.env.BACKTRACE_STORAGE_DIR || "fingerprints";
11
+ const FEISHU_WEBHOOK_URL = process.env.FEISHU_WEBHOOK_URL || "";
12
+ const SCHEDULER_CRON = process.env.SCHEDULER_CRON || "*/5 * * * *";
11
13
 
12
14
  module.exports = {
13
15
  DEFAULT_QUERY_URL,
@@ -20,4 +22,6 @@ module.exports = {
20
22
  DEFAULT_DOWNLOAD_CONCURRENCY,
21
23
  DEFAULT_RETRIES,
22
24
  DEFAULT_STORAGE_DIR,
25
+ FEISHU_WEBHOOK_URL,
26
+ SCHEDULER_CRON,
23
27
  };
@@ -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
+ };