Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,11 @@ different host (for example a job's `events`/`cancel` link, or a redirect on
an asset download), the key is not sent there — see "Typed errors" below for
the exception classes this surface can raise.

The SDK identifies itself via `User-Agent` (for support + usage analytics);
no other data is collected. Pass `clientInfo` to `new Comfy(baseUrl, { ... })`
to append your own app's name to it, for example when attributing traffic
from a Worker built on top of this SDK.

## Quickstart

```ts
Expand Down
1 change: 1 addition & 0 deletions src/low/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,5 @@ export {
OPERATION_METHODS,
type ComfyLowOptions,
type RequestOptions,
type AssetContentUrl,
} from "./transport.js";
89 changes: 89 additions & 0 deletions src/low/transport.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { readFileSync } from "node:fs";
import { writeFile, mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
Expand Down Expand Up @@ -92,6 +93,56 @@ describe("ComfyLow transport", () => {
expect(await response.text()).toBe("234");
});

// -- getAssetContentUrl -----------------------------------------------

it("getAssetContentUrl returns the signed URL + parsed expiresAt when the server redirects (Cloud/object storage)", async () => {
const signedUrl =
"https://storage.googleapis.com/bucket/asset_1?X-Goog-Date=20260722T120000Z&X-Goog-Expires=3600&X-Goog-Signature=deadbeef";
server.state.contentRedirectLocation = signedUrl;
const result = await low.getAssetContentUrl("asset_1");
expect(result.url).toBe(signedUrl);
expect(result.expiresAt).toEqual(new Date("2026-07-22T13:00:00.000Z"));
});

it("getAssetContentUrl returns this endpoint's own absolute URL with a null expiresAt when served inline (self-hosted)", async () => {
server.state.contentBytes = Buffer.from("inline-bytes");
const result = await low.getAssetContentUrl("asset_1");
expect(result.url).toBe(`${server.baseUrl}/api/v2/assets/asset_1/content`);
expect(result.expiresAt).toBeNull();
});

it("getAssetContentUrl returns null expiresAt for a redirect URL with no recognizable signed-URL params", async () => {
server.state.contentRedirectLocation = "https://cdn.example.invalid/asset_1?token=opaque";
const result = await low.getAssetContentUrl("asset_1");
expect(result.url).toBe("https://cdn.example.invalid/asset_1?token=opaque");
expect(result.expiresAt).toBeNull();
});

it("getAssetContentUrl never throws for a real 404 — no, it maps to the usual typed error", async () => {
// Sanity check that a genuine failure still surfaces via the existing
// error mapping rather than being swallowed by the "never throws" cases.
await expect(low.getAssetContentUrl("")).rejects.toBeTruthy();
});

it("getAssetContentUrl does not attach the bearer token to the redirect target (redirect is never followed)", async () => {
const authed = new ComfyLow(server.baseUrl, "top-secret-key");
const attacker = new StubServer();
await attacker.start();
try {
server.state.contentRedirectOrigin = attacker.baseUrl;
const result = await authed.getAssetContentUrl("asset_1");
// The redirect is handed back, not followed.
expect(result.url.startsWith(attacker.baseUrl)).toBe(true);
// The initial (same-origin) request carried the token...
expect(server.state.lastAuthorizationHeader).toBe("Bearer top-secret-key");
// ...but since the redirect was never followed, the attacker's server
// was never even contacted, let alone with the bearer token.
expect(attacker.state.lastAuthorizationHeader).toBeNull();
} finally {
await attacker.stop();
}
});

it("postJobs rejects a reused Idempotency-Key (single-use, no replay)", async () => {
const key = "idem-key-1";
const first = await low.postJobs({ "1": {} }, { idempotencyKey: key });
Expand Down Expand Up @@ -144,6 +195,44 @@ describe("ComfyLow transport", () => {
expect(job.status).toBe("canceling");
});

// -- User-Agent identification ---------------------------------------------

it("sends a default User-Agent identifying the SDK + node runtime", async () => {
await low.getJob("job_01");
expect(server.state.lastUserAgentHeader).toMatch(
/^comfy-sdk-typescript\/\d+\.\d+\.\d+ \(node v\d+\.\d+\.\d+\)$/,
);
});

it("appends an app/{clientInfo} token when clientInfo is set, without dropping the base token", async () => {
const withClientInfo = new ComfyLow(server.baseUrl, undefined, { clientInfo: "my-worker" });
await withClientInfo.getJob("job_01");
expect(server.state.lastUserAgentHeader).toMatch(
/^comfy-sdk-typescript\/\d+\.\d+\.\d+ \(node v\d+\.\d+\.\d+\) app\/my-worker$/,
);
});

it("does not override a caller-supplied User-Agent header", async () => {
// No public method currently lets a caller pass headers through, so this
// exercises the escape hatch directly.
await low.request("GET", "/jobs/job_01", { headers: { "User-Agent": "custom-agent/1.0" } });
expect(server.state.lastUserAgentHeader).toBe("custom-agent/1.0");
});

it("rejects a clientInfo containing CR/LF (no header injection)", () => {
for (const bad of ["evil\r\nX-Injected: 1", "line\nbreak", "carriage\rreturn"]) {
expect(() => new ComfyLow(server.baseUrl, undefined, { clientInfo: bad })).toThrow();
}
});

it("User-Agent version matches package.json (guards SDK_VERSION drift)", async () => {
const pkg = JSON.parse(
readFileSync(new URL("../../package.json", import.meta.url), "utf8"),
) as { version: string };
await low.getJob("job_01");
expect(server.state.lastUserAgentHeader).toContain(`comfy-sdk-typescript/${pkg.version}`);
});

// -- per-surface auth (FIX 3) ---------------------------------------------

it("a request with no apiKey against a no-auth server sends no Authorization header", async () => {
Expand Down
122 changes: 121 additions & 1 deletion src/low/transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,12 @@ import { iterateSse, type RawEvent } from "./sse.js";
const API_PREFIX = "/api/v2";
const DEFAULT_TIMEOUT_MS = 30_000;

// Mirrors `version` in package.json. Hand-kept in sync: this package builds
// with plain `tsc` (no bundler/codegen step to inline it), and importing
// `../../package.json` directly would step outside `tsc`'s configured
// `rootDir`.
const SDK_VERSION = "0.1.0";

Comment thread
coderabbitai[bot] marked this conversation as resolved.
export interface RequestOptions {
headers?: Record<string, string>;
json?: unknown;
Expand All @@ -44,17 +50,78 @@ export interface RequestOptions {
* must not time out while idle mid-job).
*/
timeoutMs?: number | null;
/** Defaults to `"follow"`; `getAssetContentUrl` passes `"manual"` so it can
* read a redirect's `Location` instead of following it. */
redirect?: "follow" | "manual" | "error";
}

export interface ComfyLowOptions {
fetch?: typeof fetch;
timeoutMs?: number;
/**
* Appended to the default `User-Agent` as `app/{clientInfo}` — lets an
* app built on this SDK attribute its own traffic in request logs.
* Opt-in; omitted by default.
*/
clientInfo?: string;
}

/** Return shape of {@link ComfyLow.getAssetContentUrl}. */
export interface AssetContentUrl {
url: string;
expiresAt: Date | null;
}

function looksLikePath(value: string): boolean {
return value.startsWith("http") || value.startsWith("/");
}

function buildUserAgent(clientInfo?: string): string {
const base = `comfy-sdk-typescript/${SDK_VERSION} (node ${process.version})`;
if (!clientInfo) return base;
// A caller-set token goes verbatim into a header value; reject CR/LF so it
// can never split/inject headers (undici would reject it anyway, but fail
// fast with a clear message at construction).
if (/[\r\n]/.test(clientInfo)) {
throw new Error("clientInfo must not contain CR or LF characters");
}
return `${base} app/${clientInfo}`;
}

const GOOG_DATE_RE = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z$/;

/**
* Expiry off a GCS V4 signed URL's `X-Goog-Date` (`YYYYMMDDTHHMMSSZ`, UTC) +
* `X-Goog-Expires` (seconds) query params. `null` if either is missing or
* doesn't match — an unrecognized signed-URL flavor degrades to "unknown
* expiry" rather than throwing.
*/
function parseExpiry(url: string): Date | null {
let parsed: URL;
try {
parsed = new URL(url);
} catch {
return null;
}
const dateParam = parsed.searchParams.get("X-Goog-Date");
const expiresParam = parsed.searchParams.get("X-Goog-Expires");
if (!dateParam || !expiresParam) return null;
const match = GOOG_DATE_RE.exec(dateParam);
if (!match) return null;
const expiresSeconds = Number.parseInt(expiresParam, 10);
if (Number.isNaN(expiresSeconds)) return null;
const [, year, month, day, hour, minute, second] = match;
const epochMs = Date.UTC(
Number(year),
Number(month) - 1,
Number(day),
Number(hour),
Number(minute),
Number(second),
);
return new Date(epochMs + expiresSeconds * 1000);
}

function parseRetryAfter(response: Response): number | null {
const raw = response.headers.get("Retry-After");
if (raw === null) return null;
Expand All @@ -68,12 +135,14 @@ export class ComfyLow {
private readonly apiKey?: string;
private readonly fetchImpl: typeof fetch;
private readonly defaultTimeoutMs: number;
private readonly userAgent: string;

constructor(baseUrl: string, apiKey?: string, options: ComfyLowOptions = {}) {
this.baseUrl = baseUrl.replace(/\/$/, "");
this.apiKey = apiKey;
this.fetchImpl = options.fetch ?? fetch;
this.defaultTimeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
this.userAgent = buildUserAgent(options.clientInfo);
}

private urlFor(path: string): string {
Expand Down Expand Up @@ -114,6 +183,12 @@ export class ComfyLow {
headers.set(key, value);
}
}
// Identifies this SDK's traffic in request logs (support + adoption
// metrics, no other data collected) — never overrides a caller-supplied
// header.
if (!headers.has("User-Agent")) {
headers.set("User-Agent", this.userAgent);
}
return headers;
}

Expand Down Expand Up @@ -141,7 +216,13 @@ export class ComfyLow {
body = JSON.stringify(options.json);
}
const signal = this.resolveSignal(options.signal, options.timeoutMs);
return this.fetchImpl(url, { method, headers, body, signal, redirect: "follow" });
return this.fetchImpl(url, {
method,
headers,
body,
signal,
redirect: options.redirect ?? "follow",
});
}

private async parseOrRaise<T>(response: Response, ok: readonly number[]): Promise<T> {
Expand Down Expand Up @@ -261,6 +342,45 @@ export class ComfyLow {
return response;
}

/**
* `GET /api/v2/assets/{id}/content` with the redirect **not** followed —
* hands back a directly-fetchable URL instead of the bytes, so a caller
* (e.g. a serverless/Cloudflare Worker) can pass the URL to a downstream
* consumer without streaming the body through itself. On an object-storage
* backend (Cloud/serverless) the server redirects to a short-lived signed
* URL; that redirect is deliberately not followed (unlike
* {@link getAssetContent}), both so its `Location` can be read and so this
* client's bearer token is never attached to the object-storage host. On a
* self-hosted proxy (which serves the bytes inline, no redirect) this
* returns the endpoint's own absolute URL instead. Works on every backend
* and never throws for either shape — only a genuine failure maps to the
* usual typed error.
*/
async getAssetContentUrl(
assetId: string,
options: { signal?: AbortSignal } = {},
): Promise<AssetContentUrl> {
const path = `/assets/${encodeURIComponent(assetId)}/content`;
const response = await this.request("GET", path, {
signal: options.signal,
redirect: "manual",
});
const location = response.headers.get("Location");
// Node/undici hands back the real 3xx status with a readable `Location`
// for a manual redirect; status 0 covers a browser's opaque redirect.
// We only want the URL, not the bytes — release the response body so
// undici can reuse the connection instead of pinning it until GC.
if (location && (response.status === 0 || (response.status >= 300 && response.status < 400))) {
await response.body?.cancel();
return { url: location, expiresAt: parseExpiry(location) };
}
if (response.status === 200 || response.status === 206) {
await response.body?.cancel();
return { url: this.urlFor(path), expiresAt: null };
}
return this.parseOrRaise<AssetContentUrl>(response, [200, 206]); // always throws here
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// -- jobs -----------------------------------------------------------------

/**
Expand Down
4 changes: 4 additions & 0 deletions src/sdk/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ export interface ComfyOptions {
apiKey?: string;
timeoutMs?: number;
fetch?: ComfyLowOptions["fetch"];
/** Appended to the SDK's default `User-Agent` as `app/{clientInfo}` — lets
* an app built on this SDK attribute its own traffic in request logs. */
clientInfo?: string;
}

function guardUiFormat(workflow: Workflow): void {
Expand All @@ -75,6 +78,7 @@ export class Comfy {
this.low = new ComfyLow(baseUrl, options.apiKey, {
timeoutMs: options.timeoutMs,
fetch: options.fetch,
clientInfo: options.clientInfo,
});
this.assets = new AssetFactory(this.low);
this.workflows = new WorkflowFactory();
Expand Down
38 changes: 36 additions & 2 deletions src/sdk/outputs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,15 @@ describe("Output", () => {
await server.stop();
});

function output(): Output {
function outputWithId(id: string): Output {
return new Output(
{
node_id: "13",
name: "out.png",
type: "image",
content_type: "image/png",
size_bytes: 10,
id: "asset_out_01",
id,
hash: null,
url: "http://example.invalid/out",
url_expires_at: "2026-07-10T19:20:00Z",
Expand All @@ -39,6 +39,10 @@ describe("Output", () => {
);
}

function output(): Output {
return outputWithId("asset_out_01");
}

it("exposes the low-level output fields", () => {
const out = output();
expect(out.nodeId).toBe("13");
Expand Down Expand Up @@ -74,4 +78,34 @@ describe("Output", () => {
await rm(dir, { recursive: true, force: true });
}
});

// -- getDownloadUrl -----------------------------------------------------

it("getDownloadUrl returns this output's content URL with a null expiresAt on a self-hosted backend", async () => {
server.state.contentBytes = Buffer.from("bytes");
const result = await output().getDownloadUrl();
expect(result.url).toBe(`${server.baseUrl}/api/v2/assets/asset_out_01/content`);
expect(result.expiresAt).toBeNull();
});

it("getDownloadUrl surfaces the signed URL + expiresAt when the backend redirects (Cloud/serverless)", async () => {
const signedUrl =
"https://storage.googleapis.com/bucket/o?X-Goog-Date=20260722T000000Z&X-Goog-Expires=60";
server.state.contentRedirectLocation = signedUrl;
const result = await output().getDownloadUrl();
expect(result.url).toBe(signedUrl);
expect(result.expiresAt).toEqual(new Date("2026-07-22T00:01:00.000Z"));
});

it("getDownloadUrl resolves each output of a multi-output job to its own distinct URL", async () => {
server.state.contentBytes = Buffer.from("bytes");
const outA = outputWithId("asset_out_a");
const outB = outputWithId("asset_out_b");
const [a, b] = await Promise.all([outA.getDownloadUrl(), outB.getDownloadUrl()]);
expect(a.url).not.toBe(b.url);
expect(a.url).toBe(`${server.baseUrl}/api/v2/assets/asset_out_a/content`);
expect(b.url).toBe(`${server.baseUrl}/api/v2/assets/asset_out_b/content`);
expect(a.expiresAt).toBeNull();
expect(b.expiresAt).toBeNull();
});
});
Loading
Loading