@comfyorg/sdk 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +236 -0
  2. package/dist/index.d.ts +27 -0
  3. package/dist/index.js +27 -0
  4. package/dist/low/errors.d.ts +73 -0
  5. package/dist/low/errors.js +104 -0
  6. package/dist/low/generated/index.d.ts +1 -0
  7. package/dist/low/generated/index.js +2 -0
  8. package/dist/low/generated/types.gen.d.ts +528 -0
  9. package/dist/low/generated/types.gen.js +2 -0
  10. package/dist/low/generated/zod.gen.d.ts +455 -0
  11. package/dist/low/generated/zod.gen.js +204 -0
  12. package/dist/low/index.d.ts +21 -0
  13. package/dist/low/index.js +21 -0
  14. package/dist/low/models.d.ts +47 -0
  15. package/dist/low/models.js +16 -0
  16. package/dist/low/sse.d.ts +24 -0
  17. package/dist/low/sse.js +44 -0
  18. package/dist/low/transport.d.ts +128 -0
  19. package/dist/low/transport.js +285 -0
  20. package/dist/sdk/abortable-sleep.d.ts +13 -0
  21. package/dist/sdk/abortable-sleep.js +31 -0
  22. package/dist/sdk/assets.d.ts +74 -0
  23. package/dist/sdk/assets.js +200 -0
  24. package/dist/sdk/client.d.ts +70 -0
  25. package/dist/sdk/client.js +120 -0
  26. package/dist/sdk/core.d.ts +33 -0
  27. package/dist/sdk/core.js +89 -0
  28. package/dist/sdk/events.d.ts +60 -0
  29. package/dist/sdk/events.js +84 -0
  30. package/dist/sdk/exceptions.d.ts +79 -0
  31. package/dist/sdk/exceptions.js +114 -0
  32. package/dist/sdk/hashing.d.ts +14 -0
  33. package/dist/sdk/hashing.js +28 -0
  34. package/dist/sdk/index.d.ts +13 -0
  35. package/dist/sdk/index.js +12 -0
  36. package/dist/sdk/jobs.d.ts +47 -0
  37. package/dist/sdk/jobs.js +145 -0
  38. package/dist/sdk/outputs.d.ts +26 -0
  39. package/dist/sdk/outputs.js +48 -0
  40. package/dist/sdk/workflows.d.ts +32 -0
  41. package/dist/sdk/workflows.js +45 -0
  42. package/package.json +52 -0
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Hand-maintained supplement to the generated models.
3
+ *
4
+ * `@hey-api/openapi-ts` only emits types reachable from an operation's
5
+ * request/response schema. Four schemas in `spec/openapi.yaml` are
6
+ * documented but never referenced that way — the SSE event payloads
7
+ * (`StatusEvent`, `PreviewEvent`, `LogEvent`; `progress` and `output`
8
+ * events reuse the generated `Progress`/`Output` types directly) are only
9
+ * reachable via the non-standard `x-sse-events` vendor extension, and
10
+ * `AssetReference` (the `core/ASSET` object) documents a shape substituted
11
+ * into workflow JSON, not a request/response body. Kept here by hand
12
+ * instead of forcing codegen to walk vendor extensions it doesn't
13
+ * understand. `tests/spec-coverage.test.ts` asserts these stay in sync with
14
+ * the spec's property lists.
15
+ */
16
+ import type { JobStatus } from "./generated/types.gen.js";
17
+ /** SSE `status` event payload. */
18
+ export interface StatusEvent {
19
+ status: JobStatus;
20
+ queue_position?: number | null;
21
+ }
22
+ /** SSE `preview` event payload (JPEG, base64, throttled). */
23
+ export interface PreviewEvent {
24
+ node_id: string;
25
+ content_type: string;
26
+ data_base64: string;
27
+ }
28
+ /** SSE `log` event payload. Best-effort diagnostics. */
29
+ export interface LogEvent {
30
+ level: string;
31
+ message: string;
32
+ }
33
+ /**
34
+ * The typed asset-reference object placed inside workflow JSON where a
35
+ * filename would normally go: `{"__type": "core/ASSET", "info": {"id":
36
+ * "asset_...", "hash": "blake3:...", "file_path": "photo.png"}}`. `info.id`
37
+ * is required and authoritative; `hash`/`file_path` are optional
38
+ * staging/lookup hints.
39
+ */
40
+ export interface AssetReference {
41
+ __type: "core/ASSET";
42
+ info: {
43
+ id: string;
44
+ hash?: string;
45
+ file_path?: string;
46
+ };
47
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Hand-maintained supplement to the generated models.
3
+ *
4
+ * `@hey-api/openapi-ts` only emits types reachable from an operation's
5
+ * request/response schema. Four schemas in `spec/openapi.yaml` are
6
+ * documented but never referenced that way — the SSE event payloads
7
+ * (`StatusEvent`, `PreviewEvent`, `LogEvent`; `progress` and `output`
8
+ * events reuse the generated `Progress`/`Output` types directly) are only
9
+ * reachable via the non-standard `x-sse-events` vendor extension, and
10
+ * `AssetReference` (the `core/ASSET` object) documents a shape substituted
11
+ * into workflow JSON, not a request/response body. Kept here by hand
12
+ * instead of forcing codegen to walk vendor extensions it doesn't
13
+ * understand. `tests/spec-coverage.test.ts` asserts these stay in sync with
14
+ * the spec's property lists.
15
+ */
16
+ export {};
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Server-Sent-Events decoding over a `fetch` response body.
3
+ *
4
+ * Deliberately built on `eventsource-parser` over the raw fetch stream, not
5
+ * the `EventSource` global: the global can't send headers (no
6
+ * `Authorization`/`Accept`) and can't be aborted per-request, both of which
7
+ * this transport needs. Mirrors the wire-format contract of `comfy_low.sse`
8
+ * in the Python SDK (a `RawEvent` per frame, `event` defaults to `message`),
9
+ * but the parsing itself is delegated to `eventsource-parser` rather than
10
+ * hand-rolled, since JS has no sans-IO decoder to share between a sync and
11
+ * an async transport the way Python's dual sync/async layers did.
12
+ */
13
+ export interface RawEvent {
14
+ event: string;
15
+ data: Record<string, unknown>;
16
+ }
17
+ /**
18
+ * Decode a fetch response body into a stream of {@link RawEvent}s.
19
+ *
20
+ * No cursor/replay support by design — the contract carries no `id`/
21
+ * `Last-Event-ID`; a blank `data:` frame (no payload) is dropped, matching
22
+ * the Python decoder.
23
+ */
24
+ export declare function iterateSse(body: ReadableStream<Uint8Array>): AsyncGenerator<RawEvent, void, void>;
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Server-Sent-Events decoding over a `fetch` response body.
3
+ *
4
+ * Deliberately built on `eventsource-parser` over the raw fetch stream, not
5
+ * the `EventSource` global: the global can't send headers (no
6
+ * `Authorization`/`Accept`) and can't be aborted per-request, both of which
7
+ * this transport needs. Mirrors the wire-format contract of `comfy_low.sse`
8
+ * in the Python SDK (a `RawEvent` per frame, `event` defaults to `message`),
9
+ * but the parsing itself is delegated to `eventsource-parser` rather than
10
+ * hand-rolled, since JS has no sans-IO decoder to share between a sync and
11
+ * an async transport the way Python's dual sync/async layers did.
12
+ */
13
+ import { EventSourceParserStream } from "eventsource-parser/stream";
14
+ function parseData(raw) {
15
+ let parsed;
16
+ try {
17
+ parsed = JSON.parse(raw);
18
+ }
19
+ catch {
20
+ return { raw };
21
+ }
22
+ if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) {
23
+ return parsed;
24
+ }
25
+ return { value: parsed };
26
+ }
27
+ /**
28
+ * Decode a fetch response body into a stream of {@link RawEvent}s.
29
+ *
30
+ * No cursor/replay support by design — the contract carries no `id`/
31
+ * `Last-Event-ID`; a blank `data:` frame (no payload) is dropped, matching
32
+ * the Python decoder.
33
+ */
34
+ export async function* iterateSse(body) {
35
+ const stream = body
36
+ .pipeThrough(new TextDecoderStream())
37
+ .pipeThrough(new EventSourceParserStream());
38
+ for await (const message of stream) {
39
+ if (message.data === "") {
40
+ continue;
41
+ }
42
+ yield { event: message.event || "message", data: parseData(message.data) };
43
+ }
44
+ }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Thin `low` transport over `fetch` — one async function per `operationId`
3
+ * in `spec/openapi.yaml`, plus the mandatory escape hatches the hand-written
4
+ * `sdk` layer builds on:
5
+ *
6
+ * - **raw response access** — every method's Promise resolves from (or, via
7
+ * {@link ComfyLow.request}, directly returns) a `Response` whose body is
8
+ * never pre-read.
9
+ * - **unbuffered / streaming bodies** — a `fetch` `Response.body` is always a
10
+ * lazy `ReadableStream`; nothing here buffers it before the caller asks
11
+ * for `.json()`/`.body`. This is why, unlike the Python transport (which
12
+ * distinguishes a buffering `raw_request` from a streaming `open`), there
13
+ * is exactly one request primitive here — fetch's laziness already gives
14
+ * both hatches from a single call.
15
+ * - **streaming request bodies** — `postAssets` takes a `Blob` (a Node
16
+ * `fs.openAsBlob()` handle is a lazy, disk-backed `Blob`, so a multi-GB
17
+ * upload is never buffered whole in memory) inside a native `FormData`;
18
+ * `fetch`/undici streams the encoded body to the wire.
19
+ * - **per-request timeout/abort** — every method accepts `signal` and
20
+ * `timeoutMs`; `AbortSignal.any` composes a caller's signal with this
21
+ * client's default timeout (or overrides/disables it per call).
22
+ *
23
+ * This layer contains no orchestration, retries, hashing, or SSE
24
+ * reconnection — those live in `../sdk`. Mirrors `comfy_low.transport` in
25
+ * the Python SDK (its `AsyncComfyLow`; there is no sync variant here — see
26
+ * the package README for why).
27
+ */
28
+ import type { Asset, Job } from "./generated/types.gen.js";
29
+ import { type RawEvent } from "./sse.js";
30
+ export interface RequestOptions {
31
+ headers?: Record<string, string>;
32
+ json?: unknown;
33
+ body?: string | FormData;
34
+ signal?: AbortSignal;
35
+ /**
36
+ * Per-request timeout. `undefined` uses the client's default; `null`
37
+ * disables the default timeout entirely (used for the SSE stream, which
38
+ * must not time out while idle mid-job).
39
+ */
40
+ timeoutMs?: number | null;
41
+ }
42
+ export interface ComfyLowOptions {
43
+ fetch?: typeof fetch;
44
+ timeoutMs?: number;
45
+ }
46
+ /** Synchronous protocol bindings — async throughout (JS is async-native). */
47
+ export declare class ComfyLow {
48
+ private readonly baseUrl;
49
+ private readonly apiKey?;
50
+ private readonly fetchImpl;
51
+ private readonly defaultTimeoutMs;
52
+ constructor(baseUrl: string, apiKey?: string, options?: ComfyLowOptions);
53
+ private urlFor;
54
+ /**
55
+ * Is `url` on the same origin (scheme + host + port) as this client's
56
+ * `baseUrl`? A relative `path` always resolves to a `baseUrl`-derived URL
57
+ * (see {@link urlFor}), so this only ever says "no" for a server-returned
58
+ * absolute URL (`model.urls.self`/`cancel`/`events`) that points somewhere
59
+ * else — which is exactly the case where the bearer token must not be
60
+ * attached.
61
+ */
62
+ private isSameOrigin;
63
+ private buildHeaders;
64
+ private resolveSignal;
65
+ /**
66
+ * The single escape hatch: an unread `Response` (raw headers/status,
67
+ * lazy body) for a request built from `{json | body}`. Every typed
68
+ * method below is a thin wrapper over this.
69
+ */
70
+ request(method: string, path: string, options?: RequestOptions): Promise<Response>;
71
+ private parseOrRaise;
72
+ /** `POST /api/v2/assets` — streaming multipart upload. */
73
+ postAssets(file: Blob, contentType: string, filePath: string, options?: {
74
+ expectedHash?: string;
75
+ tags?: readonly string[];
76
+ idempotencyKey?: string;
77
+ signal?: AbortSignal;
78
+ timeoutMs?: number | null;
79
+ }): Promise<Asset>;
80
+ /** `POST /api/v2/assets/from-hash` — dedup mint over existing bytes. */
81
+ assetFromHash(hash: string, options?: {
82
+ filePath?: string;
83
+ tags?: readonly string[];
84
+ signal?: AbortSignal;
85
+ }): Promise<Asset>;
86
+ /** `HEAD /api/v2/assets/by-hash/{hash}` — existence probe. */
87
+ headAssetByHash(hash: string, options?: {
88
+ signal?: AbortSignal;
89
+ }): Promise<boolean>;
90
+ /** `GET /api/v2/assets/{id}` — metadata with a fresh content URL. */
91
+ getAsset(assetId: string, options?: {
92
+ signal?: AbortSignal;
93
+ }): Promise<Asset>;
94
+ /**
95
+ * `GET /api/v2/assets/{id}/content` — raw, streamed, range-aware body.
96
+ * Returns the response itself (escape hatch); the caller reads
97
+ * `response.body`.
98
+ */
99
+ getAssetContent(assetId: string, options?: {
100
+ range?: readonly [number, number];
101
+ signal?: AbortSignal;
102
+ }): Promise<Response>;
103
+ /** `POST /api/v2/jobs`. */
104
+ postJobs(workflow: Record<string, unknown>, options?: {
105
+ idempotencyKey?: string;
106
+ signal?: AbortSignal;
107
+ }): Promise<Job>;
108
+ /** `GET /api/v2/jobs/{id}` (or an absolute self link). */
109
+ getJob(jobIdOrUrl: string, options?: {
110
+ signal?: AbortSignal;
111
+ }): Promise<Job>;
112
+ /**
113
+ * `GET /api/v2/jobs/{id}/events` — raw live SSE iterator (escape hatch).
114
+ * No reconnection here; a single connection's frames. `../sdk` adds the
115
+ * reconnect loop. No default timeout: an idle stream must not time out
116
+ * mid-job (pass `timeoutMs` to override).
117
+ */
118
+ getJobEvents(jobIdOrUrl: string, options?: {
119
+ signal?: AbortSignal;
120
+ timeoutMs?: number | null;
121
+ }): AsyncGenerator<RawEvent, void, void>;
122
+ /** `POST /api/v2/jobs/{id}/cancel` — idempotent. */
123
+ cancelJob(jobIdOrUrl: string, options?: {
124
+ signal?: AbortSignal;
125
+ }): Promise<Job>;
126
+ }
127
+ export declare const OPERATION_IDS: readonly ["postAssets", "assetFromHash", "headAssetByHash", "getAsset", "getAssetContent", "postJobs", "getJob", "getJobEvents", "cancelJob"];
128
+ export declare const OPERATION_METHODS: Record<(typeof OPERATION_IDS)[number], keyof ComfyLow>;
@@ -0,0 +1,285 @@
1
+ /**
2
+ * Thin `low` transport over `fetch` — one async function per `operationId`
3
+ * in `spec/openapi.yaml`, plus the mandatory escape hatches the hand-written
4
+ * `sdk` layer builds on:
5
+ *
6
+ * - **raw response access** — every method's Promise resolves from (or, via
7
+ * {@link ComfyLow.request}, directly returns) a `Response` whose body is
8
+ * never pre-read.
9
+ * - **unbuffered / streaming bodies** — a `fetch` `Response.body` is always a
10
+ * lazy `ReadableStream`; nothing here buffers it before the caller asks
11
+ * for `.json()`/`.body`. This is why, unlike the Python transport (which
12
+ * distinguishes a buffering `raw_request` from a streaming `open`), there
13
+ * is exactly one request primitive here — fetch's laziness already gives
14
+ * both hatches from a single call.
15
+ * - **streaming request bodies** — `postAssets` takes a `Blob` (a Node
16
+ * `fs.openAsBlob()` handle is a lazy, disk-backed `Blob`, so a multi-GB
17
+ * upload is never buffered whole in memory) inside a native `FormData`;
18
+ * `fetch`/undici streams the encoded body to the wire.
19
+ * - **per-request timeout/abort** — every method accepts `signal` and
20
+ * `timeoutMs`; `AbortSignal.any` composes a caller's signal with this
21
+ * client's default timeout (or overrides/disables it per call).
22
+ *
23
+ * This layer contains no orchestration, retries, hashing, or SSE
24
+ * reconnection — those live in `../sdk`. Mirrors `comfy_low.transport` in
25
+ * the Python SDK (its `AsyncComfyLow`; there is no sync variant here — see
26
+ * the package README for why).
27
+ */
28
+ import { errorFromEnvelope } from "./errors.js";
29
+ import { iterateSse } from "./sse.js";
30
+ const API_PREFIX = "/api/v2";
31
+ const DEFAULT_TIMEOUT_MS = 30_000;
32
+ function looksLikePath(value) {
33
+ return value.startsWith("http") || value.startsWith("/");
34
+ }
35
+ function parseRetryAfter(response) {
36
+ const raw = response.headers.get("Retry-After");
37
+ if (raw === null)
38
+ return null;
39
+ const seconds = Number.parseInt(raw, 10);
40
+ return Number.isNaN(seconds) ? null : seconds;
41
+ }
42
+ /** Synchronous protocol bindings — async throughout (JS is async-native). */
43
+ export class ComfyLow {
44
+ baseUrl;
45
+ apiKey;
46
+ fetchImpl;
47
+ defaultTimeoutMs;
48
+ constructor(baseUrl, apiKey, options = {}) {
49
+ this.baseUrl = baseUrl.replace(/\/$/, "");
50
+ this.apiKey = apiKey;
51
+ this.fetchImpl = options.fetch ?? fetch;
52
+ this.defaultTimeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
53
+ }
54
+ urlFor(path) {
55
+ if (path.startsWith("http"))
56
+ return path;
57
+ if (path.startsWith("/api/"))
58
+ return this.baseUrl + path;
59
+ return this.baseUrl + API_PREFIX + path;
60
+ }
61
+ /**
62
+ * Is `url` on the same origin (scheme + host + port) as this client's
63
+ * `baseUrl`? A relative `path` always resolves to a `baseUrl`-derived URL
64
+ * (see {@link urlFor}), so this only ever says "no" for a server-returned
65
+ * absolute URL (`model.urls.self`/`cancel`/`events`) that points somewhere
66
+ * else — which is exactly the case where the bearer token must not be
67
+ * attached.
68
+ */
69
+ isSameOrigin(url) {
70
+ try {
71
+ return new URL(url).origin === new URL(this.baseUrl).origin;
72
+ }
73
+ catch {
74
+ return false;
75
+ }
76
+ }
77
+ buildHeaders(url, extra) {
78
+ const headers = new Headers();
79
+ // Only authenticate when a key is set (a local proxy fronts a ComfyUI
80
+ // with no auth, so we never leak credentials it does not want) AND the
81
+ // request target is this client's own origin. `getJob`/`cancelJob`/
82
+ // `getJobEvents` can be fed a server-returned absolute URL
83
+ // (`model.urls.self/cancel/events`); if that ever points at a different
84
+ // host, the bearer token must not follow it there.
85
+ if (this.apiKey && this.isSameOrigin(url)) {
86
+ headers.set("Authorization", `Bearer ${this.apiKey}`);
87
+ }
88
+ if (extra) {
89
+ for (const [key, value] of Object.entries(extra)) {
90
+ headers.set(key, value);
91
+ }
92
+ }
93
+ return headers;
94
+ }
95
+ resolveSignal(callerSignal, timeoutMs) {
96
+ const effective = timeoutMs === undefined ? this.defaultTimeoutMs : timeoutMs;
97
+ if (effective === null)
98
+ return callerSignal;
99
+ const timeoutSignal = AbortSignal.timeout(effective);
100
+ return callerSignal ? AbortSignal.any([callerSignal, timeoutSignal]) : timeoutSignal;
101
+ }
102
+ /**
103
+ * The single escape hatch: an unread `Response` (raw headers/status,
104
+ * lazy body) for a request built from `{json | body}`. Every typed
105
+ * method below is a thin wrapper over this.
106
+ */
107
+ async request(method, path, options = {}) {
108
+ const url = this.urlFor(path);
109
+ const headers = this.buildHeaders(url, options.headers);
110
+ let body = options.body;
111
+ if (options.json !== undefined) {
112
+ headers.set("Content-Type", "application/json");
113
+ body = JSON.stringify(options.json);
114
+ }
115
+ const signal = this.resolveSignal(options.signal, options.timeoutMs);
116
+ return this.fetchImpl(url, { method, headers, body, signal, redirect: "follow" });
117
+ }
118
+ async parseOrRaise(response, ok) {
119
+ if (ok.includes(response.status)) {
120
+ if (response.status === 204 || response.headers.get("Content-Length") === "0") {
121
+ return {};
122
+ }
123
+ const text = await response.text();
124
+ return text ? JSON.parse(text) : {};
125
+ }
126
+ let body;
127
+ try {
128
+ body = await response.json();
129
+ }
130
+ catch {
131
+ body = undefined;
132
+ }
133
+ throw errorFromEnvelope(response.status, body, {
134
+ retryAfter: parseRetryAfter(response),
135
+ });
136
+ }
137
+ // -- assets -------------------------------------------------------------
138
+ /** `POST /api/v2/assets` — streaming multipart upload. */
139
+ async postAssets(file, contentType, filePath, options = {}) {
140
+ const form = new FormData();
141
+ form.append("file", file, filePath);
142
+ form.append("content_type", contentType);
143
+ form.append("file_path", filePath);
144
+ if (options.expectedHash !== undefined) {
145
+ form.append("expected_hash", options.expectedHash);
146
+ }
147
+ for (const tag of options.tags ?? []) {
148
+ form.append("tags", tag);
149
+ }
150
+ const headers = {};
151
+ if (options.idempotencyKey) {
152
+ headers["Idempotency-Key"] = options.idempotencyKey;
153
+ }
154
+ const response = await this.request("POST", "/assets", {
155
+ headers,
156
+ body: form,
157
+ signal: options.signal,
158
+ timeoutMs: options.timeoutMs,
159
+ });
160
+ return this.parseOrRaise(response, [200, 201, 202]);
161
+ }
162
+ /** `POST /api/v2/assets/from-hash` — dedup mint over existing bytes. */
163
+ async assetFromHash(hash, options = {}) {
164
+ const json = { hash };
165
+ if (options.filePath !== undefined)
166
+ json.file_path = options.filePath;
167
+ if (options.tags !== undefined)
168
+ json.tags = [...options.tags];
169
+ const response = await this.request("POST", "/assets/from-hash", {
170
+ json,
171
+ signal: options.signal,
172
+ });
173
+ return this.parseOrRaise(response, [200, 201]);
174
+ }
175
+ /** `HEAD /api/v2/assets/by-hash/{hash}` — existence probe. */
176
+ async headAssetByHash(hash, options = {}) {
177
+ const response = await this.request("HEAD", `/assets/by-hash/${encodeURIComponent(hash)}`, {
178
+ signal: options.signal,
179
+ });
180
+ if (response.status === 200)
181
+ return true;
182
+ if (response.status === 404)
183
+ return false;
184
+ return this.parseOrRaise(response, [200]);
185
+ }
186
+ /** `GET /api/v2/assets/{id}` — metadata with a fresh content URL. */
187
+ async getAsset(assetId, options = {}) {
188
+ const response = await this.request("GET", `/assets/${encodeURIComponent(assetId)}`, {
189
+ signal: options.signal,
190
+ });
191
+ return this.parseOrRaise(response, [200]);
192
+ }
193
+ /**
194
+ * `GET /api/v2/assets/{id}/content` — raw, streamed, range-aware body.
195
+ * Returns the response itself (escape hatch); the caller reads
196
+ * `response.body`.
197
+ */
198
+ async getAssetContent(assetId, options = {}) {
199
+ const headers = {};
200
+ if (options.range) {
201
+ headers.Range = `bytes=${options.range[0]}-${options.range[1]}`;
202
+ }
203
+ const response = await this.request("GET", `/assets/${encodeURIComponent(assetId)}/content`, {
204
+ headers,
205
+ signal: options.signal,
206
+ });
207
+ if (response.status !== 200 && response.status !== 206) {
208
+ await this.parseOrRaise(response, [200, 206]);
209
+ }
210
+ return response;
211
+ }
212
+ // -- jobs -----------------------------------------------------------------
213
+ /** `POST /api/v2/jobs`. */
214
+ async postJobs(workflow, options = {}) {
215
+ const headers = {};
216
+ if (options.idempotencyKey) {
217
+ headers["Idempotency-Key"] = options.idempotencyKey;
218
+ }
219
+ const json = { workflow };
220
+ const response = await this.request("POST", "/jobs", { headers, json, signal: options.signal });
221
+ return this.parseOrRaise(response, [201]);
222
+ }
223
+ /** `GET /api/v2/jobs/{id}` (or an absolute self link). */
224
+ async getJob(jobIdOrUrl, options = {}) {
225
+ const path = looksLikePath(jobIdOrUrl) ? jobIdOrUrl : `/jobs/${encodeURIComponent(jobIdOrUrl)}`;
226
+ const response = await this.request("GET", path, { signal: options.signal });
227
+ return this.parseOrRaise(response, [200]);
228
+ }
229
+ /**
230
+ * `GET /api/v2/jobs/{id}/events` — raw live SSE iterator (escape hatch).
231
+ * No reconnection here; a single connection's frames. `../sdk` adds the
232
+ * reconnect loop. No default timeout: an idle stream must not time out
233
+ * mid-job (pass `timeoutMs` to override).
234
+ */
235
+ async *getJobEvents(jobIdOrUrl, options = {}) {
236
+ const path = looksLikePath(jobIdOrUrl)
237
+ ? jobIdOrUrl
238
+ : `/jobs/${encodeURIComponent(jobIdOrUrl)}/events`;
239
+ const response = await this.request("GET", path, {
240
+ headers: { Accept: "text/event-stream" },
241
+ signal: options.signal,
242
+ timeoutMs: options.timeoutMs ?? null,
243
+ });
244
+ if (response.status !== 200) {
245
+ await this.parseOrRaise(response, [200]);
246
+ return;
247
+ }
248
+ if (!response.body)
249
+ return;
250
+ yield* iterateSse(response.body);
251
+ }
252
+ /** `POST /api/v2/jobs/{id}/cancel` — idempotent. */
253
+ async cancelJob(jobIdOrUrl, options = {}) {
254
+ const path = looksLikePath(jobIdOrUrl)
255
+ ? jobIdOrUrl
256
+ : `/jobs/${encodeURIComponent(jobIdOrUrl)}/cancel`;
257
+ const response = await this.request("POST", path, { signal: options.signal });
258
+ return this.parseOrRaise(response, [200]);
259
+ }
260
+ }
261
+ // The exact set of operationIds the transport must cover; the spec-coverage
262
+ // test asserts this equals the set of operationIds in spec/openapi.yaml.
263
+ export const OPERATION_IDS = [
264
+ "postAssets",
265
+ "assetFromHash",
266
+ "headAssetByHash",
267
+ "getAsset",
268
+ "getAssetContent",
269
+ "postJobs",
270
+ "getJob",
271
+ "getJobEvents",
272
+ "cancelJob",
273
+ ];
274
+ // operationId -> transport method name.
275
+ export const OPERATION_METHODS = {
276
+ postAssets: "postAssets",
277
+ assetFromHash: "assetFromHash",
278
+ headAssetByHash: "headAssetByHash",
279
+ getAsset: "getAsset",
280
+ getAssetContent: "getAssetContent",
281
+ postJobs: "postJobs",
282
+ getJob: "getJob",
283
+ getJobEvents: "getJobEvents",
284
+ cancelJob: "cancelJob",
285
+ };
@@ -0,0 +1,13 @@
1
+ /**
2
+ * `setTimeout`-based sleep that rejects immediately when `signal` aborts,
3
+ * instead of only resolving after the full delay.
4
+ *
5
+ * Every internal wait the `sdk` layer schedules on the caller's behalf — the
6
+ * adaptive poll backoff in `Job.wait`, the reconnect pause in `Job.events`,
7
+ * the queue-full retry pause in `Comfy.submit` — uses this instead of a
8
+ * plain sleep. Without it, an aborted `AbortSignal` would only cancel the
9
+ * *current* fetch; the loop would still wake up on its own timer and start
10
+ * another iteration. Composing the signal into the wait itself is what
11
+ * makes an abort actually stop the loop promptly.
12
+ */
13
+ export declare function abortableSleep(ms: number, signal?: AbortSignal): Promise<void>;
@@ -0,0 +1,31 @@
1
+ /**
2
+ * `setTimeout`-based sleep that rejects immediately when `signal` aborts,
3
+ * instead of only resolving after the full delay.
4
+ *
5
+ * Every internal wait the `sdk` layer schedules on the caller's behalf — the
6
+ * adaptive poll backoff in `Job.wait`, the reconnect pause in `Job.events`,
7
+ * the queue-full retry pause in `Comfy.submit` — uses this instead of a
8
+ * plain sleep. Without it, an aborted `AbortSignal` would only cancel the
9
+ * *current* fetch; the loop would still wake up on its own timer and start
10
+ * another iteration. Composing the signal into the wait itself is what
11
+ * makes an abort actually stop the loop promptly.
12
+ */
13
+ export function abortableSleep(ms, signal) {
14
+ if (signal?.aborted) {
15
+ return Promise.reject(abortReason(signal));
16
+ }
17
+ return new Promise((resolve, reject) => {
18
+ const onAbort = () => {
19
+ clearTimeout(timer);
20
+ reject(abortReason(signal));
21
+ };
22
+ const timer = setTimeout(() => {
23
+ signal?.removeEventListener("abort", onAbort);
24
+ resolve();
25
+ }, ms);
26
+ signal?.addEventListener("abort", onAbort, { once: true });
27
+ });
28
+ }
29
+ function abortReason(signal) {
30
+ return signal.reason ?? new DOMException("This operation was aborted", "AbortError");
31
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Content-addressed asset handles and their constructors.
3
+ *
4
+ * `client.assets.from*` returns a **lazy** {@link Asset} handle: no network
5
+ * at construction. On first use (`commit`/`asReference`/submit) the handle
6
+ * hashes its bytes locally with blake3, probes the server's dedup
7
+ * fast-path (`HEAD by-hash` then `from-hash` mint over existing bytes), and
8
+ * only streams a full multipart upload when the server does not already
9
+ * have the bytes. Because the handle carries an idempotency key,
10
+ * re-running a script re-uploads nothing whose bytes already made it to
11
+ * the server. Mirrors `comfy_sdk.assets` in the Python SDK — folded to one
12
+ * async class since JS is async-native (no separate sync variant).
13
+ */
14
+ import type { Asset as LowAsset, ComfyLow } from "../low/index.js";
15
+ import { ASSET_HANDLE } from "./core.js";
16
+ type Hasher = () => Promise<string>;
17
+ type Opener = () => Promise<Blob>;
18
+ interface Source {
19
+ contentType: string;
20
+ filePath: string;
21
+ hasher: Hasher;
22
+ opener: Opener;
23
+ }
24
+ export declare class Asset {
25
+ readonly [ASSET_HANDLE]: true;
26
+ private readonly low;
27
+ private readonly source;
28
+ private readonly idempotencyKey;
29
+ private hashValue?;
30
+ private idValue?;
31
+ private createdNewValue;
32
+ constructor(low: ComfyLow, source: Source);
33
+ get id(): string | undefined;
34
+ get filePath(): string;
35
+ get createdNew(): boolean | null;
36
+ /** The local blake3 (computed once, lazily). */
37
+ hash(): Promise<string>;
38
+ /**
39
+ * Adopt a resolved `Asset` model's identity. Not part of the public
40
+ * surface — used internally after a commit and by
41
+ * {@link AssetFactory.get} to rehydrate an already-committed handle.
42
+ */
43
+ adopt(asset: LowAsset): void;
44
+ /** Force the hash/dedup/upload now; return the asset UUID. */
45
+ commit(signal?: AbortSignal): Promise<string>;
46
+ /** The `core/ASSET` object (commits first if needed). */
47
+ asReference(signal?: AbortSignal): Promise<import("../low/models.js").AssetReference>;
48
+ }
49
+ /** `client.assets` — alternative constructors for {@link Asset}. */
50
+ export declare class AssetFactory {
51
+ private readonly low;
52
+ constructor(low: ComfyLow);
53
+ fromFile(path: string): Asset;
54
+ fromBytes(data: Uint8Array, options?: {
55
+ filename?: string;
56
+ contentType?: string;
57
+ }): Asset;
58
+ /**
59
+ * Buffers the stream fully so the dedup probe can hash before upload
60
+ * (the bytes must be re-readable for the upload itself). If the source
61
+ * is a file, prefer {@link fromFile} — it stays lazy end to end.
62
+ */
63
+ fromStream(stream: ReadableStream<Uint8Array> | NodeJS.ReadableStream, options?: {
64
+ filename?: string;
65
+ contentType?: string;
66
+ }): Promise<Asset>;
67
+ /** Client-side download (not a server-side fetch): the bytes must
68
+ * transit the same blake3 -> dedup -> upload pipeline as every other
69
+ * source. */
70
+ fromUrl(url: string): Promise<Asset>;
71
+ /** Rehydrate an already-committed asset by UUID. */
72
+ get(assetId: string): Promise<Asset>;
73
+ }
74
+ export {};