bunqueue-dashboard 0.0.14

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.
Files changed (71) hide show
  1. package/CHANGELOG.md +315 -0
  2. package/LICENSE +21 -0
  3. package/README.md +287 -0
  4. package/agent/db.ts +491 -0
  5. package/agent/dbQueryWorker.ts +21 -0
  6. package/agent/index.ts +47 -0
  7. package/agent/logger.ts +23 -0
  8. package/agent/manager.ts +262 -0
  9. package/agent/server.ts +213 -0
  10. package/dist/.vite/manifest.json +578 -0
  11. package/dist/assets/AddJob-DOtZrwc-.js +3 -0
  12. package/dist/assets/AreaChart-DzUXW8u3.js +1 -0
  13. package/dist/assets/Benchmark-BKVE509y.js +1 -0
  14. package/dist/assets/CopilotPanel-Bn8gPZx_.js +152 -0
  15. package/dist/assets/CopyButton-CSjndiY7.js +1 -0
  16. package/dist/assets/Cron-DzeoQTpD.js +1 -0
  17. package/dist/assets/CronManager-CI6wTFbe.js +1 -0
  18. package/dist/assets/Database-Bjkl_O9n.js +3 -0
  19. package/dist/assets/Diagnostics-BUeDbexv.js +1 -0
  20. package/dist/assets/Dlq-BzwhitJI.js +1 -0
  21. package/dist/assets/DlqControl-BUCut0SS.js +1 -0
  22. package/dist/assets/DlqPro-HoeGdD-B.js +1 -0
  23. package/dist/assets/Flows-D9sVq4vd.js +1 -0
  24. package/dist/assets/JobInspector-dB68adRz.js +2 -0
  25. package/dist/assets/Jobs-CAHFY5qa.js +1 -0
  26. package/dist/assets/JobsPro-BqIHonV6.js +1 -0
  27. package/dist/assets/Logs-DjNLXK_r.js +1 -0
  28. package/dist/assets/LogsPro-BPWf8AAv.js +1 -0
  29. package/dist/assets/McpServer-BVxK5LwU.js +22 -0
  30. package/dist/assets/Metrics-Bm_pwxzy.js +1 -0
  31. package/dist/assets/MetricsPro-DTsnXQVd.js +1 -0
  32. package/dist/assets/Overview-O67Y-R5x.js +1 -0
  33. package/dist/assets/Pagination-B-nfZjQZ.js +1 -0
  34. package/dist/assets/QueueControl-BrHXeXPc.js +1 -0
  35. package/dist/assets/QueueDetail-D-C5SXxB.js +1 -0
  36. package/dist/assets/Queues-5tq1o_1I.js +1 -0
  37. package/dist/assets/QueuesOverview-ClZM34rT.js +1 -0
  38. package/dist/assets/S3Backup-BZfckRKX.js +1 -0
  39. package/dist/assets/S3BackupPro-CteU25_J.js +1 -0
  40. package/dist/assets/ServerControl-BSLX2WeV.js +3 -0
  41. package/dist/assets/Settings-BAELEgud.js +1 -0
  42. package/dist/assets/Usage-B_8T34Sp.js +1 -0
  43. package/dist/assets/UsagePro-BhhM0H05.js +1 -0
  44. package/dist/assets/Webhooks-B_qJ0bx1.js +1 -0
  45. package/dist/assets/Workers-N6qIdxg1.js +1 -0
  46. package/dist/assets/WorkersPro-DzjKVhCO.js +1 -0
  47. package/dist/assets/cn-CMFYpumL.js +1 -0
  48. package/dist/assets/connectionStore-DI-LYcct.js +1 -0
  49. package/dist/assets/form-PtDLk33Z.js +1 -0
  50. package/dist/assets/index-ChJpSdwV.js +8 -0
  51. package/dist/assets/index-DNaP_bi8.css +1 -0
  52. package/dist/assets/install-BvB40Zk0.js +3 -0
  53. package/dist/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
  54. package/dist/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
  55. package/dist/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
  56. package/dist/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
  57. package/dist/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
  58. package/dist/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
  59. package/dist/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
  60. package/dist/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
  61. package/dist/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
  62. package/dist/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
  63. package/dist/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
  64. package/dist/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
  65. package/dist/assets/jobActions-CuOdMglt.js +1 -0
  66. package/dist/assets/react-vendor-CHIHtdAC.js +11 -0
  67. package/dist/favicon.svg +27 -0
  68. package/dist/index.html +30 -0
  69. package/package.json +90 -0
  70. package/scripts/embedded.gen.ts +122 -0
  71. package/scripts/serve.ts +92 -0
package/agent/db.ts ADDED
@@ -0,0 +1,491 @@
1
+ /**
2
+ * Read-only SQLite inspector backing the dashboard's Database section.
3
+ *
4
+ * Every connection is opened with `{ readonly: true }`, so even a hand-typed
5
+ * query physically cannot mutate the store — SQLite rejects INSERT/UPDATE/DDL
6
+ * with SQLITE_READONLY at the engine level. Connections are opened per request
7
+ * and closed immediately, so the inspector never holds a lock against the
8
+ * running bunqueue server (WAL allows concurrent readers).
9
+ *
10
+ * Defense in depth for the arbitrary-query endpoint (dbQuery):
11
+ * - a positive statement allowlist rejects ATTACH/DETACH/CREATE-TEMP/etc.
12
+ * BEFORE execution, so store safety never relies on Bun's single-statement
13
+ * compilation quirk;
14
+ * - results are streamed with an early break at MAX_ROWS, so a huge or
15
+ * row-bomb query can't materialize the whole set in agent memory;
16
+ * - queryWithTimeout() runs it in a disposable Worker raced against a wall
17
+ * clock, so an O(N^k) scan can't pin the agent's only thread and freeze the
18
+ * process-control endpoints (falls back to synchronous run if a Worker can't
19
+ * be spawned, e.g. inside a compiled single-file binary).
20
+ */
21
+ import { existsSync } from 'node:fs';
22
+ import { Database } from 'bun:sqlite';
23
+
24
+ /** Hard cap on rows returned by any endpoint — bounds payload and memory. */
25
+ export const MAX_ROWS = 500;
26
+ /** Cell strings longer than this are truncated server-side (payload bound). */
27
+ const MAX_CELL = 2000;
28
+ /** Full single-cell fetch (detail view) hard cap — bounds one pathological blob. */
29
+ const MAX_FULL_CELL = 1_000_000;
30
+ /** Wall-clock budget for an arbitrary query before it is aborted. */
31
+ export const QUERY_TIMEOUT_MS = 5000;
32
+
33
+ /** Thrown when the database file does not exist yet (maps to HTTP 404). */
34
+ export class MissingDbError extends Error {
35
+ readonly missing = true;
36
+ }
37
+
38
+ export interface DbTableInfo {
39
+ name: string;
40
+ rows: number;
41
+ columns: number;
42
+ }
43
+
44
+ export interface DbFilter {
45
+ column: string;
46
+ op: 'contains' | 'eq' | 'ne';
47
+ value: string;
48
+ }
49
+
50
+ export interface DbRowsPage {
51
+ table: string;
52
+ columns: string[];
53
+ rows: unknown[][];
54
+ /** rowid per row (null for WITHOUT ROWID tables) — keys the full-cell fetch. */
55
+ rowids: (number | null)[];
56
+ /** Which cells were truncated server-side, so the UI can flag them. */
57
+ truncatedCells: boolean[][];
58
+ total: number;
59
+ limit: number;
60
+ offset: number;
61
+ orderBy: string | null;
62
+ dir: 'asc' | 'desc';
63
+ filter: DbFilter | null;
64
+ }
65
+
66
+ export interface DbColumnInfo {
67
+ name: string;
68
+ type: string;
69
+ notNull: boolean;
70
+ defaultValue: string | null;
71
+ primaryKey: boolean;
72
+ }
73
+
74
+ export interface DbIndexInfo {
75
+ name: string;
76
+ unique: boolean;
77
+ columns: string[];
78
+ }
79
+
80
+ export interface DbTableSchema {
81
+ table: string;
82
+ columns: DbColumnInfo[];
83
+ indexes: DbIndexInfo[];
84
+ /** Original CREATE TABLE statement from sqlite_master. */
85
+ sql: string | null;
86
+ rowCount: number;
87
+ }
88
+
89
+ export interface DbInfo {
90
+ sqliteVersion: string;
91
+ pageSize: number;
92
+ pageCount: number;
93
+ journalMode: string;
94
+ freelistPages: number;
95
+ tables: number;
96
+ indexes: number;
97
+ fileSize: number;
98
+ walSize: number;
99
+ }
100
+
101
+ export interface DbQueryResult {
102
+ columns: string[];
103
+ rows: unknown[][];
104
+ /** Row count actually returned. A lower bound (">= this") when `truncated`. */
105
+ rowCount: number;
106
+ truncated: boolean;
107
+ ms: number;
108
+ }
109
+
110
+ function open(path: string): Database {
111
+ // Distinguish "file missing" (expected before first server start → 404) from
112
+ // "file present but unreadable/corrupt" (a real error → 400/500).
113
+ try {
114
+ if (!Bun.file(path).size && !existsSync(path)) {
115
+ throw new MissingDbError(
116
+ `Database not found at "${path}" — start the server once to create it.`
117
+ );
118
+ }
119
+ } catch (e) {
120
+ if (e instanceof MissingDbError) throw e;
121
+ // Bun.file().size can throw on some paths; fall through to the open attempt.
122
+ }
123
+ try {
124
+ return new Database(path, { readonly: true });
125
+ } catch (e) {
126
+ if (!existsSync(path)) {
127
+ throw new MissingDbError(
128
+ `Database not found at "${path}" — start the server once to create it.`
129
+ );
130
+ }
131
+ throw new Error(`Could not open database: ${(e as Error).message}`);
132
+ }
133
+ }
134
+
135
+ /** Quote an identifier for SQLite ("" escapes embedded quotes). */
136
+ const ident = (name: string) => `"${name.replaceAll('"', '""')}"`;
137
+
138
+ /** True when a value is over the cell truncation bound. */
139
+ function isTruncated(v: unknown): boolean {
140
+ return (v instanceof Uint8Array && v.byteLength > 0) || (typeof v === 'string' && v.length > MAX_CELL);
141
+ }
142
+
143
+ /** JSON-safe cell: BLOBs become a marker, long strings are truncated. */
144
+ function cell(v: unknown): unknown {
145
+ if (v instanceof Uint8Array) return `<blob ${v.byteLength} B>`;
146
+ if (typeof v === 'string' && v.length > MAX_CELL) return `${v.slice(0, MAX_CELL)}…`;
147
+ if (typeof v === 'bigint') return v.toString();
148
+ return v;
149
+ }
150
+
151
+ const sanitize = (rows: unknown[][]): unknown[][] => rows.map((r) => r.map(cell));
152
+
153
+ /** All user tables with row + column counts. */
154
+ export function dbTables(path: string): DbTableInfo[] {
155
+ const db = open(path);
156
+ try {
157
+ const tables = db
158
+ .query(
159
+ "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name"
160
+ )
161
+ .all() as { name: string }[];
162
+ return tables.map(({ name }) => {
163
+ const { c } = db.query(`SELECT COUNT(*) AS c FROM ${ident(name)}`).get() as { c: number };
164
+ const cols = db.query(`PRAGMA table_info(${ident(name)})`).all();
165
+ return { name, rows: c, columns: cols.length };
166
+ });
167
+ } finally {
168
+ db.close();
169
+ }
170
+ }
171
+
172
+ /** Resolve a caller-supplied table name against sqlite_master (identifier allowlist). */
173
+ function knownTable(db: Database, table: string): string {
174
+ const known = db
175
+ .query("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
176
+ .get(table) as { name: string } | null;
177
+ if (!known) throw new Error(`No such table: ${table}`);
178
+ return known.name;
179
+ }
180
+
181
+ /** Build a validated WHERE clause + bound param for a browse filter (never interpolates the value). */
182
+ function buildFilter(
183
+ columns: string[],
184
+ filter: DbFilter | undefined
185
+ ): { clause: string; params: unknown[]; applied: DbFilter | null } {
186
+ if (!filter || !filter.value) return { clause: '', params: [], applied: null };
187
+ const col = columns.find((c) => c === filter.column);
188
+ if (!col) throw new Error(`No such column: ${filter.column}`);
189
+ const q = ident(col);
190
+ if (filter.op === 'eq') return { clause: ` WHERE ${q} = ?`, params: [filter.value], applied: filter };
191
+ if (filter.op === 'ne') return { clause: ` WHERE ${q} <> ?`, params: [filter.value], applied: filter };
192
+ // contains (default)
193
+ return { clause: ` WHERE ${q} LIKE ?`, params: [`%${filter.value}%`], applied: { ...filter, op: 'contains' } };
194
+ }
195
+
196
+ /**
197
+ * One page of a table's rows. Table, orderBy and filter-column identifiers are
198
+ * validated against sqlite_master / table_info — never interpolated from raw
199
+ * input; the filter value is always bound as a parameter.
200
+ */
201
+ export function dbRows(
202
+ path: string,
203
+ table: string,
204
+ limit: number,
205
+ offset: number,
206
+ orderBy?: string,
207
+ dir: 'asc' | 'desc' = 'asc',
208
+ filter?: DbFilter
209
+ ): DbRowsPage {
210
+ const lim = Math.max(1, Math.min(MAX_ROWS, Math.floor(limit) || 50));
211
+ const off = Math.max(0, Math.floor(offset) || 0);
212
+ const db = open(path);
213
+ try {
214
+ const name = knownTable(db, table);
215
+ const columns = (db.query(`PRAGMA table_info(${ident(name)})`).all() as { name: string }[]).map(
216
+ (c) => c.name
217
+ );
218
+
219
+ let order = '';
220
+ let sortCol: string | null = null;
221
+ if (orderBy) {
222
+ sortCol = columns.find((c) => c === orderBy) ?? null;
223
+ if (!sortCol) throw new Error(`No such column: ${orderBy}`);
224
+ order = ` ORDER BY ${ident(sortCol)} ${dir === 'desc' ? 'DESC' : 'ASC'}`;
225
+ }
226
+
227
+ const { clause, params, applied } = buildFilter(columns, filter);
228
+
229
+ const { c: total } = db
230
+ .query(`SELECT COUNT(*) AS c FROM ${ident(name)}${clause}`)
231
+ .get(...(params as [])) as { c: number };
232
+
233
+ // Prefer selecting rowid so the detail view can fetch untruncated cells;
234
+ // WITHOUT ROWID tables have no rowid column — fall back to a plain select.
235
+ let rowids: (number | null)[] = [];
236
+ let rawRows: unknown[][];
237
+ try {
238
+ const withRowid = db
239
+ .query(`SELECT rowid AS __rid, * FROM ${ident(name)}${clause}${order} LIMIT ? OFFSET ?`)
240
+ .values(...(params as []), lim, off) as unknown[][];
241
+ rowids = withRowid.map((r) => (typeof r[0] === 'number' ? r[0] : null));
242
+ rawRows = withRowid.map((r) => r.slice(1));
243
+ } catch {
244
+ rawRows = db
245
+ .query(`SELECT * FROM ${ident(name)}${clause}${order} LIMIT ? OFFSET ?`)
246
+ .values(...(params as []), lim, off) as unknown[][];
247
+ rowids = rawRows.map(() => null);
248
+ }
249
+
250
+ const truncatedCells = rawRows.map((r) => r.map(isTruncated));
251
+ return {
252
+ table: name,
253
+ columns,
254
+ rows: sanitize(rawRows),
255
+ rowids,
256
+ truncatedCells,
257
+ total,
258
+ limit: lim,
259
+ offset: off,
260
+ orderBy: sortCol,
261
+ dir: dir === 'desc' ? 'desc' : 'asc',
262
+ filter: applied,
263
+ };
264
+ } finally {
265
+ db.close();
266
+ }
267
+ }
268
+
269
+ /** Full, untruncated value of a single cell, keyed by rowid (for the detail view). */
270
+ export function dbCell(path: string, table: string, rowid: number, column: string): { value: unknown } {
271
+ const db = open(path);
272
+ try {
273
+ const name = knownTable(db, table);
274
+ const columns = (db.query(`PRAGMA table_info(${ident(name)})`).all() as { name: string }[]).map(
275
+ (c) => c.name
276
+ );
277
+ const col = columns.find((c) => c === column);
278
+ if (!col) throw new Error(`No such column: ${column}`);
279
+ const row = db
280
+ .query(`SELECT ${ident(col)} AS v FROM ${ident(name)} WHERE rowid = ?`)
281
+ .get(rowid) as { v: unknown } | null;
282
+ if (!row) throw new Error('Row not found');
283
+ let v = row.v;
284
+ if (v instanceof Uint8Array) v = `<blob ${v.byteLength} B — binary, not shown>`;
285
+ else if (typeof v === 'bigint') v = v.toString();
286
+ else if (typeof v === 'string' && v.length > MAX_FULL_CELL) v = `${v.slice(0, MAX_FULL_CELL)}…`;
287
+ return { value: v };
288
+ } finally {
289
+ db.close();
290
+ }
291
+ }
292
+
293
+ /** Column definitions, indexes and original DDL for one table. */
294
+ export function dbSchema(path: string, table: string): DbTableSchema {
295
+ const db = open(path);
296
+ try {
297
+ const name = knownTable(db, table);
298
+ const columns = (
299
+ db.query(`PRAGMA table_info(${ident(name)})`).all() as {
300
+ name: string;
301
+ type: string;
302
+ notnull: number;
303
+ dflt_value: string | null;
304
+ pk: number;
305
+ }[]
306
+ ).map((c) => ({
307
+ name: c.name,
308
+ type: c.type || 'ANY',
309
+ notNull: c.notnull === 1,
310
+ defaultValue: c.dflt_value,
311
+ primaryKey: c.pk > 0,
312
+ }));
313
+ const indexes = (
314
+ db.query(`PRAGMA index_list(${ident(name)})`).all() as { name: string; unique: number }[]
315
+ ).map((ix) => ({
316
+ name: ix.name,
317
+ unique: ix.unique === 1,
318
+ columns: (db.query(`PRAGMA index_info(${ident(ix.name)})`).all() as { name: string }[]).map(
319
+ (c) => c.name
320
+ ),
321
+ }));
322
+ const master = db
323
+ .query("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?")
324
+ .get(name) as { sql: string | null } | null;
325
+ const { c: rowCount } = db.query(`SELECT COUNT(*) AS c FROM ${ident(name)}`).get() as {
326
+ c: number;
327
+ };
328
+ return { table: name, columns, indexes, sql: master?.sql ?? null, rowCount };
329
+ } finally {
330
+ db.close();
331
+ }
332
+ }
333
+
334
+ /** Store-level metadata: engine version, pragmas, object counts, on-disk size. */
335
+ export function dbInfo(path: string): DbInfo {
336
+ const db = open(path);
337
+ try {
338
+ const one = (sql: string) => (db.query(sql).values()[0] as unknown[])[0];
339
+ const count = (type: string) =>
340
+ (
341
+ db
342
+ .query(
343
+ "SELECT COUNT(*) AS c FROM sqlite_master WHERE type = ? AND name NOT LIKE 'sqlite_%'"
344
+ )
345
+ .get(type) as { c: number }
346
+ ).c;
347
+ const size = (p: string) => {
348
+ try {
349
+ return Bun.file(p).size || 0;
350
+ } catch {
351
+ return 0;
352
+ }
353
+ };
354
+ return {
355
+ sqliteVersion: String(one('SELECT sqlite_version()')),
356
+ pageSize: Number(one('PRAGMA page_size')),
357
+ pageCount: Number(one('PRAGMA page_count')),
358
+ journalMode: String(one('PRAGMA journal_mode')),
359
+ freelistPages: Number(one('PRAGMA freelist_count')),
360
+ tables: count('table'),
361
+ indexes: count('index'),
362
+ fileSize: size(path),
363
+ walSize: size(`${path}-wal`),
364
+ };
365
+ } finally {
366
+ db.close();
367
+ }
368
+ }
369
+
370
+ /** Leading keyword of a statement, ignoring leading whitespace/comments. */
371
+ function leadingKeyword(sql: string): string {
372
+ const head = sql.replace(/^(?:\s|--[^\n]*\n|\/\*[\s\S]*?\*\/)*/, '');
373
+ return head.slice(0, 12).toUpperCase();
374
+ }
375
+
376
+ const READ_ONLY_LEAD = /^(?:SELECT|WITH|EXPLAIN|VALUES|PRAGMA)\b/;
377
+
378
+ /**
379
+ * Run an arbitrary read-only query. Rejects non-read statements up front,
380
+ * streams rows with an early break at MAX_ROWS, and translates the raw SQLite
381
+ * read-only error into a friendly message. Synchronous + pure (see
382
+ * queryWithTimeout for the off-thread, time-boxed wrapper the route uses).
383
+ */
384
+ export function dbQuery(path: string, sql: string): DbQueryResult {
385
+ const trimmed = sql.trim();
386
+ if (!trimmed) throw new Error('Empty query');
387
+ if (!READ_ONLY_LEAD.test(leadingKeyword(trimmed))) {
388
+ throw new Error('Only read-only SELECT / WITH / EXPLAIN / VALUES / PRAGMA queries are allowed here.');
389
+ }
390
+ const db = open(path);
391
+ try {
392
+ const started = performance.now();
393
+ let stmt: ReturnType<Database['query']>;
394
+ try {
395
+ stmt = db.query(trimmed);
396
+ } catch (e) {
397
+ throw translateError(e);
398
+ }
399
+ const cols = stmt.columnNames;
400
+ const rows: unknown[][] = [];
401
+ let truncated = false;
402
+ try {
403
+ // Rebuild positional arrays from the object iterator so memory is bounded
404
+ // to MAX_ROWS regardless of the query's true cardinality.
405
+ for (const row of stmt.iterate() as IterableIterator<Record<string, unknown>>) {
406
+ if (rows.length >= MAX_ROWS) {
407
+ truncated = true;
408
+ break;
409
+ }
410
+ rows.push(cols.map((c) => (row as Record<string, unknown>)[c]));
411
+ }
412
+ } catch (e) {
413
+ throw translateError(e);
414
+ }
415
+ const ms = performance.now() - started;
416
+ return {
417
+ columns: cols,
418
+ rows: sanitize(rows),
419
+ rowCount: rows.length,
420
+ truncated,
421
+ ms: Math.round(ms * 10) / 10,
422
+ };
423
+ } finally {
424
+ db.close();
425
+ }
426
+ }
427
+
428
+ function translateError(e: unknown): Error {
429
+ const msg = String((e as Error)?.message ?? e);
430
+ if (/readonly|read-only|SQLITE_READONLY/i.test(msg)) {
431
+ return new Error('Connection is read-only — INSERT / UPDATE / DELETE / DDL are not permitted here.');
432
+ }
433
+ return e instanceof Error ? e : new Error(msg);
434
+ }
435
+
436
+ /**
437
+ * Time-boxed, off-thread execution of an arbitrary query. Spawns a disposable
438
+ * readonly Worker and races it against QUERY_TIMEOUT_MS so a runaway scan can't
439
+ * freeze the agent's control endpoints. Falls back to a synchronous run when a
440
+ * Worker cannot be created (e.g. a compiled single-file binary) — still bounded
441
+ * in memory and statement type, just not interruptible.
442
+ */
443
+ export async function queryWithTimeout(path: string, sql: string): Promise<DbQueryResult> {
444
+ let worker: Worker;
445
+ try {
446
+ worker = new Worker(new URL('./dbQueryWorker.ts', import.meta.url).href, { type: 'module' });
447
+ } catch {
448
+ return dbQuery(path, sql); // no Worker available — degrade to synchronous
449
+ }
450
+ try {
451
+ return await new Promise<DbQueryResult>((resolve, reject) => {
452
+ // The Worker module is not always available at runtime — notably a
453
+ // `bun build --compile` single-file binary does not embed it, and there
454
+ // `new Worker(...)` does NOT throw synchronously: it resolves and then
455
+ // fails asynchronously via the `error` event (ModuleNotFound). Guard both
456
+ // paths and degrade to a synchronous run rather than failing the request.
457
+ let settled = false;
458
+ const timer = setTimeout(() => {
459
+ if (settled) return;
460
+ settled = true;
461
+ worker.terminate();
462
+ reject(new Error(`Query exceeded the ${QUERY_TIMEOUT_MS / 1000}s time limit and was aborted.`));
463
+ }, QUERY_TIMEOUT_MS);
464
+ worker.addEventListener('message', (ev: MessageEvent) => {
465
+ if (settled) return;
466
+ settled = true;
467
+ clearTimeout(timer);
468
+ const d = ev.data as { ok: boolean; result?: DbQueryResult; error?: string };
469
+ if (d.ok && d.result) resolve(d.result);
470
+ else reject(new Error(d.error ?? 'Query failed'));
471
+ });
472
+ worker.addEventListener('error', (ev: ErrorEvent) => {
473
+ if (settled) return;
474
+ settled = true;
475
+ clearTimeout(timer);
476
+ // Worker infra itself failed to load/run (e.g. not embedded in the
477
+ // compiled binary). Fall back to the synchronous path — still readonly,
478
+ // allowlisted and row-capped, just not interruptible by the wall clock.
479
+ ev.preventDefault?.();
480
+ try {
481
+ resolve(dbQuery(path, sql));
482
+ } catch (e) {
483
+ reject(translateError(e));
484
+ }
485
+ });
486
+ worker.postMessage({ path, sql });
487
+ });
488
+ } finally {
489
+ worker.terminate();
490
+ }
491
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Disposable worker that runs ONE read-only query and posts the result back.
3
+ *
4
+ * queryWithTimeout() (agent/db.ts) spawns one of these per /db/query request and
5
+ * races it against a wall clock; on timeout the parent terminate()s this worker,
6
+ * so a runaway O(N^k) scan is killed instead of pinning the agent's only thread
7
+ * and freezing the process-control endpoints. The connection is opened readonly
8
+ * inside dbQuery, so nothing here can mutate the store.
9
+ */
10
+ import { dbQuery } from './db';
11
+
12
+ declare const self: Worker;
13
+
14
+ self.addEventListener('message', (ev: MessageEvent) => {
15
+ const { path, sql } = ev.data as { path: string; sql: string };
16
+ try {
17
+ self.postMessage({ ok: true, result: dbQuery(path, sql) });
18
+ } catch (e) {
19
+ self.postMessage({ ok: false, error: (e as Error).message ?? String(e) });
20
+ }
21
+ });
package/agent/index.ts ADDED
@@ -0,0 +1,47 @@
1
+ /**
2
+ * bunqueue dashboard control agent.
3
+ *
4
+ * A tiny local Bun server that lets the dashboard start / stop / restart a
5
+ * bunqueue server process. It can spawn processes, so it binds 127.0.0.1 only
6
+ * AND enforces an Origin allowlist + locked CORS (never `*`) so a malicious web
7
+ * page cannot drive it via CSRF (see server.ts for the full threat model). Set
8
+ * AGENT_TOKEN for an extra bearer-token gate on state-changing requests.
9
+ *
10
+ * Run: bun run agent/index.ts (default port 6800)
11
+ * Env: AGENT_PORT, AGENT_ALLOWED_ORIGINS, AGENT_TOKEN,
12
+ * BUNQUEUE_START_CMD, HTTP_PORT, TCP_PORT, BUNQUEUE_DATA_PATH
13
+ */
14
+ import { logger } from './logger';
15
+ import { ProcessManager } from './manager';
16
+ import { createFetchHandler, resolveAllowedOrigins } from './server';
17
+
18
+ const mgr = new ProcessManager();
19
+ const PORT = Number(process.env.AGENT_PORT) || 6800;
20
+ const allowedOrigins = resolveAllowedOrigins();
21
+ const token = process.env.AGENT_TOKEN || undefined;
22
+
23
+ const handle = createFetchHandler(mgr, { allowedOrigins, token });
24
+
25
+ Bun.serve({
26
+ port: PORT,
27
+ hostname: '127.0.0.1',
28
+ fetch: handle,
29
+ });
30
+
31
+ // Stop the managed server before exiting — without this, Ctrl-C / SIGTERM on
32
+ // the agent orphans the spawned bunqueue child (it reparents to PID 1 and keeps
33
+ // holding the ports and the SQLite db, so the next start fails EADDRINUSE).
34
+ let shuttingDown = false;
35
+ const shutdown = (signal: string) => {
36
+ if (shuttingDown) return;
37
+ shuttingDown = true;
38
+ logger.info({ signal }, 'signal received, stopping managed server');
39
+ void mgr.stop().finally(() => process.exit(0));
40
+ };
41
+ process.on('SIGINT', () => shutdown('SIGINT'));
42
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
43
+
44
+ logger.info(
45
+ { url: `http://127.0.0.1:${PORT}/control`, allowedOrigins, tokenAuth: Boolean(token) },
46
+ 'bunqueue dashboard control agent ready'
47
+ );
@@ -0,0 +1,23 @@
1
+ import pino from 'pino';
2
+ import pretty from 'pino-pretty';
3
+
4
+ /**
5
+ * Shared structured logger for the Bun-side infra (the control agent and the
6
+ * standalone server). Pretty, colorized output on an interactive terminal;
7
+ * newline-delimited JSON everywhere else (piped, redirected, or under a
8
+ * process manager) so logs stay machine-parseable in production.
9
+ *
10
+ * pino-pretty is attached as a synchronous *stream*, not a worker-thread
11
+ * transport, so this also works inside the `bun build --compile` standalone
12
+ * binary (a transport worker module isn't embedded by the compiler).
13
+ *
14
+ * Level is controlled by LOG_LEVEL (default "info").
15
+ */
16
+ const level = process.env.LOG_LEVEL ?? 'info';
17
+
18
+ export const logger = process.stdout.isTTY
19
+ ? pino(
20
+ { level },
21
+ pretty({ colorize: true, translateTime: 'SYS:HH:MM:ss', ignore: 'pid,hostname' })
22
+ )
23
+ : pino({ level });