bunqueue-dashboard 0.0.35 → 0.0.36

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 (173) hide show
  1. package/README.md +18 -11
  2. package/agent/backup/processExecutor.ts +90 -0
  3. package/agent/backup/resultValidation.ts +85 -0
  4. package/agent/backup/routes.ts +160 -0
  5. package/agent/backup/runner.ts +91 -0
  6. package/agent/backup/standaloneWorker.ts +23 -0
  7. package/agent/backup/workerExecutor.ts +40 -0
  8. package/agent/backup/workerFactory.ts +21 -0
  9. package/agent/backup/workerProtocol.ts +10 -0
  10. package/agent/db/core.ts +141 -0
  11. package/agent/db/csvExport.ts +172 -0
  12. package/agent/db/exportTimeout.ts +190 -0
  13. package/agent/db/query.ts +82 -0
  14. package/agent/db/queryTimeout.ts +76 -0
  15. package/agent/db/schema.ts +79 -0
  16. package/agent/db/tables.ts +157 -0
  17. package/agent/db/types.ts +118 -0
  18. package/agent/db/workerFactory.ts +25 -0
  19. package/agent/db.ts +43 -1099
  20. package/agent/errorMessage.ts +9 -0
  21. package/agent/flow/routes.ts +250 -0
  22. package/agent/flow/service.ts +266 -0
  23. package/agent/flow/types.ts +34 -0
  24. package/agent/flow/validation.ts +230 -0
  25. package/agent/index.ts +5 -17
  26. package/agent/logger.ts +121 -17
  27. package/agent/managedTarget.ts +43 -0
  28. package/agent/manager/config.ts +84 -0
  29. package/agent/manager/logs.ts +83 -0
  30. package/agent/manager/process.ts +233 -0
  31. package/agent/manager/storage.ts +28 -0
  32. package/agent/manager/types.ts +38 -0
  33. package/agent/manager.ts +10 -490
  34. package/agent/queue/routes.ts +128 -0
  35. package/agent/queue/runtime.ts +119 -0
  36. package/agent/queue/types.ts +34 -0
  37. package/agent/queue/validation.ts +100 -0
  38. package/agent/server/controlRoutes.ts +153 -0
  39. package/agent/server/databaseRoutes.ts +165 -0
  40. package/agent/server/errors.ts +48 -0
  41. package/agent/server/handler.ts +146 -0
  42. package/agent/server/jsonBody.ts +109 -0
  43. package/agent/server/lifecycle.ts +72 -0
  44. package/agent/server/managedRuntime.ts +41 -0
  45. package/agent/server/policy.ts +60 -0
  46. package/agent/server/router.ts +150 -0
  47. package/agent/server/types.ts +39 -0
  48. package/agent/server/workflowReadRoutes.ts +80 -0
  49. package/agent/server.ts +16 -441
  50. package/agent/shutdown.ts +156 -0
  51. package/agent/workflow/codec.ts +78 -0
  52. package/agent/workflow/detail.ts +63 -0
  53. package/agent/workflow/list.ts +87 -0
  54. package/agent/workflow/runtime.ts +274 -0
  55. package/agent/workflow/runtimeConfig.ts +103 -0
  56. package/agent/workflow/runtimeRoutes.ts +162 -0
  57. package/agent/workflow/sqlite.ts +52 -0
  58. package/agent/workflow/stats.ts +79 -0
  59. package/agent/workflow/types.ts +81 -0
  60. package/agent/workflow/validation.ts +114 -0
  61. package/agent/workflows.ts +14 -489
  62. package/dist/.vite/manifest.json +595 -234
  63. package/dist/assets/AddJob-UUa2fSXE.js +3 -0
  64. package/dist/assets/Alerts-7oGYfQMb.js +1 -0
  65. package/dist/assets/{AreaChart-REK4ZsTC.js → AreaChart-w0ruozXH.js} +1 -1
  66. package/dist/assets/Benchmark-C2NFp_kj.js +1 -0
  67. package/dist/assets/BulkAddJobs-CL4yBae-.js +5 -0
  68. package/dist/assets/Button-BPqpRZJD.js +1 -0
  69. package/dist/assets/Card-C7NMhqUt.js +1 -0
  70. package/dist/assets/{CopilotPanel-COLw-atS.js → CopilotPanel-BQ2Cgo9p.js} +3 -3
  71. package/dist/assets/Cron-DfWBhoBf.js +1 -0
  72. package/dist/assets/CronManager-DAK7RSOX.js +1 -0
  73. package/dist/assets/Database-DqL7giEj.js +3 -0
  74. package/dist/assets/Diagnostics-D98YwN-6.js +1 -0
  75. package/dist/assets/Dlq-BHmA5PTI.js +1 -0
  76. package/dist/assets/DlqControl-DihxvP-i.js +1 -0
  77. package/dist/assets/DlqPro-ncgnDtpE.js +1 -0
  78. package/dist/assets/Flows-B5N5Cil0.js +2 -0
  79. package/dist/assets/JobInspector-B40nFiGh.js +2 -0
  80. package/dist/assets/Jobs-Bla3IEef.js +1 -0
  81. package/dist/assets/JobsPro-CnczVx7x.js +1 -0
  82. package/dist/assets/Logs-T91Q3cH0.js +1 -0
  83. package/dist/assets/LogsPro-CMn6-Wwv.js +1 -0
  84. package/dist/assets/{McpServer-CbaU52l2.js → McpServer-BQBabwW-.js} +2 -2
  85. package/dist/assets/Metrics-DxT_RI2B.js +1 -0
  86. package/dist/assets/MetricsPro-Cjen-nAP.js +1 -0
  87. package/dist/assets/Overview-XN2ej8ph.js +1 -0
  88. package/dist/assets/PageHeader-BXlZSBcc.js +1 -0
  89. package/dist/assets/Pagination-Q-vf4hsX.js +1 -0
  90. package/dist/assets/QueueActions-C-Aferd6.js +1 -0
  91. package/dist/assets/QueueControl-B8Q9NX6t.js +1 -0
  92. package/dist/assets/QueueDetail-BMhG0jUB.js +1 -0
  93. package/dist/assets/QueueDetailPro-BKTTyLR4.js +1 -0
  94. package/dist/assets/Queues-COqPMoY9.js +1 -0
  95. package/dist/assets/QueuesOverview-pWdrPy8Z.js +1 -0
  96. package/dist/assets/S3Backup-BmsZ8xtq.js +1 -0
  97. package/dist/assets/S3BackupPro-AtlsPUJL.js +3 -0
  98. package/dist/assets/ServerControl-D3XwHr72.js +3 -0
  99. package/dist/assets/Settings-BofKZoHn.js +1 -0
  100. package/dist/assets/StatusBadge-B0I7hNBX.js +1 -0
  101. package/dist/assets/Usage-DAR9E22v.js +1 -0
  102. package/dist/assets/UsagePro-Bc30N2Lo.js +1 -0
  103. package/dist/assets/Webhooks-Bg2k1PU4.js +1 -0
  104. package/dist/assets/Workers-BqUZMJVQ.js +1 -0
  105. package/dist/assets/WorkersPro-C9aSadUh.js +1 -0
  106. package/dist/assets/Workflows-BEFMfHmo.js +1 -0
  107. package/dist/assets/api-CQ5Z7xR7.js +1 -0
  108. package/dist/assets/bq-BeBxa743.js +1 -0
  109. package/dist/assets/cn-zFEt5SO8.js +1 -0
  110. package/dist/assets/connectionStore-DbBYqHEY.js +1 -0
  111. package/dist/assets/controlConnectionEpoch-CFSO6237.js +1 -0
  112. package/dist/assets/feedback-BnGE2oW2.js +1 -0
  113. package/dist/assets/{form-DsUq0w8s.js → form-tC20r4Ma.js} +1 -1
  114. package/dist/assets/format-Cg7VCIZW.js +1 -0
  115. package/dist/assets/icons-CIvFfU2b.js +1 -0
  116. package/dist/assets/index-Cui9IwWy.js +8 -0
  117. package/dist/assets/index-DOze4TKg.css +1 -0
  118. package/dist/assets/install-Cd9Is10G.js +3 -0
  119. package/dist/assets/{react-vendor-Bjui4wS4.js → react-vendor-CyjgmGTq.js} +3 -3
  120. package/dist/assets/responses-kPO4rSu5.js +1 -0
  121. package/dist/assets/toastStore-CS_9Xndn.js +1 -0
  122. package/dist/assets/usePolledData-DzlUpBOO.js +1 -0
  123. package/dist/assets/{useServerActionGuard-CItj3hvh.js → useServerActionGuard-BlH-Kso1.js} +1 -1
  124. package/dist/assets/useThroughputSeries-C2pa1Wd_.js +1 -0
  125. package/dist/index.html +16 -5
  126. package/package.json +12 -3
  127. package/scripts/embedded.gen.ts +152 -126
  128. package/scripts/serve.ts +96 -484
  129. package/scripts/serveHandler.ts +217 -0
  130. package/scripts/servePolicy.ts +133 -0
  131. package/dist/assets/AddJob-CG28MSxU.js +0 -3
  132. package/dist/assets/Alerts-D9JojJrj.js +0 -1
  133. package/dist/assets/Benchmark-BVjUH9dG.js +0 -1
  134. package/dist/assets/BulkAddJobs-BHzUCMj_.js +0 -5
  135. package/dist/assets/Cron-DoEHDHwy.js +0 -1
  136. package/dist/assets/CronManager-vulcesqJ.js +0 -1
  137. package/dist/assets/Database-DlB_t04Z.js +0 -3
  138. package/dist/assets/Diagnostics-DddCUgeR.js +0 -1
  139. package/dist/assets/Dlq-DMKrsSMP.js +0 -1
  140. package/dist/assets/DlqControl-9m0Re_TH.js +0 -1
  141. package/dist/assets/DlqPro-Dmw-0wta.js +0 -1
  142. package/dist/assets/Flows-C3-9nXW4.js +0 -1
  143. package/dist/assets/JobInspector-DwIfABmI.js +0 -2
  144. package/dist/assets/Jobs-j8tTS3NC.js +0 -1
  145. package/dist/assets/JobsPro-CS__CJJl.js +0 -1
  146. package/dist/assets/Logs-BB-VDPA1.js +0 -1
  147. package/dist/assets/LogsPro-RNtSQZRT.js +0 -1
  148. package/dist/assets/Metrics-ivcddPwT.js +0 -1
  149. package/dist/assets/MetricsPro-xjZno4vg.js +0 -1
  150. package/dist/assets/Overview-D84aD2ph.js +0 -1
  151. package/dist/assets/Pagination-Bfi4vkpp.js +0 -1
  152. package/dist/assets/QueueActions-DTbD6uek.js +0 -1
  153. package/dist/assets/QueueControl-B5xK_SQ-.js +0 -1
  154. package/dist/assets/QueueDetail-BH59kwm9.js +0 -1
  155. package/dist/assets/QueueDetailPro-DoN25ndy.js +0 -1
  156. package/dist/assets/Queues-CKMJqFHX.js +0 -1
  157. package/dist/assets/QueuesOverview-BX1lzojl.js +0 -1
  158. package/dist/assets/S3Backup-Bz3GfoZ9.js +0 -1
  159. package/dist/assets/S3BackupPro-BC_6-uX-.js +0 -2
  160. package/dist/assets/ServerControl-CSSrN8G5.js +0 -3
  161. package/dist/assets/Settings-DkqmZUIS.js +0 -1
  162. package/dist/assets/Usage-Ba20p0xd.js +0 -1
  163. package/dist/assets/UsagePro-C2pQwjN9.js +0 -1
  164. package/dist/assets/Webhooks-CVuuvb0L.js +0 -1
  165. package/dist/assets/Workers-C1wPU6CS.js +0 -1
  166. package/dist/assets/WorkersPro-B0vEkC0T.js +0 -1
  167. package/dist/assets/Workflows-C8Qmkflf.js +0 -1
  168. package/dist/assets/cn-CMFYpumL.js +0 -1
  169. package/dist/assets/connectionStore-Cd9-3Fw0.js +0 -1
  170. package/dist/assets/index-BCNzbhhD.css +0 -1
  171. package/dist/assets/index-CAtsVmyh.js +0 -8
  172. package/dist/assets/install-D0eRLf9F.js +0 -3
  173. package/dist/assets/useThroughputSeries-DjxGN8ob.js +0 -1
@@ -0,0 +1,230 @@
1
+ import type { FlowJobTarget, FlowMutationOperation } from './types';
2
+ import { readLimitedJsonBody } from '../server/jsonBody';
3
+
4
+ const MAX_PROGRESS_JSON_BYTES = 65_536;
5
+ const MAX_PROGRESS_DEPTH = 32;
6
+ const MAX_PROGRESS_VALUES = 10_000;
7
+ const UNSAFE_JSON_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
8
+ const MAX_FLOW_BODY_BYTES = 1024 * 1024;
9
+
10
+ export interface FlowProgressWireValue {
11
+ progress: number;
12
+ message?: string;
13
+ }
14
+
15
+ export function validateFlowTarget(target: FlowJobTarget): void {
16
+ if (!target.id || target.id.length > 1024) {
17
+ throw new Error('Flow job id must contain 1–1024 characters');
18
+ }
19
+ if (!target.queueName || target.queueName.length > 256) {
20
+ throw new Error('Flow queue name must contain 1–256 characters');
21
+ }
22
+ }
23
+
24
+ export function validateParentIds(value: unknown): asserts value is string[] {
25
+ if (!Array.isArray(value) || value.length < 1 || value.length > 1_000) {
26
+ throw new Error('Flow parent IDs must contain between 1 and 1000 entries');
27
+ }
28
+ for (const id of value) {
29
+ if (typeof id !== 'string' || id.length < 1 || id.length > 1024) {
30
+ throw new Error('Each Flow parent ID must contain 1–1024 characters');
31
+ }
32
+ }
33
+ }
34
+
35
+ export function validateMutationPayload(
36
+ operation: FlowMutationOperation,
37
+ payload: Record<string, unknown>
38
+ ): void {
39
+ const keys: Partial<Record<FlowMutationOperation, readonly string[]>> = {
40
+ updateData: ['data'],
41
+ updateProgress: ['progress', 'message'],
42
+ log: ['message'],
43
+ changeDelay: ['delay'],
44
+ changePriority: ['priority', 'lifo'],
45
+ clearLogs: ['keepLogs'],
46
+ };
47
+ const allowed = keys[operation] ?? [];
48
+ const unknown = Object.keys(payload).filter((key) => !allowed.includes(key));
49
+ if (unknown.length) throw new Error(`Unknown Flow ${operation} option: ${unknown[0]}`);
50
+ if (operation === 'updateData' && !Object.hasOwn(payload, 'data')) {
51
+ throw new Error('Flow updateData requires data');
52
+ }
53
+ if (operation === 'updateProgress') validateProgress(payload);
54
+ if (
55
+ operation === 'log' &&
56
+ (typeof payload.message !== 'string' || payload.message.length > 65_536)
57
+ ) {
58
+ throw new Error('Flow log message must be a string of at most 65536 characters');
59
+ }
60
+ if (operation === 'changeDelay') {
61
+ boundedInteger(payload.delay, 'delay', 10 * 365 * 24 * 60 * 60 * 1_000);
62
+ }
63
+ if (operation === 'changePriority') {
64
+ boundedInteger(payload.priority, 'priority', 2_147_483_647);
65
+ if (payload.lifo !== undefined && typeof payload.lifo !== 'boolean') {
66
+ throw new Error('Flow lifo must be boolean');
67
+ }
68
+ }
69
+ if (operation === 'clearLogs' && payload.keepLogs !== undefined) {
70
+ boundedInteger(payload.keepLogs, 'keepLogs', 1_000_000);
71
+ }
72
+ }
73
+
74
+ export function asFlowRecord(value: unknown): Record<string, unknown> {
75
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
76
+ throw new Error('Flow request must be an object');
77
+ }
78
+ return value as Record<string, unknown>;
79
+ }
80
+
81
+ export function assertExactFlowKeys(
82
+ value: Record<string, unknown>,
83
+ allowed: readonly string[],
84
+ label: string
85
+ ): void {
86
+ const unknown = Object.keys(value).find((key) => !allowed.includes(key));
87
+ if (unknown) throw new Error(`Unknown Flow ${label} option: ${unknown}`);
88
+ }
89
+
90
+ export async function readFlowJsonBody(request: Request): Promise<unknown> {
91
+ return readLimitedJsonBody(request, {
92
+ scope: 'Flow',
93
+ maxBytes: MAX_FLOW_BODY_BYTES,
94
+ limitLabel: '1 MiB',
95
+ invalidContentLengthMessage: 'Flow Content-Length must be an integer',
96
+ });
97
+ }
98
+
99
+ export function assertNoFlowBody(request: Request): void {
100
+ const declared = request.headers.get('content-length');
101
+ if (request.body !== null || (declared !== null && declared !== '0')) {
102
+ throw new Error('This Flow operation does not accept a request body');
103
+ }
104
+ }
105
+
106
+ export function assertFlowBodySize(value: unknown): void {
107
+ if (JSON.stringify(value).length > MAX_FLOW_BODY_BYTES) {
108
+ throw new Error('Flow request exceeds 1 MiB');
109
+ }
110
+ }
111
+
112
+ export function normalizeFlowProgressPayload(
113
+ payload: Record<string, unknown>
114
+ ): FlowProgressWireValue {
115
+ const value = payload.progress;
116
+ if (typeof value === 'number') {
117
+ if (!Number.isFinite(value) || value < 0 || value > 100) {
118
+ throw new Error('Flow progress must be a number from 0 to 100 or a JSON object');
119
+ }
120
+ const message = optionalProgressMessage(payload.message);
121
+ return message === undefined ? { progress: value } : { progress: value, message };
122
+ }
123
+ if (!isPlainRecord(value)) {
124
+ if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
125
+ throw new Error('Flow progress object must use only plain JSON objects and arrays');
126
+ }
127
+ throw new Error('Flow progress must be a number from 0 to 100 or a JSON object');
128
+ }
129
+ if (payload.message !== undefined) {
130
+ throw new Error('Flow progress message is only supported with numeric progress');
131
+ }
132
+ validateJsonValue(value, new WeakSet(), { count: 0 }, 0);
133
+ const message = JSON.stringify(value);
134
+ if (new TextEncoder().encode(message).byteLength > MAX_PROGRESS_JSON_BYTES) {
135
+ throw new Error(`Flow progress object must encode to at most ${MAX_PROGRESS_JSON_BYTES} bytes`);
136
+ }
137
+ return { progress: 0, message };
138
+ }
139
+
140
+ function validateProgress(payload: Record<string, unknown>): void {
141
+ normalizeFlowProgressPayload(payload);
142
+ }
143
+
144
+ function optionalProgressMessage(value: unknown): string | undefined {
145
+ if (value === undefined) return undefined;
146
+ if (
147
+ typeof value !== 'string' ||
148
+ new TextEncoder().encode(value).byteLength > MAX_PROGRESS_JSON_BYTES
149
+ ) {
150
+ throw new Error('Flow progress message must be a string of at most 65536 bytes');
151
+ }
152
+ return value;
153
+ }
154
+
155
+ function validateJsonValue(
156
+ value: unknown,
157
+ ancestors: WeakSet<object>,
158
+ budget: { count: number },
159
+ depth: number
160
+ ): void {
161
+ budget.count += 1;
162
+ if (budget.count > MAX_PROGRESS_VALUES) {
163
+ throw new Error(`Flow progress object may contain at most ${MAX_PROGRESS_VALUES} values`);
164
+ }
165
+ if (depth > MAX_PROGRESS_DEPTH) {
166
+ throw new Error(`Flow progress object may be at most ${MAX_PROGRESS_DEPTH} levels deep`);
167
+ }
168
+ if (value === null || typeof value === 'string' || typeof value === 'boolean') return;
169
+ if (typeof value === 'number') {
170
+ if (!Number.isFinite(value)) throw new Error('Flow progress object must contain valid JSON');
171
+ return;
172
+ }
173
+ if (typeof value !== 'object') throw new Error('Flow progress object must contain valid JSON');
174
+ if (ancestors.has(value)) throw new Error('Flow progress object must not contain cycles');
175
+ if (!Array.isArray(value) && !isPlainRecord(value)) {
176
+ throw new Error('Flow progress object must use only plain JSON objects and arrays');
177
+ }
178
+ if (Array.isArray(value) && Object.getPrototypeOf(value) !== Array.prototype) {
179
+ throw new Error('Flow progress object must use only plain JSON objects and arrays');
180
+ }
181
+ ancestors.add(value);
182
+ validateJsonProperties(value, ancestors, budget, depth);
183
+ ancestors.delete(value);
184
+ }
185
+
186
+ function validateJsonProperties(
187
+ value: object,
188
+ ancestors: WeakSet<object>,
189
+ budget: { count: number },
190
+ depth: number
191
+ ): void {
192
+ if (Array.isArray(value)) validateJsonArrayShape(value);
193
+ const descriptors = Object.getOwnPropertyDescriptors(value);
194
+ for (const key of Reflect.ownKeys(descriptors)) {
195
+ if (Array.isArray(value) && key === 'length') continue;
196
+ if (typeof key !== 'string' || UNSAFE_JSON_KEYS.has(key)) {
197
+ throw new Error('Flow progress object contains an unsafe property name');
198
+ }
199
+ const descriptor = descriptors[key];
200
+ if (!descriptor?.enumerable || !Object.hasOwn(descriptor, 'value')) {
201
+ throw new Error('Flow progress object must contain plain enumerable JSON values');
202
+ }
203
+ validateJsonValue(descriptor.value, ancestors, budget, depth + 1);
204
+ }
205
+ }
206
+
207
+ function validateJsonArrayShape(value: unknown[]): void {
208
+ if (value.length > MAX_PROGRESS_VALUES) {
209
+ throw new Error(`Flow progress object may contain at most ${MAX_PROGRESS_VALUES} values`);
210
+ }
211
+ const keys = Object.keys(value);
212
+ if (
213
+ keys.length !== value.length ||
214
+ keys.some((key, index) => key !== String(index) || !Object.hasOwn(value, index))
215
+ ) {
216
+ throw new Error('Flow progress object must contain dense arrays without extra properties');
217
+ }
218
+ }
219
+
220
+ function isPlainRecord(value: unknown): value is Record<string, unknown> {
221
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
222
+ const prototype = Object.getPrototypeOf(value);
223
+ return prototype === Object.prototype || prototype === null;
224
+ }
225
+
226
+ function boundedInteger(value: unknown, label: string, maximum: number): void {
227
+ if (!Number.isSafeInteger(value) || (value as number) < 0 || (value as number) > maximum) {
228
+ throw new Error(`Flow ${label} must be an integer from 0 to ${maximum}`);
229
+ }
230
+ }
package/agent/index.ts CHANGED
@@ -14,6 +14,7 @@
14
14
  import { logger } from './logger';
15
15
  import { ProcessManager } from './manager';
16
16
  import { createFetchHandler, resolveAllowedHosts, resolveAllowedOrigins } from './server';
17
+ import { installAgentShutdown } from './shutdown';
17
18
 
18
19
  const mgr = new ProcessManager();
19
20
  const PORT = Number(process.env.AGENT_PORT) || 6800;
@@ -26,28 +27,15 @@ const allowedHosts = resolveAllowedHosts();
26
27
  const token = process.env.AGENT_TOKEN || undefined;
27
28
 
28
29
  const handle = createFetchHandler(mgr, { allowedOrigins, allowedHosts, token });
30
+ const stopAccepting: Array<() => unknown | Promise<unknown>> = [];
31
+ installAgentShutdown(handle, { stopAccepting });
29
32
 
30
- Bun.serve({
33
+ const server = Bun.serve({
31
34
  port: PORT,
32
35
  hostname: '127.0.0.1',
33
36
  fetch: handle,
34
37
  });
35
-
36
- // Stop the managed server before exiting — without this, Ctrl-C / SIGTERM on
37
- // the agent orphans the spawned bunqueue child (it reparents to PID 1 and keeps
38
- // holding the ports and the SQLite db, so the next start fails EADDRINUSE).
39
- let shuttingDown = false;
40
- const shutdown = (signal: string) => {
41
- if (shuttingDown) return;
42
- shuttingDown = true;
43
- logger.info({ signal }, 'signal received, stopping managed server');
44
- // shutdown() (not stop()) latches the manager closed first: a plain stop()
45
- // racing an in-flight restart() returns successfully *because* restart's
46
- // start() already spawned a replacement — which process.exit() would orphan.
47
- void mgr.shutdown().finally(() => process.exit(0));
48
- };
49
- process.on('SIGINT', () => shutdown('SIGINT'));
50
- process.on('SIGTERM', () => shutdown('SIGTERM'));
38
+ stopAccepting.push(() => server.stop());
51
39
 
52
40
  logger.info(
53
41
  { url: `http://127.0.0.1:${PORT}/control`, allowedOrigins, allowedHosts, tokenAuth: Boolean(token) },
package/agent/logger.ts CHANGED
@@ -42,29 +42,133 @@ const isTTY = !!process.stdout.isTTY;
42
42
 
43
43
  const pad2 = (n: number): string => String(n).padStart(2, '0');
44
44
  const clock = (d: Date): string => `${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}`;
45
+ const RESERVED_FIELDS = new Set(['level', 'time', 'msg', 'toJSON', 'logSerializationError']);
46
+ const UNSAFE_FIELDS = '[Unsafe log fields omitted]';
47
+ const UNSERIALIZABLE_FIELDS = '[Unserializable log fields]';
45
48
 
46
- function write(level: LogLevel, a?: unknown, b?: unknown): void {
47
- if (LEVELS[level] < threshold) return;
49
+ function json(value: unknown): string | null {
50
+ const seen = new WeakSet<object>();
51
+ try {
52
+ return (
53
+ JSON.stringify(value, (_key, current: unknown) => {
54
+ if (typeof current === 'bigint') return current.toString();
55
+ if (typeof current === 'symbol') return `[${String(current)}]`;
56
+ if (typeof current === 'function') return `[Function ${current.name || 'anonymous'}]`;
57
+ if (typeof current === 'object' && current !== null) {
58
+ if (seen.has(current)) return '[Circular]';
59
+ seen.add(current);
60
+ }
61
+ return current;
62
+ }) ?? null
63
+ );
64
+ } catch {
65
+ return null;
66
+ }
67
+ }
68
+
69
+ function safeText(value: unknown): string {
70
+ if (typeof value === 'string') return value;
71
+ try {
72
+ return value === undefined ? '' : String(value);
73
+ } catch {
74
+ return '[Unprintable log message]';
75
+ }
76
+ }
77
+
78
+ function safeFields(value: unknown): Record<string, unknown> | undefined {
79
+ if (value === undefined) return undefined;
80
+ if ((typeof value !== 'object' || value === null) && typeof value !== 'function') {
81
+ return { value };
82
+ }
83
+ const fields: Record<string, unknown> = Object.create(null);
84
+ let unsafe = false;
85
+ let keys: PropertyKey[];
86
+ try {
87
+ keys = Reflect.ownKeys(value);
88
+ } catch {
89
+ return { logSerializationError: UNSERIALIZABLE_FIELDS };
90
+ }
91
+ for (const key of keys) {
92
+ if (typeof key !== 'string') continue;
93
+ if (RESERVED_FIELDS.has(key)) {
94
+ unsafe = true;
95
+ continue;
96
+ }
97
+ let descriptor: PropertyDescriptor | undefined;
98
+ try {
99
+ descriptor = Reflect.getOwnPropertyDescriptor(value, key);
100
+ } catch {
101
+ return { logSerializationError: UNSERIALIZABLE_FIELDS };
102
+ }
103
+ if (!descriptor?.enumerable) continue;
104
+ if ('value' in descriptor) fields[key] = descriptor.value;
105
+ else {
106
+ fields[key] = '[Accessor log field omitted]';
107
+ unsafe = true;
108
+ }
109
+ }
110
+ if (unsafe) fields.logSerializationError = UNSAFE_FIELDS;
111
+ return fields;
112
+ }
113
+
114
+ function logLine(level: LogLevel, obj: unknown, msg: unknown): string {
115
+ const time = Date.now();
116
+ const record: Record<string, unknown> = Object.create(null);
117
+ record.level = LEVELS[level];
118
+ record.time = time;
119
+ const fields = safeFields(obj);
120
+ if (fields) {
121
+ for (const [key, value] of Object.entries(fields)) record[key] = value;
122
+ }
123
+ record.msg = safeText(msg);
124
+ const line = json(record);
125
+ if (line) return line;
126
+ return JSON.stringify({
127
+ level: LEVELS[level],
128
+ time,
129
+ logSerializationError: UNSERIALIZABLE_FIELDS,
130
+ msg: safeText(msg),
131
+ });
132
+ }
133
+
134
+ function logArguments(a?: unknown, b?: unknown): { obj: unknown; msg: unknown } {
48
135
  // pino signature: (mergeObject, message) or just (message). An Error as the
49
136
  // first arg gets its message/stack lifted explicitly — Error props are
50
137
  // non-enumerable, so a plain spread would silently log `{}`.
51
- const [obj, msg] =
52
- typeof a === 'string'
53
- ? [undefined, a]
54
- : a instanceof Error
55
- ? [{ err: { message: a.message, stack: a.stack } }, b as string | undefined]
56
- : [a as Record<string, unknown> | undefined, b as string | undefined];
138
+ let obj: unknown;
139
+ let msg: unknown;
140
+ if (typeof a === 'string') {
141
+ msg = a;
142
+ } else {
143
+ msg = b;
144
+ try {
145
+ obj =
146
+ a instanceof Error
147
+ ? { err: { name: a.name, message: a.message, stack: a.stack } }
148
+ : (a as Record<string, unknown> | undefined);
149
+ } catch {
150
+ obj = { err: '[Uninspectable log argument]' };
151
+ }
152
+ }
153
+ return { obj, msg };
154
+ }
57
155
 
58
- if (isTTY) {
59
- const fields = obj && Object.keys(obj).length ? ` ${DIM}${JSON.stringify(obj)}${RESET}` : '';
60
- console.log(
61
- `${DIM}${clock(new Date())}${RESET} ${COLORS[level]}${level.toUpperCase().padEnd(5)}${RESET} ${msg ?? ''}${fields}`
62
- );
63
- return;
156
+ export function formatLogEntry(level: LogLevel, a?: unknown, b?: unknown, tty = isTTY): string {
157
+ const { obj, msg } = logArguments(a, b);
158
+ if (tty) {
159
+ const fieldsObject = safeFields(obj);
160
+ const serialized = fieldsObject
161
+ ? (json(fieldsObject) ?? JSON.stringify({ logSerializationError: UNSERIALIZABLE_FIELDS }))
162
+ : null;
163
+ const fields = serialized && serialized !== '{}' ? ` ${DIM}${serialized}${RESET}` : '';
164
+ return `${DIM}${clock(new Date())}${RESET} ${COLORS[level]}${level.toUpperCase().padEnd(5)}${RESET} ${safeText(msg)}${fields}`;
64
165
  }
65
- console.log(
66
- JSON.stringify({ level: LEVELS[level], time: Date.now(), ...(obj ?? {}), msg: msg ?? '' })
67
- );
166
+ return logLine(level, obj, msg);
167
+ }
168
+
169
+ function write(level: LogLevel, a?: unknown, b?: unknown): void {
170
+ if (LEVELS[level] < threshold) return;
171
+ console.log(formatLogEntry(level, a, b));
68
172
  }
69
173
 
70
174
  export const logger = {
@@ -0,0 +1,43 @@
1
+ import type { ServerConfig } from './manager';
2
+
3
+ /** Refuse agent-side SDK operations for a server the agent does not manage. */
4
+ export function assertManagedTarget(
5
+ query: URLSearchParams,
6
+ config: ServerConfig,
7
+ proxyTarget = process.env.BUNQUEUE_URL?.trim() || 'http://localhost:6790'
8
+ ): void {
9
+ const target = query.get('target');
10
+ if (!target) throw new Error('Managed Bunqueue target is required');
11
+ if (target === '/api') {
12
+ assertLocalTarget(proxyTarget, config, 'Dashboard /api proxy');
13
+ return;
14
+ }
15
+ assertLocalTarget(target, config, 'Managed target');
16
+ }
17
+
18
+ /** Resolve the exact AUTH_TOKENS value inherited by the managed child. */
19
+ export function managedAuthToken(config: ServerConfig): string | undefined {
20
+ const configured = Object.hasOwn(config.extraEnv, 'AUTH_TOKENS')
21
+ ? config.extraEnv.AUTH_TOKENS
22
+ : process.env.AUTH_TOKENS;
23
+ return configured
24
+ ?.split(',')
25
+ .map((token) => token.trim())
26
+ .find(Boolean);
27
+ }
28
+
29
+ function assertLocalTarget(target: string, config: ServerConfig, label: string): void {
30
+ let url: URL;
31
+ try {
32
+ url = new URL(target);
33
+ } catch {
34
+ throw new Error(`${label} must be an absolute local HTTP URL`);
35
+ }
36
+ const local = ['localhost', '127.0.0.1', '::1'].includes(url.hostname);
37
+ const port = Number(url.port || (url.protocol === 'https:' ? 443 : 80));
38
+ if (!local || port !== config.httpPort || (url.protocol !== 'http:' && url.protocol !== 'https:')) {
39
+ throw new Error(
40
+ `${label} ${target} does not match the agent-managed Bunqueue server on port ${config.httpPort}`
41
+ );
42
+ }
43
+ }
@@ -0,0 +1,84 @@
1
+ import type { ServerConfig } from './types';
2
+
3
+ const CONFIG_KEYS = new Set<keyof ServerConfig>([
4
+ 'command',
5
+ 'httpPort',
6
+ 'tcpPort',
7
+ 'dataPath',
8
+ 'extraEnv',
9
+ ]);
10
+
11
+ export function defaultConfig(): ServerConfig {
12
+ return {
13
+ command: process.env.BUNQUEUE_START_CMD || 'bunx bunqueue@2.8.57 start',
14
+ httpPort: Number(process.env.HTTP_PORT) || 6790,
15
+ tcpPort: Number(process.env.TCP_PORT) || 6789,
16
+ dataPath: process.env.BUNQUEUE_DATA_PATH || './data/bunq.db',
17
+ extraEnv: {},
18
+ };
19
+ }
20
+
21
+ function isRecord(value: unknown): value is Record<string, unknown> {
22
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) return false;
23
+ const prototype = Object.getPrototypeOf(value);
24
+ return prototype === Object.prototype || prototype === null;
25
+ }
26
+
27
+ function validatePort(name: 'httpPort' | 'tcpPort', value: unknown): number {
28
+ if (typeof value !== 'number' || !Number.isInteger(value) || value < 1 || value > 65_535) {
29
+ throw new Error(`${name} must be an integer between 1 and 65535`);
30
+ }
31
+ return value;
32
+ }
33
+
34
+ /** Validate an untrusted PUT /control/config body before merging any field. */
35
+ export function validateConfigPatch(value: unknown): Partial<ServerConfig> {
36
+ if (!isRecord(value)) throw new Error('Config must be an object');
37
+ for (const key of Object.keys(value)) {
38
+ if (!CONFIG_KEYS.has(key as keyof ServerConfig)) {
39
+ throw new Error(`Unknown config key: ${key}`);
40
+ }
41
+ }
42
+
43
+ const patch: Partial<ServerConfig> = {};
44
+ if (Object.hasOwn(value, 'command')) {
45
+ if (typeof value.command !== 'string' || value.command.trim() === '') {
46
+ throw new Error('command must be a non-empty string');
47
+ }
48
+ patch.command = value.command;
49
+ }
50
+ if (Object.hasOwn(value, 'httpPort')) {
51
+ patch.httpPort = validatePort('httpPort', value.httpPort);
52
+ }
53
+ if (Object.hasOwn(value, 'tcpPort')) {
54
+ patch.tcpPort = validatePort('tcpPort', value.tcpPort);
55
+ }
56
+ if (Object.hasOwn(value, 'dataPath')) {
57
+ if (typeof value.dataPath !== 'string') throw new Error('dataPath must be a string');
58
+ patch.dataPath = value.dataPath;
59
+ }
60
+ if (Object.hasOwn(value, 'extraEnv')) {
61
+ if (!isRecord(value.extraEnv)) {
62
+ throw new Error('extraEnv must be an object containing only string values');
63
+ }
64
+ const entries = Object.entries(value.extraEnv);
65
+ for (const [key, entry] of entries) {
66
+ if (typeof entry !== 'string') throw new Error(`extraEnv.${key} must be a string`);
67
+ }
68
+ patch.extraEnv = Object.fromEntries(entries) as Record<string, string>;
69
+ }
70
+ return patch;
71
+ }
72
+
73
+ export function validateServerConfig(value: unknown): ServerConfig {
74
+ if (!isRecord(value)) throw new Error('Config must be an object');
75
+ const config = validateConfigPatch(value);
76
+ for (const key of CONFIG_KEYS) {
77
+ if (!Object.hasOwn(value, key)) throw new Error(`Missing config key: ${key}`);
78
+ }
79
+ return config as ServerConfig;
80
+ }
81
+
82
+ export function copyConfig(config: ServerConfig): ServerConfig {
83
+ return { ...config, extraEnv: { ...config.extraEnv } };
84
+ }
@@ -0,0 +1,83 @@
1
+ import type { LogLine } from './types';
2
+
3
+ const MAX_LOGS = 800;
4
+ const MAX_LINE = 8192;
5
+
6
+ interface CancelablePipeReader {
7
+ cancel(reason?: unknown): Promise<void>;
8
+ }
9
+
10
+ export class ProcessLogs {
11
+ private lines: LogLine[] = [];
12
+ private sequence = 0;
13
+ private readers = new Map<number, Set<CancelablePipeReader>>();
14
+
15
+ get(): LogLine[] {
16
+ return this.lines;
17
+ }
18
+
19
+ push(stream: LogLine['stream'], line: string): void {
20
+ const capped = line.length > MAX_LINE ? `${line.slice(0, MAX_LINE)}…[truncated]` : line;
21
+ this.lines.push({ seq: this.sequence++, ts: Date.now(), stream, line: capped });
22
+ if (this.lines.length > MAX_LOGS + 256) {
23
+ this.lines.splice(0, this.lines.length - MAX_LOGS);
24
+ }
25
+ }
26
+
27
+ cancel(token: number): void {
28
+ const active = this.readers.get(token);
29
+ if (!active) return;
30
+ this.readers.delete(token);
31
+ for (const reader of active) {
32
+ void reader.cancel('process generation ended').catch(() => undefined);
33
+ }
34
+ }
35
+
36
+ async capture(
37
+ stream: ReadableStream<Uint8Array>,
38
+ name: 'stdout' | 'stderr',
39
+ token: number,
40
+ isCurrent: () => boolean
41
+ ): Promise<void> {
42
+ const reader = stream.getReader();
43
+ let active = this.readers.get(token);
44
+ if (!active) {
45
+ active = new Set();
46
+ this.readers.set(token, active);
47
+ }
48
+ active.add(reader);
49
+ const decoder = new TextDecoder();
50
+ let buffer = '';
51
+ try {
52
+ try {
53
+ for (;;) {
54
+ const { done, value } = await reader.read();
55
+ if (!isCurrent()) return;
56
+ if (done) break;
57
+ buffer += decoder.decode(value, { stream: true });
58
+ let newline: number;
59
+ while ((newline = buffer.indexOf('\n')) !== -1) {
60
+ const line = buffer.slice(0, newline);
61
+ buffer = buffer.slice(newline + 1);
62
+ if (line.trim() && isCurrent()) this.push(name, line);
63
+ }
64
+ while (buffer.length > MAX_LINE) {
65
+ if (!isCurrent()) return;
66
+ this.push(name, buffer.slice(0, MAX_LINE));
67
+ buffer = buffer.slice(MAX_LINE);
68
+ }
69
+ }
70
+ } catch {
71
+ // Stream closed or explicitly cancelled.
72
+ }
73
+ if (!isCurrent()) return;
74
+ const tail = buffer + decoder.decode();
75
+ if (tail.trim() && isCurrent()) this.push(name, tail);
76
+ } finally {
77
+ const current = this.readers.get(token);
78
+ current?.delete(reader);
79
+ if (current?.size === 0) this.readers.delete(token);
80
+ reader.releaseLock();
81
+ }
82
+ }
83
+ }