bunqueue-dashboard 0.0.46 → 1.0.1
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 +6 -6
- package/agent/compiledRuntime.ts +27 -0
- package/agent/db/boundedStream.ts +18 -0
- package/agent/db/exportTimeout.ts +8 -6
- package/agent/db/processWorker.ts +121 -0
- package/agent/db/queryTimeout.ts +62 -50
- package/agent/db/readAdmission.ts +53 -0
- package/agent/db/readOperations.ts +27 -0
- package/agent/db/readProcessMain.ts +64 -0
- package/agent/db/types.ts +3 -0
- package/agent/db/workerFactory.ts +12 -10
- package/agent/dbReadWorker.ts +21 -0
- package/agent/index.ts +2 -1
- package/agent/managedTarget.ts +3 -1
- package/agent/manager/commandLine.ts +33 -0
- package/agent/manager/configStore.ts +65 -0
- package/agent/manager/errors.ts +4 -0
- package/agent/manager/process.ts +12 -3
- package/agent/manager.ts +1 -0
- package/agent/server/controlRoutes.ts +10 -0
- package/agent/server/controlStatus.ts +26 -5
- package/agent/server/databaseRoutes.ts +13 -17
- package/agent/server/errors.ts +6 -0
- package/agent/server/handler.ts +2 -1
- package/agent/server/router.ts +2 -1
- package/agent/server/workflowReadRoutes.ts +9 -11
- package/agent/shutdown.ts +3 -2
- package/agent/workflow/sqlite.ts +1 -1
- package/dist/.vite/manifest.json +537 -575
- package/dist/assets/AddJob-CiwWycYG.js +3 -0
- package/dist/assets/Alerts-DzKKoA9U.js +1 -0
- package/dist/assets/AreaChart-Db2UVnaK.js +1 -0
- package/dist/assets/Benchmark-CHYDL31K.js +1 -0
- package/dist/assets/BulkAddJobs-GRMVnf0j.js +5 -0
- package/dist/assets/Button-_aSJvRoW.js +1 -0
- package/dist/assets/Card-egIDLwVk.js +1 -0
- package/dist/assets/Cron-XL0PdUEc.js +1 -0
- package/dist/assets/CronManager-Cqtg9Zr8.js +1 -0
- package/dist/assets/{Database-DV2pcDLL.js → Database-BoxVpjts.js} +3 -3
- package/dist/assets/Diagnostics-B3QSQuFF.js +1 -0
- package/dist/assets/Dlq-DHOfgORa.js +1 -0
- package/dist/assets/DlqControl-BY1e55-x.js +1 -0
- package/dist/assets/DlqPro-CfwKDcpc.js +1 -0
- package/dist/assets/Fleet-Bkbq0dFB.js +1 -0
- package/dist/assets/Flows-CDCQrPit.js +2 -0
- package/dist/assets/JobInspector-Bw3Gs1Iy.js +2 -0
- package/dist/assets/Jobs-IrlneOy6.js +1 -0
- package/dist/assets/JobsPro-vlPd6Ows.js +1 -0
- package/dist/assets/Logs-DG7cK-tF.js +1 -0
- package/dist/assets/LogsPro-ClSqFKys.js +1 -0
- package/dist/assets/{McpServer-fVy27Mof.js → McpServer-DoNFY0Pj.js} +1 -1
- package/dist/assets/{Metrics-Ba46pdzT.js → Metrics-BkePZiCZ.js} +1 -1
- package/dist/assets/MetricsPro-ooSvpQ3H.js +1 -0
- package/dist/assets/{Overview-DV2qo5v3.js → Overview-CuWmPRra.js} +1 -1
- package/dist/assets/{PageHeader-CvIyaFOO.js → PageHeader-CQzNsw8r.js} +1 -1
- package/dist/assets/Pagination-CSkzhNW6.js +1 -0
- package/dist/assets/QueueActions-BBxRt-t0.js +1 -0
- package/dist/assets/QueueControl-C01l6bBJ.js +1 -0
- package/dist/assets/QueueDetail-gSf9Tb7G.js +1 -0
- package/dist/assets/QueueDetailPro-BKszqI0M.js +1 -0
- package/dist/assets/Queues-BAg2nSz0.js +1 -0
- package/dist/assets/QueuesOverview-BLSjiYsA.js +1 -0
- package/dist/assets/{S3Backup-DNABHR2Q.js → S3Backup-Ci4dJPP_.js} +1 -1
- package/dist/assets/S3BackupPro-DcjlrzC5.js +3 -0
- package/dist/assets/ServerControl-SiZBNo9P.js +3 -0
- package/dist/assets/Settings-DR_iqW0T.js +1 -0
- package/dist/assets/StatusBadge-DereLzOj.js +1 -0
- package/dist/assets/{Usage-1KxG1mJ9.js → Usage-ZWfB7PcR.js} +1 -1
- package/dist/assets/UsagePro-BjdeS2zb.js +1 -0
- package/dist/assets/Webhooks-BonYUgMo.js +1 -0
- package/dist/assets/Workers-C1h6hCPp.js +1 -0
- package/dist/assets/WorkersPro-B0f_Nfdz.js +1 -0
- package/dist/assets/Workflows-CqZZ96EK.js +1 -0
- package/dist/assets/{api-BgWE-705.js → api-D9aT0ND7.js} +1 -1
- package/dist/assets/bq-DR2BTISO.js +1 -0
- package/dist/assets/classicJobsData-Csj4iihY.js +1 -0
- package/dist/assets/connectionStore-CVjQyIZv.js +1 -0
- package/dist/assets/controlConnectionEpoch-ChAeMMLk.js +1 -0
- package/dist/assets/feedback-CtGN_kqB.js +1 -0
- package/dist/assets/form-DG3X6cLL.js +1 -0
- package/dist/assets/{icons-BVzwsfbG.js → icons-DH9SS1DA.js} +1 -1
- package/dist/assets/index-Bat1DN5U.js +8 -0
- package/dist/assets/react-vendor-CFL9l8LK.js +11 -0
- package/dist/assets/{cn-zFEt5SO8.js → rolldown-runtime-QTnfLwEv.js} +1 -1
- package/dist/assets/{toastStore-IZn6OPLZ.js → toastStore-XmMX7A5W.js} +1 -1
- package/dist/assets/useControlActionGuard-1i7aFA7E.js +1 -0
- package/dist/assets/usePolledData-CEyyLRYa.js +1 -0
- package/dist/assets/useServerActionGuard-CUx8TOAC.js +1 -0
- package/dist/assets/useThroughputSeries-CeFx-Mwq.js +1 -0
- package/dist/index.html +14 -14
- package/package.json +16 -10
- package/scripts/embedded.gen.ts +152 -162
- package/scripts/serve.ts +10 -15
- package/scripts/serveHandler.ts +28 -1
- package/dist/assets/AddJob-FNaPbs_V.js +0 -3
- package/dist/assets/Alerts-vzIYtFpW.js +0 -1
- package/dist/assets/AreaChart-DEAalJ6P.js +0 -1
- package/dist/assets/Benchmark-C16tT4pF.js +0 -1
- package/dist/assets/BulkAddJobs-IiL1uud0.js +0 -5
- package/dist/assets/Button-Ch1W94fJ.js +0 -1
- package/dist/assets/Card-CWxrC8OY.js +0 -1
- package/dist/assets/CopilotPanel-FlAW3RlL.js +0 -44
- package/dist/assets/Cron-1DZTZWXO.js +0 -1
- package/dist/assets/CronManager-B6xLLgI5.js +0 -1
- package/dist/assets/Diagnostics-B6apZrC5.js +0 -1
- package/dist/assets/Dlq-MRmkQrNC.js +0 -1
- package/dist/assets/DlqControl-DNJPVOTF.js +0 -1
- package/dist/assets/DlqPro-CwtTDwGN.js +0 -1
- package/dist/assets/Fleet-Bdrj9bLI.js +0 -1
- package/dist/assets/Flows-1dC8F_wx.js +0 -2
- package/dist/assets/JobInspector-Ct9Ga-UT.js +0 -2
- package/dist/assets/Jobs-Chqv0RuL.js +0 -1
- package/dist/assets/JobsPro-CLzOhKAL.js +0 -1
- package/dist/assets/Logs-C5PNP9Wq.js +0 -1
- package/dist/assets/LogsPro-B3KwTSsn.js +0 -1
- package/dist/assets/MetricsPro-B9rD_KQc.js +0 -1
- package/dist/assets/Pagination-x-Cwgrul.js +0 -1
- package/dist/assets/QueueActions-BWnmNEqV.js +0 -1
- package/dist/assets/QueueControl-DqBvuDnF.js +0 -1
- package/dist/assets/QueueDetail-COSH9bJl.js +0 -1
- package/dist/assets/QueueDetailPro-ClKtyHIf.js +0 -1
- package/dist/assets/Queues-D2-R8nYh.js +0 -1
- package/dist/assets/QueuesOverview-tokquA3g.js +0 -1
- package/dist/assets/S3BackupPro-CDX5WQUY.js +0 -3
- package/dist/assets/ServerControl-BmccMNW9.js +0 -3
- package/dist/assets/Settings-UcujwvKv.js +0 -1
- package/dist/assets/StatusBadge-BoOVTnKu.js +0 -1
- package/dist/assets/UsagePro-BGLufsE7.js +0 -1
- package/dist/assets/Webhooks-DT2LK17V.js +0 -1
- package/dist/assets/Workers-BX1qspaQ.js +0 -1
- package/dist/assets/WorkersPro-BsUQVgQZ.js +0 -1
- package/dist/assets/Workflows-DZZA6aGW.js +0 -1
- package/dist/assets/bq-F5BbTG1T.js +0 -1
- package/dist/assets/connectionStore-BJLihGaC.js +0 -1
- package/dist/assets/controlConnectionEpoch-CZsS0wAb.js +0 -1
- package/dist/assets/dist-1f1fMHVO.js +0 -11
- package/dist/assets/dist-BlWBaCN6.js +0 -11
- package/dist/assets/dist-DZL8soAh.js +0 -79
- package/dist/assets/dist-DjvZiKhB.js +0 -2
- package/dist/assets/dist-DpZcbP9W.js +0 -11
- package/dist/assets/feedback-QDN1AsYt.js +0 -1
- package/dist/assets/form-C4oby9pI.js +0 -1
- package/dist/assets/index-8Ii-_Rml.js +0 -8
- package/dist/assets/react-vendor-CvwrP0tU.js +0 -11
- package/dist/assets/useControlActionGuard-B-bzKZKI.js +0 -1
- package/dist/assets/usePolledData-0CVelkl2.js +0 -1
- package/dist/assets/useServerActionGuard-uGePilQQ.js +0 -1
- package/dist/assets/useThroughputSeries-DgO8uicf.js +0 -1
package/README.md
CHANGED
|
@@ -26,7 +26,7 @@ The live demo uses sample data. The docs explain how to connect and operate a re
|
|
|
26
26
|
[](LICENSE)
|
|
27
27
|
[](https://egeominotti.github.io/bunqueue-dashboard/docs/)
|
|
28
28
|
[](https://egeominotti.github.io/bunqueue-dashboard/)
|
|
29
|
-

|
|
30
30
|
|
|
31
31
|

|
|
32
32
|

|
|
@@ -58,9 +58,9 @@ The live demo uses sample data. The docs explain how to connect and operate a re
|
|
|
58
58
|
|
|
59
59
|
---
|
|
60
60
|
|
|
61
|
-
>
|
|
62
|
-
>
|
|
63
|
-
>
|
|
61
|
+
> ✅ **Stable (1.x).** The dashboard follows [Semantic Versioning](https://semver.org/):
|
|
62
|
+
> breaking changes to its configuration, routes or agent API only ship in a new
|
|
63
|
+
> major version.
|
|
64
64
|
|
|
65
65
|
## 📚 Documentation
|
|
66
66
|
|
|
@@ -129,8 +129,8 @@ contracts; it never patches Bunqueue internals.
|
|
|
129
129
|
> registration or flow order. Cancel, Discard, Drain, Clean, Obliterate and DLQ Purge also fail
|
|
130
130
|
> closed. Bunqueue 2.9's `Queue.removeDlqJob()` also accepts only queue + job ID, so custom-ID reuse
|
|
131
131
|
> prevents the dashboard from proving that a later deletion still targets the observed generation.
|
|
132
|
-
> DLQ `maxAge`/`maxEntries` are read-only, auto-retry can only be disabled
|
|
133
|
-
>
|
|
132
|
+
> DLQ `maxAge`/`maxEntries` are read-only, and auto-retry can only be disabled. The in-app Copilot
|
|
133
|
+
> is switched off in official builds (`VITE_ENABLE_COPILOT=1` opts a custom build back in).
|
|
134
134
|
|
|
135
135
|
## Quick start
|
|
136
136
|
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/** Bun uses a POSIX virtual root and a drive-qualified Windows virtual root. */
|
|
2
|
+
function compiledRoot(moduleUrl: string): URL | null {
|
|
3
|
+
const url = new URL(moduleUrl);
|
|
4
|
+
if (url.protocol !== 'file:') return null;
|
|
5
|
+
// Windows import.meta.url escapes the virtual root as /B:/%7EBUN/root/.
|
|
6
|
+
let pathname: string;
|
|
7
|
+
try { pathname = decodeURIComponent(url.pathname); } catch { return null; }
|
|
8
|
+
for (const marker of ['/$bunfs/root/', '/~BUN/root/']) {
|
|
9
|
+
const index = pathname.indexOf(marker);
|
|
10
|
+
if (index !== -1) {
|
|
11
|
+
url.pathname = pathname.slice(0, index + marker.length);
|
|
12
|
+
url.search = '';
|
|
13
|
+
url.hash = '';
|
|
14
|
+
return url;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function isCompiledModule(moduleUrl: string): boolean {
|
|
21
|
+
return compiledRoot(moduleUrl) !== null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function compiledWorkerUrl(moduleUrl: string, path: string): string | null {
|
|
25
|
+
const root = compiledRoot(moduleUrl);
|
|
26
|
+
return root ? new URL(path, root).href : null;
|
|
27
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** Bound both pipe buffering and the final allocation before decoding a child response. */
|
|
2
|
+
export async function readBoundedBytes(stream: ReadableStream<Uint8Array>, limit: number): Promise<Buffer> {
|
|
3
|
+
const reader = stream.getReader();
|
|
4
|
+
const chunks: Uint8Array[] = [];
|
|
5
|
+
let bytes = 0;
|
|
6
|
+
try {
|
|
7
|
+
for (;;) {
|
|
8
|
+
const next = await reader.read();
|
|
9
|
+
if (next.done) break;
|
|
10
|
+
bytes += next.value.byteLength;
|
|
11
|
+
if (bytes > limit) throw new Error('Database process output exceeded its byte limit');
|
|
12
|
+
chunks.push(next.value);
|
|
13
|
+
}
|
|
14
|
+
return Buffer.concat(chunks, bytes);
|
|
15
|
+
} finally {
|
|
16
|
+
reader.releaseLock();
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { validateWorkerBounds } from './core';
|
|
2
|
+
import type { DatabaseWorker } from './processWorker';
|
|
2
3
|
import { createExportWorker } from './workerFactory';
|
|
3
4
|
import {
|
|
4
5
|
DB_EXPORT_MAX_ROWS,
|
|
@@ -14,7 +15,7 @@ import {
|
|
|
14
15
|
|
|
15
16
|
let liveExportWorkers = 0;
|
|
16
17
|
|
|
17
|
-
/** Export workers alive now, including
|
|
18
|
+
/** Export workers alive now, including cancelled children being reaped. */
|
|
18
19
|
export const exportWorkerLoad = (): number => liveExportWorkers;
|
|
19
20
|
|
|
20
21
|
function validatedWorkerExport(value: unknown, expectedTable: string): DbCsvExport {
|
|
@@ -67,11 +68,11 @@ export async function exportWithTimeout(
|
|
|
67
68
|
signal?.throwIfAborted();
|
|
68
69
|
if (liveExportWorkers >= MAX_CONCURRENT_EXPORTS) {
|
|
69
70
|
throw new DbExportBusyError(
|
|
70
|
-
'A database export is already running.
|
|
71
|
+
'A database export is already running. Retry after it finishes.'
|
|
71
72
|
);
|
|
72
73
|
}
|
|
73
74
|
|
|
74
|
-
let worker:
|
|
75
|
+
let worker: DatabaseWorker;
|
|
75
76
|
try {
|
|
76
77
|
worker = createExportWorker();
|
|
77
78
|
} catch (error) {
|
|
@@ -117,7 +118,7 @@ export async function exportWithTimeout(
|
|
|
117
118
|
reject(new DbExportUnavailableError('Database export worker exited unexpectedly'));
|
|
118
119
|
}
|
|
119
120
|
});
|
|
120
|
-
worker.addEventListener('message', (event
|
|
121
|
+
worker.addEventListener('message', (event) => {
|
|
121
122
|
if (settled) return;
|
|
122
123
|
settled = true;
|
|
123
124
|
cleanup();
|
|
@@ -147,7 +148,7 @@ export async function exportWithTimeout(
|
|
|
147
148
|
);
|
|
148
149
|
}
|
|
149
150
|
});
|
|
150
|
-
worker.addEventListener('error', (event
|
|
151
|
+
worker.addEventListener('error', (event) => {
|
|
151
152
|
if (settled) return;
|
|
152
153
|
event.preventDefault?.();
|
|
153
154
|
abandon(
|
|
@@ -161,7 +162,7 @@ export async function exportWithTimeout(
|
|
|
161
162
|
() =>
|
|
162
163
|
abandon(
|
|
163
164
|
new DbExportUnavailableError(
|
|
164
|
-
`Database export exceeded the ${DB_EXPORT_TIMEOUT_MS / 1000}s time limit and was
|
|
165
|
+
`Database export exceeded the ${DB_EXPORT_TIMEOUT_MS / 1000}s time limit and was terminated.`
|
|
165
166
|
)
|
|
166
167
|
),
|
|
167
168
|
DB_EXPORT_TIMEOUT_MS
|
|
@@ -186,5 +187,6 @@ export async function exportWithTimeout(
|
|
|
186
187
|
});
|
|
187
188
|
} finally {
|
|
188
189
|
worker.terminate();
|
|
190
|
+
if (worker.exited) { await worker.exited; release(); }
|
|
189
191
|
}
|
|
190
192
|
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { fileURLToPath } from 'node:url';
|
|
2
|
+
import { deserialize, serialize } from 'node:v8';
|
|
3
|
+
import { readBoundedBytes } from './boundedStream';
|
|
4
|
+
import { isCompiledModule } from '../compiledRuntime';
|
|
5
|
+
|
|
6
|
+
type WorkerEvents = {
|
|
7
|
+
message: { data: unknown };
|
|
8
|
+
error: { message: string; preventDefault?: () => void };
|
|
9
|
+
close: Record<string, never>;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
/** Minimal Worker-compatible seam retained for existing integrations and fault injection. */
|
|
13
|
+
export interface DatabaseWorker {
|
|
14
|
+
addEventListener<K extends keyof WorkerEvents>(type: K, listener: (event: WorkerEvents[K]) => void): void;
|
|
15
|
+
postMessage(request: unknown): void;
|
|
16
|
+
terminate(): void;
|
|
17
|
+
/** Production workers resolve only after the OS has reaped the child. */
|
|
18
|
+
exited?: Promise<void>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const active = new Set<DatabaseProcessWorker>();
|
|
22
|
+
export const databaseProcessLoad = (): number => active.size;
|
|
23
|
+
export function terminateDatabaseProcesses(): void {
|
|
24
|
+
for (const worker of active) worker.terminate();
|
|
25
|
+
}
|
|
26
|
+
process.once('exit', terminateDatabaseProcesses);
|
|
27
|
+
|
|
28
|
+
/** Unlike Worker.terminate(), SIGKILL interrupts synchronous sqlite3_step. */
|
|
29
|
+
export class DatabaseProcessWorker implements DatabaseWorker {
|
|
30
|
+
private listeners = new Map<string, Array<(event: unknown) => void>>();
|
|
31
|
+
private child?: Bun.Subprocess<'ignore', 'pipe', 'ignore' | 'inherit'>;
|
|
32
|
+
private stopped = false;
|
|
33
|
+
private started = false;
|
|
34
|
+
private closed = false;
|
|
35
|
+
private resolveExit!: () => void;
|
|
36
|
+
readonly exited = new Promise<void>((resolve) => { this.resolveExit = resolve; });
|
|
37
|
+
get pid(): number | undefined { return this.child?.pid; }
|
|
38
|
+
|
|
39
|
+
constructor(private readonly workerUrl?: string) {}
|
|
40
|
+
|
|
41
|
+
addEventListener<K extends keyof WorkerEvents>(type: K, listener: (event: WorkerEvents[K]) => void): void {
|
|
42
|
+
this.listeners.set(type, [...(this.listeners.get(type) ?? []), (event) => listener(event as WorkerEvents[K])]);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
postMessage(request: unknown): void {
|
|
46
|
+
if (this.started || this.stopped) throw new Error('Database process is already used');
|
|
47
|
+
this.started = true;
|
|
48
|
+
active.add(this);
|
|
49
|
+
void this.run(request);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
terminate(): void {
|
|
53
|
+
this.stopped = true;
|
|
54
|
+
if (this.child && this.child.exitCode === null) this.child.kill('SIGKILL');
|
|
55
|
+
if (!this.started) this.close();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
private emit<K extends keyof WorkerEvents>(type: K, event: WorkerEvents[K]): void {
|
|
59
|
+
for (const listener of this.listeners.get(type) ?? []) listener(event);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
private close(): void {
|
|
63
|
+
if (this.closed) return;
|
|
64
|
+
this.closed = true;
|
|
65
|
+
active.delete(this);
|
|
66
|
+
this.resolveExit();
|
|
67
|
+
this.emit('close', {});
|
|
68
|
+
this.listeners.clear();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
private async run(request: unknown): Promise<void> {
|
|
72
|
+
try {
|
|
73
|
+
const message = request as Record<string, unknown>;
|
|
74
|
+
const operation = message.operation ? request : message.kind === 'export'
|
|
75
|
+
? { operation: 'dbExportCsv', args: [message.path, message.table, message.orderBy, message.dir, message.filter] }
|
|
76
|
+
: { operation: 'dbQuery', args: [message.path, message.sql] };
|
|
77
|
+
// Legacy overrides never handled the newly isolated browse/workflow operations.
|
|
78
|
+
const input = serialize(this.workerUrl !== undefined && !message.operation
|
|
79
|
+
? { customWorkerUrl: this.workerUrl, request }
|
|
80
|
+
: operation);
|
|
81
|
+
if (input.byteLength > 128 * 1024) throw new Error('Database request exceeds 128 KiB');
|
|
82
|
+
const compiled = isCompiledModule(import.meta.url);
|
|
83
|
+
const trace = process.env.BQ_DB_PROCESS_TRACE === '1';
|
|
84
|
+
if (trace) console.error(JSON.stringify({ component: 'db-process', phase: 'spawn', module: import.meta.url, compiled, executable: process.execPath }));
|
|
85
|
+
let delivered = false;
|
|
86
|
+
this.child = Bun.spawn(compiled
|
|
87
|
+
? [process.execPath, '--bq-db-read']
|
|
88
|
+
: [process.execPath, fileURLToPath(new URL('./readProcessMain.ts', import.meta.url))], {
|
|
89
|
+
// Database readers need no managed-server tokens or cloud credentials.
|
|
90
|
+
env: { PATH: process.env.PATH, SystemRoot: process.env.SystemRoot, TEMP: process.env.TEMP, TMPDIR: process.env.TMPDIR, BQ_DB_PROCESS_TRACE: trace ? '1' : undefined },
|
|
91
|
+
stdin: 'ignore', stdout: 'pipe', stderr: trace ? 'inherit' : 'ignore',
|
|
92
|
+
ipc: (message, subprocess) => {
|
|
93
|
+
if (message !== 'ready' || delivered || this.stopped) return;
|
|
94
|
+
delivered = true;
|
|
95
|
+
if (trace) console.error(JSON.stringify({ component: 'db-process', phase: 'ready', pid: subprocess.pid }));
|
|
96
|
+
try { subprocess.send(input); } catch { this.terminate(); }
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
const [output, code] = await Promise.all([
|
|
100
|
+
readBoundedBytes(this.child.stdout, 32 * 1024 * 1024), this.child.exited,
|
|
101
|
+
]);
|
|
102
|
+
if (trace) console.error(JSON.stringify({ component: 'db-process', phase: 'exited', code, bytes: output.byteLength }));
|
|
103
|
+
if (this.stopped) return;
|
|
104
|
+
if (code !== 0) throw new Error(`Database process exited with code ${code}`);
|
|
105
|
+
const response = deserialize(output) as Record<string, unknown>;
|
|
106
|
+
if (message.kind === 'export' && response.ok && !Object.hasOwn(response, 'export') && Object.hasOwn(response, 'result')) {
|
|
107
|
+
response.export = response.result;
|
|
108
|
+
}
|
|
109
|
+
// The process has exited before any caller can release its lifecycle lease.
|
|
110
|
+
this.emit('message', { data: response });
|
|
111
|
+
} catch (error) {
|
|
112
|
+
this.terminate();
|
|
113
|
+
if (this.child) await this.child.exited;
|
|
114
|
+
this.emit('error', { message: error instanceof Error ? error.message : 'Database process failed' });
|
|
115
|
+
} finally {
|
|
116
|
+
try { this.child?.disconnect(); } catch { /* child already closed its IPC channel */ }
|
|
117
|
+
this.child = undefined;
|
|
118
|
+
this.close();
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
package/agent/db/queryTimeout.ts
CHANGED
|
@@ -1,76 +1,88 @@
|
|
|
1
|
+
import { ReadAdmission } from './readAdmission';
|
|
1
2
|
import { createQueryWorker } from './workerFactory';
|
|
3
|
+
import type { ReadArguments, ReadOperation, ReadResult } from './readOperations';
|
|
2
4
|
import {
|
|
3
|
-
|
|
4
|
-
MissingDbError,
|
|
5
|
-
QUERY_TIMEOUT_MS,
|
|
6
|
-
type DbQueryResult,
|
|
5
|
+
DbReadUnavailableError,
|
|
6
|
+
MissingDbError, QUERY_TIMEOUT_MS, type DbQueryResult,
|
|
7
7
|
} from './types';
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
const admission = new ReadAdmission();
|
|
10
|
+
/** Active database read processes, including children being reaped after cancellation. */
|
|
11
|
+
export const queryWorkerLoad = (): number => admission.load;
|
|
10
12
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
+
export function queryWithTimeout(path: string, sql: string, signal?: AbortSignal): Promise<DbQueryResult> {
|
|
14
|
+
return runReadWorker({ path, sql }, signal);
|
|
15
|
+
}
|
|
13
16
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
}
|
|
20
|
-
|
|
17
|
+
/** Every HTTP SQLite read shares the same bounded admission pool. */
|
|
18
|
+
export function readWithTimeout<K extends ReadOperation>(
|
|
19
|
+
operation: K, args: ReadArguments<K>, signal?: AbortSignal,
|
|
20
|
+
timeoutMs = QUERY_TIMEOUT_MS
|
|
21
|
+
): Promise<ReadResult<K>> {
|
|
22
|
+
return runReadWorker({ operation, args }, signal, timeoutMs, true);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function runReadWorker<T>(request: unknown, signal?: AbortSignal, timeoutMs = QUERY_TIMEOUT_MS, queue = false): Promise<T> {
|
|
26
|
+
const limit = Math.max(1, Math.min(timeoutMs, QUERY_TIMEOUT_MS));
|
|
27
|
+
const deadline = performance.now() + limit;
|
|
28
|
+
const ticket = admission.acquire(queue, deadline, signal);
|
|
29
|
+
const release = typeof ticket === 'function' ? ticket : await ticket;
|
|
30
|
+
let worker;
|
|
21
31
|
try {
|
|
32
|
+
signal?.throwIfAborted();
|
|
33
|
+
if (performance.now() >= deadline) throw new Error('Database read exceeded the time limit while waiting for capacity');
|
|
22
34
|
worker = createQueryWorker();
|
|
23
35
|
} catch (error) {
|
|
24
|
-
|
|
36
|
+
release();
|
|
37
|
+
throw new DbReadUnavailableError(`Query worker unavailable: ${(error as Error).message}`);
|
|
25
38
|
}
|
|
26
|
-
|
|
27
|
-
let released = false;
|
|
28
|
-
const release = () => {
|
|
29
|
-
if (released) return;
|
|
30
|
-
released = true;
|
|
31
|
-
liveQueryWorkers--;
|
|
32
|
-
};
|
|
33
|
-
worker.addEventListener('close', release);
|
|
39
|
+
let cleanup = () => {};
|
|
34
40
|
try {
|
|
35
|
-
return await new Promise<
|
|
41
|
+
return await new Promise<T>((resolve, reject) => {
|
|
36
42
|
let settled = false;
|
|
37
|
-
const
|
|
43
|
+
const fail = (error: Error) => {
|
|
38
44
|
if (settled) return;
|
|
39
45
|
settled = true;
|
|
46
|
+
cleanup();
|
|
40
47
|
worker.terminate();
|
|
41
|
-
reject(
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
settled = true;
|
|
50
|
-
clearTimeout(timer);
|
|
48
|
+
reject(error);
|
|
49
|
+
};
|
|
50
|
+
const onAbort = () => fail(signal?.reason instanceof Error ? signal.reason : new Error('Database read aborted'));
|
|
51
|
+
const timer = setTimeout(() => fail(new DbReadUnavailableError(
|
|
52
|
+
`Query exceeded the ${limit / 1000}s time limit and was terminated.`
|
|
53
|
+
)), Math.max(1, deadline - performance.now()));
|
|
54
|
+
cleanup = () => { clearTimeout(timer); signal?.removeEventListener('abort', onAbort); };
|
|
55
|
+
worker.addEventListener('close', () => {
|
|
51
56
|
release();
|
|
52
|
-
|
|
53
|
-
ok: boolean;
|
|
54
|
-
result?: DbQueryResult;
|
|
55
|
-
error?: string;
|
|
56
|
-
missing?: boolean;
|
|
57
|
-
};
|
|
58
|
-
if (response.ok && response.result) resolve(response.result);
|
|
59
|
-
else if (response.missing) {
|
|
60
|
-
reject(new MissingDbError(response.error ?? 'Database not found'));
|
|
61
|
-
} else reject(new Error(response.error ?? 'Query failed'));
|
|
57
|
+
if (!settled) fail(new DbReadUnavailableError('Query worker exited unexpectedly'));
|
|
62
58
|
});
|
|
63
|
-
worker.addEventListener('
|
|
59
|
+
worker.addEventListener('message', (event) => {
|
|
64
60
|
if (settled) return;
|
|
61
|
+
const response = event.data as { ok?: unknown; result?: unknown; missing?: unknown; error?: unknown } | null;
|
|
62
|
+
if (!response || typeof response !== 'object' || typeof response.ok !== 'boolean') {
|
|
63
|
+
fail(new DbReadUnavailableError('Query worker returned a malformed response'));
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
65
66
|
settled = true;
|
|
66
|
-
|
|
67
|
-
release();
|
|
67
|
+
cleanup();
|
|
68
|
+
if (!worker.exited) release();
|
|
69
|
+
if (response.ok && Object.hasOwn(response, 'result')) resolve(response.result as T);
|
|
70
|
+
else if (response.missing === true) reject(new MissingDbError(typeof response.error === 'string' ? response.error : 'Database not found'));
|
|
71
|
+
else reject(new Error(typeof response.error === 'string' ? response.error : 'Query failed'));
|
|
72
|
+
});
|
|
73
|
+
worker.addEventListener('error', (event) => {
|
|
68
74
|
event.preventDefault?.();
|
|
69
|
-
|
|
75
|
+
fail(new DbReadUnavailableError(`Query worker failed: ${event.message || 'unknown worker error'}`));
|
|
70
76
|
});
|
|
71
|
-
|
|
77
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
78
|
+
if (signal?.aborted) { onAbort(); return; }
|
|
79
|
+
try { worker.postMessage(request); } catch (error) {
|
|
80
|
+
fail(new DbReadUnavailableError(`Could not start database read: ${(error as Error).message}`));
|
|
81
|
+
}
|
|
72
82
|
});
|
|
73
83
|
} finally {
|
|
84
|
+
cleanup();
|
|
74
85
|
worker.terminate();
|
|
86
|
+
if (worker.exited) { await worker.exited; release(); }
|
|
75
87
|
}
|
|
76
88
|
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { DbReadBusyError, DbReadUnavailableError, MAX_CONCURRENT_QUERIES } from './types';
|
|
2
|
+
|
|
3
|
+
type Release = () => void;
|
|
4
|
+
interface Waiter { admit(): void; cancel(error: unknown): void }
|
|
5
|
+
const MAX_PENDING_READS = 32;
|
|
6
|
+
|
|
7
|
+
/** Fair, bounded admission absorbs a page's parallel polls without spawning more SQLite processes. */
|
|
8
|
+
export class ReadAdmission {
|
|
9
|
+
private active = 0;
|
|
10
|
+
private waiting: Waiter[] = [];
|
|
11
|
+
get load(): number { return this.active; }
|
|
12
|
+
get pending(): number { return this.waiting.length; }
|
|
13
|
+
|
|
14
|
+
acquire(queue: boolean, deadline: number, signal?: AbortSignal): Release | Promise<Release> {
|
|
15
|
+
signal?.throwIfAborted();
|
|
16
|
+
if (this.active < MAX_CONCURRENT_QUERIES) return this.reserve();
|
|
17
|
+
if (!queue || this.waiting.length >= MAX_PENDING_READS) {
|
|
18
|
+
throw new DbReadBusyError(`Too many queries running (${this.active}/${MAX_CONCURRENT_QUERIES}). Retry after a current read finishes.`);
|
|
19
|
+
}
|
|
20
|
+
return new Promise<Release>((resolve, reject) => {
|
|
21
|
+
let settled = false;
|
|
22
|
+
const cleanup = () => {
|
|
23
|
+
settled = true;
|
|
24
|
+
clearTimeout(timer);
|
|
25
|
+
signal?.removeEventListener('abort', onAbort);
|
|
26
|
+
const index = this.waiting.indexOf(waiter);
|
|
27
|
+
if (index !== -1) this.waiting.splice(index, 1);
|
|
28
|
+
};
|
|
29
|
+
const waiter: Waiter = {
|
|
30
|
+
admit: () => { if (!settled) { cleanup(); resolve(this.reserve()); } },
|
|
31
|
+
cancel: (error) => { if (!settled) { cleanup(); reject(error); } },
|
|
32
|
+
};
|
|
33
|
+
const onAbort = () => waiter.cancel(signal?.reason instanceof Error ? signal.reason : new Error('Database read aborted'));
|
|
34
|
+
const timer = setTimeout(() => waiter.cancel(new DbReadUnavailableError(
|
|
35
|
+
'Database read exceeded the time limit while waiting for capacity'
|
|
36
|
+
)), Math.max(1, deadline - performance.now()));
|
|
37
|
+
this.waiting.push(waiter);
|
|
38
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
39
|
+
if (signal?.aborted) onAbort();
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
private reserve(): Release {
|
|
44
|
+
this.active++;
|
|
45
|
+
let released = false;
|
|
46
|
+
return () => {
|
|
47
|
+
if (released) return;
|
|
48
|
+
released = true;
|
|
49
|
+
this.active--;
|
|
50
|
+
this.waiting[0]?.admit();
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { dbExportCsv } from './csvExport';
|
|
2
|
+
import { dbQuery } from './query';
|
|
3
|
+
import { dbInfo, dbSchema } from './schema';
|
|
4
|
+
import { dbCell, dbRows, dbTables } from './tables';
|
|
5
|
+
import { workflowExecution } from '../workflow/detail';
|
|
6
|
+
import { workflowExecutions } from '../workflow/list';
|
|
7
|
+
import { workflowStats } from '../workflow/stats';
|
|
8
|
+
|
|
9
|
+
/** Fixed allowlist shared by the typed caller and isolated SQLite process. */
|
|
10
|
+
export const READ_OPERATIONS = {
|
|
11
|
+
dbQuery, dbExportCsv, dbInfo, dbSchema, dbCell, dbRows, dbTables,
|
|
12
|
+
workflowExecution, workflowExecutions, workflowStats,
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type ReadOperation = keyof typeof READ_OPERATIONS;
|
|
16
|
+
export type ReadArguments<K extends ReadOperation> = Parameters<(typeof READ_OPERATIONS)[K]>;
|
|
17
|
+
export type ReadResult<K extends ReadOperation> = ReturnType<(typeof READ_OPERATIONS)[K]>;
|
|
18
|
+
|
|
19
|
+
export function dispatchRead(request: unknown): unknown {
|
|
20
|
+
if (!request || typeof request !== 'object') throw new Error('Invalid database read request');
|
|
21
|
+
const { operation, args } = request as { operation: string; args: unknown[] };
|
|
22
|
+
if (!Object.hasOwn(READ_OPERATIONS, operation) || !Array.isArray(args)) {
|
|
23
|
+
throw new Error('Unknown database read operation');
|
|
24
|
+
}
|
|
25
|
+
const execute = READ_OPERATIONS[operation as ReadOperation] as (...args: unknown[]) => unknown;
|
|
26
|
+
return execute(...args);
|
|
27
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { deserialize, serialize } from 'node:v8';
|
|
2
|
+
import { compiledWorkerUrl } from '../compiledRuntime';
|
|
3
|
+
|
|
4
|
+
/** One request, one readonly connection, one process. Also bundled into the standalone server. */
|
|
5
|
+
export async function runDatabaseReadProcess(): Promise<void> {
|
|
6
|
+
let response: unknown;
|
|
7
|
+
let worker: Worker | undefined;
|
|
8
|
+
const trace = (phase: string, details: Record<string, unknown> = {}): void => {
|
|
9
|
+
if (process.env.BQ_DB_PROCESS_TRACE === '1') console.error(JSON.stringify({ component: 'db-supervisor', phase, ...details }));
|
|
10
|
+
};
|
|
11
|
+
try {
|
|
12
|
+
trace('started', { module: import.meta.url, ipc: Boolean(process.send), connected: process.connected });
|
|
13
|
+
if (!process.send || !process.connected) throw new Error('Database process requires an IPC parent');
|
|
14
|
+
// Keep request delivery and parent liveness separate from standard streams.
|
|
15
|
+
// Disconnect also covers abrupt parent death.
|
|
16
|
+
process.once('disconnect', () => process.exit(1));
|
|
17
|
+
const pending = new Promise<unknown>((resolve) => process.once('message', resolve));
|
|
18
|
+
process.send('ready');
|
|
19
|
+
const message = await pending;
|
|
20
|
+
trace('request-received');
|
|
21
|
+
if (!(message instanceof Uint8Array) || message.byteLength > 128 * 1024) {
|
|
22
|
+
throw new Error('Invalid database request or request exceeds 128 KiB');
|
|
23
|
+
}
|
|
24
|
+
let input: unknown = deserialize(message);
|
|
25
|
+
let workerUrl = compiledWorkerUrl(import.meta.url, 'agent/dbReadWorker.js')
|
|
26
|
+
?? new URL('../dbReadWorker.ts', import.meta.url).href;
|
|
27
|
+
let customWorker = false;
|
|
28
|
+
if (input && typeof input === 'object' && 'customWorkerUrl' in input) {
|
|
29
|
+
if (typeof input.customWorkerUrl !== 'string' || !input.customWorkerUrl || !('request' in input)) {
|
|
30
|
+
throw new Error('Invalid database worker override');
|
|
31
|
+
}
|
|
32
|
+
workerUrl = input.customWorkerUrl;
|
|
33
|
+
input = input.request;
|
|
34
|
+
customWorker = true;
|
|
35
|
+
}
|
|
36
|
+
trace('creating-worker', customWorker ? { customWorker: true } : { workerUrl });
|
|
37
|
+
worker = new Worker(workerUrl, { type: 'module' });
|
|
38
|
+
trace('worker-created');
|
|
39
|
+
response = await new Promise<unknown>((resolve, reject) => {
|
|
40
|
+
worker!.addEventListener('message', (event: MessageEvent) => { trace('worker-result'); resolve(event.data); });
|
|
41
|
+
worker!.addEventListener('error', (event: ErrorEvent) => {
|
|
42
|
+
event.preventDefault?.();
|
|
43
|
+
reject(new Error(event.message || 'Database worker failed'));
|
|
44
|
+
});
|
|
45
|
+
worker!.postMessage(input);
|
|
46
|
+
});
|
|
47
|
+
} catch (error) {
|
|
48
|
+
trace('failed');
|
|
49
|
+
response = {
|
|
50
|
+
ok: false,
|
|
51
|
+
error: error instanceof Error ? error.message : 'Database read failed',
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
const output = serialize(response);
|
|
55
|
+
await Bun.write(Bun.stdout, output.byteLength <= 32 * 1024 * 1024
|
|
56
|
+
? output
|
|
57
|
+
: serialize({ ok: false, error: 'Database result exceeded 32 MiB' }));
|
|
58
|
+
trace('output-written', { bytes: output.byteLength });
|
|
59
|
+
// Exit the whole process, including its SQLite thread and IPC channel.
|
|
60
|
+
worker?.terminate();
|
|
61
|
+
process.exit(0);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (import.meta.main) await runDatabaseReadProcess();
|
package/agent/db/types.ts
CHANGED
|
@@ -19,6 +19,9 @@ export class MissingDbError extends Error {
|
|
|
19
19
|
readonly missing = true;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
export class DbReadBusyError extends Error {}
|
|
23
|
+
export class DbReadUnavailableError extends Error {}
|
|
24
|
+
|
|
22
25
|
/** Maps a saturated export worker pool to HTTP 429. */
|
|
23
26
|
export class DbExportBusyError extends Error {}
|
|
24
27
|
|
|
@@ -1,25 +1,27 @@
|
|
|
1
|
-
|
|
1
|
+
import { DatabaseProcessWorker, type DatabaseWorker } from './processWorker';
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
let
|
|
3
|
+
let workerUrl: string | undefined;
|
|
4
|
+
const spawnWorker = (): DatabaseWorker => new DatabaseProcessWorker(workerUrl);
|
|
5
|
+
let queryFactory: () => DatabaseWorker = spawnWorker;
|
|
6
|
+
let exportFactory: () => DatabaseWorker = spawnWorker;
|
|
6
7
|
|
|
7
|
-
export function createQueryWorker():
|
|
8
|
+
export function createQueryWorker(): DatabaseWorker {
|
|
8
9
|
return queryFactory();
|
|
9
10
|
}
|
|
10
11
|
|
|
11
|
-
export function createExportWorker():
|
|
12
|
+
export function createExportWorker(): DatabaseWorker {
|
|
12
13
|
return exportFactory();
|
|
13
14
|
}
|
|
14
15
|
|
|
15
|
-
export
|
|
16
|
-
|
|
16
|
+
/** Optional legacy query/export worker override, still hosted in a killable process. */
|
|
17
|
+
export function setQueryWorkerUrl(url: string | null): void {
|
|
18
|
+
workerUrl = url ?? undefined;
|
|
17
19
|
}
|
|
18
20
|
|
|
19
|
-
export function setQueryWorkerFactory(factory: (() =>
|
|
21
|
+
export function setQueryWorkerFactory(factory: (() => DatabaseWorker) | null): void {
|
|
20
22
|
queryFactory = factory ?? spawnWorker;
|
|
21
23
|
}
|
|
22
24
|
|
|
23
|
-
export function setExportWorkerFactory(factory: (() =>
|
|
25
|
+
export function setExportWorkerFactory(factory: (() => DatabaseWorker) | null): void {
|
|
24
26
|
exportFactory = factory ?? spawnWorker;
|
|
25
27
|
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { dispatchRead } from './db/readOperations';
|
|
2
|
+
import { MissingDbError } from './db/types';
|
|
3
|
+
|
|
4
|
+
declare const self: Worker;
|
|
5
|
+
|
|
6
|
+
if (process.env.BQ_DB_PROCESS_TRACE === '1') console.error(JSON.stringify({ component: 'db-thread', phase: 'listening' }));
|
|
7
|
+
|
|
8
|
+
// Runs inside a disposable PROCESS. Its supervisor remains responsive to IPC
|
|
9
|
+
// disconnects when the agent dies, even while this thread is in sqlite3_step.
|
|
10
|
+
self.addEventListener('message', (event: MessageEvent) => {
|
|
11
|
+
try {
|
|
12
|
+
const result = dispatchRead(event.data);
|
|
13
|
+
const content = result && typeof result === 'object' && 'content' in result ? result.content : null;
|
|
14
|
+
self.postMessage({ ok: true, result }, content instanceof Uint8Array && content.buffer instanceof ArrayBuffer ? [content.buffer] : []);
|
|
15
|
+
} catch (error) {
|
|
16
|
+
self.postMessage({ ok: false,
|
|
17
|
+
error: error instanceof Error ? error.message : 'Database read failed',
|
|
18
|
+
missing: error instanceof MissingDbError,
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
});
|
package/agent/index.ts
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
import { assertRequiredBunVersion } from '../scripts/bunVersion';
|
|
16
16
|
import { logger } from './logger';
|
|
17
17
|
import { ProcessManager } from './manager';
|
|
18
|
+
import { agentConfigStore } from './manager/configStore';
|
|
18
19
|
import {
|
|
19
20
|
createFetchHandler,
|
|
20
21
|
resolveAllowedHosts,
|
|
@@ -25,7 +26,7 @@ import { installAgentShutdown } from './shutdown';
|
|
|
25
26
|
|
|
26
27
|
assertRequiredBunVersion();
|
|
27
28
|
|
|
28
|
-
const mgr = new ProcessManager();
|
|
29
|
+
const mgr = new ProcessManager(undefined, agentConfigStore());
|
|
29
30
|
const PORT = Number(process.env.AGENT_PORT) || 6800;
|
|
30
31
|
const allowedOrigins = resolveAllowedOrigins();
|
|
31
32
|
// The agent binds loopback only, so a legitimate Host is always a loopback
|
package/agent/managedTarget.ts
CHANGED
|
@@ -72,7 +72,9 @@ function assertLocalTarget(target: string, config: ServerConfig, label: string):
|
|
|
72
72
|
} catch {
|
|
73
73
|
throw new Error(`${label} must be an absolute local HTTP URL`);
|
|
74
74
|
}
|
|
75
|
-
|
|
75
|
+
// URL keeps IPv6 brackets in hostname (`[::1]`), so compare without them.
|
|
76
|
+
const host = url.hostname.replace(/^\[(.*)\]$/, '$1');
|
|
77
|
+
const local = ['localhost', '127.0.0.1', '::1'].includes(host);
|
|
76
78
|
const port = Number(url.port || (url.protocol === 'https:' ? 443 : 80));
|
|
77
79
|
if (!local || port !== config.httpPort || (url.protocol !== 'http:' && url.protocol !== 'https:')) {
|
|
78
80
|
throw new Error(
|