dsh-multimodal-node 0.0.0-stage → 0.3.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/lib/gateway.js ADDED
@@ -0,0 +1,974 @@
1
+ /**
2
+ * The gateway listener: a standalone HTTP server that speaks ComfyUI's own
3
+ * protocol on one port (8187 by default) so every existing ComfyUI client —
4
+ * the official frontend, an API script, another ComfyUI node — can keep posting
5
+ * JSON to a single address and never learn that two GPUs are behind it.
6
+ *
7
+ * What it fakes, and why that is safe:
8
+ *
9
+ * /prompt accepted here, queued, and forwarded later by the scheduler.
10
+ * The id returned is a gateway id; the same id addresses
11
+ * /history/<id> and /view forever.
12
+ * /queue our pool merged with whatever the instances' own queues hold.
13
+ * /jobs our pool merged with every instance's own job list, so the
14
+ * dsh-comfyui task centre sees one list instead of the primary's
15
+ * slice. Cancel routes are matched to the instance that holds
16
+ * the job; /jobs/{id} resolves across the pool first.
17
+ * /history our finished runs merged with the instances' own history, so
18
+ * a run started directly on 8188/8189 still shows up.
19
+ * /system_stats, /object_info, /models, /embeddings, /features
20
+ * answered from the primary instance (they describe the
21
+ * installation, not the GPU), with the device list merged.
22
+ * /ws a real WebSocket pushing the same event names the frontend
23
+ * listens for, sourced from the pool instead of one server.
24
+ * /view streamed from whichever instance holds the file.
25
+ *
26
+ * Anything not listed is relayed to the primary instance, so an endpoint this
27
+ * gateway does not know still behaves like ComfyUI.
28
+ */
29
+ import { createServer } from 'node:http';
30
+ import { errorMessage, pipeGuarded, readJson, sendJson, closeServer } from './http.js';
31
+ import { WsServer } from './ws.js';
32
+
33
+ const COMFY_ID = /^[0-9a-f-]{8,64}$/i;
34
+
35
+ /** Accept `bytes=…` ranges on the merged media route (players seek with them). */
36
+ function normalizePath(url) {
37
+ const path = url.split('?')[0];
38
+ return path.startsWith('/api/') ? path.slice(4) : path;
39
+ }
40
+
41
+ export class GatewayServer {
42
+ /**
43
+ * @param {{settings:()=>any, instances: import('./instances.js').InstanceManager,
44
+ * pool: import('./pool.js').TaskPool, log:(level:string,message:string)=>void}} deps
45
+ */
46
+ constructor(deps) {
47
+ this.deps = deps;
48
+ this.server = null;
49
+ this.ws = new WsServer();
50
+ this.objectInfo = new Map(); // instanceId -> { ts, value }
51
+ this.primaryCache = { at: 0, id: null };
52
+ this.lastError = null;
53
+ this.port = null;
54
+ this.host = null;
55
+ }
56
+
57
+ get listening() {
58
+ return this.server !== null && this.server.listening;
59
+ }
60
+
61
+ /** Primary instance: the first enabled, healthy one; the graph source for object_info. */
62
+ async primary() {
63
+ const now = Date.now();
64
+ if (now - this.primaryCache.at < 3000 && this.primaryCache.id !== null) {
65
+ const known = this.deps.instances.get(this.primaryCache.id);
66
+ if (known !== undefined && known.health.ok) return known;
67
+ }
68
+ const candidates = this.deps.instances.enabled();
69
+ const healthy = candidates.find((runtime) => runtime.health.ok);
70
+ const chosen = healthy ?? candidates[0];
71
+ this.primaryCache = { at: now, id: chosen?.id ?? null };
72
+ return chosen;
73
+ }
74
+
75
+ async start() {
76
+ const settings = this.deps.settings().listen ?? {};
77
+ if (settings.enabled === false) return { ok: false, skipped: 'disabled' };
78
+ const host = settings.host ?? '0.0.0.0';
79
+ const port = Number(settings.port ?? 8287);
80
+ await this.stop();
81
+ const server = createServer((request, response) => {
82
+ this.handle(request, response).catch((error) => {
83
+ if (!response.headersSent) sendJson(response, 500, { error: errorMessage(error) });
84
+ else response.end();
85
+ });
86
+ });
87
+ server.on('upgrade', (request, socket, head) => {
88
+ const path = normalizePath(request.url ?? '/');
89
+ if (path !== '/ws') {
90
+ socket.destroy();
91
+ return;
92
+ }
93
+ this.ws.accept(request, socket);
94
+ // Greet with the current status so a fresh frontend paints immediately.
95
+ void this.pushStatus();
96
+ });
97
+ await new Promise((resolve, reject) => {
98
+ const onError = (error) => {
99
+ server.off('listening', onListening);
100
+ reject(error);
101
+ };
102
+ const onListening = () => {
103
+ server.off('error', onError);
104
+ resolve();
105
+ };
106
+ server.once('error', onError);
107
+ server.once('listening', onListening);
108
+ server.listen(port, host);
109
+ });
110
+ server.on('error', (error) => {
111
+ this.lastError = errorMessage(error);
112
+ this.deps.log('error', `gateway listen error: ${this.lastError}`);
113
+ });
114
+ this.server = server;
115
+ this.port = port;
116
+ this.host = host;
117
+ this.lastError = null;
118
+ this.deps.log('info', `gateway listening on ${host}:${port}`);
119
+ return { ok: true, host, port };
120
+ }
121
+
122
+ async stop() {
123
+ this.ws.dispose();
124
+ const server = this.server;
125
+ this.server = null;
126
+ if (server === null) return { ok: true };
127
+ // closeServer 会强制断开 keep-alive 连接,端口才真的释放(见 http.js)。
128
+ await closeServer(server);
129
+ return { ok: true };
130
+ }
131
+
132
+ async handle(request, response) {
133
+ const url = new URL(request.url ?? '/', 'http://gateway.invalid');
134
+ const path = normalizePath(request.url ?? '/');
135
+ const method = request.method ?? 'GET';
136
+
137
+ // CORS: callers are usually on the LAN (other PCs, other ComfyUI nodes).
138
+ response.setHeader('access-control-allow-origin', '*');
139
+ response.setHeader('access-control-allow-headers', 'content-type,authorization');
140
+ response.setHeader('access-control-allow-methods', 'GET,POST,HEAD,OPTIONS');
141
+ if (method === 'OPTIONS') {
142
+ response.writeHead(204);
143
+ response.end();
144
+ return;
145
+ }
146
+ /**
147
+ * 用户节点接口先走:/node/ 前缀独占,处理完就直接返回。
148
+ *
149
+ * 放在 CORS 之后、任何 ComfyUI 协议分支之前——否则一个 /node/ping 会被当成
150
+ * 「未知路由」转发到主实例,用户节点收到的就是一个 ComfyUI 的 404。
151
+ */
152
+ if (typeof this.deps.nodeRoutes === 'function' && this.deps.nodeRoutes(request, response, url, path, method) === true) {
153
+ return undefined;
154
+ }
155
+
156
+ /**
157
+ * API 接入端口的凭据闸门。
158
+ *
159
+ * 一个地方判完,把结果挂在请求上,后面的分支(尤其是 /prompt)按 tier 兜底。
160
+ * 放在这里而不是每个分支里各判一次:漏判一条路由就是一次未授权提交,而
161
+ * "每个分支都记得判" 这种约定,第二个人的第一个改动就会破。
162
+ *
163
+ * 未开启校验时 verdict.checked 为 false —— 放行但标注,审计日志里能分开。
164
+ */
165
+ if (typeof this.deps.apiGuard === 'function') {
166
+ const verdict = this.deps.apiGuard(request, url);
167
+ if (verdict.ok !== true) {
168
+ return sendJson(response, 401, { ok: false, error: verdict.error ?? '缺少 apikey', authRequired: true });
169
+ }
170
+ request.dshAuth = verdict;
171
+ }
172
+
173
+ if (path === '/gateway/status') return sendJson(response, 200, this.statusPayload());
174
+ if (path === '/gateway/instances' && method === 'POST') {
175
+ const body = await readJson(request).catch(() => ({}));
176
+ const action = String(body.action ?? '');
177
+ const id = String(body.id ?? '');
178
+ try {
179
+ if (action === 'start') return sendJson(response, 200, await this.deps.instances.start(id));
180
+ if (action === 'stop') return sendJson(response, 200, await this.deps.instances.stop(id));
181
+ if (action === 'restart') return sendJson(response, 200, await this.deps.instances.restart(id));
182
+ } catch (error) {
183
+ return sendJson(response, 200, { ok: false, error: errorMessage(error) });
184
+ }
185
+ }
186
+
187
+ if (path === '/prompt' && method === 'POST') {
188
+ let body;
189
+ try {
190
+ body = await readJson(request);
191
+ } catch (error) {
192
+ return sendJson(response, 400, { error: errorMessage(error) });
193
+ }
194
+ /**
195
+ * 带 key 的调用按 key 的分级兜底一次。
196
+ *
197
+ * /prompt 本身不审查(程序发的就是要的,这是 03 号插件锁了源码级测试的边界),
198
+ * 但**发出去一把 SFW 的 key** 必须真的限住内容,否则那把 key 等于没发。
199
+ * 没开校验时 checked=false,这条判断整体跳过。
200
+ */
201
+ const auth = request.dshAuth;
202
+ if (auth !== undefined && auth.checked === true && auth.tier !== 'nsfw') {
203
+ const { classifyWorkflow, visibleTo } = await import('./tiers.js');
204
+ const classification = classifyWorkflow({ api: body?.prompt ?? {}, name: body?.extra_data?.workflowName ?? '' });
205
+ if (!visibleTo(auth.tier, classification.tier)) {
206
+ this.deps.log?.('warn', `apikey ${auth.name} 提交了成人工作流,按 tier=sfw 拒绝`);
207
+ return sendJson(response, 403, {
208
+ ok: false,
209
+ error: '这个 apikey 的分级是 SFW,不能提交成人内容的工作流',
210
+ tier: auth.tier,
211
+ reason: classification.reason,
212
+ });
213
+ }
214
+ }
215
+ try {
216
+ const reply = this.deps.pool.enqueue(body);
217
+ return sendJson(response, 200, reply);
218
+ } catch (error) {
219
+ return sendJson(response, 400, { error: errorMessage(error), node_errors: {} });
220
+ }
221
+ }
222
+
223
+ if (path === '/queue' && method === 'GET') {
224
+ const queue = await this.deps.pool.queueView();
225
+ return sendJson(response, 200, queue);
226
+ }
227
+ if (path === '/queue' && method === 'POST') {
228
+ const body = await readJson(request).catch(() => ({}));
229
+ if (body.clear === true) {
230
+ for (const task of this.deps.pool.ordered()) this.deps.pool.cancel(task.id);
231
+ for (const runtime of this.deps.instances.enabled()) {
232
+ try {
233
+ await this.deps.pool.backend(runtime).clearQueue();
234
+ } catch {
235
+ // unreachable instance: its queue is not ours to clear
236
+ }
237
+ }
238
+ return sendJson(response, 200, {});
239
+ }
240
+ if (Array.isArray(body.delete)) {
241
+ for (const id of body.delete) this.deps.pool.cancel(String(id));
242
+ return sendJson(response, 200, {});
243
+ }
244
+ return sendJson(response, 200, {});
245
+ }
246
+
247
+ if (path === '/interrupt' && method === 'POST') {
248
+ const body = await readJson(request).catch(() => ({}));
249
+ if (typeof body.prompt_id === 'string' && body.prompt_id !== '') {
250
+ const known = this.deps.pool.tasks.has(body.prompt_id);
251
+ const result = this.deps.pool.cancel(body.prompt_id);
252
+ // A prompt id we do not track is somebody else's run — it was started
253
+ // directly on an instance — so the only way to stop it is to ask the
254
+ // instances. For an id we do track, `cancel` already interrupted the
255
+ // right upstream prompt; broadcasting it again would stop unrelated
256
+ // work on another GPU.
257
+ if (!known) {
258
+ await Promise.all(
259
+ this.deps.instances.enabled().map(async (runtime) => {
260
+ try {
261
+ await this.deps.pool.backend(runtime).interrupt(body.prompt_id);
262
+ } catch {
263
+ // instance asleep: nothing to interrupt there
264
+ }
265
+ }),
266
+ );
267
+ }
268
+ return sendJson(response, 200, result);
269
+ }
270
+ return sendJson(response, 200, {});
271
+ }
272
+
273
+ if (path === '/free' && method === 'POST') {
274
+ const body = await readJson(request).catch(() => ({}));
275
+ await Promise.all(
276
+ this.deps.instances.enabled().map(async (runtime) => {
277
+ try {
278
+ await this.deps.pool.backend(runtime).free(body);
279
+ } catch {
280
+ // ignore a sleeping instance
281
+ }
282
+ }),
283
+ );
284
+ return sendJson(response, 200, {});
285
+ }
286
+
287
+ if (path === '/history' && method === 'GET') {
288
+ const max = Math.min(1000, Math.max(1, Number(url.searchParams.get('max_items') ?? '64') || 64));
289
+ const history = await this.mergedHistory(max);
290
+ return sendJson(response, 200, history);
291
+ }
292
+ if (path.startsWith('/history/') && method === 'GET') {
293
+ const id = decodeURIComponent(path.slice('/history/'.length));
294
+ const entry = await this.mergedHistoryOne(id);
295
+ return sendJson(response, 200, entry === undefined ? {} : { [id]: entry });
296
+ }
297
+
298
+ /**
299
+ * The job API — what the dsh-comfyui task centre actually polls.
300
+ *
301
+ * Two things had to be handled here rather than by the generic relay:
302
+ *
303
+ * 1. The list is per-instance on a real server: 8188 answers with its own history
304
+ * and knows nothing about 8189. Relaying the list to the primary made half the
305
+ * farm invisible ("生成完的记录只出现一部分"), so every enabled instance is
306
+ * queried and the rows are merged with the gateway's own finished tasks.
307
+ * 2. `status=all` is the gateway's dialect, not ComfyUI's — a real server rejects
308
+ * it with 400 ("Invalid status value(s): all"), and the caller shows that as a
309
+ * red error box. It is translated to "no status filter" here.
310
+ */
311
+ if (path === '/jobs' && method === 'GET') {
312
+ return sendJson(response, 200, await this.mergedJobs(url));
313
+ }
314
+ if (path.startsWith('/jobs/') && method === 'GET') {
315
+ const id = decodeURIComponent(path.slice('/jobs/'.length));
316
+ return sendJson(response, 200, await this.mergedJobOne(id));
317
+ }
318
+ if ((path === '/jobs/cancel' || /^\/jobs\/[^/]+\/cancel$/.test(path)) && method === 'POST') {
319
+ const raw = await readRaw(request);
320
+ return this.proxyJobMutation(path, raw, request, response);
321
+ }
322
+
323
+ if (path === '/view') {
324
+ return this.proxyView(url, request, response);
325
+ }
326
+
327
+ if (path === '/system_stats') {
328
+ return sendJson(response, 200, await this.mergedStats());
329
+ }
330
+
331
+ if (path === '/object_info' || path.startsWith('/object_info/')) {
332
+ const primary = await this.primary();
333
+ if (primary === undefined) return sendJson(response, 503, { error: '没有可用的 ComfyUI 实例' });
334
+ try {
335
+ const value = await this.cachedObjectInfo(primary, path.slice('/object_info'.length));
336
+ return sendJson(response, 200, value);
337
+ } catch (error) {
338
+ return sendJson(response, 502, { error: errorMessage(error) });
339
+ }
340
+ }
341
+
342
+ if (path === '/models' || path.startsWith('/models/')) {
343
+ const primary = await this.primary();
344
+ if (primary === undefined) return sendJson(response, 200, []);
345
+ try {
346
+ const value = await this.deps.pool.backend(primary).json(path);
347
+ return sendJson(response, 200, value);
348
+ } catch (error) {
349
+ return sendJson(response, 502, { error: errorMessage(error) });
350
+ }
351
+ }
352
+
353
+ /**
354
+ * Everything else: relay to the primary instance.
355
+ *
356
+ * Two separate faults used to live here, both surfacing as an unusable message the
357
+ * calling DSH then showed as 502 or "no such endpoint":
358
+ *
359
+ * 1. The `/api` prefix was stripped unconditionally. That is right for the classic
360
+ * routes (ComfyUI mirrors every one of them under /api, and an `/api`-only
361
+ * reverse proxy is the reason the bare form is worth trying), but WRONG for the
362
+ * job API: `/api/jobs` exists only with its prefix, so rewriting it to `/jobs`
363
+ * asked a healthy 0.31.1 server for a route it has never had and got 404 back.
364
+ * `upstreamCandidates()` therefore offers the caller's own spelling first and
365
+ * the bare form only as a 404 fallback.
366
+ * 2. `fetch` reports DNS failure, refusal, TLS refusal and timeouts as the same
367
+ * `fetch failed`, and the Host header was being replaced with the upstream's.
368
+ * The caller's authorization is forwarded, their Host is not, a loopback name is
369
+ * pinned to 127.0.0.1, and `describeFetchError` names the cause.
370
+ */
371
+ const primary = await this.primary();
372
+ if (primary === undefined) return sendJson(response, 503, { error: '没有可用的 ComfyUI 实例' });
373
+ const candidates = upstreamCandidates(primary.instance.baseUrl, request.url ?? '/');
374
+ const body = method === 'GET' || method === 'HEAD' ? undefined : await readRaw(request);
375
+ const headers = {};
376
+ if (request.headers.authorization) headers.authorization = request.headers.authorization;
377
+ if (request.headers['content-type']) headers['content-type'] = request.headers['content-type'];
378
+ let lastTarget = candidates[0];
379
+ try {
380
+ let upstream;
381
+ for (const target of candidates) {
382
+ lastTarget = target;
383
+ upstream = await fetch(target, {
384
+ method,
385
+ headers,
386
+ body,
387
+ // A relay for an unknown route cannot know how long the work takes. Jobs
388
+ // listing with a big limit against a busy ComfyUI easily exceeds 30s, and
389
+ // a timeout surfaces as the same opaque `fetch failed`; 120s is generous
390
+ // for anything a caller would poll synchronously.
391
+ signal: AbortSignal.timeout(120000),
392
+ });
393
+ // Only a 404 justifies the other spelling: every other status is the real
394
+ // answer to the route the caller asked for and must pass through untouched.
395
+ if (upstream.status !== 404 || target === candidates[candidates.length - 1]) break;
396
+ await upstream.body?.cancel().catch(() => undefined);
397
+ this.deps.log('info', `relay ${target} -> 404, retrying the other spelling`);
398
+ }
399
+ const buffer = Buffer.from(await upstream.arrayBuffer());
400
+ response.writeHead(upstream.status, {
401
+ 'content-type': upstream.headers.get('content-type') ?? 'application/octet-stream',
402
+ 'content-length': String(buffer.length),
403
+ });
404
+ response.end(buffer);
405
+ } catch (error) {
406
+ const reason = describeFetchError(error);
407
+ this.deps.log('warn', `relay ${lastTarget} failed: ${reason}`);
408
+ sendJson(response, 502, {
409
+ error: `上游 ${primary.id} (${lastTarget}) 请求失败:${reason}`,
410
+ upstream: primary.id,
411
+ target: lastTarget,
412
+ detail: errorMessage(error),
413
+ });
414
+ }
415
+ }
416
+
417
+ async cachedObjectInfo(runtime, suffix) {
418
+ const now = Date.now();
419
+ const cached = this.objectInfo.get(runtime.id);
420
+ let value;
421
+ if (cached !== undefined && now - cached.ts < 60000) {
422
+ value = cached.value;
423
+ } else {
424
+ value = await this.deps.pool.backend(runtime).objectInfo();
425
+ this.objectInfo.set(runtime.id, { ts: now, value });
426
+ }
427
+ if (suffix === '' || suffix === '/') return value;
428
+ const key = decodeURIComponent(suffix.slice(1));
429
+ return key in value ? { [key]: value[key] } : {};
430
+ }
431
+
432
+ /**
433
+ * One merged /history: our finished runs plus each instance's own history.
434
+ * Ordering is by execution start, newest first, because the frontend sorts
435
+ * by nothing and simply paints the object in insertion order.
436
+ */
437
+ async mergedHistory(max) {
438
+ const merged = new Map();
439
+ const limit = Math.min(max, this.deps.settings().historyItems ?? 120);
440
+ for (const task of this.deps.pool.tasks.values()) {
441
+ if (task.state !== 'done' && task.state !== 'failed') continue;
442
+ if (task.historyEntry !== undefined && task.historyEntry !== null) {
443
+ merged.set(task.id, decorate(task.historyEntry, task));
444
+ }
445
+ }
446
+ await Promise.all(
447
+ this.deps.instances.enabled().map(async (runtime) => {
448
+ try {
449
+ const history = await this.deps.pool.backend(runtime).history(limit);
450
+ for (const [id, entry] of Object.entries(history ?? {})) {
451
+ if (merged.has(id)) continue;
452
+ // A run this gateway submitted appears under its upstream id;
453
+ // its gateway id is what callers poll, so do not double-list it.
454
+ if ([...this.deps.pool.tasks.values()].some((task) => task.upstreamId === id)) continue;
455
+ merged.set(id, decorate(entry, undefined, runtime.id));
456
+ }
457
+ } catch {
458
+ // an offline instance contributes nothing
459
+ }
460
+ }),
461
+ );
462
+ const entries = [...merged.entries()]
463
+ .sort((left, right) => startTime(right[1]) - startTime(left[1]))
464
+ .slice(0, limit);
465
+ return Object.fromEntries(entries);
466
+ }
467
+
468
+ /**
469
+ * GET one JSON route from one instance, trying the caller's spelling and then the bare
470
+ * form on a 404. Needed because the job API lives under `/api` while the classic routes
471
+ * are mirrored — `Backend` only speaks the bare form.
472
+ */
473
+ async instanceJson(runtime, rawPath, timeoutMs = 30000) {
474
+ let lastError = null;
475
+ for (const target of upstreamCandidates(runtime.instance.baseUrl, rawPath)) {
476
+ try {
477
+ const headers = {};
478
+ if (typeof runtime.instance.apiKey === 'string' && runtime.instance.apiKey !== '') {
479
+ headers.authorization = `Bearer ${runtime.instance.apiKey}`;
480
+ }
481
+ const upstream = await fetch(target, { headers, signal: AbortSignal.timeout(timeoutMs) });
482
+ const text = await upstream.text();
483
+ if (upstream.status === 404) {
484
+ lastError = new Error(`${target} -> HTTP 404`);
485
+ continue;
486
+ }
487
+ if (!upstream.ok) throw new Error(`${target} -> HTTP ${upstream.status} ${text.slice(0, 160)}`);
488
+ return text === '' ? undefined : JSON.parse(text);
489
+ } catch (error) {
490
+ lastError = error;
491
+ }
492
+ }
493
+ throw lastError ?? new Error(`${runtime.id} ${rawPath}: no route answered`);
494
+ }
495
+
496
+ async mergedHistoryOne(id) {
497
+ const task = this.deps.pool.tasks.get(id);
498
+ if (task !== undefined && task.historyEntry !== undefined && task.historyEntry !== null) {
499
+ return decorate(task.historyEntry, task);
500
+ }
501
+ if (task !== undefined && task.upstreamId !== null) {
502
+ const runtime = task.backendId === null ? undefined : this.deps.instances.get(task.backendId);
503
+ if (runtime !== undefined) {
504
+ try {
505
+ const history = await this.deps.pool.backend(runtime).historyOf(task.upstreamId);
506
+ const entry = history?.[task.upstreamId];
507
+ if (entry !== undefined) return decorate(entry, task);
508
+ } catch {
509
+ // fall through to a direct search
510
+ }
511
+ }
512
+ }
513
+ for (const runtime of this.deps.instances.enabled()) {
514
+ try {
515
+ const history = await this.deps.pool.backend(runtime).historyOf(id);
516
+ if (history?.[id] !== undefined) return decorate(history[id], undefined, runtime.id);
517
+ } catch {
518
+ // try the next instance
519
+ }
520
+ }
521
+ return undefined;
522
+ }
523
+
524
+ /**
525
+ * One merged job list: our own tasks plus every instance's own rows.
526
+ *
527
+ * The caller is a ComfyUI frontend (or the dsh-comfyui panel), so the shape must stay
528
+ * ComfyUI's: `{jobs: [...], pagination: {offset, limit, total, has_more}}`. Paging is
529
+ * applied AFTER the merge — paging per instance would return `limit` rows from each and
530
+ * then truncate, which silently drops the newest rows of the second GPU.
531
+ */
532
+ async mergedJobs(url) {
533
+ const query = url.searchParams;
534
+ const statusParam = (query.get('status') ?? '').split(',').map((s) => s.trim()).filter((s) => s !== '');
535
+ // `all` is the gateway's dialect: a real ComfyUI rejects it with 400.
536
+ const statuses = statusParam.includes('all') || statusParam.includes('') ? [] : statusParam;
537
+ const limitRaw = Number(query.get('limit') ?? '');
538
+ const limit = Number.isFinite(limitRaw) && limitRaw > 0 ? Math.floor(limitRaw) : null;
539
+ const offsetRaw = Number(query.get('offset') ?? '');
540
+ const offset = Number.isFinite(offsetRaw) && offsetRaw > 0 ? Math.floor(offsetRaw) : 0;
541
+
542
+ const seen = new Set();
543
+ const jobs = [];
544
+
545
+ // Our own tasks first: they carry the gateway id callers already know.
546
+ for (const task of this.deps.pool.tasks.values()) {
547
+ if (task.state === 'queued' || task.state === 'running') {
548
+ const status = task.state === 'running' ? 'in_progress' : 'pending';
549
+ if (statuses.length > 0 && !statuses.includes(status)) continue;
550
+ jobs.push({
551
+ id: task.id,
552
+ status,
553
+ priority: task.priority,
554
+ create_time: task.enqueuedAt,
555
+ outputs_count: 0,
556
+ workflow_id: null,
557
+ });
558
+ seen.add(task.id);
559
+ if (task.upstreamId !== null) seen.add(task.upstreamId);
560
+ continue;
561
+ }
562
+ const status = task.state === 'done' ? 'completed' : task.state === 'cancelled' ? 'cancelled' : 'failed';
563
+ if (statuses.length > 0 && !statuses.includes(status)) continue;
564
+ const entry = task.historyEntry ?? null;
565
+ jobs.push({
566
+ id: task.id,
567
+ status,
568
+ priority: task.priority,
569
+ create_time: task.enqueuedAt,
570
+ execution_start_time: task.startedAt,
571
+ execution_end_time: task.finishedAt,
572
+ execution_error: status === 'failed' ? { exception_message: task.error ?? 'failed' } : null,
573
+ outputs_count: countOutputs(task.outputs),
574
+ workflow_id: null,
575
+ });
576
+ void entry;
577
+ seen.add(task.id);
578
+ if (task.upstreamId !== null) seen.add(task.upstreamId);
579
+ }
580
+
581
+ // Then whatever each instance reports, minus anything already represented above.
582
+ await Promise.all(
583
+ this.deps.instances.enabled().map(async (runtime) => {
584
+ try {
585
+ const params = new URLSearchParams();
586
+ if (statuses.length > 0) params.set('status', statuses.join(','));
587
+ params.set('sort_by', 'created_at');
588
+ params.set('sort_order', 'desc');
589
+ // No limit here on purpose: the merge pages once, at the end.
590
+ const reply = await this.instanceJson(runtime, `/api/jobs?${params.toString()}`, 120000);
591
+ for (const job of Array.isArray(reply?.jobs) ? reply.jobs : []) {
592
+ const id = typeof job?.id === 'string' ? job.id : '';
593
+ if (id === '' || seen.has(id)) continue;
594
+ seen.add(id);
595
+ jobs.push({ ...job, backendId: runtime.id });
596
+ }
597
+ } catch {
598
+ // an offline instance contributes nothing, same as mergedHistory
599
+ }
600
+ }),
601
+ );
602
+
603
+ jobs.sort((left, right) => Number(right.create_time ?? 0) - Number(left.create_time ?? 0));
604
+ const total = jobs.length;
605
+ const page = jobs.slice(offset, limit === null ? undefined : offset + limit);
606
+ return { jobs: page, pagination: { offset, limit, total, has_more: offset + page.length < total } };
607
+ }
608
+
609
+ /** One job by id across the pool and every instance (first hit wins). */
610
+ async mergedJobOne(id) {
611
+ const task = this.deps.pool.tasks.get(id);
612
+ if (task !== undefined) {
613
+ return {
614
+ id: task.id,
615
+ status: task.state === 'queued' ? 'pending' : task.state === 'running' ? 'in_progress' : task.state === 'done' ? 'completed' : task.state,
616
+ priority: task.priority,
617
+ create_time: task.enqueuedAt,
618
+ execution_start_time: task.startedAt,
619
+ execution_end_time: task.finishedAt,
620
+ execution_error: task.error === null ? null : { exception_message: task.error },
621
+ outputs_count: countOutputs(task.outputs),
622
+ workflow_id: null,
623
+ };
624
+ }
625
+ for (const runtime of this.deps.instances.enabled()) {
626
+ try {
627
+ return await this.instanceJson(runtime, `/api/jobs/${encodeURIComponent(id)}`);
628
+ } catch {
629
+ // 404 here is "not this instance"; keep looking
630
+ }
631
+ }
632
+ return { error: 'Job not found' };
633
+ }
634
+
635
+ /**
636
+ * Cancel passes through to whoever holds the job.
637
+ *
638
+ * A gateway task is cancelled in the pool (which also stops the right upstream prompt);
639
+ * anything else is an id that lives on exactly one instance, so the call is broadcast
640
+ * and the first non-error answer wins. Broadcasting a *cancel* is safe in a way
641
+ * broadcasting an interrupt is not: the instances ignore ids they do not know.
642
+ */
643
+ async proxyJobMutation(path, raw, request, response) {
644
+ let body = {};
645
+ try {
646
+ body = raw === undefined || raw.length === 0 ? {} : JSON.parse(raw.toString('utf8'));
647
+ } catch {
648
+ return sendJson(response, 400, { error: 'invalid JSON body' });
649
+ }
650
+ const single = /^\/jobs\/([^/]+)\/cancel$/.exec(path);
651
+ if (single !== null) {
652
+ const id = decodeURIComponent(single[1]);
653
+ if (this.deps.pool.tasks.has(id)) {
654
+ const result = this.deps.pool.cancel(id);
655
+ return sendJson(response, 200, { cancelled: result.ok === true, ...(result.ok === true ? {} : { error: result.error }) });
656
+ }
657
+ } else if (Array.isArray(body.job_ids)) {
658
+ const known = body.job_ids.filter((id) => typeof id === 'string' && this.deps.pool.tasks.has(id));
659
+ for (const id of known) this.deps.pool.cancel(id);
660
+ }
661
+ const headers = {};
662
+ if (request.headers.authorization) headers.authorization = request.headers.authorization;
663
+ if (request.headers['content-type']) headers['content-type'] = request.headers['content-type'];
664
+ let lastError = null;
665
+ for (const runtime of this.deps.instances.enabled()) {
666
+ for (const target of upstreamCandidates(runtime.instance.baseUrl, `${path}`)) {
667
+ try {
668
+ const upstream = await fetch(target, {
669
+ method: 'POST',
670
+ headers,
671
+ body: raw,
672
+ signal: AbortSignal.timeout(30000),
673
+ });
674
+ const buffer = Buffer.from(await upstream.arrayBuffer());
675
+ if (upstream.status === 404) {
676
+ lastError = `${runtime.id} ${target} -> 404`;
677
+ continue;
678
+ }
679
+ response.writeHead(upstream.status, {
680
+ 'content-type': upstream.headers.get('content-type') ?? 'application/json',
681
+ 'content-length': String(buffer.length),
682
+ });
683
+ response.end(buffer);
684
+ return undefined;
685
+ } catch (error) {
686
+ lastError = describeFetchError(error);
687
+ }
688
+ }
689
+ }
690
+ return sendJson(response, 200, { cancelled: false, error: lastError ?? 'no instance accepted the cancel' });
691
+ }
692
+
693
+ /** /system_stats from the primary instance, with every bound GPU listed. */
694
+ async mergedStats() {
695
+ const primary = await this.primary();
696
+ if (primary === undefined) {
697
+ return { system: { comfyui_version: 'gateway', os: process.platform, python_version: null }, devices: [] };
698
+ }
699
+ let base;
700
+ try {
701
+ base = await this.deps.pool.backend(primary).systemStats();
702
+ } catch (error) {
703
+ return { system: { comfyui_version: 'gateway', os: process.platform }, devices: [], error: errorMessage(error) };
704
+ }
705
+ const devices = [];
706
+ for (const runtime of this.deps.instances.enabled()) {
707
+ for (const device of runtime.health.devices ?? []) {
708
+ devices.push({ ...device, index: devices.length });
709
+ }
710
+ }
711
+ return {
712
+ ...base,
713
+ system: {
714
+ ...base.system,
715
+ gateway_port: this.port,
716
+ gateway_instances: this.deps.instances.enabled().map((runtime) => ({
717
+ id: runtime.id,
718
+ baseUrl: runtime.instance.baseUrl,
719
+ healthy: runtime.health.ok,
720
+ })),
721
+ },
722
+ devices: devices.length > 0 ? devices : base.devices ?? [],
723
+ };
724
+ }
725
+
726
+ /** Find which instance holds this file, then stream it through. */
727
+ async proxyView(url, request, response) {
728
+ const filename = url.searchParams.get('filename') ?? url.searchParams.get('file') ?? '';
729
+ const subfolder = url.searchParams.get('subfolder') ?? '';
730
+ const type = url.searchParams.get('type') ?? 'output';
731
+ if (filename === '') return sendJson(response, 400, { error: 'filename is required' });
732
+
733
+ const params = new URLSearchParams({ filename, subfolder, type });
734
+ const promptId = url.searchParams.get('prompt_id');
735
+ const ordered = [];
736
+ if (promptId !== null && promptId !== '') {
737
+ let task = this.deps.pool.tasks.get(promptId);
738
+ if (task === undefined) {
739
+ task = [...this.deps.pool.tasks.values()].find((candidate) => candidate.upstreamId === promptId);
740
+ }
741
+ const runtime = task?.backendId === null || task?.backendId === undefined ? undefined : this.deps.instances.get(task.backendId);
742
+ if (runtime !== undefined) ordered.push(runtime);
743
+ }
744
+ for (const runtime of this.deps.instances.enabled()) {
745
+ if (!ordered.includes(runtime)) ordered.push(runtime);
746
+ }
747
+ const method = request.method === 'HEAD' ? 'HEAD' : 'GET';
748
+ let lastError = 'no instance holds that file';
749
+ for (const runtime of ordered) {
750
+ try {
751
+ const stream = await this.deps.pool.backend(runtime).openView(params.toString(), method, request.headers.range);
752
+ const head = {
753
+ 'content-type': stream.headers.get('content-type') ?? 'application/octet-stream',
754
+ 'accept-ranges': 'bytes',
755
+ 'cache-control': 'private, max-age=3600',
756
+ 'access-control-allow-origin': '*',
757
+ };
758
+ for (const name of ['content-length', 'content-range']) {
759
+ const value = stream.headers.get(name);
760
+ if (value !== null) head[name] = value;
761
+ }
762
+ response.writeHead(stream.status, head);
763
+ if (method === 'HEAD' || stream.body === null) {
764
+ response.end();
765
+ return undefined;
766
+ }
767
+ // The body carries a 60 s AbortSignal; a bare `.pipe()` here would let
768
+ // a client that walked away turn that timeout into an uncaught exception
769
+ // and take the whole Host down. See pipeGuarded().
770
+ pipeGuarded(stream.body, response);
771
+ return undefined;
772
+ } catch (error) {
773
+ lastError = errorMessage(error);
774
+ }
775
+ }
776
+ return sendJson(response, 404, { error: lastError });
777
+ }
778
+
779
+ statusPayload() {
780
+ const snapshot = this.deps.pool.snapshot();
781
+ return {
782
+ ok: true,
783
+ listening: this.listening,
784
+ host: this.host,
785
+ port: this.port,
786
+ lastError: this.lastError,
787
+ wsClients: this.ws.size,
788
+ ...snapshot,
789
+ instances: this.deps.instances.list().map((runtime) => ({
790
+ id: runtime.id,
791
+ label: runtime.instance.label,
792
+ baseUrl: runtime.instance.baseUrl,
793
+ enabled: runtime.instance.enabled,
794
+ healthy: runtime.health.ok,
795
+ version: runtime.health.version,
796
+ latencyMs: runtime.health.latencyMs,
797
+ devices: runtime.health.devices,
798
+ lastError: runtime.lastError ?? runtime.health.error,
799
+ speed: Number((runtime.speed ?? 1).toFixed(2)),
800
+ speedSamples: runtime.speedSamples,
801
+ maxConcurrent: runtime.instance.maxConcurrent,
802
+ weight: runtime.instance.weight,
803
+ control: runtime.instance.control.type,
804
+ /**
805
+ * The full control block, so the panel can edit batPath/cwd.
806
+ *
807
+ * `control` above is the *type string* — the panel must never spread it
808
+ * into an object (that is what made the 托管方式 dropdown snap back to
809
+ * 不托管: `{...'spawn'}` yields `{}` with no type). Read this one instead.
810
+ */
811
+ controlDetail: runtime.instance.control,
812
+ pid: runtime.pid,
813
+ starting: runtime.starting,
814
+ stopping: runtime.stopping,
815
+ autoStart: runtime.instance.autoStart,
816
+ })),
817
+ };
818
+ }
819
+
820
+ /** Push the events the official frontend listens for. */
821
+ async pushStatus() {
822
+ if (this.ws.size === 0) return;
823
+ this.ws.broadcast('status', {
824
+ status: { exec_info: { queue_remaining: this.deps.pool.ordered().length } },
825
+ sid: 'gateway',
826
+ });
827
+ for (const task of this.deps.pool.tasks.values()) {
828
+ if (task.state === 'running' && task.upstreamId !== null) {
829
+ this.ws.broadcast('executing', { node: null, prompt_id: task.id });
830
+ if (task.progress?.position === 'running') {
831
+ this.ws.broadcast('execution_start', { prompt_id: task.id });
832
+ }
833
+ }
834
+ if (task.state === 'done' && task.finishedAt !== null && Date.now() - task.finishedAt < 4000 && !task.announced) {
835
+ task.announced = true;
836
+ this.ws.broadcast('executing', { node: null, prompt_id: task.id });
837
+ this.ws.broadcast('execution_success', { prompt_id: task.id, timestamp: task.finishedAt });
838
+ this.ws.broadcast('executed', {
839
+ node: null,
840
+ display_node: null,
841
+ output: task.outputs ?? {},
842
+ prompt_id: task.id,
843
+ });
844
+ }
845
+ if (task.state === 'failed' && task.finishedAt !== null && Date.now() - task.finishedAt < 4000 && !task.announcedFailure) {
846
+ task.announcedFailure = true;
847
+ this.ws.broadcast('execution_error', {
848
+ prompt_id: task.id,
849
+ node_id: null,
850
+ node_type: null,
851
+ executed: [],
852
+ exception_message: task.error ?? 'failed',
853
+ exception_type: 'gateway',
854
+ traceback: '',
855
+ current_inputs: {},
856
+ current_outputs: {},
857
+ });
858
+ }
859
+ }
860
+ }
861
+ }
862
+
863
+ function decorate(entry, task, backendId) {
864
+ if (entry === null || typeof entry !== 'object') return entry;
865
+ const gateway = task === undefined ? undefined : { id: task.id, backendId: task.backendId, cost: task.cost };
866
+ return {
867
+ ...entry,
868
+ meta: {
869
+ ...(entry.meta ?? {}),
870
+ ...(gateway === undefined ? {} : { gateway }),
871
+ ...(backendId === undefined ? {} : { backendId }),
872
+ },
873
+ };
874
+ }
875
+
876
+ function startTime(entry) {
877
+ const messages = entry?.status?.messages ?? [];
878
+ for (const message of messages) {
879
+ if (Array.isArray(message) && message[0] === 'execution_start') return Number(message[1]?.timestamp ?? 0);
880
+ }
881
+ return 0;
882
+ }
883
+
884
+ async function readRaw(request) {
885
+ const chunks = [];
886
+ for await (const chunk of request) chunks.push(chunk);
887
+ return chunks.length === 0 ? undefined : Buffer.concat(chunks);
888
+ }
889
+
890
+ /**
891
+ * Ordered upstream URLs for a relayed request.
892
+ *
893
+ * The gateway accepts both spellings (`/api/queue` and `/queue`), and the old relay
894
+ * assumed ComfyUI did the same, so it stripped `/api` on the way out. That assumption is
895
+ * wrong for the job API: `server.py` declares `/api/jobs`, `/api/jobs/{job_id}`,
896
+ * `/api/jobs/{job_id}/cancel` and `/api/jobs/cancel` *literally with the prefix*, and the
897
+ * "mirror every route under /api" pass only ever ADDS a prefix — nothing registers a bare
898
+ * `/jobs`. A real 0.31.1 server answers `GET /api/jobs` with 200 and `GET /jobs` with 404.
899
+ * Stripping the prefix therefore converted a working endpoint into "HTTP 404" for every
900
+ * caller, which is exactly how the dsh-comfyui task centre ended up showing nothing but
901
+ * `ComfyUI /api/jobs?... failed: HTTP 404`.
902
+ *
903
+ * So the prefix is no longer stripped blind: the request goes out as the caller spelled it
904
+ * first, and only a 404 falls back to the bare form. That fallback is what keeps an
905
+ * `/api`-only reverse proxy working (such a proxy forwards just `/api/*` and rejects the
906
+ * bare path). The query string is preserved throughout — `/jobs?status=…` is meaningless
907
+ * without it.
908
+ */
909
+ export function upstreamCandidates(baseUrl, rawUrl) {
910
+ // Loopback written as a name can resolve to ::1 while ComfyUI listens on 127.0.0.1.
911
+ // Pinning the literal address removes a whole class of "fetch failed" that says
912
+ // nothing about the actual fault.
913
+ const base = String(baseUrl ?? '')
914
+ .replace(/\/+$/, '')
915
+ .replace(/\/\/(localhost|\[::1\])(?=[:/]|$)/i, '//127.0.0.1');
916
+ const raw = String(rawUrl ?? '/');
917
+ const queryAt = raw.indexOf('?');
918
+ const rawPath = queryAt < 0 ? raw : raw.slice(0, queryAt);
919
+ const query = queryAt < 0 ? '' : raw.slice(queryAt);
920
+ const path = rawPath === '' ? '/' : rawPath;
921
+
922
+ const urls = [`${base}${path}${query}`];
923
+ let bare = path;
924
+ if (path.startsWith('/api/')) bare = path.slice(4);
925
+ else if (path === '/api') bare = '/';
926
+ if (bare !== path) urls.push(`${base}${bare}${query}`);
927
+ return urls;
928
+ }
929
+
930
+ /** The preferred upstream URL for a relayed request (first candidate). */
931
+ export function upstreamUrl(baseUrl, rawUrl) {
932
+ return upstreamCandidates(baseUrl, rawUrl)[0];
933
+ }
934
+
935
+ /**
936
+ * Turn an undici/Node fetch failure into something an operator can act on.
937
+ *
938
+ * `fetch failed` alone is the string that started this: it is what Node prints for DNS
939
+ * failure, connection refused, TLS refusal and a dozen other causes, all wrapped in a
940
+ * `cause`. Unwrapping the cause is the difference between "it is down" and "it timed out".
941
+ */
942
+ export function describeFetchError(error) {
943
+ if (error === null || error === undefined) return '未知错误';
944
+ const name = String(error.name ?? '');
945
+ if (name === 'TimeoutError' || name === 'AbortError') return '超时(上游没有在限定时间内回应)';
946
+ const cause = error.cause ?? {};
947
+ const code = String(cause.code ?? '');
948
+ /**
949
+ * `bad port` is not a network failure at all: Node's fetch refuses a list of reserved
950
+ * ports (1, 9, 25, 110, …) before opening a socket. Reporting it as a connection problem
951
+ * sends the operator to look at the wrong machine, so it gets its own sentence.
952
+ */
953
+ if (/bad port/i.test(String(cause.message ?? ''))) {
954
+ return '端口被 Node 拒绝(1/9/25 这类保留端口不允许 fetch,请换成真实的服务端口)';
955
+ }
956
+ const map = {
957
+ ECONNREFUSED: '连接被拒绝(该端口没有服务在监听)',
958
+ ENOTFOUND: '域名解析失败',
959
+ EAI_AGAIN: '域名解析暂时失败',
960
+ ECONNRESET: '连接被重置',
961
+ EHOSTUNREACH: '主机不可达',
962
+ ENETUNREACH: '网络不可达',
963
+ ETIMEDOUT: '连接超时',
964
+ UND_ERR_CONNECT_TIMEOUT: '连接超时',
965
+ CERT_HAS_EXPIRED: '证书已过期',
966
+ DEPTH_ZERO_SELF_SIGNED_CERT: '自签名证书被拒绝',
967
+ };
968
+ if (code !== '' && map[code] !== undefined) return `${map[code]}(${code})`;
969
+ const message = String(cause.message ?? error.message ?? '');
970
+ if (code !== '') return `${message || '请求失败'}(${code})`;
971
+ return message || '请求失败';
972
+ }
973
+
974
+ export { COMFY_ID };