bunqueue-dashboard 0.0.35 → 0.0.37
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 +36 -16
- package/agent/backup/processExecutor.ts +90 -0
- package/agent/backup/resultValidation.ts +85 -0
- package/agent/backup/routes.ts +161 -0
- package/agent/backup/runner.ts +91 -0
- package/agent/backup/standaloneWorker.ts +23 -0
- package/agent/backup/workerExecutor.ts +40 -0
- package/agent/backup/workerFactory.ts +21 -0
- package/agent/backup/workerProtocol.ts +10 -0
- package/agent/db/core.ts +141 -0
- package/agent/db/csvExport.ts +172 -0
- package/agent/db/exportTimeout.ts +190 -0
- package/agent/db/query.ts +82 -0
- package/agent/db/queryTimeout.ts +76 -0
- package/agent/db/schema.ts +79 -0
- package/agent/db/tables.ts +157 -0
- package/agent/db/types.ts +118 -0
- package/agent/db/workerFactory.ts +25 -0
- package/agent/db.ts +43 -1099
- package/agent/errorMessage.ts +9 -0
- package/agent/flow/routes.ts +251 -0
- package/agent/flow/service.ts +266 -0
- package/agent/flow/types.ts +34 -0
- package/agent/flow/validation.ts +230 -0
- package/agent/index.ts +22 -21
- package/agent/logger.ts +121 -17
- package/agent/managedTarget.ts +82 -0
- package/agent/manager/config.ts +84 -0
- package/agent/manager/logs.ts +83 -0
- package/agent/manager/process.ts +233 -0
- package/agent/manager/storage.ts +28 -0
- package/agent/manager/types.ts +38 -0
- package/agent/manager.ts +10 -490
- package/agent/queue/routes.ts +135 -0
- package/agent/queue/runtime.ts +119 -0
- package/agent/queue/types.ts +34 -0
- package/agent/queue/validation.ts +100 -0
- package/agent/server/controlRoutes.ts +185 -0
- package/agent/server/controlTarget.ts +116 -0
- package/agent/server/databaseRoutes.ts +165 -0
- package/agent/server/errors.ts +49 -0
- package/agent/server/handler.ts +155 -0
- package/agent/server/jsonBody.ts +109 -0
- package/agent/server/lifecycle.ts +72 -0
- package/agent/server/managedRuntime.ts +41 -0
- package/agent/server/policy.ts +60 -0
- package/agent/server/router.ts +161 -0
- package/agent/server/types.ts +46 -0
- package/agent/server/workflowReadRoutes.ts +80 -0
- package/agent/server.ts +26 -441
- package/agent/shutdown.ts +156 -0
- package/agent/workflow/codec.ts +78 -0
- package/agent/workflow/detail.ts +63 -0
- package/agent/workflow/list.ts +87 -0
- package/agent/workflow/runtime.ts +274 -0
- package/agent/workflow/runtimeConfig.ts +103 -0
- package/agent/workflow/runtimeRoutes.ts +163 -0
- package/agent/workflow/sqlite.ts +52 -0
- package/agent/workflow/stats.ts +79 -0
- package/agent/workflow/types.ts +81 -0
- package/agent/workflow/validation.ts +114 -0
- package/agent/workflows.ts +14 -489
- package/dist/.vite/manifest.json +611 -250
- package/dist/assets/AddJob-CWkkVHmf.js +3 -0
- package/dist/assets/Alerts-Gh1pVbqj.js +1 -0
- package/dist/assets/{AreaChart-REK4ZsTC.js → AreaChart-w0ruozXH.js} +1 -1
- package/dist/assets/Benchmark-Dir2A1CQ.js +1 -0
- package/dist/assets/BulkAddJobs-Di_IgRSH.js +5 -0
- package/dist/assets/Button-BPqpRZJD.js +1 -0
- package/dist/assets/Card-C7NMhqUt.js +1 -0
- package/dist/assets/{CopilotPanel-COLw-atS.js → CopilotPanel-Db5nYpo1.js} +3 -3
- package/dist/assets/Cron-BRgWcBGk.js +1 -0
- package/dist/assets/CronManager-CbM0x_s0.js +1 -0
- package/dist/assets/Database-BeoEreW0.js +3 -0
- package/dist/assets/Diagnostics-Cw9iJ84f.js +1 -0
- package/dist/assets/Dlq-DfCg04Jd.js +1 -0
- package/dist/assets/DlqControl-BRCkUXvb.js +1 -0
- package/dist/assets/DlqPro-BHJwBYsN.js +1 -0
- package/dist/assets/Flows-BAzbFP_W.js +2 -0
- package/dist/assets/JobInspector-qvW_APO6.js +2 -0
- package/dist/assets/Jobs-URpnFjC6.js +1 -0
- package/dist/assets/JobsPro-pQ_RFSU3.js +1 -0
- package/dist/assets/Logs-C5jX2OPE.js +1 -0
- package/dist/assets/LogsPro-Cq_npmmS.js +1 -0
- package/dist/assets/{McpServer-CbaU52l2.js → McpServer-DtCTmQBu.js} +2 -2
- package/dist/assets/Metrics-BdxitePr.js +1 -0
- package/dist/assets/MetricsPro-orHfW0mg.js +1 -0
- package/dist/assets/Overview-DaNns1aj.js +1 -0
- package/dist/assets/PageHeader-BXlZSBcc.js +1 -0
- package/dist/assets/Pagination-Q-vf4hsX.js +1 -0
- package/dist/assets/QueueActions-jiTzndPH.js +1 -0
- package/dist/assets/QueueControl-l5bSdMgb.js +1 -0
- package/dist/assets/QueueDetail-URK2pjtH.js +1 -0
- package/dist/assets/QueueDetailPro-C9M5CV4K.js +1 -0
- package/dist/assets/Queues-DeLXlww2.js +1 -0
- package/dist/assets/QueuesOverview-DoQ-MUfT.js +1 -0
- package/dist/assets/S3Backup-D2agV9_z.js +1 -0
- package/dist/assets/S3BackupPro--WSZWTQn.js +3 -0
- package/dist/assets/ServerControl-QVd6m_iR.js +3 -0
- package/dist/assets/Settings-CiJRj_SM.js +1 -0
- package/dist/assets/StatusBadge-B0I7hNBX.js +1 -0
- package/dist/assets/Usage-DbHJgA4t.js +1 -0
- package/dist/assets/UsagePro-LoPo3k1L.js +1 -0
- package/dist/assets/Webhooks-CuzIJHwk.js +1 -0
- package/dist/assets/Workers-BpUgpwUH.js +1 -0
- package/dist/assets/WorkersPro-BYY0P1Pt.js +1 -0
- package/dist/assets/Workflows-ByJw4FFH.js +1 -0
- package/dist/assets/api-V_sVpHDo.js +1 -0
- package/dist/assets/bq-DYmbVkgR.js +1 -0
- package/dist/assets/cn-zFEt5SO8.js +1 -0
- package/dist/assets/connectionStore-CKFQrxDo.js +1 -0
- package/dist/assets/controlConnectionEpoch-JM4omZ6j.js +1 -0
- package/dist/assets/feedback-BnGE2oW2.js +1 -0
- package/dist/assets/{flowMutationSafety-CoOp94wS.js → flowMutationSafety-DObp9nJo.js} +1 -1
- package/dist/assets/{form-DsUq0w8s.js → form-tC20r4Ma.js} +1 -1
- package/dist/assets/format-Cg7VCIZW.js +1 -0
- package/dist/assets/icons-CIvFfU2b.js +1 -0
- package/dist/assets/index-D64v2vNO.js +8 -0
- package/dist/assets/index-DOze4TKg.css +1 -0
- package/dist/assets/install-jXi1SOYY.js +3 -0
- package/dist/assets/{react-vendor-Bjui4wS4.js → react-vendor-CyjgmGTq.js} +3 -3
- package/dist/assets/responses-CBLyy1Ob.js +1 -0
- package/dist/assets/toastStore-B7n0zS0y.js +1 -0
- package/dist/assets/{upstreamPaths-D_LPsv7b.js → upstreamPaths-CDVsu6IM.js} +1 -1
- package/dist/assets/usePolledData-c53tx1jw.js +1 -0
- package/dist/assets/{useServerActionGuard-CItj3hvh.js → useServerActionGuard-OTv6_fU0.js} +1 -1
- package/dist/assets/useThroughputSeries-DWHQl7cM.js +1 -0
- package/dist/index.html +17 -6
- package/package.json +15 -4
- package/scripts/embedded.gen.ts +160 -134
- package/scripts/serve.ts +127 -484
- package/scripts/serveHandler.ts +237 -0
- package/scripts/servePolicy.ts +187 -0
- package/dist/assets/AddJob-CG28MSxU.js +0 -3
- package/dist/assets/Alerts-D9JojJrj.js +0 -1
- package/dist/assets/Benchmark-BVjUH9dG.js +0 -1
- package/dist/assets/BulkAddJobs-BHzUCMj_.js +0 -5
- package/dist/assets/Cron-DoEHDHwy.js +0 -1
- package/dist/assets/CronManager-vulcesqJ.js +0 -1
- package/dist/assets/Database-DlB_t04Z.js +0 -3
- package/dist/assets/Diagnostics-DddCUgeR.js +0 -1
- package/dist/assets/Dlq-DMKrsSMP.js +0 -1
- package/dist/assets/DlqControl-9m0Re_TH.js +0 -1
- package/dist/assets/DlqPro-Dmw-0wta.js +0 -1
- package/dist/assets/Flows-C3-9nXW4.js +0 -1
- package/dist/assets/JobInspector-DwIfABmI.js +0 -2
- package/dist/assets/Jobs-j8tTS3NC.js +0 -1
- package/dist/assets/JobsPro-CS__CJJl.js +0 -1
- package/dist/assets/Logs-BB-VDPA1.js +0 -1
- package/dist/assets/LogsPro-RNtSQZRT.js +0 -1
- package/dist/assets/Metrics-ivcddPwT.js +0 -1
- package/dist/assets/MetricsPro-xjZno4vg.js +0 -1
- package/dist/assets/Overview-D84aD2ph.js +0 -1
- package/dist/assets/Pagination-Bfi4vkpp.js +0 -1
- package/dist/assets/QueueActions-DTbD6uek.js +0 -1
- package/dist/assets/QueueControl-B5xK_SQ-.js +0 -1
- package/dist/assets/QueueDetail-BH59kwm9.js +0 -1
- package/dist/assets/QueueDetailPro-DoN25ndy.js +0 -1
- package/dist/assets/Queues-CKMJqFHX.js +0 -1
- package/dist/assets/QueuesOverview-BX1lzojl.js +0 -1
- package/dist/assets/S3Backup-Bz3GfoZ9.js +0 -1
- package/dist/assets/S3BackupPro-BC_6-uX-.js +0 -2
- package/dist/assets/ServerControl-CSSrN8G5.js +0 -3
- package/dist/assets/Settings-DkqmZUIS.js +0 -1
- package/dist/assets/Usage-Ba20p0xd.js +0 -1
- package/dist/assets/UsagePro-C2pQwjN9.js +0 -1
- package/dist/assets/Webhooks-CVuuvb0L.js +0 -1
- package/dist/assets/Workers-C1wPU6CS.js +0 -1
- package/dist/assets/WorkersPro-B0vEkC0T.js +0 -1
- package/dist/assets/Workflows-C8Qmkflf.js +0 -1
- package/dist/assets/cn-CMFYpumL.js +0 -1
- package/dist/assets/connectionStore-Cd9-3Fw0.js +0 -1
- package/dist/assets/index-BCNzbhhD.css +0 -1
- package/dist/assets/index-CAtsVmyh.js +0 -8
- package/dist/assets/install-D0eRLf9F.js +0 -3
- package/dist/assets/useThroughputSeries-DjxGN8ob.js +0 -1
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type DbFilter,
|
|
3
|
+
dbCell,
|
|
4
|
+
dbInfo,
|
|
5
|
+
dbRows,
|
|
6
|
+
dbSchema,
|
|
7
|
+
dbTables,
|
|
8
|
+
exportWithTimeout,
|
|
9
|
+
queryWithTimeout,
|
|
10
|
+
} from '../db';
|
|
11
|
+
import { corsHeaders } from './policy';
|
|
12
|
+
import { readLimitedJsonBody } from './jsonBody';
|
|
13
|
+
import type { RouteResponse } from './types';
|
|
14
|
+
|
|
15
|
+
const MAX_QUERY_BODY_BYTES = 64 * 1024;
|
|
16
|
+
|
|
17
|
+
function exportFilter(query: URLSearchParams): DbFilter | undefined {
|
|
18
|
+
const column = query.get('fcol');
|
|
19
|
+
const operation = query.get('fop');
|
|
20
|
+
const value = query.get('fval');
|
|
21
|
+
const hasPart = column !== null || operation !== null || value !== null;
|
|
22
|
+
if (!hasPart) return undefined;
|
|
23
|
+
if (
|
|
24
|
+
!column ||
|
|
25
|
+
!value ||
|
|
26
|
+
(operation !== 'contains' && operation !== 'eq' && operation !== 'ne')
|
|
27
|
+
) {
|
|
28
|
+
throw new Error('Database export filter requires valid fcol, fop and fval values');
|
|
29
|
+
}
|
|
30
|
+
return { column, op: operation, value };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function validateExportQuery(query: URLSearchParams): void {
|
|
34
|
+
const allowed = new Set(['orderBy', 'dir', 'fcol', 'fop', 'fval']);
|
|
35
|
+
for (const key of query.keys()) {
|
|
36
|
+
if (!allowed.has(key)) throw new Error(`Unknown database export option: ${key}`);
|
|
37
|
+
if (query.getAll(key).length !== 1) {
|
|
38
|
+
throw new Error(`Duplicate database export option: ${key}`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function exportTable(
|
|
44
|
+
request: Request,
|
|
45
|
+
table: string,
|
|
46
|
+
dataPath: string,
|
|
47
|
+
origin: string | null,
|
|
48
|
+
allowedOrigins: string[]
|
|
49
|
+
): Promise<Response> {
|
|
50
|
+
const query = new URL(request.url).searchParams;
|
|
51
|
+
validateExportQuery(query);
|
|
52
|
+
const orderBy = query.get('orderBy');
|
|
53
|
+
if (orderBy === '') throw new Error('Database export orderBy must not be empty');
|
|
54
|
+
const direction = query.get('dir');
|
|
55
|
+
if (direction !== null && direction !== 'asc' && direction !== 'desc') {
|
|
56
|
+
throw new Error('Database export dir must be "asc" or "desc"');
|
|
57
|
+
}
|
|
58
|
+
const exported = await exportWithTimeout(
|
|
59
|
+
dataPath,
|
|
60
|
+
table,
|
|
61
|
+
orderBy ?? undefined,
|
|
62
|
+
direction === 'desc' ? 'desc' : 'asc',
|
|
63
|
+
exportFilter(query),
|
|
64
|
+
request.signal
|
|
65
|
+
);
|
|
66
|
+
return new Response(exported.content, {
|
|
67
|
+
status: 200,
|
|
68
|
+
headers: {
|
|
69
|
+
'Content-Type': 'text/csv; charset=utf-8',
|
|
70
|
+
'Content-Length': String(exported.bytes),
|
|
71
|
+
'Cache-Control': 'no-store, no-transform',
|
|
72
|
+
'X-Content-Type-Options': 'nosniff',
|
|
73
|
+
'X-Bunqueue-Db-Export-Version': '1',
|
|
74
|
+
'X-Bunqueue-Db-Export-Table': encodeURIComponent(exported.table),
|
|
75
|
+
'X-Bunqueue-Db-Export-Rows': String(exported.rowCount),
|
|
76
|
+
'X-Bunqueue-Db-Export-Bytes': String(exported.bytes),
|
|
77
|
+
'X-Bunqueue-Db-Export-Cap': exported.cap ?? 'none',
|
|
78
|
+
'Access-Control-Expose-Headers':
|
|
79
|
+
'Content-Length, X-Bunqueue-Db-Export-Version, X-Bunqueue-Db-Export-Table, X-Bunqueue-Db-Export-Rows, X-Bunqueue-Db-Export-Bytes, X-Bunqueue-Db-Export-Cap',
|
|
80
|
+
...corsHeaders(origin, allowedOrigins),
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function browseFilter(query: URLSearchParams): DbFilter | undefined {
|
|
86
|
+
const column = query.get('fcol');
|
|
87
|
+
const operation = query.get('fop');
|
|
88
|
+
const value = query.get('fval');
|
|
89
|
+
if (!column || !value) return undefined;
|
|
90
|
+
return {
|
|
91
|
+
column,
|
|
92
|
+
op: operation === 'eq' ? 'eq' : operation === 'ne' ? 'ne' : 'contains',
|
|
93
|
+
value,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export async function routeDatabaseRequest(
|
|
98
|
+
request: Request,
|
|
99
|
+
pathname: string,
|
|
100
|
+
method: string,
|
|
101
|
+
dataPath: string,
|
|
102
|
+
origin: string | null,
|
|
103
|
+
allowedOrigins: string[]
|
|
104
|
+
): Promise<RouteResponse | Response | null> {
|
|
105
|
+
if (pathname === '/db/info' && method === 'GET') {
|
|
106
|
+
return { status: 200, body: { ok: true, ...dbInfo(dataPath) } };
|
|
107
|
+
}
|
|
108
|
+
if (pathname === '/db/tables' && method === 'GET') {
|
|
109
|
+
return { status: 200, body: { ok: true, tables: dbTables(dataPath) } };
|
|
110
|
+
}
|
|
111
|
+
if (pathname.startsWith('/db/tables/') && method === 'GET') {
|
|
112
|
+
const segments = pathname.slice('/db/tables/'.length).split('/');
|
|
113
|
+
const table = decodeURIComponent(segments[0] ?? '');
|
|
114
|
+
const subresource = segments[1];
|
|
115
|
+
if (segments.length === 2 && subresource === 'export') {
|
|
116
|
+
return exportTable(request, table, dataPath, origin, allowedOrigins);
|
|
117
|
+
}
|
|
118
|
+
if (segments.length === 2 && subresource === 'schema') {
|
|
119
|
+
return { status: 200, body: { ok: true, ...dbSchema(dataPath, table) } };
|
|
120
|
+
}
|
|
121
|
+
if (segments.length === 2 && subresource === 'cell') {
|
|
122
|
+
const query = new URL(request.url).searchParams;
|
|
123
|
+
const rowid = query.get('rowid');
|
|
124
|
+
if (rowid === null) throw new Error('rowid is required');
|
|
125
|
+
return {
|
|
126
|
+
status: 200,
|
|
127
|
+
body: {
|
|
128
|
+
ok: true,
|
|
129
|
+
...dbCell(dataPath, table, rowid, query.get('column') ?? ''),
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
if (segments.length !== 1) {
|
|
134
|
+
return { status: 404, body: { ok: false, error: 'Not found' } };
|
|
135
|
+
}
|
|
136
|
+
const query = new URL(request.url).searchParams;
|
|
137
|
+
return {
|
|
138
|
+
status: 200,
|
|
139
|
+
body: {
|
|
140
|
+
ok: true,
|
|
141
|
+
...dbRows(
|
|
142
|
+
dataPath,
|
|
143
|
+
table,
|
|
144
|
+
Number(query.get('limit')) || 50,
|
|
145
|
+
Number(query.get('offset')) || 0,
|
|
146
|
+
query.get('orderBy') || undefined,
|
|
147
|
+
query.get('dir') === 'desc' ? 'desc' : 'asc',
|
|
148
|
+
browseFilter(query)
|
|
149
|
+
),
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
if (pathname === '/db/query' && method === 'POST') {
|
|
154
|
+
const { sql } = (await readLimitedJsonBody(request, {
|
|
155
|
+
scope: 'Database query',
|
|
156
|
+
maxBytes: MAX_QUERY_BODY_BYTES,
|
|
157
|
+
limitLabel: '64 KiB',
|
|
158
|
+
})) as { sql?: string };
|
|
159
|
+
return {
|
|
160
|
+
status: 200,
|
|
161
|
+
body: { ok: true, ...(await queryWithTimeout(dataPath, sql ?? '')) },
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DbExportBusyError,
|
|
3
|
+
DbExportUnavailableError,
|
|
4
|
+
MissingDbError,
|
|
5
|
+
} from '../db';
|
|
6
|
+
import { safeErrorMessage } from '../errorMessage';
|
|
7
|
+
import { QueueOperationsUnavailableError } from '../queue/types';
|
|
8
|
+
import { WorkflowRuntimeUnavailableError } from '../workflow/runtime';
|
|
9
|
+
import { AgentLifecycleClosedError } from './lifecycle';
|
|
10
|
+
|
|
11
|
+
const CONFLICT_MESSAGES = [
|
|
12
|
+
'Start the managed Bunqueue server',
|
|
13
|
+
'Stop the managed Bunqueue server',
|
|
14
|
+
'Another backup operation is already running',
|
|
15
|
+
'Another maintenance operation is already running',
|
|
16
|
+
'Cannot start the managed Bunqueue server',
|
|
17
|
+
'Cannot restart the managed Bunqueue server',
|
|
18
|
+
'BUNQUEUE_WORKFLOW_QUEUE_NAME must',
|
|
19
|
+
'BUNQUEUE_WORKFLOW_CONCURRENCY must',
|
|
20
|
+
'Managed Bunqueue server restarted',
|
|
21
|
+
'Server lifecycle is disabled in external mode',
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
export function errorStatus(error: unknown): number {
|
|
25
|
+
try {
|
|
26
|
+
if (error instanceof AgentLifecycleClosedError) return 503;
|
|
27
|
+
if (error instanceof MissingDbError) return 404;
|
|
28
|
+
if (error instanceof DbExportBusyError) return 429;
|
|
29
|
+
if (error instanceof DbExportUnavailableError) return 503;
|
|
30
|
+
if (error instanceof QueueOperationsUnavailableError) {
|
|
31
|
+
return error.message.includes('runtime is closed') ? 503 : 409;
|
|
32
|
+
}
|
|
33
|
+
if (error instanceof WorkflowRuntimeUnavailableError) {
|
|
34
|
+
// Configuration shape is actionable by the caller; a missing/unreadable
|
|
35
|
+
// configured module is an unavailable server-side dependency.
|
|
36
|
+
return /^(Set BUNQUEUE_WORKFLOW_MODULE|BUNQUEUE_WORKFLOW_MODULE must|BUNQUEUE_WORKFLOW_QUEUE_NAME must|BUNQUEUE_WORKFLOW_CONCURRENCY must|Workflow module must|workflowNames must)/.test(error.message)
|
|
37
|
+
? 409
|
|
38
|
+
: 503;
|
|
39
|
+
}
|
|
40
|
+
} catch {
|
|
41
|
+
return 500;
|
|
42
|
+
}
|
|
43
|
+
const message = safeErrorMessage(error);
|
|
44
|
+
return CONFLICT_MESSAGES.some((prefix) => message.startsWith(prefix)) ? 409 : 400;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function errorMessage(error: unknown): string {
|
|
48
|
+
return safeErrorMessage(error);
|
|
49
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { BunqueueBackupRunner, type BackupRunnerPort } from '../backup/runner';
|
|
2
|
+
import type { ProcessManager } from '../manager';
|
|
3
|
+
import { QueueOperationsRuntime } from '../queue/runtime';
|
|
4
|
+
import type { QueueOperationsPort } from '../queue/types';
|
|
5
|
+
import { WorkflowRuntime, type WorkflowRuntimePort } from '../workflow/runtime';
|
|
6
|
+
import { resolveManagedTargetPolicy } from '../managedTarget';
|
|
7
|
+
import { errorMessage, errorStatus } from './errors';
|
|
8
|
+
import { MANAGED_CONTROL_TARGET } from './controlTarget';
|
|
9
|
+
import { AgentLifecycleGate, type AgentLifecyclePort } from './lifecycle';
|
|
10
|
+
import { corsHeaders, isHostAllowed, isOriginAllowed, tokenOk } from './policy';
|
|
11
|
+
import { routeAgentRequest } from './router';
|
|
12
|
+
import type { AgentFetchHandler, AgentOptions, RouteResponse } from './types';
|
|
13
|
+
|
|
14
|
+
function responseOf(
|
|
15
|
+
result: RouteResponse | Response | null,
|
|
16
|
+
origin: string | null,
|
|
17
|
+
allowedOrigins: string[]
|
|
18
|
+
): Response {
|
|
19
|
+
if (result instanceof Response) return result;
|
|
20
|
+
const routed = result ?? { status: 404, body: { ok: false, error: 'Not found' } };
|
|
21
|
+
return new Response(JSON.stringify(routed.body), {
|
|
22
|
+
status: routed.status,
|
|
23
|
+
headers: {
|
|
24
|
+
'Content-Type': 'application/json',
|
|
25
|
+
...corsHeaders(origin, allowedOrigins),
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Build a network-independent request handler with persistent SDK resources. */
|
|
31
|
+
export function createFetchHandler(
|
|
32
|
+
manager: ProcessManager,
|
|
33
|
+
options: AgentOptions,
|
|
34
|
+
runtime: WorkflowRuntimePort = new WorkflowRuntime(),
|
|
35
|
+
backupRunner: BackupRunnerPort = new BunqueueBackupRunner(),
|
|
36
|
+
queueRuntime: QueueOperationsPort = new QueueOperationsRuntime(),
|
|
37
|
+
lifecycle: AgentLifecyclePort = new AgentLifecycleGate()
|
|
38
|
+
): AgentFetchHandler {
|
|
39
|
+
const {
|
|
40
|
+
allowedOrigins,
|
|
41
|
+
allowedHosts,
|
|
42
|
+
token,
|
|
43
|
+
requireTokenForAll = false,
|
|
44
|
+
controlTarget = MANAGED_CONTROL_TARGET,
|
|
45
|
+
} = options;
|
|
46
|
+
const managedTargetPolicy = resolveManagedTargetPolicy(
|
|
47
|
+
options.managedProxyPath,
|
|
48
|
+
options.managedProxyUrl
|
|
49
|
+
);
|
|
50
|
+
const json = (body: unknown, status: number, origin: string | null) =>
|
|
51
|
+
responseOf({ body, status }, origin, allowedOrigins);
|
|
52
|
+
|
|
53
|
+
const handle = async (request: Request): Promise<Response> => {
|
|
54
|
+
const { pathname } = new URL(request.url);
|
|
55
|
+
const method = request.method;
|
|
56
|
+
const origin = request.headers.get('origin');
|
|
57
|
+
if (!isHostAllowed(request.headers.get('host'), allowedHosts)) {
|
|
58
|
+
return json({ ok: false, error: 'Host not allowed' }, 403, origin);
|
|
59
|
+
}
|
|
60
|
+
if (!isOriginAllowed(origin, allowedOrigins)) {
|
|
61
|
+
return json({ ok: false, error: 'Origin not allowed' }, 403, origin);
|
|
62
|
+
}
|
|
63
|
+
if (method === 'OPTIONS') {
|
|
64
|
+
return new Response(null, { status: 204, headers: corsHeaders(origin, allowedOrigins) });
|
|
65
|
+
}
|
|
66
|
+
const mutating = method === 'POST' || method === 'PUT';
|
|
67
|
+
const authRequired = requireTokenForAll || (mutating && Boolean(token));
|
|
68
|
+
if (authRequired && !tokenOk(request, token)) {
|
|
69
|
+
return json({ ok: false, error: 'Unauthorized' }, 401, origin);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
lifecycle.assertOpen();
|
|
74
|
+
const result = await routeAgentRequest(
|
|
75
|
+
request,
|
|
76
|
+
pathname,
|
|
77
|
+
method,
|
|
78
|
+
manager,
|
|
79
|
+
runtime,
|
|
80
|
+
backupRunner,
|
|
81
|
+
queueRuntime,
|
|
82
|
+
lifecycle,
|
|
83
|
+
origin,
|
|
84
|
+
allowedOrigins,
|
|
85
|
+
controlTarget,
|
|
86
|
+
managedTargetPolicy
|
|
87
|
+
);
|
|
88
|
+
return responseOf(result, origin, allowedOrigins);
|
|
89
|
+
} catch (error) {
|
|
90
|
+
return json({ ok: false, error: errorMessage(error) }, errorStatus(error), origin);
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
let closing: Promise<void> | null = null;
|
|
94
|
+
const close = () => {
|
|
95
|
+
closing ??= lifecycle.close(() =>
|
|
96
|
+
settleResourceClosures([
|
|
97
|
+
() => runtime.close(),
|
|
98
|
+
() => backupRunner.close(),
|
|
99
|
+
() => queueRuntime.close(),
|
|
100
|
+
])
|
|
101
|
+
);
|
|
102
|
+
return closing;
|
|
103
|
+
};
|
|
104
|
+
const beginShutdown = () => {
|
|
105
|
+
manager.beginShutdown();
|
|
106
|
+
return close();
|
|
107
|
+
};
|
|
108
|
+
let shuttingDown: Promise<void> | null = null;
|
|
109
|
+
const shutdown = () => {
|
|
110
|
+
shuttingDown ??= closeThenShutdown(beginShutdown, manager);
|
|
111
|
+
return shuttingDown;
|
|
112
|
+
};
|
|
113
|
+
const forceShutdown = () => manager.forceShutdown();
|
|
114
|
+
return Object.assign(handle, { close, beginShutdown, shutdown, forceShutdown });
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function closeThenShutdown(
|
|
118
|
+
beginShutdown: () => Promise<void>,
|
|
119
|
+
manager: ProcessManager
|
|
120
|
+
): Promise<void> {
|
|
121
|
+
const closeResult = await settle(beginShutdown);
|
|
122
|
+
const shutdownResult = await settle(() => manager.shutdown());
|
|
123
|
+
if (!closeResult.ok && !shutdownResult.ok) {
|
|
124
|
+
throw new AggregateError(
|
|
125
|
+
[closeResult.error, shutdownResult.error],
|
|
126
|
+
'Agent resources and managed server both failed to shut down'
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
if (!closeResult.ok) throw closeResult.error;
|
|
130
|
+
if (!shutdownResult.ok) throw shutdownResult.error;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function settleResourceClosures(closures: Array<() => Promise<void>>): Promise<void> {
|
|
134
|
+
const settled = await Promise.allSettled(
|
|
135
|
+
closures.map((closeResource) => Promise.resolve().then(closeResource))
|
|
136
|
+
);
|
|
137
|
+
const failures = settled.flatMap((result) =>
|
|
138
|
+
result.status === 'rejected' ? [result.reason] : []
|
|
139
|
+
);
|
|
140
|
+
if (failures.length === 1) throw failures[0];
|
|
141
|
+
if (failures.length > 1) {
|
|
142
|
+
throw new AggregateError(failures, 'Multiple agent resources failed to close');
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
type SettledOperation = { ok: true } | { ok: false; error: unknown };
|
|
147
|
+
|
|
148
|
+
async function settle(operation: () => Promise<unknown>): Promise<SettledOperation> {
|
|
149
|
+
try {
|
|
150
|
+
await operation();
|
|
151
|
+
return { ok: true };
|
|
152
|
+
} catch (error) {
|
|
153
|
+
return { ok: false, error };
|
|
154
|
+
}
|
|
155
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
export interface LimitedJsonBodyOptions {
|
|
2
|
+
scope: string;
|
|
3
|
+
maxBytes: number;
|
|
4
|
+
limitLabel: string;
|
|
5
|
+
invalidContentLengthMessage?: string;
|
|
6
|
+
exceedsMessage?: string;
|
|
7
|
+
missingMessage?: string;
|
|
8
|
+
invalidUtf8Message?: string;
|
|
9
|
+
invalidJsonMessage?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Read and parse a JSON body without ever buffering more than maxBytes. */
|
|
13
|
+
export async function readLimitedJsonBody(
|
|
14
|
+
request: Request,
|
|
15
|
+
options: LimitedJsonBodyOptions
|
|
16
|
+
): Promise<unknown> {
|
|
17
|
+
validateContentLength(request.headers.get('content-length'), options);
|
|
18
|
+
if (!request.body) throw new Error(message(options, 'missing'));
|
|
19
|
+
|
|
20
|
+
const reader = request.body.getReader();
|
|
21
|
+
const decoder = new TextDecoder('utf-8', { fatal: true });
|
|
22
|
+
const textChunks: string[] = [];
|
|
23
|
+
let totalBytes = 0;
|
|
24
|
+
try {
|
|
25
|
+
for (;;) {
|
|
26
|
+
const next = await reader.read();
|
|
27
|
+
if (next.done) break;
|
|
28
|
+
totalBytes += next.value.byteLength;
|
|
29
|
+
if (totalBytes > options.maxBytes) {
|
|
30
|
+
throw new Error(message(options, 'exceeds'));
|
|
31
|
+
}
|
|
32
|
+
textChunks.push(decode(decoder, next.value, true, options));
|
|
33
|
+
}
|
|
34
|
+
textChunks.push(decode(decoder, undefined, false, options));
|
|
35
|
+
} catch (error) {
|
|
36
|
+
await cancelQuietly(reader);
|
|
37
|
+
throw error;
|
|
38
|
+
} finally {
|
|
39
|
+
reader.releaseLock();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (totalBytes === 0) throw new Error(message(options, 'missing'));
|
|
43
|
+
try {
|
|
44
|
+
return JSON.parse(textChunks.join(''));
|
|
45
|
+
} catch {
|
|
46
|
+
throw new Error(message(options, 'invalidJson'));
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function validateContentLength(
|
|
51
|
+
declared: string | null,
|
|
52
|
+
options: LimitedJsonBodyOptions
|
|
53
|
+
): void {
|
|
54
|
+
if (declared === null) return;
|
|
55
|
+
if (!/^\d+$/.test(declared)) {
|
|
56
|
+
throw new Error(message(options, 'invalidContentLength'));
|
|
57
|
+
}
|
|
58
|
+
const normalized = declared.replace(/^0+/, '') || '0';
|
|
59
|
+
const maximum = String(options.maxBytes);
|
|
60
|
+
if (
|
|
61
|
+
normalized.length > maximum.length ||
|
|
62
|
+
(normalized.length === maximum.length && normalized > maximum)
|
|
63
|
+
) {
|
|
64
|
+
throw new Error(message(options, 'exceeds'));
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function decode(
|
|
69
|
+
decoder: TextDecoder,
|
|
70
|
+
value: Uint8Array | undefined,
|
|
71
|
+
stream: boolean,
|
|
72
|
+
options: LimitedJsonBodyOptions
|
|
73
|
+
): string {
|
|
74
|
+
try {
|
|
75
|
+
return decoder.decode(value, { stream });
|
|
76
|
+
} catch {
|
|
77
|
+
throw new Error(message(options, 'invalidUtf8'));
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function cancelQuietly(reader: { cancel(reason?: unknown): Promise<void> }): Promise<void> {
|
|
82
|
+
await reader.cancel().catch(() => undefined);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
type MessageKind =
|
|
86
|
+
| 'invalidContentLength'
|
|
87
|
+
| 'exceeds'
|
|
88
|
+
| 'missing'
|
|
89
|
+
| 'invalidUtf8'
|
|
90
|
+
| 'invalidJson';
|
|
91
|
+
|
|
92
|
+
function message(options: LimitedJsonBodyOptions, kind: MessageKind): string {
|
|
93
|
+
if (kind === 'invalidContentLength') {
|
|
94
|
+
return (
|
|
95
|
+
options.invalidContentLengthMessage ??
|
|
96
|
+
`${options.scope} Content-Length must be a non-negative integer`
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
if (kind === 'exceeds') {
|
|
100
|
+
return options.exceedsMessage ?? `${options.scope} request exceeds ${options.limitLabel}`;
|
|
101
|
+
}
|
|
102
|
+
if (kind === 'missing') {
|
|
103
|
+
return options.missingMessage ?? `${options.scope} request body is required`;
|
|
104
|
+
}
|
|
105
|
+
if (kind === 'invalidUtf8') {
|
|
106
|
+
return options.invalidUtf8Message ?? `${options.scope} request body must use valid UTF-8`;
|
|
107
|
+
}
|
|
108
|
+
return options.invalidJsonMessage ?? `${options.scope} request body must contain valid JSON`;
|
|
109
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/** Coordinates exclusive process transitions with concurrent SDK leases. */
|
|
2
|
+
export interface AgentLifecyclePort {
|
|
3
|
+
assertOpen(): void;
|
|
4
|
+
lease<T>(operation: () => Promise<T>): Promise<T>;
|
|
5
|
+
run<T>(operation: () => Promise<T>): Promise<T>;
|
|
6
|
+
close(operation: () => Promise<void>): Promise<void>;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export class AgentLifecycleClosedError extends Error {
|
|
10
|
+
constructor() {
|
|
11
|
+
super('The Bunqueue control agent is closing');
|
|
12
|
+
this.name = 'AgentLifecycleClosedError';
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export class AgentLifecycleGate implements AgentLifecyclePort {
|
|
17
|
+
private barrier: Promise<void> = Promise.resolve();
|
|
18
|
+
private leases = new Set<Promise<void>>();
|
|
19
|
+
private closed = false;
|
|
20
|
+
private closing: Promise<void> | null = null;
|
|
21
|
+
|
|
22
|
+
assertOpen(): void {
|
|
23
|
+
if (this.closed) throw new AgentLifecycleClosedError();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
lease<T>(operation: () => Promise<T>): Promise<T> {
|
|
27
|
+
try {
|
|
28
|
+
this.assertOpen();
|
|
29
|
+
} catch (error) {
|
|
30
|
+
return Promise.reject(error);
|
|
31
|
+
}
|
|
32
|
+
const result = this.barrier.then(operation);
|
|
33
|
+
const batch = this.leases;
|
|
34
|
+
const completion = result.then(
|
|
35
|
+
() => undefined,
|
|
36
|
+
() => undefined
|
|
37
|
+
);
|
|
38
|
+
batch.add(completion);
|
|
39
|
+
void completion.then(() => batch.delete(completion));
|
|
40
|
+
return result;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
run<T>(operation: () => Promise<T>): Promise<T> {
|
|
44
|
+
try {
|
|
45
|
+
this.assertOpen();
|
|
46
|
+
} catch (error) {
|
|
47
|
+
return Promise.reject(error);
|
|
48
|
+
}
|
|
49
|
+
return this.enqueueTransition(operation);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
close(operation: () => Promise<void>): Promise<void> {
|
|
53
|
+
if (this.closing) return this.closing;
|
|
54
|
+
this.closed = true;
|
|
55
|
+
this.closing = this.enqueueTransition(operation);
|
|
56
|
+
return this.closing;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
private enqueueTransition<T>(operation: () => Promise<T>): Promise<T> {
|
|
60
|
+
const precedingBarrier = this.barrier;
|
|
61
|
+
const precedingLeases = this.leases;
|
|
62
|
+
this.leases = new Set();
|
|
63
|
+
const result = precedingBarrier
|
|
64
|
+
.then(() => Promise.all(precedingLeases))
|
|
65
|
+
.then(operation);
|
|
66
|
+
this.barrier = result.then(
|
|
67
|
+
() => undefined,
|
|
68
|
+
() => undefined
|
|
69
|
+
);
|
|
70
|
+
return result;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { ProcessManager, ServerConfig, StatusSnapshot } from '../manager';
|
|
2
|
+
import type { AgentLifecyclePort } from './lifecycle';
|
|
3
|
+
|
|
4
|
+
export interface ManagedRuntimeSnapshot {
|
|
5
|
+
config: ServerConfig;
|
|
6
|
+
running: boolean;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export type ManagedRuntimeAdmission = <T>(
|
|
10
|
+
operation: (snapshot: ManagedRuntimeSnapshot) => Promise<T>
|
|
11
|
+
) => Promise<T>;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Admit SDK work against one managed-process generation. The operation and its
|
|
15
|
+
* final state/config check share the same lifecycle section as start/stop.
|
|
16
|
+
*/
|
|
17
|
+
export function managedRuntimeAdmission(
|
|
18
|
+
manager: ProcessManager,
|
|
19
|
+
lifecycle: AgentLifecyclePort,
|
|
20
|
+
initial: StatusSnapshot
|
|
21
|
+
): ManagedRuntimeAdmission {
|
|
22
|
+
return (operation) =>
|
|
23
|
+
lifecycle.lease(async () => {
|
|
24
|
+
const current = manager.getStatus();
|
|
25
|
+
if (generationChanged(initial, current)) {
|
|
26
|
+
throw new Error('Managed Bunqueue server restarted while the request was prepared');
|
|
27
|
+
}
|
|
28
|
+
return operation({
|
|
29
|
+
config: current.runningConfig ?? current.config,
|
|
30
|
+
running: current.status === 'running',
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function generationChanged(initial: StatusSnapshot, current: StatusSnapshot): boolean {
|
|
36
|
+
return (
|
|
37
|
+
initial.status === 'running' &&
|
|
38
|
+
current.status === 'running' &&
|
|
39
|
+
initial.generation !== current.generation
|
|
40
|
+
);
|
|
41
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
const DEFAULT_ORIGINS = ['http://localhost:5273', 'http://127.0.0.1:5273'];
|
|
2
|
+
const LOOPBACK_HOSTS = ['localhost', '127.0.0.1', '::1', '0.0.0.0'];
|
|
3
|
+
|
|
4
|
+
export function resolveAllowedOrigins(env = process.env): string[] {
|
|
5
|
+
const extra = (env.AGENT_ALLOWED_ORIGINS ?? '')
|
|
6
|
+
.split(',')
|
|
7
|
+
.map((origin) => origin.trim().replace(/\/$/, ''))
|
|
8
|
+
.filter(Boolean);
|
|
9
|
+
return Array.from(new Set([...DEFAULT_ORIGINS, ...extra]));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Extract a lowercase bare hostname from a Host header or origin. */
|
|
13
|
+
export function hostnameOf(host: string): string {
|
|
14
|
+
const stripped = host.trim().replace(/^[a-z][a-z0-9+.-]*:\/\//i, '');
|
|
15
|
+
const ipv6 = stripped.match(/^\[([^\]]+)\]/);
|
|
16
|
+
if (ipv6) return ipv6[1].toLowerCase();
|
|
17
|
+
const firstColon = stripped.indexOf(':');
|
|
18
|
+
if (firstColon === -1) return stripped.toLowerCase();
|
|
19
|
+
if (firstColon !== stripped.lastIndexOf(':')) return stripped.toLowerCase();
|
|
20
|
+
return stripped.slice(0, firstColon).toLowerCase();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function resolveAllowedHosts(env = process.env, extra: string[] = []): string[] {
|
|
24
|
+
const fromEnvironment = (env.AGENT_ALLOWED_HOSTS ?? '')
|
|
25
|
+
.split(',')
|
|
26
|
+
.map((host) => hostnameOf(host.trim()))
|
|
27
|
+
.filter(Boolean);
|
|
28
|
+
const extraHosts = extra.map(hostnameOf).filter(Boolean);
|
|
29
|
+
return Array.from(new Set([...LOOPBACK_HOSTS, ...extraHosts, ...fromEnvironment]));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function isOriginAllowed(origin: string | null, allowed: string[]): boolean {
|
|
33
|
+
if (!origin) return true;
|
|
34
|
+
return allowed.includes(origin.replace(/\/$/, ''));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function isHostAllowed(host: string | null, allowed?: string[]): boolean {
|
|
38
|
+
if (!allowed) return true;
|
|
39
|
+
if (!host) return false;
|
|
40
|
+
return allowed.includes(hostnameOf(host));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function corsHeaders(origin: string | null, allowed: string[]): Record<string, string> {
|
|
44
|
+
const headers: Record<string, string> = {
|
|
45
|
+
'Access-Control-Allow-Methods': 'GET, POST, PUT, OPTIONS',
|
|
46
|
+
'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Agent-Token',
|
|
47
|
+
Vary: 'Origin',
|
|
48
|
+
};
|
|
49
|
+
if (origin && allowed.includes(origin.replace(/\/$/, ''))) {
|
|
50
|
+
headers['Access-Control-Allow-Origin'] = origin;
|
|
51
|
+
}
|
|
52
|
+
return headers;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function tokenOk(request: Request, token?: string): boolean {
|
|
56
|
+
if (!token) return false;
|
|
57
|
+
const authorization = request.headers.get('authorization') ?? '';
|
|
58
|
+
const bearer = authorization.startsWith('Bearer ') ? authorization.slice(7) : '';
|
|
59
|
+
return bearer === token || request.headers.get('x-agent-token') === token;
|
|
60
|
+
}
|