@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.
- package/README.md +236 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.js +27 -0
- package/dist/low/errors.d.ts +73 -0
- package/dist/low/errors.js +104 -0
- package/dist/low/generated/index.d.ts +1 -0
- package/dist/low/generated/index.js +2 -0
- package/dist/low/generated/types.gen.d.ts +528 -0
- package/dist/low/generated/types.gen.js +2 -0
- package/dist/low/generated/zod.gen.d.ts +455 -0
- package/dist/low/generated/zod.gen.js +204 -0
- package/dist/low/index.d.ts +21 -0
- package/dist/low/index.js +21 -0
- package/dist/low/models.d.ts +47 -0
- package/dist/low/models.js +16 -0
- package/dist/low/sse.d.ts +24 -0
- package/dist/low/sse.js +44 -0
- package/dist/low/transport.d.ts +128 -0
- package/dist/low/transport.js +285 -0
- package/dist/sdk/abortable-sleep.d.ts +13 -0
- package/dist/sdk/abortable-sleep.js +31 -0
- package/dist/sdk/assets.d.ts +74 -0
- package/dist/sdk/assets.js +200 -0
- package/dist/sdk/client.d.ts +70 -0
- package/dist/sdk/client.js +120 -0
- package/dist/sdk/core.d.ts +33 -0
- package/dist/sdk/core.js +89 -0
- package/dist/sdk/events.d.ts +60 -0
- package/dist/sdk/events.js +84 -0
- package/dist/sdk/exceptions.d.ts +79 -0
- package/dist/sdk/exceptions.js +114 -0
- package/dist/sdk/hashing.d.ts +14 -0
- package/dist/sdk/hashing.js +28 -0
- package/dist/sdk/index.d.ts +13 -0
- package/dist/sdk/index.js +12 -0
- package/dist/sdk/jobs.d.ts +47 -0
- package/dist/sdk/jobs.js +145 -0
- package/dist/sdk/outputs.d.ts +26 -0
- package/dist/sdk/outputs.js +48 -0
- package/dist/sdk/workflows.d.ts +32 -0
- package/dist/sdk/workflows.js +45 -0
- 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>;
|
package/dist/low/sse.js
ADDED
|
@@ -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 {};
|