dsh-multimodal-node 0.0.0-stage → 0.2.2

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/http.js ADDED
@@ -0,0 +1,119 @@
1
+ /** Tiny HTTP helpers shared by the gateway listener and the panel routes. */
2
+ export function sendJson(response, status, value) {
3
+ const body = JSON.stringify(value ?? {});
4
+ response.writeHead(status, {
5
+ 'content-type': 'application/json; charset=utf-8',
6
+ 'content-length': String(Buffer.byteLength(body)),
7
+ 'cache-control': 'no-store',
8
+ });
9
+ response.end(body);
10
+ }
11
+
12
+ export function errorMessage(error) {
13
+ if (error instanceof Error) return error.message;
14
+ return String(error);
15
+ }
16
+
17
+ /**
18
+ * Pipe a stream into an HTTP response without letting its late `error` kill the
19
+ * host process.
20
+ *
21
+ * `Readable.fromWeb(response.body)` is a live view of a `fetch()` that carries
22
+ * `AbortSignal.timeout(...)`. When that timer fires after the client has already
23
+ * gone (browser cancelled a thumbnail, navigated away on save, closed the tab),
24
+ * the readable emits `'error'` — and a bare `source.pipe(response)` has **no**
25
+ * `'error'` listener on the source, so Node turns it into an `uncaughtException`.
26
+ * The desktop host installs fail-loud handlers, so one abandoned `/media` request
27
+ * makes the whole DSH exit ~60 s later with "dsh desktop host exited with 1".
28
+ *
29
+ * Guarding both directions keeps a dead client and a stalled upstream local to the
30
+ * request: destroy the source when the response is gone, destroy the response when
31
+ * the source fails.
32
+ */
33
+ export function pipeGuarded(source, response) {
34
+ let settled = false;
35
+ const stop = () => {
36
+ if (settled) return;
37
+ settled = true;
38
+ source.destroy?.();
39
+ };
40
+ const fail = () => {
41
+ if (settled) return;
42
+ settled = true;
43
+ source.destroy?.();
44
+ if (!response.writableEnded && !response.destroyed) response.destroy();
45
+ };
46
+ source.on?.('error', fail);
47
+ response.on('error', stop);
48
+ response.on('close', () => {
49
+ if (!response.writableEnded) stop();
50
+ });
51
+ source.pipe(response);
52
+ }
53
+
54
+ /** Read a JSON body with a hard size cap; throws on malformed JSON. */
55
+ export async function readJson(request, limitBytes = 32 * 1024 * 1024) {
56
+ const chunks = [];
57
+ let total = 0;
58
+ for await (const chunk of request) {
59
+ total += chunk.length;
60
+ if (total > limitBytes) throw new Error(`请求体超过 ${Math.round(limitBytes / 1048576)} MB`);
61
+ chunks.push(chunk);
62
+ }
63
+ if (chunks.length === 0) return {};
64
+ const text = Buffer.concat(chunks).toString('utf8');
65
+ return JSON.parse(text);
66
+ }
67
+
68
+ /** POST guard for the panel routes: same origin only. */
69
+ export function sameOrigin(request) {
70
+ const origin = request.headers.origin;
71
+ if (typeof origin !== 'string' || origin === '') return true;
72
+ const host = request.headers.host;
73
+ if (typeof host !== 'string') return false;
74
+ try {
75
+ return new URL(origin).host === host;
76
+ } catch {
77
+ return false;
78
+ }
79
+ }
80
+
81
+ /**
82
+ * 关掉一个 HTTP server,并**保证端口立刻释放**。
83
+ *
84
+ * `server.close()` 只是「不再接受新连接」,它要等所有现有连接(包括 keep-alive
85
+ * 空闲连接)自然结束才回调。浏览器保持长连接、手机锁屏挂着的页面、甚至一次
86
+ * 刚结束的 fetch 都会留下这种连接——于是 close 的回调迟迟不来,端口一直占着,
87
+ * 紧接着的重新 listen 就撞 EADDRINUSE。
88
+ *
89
+ * 实测症状(本机):插件重载后日志只有一行 `address already in use`,
90
+ * 而「是不是刚有人在用网页」完全看不出来。
91
+ *
92
+ * `closeAllConnections()` 是 Node 18.2+ 提供的:强制断开所有连接,
93
+ * 配合 close 就把端口真正还回去了。老版本没有就退回 closeIdleConnections。
94
+ */
95
+ export function closeServer(server) {
96
+ if (server === null || server === undefined) return Promise.resolve();
97
+ return new Promise((resolve) => {
98
+ let settled = false;
99
+ const done = () => {
100
+ if (settled) return;
101
+ settled = true;
102
+ resolve();
103
+ };
104
+ // 兜底:close 的回调在某些平台上可能因为残留 socket 迟到,不能无限等。
105
+ const timer = setTimeout(done, 2000);
106
+ if (typeof timer.unref === 'function') timer.unref();
107
+ try {
108
+ server.close(() => {
109
+ clearTimeout(timer);
110
+ done();
111
+ });
112
+ if (typeof server.closeAllConnections === 'function') server.closeAllConnections();
113
+ else if (typeof server.closeIdleConnections === 'function') server.closeIdleConnections();
114
+ } catch {
115
+ clearTimeout(timer);
116
+ done();
117
+ }
118
+ });
119
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Pixel dimensions from the first bytes of a file.
3
+ *
4
+ * The console shows each asset's size, and ComfyUI's history does not carry it:
5
+ * `outputs` names the file, nothing more. Reading the header is cheap (a few
6
+ * hundred bytes), works for every format this plugin can produce, and avoids
7
+ * decoding the image on either side.
8
+ */
9
+ import { Readable } from 'node:stream';
10
+
11
+ /** Bytes needed to describe any format we support. */
12
+ export const HEADER_BYTES = 65536;
13
+
14
+ /**
15
+ * @param {Buffer} buffer at least the first ~32 bytes of the file
16
+ * @returns {{ width: number, height: number } | null}
17
+ */
18
+ export function imageSize(buffer) {
19
+ if (buffer === null || buffer === undefined || buffer.length < 8) return null;
20
+
21
+ // PNG: 8-byte signature, then an IHDR chunk whose width/height are big-endian.
22
+ if (buffer.length >= 24 && buffer.readUInt32BE(0) === 0x89504e47 && buffer.readUInt32BE(4) === 0x0d0a1a0a) {
23
+ return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) };
24
+ }
25
+
26
+ // GIF: "GIF87a" / "GIF89a", then little-endian width/height.
27
+ const head6 = buffer.subarray(0, 6).toString('latin1');
28
+ if ((head6 === 'GIF87a' || head6 === 'GIF89a') && buffer.length >= 10) {
29
+ return { width: buffer.readUInt16LE(6), height: buffer.readUInt16LE(8) };
30
+ }
31
+
32
+ // WebP: RIFF....WEBP, then a VP8/VP8L/VP8X chunk.
33
+ if (buffer.length >= 30 && buffer.subarray(0, 4).toString('latin1') === 'RIFF' && buffer.subarray(8, 12).toString('latin1') === 'WEBP') {
34
+ const kind = buffer.subarray(12, 16).toString('latin1');
35
+ if (kind === 'VP8 ') {
36
+ return { width: buffer.readUInt16LE(26) & 0x3fff, height: buffer.readUInt16LE(28) & 0x3fff };
37
+ }
38
+ if (kind === 'VP8L') {
39
+ const bits = buffer.readUInt32LE(21);
40
+ return { width: (bits & 0x3fff) + 1, height: ((bits >> 14) & 0x3fff) + 1 };
41
+ }
42
+ if (kind === 'VP8X') {
43
+ return { width: buffer.readUIntLE(24, 3) + 1, height: buffer.readUIntLE(27, 3) + 1 };
44
+ }
45
+ return null;
46
+ }
47
+
48
+ // JPEG: walk the segment chain to the first SOF marker.
49
+ if (buffer[0] === 0xff && buffer[1] === 0xd8) {
50
+ let offset = 2;
51
+ while (offset + 9 < buffer.length) {
52
+ if (buffer[offset] !== 0xff) {
53
+ offset += 1;
54
+ continue;
55
+ }
56
+ const marker = buffer[offset + 1];
57
+ // SOF0..SOF15, excluding DHT (C4), JPG (C8) and DAC (CC).
58
+ if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
59
+ return { width: buffer.readUInt16BE(offset + 7), height: buffer.readUInt16BE(offset + 5) };
60
+ }
61
+ const length = buffer.readUInt16BE(offset + 2);
62
+ if (length < 2) return null;
63
+ offset += 2 + length;
64
+ }
65
+ return null;
66
+ }
67
+
68
+ return null;
69
+ }
70
+
71
+ /**
72
+ * Read just the header of a file through one instance's `/view` and report its
73
+ * pixel size. Failure is `null`, never a throw: a missing size must not stop a
74
+ * run from being recorded.
75
+ */
76
+ export async function probeSize(backend, query) {
77
+ try {
78
+ const stream = await backend.openView(query, 'GET', `bytes=0-${HEADER_BYTES - 1}`);
79
+ const chunks = [];
80
+ let total = 0;
81
+ // The body is a Readable; take what we need and drop the rest so the
82
+ // connection is not held open by a response nobody reads to the end.
83
+ for await (const chunk of stream.body ?? Readable.from([])) {
84
+ chunks.push(chunk);
85
+ total += chunk.length;
86
+ if (total >= HEADER_BYTES) break;
87
+ }
88
+ return imageSize(Buffer.concat(chunks));
89
+ } catch {
90
+ return null;
91
+ }
92
+ }