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
79 changes: 79 additions & 0 deletions src/low/followUpLinks.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
/**
* Follow-up links (`job.urls.*`) resolve against the origin, not baseUrl.
*
* A server mounts the v2 contract wherever it likes — the serverless gateway
* serves it under `/deployment/{id}/api/v2` — and its host-relative follow-up
* links already include that mount prefix. Resolving them against `baseUrl`
* (which carries the same prefix) doubles it and 404s; they must resolve
* against the scheme+authority only. Internal shorthand paths (`/jobs/…`,
* `/assets…`) keep resolving under `baseUrl` + `/api/v2`.
*/

import { describe, expect, it } from "vitest";

import { ComfyLow } from "./transport.js";

const GATEWAY_BASE = "https://stagingplatformapi.comfy.org/deployment/dep_123";

function jobJson(id: string, urlsPrefix: string) {
return {
id,
status: "queued",
created_at: "2026-07-10T18:20:00Z",
started_at: null,
completed_at: null,
expires_at: "2026-07-11T18:20:00Z",
queue_position: 0,
progress: null,
outputs: [],
error: null,
metrics: null,
urls: {
self: `${urlsPrefix}/api/v2/jobs/${id}`,
events: `${urlsPrefix}/api/v2/jobs/${id}/events`,
cancel: `${urlsPrefix}/api/v2/jobs/${id}/cancel`,
},
};
}

function capturingLow(baseUrl: string, apiKey?: string) {
const requests: { url: string; auth: string | null }[] = [];
const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit) => {
const url = input instanceof Request ? input.url : String(input);
const headers = new Headers(input instanceof Request ? input.headers : init?.headers);
requests.push({ url, auth: headers.get("Authorization") });
return new Response(JSON.stringify(jobJson("j1", "/deployment/dep_123")), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}) as typeof fetch;
return { low: new ComfyLow(baseUrl, apiKey, { fetch: fetchImpl }), requests };
}

describe("follow-up link resolution", () => {
it("resolves a gateway self link against the origin, not baseUrl", async () => {
const { low, requests } = capturingLow(GATEWAY_BASE, "comfyui-k");
await low.getJob("/deployment/dep_123/api/v2/jobs/j1");
expect(requests[0].url).toBe(
"https://stagingplatformapi.comfy.org/deployment/dep_123/api/v2/jobs/j1",
);
});

it("keeps the deployment prefix for internal shorthand paths", async () => {
const { low, requests } = capturingLow(GATEWAY_BASE, "comfyui-k");
await low.getJob("j1");
expect(requests[0].url).toBe(`${GATEWAY_BASE}/api/v2/jobs/j1`);
});

it("leaves bare-surface self links unchanged", async () => {
const { low, requests } = capturingLow("https://api.comfy.org", "comfyui-k");
await low.getJob("/api/v2/jobs/j1");
expect(requests[0].url).toBe("https://api.comfy.org/api/v2/jobs/j1");
});

it("still attaches auth to origin-resolved links", async () => {
const { low, requests } = capturingLow(GATEWAY_BASE, "comfyui-k");
await low.getJob("/deployment/dep_123/api/v2/jobs/j1");
expect(requests[0].auth).toBe("Bearer comfyui-k");
});
});
11 changes: 10 additions & 1 deletion src/low/transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,16 @@ export class ComfyLow {

private urlFor(path: string): string {
if (path.startsWith("http")) return path;
if (path.startsWith("/api/")) return this.baseUrl + path;
// A server link (job.urls.*, marked by containing /api/) already carries
// the server's mount prefix, so it resolves against the origin — joining
// it to baseUrl would double the prefix on a prefix-mounted surface.
if (path.startsWith("/") && path.includes("/api/")) {
try {
return new URL(this.baseUrl).origin + path;
} catch {
return this.baseUrl + path;
}
}
return this.baseUrl + API_PREFIX + path;
}

Expand Down
Loading