Skip to content

Commit 8fd6337

Browse files
authored
feat: add fal.ai as a first-class provider (record + replay) (#153)
## Summary - Adds a general fal.ai handler (`src/fal.ts`) supporting arbitrary JSON request/response payloads — image, video, motion, music, etc. — alongside the existing audio-only `src/fal-audio.ts`. - Routes by `x-fal-target-host` header to mirror the `@fal-ai/client` server-side `requestMiddleware` convention (since `proxyUrl` is browser-only). Falls through to the legacy `/fal/queue/...` and `/fal/run/...` paths for back-compat. - Adds `mock.onFalQueue(/model/, payload)` (primary) and `mock.onFalRun(/model/, payload)` (sync alias) to `LLMock`. Queue submit auto-mints a request_id and returns the standard envelope; status/result lookups go through a per-testId TTL+bounded `FalQueueStateMap` (mirrors the `FalJobMap` pattern from `fal-audio.ts` and the `VideoStateMap` from `video.ts`). - Adds a `RawJSONResponse` (`{ json: unknown }`) variant to `FixtureResponse` so the recorder can save fal payloads verbatim instead of mis-classifying them as `ImageResponse` / `VideoResponse`. Closes #152. ## Surface | Method | Path | x-fal-target-host | Behaviour | | --- | --- | --- | --- | | POST | `/fal/{owner}/{model}` | `queue.fal.run` | queue submit (auto-mints request_id, returns envelope) | | GET | `/fal/{owner}/{model}/requests/{id}/status` | `queue.fal.run` | queue status (`COMPLETED`) | | GET | `/fal/{owner}/{model}/requests/{id}` | `queue.fal.run` | queue result (the matched JSON payload) | | PUT | `/fal/{owner}/{model}/requests/{id}/cancel` | `queue.fal.run` | `ALREADY_COMPLETED` | | POST | `/fal/{owner}/{model}` | `fal.run` | sync run (returns JSON directly, no envelope) | | POST | `/fal/storage/upload/initiate` | `rest.fal.ai` / `rest.alpha.fal.ai` | synthesised upload envelope stub | | _legacy_ | `/fal/queue/submit/{model}`, `/fal/queue/requests/{id}/...`, `/fal/run/{model}` | _(none)_ | unchanged — `fal-audio.ts` | ## Usage ```ts const mock = await LLMock.create({ port: 4010 }); mock.onFalQueue(/flux/, { images: [{ url: 'https://example.com/cat.png' }] }); mock.onFalQueue(/kling/, { video: { url: 'https://example.com/v.mp4' } }); // In your app: fal.config({ requestMiddleware: async (req) => { const original = new URL(req.url); if (!FAL_HOSTS.has(original.hostname)) return req; const rewritten = new URL('http://localhost:4010/fal'); rewritten.pathname += original.pathname; rewritten.search = original.search; return { ...req, url: rewritten.toString(), headers: { ...req.headers, 'x-fal-target-host': original.hostname }, }; }, }); await fal.queue.submit('fal-ai/flux/dev', { input: { prompt: 'a cat' } }); // → returns { request_id, status_url, response_url, cancel_url, queue_position: 0 } ``` In record mode (`record.providers.fal: 'https://queue.fal.run'`, or omit and let the header drive it), unmatched requests proxy through to fal.ai and are saved as `endpoint: "fal"` fixtures with the verbatim JSON payload. ## Test plan - [x] `pnpm test` — 2751 passed, 36 skipped, 78 test files - [x] `pnpm run lint`, `pnpm run format:check` (the only formatting warning is in pre-existing `.claude/settings.local.json`) - [x] New `src/__tests__/fal.test.ts` (12 tests): queue lifecycle, host-mirror routing, sync run, cancel, error fixtures, storage stub, testId isolation, legacy back-compat, record + replay - [x] Existing `src/__tests__/fal-audio.test.ts` still green - [x] Existing `src/__tests__/recorder.test.ts` still green - [ ] Smoke test against real `@fal-ai/client` in a downstream app (TanStack Start) ## Notes - `recorder.ts` already tees SSE streams progressively (added in a prior commit), so the streaming-relay concern raised in the issue's first comment is no longer a blocker for fal. - The new helpers expose `onFalQueue` as the primary entry point and `onFalRun` as a thin alias, since `fal.queue.*` is how clients actually call fal in modern code. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
2 parents 78a629d + 05baea0 commit 8fd6337

10 files changed

Lines changed: 967 additions & 15 deletions

File tree

src/__tests__/fal.test.ts

Lines changed: 314 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,314 @@
1+
import { describe, test, expect, afterEach } from "vitest";
2+
import * as http from "node:http";
3+
import * as fs from "node:fs";
4+
import * as os from "node:os";
5+
import * as path from "node:path";
6+
import { LLMock } from "../llmock.js";
7+
8+
// Spin up a tiny stub upstream that responds with whatever JSON the test
9+
// hands it; lets us exercise the record-and-replay path without depending on
10+
// fal.ai itself.
11+
function startStubUpstream(
12+
handler: (req: http.IncomingMessage, body: string) => { status?: number; body: unknown },
13+
): Promise<{ url: string; close: () => Promise<void> }> {
14+
return new Promise((resolve) => {
15+
const server = http.createServer((req, res) => {
16+
const chunks: Buffer[] = [];
17+
req.on("data", (c: Buffer) => chunks.push(c));
18+
req.on("end", () => {
19+
const body = Buffer.concat(chunks).toString();
20+
const result = handler(req, body);
21+
res.writeHead(result.status ?? 200, { "Content-Type": "application/json" });
22+
res.end(JSON.stringify(result.body));
23+
});
24+
});
25+
server.listen(0, "127.0.0.1", () => {
26+
const { port } = server.address() as { port: number };
27+
resolve({
28+
url: `http://127.0.0.1:${port}`,
29+
close: () =>
30+
new Promise<void>((r) => {
31+
server.close(() => r());
32+
}),
33+
});
34+
});
35+
});
36+
}
37+
38+
describe("fal.ai general handler — fixture lookup", () => {
39+
let mock: LLMock;
40+
41+
afterEach(async () => {
42+
await mock?.stop();
43+
});
44+
45+
test("onFalQueue: submit returns envelope, status returns COMPLETED, result returns JSON", async () => {
46+
mock = new LLMock({ port: 0 });
47+
mock.onFalQueue(/flux/, { images: [{ url: "https://example.com/cat.png" }] });
48+
await mock.start();
49+
50+
const submit = await fetch(`${mock.url}/fal/fal-ai/flux/dev`, {
51+
method: "POST",
52+
headers: { "Content-Type": "application/json", "x-fal-target-host": "queue.fal.run" },
53+
body: JSON.stringify({ input: { prompt: "a cat" } }),
54+
});
55+
expect(submit.status).toBe(200);
56+
const envelope = await submit.json();
57+
expect(envelope.request_id).toBeDefined();
58+
expect(envelope.status_url).toContain(envelope.request_id);
59+
expect(envelope.response_url).toContain(envelope.request_id);
60+
expect(envelope.cancel_url).toContain(envelope.request_id);
61+
62+
const status = await fetch(
63+
`${mock.url}/fal/fal-ai/flux/dev/requests/${envelope.request_id}/status`,
64+
{ headers: { "x-fal-target-host": "queue.fal.run" } },
65+
);
66+
expect(status.status).toBe(200);
67+
const statusBody = await status.json();
68+
expect(statusBody.status).toBe("COMPLETED");
69+
expect(statusBody.request_id).toBe(envelope.request_id);
70+
71+
const result = await fetch(`${mock.url}/fal/fal-ai/flux/dev/requests/${envelope.request_id}`, {
72+
headers: { "x-fal-target-host": "queue.fal.run" },
73+
});
74+
expect(result.status).toBe(200);
75+
const resultBody = await result.json();
76+
expect(resultBody).toEqual({ images: [{ url: "https://example.com/cat.png" }] });
77+
});
78+
79+
test("body extraction handles input.prompt nesting (fal-client default shape)", async () => {
80+
mock = new LLMock({ port: 0 });
81+
mock.onFalQueue(/flux/, { images: [{ url: "https://example.com/x.png" }] });
82+
await mock.start();
83+
84+
const submit = await fetch(`${mock.url}/fal/fal-ai/flux/dev`, {
85+
method: "POST",
86+
headers: { "Content-Type": "application/json", "x-fal-target-host": "queue.fal.run" },
87+
body: JSON.stringify({ input: { prompt: "a cat", image_size: "square_hd" }, logs: false }),
88+
});
89+
expect(submit.status).toBe(200);
90+
});
91+
92+
test("sync run returns JSON directly via x-fal-target-host: fal.run", async () => {
93+
mock = new LLMock({ port: 0 });
94+
mock.onFalRun(/flux/, { images: [{ url: "https://example.com/sync.png" }] });
95+
await mock.start();
96+
97+
const res = await fetch(`${mock.url}/fal/fal-ai/flux/dev`, {
98+
method: "POST",
99+
headers: { "Content-Type": "application/json", "x-fal-target-host": "fal.run" },
100+
body: JSON.stringify({ prompt: "a cat" }),
101+
});
102+
expect(res.status).toBe(200);
103+
const data = await res.json();
104+
expect(data).toEqual({ images: [{ url: "https://example.com/sync.png" }] });
105+
expect(data.request_id).toBeUndefined();
106+
});
107+
108+
test("cancel returns ALREADY_COMPLETED for stored job", async () => {
109+
mock = new LLMock({ port: 0 });
110+
mock.onFalQueue(/kling/, { video: { url: "https://example.com/v.mp4" } });
111+
await mock.start();
112+
113+
const submit = await fetch(`${mock.url}/fal/fal-ai/kling/v1`, {
114+
method: "POST",
115+
headers: { "Content-Type": "application/json", "x-fal-target-host": "queue.fal.run" },
116+
body: JSON.stringify({ input: { prompt: "river" } }),
117+
});
118+
const envelope = await submit.json();
119+
120+
const cancel = await fetch(
121+
`${mock.url}/fal/fal-ai/kling/v1/requests/${envelope.request_id}/cancel`,
122+
{ method: "PUT", headers: { "x-fal-target-host": "queue.fal.run" } },
123+
);
124+
expect(cancel.status).toBe(400);
125+
const body = await cancel.json();
126+
expect(body.status).toBe("ALREADY_COMPLETED");
127+
});
128+
129+
test("status for unknown request_id returns 404", async () => {
130+
mock = new LLMock({ port: 0 });
131+
await mock.start();
132+
133+
const res = await fetch(`${mock.url}/fal/fal-ai/flux/dev/requests/missing/status`, {
134+
headers: { "x-fal-target-host": "queue.fal.run" },
135+
});
136+
expect(res.status).toBe(404);
137+
});
138+
139+
test("no fixture match returns 404 in non-strict mode", async () => {
140+
mock = new LLMock({ port: 0 });
141+
mock.onFalQueue(/flux/, { images: [] });
142+
await mock.start();
143+
144+
const res = await fetch(`${mock.url}/fal/fal-ai/different-model/v1`, {
145+
method: "POST",
146+
headers: { "Content-Type": "application/json", "x-fal-target-host": "queue.fal.run" },
147+
body: JSON.stringify({ input: { prompt: "x" } }),
148+
});
149+
expect(res.status).toBe(404);
150+
const data = await res.json();
151+
expect(data.error.code).toBe("no_fixture_match");
152+
});
153+
154+
test("error fixture returns the configured status", async () => {
155+
mock = new LLMock({ port: 0 });
156+
mock.addFixture({
157+
match: { model: /kling/, endpoint: "fal" },
158+
response: { error: { message: "rate limited", type: "rate_limit_error" }, status: 429 },
159+
});
160+
await mock.start();
161+
162+
const res = await fetch(`${mock.url}/fal/fal-ai/kling/v1`, {
163+
method: "POST",
164+
headers: { "Content-Type": "application/json", "x-fal-target-host": "queue.fal.run" },
165+
body: JSON.stringify({ input: { prompt: "river" } }),
166+
});
167+
expect(res.status).toBe(429);
168+
const body = await res.json();
169+
expect(body.error.message).toBe("rate limited");
170+
});
171+
172+
test("storage upload initiate returns synthesised envelope", async () => {
173+
mock = new LLMock({ port: 0 });
174+
await mock.start();
175+
176+
const res = await fetch(`${mock.url}/fal/storage/upload/initiate`, {
177+
method: "POST",
178+
headers: { "Content-Type": "application/json", "x-fal-target-host": "rest.alpha.fal.ai" },
179+
body: JSON.stringify({ filename: "cat.png" }),
180+
});
181+
expect(res.status).toBe(200);
182+
const data = await res.json();
183+
expect(data.upload_url).toContain("rest.alpha.fal.ai");
184+
expect(data.file_url).toContain("cat.png");
185+
});
186+
187+
test("X-Test-Id isolation across queue jobs", async () => {
188+
mock = new LLMock({ port: 0 });
189+
mock.onFalQueue(/flux/, { images: [{ url: "https://example.com/iso.png" }] });
190+
await mock.start();
191+
192+
const submitA = await fetch(`${mock.url}/fal/fal-ai/flux/dev`, {
193+
method: "POST",
194+
headers: {
195+
"Content-Type": "application/json",
196+
"x-fal-target-host": "queue.fal.run",
197+
"X-Test-Id": "A",
198+
},
199+
body: JSON.stringify({ input: { prompt: "a" } }),
200+
});
201+
const envelopeA = await submitA.json();
202+
203+
const cross = await fetch(
204+
`${mock.url}/fal/fal-ai/flux/dev/requests/${envelopeA.request_id}/status`,
205+
{
206+
headers: { "x-fal-target-host": "queue.fal.run", "X-Test-Id": "B" },
207+
},
208+
);
209+
expect(cross.status).toBe(404);
210+
211+
const same = await fetch(
212+
`${mock.url}/fal/fal-ai/flux/dev/requests/${envelopeA.request_id}/status`,
213+
{
214+
headers: { "x-fal-target-host": "queue.fal.run", "X-Test-Id": "A" },
215+
},
216+
);
217+
expect(same.status).toBe(200);
218+
});
219+
220+
test("legacy /fal/queue/submit/{model} path still works for audio fixtures", async () => {
221+
mock = new LLMock({ port: 0 });
222+
mock.onFalAudio("drum", { audio: "SGVsbG8=", format: "mp3" });
223+
await mock.start();
224+
225+
const submit = await fetch(`${mock.url}/fal/queue/submit/fal-ai/stable-audio`, {
226+
method: "POST",
227+
headers: { "Content-Type": "application/json" },
228+
body: JSON.stringify({ prompt: "drum" }),
229+
});
230+
expect(submit.status).toBe(200);
231+
});
232+
});
233+
234+
describe("fal.ai general handler — record and replay", () => {
235+
let mock: LLMock;
236+
let upstream: { url: string; close: () => Promise<void> } | undefined;
237+
let tmpDir: string | undefined;
238+
239+
afterEach(async () => {
240+
await mock?.stop();
241+
await upstream?.close();
242+
upstream = undefined;
243+
if (tmpDir) {
244+
fs.rmSync(tmpDir, { recursive: true, force: true });
245+
tmpDir = undefined;
246+
}
247+
});
248+
249+
test("proxies unmatched fal request to upstream and saves fixture", async () => {
250+
upstream = await startStubUpstream(() => ({
251+
body: { images: [{ url: "https://example.com/recorded.png" }] },
252+
}));
253+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "aimock-fal-record-"));
254+
255+
mock = new LLMock({
256+
port: 0,
257+
record: { providers: { fal: upstream.url }, fixturePath: tmpDir },
258+
});
259+
await mock.start();
260+
261+
// In record mode the proxy is transparent — what upstream says is what
262+
// the client gets. Queue envelope synthesis only happens in fixture mode.
263+
const res = await fetch(`${mock.url}/fal/fal-ai/flux/dev`, {
264+
method: "POST",
265+
headers: { "Content-Type": "application/json", "x-fal-target-host": "queue.fal.run" },
266+
body: JSON.stringify({ input: { prompt: "a cat" } }),
267+
});
268+
expect(res.status).toBe(200);
269+
const body = await res.json();
270+
expect(body).toEqual({ images: [{ url: "https://example.com/recorded.png" }] });
271+
272+
const files = fs.readdirSync(tmpDir);
273+
const falFixtures = files.filter((f) => f.startsWith("fal-") && f.endsWith(".json"));
274+
expect(falFixtures.length).toBeGreaterThanOrEqual(1);
275+
276+
const recorded = JSON.parse(fs.readFileSync(path.join(tmpDir, falFixtures[0]), "utf-8"));
277+
expect(recorded.fixtures[0].match.endpoint).toBe("fal");
278+
expect(recorded.fixtures[0].response.json).toEqual({
279+
images: [{ url: "https://example.com/recorded.png" }],
280+
});
281+
});
282+
283+
test("replays from in-memory fixture on second identical request (no second proxy)", async () => {
284+
let upstreamCalls = 0;
285+
upstream = await startStubUpstream(() => {
286+
upstreamCalls++;
287+
return { body: { images: [{ url: "https://example.com/replay.png" }] } };
288+
});
289+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "aimock-fal-replay-"));
290+
291+
mock = new LLMock({
292+
port: 0,
293+
record: { providers: { fal: upstream.url }, fixturePath: tmpDir },
294+
});
295+
await mock.start();
296+
297+
// First call — hits upstream
298+
await fetch(`${mock.url}/fal/fal-ai/flux/dev`, {
299+
method: "POST",
300+
headers: { "Content-Type": "application/json", "x-fal-target-host": "queue.fal.run" },
301+
body: JSON.stringify({ input: { prompt: "a cat" } }),
302+
});
303+
expect(upstreamCalls).toBe(1);
304+
305+
// Second call — should match recorded fixture, no upstream hit
306+
const res2 = await fetch(`${mock.url}/fal/fal-ai/flux/dev`, {
307+
method: "POST",
308+
headers: { "Content-Type": "application/json", "x-fal-target-host": "queue.fal.run" },
309+
body: JSON.stringify({ input: { prompt: "a cat" } }),
310+
});
311+
expect(res2.status).toBe(200);
312+
expect(upstreamCalls).toBe(1);
313+
});
314+
});

src/fal-audio.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ export const falJobs = new FalJobMap();
7474

7575
// ─── Audio response translation ──────────────────────────────────────────
7676

77-
function audioToFalFile(response: AudioResponse): Record<string, unknown> {
77+
export function audioToFalFile(response: AudioResponse): Record<string, unknown> {
7878
let contentType: string;
7979
let data: string;
8080

0 commit comments

Comments
 (0)