@tscomfy/client 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +76 -2
- package/dist/channel.d.ts +54 -0
- package/dist/channel.d.ts.map +1 -0
- package/dist/channel.js +307 -0
- package/dist/channel.js.map +1 -0
- package/dist/client-error.d.ts +15 -0
- package/dist/client-error.d.ts.map +1 -0
- package/dist/client-error.js +16 -0
- package/dist/client-error.js.map +1 -0
- package/dist/connection.d.ts +33 -0
- package/dist/connection.d.ts.map +1 -0
- package/dist/connection.js +35 -0
- package/dist/connection.js.map +1 -0
- package/dist/event-queue.d.ts +16 -0
- package/dist/event-queue.d.ts.map +1 -0
- package/dist/event-queue.js +66 -0
- package/dist/event-queue.js.map +1 -0
- package/dist/events.d.ts +60 -0
- package/dist/events.d.ts.map +1 -0
- package/dist/events.js +2 -0
- package/dist/events.js.map +1 -0
- package/dist/files.d.ts +55 -0
- package/dist/files.d.ts.map +1 -0
- package/dist/files.js +69 -0
- package/dist/files.js.map +1 -0
- package/dist/frames.d.ts +24 -0
- package/dist/frames.d.ts.map +1 -0
- package/dist/frames.js +71 -0
- package/dist/frames.js.map +1 -0
- package/dist/http.d.ts +28 -0
- package/dist/http.d.ts.map +1 -0
- package/dist/http.js +96 -0
- package/dist/http.js.map +1 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +14 -0
- package/dist/index.js.map +1 -0
- package/dist/job.d.ts +59 -0
- package/dist/job.d.ts.map +1 -0
- package/dist/job.js +481 -0
- package/dist/job.js.map +1 -0
- package/dist/outcome.d.ts +47 -0
- package/dist/outcome.d.ts.map +1 -0
- package/dist/outcome.js +2 -0
- package/dist/outcome.js.map +1 -0
- package/dist/outputs.d.ts +53 -0
- package/dist/outputs.d.ts.map +1 -0
- package/dist/outputs.js +21 -0
- package/dist/outputs.js.map +1 -0
- package/dist/read-outputs.d.ts +11 -0
- package/dist/read-outputs.d.ts.map +1 -0
- package/dist/read-outputs.js +142 -0
- package/dist/read-outputs.js.map +1 -0
- package/dist/server-errors.d.ts +7 -0
- package/dist/server-errors.d.ts.map +1 -0
- package/dist/server-errors.js +46 -0
- package/dist/server-errors.js.map +1 -0
- package/dist/server.d.ts +30 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +2 -0
- package/dist/server.js.map +1 -0
- package/dist/timers.d.ts +11 -0
- package/dist/timers.d.ts.map +1 -0
- package/dist/timers.js +38 -0
- package/dist/timers.js.map +1 -0
- package/dist/url.d.ts +14 -0
- package/dist/url.d.ts.map +1 -0
- package/dist/url.js +39 -0
- package/dist/url.js.map +1 -0
- package/dist/uuid.d.ts +7 -0
- package/dist/uuid.d.ts.map +1 -0
- package/dist/uuid.js +13 -0
- package/dist/uuid.js.map +1 -0
- package/package.json +46 -4
- package/src/channel.ts +334 -0
- package/src/client-error.ts +19 -0
- package/src/connection.ts +59 -0
- package/src/event-queue.ts +70 -0
- package/src/events.ts +76 -0
- package/src/files.ts +119 -0
- package/src/frames.ts +87 -0
- package/src/http.ts +113 -0
- package/src/index.ts +53 -0
- package/src/job.ts +562 -0
- package/src/outcome.ts +52 -0
- package/src/outputs.ts +69 -0
- package/src/read-outputs.ts +171 -0
- package/src/server-errors.ts +61 -0
- package/src/server.ts +29 -0
- package/src/timers.ts +35 -0
- package/src/url.ts +41 -0
- package/src/uuid.ts +12 -0
package/src/channel.ts
ADDED
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
import { type JsonObject, parse, stringify } from "@tscomfy/core";
|
|
2
|
+
import { ClientError } from "./client-error.ts";
|
|
3
|
+
import type { Connection } from "./connection.ts";
|
|
4
|
+
import { decodeFrame, type PreviewFrame, type TextFrame } from "./frames.ts";
|
|
5
|
+
import { isObject, readObject, send } from "./http.ts";
|
|
6
|
+
import type { Server } from "./server.ts";
|
|
7
|
+
import { abortable, sleep } from "./timers.ts";
|
|
8
|
+
import { baseUrl, endpoint, shownUrl } from "./url.ts";
|
|
9
|
+
|
|
10
|
+
/** Connections and the channels behind them. */
|
|
11
|
+
const channels = new WeakMap<Connection, Channel>();
|
|
12
|
+
|
|
13
|
+
/** Ties a connection to its channel. */
|
|
14
|
+
export function attach(connection: Connection, channel: Channel): void {
|
|
15
|
+
channels.set(connection, channel);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** The channel behind a connection. Throws a `TypeError` for anything `connect` didn't return. */
|
|
19
|
+
export function channelOf(connection: Connection): Channel {
|
|
20
|
+
const channel = channels.get(connection);
|
|
21
|
+
if (channel === undefined) {
|
|
22
|
+
throw new TypeError("Expected a connection returned by connect()");
|
|
23
|
+
}
|
|
24
|
+
return channel;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** What a job hears from the channel. */
|
|
28
|
+
export interface JobListener {
|
|
29
|
+
readonly id: string;
|
|
30
|
+
/** A JSON event carrying this job's prompt id. */
|
|
31
|
+
message(type: string, data: JsonObject): void;
|
|
32
|
+
/** A preview or progress text of this job. */
|
|
33
|
+
frame(frame: PreviewFrame | TextFrame): void;
|
|
34
|
+
/** The server's queue changed (a `status` broadcast). */
|
|
35
|
+
queueChanged(): void;
|
|
36
|
+
/** The websocket reopened after a drop: events may have been lost. */
|
|
37
|
+
reconnected(): void;
|
|
38
|
+
/** The channel is gone for good: closed, or the server stayed unreachable. */
|
|
39
|
+
fail(error: ClientError): void;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** How long the server gets to answer the feature flags. */
|
|
43
|
+
const HANDSHAKE_MS = 10_000;
|
|
44
|
+
|
|
45
|
+
/** Delays between reconnect attempts, before jitter: about two minutes in all. */
|
|
46
|
+
const BACKOFF_MS = [250, 500, 1000, 2000, 4000, 8000, 15_000, 30_000, 30_000, 30_000];
|
|
47
|
+
|
|
48
|
+
/** The lowest server version the client supports: the per-job cancel came in 0.26.0. */
|
|
49
|
+
const MIN_VERSION = [0, 26, 0] as const;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The flags the client announces as its first message: previews and progress text that say
|
|
53
|
+
* which prompt they belong to, as the frontend announces them.
|
|
54
|
+
*/
|
|
55
|
+
const CLIENT_FLAGS = { supports_preview_metadata: true, supports_progress_text_metadata: true };
|
|
56
|
+
|
|
57
|
+
/** A websocket's `readyState` once closed; the global `WebSocket` may not exist. */
|
|
58
|
+
const CLOSED = 3;
|
|
59
|
+
|
|
60
|
+
const closedError = (): ClientError => new ClientError("The connection is closed");
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* One websocket and the jobs listening on it. Opens one socket at a time, shared by every
|
|
64
|
+
* caller; reconnects only while jobs are open, after the old socket has closed (two live sockets
|
|
65
|
+
* with one client id lose events on the server), and re-sends the feature flags first. `close()`
|
|
66
|
+
* stops everything it started: sockets, handshakes, backoff waits.
|
|
67
|
+
*/
|
|
68
|
+
export class Channel {
|
|
69
|
+
readonly server: Server;
|
|
70
|
+
readonly clientId: string;
|
|
71
|
+
features: JsonObject = {};
|
|
72
|
+
readonly #jobs = new Map<string, JobListener>();
|
|
73
|
+
/** Every socket this channel opened and that hasn't closed yet. */
|
|
74
|
+
readonly #sockets = new Set<WebSocket>();
|
|
75
|
+
/** Aborted by `close()`: ends handshakes and backoff waits. */
|
|
76
|
+
readonly #life = new AbortController();
|
|
77
|
+
#socket: WebSocket | undefined;
|
|
78
|
+
#opening: Promise<void> | undefined;
|
|
79
|
+
#reconnecting: Promise<void> | undefined;
|
|
80
|
+
#closing: Promise<void> | undefined;
|
|
81
|
+
/** The job whose prompt the server is running, for progress text without a prompt id. */
|
|
82
|
+
#running: JobListener | undefined;
|
|
83
|
+
|
|
84
|
+
constructor(server: Server, clientId: string) {
|
|
85
|
+
this.server = server;
|
|
86
|
+
this.clientId = clientId;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Whether `close()` was called. */
|
|
90
|
+
get closed(): boolean {
|
|
91
|
+
return this.#life.signal.aborted;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Waits until the websocket is open, opening it (once, for every caller) if it isn't. Rejects
|
|
96
|
+
* with the signal's reason when `signal` aborts, and with a {@link ClientError} when the
|
|
97
|
+
* channel is closed or the socket can't be opened.
|
|
98
|
+
*/
|
|
99
|
+
async ready(signal?: AbortSignal): Promise<void> {
|
|
100
|
+
for (;;) {
|
|
101
|
+
if (this.closed) throw closedError();
|
|
102
|
+
if (this.#reconnecting !== undefined) {
|
|
103
|
+
// oxlint-disable-next-line no-await-in-loop -- the state changes while it waits
|
|
104
|
+
await abortable(this.#reconnecting, signal);
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (this.#socket !== undefined) return;
|
|
108
|
+
this.#opening ??= this.#open().finally(() => {
|
|
109
|
+
this.#opening = undefined;
|
|
110
|
+
});
|
|
111
|
+
// oxlint-disable-next-line no-await-in-loop -- the state changes while it waits
|
|
112
|
+
await abortable(this.#opening, signal);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Checks that the server is one the client supports: ComfyUI 0.26 or later by its version
|
|
118
|
+
* (when it reports one it can parse), and with the job API. Rejects with a {@link ClientError}.
|
|
119
|
+
*/
|
|
120
|
+
async checkServer(signal?: AbortSignal): Promise<void> {
|
|
121
|
+
const stats = await send(this.server, { method: "GET", path: "system_stats", signal });
|
|
122
|
+
if (stats.ok) {
|
|
123
|
+
const body = await readObject(stats).catch(() => undefined);
|
|
124
|
+
const system = body?.["system"];
|
|
125
|
+
const version = isObject(system) ? system["comfyui_version"] : undefined;
|
|
126
|
+
if (typeof version === "string" && isOlder(version, MIN_VERSION)) {
|
|
127
|
+
throw new ClientError(
|
|
128
|
+
`The server runs ComfyUI ${version}; @tscomfy/client needs ComfyUI ${MIN_VERSION.join(".")} or later`,
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
} else {
|
|
132
|
+
await stats.body?.cancel();
|
|
133
|
+
}
|
|
134
|
+
const jobs = await send(this.server, {
|
|
135
|
+
method: "GET",
|
|
136
|
+
path: "api/jobs",
|
|
137
|
+
query: { limit: "1" },
|
|
138
|
+
signal,
|
|
139
|
+
});
|
|
140
|
+
await jobs.body?.cancel();
|
|
141
|
+
if (!jobs.ok) {
|
|
142
|
+
throw new ClientError(
|
|
143
|
+
`The server has no job API (GET /api/jobs answered ${String(jobs.status)}); @tscomfy/client needs ComfyUI ${MIN_VERSION.join(".")} or later`,
|
|
144
|
+
{ status: jobs.status },
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
register(job: JobListener): void {
|
|
150
|
+
if (this.closed) throw closedError();
|
|
151
|
+
this.#jobs.set(job.id, job);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
unregister(job: JobListener): void {
|
|
155
|
+
if (this.#jobs.get(job.id) === job) this.#jobs.delete(job.id);
|
|
156
|
+
if (this.#running === job) this.#running = undefined;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Closes every socket and fails every open job; waits until the sockets have closed. */
|
|
160
|
+
close(): Promise<void> {
|
|
161
|
+
this.#closing ??= this.#close();
|
|
162
|
+
return this.#closing;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async #close(): Promise<void> {
|
|
166
|
+
this.#life.abort(closedError());
|
|
167
|
+
this.#socket = undefined;
|
|
168
|
+
const jobs = [...this.#jobs.values()];
|
|
169
|
+
this.#jobs.clear();
|
|
170
|
+
for (const job of jobs) job.fail(new ClientError("The connection was closed"));
|
|
171
|
+
await Promise.all([...this.#sockets].map((socket) => closeSocket(socket)));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Opens a socket and negotiates the feature flags; the socket becomes the channel's. */
|
|
175
|
+
async #open(): Promise<void> {
|
|
176
|
+
const url = endpoint(baseUrl(this.server), "ws", { clientId: this.clientId });
|
|
177
|
+
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
|
178
|
+
const shown = shownUrl(url);
|
|
179
|
+
const life = this.#life.signal;
|
|
180
|
+
const socket = this.server.webSocket?.(url.href) ?? new WebSocket(url.href);
|
|
181
|
+
socket.binaryType = "arraybuffer";
|
|
182
|
+
this.#sockets.add(socket);
|
|
183
|
+
socket.addEventListener("close", () => {
|
|
184
|
+
this.#sockets.delete(socket);
|
|
185
|
+
this.#dropped(socket);
|
|
186
|
+
});
|
|
187
|
+
await new Promise<void>((resolve, reject) => {
|
|
188
|
+
const timer = setTimeout(() => {
|
|
189
|
+
fail(new ClientError(`${shown} didn't answer the websocket handshake in time`));
|
|
190
|
+
}, HANDSHAKE_MS);
|
|
191
|
+
const done = (): void => {
|
|
192
|
+
clearTimeout(timer);
|
|
193
|
+
life.removeEventListener("abort", aborted);
|
|
194
|
+
socket.removeEventListener("close", closed);
|
|
195
|
+
};
|
|
196
|
+
const fail = (error: unknown): void => {
|
|
197
|
+
done();
|
|
198
|
+
socket.close();
|
|
199
|
+
reject(error);
|
|
200
|
+
};
|
|
201
|
+
const aborted = (): void => {
|
|
202
|
+
fail(closedError());
|
|
203
|
+
};
|
|
204
|
+
const closed = (): void => {
|
|
205
|
+
fail(new ClientError(`Could not open a websocket to ${shown}`));
|
|
206
|
+
};
|
|
207
|
+
if (life.aborted) {
|
|
208
|
+
aborted();
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
life.addEventListener("abort", aborted, { once: true });
|
|
212
|
+
socket.addEventListener("close", closed);
|
|
213
|
+
socket.addEventListener("open", () => {
|
|
214
|
+
socket.send(stringify({ type: "feature_flags", data: CLIENT_FLAGS }));
|
|
215
|
+
});
|
|
216
|
+
let negotiated = false;
|
|
217
|
+
socket.addEventListener("message", (event: MessageEvent) => {
|
|
218
|
+
if (!negotiated && typeof event.data === "string") {
|
|
219
|
+
const message = readMessage(event.data);
|
|
220
|
+
if (message?.type === "feature_flags") {
|
|
221
|
+
negotiated = true;
|
|
222
|
+
done();
|
|
223
|
+
this.features = message.data;
|
|
224
|
+
this.#socket = socket;
|
|
225
|
+
resolve();
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
this.#receive(event.data);
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
#receive(data: unknown): void {
|
|
235
|
+
if (typeof data === "string") {
|
|
236
|
+
const message = readMessage(data);
|
|
237
|
+
if (message !== undefined) this.#dispatch(message.type, message.data);
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
if (!(data instanceof ArrayBuffer)) return;
|
|
241
|
+
const frame = decodeFrame(data, this.features["supports_progress_text_metadata"] === true);
|
|
242
|
+
if (frame === undefined) return;
|
|
243
|
+
const job = frame.promptId === undefined ? this.#running : this.#jobs.get(frame.promptId);
|
|
244
|
+
job?.frame(frame);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
#dispatch(type: string, data: JsonObject): void {
|
|
248
|
+
if (type === "status") {
|
|
249
|
+
for (const job of this.#jobs.values()) job.queueChanged();
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
const promptId = data["prompt_id"];
|
|
253
|
+
if (typeof promptId !== "string") return;
|
|
254
|
+
const job = this.#jobs.get(promptId);
|
|
255
|
+
if (job === undefined) {
|
|
256
|
+
// Another client's prompt, or one of ours that settled: it isn't running ours.
|
|
257
|
+
if (type === "execution_start") this.#running = undefined;
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
if (type === "execution_start") this.#running = job;
|
|
261
|
+
if (type === "executing" && data["node"] === null && this.#running === job) {
|
|
262
|
+
this.#running = undefined;
|
|
263
|
+
}
|
|
264
|
+
job.message(type, data);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
#dropped(socket: WebSocket): void {
|
|
268
|
+
if (this.#socket !== socket) return;
|
|
269
|
+
this.#socket = undefined;
|
|
270
|
+
this.#running = undefined;
|
|
271
|
+
// With no job open, the next submit reopens it.
|
|
272
|
+
if (this.closed || this.#jobs.size === 0) return;
|
|
273
|
+
this.#reconnecting = this.#reconnect().finally(() => {
|
|
274
|
+
this.#reconnecting = undefined;
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async #reconnect(): Promise<void> {
|
|
279
|
+
const life = this.#life.signal;
|
|
280
|
+
for (const delay of BACKOFF_MS) {
|
|
281
|
+
// oxlint-disable-next-line no-await-in-loop -- attempts are sequential, with growing waits
|
|
282
|
+
await sleep(delay * (0.5 + Math.random() / 2), life);
|
|
283
|
+
if (life.aborted) return;
|
|
284
|
+
try {
|
|
285
|
+
// oxlint-disable-next-line no-await-in-loop -- attempts are sequential, with growing waits
|
|
286
|
+
await this.#open();
|
|
287
|
+
for (const job of this.#jobs.values()) job.reconnected();
|
|
288
|
+
return;
|
|
289
|
+
} catch {
|
|
290
|
+
// The next attempt, after a longer wait; none after close().
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
const error = new ClientError(
|
|
294
|
+
`Lost the websocket to ${shownUrl(baseUrl(this.server))} and couldn't reopen it`,
|
|
295
|
+
);
|
|
296
|
+
const jobs = [...this.#jobs.values()];
|
|
297
|
+
this.#jobs.clear();
|
|
298
|
+
for (const job of jobs) job.fail(error);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Closes a socket and waits for its close, a second at most. */
|
|
303
|
+
function closeSocket(socket: WebSocket): Promise<void> {
|
|
304
|
+
if (socket.readyState === CLOSED) return Promise.resolve();
|
|
305
|
+
return new Promise((resolve) => {
|
|
306
|
+
const timer = setTimeout(resolve, 1000);
|
|
307
|
+
socket.addEventListener("close", () => {
|
|
308
|
+
clearTimeout(timer);
|
|
309
|
+
resolve();
|
|
310
|
+
});
|
|
311
|
+
socket.close();
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** Whether a `major.minor.patch` version (anything after it ignored) is below `minimum`. */
|
|
316
|
+
function isOlder(version: string, minimum: readonly [number, number, number]): boolean {
|
|
317
|
+
const match = /^v?(\d+)\.(\d+)\.(\d+)/.exec(version);
|
|
318
|
+
if (match === null) return false;
|
|
319
|
+
const parts = [Number(match[1]), Number(match[2]), Number(match[3])];
|
|
320
|
+
for (const [index, part] of parts.entries()) {
|
|
321
|
+
const least = minimum[index] ?? 0;
|
|
322
|
+
if (part !== least) return part < least;
|
|
323
|
+
}
|
|
324
|
+
return false;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** A JSON message `{ type, data }`, read losslessly; anything else is ignored. */
|
|
328
|
+
function readMessage(
|
|
329
|
+
text: string,
|
|
330
|
+
): { readonly type: string; readonly data: JsonObject } | undefined {
|
|
331
|
+
const value = parse(text).value;
|
|
332
|
+
if (!isObject(value) || typeof value["type"] !== "string") return undefined;
|
|
333
|
+
return { type: value["type"], data: isObject(value["data"]) ? value["data"] : {} };
|
|
334
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The server couldn't be reached, or answered in a way the protocol doesn't allow. Thrown by the
|
|
3
|
+
* HTTP functions and used to reject a job's outcome; what the server decides about a job (a
|
|
4
|
+
* rejection, a failure) is data instead, in the {@link Outcome}. The underlying error, if any, is
|
|
5
|
+
* the `cause`.
|
|
6
|
+
*/
|
|
7
|
+
export class ClientError extends Error {
|
|
8
|
+
/** The HTTP status of the answer that caused it, when there was one. */
|
|
9
|
+
readonly status: number | undefined;
|
|
10
|
+
|
|
11
|
+
constructor(
|
|
12
|
+
message: string,
|
|
13
|
+
options: { readonly cause?: unknown; readonly status?: number } = {},
|
|
14
|
+
) {
|
|
15
|
+
super(message, { cause: options.cause });
|
|
16
|
+
this.name = "ClientError";
|
|
17
|
+
this.status = options.status;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { JsonObject } from "@tscomfy/core";
|
|
2
|
+
import { attach, Channel } from "./channel.ts";
|
|
3
|
+
import type { Server } from "./server.ts";
|
|
4
|
+
import { baseUrl } from "./url.ts";
|
|
5
|
+
import { randomUuid } from "./uuid.ts";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* An open connection to a server: a {@link Server} with a websocket that delivers the events of
|
|
9
|
+
* the jobs submitted through it. Several jobs share it. Close it when done: an open websocket
|
|
10
|
+
* keeps a Node or Bun process running.
|
|
11
|
+
*/
|
|
12
|
+
export interface Connection extends Server {
|
|
13
|
+
/** The id the server sends this connection's events to; fresh for every connection. */
|
|
14
|
+
readonly clientId: string;
|
|
15
|
+
/** The server's feature flags, as it answered them when the connection opened. */
|
|
16
|
+
readonly features: JsonObject;
|
|
17
|
+
/**
|
|
18
|
+
* Closes the websocket. Jobs still open reject their outcome with a {@link ClientError}; they
|
|
19
|
+
* keep running on the server.
|
|
20
|
+
*/
|
|
21
|
+
close(): Promise<void>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Options for {@link connect}. */
|
|
25
|
+
export interface ConnectOptions {
|
|
26
|
+
/** Aborts opening the connection; rejects with the signal's reason. */
|
|
27
|
+
readonly signal?: AbortSignal;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Opens a connection: a websocket with a fresh client id, which announces
|
|
32
|
+
* `supports_preview_metadata` (so previews say which job and node they belong to) and waits for
|
|
33
|
+
* the server's feature flags. Also checks that the server is ComfyUI 0.26 or later with the job
|
|
34
|
+
* API. Rejects with a {@link ClientError} when the server can't be reached or is too old, and
|
|
35
|
+
* with the signal's reason when `signal` aborts; either way nothing is left open. Throws a
|
|
36
|
+
* `TypeError` for an invalid `server.url`.
|
|
37
|
+
*/
|
|
38
|
+
export async function connect(server: Server, options: ConnectOptions = {}): Promise<Connection> {
|
|
39
|
+
baseUrl(server);
|
|
40
|
+
const channel = new Channel(server, randomUuid());
|
|
41
|
+
try {
|
|
42
|
+
await channel.ready(options.signal);
|
|
43
|
+
await channel.checkServer(options.signal);
|
|
44
|
+
} catch (error) {
|
|
45
|
+
await channel.close();
|
|
46
|
+
throw error;
|
|
47
|
+
}
|
|
48
|
+
const connection: Connection = Object.freeze({
|
|
49
|
+
url: server.url,
|
|
50
|
+
...(server.headers === undefined ? {} : { headers: server.headers }),
|
|
51
|
+
...(server.fetch === undefined ? {} : { fetch: server.fetch }),
|
|
52
|
+
...(server.webSocket === undefined ? {} : { webSocket: server.webSocket }),
|
|
53
|
+
clientId: channel.clientId,
|
|
54
|
+
features: channel.features,
|
|
55
|
+
close: () => channel.close(),
|
|
56
|
+
});
|
|
57
|
+
attach(connection, channel);
|
|
58
|
+
return connection;
|
|
59
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { JobEvent } from "./events.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Events of these types are snapshots: a newer one of a node replaces its unread one. Progress
|
|
5
|
+
* text isn't: each says something different ("Downloading…", "Generating…").
|
|
6
|
+
*/
|
|
7
|
+
const COALESCED: ReadonlySet<JobEvent["type"]> = new Set(["progress", "preview"]);
|
|
8
|
+
|
|
9
|
+
type Waiter = (result: IteratorResult<JobEvent, undefined>) => void;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A job's events, for one consumer. Buffered from the start, so none is missed before the
|
|
13
|
+
* consumer starts reading; while it lags, a newer progress or preview of a node replaces the
|
|
14
|
+
* unread one where it stands, so the buffer stays bounded by the number of nodes and keeps its
|
|
15
|
+
* order.
|
|
16
|
+
*/
|
|
17
|
+
export class EventQueue implements AsyncIterable<JobEvent> {
|
|
18
|
+
readonly #buffer: JobEvent[] = [];
|
|
19
|
+
/** `next()` calls waiting for an event, in the order they were made. */
|
|
20
|
+
readonly #waiting: Waiter[] = [];
|
|
21
|
+
#ended = false;
|
|
22
|
+
#taken = false;
|
|
23
|
+
|
|
24
|
+
/** Adds an event, unless the queue has ended or its consumer stopped. */
|
|
25
|
+
push(event: JobEvent): void {
|
|
26
|
+
if (this.#ended) return;
|
|
27
|
+
const waiting = this.#waiting.shift();
|
|
28
|
+
if (waiting !== undefined) {
|
|
29
|
+
waiting({ done: false, value: event });
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
if (COALESCED.has(event.type) && "node" in event) {
|
|
33
|
+
const stale = this.#buffer.findIndex(
|
|
34
|
+
(queued) => queued.type === event.type && "node" in queued && queued.node === event.node,
|
|
35
|
+
);
|
|
36
|
+
if (stale !== -1) {
|
|
37
|
+
this.#buffer[stale] = event;
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
this.#buffer.push(event);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Ends the queue: the consumer reads what is buffered, then stops. */
|
|
45
|
+
end(): void {
|
|
46
|
+
this.#ended = true;
|
|
47
|
+
for (const waiting of this.#waiting.splice(0)) waiting({ done: true, value: undefined });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
[Symbol.asyncIterator](): AsyncIterator<JobEvent, undefined> {
|
|
51
|
+
if (this.#taken) throw new TypeError("job.events can be iterated only once");
|
|
52
|
+
this.#taken = true;
|
|
53
|
+
return {
|
|
54
|
+
next: () => {
|
|
55
|
+
const event = this.#buffer.shift();
|
|
56
|
+
if (event !== undefined) return Promise.resolve({ done: false, value: event });
|
|
57
|
+
if (this.#ended) return Promise.resolve({ done: true, value: undefined });
|
|
58
|
+
return new Promise((resolve) => {
|
|
59
|
+
this.#waiting.push(resolve);
|
|
60
|
+
});
|
|
61
|
+
},
|
|
62
|
+
return: () => {
|
|
63
|
+
// The consumer stopped (`break`): drop the buffer and what comes later.
|
|
64
|
+
this.#buffer.length = 0;
|
|
65
|
+
this.end();
|
|
66
|
+
return Promise.resolve({ done: true, value: undefined });
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
}
|
package/src/events.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { NodeId } from "@tscomfy/core";
|
|
2
|
+
import type { NodeOutput } from "./outputs.ts";
|
|
3
|
+
|
|
4
|
+
/** The server started running the job. */
|
|
5
|
+
export interface StartedEvent {
|
|
6
|
+
readonly type: "started";
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Nodes whose cached results the run reuses instead of running them. */
|
|
10
|
+
export interface CachedEvent {
|
|
11
|
+
readonly type: "cached";
|
|
12
|
+
readonly nodes: readonly NodeId[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** A node started running. */
|
|
16
|
+
export interface ExecutingEvent {
|
|
17
|
+
readonly type: "executing";
|
|
18
|
+
readonly node: NodeId;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** A running node's progress: `value` of `max` (sampler steps, say). */
|
|
22
|
+
export interface ProgressEvent {
|
|
23
|
+
readonly type: "progress";
|
|
24
|
+
readonly node: NodeId;
|
|
25
|
+
readonly value: number;
|
|
26
|
+
readonly max: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* A live preview from a running node: a sampler's latent preview (a JPEG at most 512 pixels on a
|
|
31
|
+
* side, sent when the job asks for previews with `previews` in {@link SubmitOptions}, or the
|
|
32
|
+
* server was started with `--preview-method`), or a `SaveImageWebsocket` image (a full-size PNG,
|
|
33
|
+
* always sent).
|
|
34
|
+
*/
|
|
35
|
+
export interface PreviewEvent {
|
|
36
|
+
readonly type: "preview";
|
|
37
|
+
readonly node: NodeId;
|
|
38
|
+
/** `image/jpeg` or `image/png`. */
|
|
39
|
+
readonly mime: string;
|
|
40
|
+
readonly bytes: Uint8Array<ArrayBuffer>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** A node finished and reported output (files, text). */
|
|
44
|
+
export interface OutputEvent {
|
|
45
|
+
readonly type: "output";
|
|
46
|
+
readonly node: NodeId;
|
|
47
|
+
readonly output: NodeOutput;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Progress text from a running node (partner nodes report their status this way). */
|
|
51
|
+
export interface TextEvent {
|
|
52
|
+
readonly type: "text";
|
|
53
|
+
readonly node: NodeId;
|
|
54
|
+
readonly text: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* A node blocked execution with a message. The run continues without what depends on it; the
|
|
59
|
+
* outcome also lists it, as a `W_NODE_BLOCKED` diagnostic.
|
|
60
|
+
*/
|
|
61
|
+
export interface BlockedEvent {
|
|
62
|
+
readonly type: "blocked";
|
|
63
|
+
readonly node: NodeId;
|
|
64
|
+
readonly message: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** What a job reports while it runs. Node ids are the prompt's (the builder's) ids. */
|
|
68
|
+
export type JobEvent =
|
|
69
|
+
| StartedEvent
|
|
70
|
+
| CachedEvent
|
|
71
|
+
| ExecutingEvent
|
|
72
|
+
| ProgressEvent
|
|
73
|
+
| PreviewEvent
|
|
74
|
+
| OutputEvent
|
|
75
|
+
| TextEvent
|
|
76
|
+
| BlockedEvent;
|
package/src/files.ts
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { ClientError } from "./client-error.ts";
|
|
2
|
+
import { readObject, send, unexpected } from "./http.ts";
|
|
3
|
+
import type { FileRef, FolderType } from "./outputs.ts";
|
|
4
|
+
import { isFolderType } from "./read-outputs.ts";
|
|
5
|
+
import type { Server } from "./server.ts";
|
|
6
|
+
import { baseUrl, endpoint } from "./url.ts";
|
|
7
|
+
|
|
8
|
+
/** A file to upload. */
|
|
9
|
+
export interface UploadSource {
|
|
10
|
+
/** The file name to store it under. */
|
|
11
|
+
readonly name: string;
|
|
12
|
+
readonly bytes: Uint8Array;
|
|
13
|
+
/** Its media type, such as `image/png`. */
|
|
14
|
+
readonly mime?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Options for {@link uploadFile}. */
|
|
18
|
+
export interface UploadOptions {
|
|
19
|
+
/** A folder under the target directory. */
|
|
20
|
+
readonly subfolder?: string;
|
|
21
|
+
/** The directory: `input` (default), `temp` or `output`. */
|
|
22
|
+
readonly type?: FolderType;
|
|
23
|
+
/** Replace a file of the same name. Otherwise the server keeps both, renaming the new one. */
|
|
24
|
+
readonly overwrite?: boolean;
|
|
25
|
+
readonly signal?: AbortSignal;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** An uploaded file, as the server stored it. */
|
|
29
|
+
export interface UploadedFile extends FileRef {
|
|
30
|
+
/**
|
|
31
|
+
* The value a loader node's file input takes (`LoadImage`'s `image`): `subfolder/name`, with
|
|
32
|
+
* ` [temp]` or ` [output]` after it outside the input directory.
|
|
33
|
+
*/
|
|
34
|
+
readonly value: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Uploads a file (an image, video or audio for a loader node) through `/upload/image`. The
|
|
39
|
+
* stored name can differ from `source.name`: without `overwrite`, the server renames a file whose
|
|
40
|
+
* name is taken by different content. Rejects with a {@link ClientError} if the server refuses.
|
|
41
|
+
*/
|
|
42
|
+
export async function uploadFile(
|
|
43
|
+
server: Server,
|
|
44
|
+
source: UploadSource,
|
|
45
|
+
options: UploadOptions = {},
|
|
46
|
+
): Promise<UploadedFile> {
|
|
47
|
+
const form = new FormData();
|
|
48
|
+
const blob = new Blob([new Uint8Array(source.bytes)], { type: source.mime ?? "" });
|
|
49
|
+
form.append("image", blob, source.name);
|
|
50
|
+
form.append("type", options.type ?? "input");
|
|
51
|
+
if (options.subfolder !== undefined) form.append("subfolder", options.subfolder);
|
|
52
|
+
if (options.overwrite === true) form.append("overwrite", "true");
|
|
53
|
+
const response = await send(server, {
|
|
54
|
+
method: "POST",
|
|
55
|
+
path: "upload/image",
|
|
56
|
+
body: form,
|
|
57
|
+
signal: options.signal,
|
|
58
|
+
});
|
|
59
|
+
if (!response.ok) throw await unexpected(response);
|
|
60
|
+
const answer = await readObject(response);
|
|
61
|
+
const { name, subfolder, type } = answer;
|
|
62
|
+
if (typeof name !== "string" || !isFolderType(type)) {
|
|
63
|
+
throw new ClientError("The server's answer to an upload has no file name or folder");
|
|
64
|
+
}
|
|
65
|
+
const folder = typeof subfolder === "string" ? subfolder : "";
|
|
66
|
+
const path = folder === "" ? name : `${folder}/${name}`;
|
|
67
|
+
return Object.freeze({
|
|
68
|
+
filename: name,
|
|
69
|
+
subfolder: folder,
|
|
70
|
+
type,
|
|
71
|
+
value: type === "input" ? path : `${path} [${type}]`,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** A downloaded file. */
|
|
76
|
+
export interface FileData {
|
|
77
|
+
readonly bytes: Uint8Array<ArrayBuffer>;
|
|
78
|
+
/** The server's `Content-Type`; `application/octet-stream` when it sent none. */
|
|
79
|
+
readonly mime: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Options for {@link fetchFile}. */
|
|
83
|
+
export interface FetchFileOptions {
|
|
84
|
+
readonly signal?: AbortSignal;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Downloads a file the server has, such as an output, through `/view`. Rejects with a
|
|
89
|
+
* {@link ClientError} if the server doesn't have it.
|
|
90
|
+
*/
|
|
91
|
+
export async function fetchFile(
|
|
92
|
+
server: Server,
|
|
93
|
+
file: FileRef,
|
|
94
|
+
options: FetchFileOptions = {},
|
|
95
|
+
): Promise<FileData> {
|
|
96
|
+
const response = await send(server, {
|
|
97
|
+
method: "GET",
|
|
98
|
+
path: "view",
|
|
99
|
+
query: viewQuery(file),
|
|
100
|
+
signal: options.signal,
|
|
101
|
+
});
|
|
102
|
+
if (!response.ok) throw await unexpected(response);
|
|
103
|
+
return Object.freeze({
|
|
104
|
+
bytes: new Uint8Array(await response.arrayBuffer()),
|
|
105
|
+
mime: response.headers.get("content-type") ?? "application/octet-stream",
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The URL `/view` serves a file at, for `<img src>` on a page the server allows (same origin, or
|
|
111
|
+
* `--enable-cors-header`). It carries the server URL's query, so a token in it is in the result.
|
|
112
|
+
*/
|
|
113
|
+
export function fileUrl(server: Server, file: FileRef): string {
|
|
114
|
+
return endpoint(baseUrl(server), "view", viewQuery(file)).href;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function viewQuery(file: FileRef): Record<string, string> {
|
|
118
|
+
return { filename: file.filename, subfolder: file.subfolder, type: file.type };
|
|
119
|
+
}
|