@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 +53 -5
- package/dist/low/errors.js +5 -0
- package/dist/low/generated/types.gen.d.ts +17 -0
- package/dist/low/generated/zod.gen.d.ts +3 -0
- package/dist/low/generated/zod.gen.js +4 -1
- package/dist/low/index.d.ts +1 -1
- package/dist/low/sse.d.ts +15 -2
- package/dist/low/sse.js +35 -2
- package/dist/low/transport.d.ts +39 -2
- package/dist/low/transport.js +111 -3
- package/dist/sdk/client.d.ts +11 -0
- package/dist/sdk/client.js +12 -1
- package/dist/sdk/exceptions.js +5 -0
- package/dist/sdk/outputs.d.ts +14 -0
- package/dist/sdk/outputs.js +13 -0
- package/package.json +21 -14
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: "
|
|
51
|
-
| Serverless | `new Comfy(baseUrl, { apiKey: "
|
|
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
|
package/dist/low/errors.js
CHANGED
|
@@ -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()
|
package/dist/low/index.d.ts
CHANGED
|
@@ -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
|
|
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) {
|
package/dist/low/transport.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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). */
|
package/dist/low/transport.js
CHANGED
|
@@ -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, {
|
|
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
|
-
/**
|
|
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
|
}
|
package/dist/sdk/client.d.ts
CHANGED
|
@@ -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
|
}
|
package/dist/sdk/client.js
CHANGED
|
@@ -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);
|
package/dist/sdk/exceptions.js
CHANGED
|
@@ -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
|
};
|
package/dist/sdk/outputs.d.ts
CHANGED
|
@@ -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
|
}
|
package/dist/sdk/outputs.js
CHANGED
|
@@ -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.
|
|
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
|
-
"
|
|
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
|
+
}
|