@comfyorg/sdk 0.1.0 → 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/README.md CHANGED
@@ -44,11 +44,11 @@ await job.getOutputs("13")[0].toFile("out.png");
44
44
 
45
45
  ## Auth, per surface
46
46
 
47
- | Surface | Auth |
48
- | ------------------------------------------------------------------ | ------------------------------------------ |
49
- | Self-hosted proxy (`comfy-api-proxy` in front of your own ComfyUI) | none — do **not** pass `apiKey` |
50
- | Comfy Cloud | `new Comfy(baseUrl, { apiKey: "ck_..." })` |
51
- | Serverless | `new Comfy(baseUrl, { apiKey: "ck_..." })` |
47
+ | Surface | Auth |
48
+ | ------------------------------------------------------------------ | ----------------------------------------------- |
49
+ | Self-hosted proxy (`comfy-api-proxy` in front of your own ComfyUI) | none — do **not** pass `apiKey` |
50
+ | Comfy Cloud | `new Comfy(baseUrl, { apiKey: "comfyui-..." })` |
51
+ | Serverless | `new Comfy(baseUrl, { apiKey: "comfyui-..." })` |
52
52
 
53
53
  The client only attaches the `Authorization` header to requests aimed at its
54
54
  own `baseUrl`'s origin. If the server hands back an absolute URL on a
@@ -56,6 +56,11 @@ different host (for example a job's `events`/`cancel` link, or a redirect on
56
56
  an asset download), the key is not sent there — see "Typed errors" below for
57
57
  the exception classes this surface can raise.
58
58
 
59
+ The SDK identifies itself via `User-Agent` (for support + usage analytics);
60
+ no other data is collected. Pass `clientInfo` to `new Comfy(baseUrl, { ... })`
61
+ to append your own app's name to it, for example when attributing traffic
62
+ from a Worker built on top of this SDK.
63
+
59
64
  ## Quickstart
60
65
 
61
66
  ```ts
@@ -79,6 +84,23 @@ await job.wait(); // poll to terminal (adaptive backoff); or call job.refresh()
79
84
  console.log(job.status, job.outputs);
80
85
  ```
81
86
 
87
+ ## Partner (API) node auth
88
+
89
+ Workflows that use partner/API nodes (Gemini, etc.) need a Comfy API key to
90
+ authenticate them. Pass it per submit with `apiKey`. This is **not** the same as
91
+ the credential you construct `Comfy` with: the constructor key authenticates
92
+ _you_ to the server, while this one authenticates the partner nodes _inside_ the
93
+ workflow (it is often the same `comfyui-…` key):
94
+
95
+ ```ts
96
+ const job = await client.run(wf, { apiKey: "comfyui-…" });
97
+ // or: await client.submit(wf, { apiKey: "comfyui-…" });
98
+ ```
99
+
100
+ The SDK sends it once as `extra_data.api_key_comfy_org` alongside the workflow —
101
+ one key authenticates every partner node in the graph. It is never logged or
102
+ persisted by the SDK. Omit `apiKey` and no `extra_data` is sent at all.
103
+
82
104
  ## Assets and `core/ASSET`
83
105
 
84
106
  `client.assets.fromFile(path)` / `client.assets.fromBytes(data, options)`
@@ -156,6 +178,32 @@ managing an `AbortController` yourself:
156
178
  await client.run(wf, { timeoutMs: 60_000 });
157
179
  ```
158
180
 
181
+ ## Downloading outputs
182
+
183
+ A finished job exposes its results as output handles — `job.outputs`, or
184
+ `job.getOutputs(nodeId)` to filter to one node. Each is an asset you can pull
185
+ down whichever way suits the caller:
186
+
187
+ ```ts
188
+ const out = job.getOutputs("13")[0];
189
+ await out.toFile("result.png"); // stream to disk
190
+ const data = await out.toBytes(); // buffer into memory
191
+ await out.toFile("head.png", { range: [0, 1023] }); // range-aware: first 1 KiB only
192
+ ```
193
+
194
+ `getDownloadUrl()` hands back a fetchable URL instead of streaming the bytes
195
+ through your process — give it to a browser, a CDN, or another service:
196
+
197
+ ```ts
198
+ const { url, expiresAt } = await out.getDownloadUrl();
199
+ ```
200
+
201
+ On Comfy Cloud / serverless it's a short-lived, **self-authorizing** signed
202
+ storage URL: whoever holds it can read the asset until `expiresAt` with no API
203
+ key of their own. On a self-hosted proxy it's the content endpoint (normal auth
204
+ still applies) and `expiresAt` is `null`. It works on every backend and never
205
+ downloads the bytes first.
206
+
159
207
  ## Typed errors
160
208
 
161
209
  Protocol-level failures are raised as one exception class per error code, so
@@ -66,6 +66,11 @@ const BY_CODE = {
66
66
  queue_full: QueueFull,
67
67
  insufficient_credits: InsufficientCredits,
68
68
  not_found: NotFound,
69
+ // public-api currently returns entity-specific 404 codes even though the spec
70
+ // documents the generic `not_found`; map them so callers still get a typed
71
+ // NotFound. (Server/spec reconciliation is a separate follow-up.)
72
+ job_not_found: NotFound,
73
+ asset_not_found: NotFound,
69
74
  unauthorized: Unauthorized,
70
75
  forbidden: Forbidden,
71
76
  };
@@ -371,6 +371,15 @@ export type PostJobsData = {
371
371
  workflow: {
372
372
  [key: string]: unknown;
373
373
  };
374
+ /**
375
+ * Per-prompt ComfyUI `extra_data`, same shape as Comfy Cloud and local ComfyUI. Closed object: only the enumerated keys are accepted, keeping the contract fully typed. Forwarded to the worker per-prompt, never persisted, and excluded from idempotency comparison.
376
+ */
377
+ extra_data?: {
378
+ /**
379
+ * API key for partner (API) nodes.
380
+ */
381
+ api_key_comfy_org?: string;
382
+ };
374
383
  };
375
384
  headers?: {
376
385
  /**
@@ -437,6 +446,10 @@ export type GetJobErrors = {
437
446
  * `not_found`.
438
447
  */
439
448
  404: ErrorEnvelope;
449
+ /**
450
+ * `rate_limited` — the caller has exceeded the request rate limit for this account. Account/rate-scoped, not job-specific — this can be returned even for a job id the caller doesn't own or that doesn't exist, without revealing which.
451
+ */
452
+ 429: ErrorEnvelope;
440
453
  /**
441
454
  * `upstream_error` — an unexpected failure reaching or processing the request in this implementation's backing services. The message is always a generic, safe-to-display string; implementation detail (the specific upstream, its error text, transport failures) is never included here — see each implementation's own error-mapping notes. Every operation in this contract can fail this way.
442
455
  */
@@ -513,6 +526,10 @@ export type CancelJobErrors = {
513
526
  * `not_found`.
514
527
  */
515
528
  404: ErrorEnvelope;
529
+ /**
530
+ * `rate_limited` — the caller has exceeded the request rate limit for this account. Account/rate-scoped, not job-specific — this can be returned even for a job id the caller doesn't own or that doesn't exist, without revealing which.
531
+ */
532
+ 429: ErrorEnvelope;
516
533
  /**
517
534
  * `upstream_error` — an unexpected failure reaching or processing the request in this implementation's backing services. The message is always a generic, safe-to-display string; implementation detail (the specific upstream, its error text, transport failures) is never included here — see each implementation's own error-mapping notes. Every operation in this contract can fail this way.
518
535
  */
@@ -253,6 +253,9 @@ export declare const zGetAssetContentPath: z.ZodObject<{
253
253
  export declare const zGetAssetContentResponse: z.ZodString;
254
254
  export declare const zPostJobsBody: z.ZodObject<{
255
255
  workflow: z.ZodRecord<z.ZodString, z.ZodUnknown>;
256
+ extra_data: z.ZodOptional<z.ZodObject<{
257
+ api_key_comfy_org: z.ZodOptional<z.ZodString>;
258
+ }, z.core.$strip>>;
256
259
  }, z.core.$strip>;
257
260
  export declare const zPostJobsHeaders: z.ZodObject<{
258
261
  'Idempotency-Key': z.ZodOptional<z.ZodString>;
@@ -172,7 +172,10 @@ export const zGetAssetContentPath = z.object({
172
172
  */
173
173
  export const zGetAssetContentResponse = z.string();
174
174
  export const zPostJobsBody = z.object({
175
- workflow: z.record(z.string(), z.unknown())
175
+ workflow: z.record(z.string(), z.unknown()),
176
+ extra_data: z.object({
177
+ api_key_comfy_org: z.string().optional()
178
+ }).optional()
176
179
  });
177
180
  export const zPostJobsHeaders = z.object({
178
181
  'Idempotency-Key': z.string().optional()
@@ -18,4 +18,4 @@ export * as schemas from "./generated/zod.gen.js";
18
18
  export * from "./models.js";
19
19
  export * from "./errors.js";
20
20
  export { type RawEvent, iterateSse } from "./sse.js";
21
- export { ComfyLow, OPERATION_IDS, OPERATION_METHODS, type ComfyLowOptions, type RequestOptions, } from "./transport.js";
21
+ export { ComfyLow, OPERATION_IDS, OPERATION_METHODS, type ComfyLowOptions, type RequestOptions, type AssetContentUrl, } from "./transport.js";
package/dist/low/sse.d.ts CHANGED
@@ -14,11 +14,24 @@ export interface RawEvent {
14
14
  event: string;
15
15
  data: Record<string, unknown>;
16
16
  }
17
+ /**
18
+ * Default read-idle timeout for the SSE byte stream. The server sends keepalive
19
+ * comments well within this window, so no *bytes at all* for this long means a
20
+ * silently-stalled ("zombie") connection — open but delivering nothing, not
21
+ * legitimately idle. We error the stream in that case so the caller's
22
+ * reconnect/poll fallback runs (see `Job.events`) instead of hanging forever.
23
+ * The timeout is at the byte level, not the parsed-message level, so keepalive
24
+ * comments (which `eventsource-parser` drops) still count as liveness.
25
+ */
26
+ export declare const SSE_IDLE_TIMEOUT_MS = 45000;
17
27
  /**
18
28
  * Decode a fetch response body into a stream of {@link RawEvent}s.
19
29
  *
20
30
  * No cursor/replay support by design — the contract carries no `id`/
21
31
  * `Last-Event-ID`; a blank `data:` frame (no payload) is dropped, matching
22
- * the Python decoder.
32
+ * the Python decoder. A read-idle timeout (default {@link SSE_IDLE_TIMEOUT_MS})
33
+ * errors a silently-stalled connection instead of blocking forever.
23
34
  */
24
- export declare function iterateSse(body: ReadableStream<Uint8Array>): AsyncGenerator<RawEvent, void, void>;
35
+ export declare function iterateSse(body: ReadableStream<Uint8Array>, options?: {
36
+ idleTimeoutMs?: number;
37
+ }): AsyncGenerator<RawEvent, void, void>;
package/dist/low/sse.js CHANGED
@@ -24,15 +24,48 @@ function parseData(raw) {
24
24
  }
25
25
  return { value: parsed };
26
26
  }
27
+ /**
28
+ * Default read-idle timeout for the SSE byte stream. The server sends keepalive
29
+ * comments well within this window, so no *bytes at all* for this long means a
30
+ * silently-stalled ("zombie") connection — open but delivering nothing, not
31
+ * legitimately idle. We error the stream in that case so the caller's
32
+ * reconnect/poll fallback runs (see `Job.events`) instead of hanging forever.
33
+ * The timeout is at the byte level, not the parsed-message level, so keepalive
34
+ * comments (which `eventsource-parser` drops) still count as liveness.
35
+ */
36
+ export const SSE_IDLE_TIMEOUT_MS = 45_000;
37
+ /** A pass-through stream that errors if no chunk arrives within `ms`. */
38
+ function idleTimeout(ms) {
39
+ let timer;
40
+ const clear = () => {
41
+ if (timer !== undefined)
42
+ clearTimeout(timer);
43
+ };
44
+ const arm = (controller) => {
45
+ clear();
46
+ timer = setTimeout(() => controller.error(new Error(`SSE idle timeout: no data for ${ms}ms`)), ms);
47
+ };
48
+ return new TransformStream({
49
+ start: arm,
50
+ transform(chunk, controller) {
51
+ arm(controller);
52
+ controller.enqueue(chunk);
53
+ },
54
+ flush: clear,
55
+ cancel: clear,
56
+ });
57
+ }
27
58
  /**
28
59
  * Decode a fetch response body into a stream of {@link RawEvent}s.
29
60
  *
30
61
  * No cursor/replay support by design — the contract carries no `id`/
31
62
  * `Last-Event-ID`; a blank `data:` frame (no payload) is dropped, matching
32
- * the Python decoder.
63
+ * the Python decoder. A read-idle timeout (default {@link SSE_IDLE_TIMEOUT_MS})
64
+ * errors a silently-stalled connection instead of blocking forever.
33
65
  */
34
- export async function* iterateSse(body) {
66
+ export async function* iterateSse(body, options = {}) {
35
67
  const stream = body
68
+ .pipeThrough(idleTimeout(options.idleTimeoutMs ?? SSE_IDLE_TIMEOUT_MS))
36
69
  .pipeThrough(new TextDecoderStream())
37
70
  .pipeThrough(new EventSourceParserStream());
38
71
  for await (const message of stream) {
@@ -25,7 +25,7 @@
25
25
  * the Python SDK (its `AsyncComfyLow`; there is no sync variant here — see
26
26
  * the package README for why).
27
27
  */
28
- import type { Asset, Job } from "./generated/types.gen.js";
28
+ import type { Asset, Job, PostJobsData } from "./generated/types.gen.js";
29
29
  import { type RawEvent } from "./sse.js";
30
30
  export interface RequestOptions {
31
31
  headers?: Record<string, string>;
@@ -38,10 +38,24 @@ export interface RequestOptions {
38
38
  * must not time out while idle mid-job).
39
39
  */
40
40
  timeoutMs?: number | null;
41
+ /** Defaults to `"follow"`; `getAssetContentUrl` passes `"manual"` so it can
42
+ * read a redirect's `Location` instead of following it. */
43
+ redirect?: "follow" | "manual" | "error";
41
44
  }
42
45
  export interface ComfyLowOptions {
43
46
  fetch?: typeof fetch;
44
47
  timeoutMs?: number;
48
+ /**
49
+ * Appended to the default `User-Agent` as `app/{clientInfo}` — lets an
50
+ * app built on this SDK attribute its own traffic in request logs.
51
+ * Opt-in; omitted by default.
52
+ */
53
+ clientInfo?: string;
54
+ }
55
+ /** Return shape of {@link ComfyLow.getAssetContentUrl}. */
56
+ export interface AssetContentUrl {
57
+ url: string;
58
+ expiresAt: Date | null;
45
59
  }
46
60
  /** Synchronous protocol bindings — async throughout (JS is async-native). */
47
61
  export declare class ComfyLow {
@@ -49,6 +63,7 @@ export declare class ComfyLow {
49
63
  private readonly apiKey?;
50
64
  private readonly fetchImpl;
51
65
  private readonly defaultTimeoutMs;
66
+ private readonly userAgent;
52
67
  constructor(baseUrl: string, apiKey?: string, options?: ComfyLowOptions);
53
68
  private urlFor;
54
69
  /**
@@ -100,9 +115,31 @@ export declare class ComfyLow {
100
115
  range?: readonly [number, number];
101
116
  signal?: AbortSignal;
102
117
  }): Promise<Response>;
103
- /** `POST /api/v2/jobs`. */
118
+ /**
119
+ * `GET /api/v2/assets/{id}/content` with the redirect **not** followed —
120
+ * hands back a directly-fetchable URL instead of the bytes, so a caller
121
+ * (e.g. a serverless/Cloudflare Worker) can pass the URL to a downstream
122
+ * consumer without streaming the body through itself. On an object-storage
123
+ * backend (Cloud/serverless) the server redirects to a short-lived signed
124
+ * URL; that redirect is deliberately not followed (unlike
125
+ * {@link getAssetContent}), both so its `Location` can be read and so this
126
+ * client's bearer token is never attached to the object-storage host. On a
127
+ * self-hosted proxy (which serves the bytes inline, no redirect) this
128
+ * returns the endpoint's own absolute URL instead. Works on every backend
129
+ * and never throws for either shape — only a genuine failure maps to the
130
+ * usual typed error.
131
+ */
132
+ getAssetContentUrl(assetId: string, options?: {
133
+ signal?: AbortSignal;
134
+ }): Promise<AssetContentUrl>;
135
+ /**
136
+ * `POST /api/v2/jobs`. `extraData` (e.g. the partner-node API key) is a
137
+ * sibling of `workflow` on the wire, per the spec's closed `extra_data`
138
+ * object; omitted entirely when not provided, never sent empty.
139
+ */
104
140
  postJobs(workflow: Record<string, unknown>, options?: {
105
141
  idempotencyKey?: string;
142
+ extraData?: PostJobsData["body"]["extra_data"];
106
143
  signal?: AbortSignal;
107
144
  }): Promise<Job>;
108
145
  /** `GET /api/v2/jobs/{id}` (or an absolute self link). */
@@ -29,9 +29,55 @@ import { errorFromEnvelope } from "./errors.js";
29
29
  import { iterateSse } from "./sse.js";
30
30
  const API_PREFIX = "/api/v2";
31
31
  const DEFAULT_TIMEOUT_MS = 30_000;
32
+ // Mirrors `version` in package.json. Hand-kept in sync: this package builds
33
+ // with plain `tsc` (no bundler/codegen step to inline it), and importing
34
+ // `../../package.json` directly would step outside `tsc`'s configured
35
+ // `rootDir`.
36
+ const SDK_VERSION = "0.1.0";
32
37
  function looksLikePath(value) {
33
38
  return value.startsWith("http") || value.startsWith("/");
34
39
  }
40
+ function buildUserAgent(clientInfo) {
41
+ const base = `comfy-sdk-typescript/${SDK_VERSION} (node ${process.version})`;
42
+ if (!clientInfo)
43
+ return base;
44
+ // A caller-set token goes verbatim into a header value; reject CR/LF so it
45
+ // can never split/inject headers (undici would reject it anyway, but fail
46
+ // fast with a clear message at construction).
47
+ if (/[\r\n]/.test(clientInfo)) {
48
+ throw new Error("clientInfo must not contain CR or LF characters");
49
+ }
50
+ return `${base} app/${clientInfo}`;
51
+ }
52
+ const GOOG_DATE_RE = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z$/;
53
+ /**
54
+ * Expiry off a GCS V4 signed URL's `X-Goog-Date` (`YYYYMMDDTHHMMSSZ`, UTC) +
55
+ * `X-Goog-Expires` (seconds) query params. `null` if either is missing or
56
+ * doesn't match — an unrecognized signed-URL flavor degrades to "unknown
57
+ * expiry" rather than throwing.
58
+ */
59
+ function parseExpiry(url) {
60
+ let parsed;
61
+ try {
62
+ parsed = new URL(url);
63
+ }
64
+ catch {
65
+ return null;
66
+ }
67
+ const dateParam = parsed.searchParams.get("X-Goog-Date");
68
+ const expiresParam = parsed.searchParams.get("X-Goog-Expires");
69
+ if (!dateParam || !expiresParam)
70
+ return null;
71
+ const match = GOOG_DATE_RE.exec(dateParam);
72
+ if (!match)
73
+ return null;
74
+ const expiresSeconds = Number.parseInt(expiresParam, 10);
75
+ if (Number.isNaN(expiresSeconds))
76
+ return null;
77
+ const [, year, month, day, hour, minute, second] = match;
78
+ const epochMs = Date.UTC(Number(year), Number(month) - 1, Number(day), Number(hour), Number(minute), Number(second));
79
+ return new Date(epochMs + expiresSeconds * 1000);
80
+ }
35
81
  function parseRetryAfter(response) {
36
82
  const raw = response.headers.get("Retry-After");
37
83
  if (raw === null)
@@ -45,11 +91,13 @@ export class ComfyLow {
45
91
  apiKey;
46
92
  fetchImpl;
47
93
  defaultTimeoutMs;
94
+ userAgent;
48
95
  constructor(baseUrl, apiKey, options = {}) {
49
96
  this.baseUrl = baseUrl.replace(/\/$/, "");
50
97
  this.apiKey = apiKey;
51
98
  this.fetchImpl = options.fetch ?? fetch;
52
99
  this.defaultTimeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
100
+ this.userAgent = buildUserAgent(options.clientInfo);
53
101
  }
54
102
  urlFor(path) {
55
103
  if (path.startsWith("http"))
@@ -90,6 +138,12 @@ export class ComfyLow {
90
138
  headers.set(key, value);
91
139
  }
92
140
  }
141
+ // Identifies this SDK's traffic in request logs (support + adoption
142
+ // metrics, no other data collected) — never overrides a caller-supplied
143
+ // header.
144
+ if (!headers.has("User-Agent")) {
145
+ headers.set("User-Agent", this.userAgent);
146
+ }
93
147
  return headers;
94
148
  }
95
149
  resolveSignal(callerSignal, timeoutMs) {
@@ -113,7 +167,13 @@ export class ComfyLow {
113
167
  body = JSON.stringify(options.json);
114
168
  }
115
169
  const signal = this.resolveSignal(options.signal, options.timeoutMs);
116
- return this.fetchImpl(url, { method, headers, body, signal, redirect: "follow" });
170
+ return this.fetchImpl(url, {
171
+ method,
172
+ headers,
173
+ body,
174
+ signal,
175
+ redirect: options.redirect ?? "follow",
176
+ });
117
177
  }
118
178
  async parseOrRaise(response, ok) {
119
179
  if (ok.includes(response.status)) {
@@ -137,8 +197,11 @@ export class ComfyLow {
137
197
  // -- assets -------------------------------------------------------------
138
198
  /** `POST /api/v2/assets` — streaming multipart upload. */
139
199
  async postAssets(file, contentType, filePath, options = {}) {
200
+ // public-api streams the multipart upload and requires the metadata fields
201
+ // BEFORE the file part (so it can route the file stream without buffering).
202
+ // The `file` part MUST be appended last, or the server rejects with 422
203
+ // "content_type is required and must be sent before the file field".
140
204
  const form = new FormData();
141
- form.append("file", file, filePath);
142
205
  form.append("content_type", contentType);
143
206
  form.append("file_path", filePath);
144
207
  if (options.expectedHash !== undefined) {
@@ -147,6 +210,7 @@ export class ComfyLow {
147
210
  for (const tag of options.tags ?? []) {
148
211
  form.append("tags", tag);
149
212
  }
213
+ form.append("file", file, filePath);
150
214
  const headers = {};
151
215
  if (options.idempotencyKey) {
152
216
  headers["Idempotency-Key"] = options.idempotencyKey;
@@ -209,14 +273,58 @@ export class ComfyLow {
209
273
  }
210
274
  return response;
211
275
  }
276
+ /**
277
+ * `GET /api/v2/assets/{id}/content` with the redirect **not** followed —
278
+ * hands back a directly-fetchable URL instead of the bytes, so a caller
279
+ * (e.g. a serverless/Cloudflare Worker) can pass the URL to a downstream
280
+ * consumer without streaming the body through itself. On an object-storage
281
+ * backend (Cloud/serverless) the server redirects to a short-lived signed
282
+ * URL; that redirect is deliberately not followed (unlike
283
+ * {@link getAssetContent}), both so its `Location` can be read and so this
284
+ * client's bearer token is never attached to the object-storage host. On a
285
+ * self-hosted proxy (which serves the bytes inline, no redirect) this
286
+ * returns the endpoint's own absolute URL instead. Works on every backend
287
+ * and never throws for either shape — only a genuine failure maps to the
288
+ * usual typed error.
289
+ */
290
+ async getAssetContentUrl(assetId, options = {}) {
291
+ const path = `/assets/${encodeURIComponent(assetId)}/content`;
292
+ const response = await this.request("GET", path, {
293
+ signal: options.signal,
294
+ redirect: "manual",
295
+ });
296
+ const location = response.headers.get("Location");
297
+ // Node/undici hands back the real 3xx status with a readable `Location`
298
+ // for a manual redirect; status 0 covers a browser's opaque redirect.
299
+ // We only want the URL, not the bytes — release the response body so
300
+ // undici can reuse the connection instead of pinning it until GC.
301
+ if (location && (response.status === 0 || (response.status >= 300 && response.status < 400))) {
302
+ await response.body?.cancel();
303
+ return { url: location, expiresAt: parseExpiry(location) };
304
+ }
305
+ if (response.status === 200 || response.status === 206) {
306
+ await response.body?.cancel();
307
+ return { url: this.urlFor(path), expiresAt: null };
308
+ }
309
+ return this.parseOrRaise(response, [200, 206]); // always throws here
310
+ }
212
311
  // -- jobs -----------------------------------------------------------------
213
- /** `POST /api/v2/jobs`. */
312
+ /**
313
+ * `POST /api/v2/jobs`. `extraData` (e.g. the partner-node API key) is a
314
+ * sibling of `workflow` on the wire, per the spec's closed `extra_data`
315
+ * object; omitted entirely when not provided, never sent empty.
316
+ */
214
317
  async postJobs(workflow, options = {}) {
215
318
  const headers = {};
216
319
  if (options.idempotencyKey) {
217
320
  headers["Idempotency-Key"] = options.idempotencyKey;
218
321
  }
219
322
  const json = { workflow };
323
+ // Only attach a non-empty extra_data — never serialize an empty object onto
324
+ // the wire (mirrors the Python low layer's truthy guard).
325
+ if (options.extraData && Object.keys(options.extraData).length > 0) {
326
+ json.extra_data = options.extraData;
327
+ }
220
328
  const response = await this.request("POST", "/jobs", { headers, json, signal: options.signal });
221
329
  return this.parseOrRaise(response, [201]);
222
330
  }
@@ -37,6 +37,9 @@ export interface ComfyOptions {
37
37
  apiKey?: string;
38
38
  timeoutMs?: number;
39
39
  fetch?: ComfyLowOptions["fetch"];
40
+ /** Appended to the SDK's default `User-Agent` as `app/{clientInfo}` — lets
41
+ * an app built on this SDK attribute its own traffic in request logs. */
42
+ clientInfo?: string;
40
43
  }
41
44
  export declare class Comfy {
42
45
  private readonly low;
@@ -57,14 +60,22 @@ export declare class Comfy {
57
60
  * idempotent, pass an explicit `idempotencyKey` and reuse it. Note a reused
58
61
  * key is *rejected*, not replayed: on reuse, catch the error and poll/list
59
62
  * for the job the first attempt already created.
63
+ *
64
+ * Pass `apiKey` to authenticate partner (API) nodes in the workflow (for
65
+ * example Gemini) — it is sent once, as `extra_data.api_key_comfy_org`
66
+ * alongside the workflow, and is unrelated to the `Idempotency-Key`: it
67
+ * does not affect idempotency and is never persisted or logged by this
68
+ * SDK. Omit it and no `extra_data` is sent at all.
60
69
  */
61
70
  submit(workflow: Workflow, options?: {
62
71
  idempotencyKey?: string;
72
+ apiKey?: string;
63
73
  signal?: AbortSignal;
64
74
  }): Promise<Job>;
65
75
  /** Submit, then poll to terminal (authoritative). Throws on failure. */
66
76
  run(workflow: Workflow, options?: {
67
77
  timeoutMs?: number;
78
+ apiKey?: string;
68
79
  signal?: AbortSignal;
69
80
  }): Promise<Job>;
70
81
  }
@@ -52,6 +52,7 @@ export class Comfy {
52
52
  this.low = new ComfyLow(baseUrl, options.apiKey, {
53
53
  timeoutMs: options.timeoutMs,
54
54
  fetch: options.fetch,
55
+ clientInfo: options.clientInfo,
55
56
  });
56
57
  this.assets = new AssetFactory(this.low);
57
58
  this.workflows = new WorkflowFactory();
@@ -77,16 +78,26 @@ export class Comfy {
77
78
  * idempotent, pass an explicit `idempotencyKey` and reuse it. Note a reused
78
79
  * key is *rejected*, not replayed: on reuse, catch the error and poll/list
79
80
  * for the job the first attempt already created.
81
+ *
82
+ * Pass `apiKey` to authenticate partner (API) nodes in the workflow (for
83
+ * example Gemini) — it is sent once, as `extra_data.api_key_comfy_org`
84
+ * alongside the workflow, and is unrelated to the `Idempotency-Key`: it
85
+ * does not affect idempotency and is never persisted or logged by this
86
+ * SDK. Omit it and no `extra_data` is sent at all.
80
87
  */
81
88
  async submit(workflow, options = {}) {
82
89
  guardUiFormat(workflow);
83
90
  const graph = await this.materialize(workflow, options.signal);
84
91
  const key = options.idempotencyKey ?? newIdempotencyKey();
92
+ // A falsy apiKey (undefined or "") means "no key" — send no extra_data,
93
+ // matching the Python SDK's behavior so the two stay in lockstep.
94
+ const extraData = options.apiKey ? { api_key_comfy_org: options.apiKey } : undefined;
85
95
  const deadline = Date.now() + QUEUE_RETRY_BUDGET_MS;
86
96
  for (;;) {
87
97
  try {
88
98
  const job = await this.low.postJobs(graph, {
89
99
  idempotencyKey: key,
100
+ extraData,
90
101
  signal: options.signal,
91
102
  });
92
103
  return new Job(this.low, job);
@@ -105,7 +116,7 @@ export class Comfy {
105
116
  }
106
117
  /** Submit, then poll to terminal (authoritative). Throws on failure. */
107
118
  async run(workflow, options = {}) {
108
- const job = await this.submit(workflow, { signal: options.signal });
119
+ const job = await this.submit(workflow, { apiKey: options.apiKey, signal: options.signal });
109
120
  return options.timeoutMs === undefined
110
121
  ? job.result(options.signal)
111
122
  : runWithTimeout(job, options.timeoutMs, options.signal);
@@ -79,6 +79,11 @@ const BY_CODE = {
79
79
  idempotency_key_reuse: IdempotencyKeyReuse,
80
80
  insufficient_credits: InsufficientCredits,
81
81
  not_found: NotFound,
82
+ // public-api returns entity-specific 404 codes even though the spec documents
83
+ // the generic not_found; map them so a missing job/asset raises the typed
84
+ // NotFound. (Server/spec reconciliation of the code set is a separate follow-up.)
85
+ job_not_found: NotFound,
86
+ asset_not_found: NotFound,
82
87
  unauthorized: Unauthorized,
83
88
  forbidden: Forbidden,
84
89
  };
@@ -23,4 +23,18 @@ export declare class Output {
23
23
  toBytes(options?: {
24
24
  range?: readonly [number, number];
25
25
  }): Promise<Uint8Array>;
26
+ /**
27
+ * A directly-fetchable URL for this output's bytes — a short-lived,
28
+ * self-authorizing bearer credential (readable until `expiresAt`) — so a
29
+ * caller (e.g. a serverless/Cloudflare Worker) can hand the URL to a
30
+ * downstream consumer instead of streaming the bytes through itself. On an
31
+ * object-storage backend (Cloud/serverless) this is a signed URL and
32
+ * `expiresAt` is set; on a self-hosted proxy (which serves the bytes
33
+ * inline) it's this asset's own content URL and `expiresAt` is `null`.
34
+ * Works on every backend and never throws.
35
+ */
36
+ getDownloadUrl(): Promise<{
37
+ url: string;
38
+ expiresAt: Date | null;
39
+ }>;
26
40
  }
@@ -45,4 +45,17 @@ export class Output {
45
45
  const response = await this.low.getAssetContent(this.model.id, { range: options.range });
46
46
  return new Uint8Array(await response.arrayBuffer());
47
47
  }
48
+ /**
49
+ * A directly-fetchable URL for this output's bytes — a short-lived,
50
+ * self-authorizing bearer credential (readable until `expiresAt`) — so a
51
+ * caller (e.g. a serverless/Cloudflare Worker) can hand the URL to a
52
+ * downstream consumer instead of streaming the bytes through itself. On an
53
+ * object-storage backend (Cloud/serverless) this is a signed URL and
54
+ * `expiresAt` is set; on a self-hosted proxy (which serves the bytes
55
+ * inline) it's this asset's own content URL and `expiresAt` is `null`.
56
+ * Works on every backend and never throws.
57
+ */
58
+ async getDownloadUrl() {
59
+ return this.low.getAssetContentUrl(this.model.id);
60
+ }
48
61
  }
package/package.json CHANGED
@@ -1,8 +1,16 @@
1
1
  {
2
2
  "name": "@comfyorg/sdk",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "private": false,
5
5
  "description": "TypeScript SDK for running ComfyUI workflows via the Comfy API v2 (self-hosted, Comfy Cloud, serverless).",
6
+ "homepage": "https://github.com/Comfy-Org/ComfyTypeScriptSDK#readme",
7
+ "bugs": {
8
+ "url": "https://github.com/Comfy-Org/ComfyTypeScriptSDK/issues"
9
+ },
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/Comfy-Org/ComfyTypeScriptSDK.git"
13
+ },
6
14
  "files": [
7
15
  "dist"
8
16
  ],
@@ -19,17 +27,6 @@
19
27
  "default": "./dist/low/index.js"
20
28
  }
21
29
  },
22
- "scripts": {
23
- "build": "tsc",
24
- "generate": "openapi-ts",
25
- "lint": "oxlint .",
26
- "format": "oxfmt --write .",
27
- "format:check": "oxfmt --check .",
28
- "typecheck": "tsc --noEmit",
29
- "test": "vitest run",
30
- "test:coverage": "vitest run --coverage",
31
- "check:spec-drift": "node scripts/check-spec-drift.mjs"
32
- },
33
30
  "dependencies": {
34
31
  "eventsource-parser": "^3.0.0",
35
32
  "hash-wasm": "^4.11.0",
@@ -48,5 +45,15 @@
48
45
  "engines": {
49
46
  "node": ">=22"
50
47
  },
51
- "packageManager": "pnpm@11.13.0"
52
- }
48
+ "scripts": {
49
+ "build": "tsc",
50
+ "generate": "openapi-ts",
51
+ "lint": "oxlint .",
52
+ "format": "oxfmt --write .",
53
+ "format:check": "oxfmt --check .",
54
+ "typecheck": "tsc --noEmit",
55
+ "test": "vitest run",
56
+ "test:coverage": "vitest run --coverage",
57
+ "check:spec-drift": "node scripts/check-spec-drift.mjs"
58
+ }
59
+ }