bunqueue-dashboard 0.0.32 → 0.0.33
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 +44 -15
- package/agent/db.ts +537 -64
- package/agent/dbQueryWorker.ts +34 -11
- package/agent/manager.ts +186 -45
- package/agent/server.ts +124 -35
- package/dist/.vite/manifest.json +212 -157
- package/dist/assets/AddJob-ytpBLTCJ.js +3 -0
- package/dist/assets/Alerts-C39RrGbQ.js +1 -0
- package/dist/assets/{AreaChart-2gM0YoZT.js → AreaChart-REK4ZsTC.js} +1 -1
- package/dist/assets/Benchmark-CM60tHbf.js +1 -0
- package/dist/assets/BulkAddJobs-BjL-xcQy.js +5 -0
- package/dist/assets/CopilotPanel-BGK1rw-V.js +44 -0
- package/dist/assets/Cron-s1e-jgOo.js +1 -0
- package/dist/assets/CronManager-BostU7BX.js +1 -0
- package/dist/assets/Database-ZluStWMz.js +3 -0
- package/dist/assets/Diagnostics-C0N5z5j-.js +1 -0
- package/dist/assets/Dlq-CtcLzs-b.js +1 -0
- package/dist/assets/DlqControl-B_Z8KKn5.js +1 -0
- package/dist/assets/DlqPro-B68en6AG.js +1 -0
- package/dist/assets/Flows-CD_bkCBM.js +1 -0
- package/dist/assets/JobInspector-DwRtyMz0.js +2 -0
- package/dist/assets/Jobs-BUtcBKKk.js +1 -0
- package/dist/assets/JobsPro-BK_xd4ji.js +1 -0
- package/dist/assets/Logs-CmVODYCI.js +1 -0
- package/dist/assets/LogsPro-CJ-Il3LP.js +1 -0
- package/dist/assets/McpServer-dMjyJKgT.js +22 -0
- package/dist/assets/Metrics-Dsbeplz-.js +1 -0
- package/dist/assets/MetricsPro-BqxurxrJ.js +1 -0
- package/dist/assets/Overview-DH9C2dsf.js +1 -0
- package/dist/assets/{Pagination-B9JDi5MI.js → Pagination-DpxiBnt8.js} +1 -1
- package/dist/assets/QueueActions-D_QGBaVm.js +1 -0
- package/dist/assets/QueueControl-V0DJ6qk6.js +1 -0
- package/dist/assets/QueueDetail-BgKAMJqf.js +1 -0
- package/dist/assets/QueueDetailPro-BkHYzGiq.js +1 -0
- package/dist/assets/Queues-2_v7DcNi.js +1 -0
- package/dist/assets/QueuesOverview-BySSkmB4.js +1 -0
- package/dist/assets/S3Backup-CPADzKQE.js +1 -0
- package/dist/assets/S3BackupPro-CSrQYIDL.js +2 -0
- package/dist/assets/ServerControl-C0tI1VDx.js +3 -0
- package/dist/assets/Settings-CSzoM5up.js +1 -0
- package/dist/assets/Usage-B4B7tmrc.js +1 -0
- package/dist/assets/UsagePro-CCBpgSDy.js +1 -0
- package/dist/assets/Webhooks-aJz-GisQ.js +1 -0
- package/dist/assets/Workers-Bg8xKjkg.js +1 -0
- package/dist/assets/WorkersPro-BOOCrXf8.js +1 -0
- package/dist/assets/connectionStore-Cd9-3Fw0.js +1 -0
- package/dist/assets/flowMutationSafety-DVD1IMr-.js +1 -0
- package/dist/assets/form-DsUq0w8s.js +1 -0
- package/dist/assets/index-BWoUj86M.css +1 -0
- package/dist/assets/index-Bn2R06qm.js +8 -0
- package/dist/assets/{install-BIVfix28.js → install-CUS8Kijb.js} +2 -2
- package/dist/assets/jobActions-LQD4KfCc.js +1 -0
- package/dist/assets/promisePool-BVfQ9n9Q.js +1 -0
- package/dist/assets/{react-vendor-BcouBxpZ.js → react-vendor-Bjui4wS4.js} +1 -1
- package/dist/assets/upstreamPaths-nG8N1XAz.js +1 -0
- package/dist/assets/useServerActionGuard-CItj3hvh.js +1 -0
- package/dist/assets/useThroughputSeries-tb26_lkb.js +1 -0
- package/dist/index.html +5 -4
- package/package.json +11 -3
- package/scripts/embedded.gen.ts +132 -126
- package/scripts/serve.ts +259 -67
- package/dist/assets/AddJob-Dvpp--yq.js +0 -3
- package/dist/assets/Alerts-CR9jgCCs.js +0 -1
- package/dist/assets/Benchmark-CkL-Ee-M.js +0 -1
- package/dist/assets/BulkAddJobs-wI6cRZnX.js +0 -5
- package/dist/assets/CopilotPanel-DPZV9opm.js +0 -44
- package/dist/assets/Cron-gWVkYWad.js +0 -1
- package/dist/assets/CronManager-BvfW219u.js +0 -1
- package/dist/assets/Database-D2T_ZIGA.js +0 -3
- package/dist/assets/Diagnostics-C2goqq5h.js +0 -1
- package/dist/assets/Dlq-Q4TmmZrK.js +0 -1
- package/dist/assets/DlqControl-C5BfE7Gq.js +0 -1
- package/dist/assets/DlqPro-ClPNmw-X.js +0 -1
- package/dist/assets/Flows-DbhMbvSG.js +0 -1
- package/dist/assets/JobInspector-CO_zKjSX.js +0 -2
- package/dist/assets/Jobs-D2rDHPM_.js +0 -1
- package/dist/assets/JobsPro-Drdgdj3s.js +0 -1
- package/dist/assets/Logs-DjwGuRLi.js +0 -1
- package/dist/assets/LogsPro-7uTBjPWX.js +0 -1
- package/dist/assets/McpServer-DAklQDB5.js +0 -22
- package/dist/assets/Metrics-Dup9Zinh.js +0 -1
- package/dist/assets/MetricsPro-PP3TymeP.js +0 -1
- package/dist/assets/Overview-C7RCDE6r.js +0 -1
- package/dist/assets/QueueActions-DJTw0h2g.js +0 -1
- package/dist/assets/QueueControl-CxKjUjF-.js +0 -1
- package/dist/assets/QueueDetail-bf2n-r2h.js +0 -1
- package/dist/assets/QueueDetailPro-C-XLWFBa.js +0 -1
- package/dist/assets/Queues-CMQW89J9.js +0 -1
- package/dist/assets/QueuesOverview-x7-akDzf.js +0 -1
- package/dist/assets/S3Backup-1jAySC9R.js +0 -1
- package/dist/assets/S3BackupPro-BG_0OHW7.js +0 -1
- package/dist/assets/ServerControl-DohTB--h.js +0 -3
- package/dist/assets/Settings-CnS5PjNe.js +0 -1
- package/dist/assets/Usage-C3H0hMSA.js +0 -1
- package/dist/assets/UsagePro-CdnQkP9o.js +0 -1
- package/dist/assets/Webhooks-CAwVmIEZ.js +0 -1
- package/dist/assets/Workers-lvujKZwF.js +0 -1
- package/dist/assets/WorkersPro-CIJKl7U1.js +0 -1
- package/dist/assets/connectionStore-DO6xQY7h.js +0 -1
- package/dist/assets/form-DGinTEEb.js +0 -1
- package/dist/assets/index-DcVOpnSj.css +0 -1
- package/dist/assets/index-r1ZMLIZ9.js +0 -8
- package/dist/assets/jobActions-CuOdMglt.js +0 -1
- package/dist/assets/promisePool-Daxkc1OB.js +0 -1
- package/dist/assets/useThroughputSeries-CVKzWzdv.js +0 -1
package/agent/dbQueryWorker.ts
CHANGED
|
@@ -1,22 +1,45 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Disposable worker that runs ONE read-only
|
|
2
|
+
* Disposable worker that runs ONE read-only database operation and posts the
|
|
3
|
+
* result back: an arbitrary query or an atomic table CSV export.
|
|
3
4
|
*
|
|
4
|
-
* queryWithTimeout() (agent/db.ts)
|
|
5
|
-
*
|
|
6
|
-
* agent's process-control endpoints. terminate() does NOT preempt a
|
|
7
|
-
* sqlite3_step, though — it abandons the response, and this thread
|
|
8
|
-
* until SQLite finishes, which is why db.ts caps how many can be
|
|
9
|
-
*
|
|
10
|
-
* the store.
|
|
5
|
+
* queryWithTimeout() and exportWithTimeout() (agent/db.ts) spawn one of these
|
|
6
|
+
* per request and race it against a wall clock, so a runaway scan/sort can't
|
|
7
|
+
* freeze the agent's process-control endpoints. terminate() does NOT preempt a
|
|
8
|
+
* synchronous sqlite3_step, though — it abandons the response, and this thread
|
|
9
|
+
* keeps running until SQLite finishes, which is why db.ts caps how many can be
|
|
10
|
+
* alive at once. Both dbQuery and dbExportCsv open readonly connections, so
|
|
11
|
+
* nothing here can mutate the store.
|
|
11
12
|
*/
|
|
12
|
-
import { dbQuery, MissingDbError } from './db';
|
|
13
|
+
import { type DbFilter, dbExportCsv, dbQuery, MissingDbError } from './db';
|
|
13
14
|
|
|
14
15
|
declare const self: Worker;
|
|
15
16
|
|
|
16
17
|
self.addEventListener('message', (ev: MessageEvent) => {
|
|
17
|
-
const
|
|
18
|
+
const request = ev.data as
|
|
19
|
+
| { kind?: 'query'; path: string; sql: string }
|
|
20
|
+
| {
|
|
21
|
+
kind: 'export';
|
|
22
|
+
path: string;
|
|
23
|
+
table: string;
|
|
24
|
+
orderBy?: string;
|
|
25
|
+
dir: 'asc' | 'desc';
|
|
26
|
+
filter?: DbFilter;
|
|
27
|
+
};
|
|
18
28
|
try {
|
|
19
|
-
|
|
29
|
+
if (request.kind === 'export') {
|
|
30
|
+
const exported = dbExportCsv(
|
|
31
|
+
request.path,
|
|
32
|
+
request.table,
|
|
33
|
+
request.orderBy,
|
|
34
|
+
request.dir,
|
|
35
|
+
request.filter
|
|
36
|
+
);
|
|
37
|
+
// Transfer the bounded CSV buffer instead of cloning it or JSON-encoding
|
|
38
|
+
// up to 16 MiB a second time in either isolate.
|
|
39
|
+
self.postMessage({ ok: true, export: exported }, [exported.content.buffer]);
|
|
40
|
+
} else {
|
|
41
|
+
self.postMessage({ ok: true, result: dbQuery(request.path, request.sql) });
|
|
42
|
+
}
|
|
20
43
|
} catch (e) {
|
|
21
44
|
// postMessage can't carry the error class, so flag the one the route branches
|
|
22
45
|
// on (MissingDbError → 404) and let db.ts rebuild it on the parent side.
|
package/agent/manager.ts
CHANGED
|
@@ -56,6 +56,10 @@ const STOP_TIMEOUT_MS = 8000;
|
|
|
56
56
|
*/
|
|
57
57
|
const MAX_LINE = 8192;
|
|
58
58
|
|
|
59
|
+
interface CancelablePipeReader {
|
|
60
|
+
cancel(reason?: unknown): Promise<void>;
|
|
61
|
+
}
|
|
62
|
+
|
|
59
63
|
function defaultConfig(): ServerConfig {
|
|
60
64
|
return {
|
|
61
65
|
command: process.env.BUNQUEUE_START_CMD || 'bunqueue start',
|
|
@@ -66,6 +70,92 @@ function defaultConfig(): ServerConfig {
|
|
|
66
70
|
};
|
|
67
71
|
}
|
|
68
72
|
|
|
73
|
+
const CONFIG_KEYS = new Set<keyof ServerConfig>([
|
|
74
|
+
'command',
|
|
75
|
+
'httpPort',
|
|
76
|
+
'tcpPort',
|
|
77
|
+
'dataPath',
|
|
78
|
+
'extraEnv',
|
|
79
|
+
]);
|
|
80
|
+
|
|
81
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
82
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) return false;
|
|
83
|
+
const proto = Object.getPrototypeOf(value);
|
|
84
|
+
return proto === Object.prototype || proto === null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function validatePort(name: 'httpPort' | 'tcpPort', value: unknown): number {
|
|
88
|
+
if (typeof value !== 'number' || !Number.isInteger(value) || value < 1 || value > 65_535) {
|
|
89
|
+
throw new Error(`${name} must be an integer between 1 and 65535`);
|
|
90
|
+
}
|
|
91
|
+
return value;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Validate an untrusted PUT /control/config body before any field is merged.
|
|
96
|
+
* Returning a fresh object (including a fresh extraEnv) makes setConfig atomic:
|
|
97
|
+
* one invalid field cannot leave earlier fields applied, and the caller cannot
|
|
98
|
+
* mutate the accepted config later through a retained object reference.
|
|
99
|
+
*/
|
|
100
|
+
export function validateConfigPatch(value: unknown): Partial<ServerConfig> {
|
|
101
|
+
if (!isRecord(value)) throw new Error('Config must be an object');
|
|
102
|
+
|
|
103
|
+
for (const key of Object.keys(value)) {
|
|
104
|
+
if (!CONFIG_KEYS.has(key as keyof ServerConfig)) {
|
|
105
|
+
throw new Error(`Unknown config key: ${key}`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const patch: Partial<ServerConfig> = {};
|
|
110
|
+
if (Object.hasOwn(value, 'command')) {
|
|
111
|
+
if (typeof value.command !== 'string' || value.command.trim() === '') {
|
|
112
|
+
throw new Error('command must be a non-empty string');
|
|
113
|
+
}
|
|
114
|
+
patch.command = value.command;
|
|
115
|
+
}
|
|
116
|
+
if (Object.hasOwn(value, 'httpPort')) {
|
|
117
|
+
patch.httpPort = validatePort('httpPort', value.httpPort);
|
|
118
|
+
}
|
|
119
|
+
if (Object.hasOwn(value, 'tcpPort')) {
|
|
120
|
+
patch.tcpPort = validatePort('tcpPort', value.tcpPort);
|
|
121
|
+
}
|
|
122
|
+
if (Object.hasOwn(value, 'dataPath')) {
|
|
123
|
+
if (typeof value.dataPath !== 'string') throw new Error('dataPath must be a string');
|
|
124
|
+
patch.dataPath = value.dataPath;
|
|
125
|
+
}
|
|
126
|
+
if (Object.hasOwn(value, 'extraEnv')) {
|
|
127
|
+
if (!isRecord(value.extraEnv)) {
|
|
128
|
+
throw new Error('extraEnv must be an object containing only string values');
|
|
129
|
+
}
|
|
130
|
+
const entries = Object.entries(value.extraEnv);
|
|
131
|
+
for (const [key, entry] of entries) {
|
|
132
|
+
if (typeof entry !== 'string') {
|
|
133
|
+
throw new Error(`extraEnv.${key} must be a string`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
// fromEntries defines "__proto__" as ordinary data instead of invoking the
|
|
137
|
+
// legacy Object.prototype setter via indexed assignment.
|
|
138
|
+
patch.extraEnv = Object.fromEntries(entries) as Record<string, string>;
|
|
139
|
+
}
|
|
140
|
+
return patch;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Validate the complete snapshot start() is about to pass to the child. */
|
|
144
|
+
function validateServerConfig(value: unknown): ServerConfig {
|
|
145
|
+
if (!isRecord(value)) throw new Error('Config must be an object');
|
|
146
|
+
const config = validateConfigPatch(value);
|
|
147
|
+
for (const key of CONFIG_KEYS) {
|
|
148
|
+
if (!Object.hasOwn(value, key)) {
|
|
149
|
+
throw new Error(`Missing config key: ${key}`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return config as ServerConfig;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function copyConfig(config: ServerConfig): ServerConfig {
|
|
156
|
+
return { ...config, extraEnv: { ...config.extraEnv } };
|
|
157
|
+
}
|
|
158
|
+
|
|
69
159
|
export class ProcessManager {
|
|
70
160
|
private proc: ReturnType<typeof Bun.spawn> | null = null;
|
|
71
161
|
private status: Status = 'stopped';
|
|
@@ -91,9 +181,27 @@ export class ProcessManager {
|
|
|
91
181
|
private stopping: Promise<StatusSnapshot> | null = null;
|
|
92
182
|
/** Latched by shutdown(): once set, start() refuses to spawn a new child. */
|
|
93
183
|
private shuttingDown = false;
|
|
184
|
+
/** Active stdout/stderr readers, grouped by the process generation they own. */
|
|
185
|
+
private pipeReaders = new Map<number, Set<CancelablePipeReader>>();
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Actively break pending reader.read() calls for a finished generation. A
|
|
189
|
+
* descendant may inherit the child's pipe descriptor and keep it open after
|
|
190
|
+
* the managed process exits; token checks alone cannot release that reader.
|
|
191
|
+
*/
|
|
192
|
+
private cancelPipes(token: number): void {
|
|
193
|
+
const readers = this.pipeReaders.get(token);
|
|
194
|
+
if (!readers) return;
|
|
195
|
+
this.pipeReaders.delete(token);
|
|
196
|
+
for (const reader of readers) {
|
|
197
|
+
void reader.cancel('process generation ended').catch(() => {
|
|
198
|
+
/* already closed/cancelled */
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
}
|
|
94
202
|
|
|
95
203
|
getConfig(): ServerConfig {
|
|
96
|
-
return this.config;
|
|
204
|
+
return copyConfig(this.config);
|
|
97
205
|
}
|
|
98
206
|
|
|
99
207
|
/**
|
|
@@ -103,8 +211,13 @@ export class ProcessManager {
|
|
|
103
211
|
* changed in place.
|
|
104
212
|
*/
|
|
105
213
|
setConfig(patch: Partial<ServerConfig>): ServerConfig {
|
|
106
|
-
|
|
107
|
-
|
|
214
|
+
const valid = validateConfigPatch(patch);
|
|
215
|
+
this.config = {
|
|
216
|
+
...this.config,
|
|
217
|
+
...valid,
|
|
218
|
+
extraEnv: valid.extraEnv ?? this.config.extraEnv,
|
|
219
|
+
};
|
|
220
|
+
return copyConfig(this.config);
|
|
108
221
|
}
|
|
109
222
|
|
|
110
223
|
getStatus(): StatusSnapshot {
|
|
@@ -113,8 +226,8 @@ export class ProcessManager {
|
|
|
113
226
|
pid: this.proc?.pid ?? null,
|
|
114
227
|
startedAt: this.startedAt,
|
|
115
228
|
exitCode: this.exitCode,
|
|
116
|
-
config: this.config,
|
|
117
|
-
runningConfig: this.runningConfig,
|
|
229
|
+
config: copyConfig(this.config),
|
|
230
|
+
runningConfig: this.runningConfig ? copyConfig(this.runningConfig) : null,
|
|
118
231
|
};
|
|
119
232
|
}
|
|
120
233
|
|
|
@@ -173,16 +286,14 @@ export class ProcessManager {
|
|
|
173
286
|
const statusAfterWait = this.status as Status;
|
|
174
287
|
if (statusAfterWait === 'running' || statusAfterWait === 'starting') return this.getStatus();
|
|
175
288
|
|
|
176
|
-
// Validate BEFORE mutating status / burning a
|
|
177
|
-
//
|
|
178
|
-
//
|
|
179
|
-
//
|
|
180
|
-
//
|
|
181
|
-
|
|
182
|
-
const
|
|
183
|
-
const parts = command.trim().split(/\s+/);
|
|
289
|
+
// Validate the COMPLETE launch snapshot BEFORE mutating status / burning a
|
|
290
|
+
// proc token. setConfig() already validates API patches, but defaultConfig()
|
|
291
|
+
// also reads environment values; e.g. HTTP_PORT=70000 must not reach spawn.
|
|
292
|
+
// Doing this later can wedge status at 'starting' and strand stale process
|
|
293
|
+
// metadata when validation throws.
|
|
294
|
+
const launchConfig = validateServerConfig(this.config);
|
|
295
|
+
const parts = launchConfig.command.trim().split(/\s+/);
|
|
184
296
|
const [cmd, ...args] = parts;
|
|
185
|
-
if (!cmd) throw new Error('Empty command');
|
|
186
297
|
|
|
187
298
|
this.status = 'starting';
|
|
188
299
|
this.exitCode = null;
|
|
@@ -193,10 +304,10 @@ export class ProcessManager {
|
|
|
193
304
|
// while runningConfig (and the agent's /health probe) reported the old port.
|
|
194
305
|
const env: Record<string, string> = {
|
|
195
306
|
...(process.env as Record<string, string>),
|
|
196
|
-
...
|
|
197
|
-
HTTP_PORT: String(
|
|
198
|
-
TCP_PORT: String(
|
|
199
|
-
BUNQUEUE_DATA_PATH:
|
|
307
|
+
...launchConfig.extraEnv,
|
|
308
|
+
HTTP_PORT: String(launchConfig.httpPort),
|
|
309
|
+
TCP_PORT: String(launchConfig.tcpPort),
|
|
310
|
+
BUNQUEUE_DATA_PATH: launchConfig.dataPath,
|
|
200
311
|
};
|
|
201
312
|
|
|
202
313
|
try {
|
|
@@ -205,6 +316,9 @@ export class ProcessManager {
|
|
|
205
316
|
stdout: 'pipe',
|
|
206
317
|
stderr: 'pipe',
|
|
207
318
|
onExit: (_p, code) => {
|
|
319
|
+
// Always tear down this generation's readers, even if shared state
|
|
320
|
+
// already belongs to a replacement process.
|
|
321
|
+
this.cancelPipes(token);
|
|
208
322
|
// Ignore the exit of a process a newer start() has already replaced.
|
|
209
323
|
if (this.procToken !== token) return;
|
|
210
324
|
this.exitCode = code ?? null;
|
|
@@ -227,45 +341,69 @@ export class ProcessManager {
|
|
|
227
341
|
|
|
228
342
|
this.startedAt = Date.now();
|
|
229
343
|
this.status = 'running';
|
|
230
|
-
this.runningConfig =
|
|
231
|
-
this.push('sys', `started: ${
|
|
344
|
+
this.runningConfig = copyConfig(launchConfig);
|
|
345
|
+
this.push('sys', `started: ${launchConfig.command} (pid ${this.proc.pid})`);
|
|
232
346
|
// stdout/stderr are spawned as pipes, so they are ReadableStreams at
|
|
233
347
|
// runtime; Bun types them as a union that also includes a numeric fd.
|
|
234
|
-
void this.pipe(this.proc.stdout as unknown as ReadableStream<Uint8Array>, 'stdout');
|
|
235
|
-
void this.pipe(this.proc.stderr as unknown as ReadableStream<Uint8Array>, 'stderr');
|
|
348
|
+
void this.pipe(this.proc.stdout as unknown as ReadableStream<Uint8Array>, 'stdout', token);
|
|
349
|
+
void this.pipe(this.proc.stderr as unknown as ReadableStream<Uint8Array>, 'stderr', token);
|
|
236
350
|
return this.getStatus();
|
|
237
351
|
}
|
|
238
352
|
|
|
239
|
-
private async pipe(
|
|
353
|
+
private async pipe(
|
|
354
|
+
stream: ReadableStream<Uint8Array>,
|
|
355
|
+
name: 'stdout' | 'stderr',
|
|
356
|
+
token: number
|
|
357
|
+
): Promise<void> {
|
|
240
358
|
const reader = stream.getReader();
|
|
359
|
+
let readers = this.pipeReaders.get(token);
|
|
360
|
+
if (!readers) {
|
|
361
|
+
readers = new Set();
|
|
362
|
+
this.pipeReaders.set(token, readers);
|
|
363
|
+
}
|
|
364
|
+
readers.add(reader);
|
|
241
365
|
const decoder = new TextDecoder();
|
|
242
366
|
let buffer = '';
|
|
243
367
|
try {
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
if (
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
368
|
+
try {
|
|
369
|
+
while (true) {
|
|
370
|
+
const { done, value } = await reader.read();
|
|
371
|
+
// A process can exit while a descendant keeps its inherited pipe
|
|
372
|
+
// open. If a replacement has started by the time bytes arrive, those
|
|
373
|
+
// bytes belong to the obsolete generation and must never enter the
|
|
374
|
+
// current log view.
|
|
375
|
+
if (this.procToken !== token) return;
|
|
376
|
+
if (done) break;
|
|
377
|
+
buffer += decoder.decode(value, { stream: true });
|
|
378
|
+
let i: number;
|
|
379
|
+
while ((i = buffer.indexOf('\n')) !== -1) {
|
|
380
|
+
const line = buffer.slice(0, i);
|
|
381
|
+
buffer = buffer.slice(i + 1);
|
|
382
|
+
if (line.trim() && this.procToken === token) this.push(name, line);
|
|
383
|
+
}
|
|
384
|
+
// No newline in sight: flush in MAX_LINE slices instead of letting the
|
|
385
|
+
// buffer (and the eventual single LogLine) grow to the whole output.
|
|
386
|
+
while (buffer.length > MAX_LINE) {
|
|
387
|
+
if (this.procToken !== token) return;
|
|
388
|
+
this.push(name, buffer.slice(0, MAX_LINE));
|
|
389
|
+
buffer = buffer.slice(MAX_LINE);
|
|
390
|
+
}
|
|
259
391
|
}
|
|
392
|
+
} catch {
|
|
393
|
+
/* stream closed */
|
|
260
394
|
}
|
|
261
|
-
|
|
262
|
-
|
|
395
|
+
if (this.procToken !== token) return;
|
|
396
|
+
// Flush the final chunk when the stream ends without a trailing newline
|
|
397
|
+
// (e.g. a crash cause written via a bare write()) — otherwise the last,
|
|
398
|
+
// often most important, line never reaches the log buffer.
|
|
399
|
+
const tail = buffer + decoder.decode();
|
|
400
|
+
if (tail.trim() && this.procToken === token) this.push(name, tail);
|
|
401
|
+
} finally {
|
|
402
|
+
const activeReaders = this.pipeReaders.get(token);
|
|
403
|
+
activeReaders?.delete(reader);
|
|
404
|
+
if (activeReaders?.size === 0) this.pipeReaders.delete(token);
|
|
405
|
+
reader.releaseLock();
|
|
263
406
|
}
|
|
264
|
-
// Flush the final chunk when the stream ends without a trailing newline
|
|
265
|
-
// (e.g. a crash cause written via a bare write()) — otherwise the last,
|
|
266
|
-
// often most important, line never reaches the log buffer.
|
|
267
|
-
const tail = buffer + decoder.decode();
|
|
268
|
-
if (tail.trim()) this.push(name, tail);
|
|
269
407
|
}
|
|
270
408
|
|
|
271
409
|
async stop(): Promise<StatusSnapshot> {
|
|
@@ -333,6 +471,9 @@ export class ProcessManager {
|
|
|
333
471
|
]).finally(() => clearTimeout(reapTimer));
|
|
334
472
|
if (!reaped) this.push('sys', `pid ${proc.pid} did not exit after SIGKILL — giving up on it`);
|
|
335
473
|
}
|
|
474
|
+
// onExit normally performs this cancellation. Repeat it idempotently for
|
|
475
|
+
// a process whose `exited` promise/callback never settles after SIGKILL.
|
|
476
|
+
this.cancelPipes(token);
|
|
336
477
|
// Only finalize shared state if no newer start() replaced this generation
|
|
337
478
|
// while we awaited — otherwise we'd null out the wrong (live) process.
|
|
338
479
|
if (this.procToken !== token) return this.getStatus();
|
package/agent/server.ts
CHANGED
|
@@ -18,28 +18,45 @@
|
|
|
18
18
|
* cover a *same-origin* request: a page whose DNS name is rebound to
|
|
19
19
|
* 127.0.0.1 issues same-origin GETs that carry no `Origin` header, so it
|
|
20
20
|
* could otherwise read /control/status, /control/logs and the /db/*
|
|
21
|
-
* inspector (job payloads)
|
|
21
|
+
* inspector (job payloads). When the agent is reachable beyond loopback,
|
|
22
|
+
* every route also requires the configured token; Host validation remains
|
|
23
|
+
* necessary because it rejects the rebound request before any data leaves.
|
|
22
24
|
* When `allowedHosts` is configured, a request whose `Host` header names a
|
|
23
25
|
* hostname outside the allowlist is rejected (403) — the rebinding page's
|
|
24
26
|
* `Host` is the attacker's domain, never a loopback/allowlisted host.
|
|
25
|
-
* 4. Optional bearer token (AGENT_TOKEN): when set,
|
|
26
|
-
* must present it
|
|
27
|
+
* 4. Optional bearer token (AGENT_TOKEN): on loopback, when set, changing
|
|
28
|
+
* requests must present it. A network-facing caller must set
|
|
29
|
+
* `requireTokenForAll`, which protects reads and writes alike.
|
|
27
30
|
*/
|
|
28
|
-
import {
|
|
29
|
-
|
|
31
|
+
import {
|
|
32
|
+
type DbFilter,
|
|
33
|
+
dbCell,
|
|
34
|
+
DbExportBusyError,
|
|
35
|
+
DbExportUnavailableError,
|
|
36
|
+
dbInfo,
|
|
37
|
+
dbRows,
|
|
38
|
+
dbSchema,
|
|
39
|
+
dbTables,
|
|
40
|
+
exportWithTimeout,
|
|
41
|
+
MissingDbError,
|
|
42
|
+
queryWithTimeout,
|
|
43
|
+
} from './db';
|
|
44
|
+
import { type ProcessManager, validateConfigPatch } from './manager';
|
|
30
45
|
|
|
31
46
|
export interface AgentOptions {
|
|
32
47
|
allowedOrigins: string[];
|
|
33
48
|
/**
|
|
34
49
|
* DNS-rebinding defense: when set (non-empty), a request whose `Host` header
|
|
35
50
|
* resolves to a hostname outside this list is rejected. Compared by hostname
|
|
36
|
-
* only (port stripped). Leave undefined
|
|
37
|
-
*
|
|
38
|
-
*
|
|
51
|
+
* only (port stripped). Leave undefined only for a loopback-only integration
|
|
52
|
+
* or when a trusted front proxy has already enforced Host. Network-facing
|
|
53
|
+
* listeners must configure this allowlist.
|
|
39
54
|
*/
|
|
40
55
|
allowedHosts?: string[];
|
|
41
|
-
/**
|
|
56
|
+
/** On loopback, when set, POST/PUT requests must present this token. */
|
|
42
57
|
token?: string;
|
|
58
|
+
/** Network exposure: require `token` on every non-OPTIONS request, including reads. */
|
|
59
|
+
requireTokenForAll?: boolean;
|
|
43
60
|
}
|
|
44
61
|
|
|
45
62
|
const DEFAULT_ORIGINS = ['http://localhost:5273', 'http://127.0.0.1:5273'];
|
|
@@ -94,14 +111,14 @@ export function isOriginAllowed(origin: string | null, allowed: string[]): boole
|
|
|
94
111
|
}
|
|
95
112
|
|
|
96
113
|
/**
|
|
97
|
-
* Host-header allowlist. Disabled (always true) when `allowed` is undefined.
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
* own domain as Host, so it fails
|
|
114
|
+
* Host-header allowlist. Disabled (always true) when `allowed` is undefined.
|
|
115
|
+
* Once enabled it is fail-closed: a real HTTP request always has Host, and
|
|
116
|
+
* accepting a missing one would create an avoidable bypass for unusual proxy
|
|
117
|
+
* paths. A DNS-rebinding page sends its own domain as Host, so it fails too.
|
|
101
118
|
*/
|
|
102
119
|
export function isHostAllowed(host: string | null, allowed?: string[]): boolean {
|
|
103
120
|
if (!allowed) return true;
|
|
104
|
-
if (!host) return
|
|
121
|
+
if (!host) return false;
|
|
105
122
|
return allowed.includes(hostnameOf(host));
|
|
106
123
|
}
|
|
107
124
|
|
|
@@ -119,7 +136,7 @@ export function corsHeaders(origin: string | null, allowed: string[]): Record<st
|
|
|
119
136
|
}
|
|
120
137
|
|
|
121
138
|
function tokenOk(req: Request, token?: string): boolean {
|
|
122
|
-
if (!token) return
|
|
139
|
+
if (!token) return false;
|
|
123
140
|
const auth = req.headers.get('authorization') ?? '';
|
|
124
141
|
const bearer = auth.startsWith('Bearer ') ? auth.slice(7) : '';
|
|
125
142
|
return bearer === token || req.headers.get('x-agent-token') === token;
|
|
@@ -130,7 +147,7 @@ function tokenOk(req: Request, token?: string): boolean {
|
|
|
130
147
|
* ProcessManager and policy, returns an async (req) => Response.
|
|
131
148
|
*/
|
|
132
149
|
export function createFetchHandler(mgr: ProcessManager, opts: AgentOptions) {
|
|
133
|
-
const { allowedOrigins, allowedHosts, token } = opts;
|
|
150
|
+
const { allowedOrigins, allowedHosts, token, requireTokenForAll = false } = opts;
|
|
134
151
|
|
|
135
152
|
const json = (data: unknown, status: number, origin: string | null): Response =>
|
|
136
153
|
new Response(JSON.stringify(data), {
|
|
@@ -149,9 +166,15 @@ export function createFetchHandler(mgr: ProcessManager, opts: AgentOptions) {
|
|
|
149
166
|
signal: AbortSignal.timeout(1500),
|
|
150
167
|
});
|
|
151
168
|
if (res.ok) {
|
|
152
|
-
const
|
|
153
|
-
|
|
154
|
-
|
|
169
|
+
const body: unknown = await res.json();
|
|
170
|
+
if (body !== null && typeof body === 'object' && !Array.isArray(body)) {
|
|
171
|
+
const health = body as Record<string, unknown>;
|
|
172
|
+
// HTTP 2xx alone is not a Bunqueue health verdict: an unrelated
|
|
173
|
+
// process, proxy fallback, or malformed payload can also return it.
|
|
174
|
+
// Fail closed unless the semantic health flag is exactly true.
|
|
175
|
+
healthy = health.ok === true;
|
|
176
|
+
if (typeof health.version === 'string') version = health.version;
|
|
177
|
+
}
|
|
155
178
|
}
|
|
156
179
|
} catch {
|
|
157
180
|
/* not up yet */
|
|
@@ -166,10 +189,6 @@ export function createFetchHandler(mgr: ProcessManager, opts: AgentOptions) {
|
|
|
166
189
|
const method = req.method;
|
|
167
190
|
const origin = req.headers.get('origin');
|
|
168
191
|
|
|
169
|
-
if (method === 'OPTIONS') {
|
|
170
|
-
return new Response(null, { status: 204, headers: corsHeaders(origin, allowedOrigins) });
|
|
171
|
-
}
|
|
172
|
-
|
|
173
192
|
// DNS-rebinding defense: reject a same-origin request whose Host header is
|
|
174
193
|
// an attacker domain rebound to loopback (no Origin header would be sent,
|
|
175
194
|
// so the Origin gate below can't see it). No-op when allowedHosts is unset.
|
|
@@ -182,16 +201,20 @@ export function createFetchHandler(mgr: ProcessManager, opts: AgentOptions) {
|
|
|
182
201
|
return json({ ok: false, error: 'Origin not allowed' }, 403, origin);
|
|
183
202
|
}
|
|
184
203
|
|
|
185
|
-
//
|
|
186
|
-
//
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
//
|
|
192
|
-
//
|
|
204
|
+
// Preflight never needs a bearer token, but Host and Origin are security
|
|
205
|
+
// boundaries in their own right and therefore fail closed before this 204.
|
|
206
|
+
if (method === 'OPTIONS') {
|
|
207
|
+
return new Response(null, { status: 204, headers: corsHeaders(origin, allowedOrigins) });
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Preserve zero-config LOOPBACK use: there, reads stay open and a configured
|
|
211
|
+
// token gates mutations. A network-facing listener sets requireTokenForAll,
|
|
212
|
+
// because Origin alone cannot authenticate curl/no-Origin callers and the db
|
|
213
|
+
// inspector contains job payloads. Misconfiguration fails closed: enabling
|
|
214
|
+
// the all-route gate without a token makes every real route answer 401.
|
|
193
215
|
const mutating = method === 'POST' || method === 'PUT';
|
|
194
|
-
|
|
216
|
+
const authRequired = requireTokenForAll || (mutating && Boolean(token));
|
|
217
|
+
if (authRequired && !tokenOk(req, token)) {
|
|
195
218
|
return json({ ok: false, error: 'Unauthorized' }, 401, origin);
|
|
196
219
|
}
|
|
197
220
|
|
|
@@ -214,7 +237,7 @@ export function createFetchHandler(mgr: ProcessManager, opts: AgentOptions) {
|
|
|
214
237
|
|
|
215
238
|
if (pathname === '/control/config' && method === 'GET') return json(mgr.getConfig(), 200, origin);
|
|
216
239
|
if (pathname === '/control/config' && method === 'PUT') {
|
|
217
|
-
const patch = (await req.json())
|
|
240
|
+
const patch = validateConfigPatch(await req.json());
|
|
218
241
|
return json(mgr.setConfig(patch), 200, origin);
|
|
219
242
|
}
|
|
220
243
|
|
|
@@ -235,12 +258,71 @@ export function createFetchHandler(mgr: ProcessManager, opts: AgentOptions) {
|
|
|
235
258
|
const segs = pathname.slice('/db/tables/'.length).split('/');
|
|
236
259
|
const table = decodeURIComponent(segs[0] ?? '');
|
|
237
260
|
const sub = segs[1];
|
|
261
|
+
if (segs.length === 2 && sub === 'export') {
|
|
262
|
+
const sp = new URL(req.url).searchParams;
|
|
263
|
+
const allowedParams = new Set(['orderBy', 'dir', 'fcol', 'fop', 'fval']);
|
|
264
|
+
for (const key of sp.keys()) {
|
|
265
|
+
if (!allowedParams.has(key)) throw new Error(`Unknown database export option: ${key}`);
|
|
266
|
+
if (sp.getAll(key).length !== 1) {
|
|
267
|
+
throw new Error(`Duplicate database export option: ${key}`);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const orderByParam = sp.get('orderBy');
|
|
272
|
+
if (orderByParam === '') throw new Error('Database export orderBy must not be empty');
|
|
273
|
+
const dirParam = sp.get('dir');
|
|
274
|
+
if (dirParam !== null && dirParam !== 'asc' && dirParam !== 'desc') {
|
|
275
|
+
throw new Error('Database export dir must be "asc" or "desc"');
|
|
276
|
+
}
|
|
277
|
+
const fCol = sp.get('fcol');
|
|
278
|
+
const fOp = sp.get('fop');
|
|
279
|
+
const fVal = sp.get('fval');
|
|
280
|
+
const hasAnyFilterPart = fCol !== null || fOp !== null || fVal !== null;
|
|
281
|
+
let filter: DbFilter | undefined;
|
|
282
|
+
if (hasAnyFilterPart) {
|
|
283
|
+
if (!fCol || !fVal || (fOp !== 'contains' && fOp !== 'eq' && fOp !== 'ne')) {
|
|
284
|
+
throw new Error('Database export filter requires valid fcol, fop and fval values');
|
|
285
|
+
}
|
|
286
|
+
filter = { column: fCol, op: fOp, value: fVal };
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const exported = await exportWithTimeout(
|
|
290
|
+
mgr.getConfig().dataPath,
|
|
291
|
+
table,
|
|
292
|
+
orderByParam ?? undefined,
|
|
293
|
+
dirParam === 'desc' ? 'desc' : 'asc',
|
|
294
|
+
filter,
|
|
295
|
+
req.signal
|
|
296
|
+
);
|
|
297
|
+
return new Response(exported.content, {
|
|
298
|
+
status: 200,
|
|
299
|
+
headers: {
|
|
300
|
+
'Content-Type': 'text/csv; charset=utf-8',
|
|
301
|
+
'Content-Length': String(exported.bytes),
|
|
302
|
+
// Keep Content-Length equal to the authoritative export-byte contract;
|
|
303
|
+
// intermediaries must not transparently recompress this body.
|
|
304
|
+
'Cache-Control': 'no-store, no-transform',
|
|
305
|
+
'X-Content-Type-Options': 'nosniff',
|
|
306
|
+
'X-Bunqueue-Db-Export-Version': '1',
|
|
307
|
+
'X-Bunqueue-Db-Export-Table': encodeURIComponent(exported.table),
|
|
308
|
+
'X-Bunqueue-Db-Export-Rows': String(exported.rowCount),
|
|
309
|
+
'X-Bunqueue-Db-Export-Bytes': String(exported.bytes),
|
|
310
|
+
'X-Bunqueue-Db-Export-Cap': exported.cap ?? 'none',
|
|
311
|
+
'Access-Control-Expose-Headers':
|
|
312
|
+
'Content-Length, X-Bunqueue-Db-Export-Version, X-Bunqueue-Db-Export-Table, X-Bunqueue-Db-Export-Rows, X-Bunqueue-Db-Export-Bytes, X-Bunqueue-Db-Export-Cap',
|
|
313
|
+
...corsHeaders(origin, allowedOrigins),
|
|
314
|
+
},
|
|
315
|
+
});
|
|
316
|
+
}
|
|
238
317
|
if (segs.length === 2 && sub === 'schema') {
|
|
239
318
|
return json({ ok: true, ...dbSchema(mgr.getConfig().dataPath, table) }, 200, origin);
|
|
240
319
|
}
|
|
241
320
|
if (segs.length === 2 && sub === 'cell') {
|
|
242
321
|
const sp = new URL(req.url).searchParams;
|
|
243
|
-
|
|
322
|
+
// Keep rowid textual through the HTTP boundary. Number() rounded
|
|
323
|
+
// values above 2^53 before SQLite ever saw the bound parameter.
|
|
324
|
+
const rowid = sp.get('rowid');
|
|
325
|
+
if (rowid === null) throw new Error('rowid is required');
|
|
244
326
|
const column = sp.get('column') ?? '';
|
|
245
327
|
return json(
|
|
246
328
|
{ ok: true, ...dbCell(mgr.getConfig().dataPath, table, rowid, column) },
|
|
@@ -276,7 +358,14 @@ export function createFetchHandler(mgr: ProcessManager, opts: AgentOptions) {
|
|
|
276
358
|
} catch (e) {
|
|
277
359
|
// A missing database file is an expected pre-first-start condition — 404
|
|
278
360
|
// so the UI can show "no database yet" distinctly from a real read error.
|
|
279
|
-
const status =
|
|
361
|
+
const status =
|
|
362
|
+
e instanceof MissingDbError
|
|
363
|
+
? 404
|
|
364
|
+
: e instanceof DbExportBusyError
|
|
365
|
+
? 429
|
|
366
|
+
: e instanceof DbExportUnavailableError
|
|
367
|
+
? 503
|
|
368
|
+
: 400;
|
|
280
369
|
return json({ ok: false, error: (e as Error).message ?? String(e) }, status, origin);
|
|
281
370
|
}
|
|
282
371
|
};
|