From a05e2ac4eaea628fa0cc5c146ba4fe6f9924ff08 Mon Sep 17 00:00:00 2001 From: Adir Amsalem Date: Mon, 25 May 2026 15:35:28 +0300 Subject: [PATCH 1/7] feat(files): add client.files and image_ref support in realtime Adds a server-managed file upload surface backed by POST /v1/files in the bouncer. `client.files.upload(blob)` returns a `FileReference`; pass `ref.id` to `rt.set({ image })`, `setImage(...)`, or `initialState.image` to reuse the asset across realtime updates without re-uploading or base64'ing it every time. Realtime accepts the id as a plain string (callers persist the id in their own storage; the full metadata object is not required). Example: const ref = await client.files.upload(blob); await rt.set({ image: ref.id, prompt: "make it cinematic" }); await rt.set({ image: ref.id, prompt: "now in noir" }); // reused --- packages/sdk/package.json | 1 + packages/sdk/src/files/client.ts | 86 ++++++++++ packages/sdk/src/files/types.ts | 25 +++ packages/sdk/src/index.ts | 20 +++ packages/sdk/src/realtime/client.ts | 19 +- packages/sdk/src/realtime/methods.ts | 15 +- .../sdk/src/realtime/signaling-channel.ts | 24 ++- packages/sdk/src/realtime/stream-session.ts | 14 +- packages/sdk/src/realtime/types.ts | 9 +- packages/sdk/src/shared/types.ts | 6 + packages/sdk/src/utils/errors.ts | 3 + .../sdk/tests/e2e-realtime-image-ref.test.ts | 162 ++++++++++++++++++ packages/sdk/tests/realtime.unit.test.ts | 90 +++++++--- packages/sdk/tests/unit.test.ts | 116 +++++++++++++ packages/sdk/vitest.config.e2e-image-ref.ts | 17 ++ 15 files changed, 568 insertions(+), 39 deletions(-) create mode 100644 packages/sdk/src/files/client.ts create mode 100644 packages/sdk/src/files/types.ts create mode 100644 packages/sdk/tests/e2e-realtime-image-ref.test.ts create mode 100644 packages/sdk/vitest.config.e2e-image-ref.ts diff --git a/packages/sdk/package.json b/packages/sdk/package.json index a608e01a..6e704b6a 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -35,6 +35,7 @@ "test": "vitest unit", "test:e2e": "vitest e2e", "test:e2e:realtime": "vitest --config vitest.config.e2e-realtime.ts", + "test:e2e:image-ref": "vitest --config vitest.config.e2e-image-ref.ts", "typecheck": "tsc --noEmit", "format": "biome format --write", "format:check": "biome check", diff --git a/packages/sdk/src/files/client.ts b/packages/sdk/src/files/client.ts new file mode 100644 index 00000000..f538e7af --- /dev/null +++ b/packages/sdk/src/files/client.ts @@ -0,0 +1,86 @@ +import { buildAuthHeaders } from "../shared/request"; +import { createSDKError } from "../utils/errors"; +import type { FileReference, FileUploadInput } from "./types"; + +export type FilesClientOptions = { + baseUrl: string; + apiKey: string; + integration?: string; +}; + +export type UploadFileOptions = { + signal?: AbortSignal; +}; + +export type FilesClient = { + /** + * Upload a file once and get a reusable reference. Pass `ref.id` to + * realtime `set({ image })` to reuse the same asset across generations + * without re-uploading. + * + * @example + * ```ts + * const ref = await client.files.upload(blob); + * await rt.set({ image: ref.id, prompt: "make it cinematic" }); + * await rt.set({ image: ref.id, prompt: "now in noir" }); // reused, no re-upload + * ``` + */ + upload: (file: FileUploadInput, options?: UploadFileOptions) => Promise; + get: (fileId: string) => Promise; + delete: (fileId: string) => Promise; +}; + +export const createFilesClient = (opts: FilesClientOptions): FilesClient => { + const { baseUrl, apiKey, integration } = opts; + + const upload = async (file: FileUploadInput, options?: UploadFileOptions): Promise => { + const formData = new FormData(); + formData.append("file", file as Blob); + + const response = await fetch(`${baseUrl}/v1/files`, { + method: "POST", + headers: buildAuthHeaders({ apiKey, integration }), + body: formData, + signal: options?.signal, + }); + + if (!response.ok) { + const errorText = await response.text().catch(() => "Unknown error"); + throw createSDKError("FILES_UPLOAD_ERROR", `Failed to upload file: ${response.status} - ${errorText}`, { + status: response.status, + }); + } + return response.json(); + }; + + const get = async (fileId: string): Promise => { + const response = await fetch(`${baseUrl}/v1/files/${encodeURIComponent(fileId)}`, { + method: "GET", + headers: buildAuthHeaders({ apiKey, integration }), + }); + + if (!response.ok) { + const errorText = await response.text().catch(() => "Unknown error"); + throw createSDKError("FILES_GET_ERROR", `Failed to get file: ${response.status} - ${errorText}`, { + status: response.status, + }); + } + return response.json(); + }; + + const deleteFile = async (fileId: string): Promise => { + const response = await fetch(`${baseUrl}/v1/files/${encodeURIComponent(fileId)}`, { + method: "DELETE", + headers: buildAuthHeaders({ apiKey, integration }), + }); + + if (!response.ok && response.status !== 204) { + const errorText = await response.text().catch(() => "Unknown error"); + throw createSDKError("FILES_DELETE_ERROR", `Failed to delete file: ${response.status} - ${errorText}`, { + status: response.status, + }); + } + }; + + return { upload, get, delete: deleteFile }; +}; diff --git a/packages/sdk/src/files/types.ts b/packages/sdk/src/files/types.ts new file mode 100644 index 00000000..892e9138 --- /dev/null +++ b/packages/sdk/src/files/types.ts @@ -0,0 +1,25 @@ +import type { ReactNativeFile } from "../process/types"; + +/** Prefix on every uploaded-file id; disambiguates a ref string from base64. */ +export const FILE_REF_PREFIX = "file_"; + +/** True if `value` is a `"file_..."` reference id from `client.files.upload(...)`. */ +export const isFileRefId = (value: unknown): value is string => + typeof value === "string" && value.startsWith(FILE_REF_PREFIX); + +/** + * Metadata for a previously-uploaded file. Returned by `client.files.upload(...)`. + * Pass `ref.id` to `realtime.set({ image })` / `setImage(...)` to reuse it. + * + * Files expire after a server-configured TTL (currently 24 h). + */ +export interface FileReference { + id: string; + filename: string | null; + mime_type: string; + size_bytes: number; + created_at: string; + expires_at: string; +} + +export type FileUploadInput = File | Blob | ReactNativeFile; diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index b2444264..81b70535 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { createFilesClient } from "./files/client"; import { createProcessClient } from "./process/client"; import { createQueueClient } from "./queue/client"; import { createRealTimeClient } from "./realtime/client"; @@ -8,6 +9,8 @@ import { readEnv } from "./utils/env"; import { createInvalidApiKeyError, createInvalidBaseUrlError } from "./utils/errors"; import { createConsoleLogger, type Logger } from "./utils/logger"; +export type { FilesClient, UploadFileOptions } from "./files/client"; +export type { FileReference, FileUploadInput } from "./files/types"; export type { ProcessClient } from "./process/client"; export type { FileInput, ProcessOptions, ReactNativeFile } from "./process/types"; export type { QueueClient } from "./queue/client"; @@ -226,6 +229,12 @@ export const createDecartClient = (options: DecartClientOptions = {}) => { integration, }); + const files = createFilesClient({ + baseUrl, + apiKey: apiKey || "", + integration, + }); + return { realtime: { connect: realtimePublish.connect, @@ -305,5 +314,16 @@ export const createDecartClient = (options: DecartClientOptions = {}) => { * ``` */ tokens, + /** + * Upload files once and reuse them across generations. + * + * @example + * ```ts + * const ref = await client.files.upload(blob); + * await rt.set({ image: ref.id, prompt: "make it cinematic" }); + * await rt.set({ image: ref.id, prompt: "now in noir" }); + * ``` + */ + files, }; }; diff --git a/packages/sdk/src/realtime/client.ts b/packages/sdk/src/realtime/client.ts index 73be6b3c..a19396dc 100644 --- a/packages/sdk/src/realtime/client.ts +++ b/packages/sdk/src/realtime/client.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { isFileRefId } from "../files/types"; import { type CustomModelDefinition, type ModelDefinition, @@ -78,6 +79,12 @@ export type RealTimeClient = { sessionId: string | null; subscribeToken: string | null; getSubscribeToken: () => string | null; + /** + * Set the reference image for the session. + * - `Blob`/`File`/data URL/http(s) URL/base64 string: bytes traverse the wire as base64. + * - `"file_..."` id (from `client.files.upload(...).id`): sent as a server-side reference. + * - `null`: clear the current image. + */ setImage: (image: Blob | File | string | null, options?: ImageSetOptions) => Promise; }; @@ -117,7 +124,9 @@ export const createRealTimeClient = (opts: RealTimeClientOptions) => { let observability: RealtimeObservability | undefined; try { - const initialImage = initialState?.image ? await imageToBase64(initialState.image) : undefined; + const initialImageRef = isFileRefId(initialState?.image) ? initialState.image : undefined; + const initialImage = + initialImageRef === undefined && initialState?.image ? await imageToBase64(initialState.image) : undefined; const initialPrompt = initialState?.prompt ? { text: initialState.prompt.text, enhance: initialState.prompt.enhance } : undefined; @@ -151,6 +160,7 @@ export const createRealTimeClient = (opts: RealTimeClientOptions) => { observability, localStream: inputStream, initialImage, + initialImageRef, initialPrompt, logger, videoCodec: safariCodec, @@ -210,9 +220,12 @@ export const createRealTimeClient = (opts: RealTimeClientOptions) => { }, getSubscribeToken: () => subscribeToken, setImage: async (image: Blob | File | string | null, imgOptions?: ImageSetOptions) => { - if (image === null) return activeSession.setImage(null, imgOptions); + if (isFileRefId(image)) { + return activeSession.setImage({ kind: "ref", ref: image }, imgOptions); + } + if (image === null) return activeSession.setImage({ kind: "data", data: null }, imgOptions); const base64 = await imageToBase64(image); - return activeSession.setImage(base64, imgOptions); + return activeSession.setImage({ kind: "data", data: base64 }, imgOptions); }, }; diff --git a/packages/sdk/src/realtime/methods.ts b/packages/sdk/src/realtime/methods.ts index 8dd29db7..56a418f1 100644 --- a/packages/sdk/src/realtime/methods.ts +++ b/packages/sdk/src/realtime/methods.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { isFileRefId } from "../files/types"; import { REALTIME_CONFIG } from "./config-realtime"; import type { StreamSession } from "./stream-session"; import type { PromptSendOptions } from "./types"; @@ -7,6 +8,10 @@ const setInputSchema = z .object({ prompt: z.string().min(1).optional(), enhance: z.boolean().optional().default(true), + /** + * - `Blob`/`File`/data:/http(s):/base64 string: bytes traverse the wire as base64. + * - `"file_..."` id (from `client.files.upload(...).id`): sent as a server-side reference. + */ image: z.union([z.instanceof(Blob), z.instanceof(File), z.string(), z.null()]).optional(), }) .refine((data) => data.prompt !== undefined || data.image !== undefined, { @@ -29,9 +34,15 @@ export const realtimeMethods = ( if (!parsed.success) throw parsed.error; const { prompt, enhance, image } = parsed.data; - const imageBase64 = image !== undefined && image !== null ? await imageToBase64(image) : null; + const options = { prompt, enhance, timeout: REALTIME_CONFIG.methods.updateTimeoutMs }; + + if (isFileRefId(image)) { + await session.setImage({ kind: "ref", ref: image }, options); + return; + } - await session.setImage(imageBase64, { prompt, enhance, timeout: REALTIME_CONFIG.methods.updateTimeoutMs }); + const imageBase64 = image !== undefined && image !== null ? await imageToBase64(image) : null; + await session.setImage({ kind: "data", data: imageBase64 }, options); }; const setPrompt = async (prompt: string, { enhance }: PromptSendOptions = {}): Promise => { diff --git a/packages/sdk/src/realtime/signaling-channel.ts b/packages/sdk/src/realtime/signaling-channel.ts index 82216fe0..6b873e4a 100644 --- a/packages/sdk/src/realtime/signaling-channel.ts +++ b/packages/sdk/src/realtime/signaling-channel.ts @@ -17,6 +17,7 @@ import type { QueuePosition, ServerError, SetImageAckMessage, + SetImagePayload, } from "./types"; export type RoomInfo = { @@ -164,8 +165,11 @@ export class SignalingChannel { if (!ack.success) throw new Error(ack.error ?? "Failed to send prompt"); } - async setImage(image: string | null, opts: ImageSetOptions = {}): Promise { - const message: OutgoingRealtimeMessage = { type: "set_image", image_data: image }; + async setImage(payload: SetImagePayload, opts: ImageSetOptions = {}): Promise { + const message: OutgoingRealtimeMessage = + payload.kind === "ref" + ? { type: "set_image", image_ref: payload.ref } + : { type: "set_image", image_data: payload.data }; if (opts.prompt !== undefined) message.prompt = opts.prompt; if (opts.enhance !== undefined) message.enhance_prompt = opts.enhance; @@ -275,11 +279,19 @@ export class SignalingChannel { private async sendInitialState(initialState?: InitialState): Promise { if (!initialState) return; + if (initialState.imageRef !== undefined) { + await this.setImage( + { kind: "ref", ref: initialState.imageRef }, + { prompt: initialState.prompt, enhance: initialState.enhance }, + ); + return; + } + if (initialState.image !== undefined) { - await this.setImage(initialState.image, { - prompt: initialState.prompt, - enhance: initialState.enhance, - }); + await this.setImage( + { kind: "data", data: initialState.image }, + { prompt: initialState.prompt, enhance: initialState.enhance }, + ); return; } diff --git a/packages/sdk/src/realtime/stream-session.ts b/packages/sdk/src/realtime/stream-session.ts index b9249c99..014baee9 100644 --- a/packages/sdk/src/realtime/stream-session.ts +++ b/packages/sdk/src/realtime/stream-session.ts @@ -18,6 +18,7 @@ import type { PromptSendOptions, QueuePosition, SessionStarted, + SetImagePayload, } from "./types"; type RetryAttemptError = Error & { @@ -56,6 +57,7 @@ interface StreamSessionConfig { observability?: RealtimeObservability; localStream: MediaStream | null; initialImage?: string; + initialImageRef?: string; initialPrompt?: InitialPrompt; logger?: Logger; videoCodec?: VideoCodec; @@ -123,9 +125,9 @@ export class StreamSession { return this.signaling.sendPrompt(text, opts); } - async setImage(image: string | null, opts?: ImageSetOptions): Promise { + async setImage(payload: SetImagePayload, opts?: ImageSetOptions): Promise { this.assertConnected(); - return this.signaling.setImage(image, opts); + return this.signaling.setImage(payload, opts); } disconnect(): void { @@ -218,6 +220,14 @@ export class StreamSession { } private getInitialState(): InitialState | undefined { + if (this.config.initialImageRef !== undefined) { + return { + imageRef: this.config.initialImageRef, + prompt: this.config.initialPrompt?.text, + enhance: this.config.initialPrompt?.enhance, + }; + } + if (this.config.initialImage !== undefined) { return { image: this.config.initialImage, diff --git a/packages/sdk/src/realtime/types.ts b/packages/sdk/src/realtime/types.ts index d5f7776e..961800a6 100644 --- a/packages/sdk/src/realtime/types.ts +++ b/packages/sdk/src/realtime/types.ts @@ -16,13 +16,17 @@ export type ErrorMessage = { error: string; }; +/** Wire shape: one of `image_data` or `image_ref` is set, not both. */ export type SetImageMessage = { type: "set_image"; - image_data: string | null; + image_data?: string | null; + image_ref?: string; prompt?: string | null; enhance_prompt?: boolean; }; +export type SetImagePayload = { kind: "data"; data: string | null } | { kind: "ref"; ref: string }; + export type SetImageAckMessage = { type: "set_image_ack"; success: boolean; @@ -87,7 +91,10 @@ export type SessionStarted = { }; export type InitialState = { + /** Pre-encoded base64 image; one of image/imageRef. */ image?: string | null; + /** Server file reference id; one of image/imageRef. */ + imageRef?: string; prompt?: string | null; enhance?: boolean; }; diff --git a/packages/sdk/src/shared/types.ts b/packages/sdk/src/shared/types.ts index 37f7d0a0..2ae8d7f4 100644 --- a/packages/sdk/src/shared/types.ts +++ b/packages/sdk/src/shared/types.ts @@ -7,6 +7,12 @@ export const modelStateSchema = z.object({ enhance: z.boolean().optional().default(true), }) .optional(), + /** + * Initial image for the session. Pass either bytes (`Blob`/`File`/data URL/ + * http(s) URL/base64 string) or a `"file_..."` id returned by + * `client.files.upload(...)`.id — the SDK detects the prefix and sends a + * server-side reference instead of base64. + */ image: z.union([z.instanceof(Blob), z.instanceof(File), z.string()]).optional(), }); export type ModelState = z.infer; diff --git a/packages/sdk/src/utils/errors.ts b/packages/sdk/src/utils/errors.ts index a8cecd05..c41c2870 100644 --- a/packages/sdk/src/utils/errors.ts +++ b/packages/sdk/src/utils/errors.ts @@ -17,6 +17,9 @@ export const ERROR_CODES = { QUEUE_RESULT_ERROR: "QUEUE_RESULT_ERROR", JOB_NOT_COMPLETED: "JOB_NOT_COMPLETED", TOKEN_CREATE_ERROR: "TOKEN_CREATE_ERROR", + FILES_UPLOAD_ERROR: "FILES_UPLOAD_ERROR", + FILES_GET_ERROR: "FILES_GET_ERROR", + FILES_DELETE_ERROR: "FILES_DELETE_ERROR", // WebRTC-specific error codes WEBRTC_WEBSOCKET_ERROR: "WEBRTC_WEBSOCKET_ERROR", WEBRTC_ICE_ERROR: "WEBRTC_ICE_ERROR", diff --git a/packages/sdk/tests/e2e-realtime-image-ref.test.ts b/packages/sdk/tests/e2e-realtime-image-ref.test.ts new file mode 100644 index 00000000..380b2923 --- /dev/null +++ b/packages/sdk/tests/e2e-realtime-image-ref.test.ts @@ -0,0 +1,162 @@ +/** + * E2E for the new file-upload + image_ref realtime flow against a local bouncer. + * + * Prereqs: + * - bouncer running locally on http://127.0.0.1:8000 with the new /v1/files + * endpoints and `image_ref` support in set_image + * - DECART_API_KEY set to an enabled apikey row for adir@decart.ai + * + * Run: + * DECART_API_KEY= pnpm test:e2e:image-ref + */ + +declare const __DECART_API_KEY__: string; + +import { createDecartClient, type DecartSDKError, models } from "@decartai/sdk"; +import { beforeAll, describe, expect, it } from "vitest"; + +const LOCAL_HTTP = "http://127.0.0.1:8000"; +const LOCAL_WS = "ws://127.0.0.1:8000"; + +function createSyntheticStream(width: number, height: number) { + const canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext("2d"); + if (!ctx) throw new Error("2D canvas context unavailable"); + + let frame = 0; + const draw = () => { + ctx.fillStyle = `hsl(${(frame * 5) % 360}, 80%, 45%)`; + ctx.fillRect(0, 0, width, height); + frame += 1; + }; + draw(); + const intervalId = window.setInterval(draw, 1000 / 30); + const stream = canvas.captureStream(30); + return { + stream, + stop: () => { + window.clearInterval(intervalId); + for (const track of stream.getTracks()) track.stop(); + }, + }; +} + +function pngBlob(width: number, height: number, color = "#3070C0"): Promise { + const canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext("2d"); + if (!ctx) throw new Error("2D canvas context unavailable"); + ctx.fillStyle = color; + ctx.fillRect(0, 0, width, height); + return new Promise((resolve, reject) => { + canvas.toBlob((blob) => (blob ? resolve(blob) : reject(new Error("toBlob failed"))), "image/png"); + }); +} + +function waitUntil(condition: () => boolean, message: string, timeoutMs = 30_000): Promise { + if (condition()) return Promise.resolve(); + return new Promise((resolve, reject) => { + const intervalId = window.setInterval(() => { + if (!condition()) return; + window.clearInterval(intervalId); + window.clearTimeout(timeoutId); + resolve(); + }, 50); + const timeoutId = window.setTimeout(() => { + window.clearInterval(intervalId); + reject(new Error(message)); + }, timeoutMs); + }); +} + +describe("Files API + realtime image_ref (against local bouncer)", { timeout: 60_000 }, () => { + let client: ReturnType; + + beforeAll(() => { + const apiKey = __DECART_API_KEY__; + if (!apiKey) { + throw new Error("Set DECART_API_KEY to run this test"); + } + client = createDecartClient({ apiKey, baseUrl: LOCAL_HTTP, realtimeBaseUrl: LOCAL_WS }); + }); + + it("uploads a file, gets a reference, and reads it back", async () => { + const blob = await pngBlob(96, 96); + + const ref = await client.files.upload(blob); + expect(ref.id).toMatch(/^file_/); + expect(ref.mime_type).toBe("image/png"); + expect(ref.size_bytes).toBeGreaterThan(0); + + const fetched = await client.files.get(ref.id); + expect(fetched.id).toBe(ref.id); + + await client.files.delete(ref.id); + await expect(client.files.get(ref.id)).rejects.toThrow(/Failed to get file/); + }); + + it("opens realtime with initialState.image set to a file reference id", async () => { + const model = models.realtime("lucy-2.1"); + const blob = await pngBlob(model.width, model.height); + const ref = await client.files.upload(blob); + + const synthetic = createSyntheticStream(model.width, model.height); + let remoteStreamReceived = false; + let realtimeClient: Awaited> | undefined; + const errors: DecartSDKError[] = []; + + try { + realtimeClient = await client.realtime.connect(synthetic.stream, { + model, + initialState: { image: ref.id, prompt: { text: "make it cinematic", enhance: false } }, + onRemoteStream: () => { + remoteStreamReceived = true; + }, + }); + + realtimeClient.on("error", (err) => errors.push(err)); + + expect(["connected", "generating"]).toContain(realtimeClient.getConnectionState()); + expect(realtimeClient.sessionId).toBeTruthy(); + + await waitUntil(() => remoteStreamReceived, "Timed out waiting for remote stream from upstream"); + expect(errors).toEqual([]); + } finally { + realtimeClient?.disconnect(); + synthetic.stop(); + try { + await client.files.delete(ref.id); + } catch { + // best-effort cleanup + } + } + }); + + it("rt.set({ image: ref.id }) swaps the reference image mid-session", async () => { + const model = models.realtime("lucy-2.1"); + const blobA = await pngBlob(model.width, model.height, "#3070C0"); + const blobB = await pngBlob(model.width, model.height, "#C03070"); + const refA = await client.files.upload(blobA); + const refB = await client.files.upload(blobB); + + const synthetic = createSyntheticStream(model.width, model.height); + let realtimeClient: Awaited> | undefined; + + try { + realtimeClient = await client.realtime.connect(synthetic.stream, { + model, + initialState: { image: refA.id, prompt: { text: "anime style", enhance: false } }, + onRemoteStream: () => {}, + }); + + await realtimeClient.set({ image: refB.id, prompt: "noir" }); + } finally { + realtimeClient?.disconnect(); + synthetic.stop(); + await Promise.allSettled([client.files.delete(refA.id), client.files.delete(refB.id)]); + } + }); +}); diff --git a/packages/sdk/tests/realtime.unit.test.ts b/packages/sdk/tests/realtime.unit.test.ts index deee25c1..270d1f44 100644 --- a/packages/sdk/tests/realtime.unit.test.ts +++ b/packages/sdk/tests/realtime.unit.test.ts @@ -207,20 +207,26 @@ describe("set()", () => { it("sends only prompt when no image provided", async () => { await methods.set({ prompt: "a cat" }); - expect(mockSession.setImage).toHaveBeenCalledWith(null, { - prompt: "a cat", - enhance: true, - timeout: REALTIME_CONFIG.methods.updateTimeoutMs, - }); + expect(mockSession.setImage).toHaveBeenCalledWith( + { kind: "data", data: null }, + { + prompt: "a cat", + enhance: true, + timeout: REALTIME_CONFIG.methods.updateTimeoutMs, + }, + ); }); it("sends prompt with enhance flag", async () => { await methods.set({ prompt: "a cat", enhance: true }); - expect(mockSession.setImage).toHaveBeenCalledWith(null, { - prompt: "a cat", - enhance: true, - timeout: REALTIME_CONFIG.methods.updateTimeoutMs, - }); + expect(mockSession.setImage).toHaveBeenCalledWith( + { kind: "data", data: null }, + { + prompt: "a cat", + enhance: true, + timeout: REALTIME_CONFIG.methods.updateTimeoutMs, + }, + ); }); it("sends only image when no prompt provided", async () => { @@ -228,22 +234,28 @@ describe("set()", () => { await methods.set({ image: "rawbase64data" }); expect(mockImageToBase64).toHaveBeenCalledWith("rawbase64data"); - expect(mockSession.setImage).toHaveBeenCalledWith("convertedbase64", { - prompt: undefined, - enhance: true, - timeout: REALTIME_CONFIG.methods.updateTimeoutMs, - }); + expect(mockSession.setImage).toHaveBeenCalledWith( + { kind: "data", data: "convertedbase64" }, + { + prompt: undefined, + enhance: true, + timeout: REALTIME_CONFIG.methods.updateTimeoutMs, + }, + ); }); it("sends prompt and image together", async () => { mockImageToBase64.mockResolvedValue("convertedbase64"); await methods.set({ prompt: "a cat", enhance: false, image: "rawbase64" }); - expect(mockSession.setImage).toHaveBeenCalledWith("convertedbase64", { - prompt: "a cat", - enhance: false, - timeout: REALTIME_CONFIG.methods.updateTimeoutMs, - }); + expect(mockSession.setImage).toHaveBeenCalledWith( + { kind: "data", data: "convertedbase64" }, + { + prompt: "a cat", + enhance: false, + timeout: REALTIME_CONFIG.methods.updateTimeoutMs, + }, + ); }); it("converts Blob image to base64", async () => { @@ -252,11 +264,39 @@ describe("set()", () => { await methods.set({ image: testBlob }); expect(mockImageToBase64).toHaveBeenCalledWith(testBlob); - expect(mockSession.setImage).toHaveBeenCalledWith("blobbase64", { - prompt: undefined, - enhance: true, - timeout: REALTIME_CONFIG.methods.updateTimeoutMs, - }); + expect(mockSession.setImage).toHaveBeenCalledWith( + { kind: "data", data: "blobbase64" }, + { + prompt: undefined, + enhance: true, + timeout: REALTIME_CONFIG.methods.updateTimeoutMs, + }, + ); + }); + + it("treats a 'file_*' string as a server-side reference id, no base64 encoding", async () => { + await methods.set({ image: "file_abc123", prompt: "make it cinematic" }); + + expect(mockImageToBase64).not.toHaveBeenCalled(); + expect(mockSession.setImage).toHaveBeenCalledWith( + { kind: "ref", ref: "file_abc123" }, + { + prompt: "make it cinematic", + enhance: true, + timeout: REALTIME_CONFIG.methods.updateTimeoutMs, + }, + ); + }); + + it("still treats non-'file_' strings as base64/URL inputs (encoded via imageToBase64)", async () => { + mockImageToBase64.mockResolvedValue("convertedbase64"); + await methods.set({ image: "rawbase64data" }); + + expect(mockImageToBase64).toHaveBeenCalledWith("rawbase64data"); + expect(mockSession.setImage).toHaveBeenCalledWith( + { kind: "data", data: "convertedbase64" }, + expect.objectContaining({ timeout: REALTIME_CONFIG.methods.updateTimeoutMs }), + ); }); }); diff --git a/packages/sdk/tests/unit.test.ts b/packages/sdk/tests/unit.test.ts index afa6c8bb..c68e6977 100644 --- a/packages/sdk/tests/unit.test.ts +++ b/packages/sdk/tests/unit.test.ts @@ -1131,6 +1131,122 @@ describe("Tokens API", () => { }); }); +describe("Files API", () => { + let decart: ReturnType; + let lastRequest: Request | null = null; + const server = setupServer(); + + beforeAll(() => { + server.listen({ onUnhandledRequest: "error" }); + }); + + afterAll(() => { + server.close(); + }); + + beforeEach(() => { + lastRequest = null; + decart = createDecartClient({ + apiKey: "test-api-key", + baseUrl: "http://localhost", + }); + }); + + afterEach(() => { + server.resetHandlers(); + }); + + describe("upload", () => { + it("uploads a Blob and returns a FileReference", async () => { + server.use( + http.post("http://localhost/v1/files", async ({ request }) => { + lastRequest = request; + return HttpResponse.json({ + id: "file_abc123", + filename: "blob", + mime_type: "image/png", + size_bytes: 4, + created_at: "2026-01-01T00:00:00Z", + expires_at: "2026-01-02T00:00:00Z", + }); + }), + ); + + const blob = new Blob([new Uint8Array([0, 1, 2, 3])], { type: "image/png" }); + const ref = await decart.files.upload(blob); + + expect(ref.id).toBe("file_abc123"); + expect(ref.mime_type).toBe("image/png"); + expect(lastRequest?.headers.get("x-api-key")).toBe("test-api-key"); + // Content-Type starts with multipart/form-data when uploading a Blob + expect(lastRequest?.headers.get("content-type") ?? "").toContain("multipart/form-data"); + }); + + it("throws on non-2xx upload", async () => { + server.use( + http.post("http://localhost/v1/files", () => { + return HttpResponse.json({ detail: "Too big" }, { status: 413 }); + }), + ); + + await expect(decart.files.upload(new Blob(["x"]))).rejects.toThrow("Failed to upload file"); + }); + }); + + describe("get", () => { + it("fetches metadata for a file id", async () => { + server.use( + http.get("http://localhost/v1/files/file_abc123", () => { + return HttpResponse.json({ + id: "file_abc123", + filename: "portrait.png", + mime_type: "image/png", + size_bytes: 1234, + created_at: "2026-01-01T00:00:00Z", + expires_at: "2026-01-02T00:00:00Z", + }); + }), + ); + + const ref = await decart.files.get("file_abc123"); + expect(ref.id).toBe("file_abc123"); + expect(ref.filename).toBe("portrait.png"); + }); + + it("throws on 404", async () => { + server.use( + http.get("http://localhost/v1/files/file_missing", () => { + return HttpResponse.json({ detail: "File not found" }, { status: 404 }); + }), + ); + + await expect(decart.files.get("file_missing")).rejects.toThrow("Failed to get file"); + }); + }); + + describe("delete", () => { + it("resolves on 204", async () => { + server.use( + http.delete("http://localhost/v1/files/file_abc123", () => { + return new HttpResponse(null, { status: 204 }); + }), + ); + + await expect(decart.files.delete("file_abc123")).resolves.toBeUndefined(); + }); + + it("throws on non-2xx", async () => { + server.use( + http.delete("http://localhost/v1/files/file_missing", () => { + return HttpResponse.json({ detail: "File not found" }, { status: 404 }); + }), + ); + + await expect(decart.files.delete("file_missing")).rejects.toThrow("Failed to delete file"); + }); + }); +}); + describe("Logger", () => { it("noopLogger does nothing", async () => { const { noopLogger } = await import("../src/utils/logger.js"); diff --git a/packages/sdk/vitest.config.e2e-image-ref.ts b/packages/sdk/vitest.config.e2e-image-ref.ts new file mode 100644 index 00000000..3770c9e2 --- /dev/null +++ b/packages/sdk/vitest.config.e2e-image-ref.ts @@ -0,0 +1,17 @@ +import { playwright } from "@vitest/browser-playwright"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + define: { + __DECART_API_KEY__: JSON.stringify(process.env.DECART_API_KEY), + }, + test: { + include: ["tests/e2e-realtime-image-ref.test.ts"], + browser: { + enabled: true, + provider: playwright(), + headless: true, + instances: [{ browser: "chromium" }], + }, + }, +}); From 5534fd177024adcc85ab51b80ffa392df2d87aa5 Mon Sep 17 00:00:00 2001 From: Adir Amsalem Date: Mon, 25 May 2026 15:42:57 +0300 Subject: [PATCH 2/7] test: fold image_ref e2e cases into the existing realtime suite --- packages/sdk/package.json | 1 - .../sdk/tests/e2e-realtime-image-ref.test.ts | 162 ------------------ packages/sdk/tests/e2e-realtime.test.ts | 81 +++++++++ packages/sdk/vitest.config.e2e-image-ref.ts | 17 -- 4 files changed, 81 insertions(+), 180 deletions(-) delete mode 100644 packages/sdk/tests/e2e-realtime-image-ref.test.ts delete mode 100644 packages/sdk/vitest.config.e2e-image-ref.ts diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 6e704b6a..a608e01a 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -35,7 +35,6 @@ "test": "vitest unit", "test:e2e": "vitest e2e", "test:e2e:realtime": "vitest --config vitest.config.e2e-realtime.ts", - "test:e2e:image-ref": "vitest --config vitest.config.e2e-image-ref.ts", "typecheck": "tsc --noEmit", "format": "biome format --write", "format:check": "biome check", diff --git a/packages/sdk/tests/e2e-realtime-image-ref.test.ts b/packages/sdk/tests/e2e-realtime-image-ref.test.ts deleted file mode 100644 index 380b2923..00000000 --- a/packages/sdk/tests/e2e-realtime-image-ref.test.ts +++ /dev/null @@ -1,162 +0,0 @@ -/** - * E2E for the new file-upload + image_ref realtime flow against a local bouncer. - * - * Prereqs: - * - bouncer running locally on http://127.0.0.1:8000 with the new /v1/files - * endpoints and `image_ref` support in set_image - * - DECART_API_KEY set to an enabled apikey row for adir@decart.ai - * - * Run: - * DECART_API_KEY= pnpm test:e2e:image-ref - */ - -declare const __DECART_API_KEY__: string; - -import { createDecartClient, type DecartSDKError, models } from "@decartai/sdk"; -import { beforeAll, describe, expect, it } from "vitest"; - -const LOCAL_HTTP = "http://127.0.0.1:8000"; -const LOCAL_WS = "ws://127.0.0.1:8000"; - -function createSyntheticStream(width: number, height: number) { - const canvas = document.createElement("canvas"); - canvas.width = width; - canvas.height = height; - const ctx = canvas.getContext("2d"); - if (!ctx) throw new Error("2D canvas context unavailable"); - - let frame = 0; - const draw = () => { - ctx.fillStyle = `hsl(${(frame * 5) % 360}, 80%, 45%)`; - ctx.fillRect(0, 0, width, height); - frame += 1; - }; - draw(); - const intervalId = window.setInterval(draw, 1000 / 30); - const stream = canvas.captureStream(30); - return { - stream, - stop: () => { - window.clearInterval(intervalId); - for (const track of stream.getTracks()) track.stop(); - }, - }; -} - -function pngBlob(width: number, height: number, color = "#3070C0"): Promise { - const canvas = document.createElement("canvas"); - canvas.width = width; - canvas.height = height; - const ctx = canvas.getContext("2d"); - if (!ctx) throw new Error("2D canvas context unavailable"); - ctx.fillStyle = color; - ctx.fillRect(0, 0, width, height); - return new Promise((resolve, reject) => { - canvas.toBlob((blob) => (blob ? resolve(blob) : reject(new Error("toBlob failed"))), "image/png"); - }); -} - -function waitUntil(condition: () => boolean, message: string, timeoutMs = 30_000): Promise { - if (condition()) return Promise.resolve(); - return new Promise((resolve, reject) => { - const intervalId = window.setInterval(() => { - if (!condition()) return; - window.clearInterval(intervalId); - window.clearTimeout(timeoutId); - resolve(); - }, 50); - const timeoutId = window.setTimeout(() => { - window.clearInterval(intervalId); - reject(new Error(message)); - }, timeoutMs); - }); -} - -describe("Files API + realtime image_ref (against local bouncer)", { timeout: 60_000 }, () => { - let client: ReturnType; - - beforeAll(() => { - const apiKey = __DECART_API_KEY__; - if (!apiKey) { - throw new Error("Set DECART_API_KEY to run this test"); - } - client = createDecartClient({ apiKey, baseUrl: LOCAL_HTTP, realtimeBaseUrl: LOCAL_WS }); - }); - - it("uploads a file, gets a reference, and reads it back", async () => { - const blob = await pngBlob(96, 96); - - const ref = await client.files.upload(blob); - expect(ref.id).toMatch(/^file_/); - expect(ref.mime_type).toBe("image/png"); - expect(ref.size_bytes).toBeGreaterThan(0); - - const fetched = await client.files.get(ref.id); - expect(fetched.id).toBe(ref.id); - - await client.files.delete(ref.id); - await expect(client.files.get(ref.id)).rejects.toThrow(/Failed to get file/); - }); - - it("opens realtime with initialState.image set to a file reference id", async () => { - const model = models.realtime("lucy-2.1"); - const blob = await pngBlob(model.width, model.height); - const ref = await client.files.upload(blob); - - const synthetic = createSyntheticStream(model.width, model.height); - let remoteStreamReceived = false; - let realtimeClient: Awaited> | undefined; - const errors: DecartSDKError[] = []; - - try { - realtimeClient = await client.realtime.connect(synthetic.stream, { - model, - initialState: { image: ref.id, prompt: { text: "make it cinematic", enhance: false } }, - onRemoteStream: () => { - remoteStreamReceived = true; - }, - }); - - realtimeClient.on("error", (err) => errors.push(err)); - - expect(["connected", "generating"]).toContain(realtimeClient.getConnectionState()); - expect(realtimeClient.sessionId).toBeTruthy(); - - await waitUntil(() => remoteStreamReceived, "Timed out waiting for remote stream from upstream"); - expect(errors).toEqual([]); - } finally { - realtimeClient?.disconnect(); - synthetic.stop(); - try { - await client.files.delete(ref.id); - } catch { - // best-effort cleanup - } - } - }); - - it("rt.set({ image: ref.id }) swaps the reference image mid-session", async () => { - const model = models.realtime("lucy-2.1"); - const blobA = await pngBlob(model.width, model.height, "#3070C0"); - const blobB = await pngBlob(model.width, model.height, "#C03070"); - const refA = await client.files.upload(blobA); - const refB = await client.files.upload(blobB); - - const synthetic = createSyntheticStream(model.width, model.height); - let realtimeClient: Awaited> | undefined; - - try { - realtimeClient = await client.realtime.connect(synthetic.stream, { - model, - initialState: { image: refA.id, prompt: { text: "anime style", enhance: false } }, - onRemoteStream: () => {}, - }); - - await realtimeClient.set({ image: refB.id, prompt: "noir" }); - } finally { - realtimeClient?.disconnect(); - synthetic.stop(); - await Promise.allSettled([client.files.delete(refA.id), client.files.delete(refB.id)]); - } - }); -}); diff --git a/packages/sdk/tests/e2e-realtime.test.ts b/packages/sdk/tests/e2e-realtime.test.ts index a93737d0..5aaa074c 100644 --- a/packages/sdk/tests/e2e-realtime.test.ts +++ b/packages/sdk/tests/e2e-realtime.test.ts @@ -118,4 +118,85 @@ describe.concurrent("Realtime E2E Tests", { timeout: TIMEOUT, retry: 2 }, () => expect(realtimeClient?.getConnectionState()).toBe("disconnected"); }); } + + // POST /v1/files + image_ref via realtime. Uses lucy-2.1 to actually exercise + // upstream forwarding through the bouncer's image_ref prologue. + describe("Files API + image_ref", () => { + async function pngBlob(width: number, height: number, color = "#3070C0"): Promise { + const canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext("2d"); + if (!ctx) throw new Error("2D canvas context unavailable"); + ctx.fillStyle = color; + ctx.fillRect(0, 0, width, height); + return new Promise((resolve, reject) => { + canvas.toBlob((blob) => (blob ? resolve(blob) : reject(new Error("toBlob failed"))), "image/png"); + }); + } + + it("upload → get → delete round-trip", async () => { + const ref = await client.files.upload(await pngBlob(96, 96)); + expect(ref.id).toMatch(/^file_/); + expect(ref.mime_type).toBe("image/png"); + + const fetched = await client.files.get(ref.id); + expect(fetched.id).toBe(ref.id); + + await client.files.delete(ref.id); + await expect(client.files.get(ref.id)).rejects.toThrow(/Failed to get file/); + }); + + it("connects with initialState.image = ref.id", async () => { + const model = models.realtime("lucy-2.1"); + const ref = await client.files.upload(await pngBlob(model.width, model.height)); + const syntheticStream = createSyntheticStream(model.width, model.height); + + let remoteStreamReceived = false; + let realtimeClient: Awaited> | undefined; + const errors: DecartSDKError[] = []; + + try { + realtimeClient = await client.realtime.connect(syntheticStream.stream, { + model, + initialState: { image: ref.id, prompt: { text: "make it cinematic", enhance: false } }, + onRemoteStream: () => { + remoteStreamReceived = true; + }, + }); + realtimeClient.on("error", (err) => errors.push(err)); + + expect(["connected", "generating"]).toContain(realtimeClient.getConnectionState()); + await waitUntil(() => remoteStreamReceived, "Timed out waiting for remote stream via image_ref"); + expect(errors).toEqual([]); + } finally { + realtimeClient?.disconnect(); + syntheticStream.stop(); + await client.files.delete(ref.id).catch(() => {}); + } + }); + + it("rt.set({ image: ref.id }) swaps the reference image mid-session", async () => { + const model = models.realtime("lucy-2.1"); + const [refA, refB] = await Promise.all([ + client.files.upload(await pngBlob(model.width, model.height, "#3070C0")), + client.files.upload(await pngBlob(model.width, model.height, "#C03070")), + ]); + const syntheticStream = createSyntheticStream(model.width, model.height); + let realtimeClient: Awaited> | undefined; + + try { + realtimeClient = await client.realtime.connect(syntheticStream.stream, { + model, + initialState: { image: refA.id, prompt: { text: "anime style", enhance: false } }, + onRemoteStream: () => {}, + }); + await realtimeClient.set({ image: refB.id, prompt: "noir" }); + } finally { + realtimeClient?.disconnect(); + syntheticStream.stop(); + await Promise.allSettled([client.files.delete(refA.id), client.files.delete(refB.id)]); + } + }); + }); }); diff --git a/packages/sdk/vitest.config.e2e-image-ref.ts b/packages/sdk/vitest.config.e2e-image-ref.ts deleted file mode 100644 index 3770c9e2..00000000 --- a/packages/sdk/vitest.config.e2e-image-ref.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { playwright } from "@vitest/browser-playwright"; -import { defineConfig } from "vitest/config"; - -export default defineConfig({ - define: { - __DECART_API_KEY__: JSON.stringify(process.env.DECART_API_KEY), - }, - test: { - include: ["tests/e2e-realtime-image-ref.test.ts"], - browser: { - enabled: true, - provider: playwright(), - headless: true, - instances: [{ browser: "chromium" }], - }, - }, -}); From 87528a0415bcd1b4ae59b61652b063160284fddd Mon Sep 17 00:00:00 2001 From: Adir Amsalem Date: Mon, 25 May 2026 15:55:21 +0300 Subject: [PATCH 3/7] fix(files): drop redundant status check in delete Bugbot review: response.ok already covers 204, so the extra status !== 204 branch is dead. Simplifies to !response.ok. --- packages/sdk/src/files/client.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/sdk/src/files/client.ts b/packages/sdk/src/files/client.ts index f538e7af..f26bb9da 100644 --- a/packages/sdk/src/files/client.ts +++ b/packages/sdk/src/files/client.ts @@ -74,7 +74,7 @@ export const createFilesClient = (opts: FilesClientOptions): FilesClient => { headers: buildAuthHeaders({ apiKey, integration }), }); - if (!response.ok && response.status !== 204) { + if (!response.ok) { const errorText = await response.text().catch(() => "Unknown error"); throw createSDKError("FILES_DELETE_ERROR", `Failed to delete file: ${response.status} - ${errorText}`, { status: response.status, From 4edc6a2074243a11fa0eac94b9623c810da0fa75 Mon Sep 17 00:00:00 2001 From: Adir Amsalem Date: Mon, 25 May 2026 16:45:52 +0300 Subject: [PATCH 4/7] feat(files): ttlSeconds option (number | "persistent") on upload Mirrors the bouncer's new ttl_seconds field. FileReference.expires_at is now string | null since persistent uploads have no expiry. --- packages/sdk/src/files/client.ts | 8 +++++++ packages/sdk/src/files/types.ts | 5 ++-- packages/sdk/tests/unit.test.ts | 39 ++++++++++++++++++++++++++++++++ 3 files changed, 50 insertions(+), 2 deletions(-) diff --git a/packages/sdk/src/files/client.ts b/packages/sdk/src/files/client.ts index f26bb9da..8a2f6484 100644 --- a/packages/sdk/src/files/client.ts +++ b/packages/sdk/src/files/client.ts @@ -10,6 +10,13 @@ export type FilesClientOptions = { export type UploadFileOptions = { signal?: AbortSignal; + /** + * Expiration: + * - omit → platform default (24 h) + * - a positive integer → TTL in seconds (60 .. 2_592_000) + * - `"persistent"` → never expires + */ + ttlSeconds?: number | "persistent"; }; export type FilesClient = { @@ -36,6 +43,7 @@ export const createFilesClient = (opts: FilesClientOptions): FilesClient => { const upload = async (file: FileUploadInput, options?: UploadFileOptions): Promise => { const formData = new FormData(); formData.append("file", file as Blob); + if (options?.ttlSeconds !== undefined) formData.append("ttl_seconds", String(options.ttlSeconds)); const response = await fetch(`${baseUrl}/v1/files`, { method: "POST", diff --git a/packages/sdk/src/files/types.ts b/packages/sdk/src/files/types.ts index 892e9138..c3f7d210 100644 --- a/packages/sdk/src/files/types.ts +++ b/packages/sdk/src/files/types.ts @@ -11,7 +11,8 @@ export const isFileRefId = (value: unknown): value is string => * Metadata for a previously-uploaded file. Returned by `client.files.upload(...)`. * Pass `ref.id` to `realtime.set({ image })` / `setImage(...)` to reuse it. * - * Files expire after a server-configured TTL (currently 24 h). + * Files expire after a server-configured TTL (default 24 h). `expires_at` is + * `null` when the upload was created with `persistent: true`. */ export interface FileReference { id: string; @@ -19,7 +20,7 @@ export interface FileReference { mime_type: string; size_bytes: number; created_at: string; - expires_at: string; + expires_at: string | null; } export type FileUploadInput = File | Blob | ReactNativeFile; diff --git a/packages/sdk/tests/unit.test.ts b/packages/sdk/tests/unit.test.ts index c68e6977..27748d20 100644 --- a/packages/sdk/tests/unit.test.ts +++ b/packages/sdk/tests/unit.test.ts @@ -1191,6 +1191,45 @@ describe("Files API", () => { await expect(decart.files.upload(new Blob(["x"]))).rejects.toThrow("Failed to upload file"); }); + + it("forwards a numeric ttlSeconds", async () => { + let receivedForm: FormData | null = null; + server.use( + http.post("http://localhost/v1/files", async ({ request }) => { + receivedForm = await request.formData(); + return HttpResponse.json({ + id: "file_abc", + filename: null, + mime_type: "image/png", + size_bytes: 1, + created_at: "2026-01-01T00:00:00Z", + expires_at: "2026-01-01T01:00:00Z", + }); + }), + ); + await decart.files.upload(new Blob(["x"]), { ttlSeconds: 3600 }); + expect(receivedForm?.get("ttl_seconds")).toBe("3600"); + }); + + it('forwards ttlSeconds="persistent" verbatim', async () => { + let receivedForm: FormData | null = null; + server.use( + http.post("http://localhost/v1/files", async ({ request }) => { + receivedForm = await request.formData(); + return HttpResponse.json({ + id: "file_abc", + filename: null, + mime_type: "image/png", + size_bytes: 1, + created_at: "2026-01-01T00:00:00Z", + expires_at: null, + }); + }), + ); + const ref = await decart.files.upload(new Blob(["x"]), { ttlSeconds: "persistent" }); + expect(receivedForm?.get("ttl_seconds")).toBe("persistent"); + expect(ref.expires_at).toBeNull(); + }); }); describe("get", () => { From 8b000c35e4fa5ecdc569331c923ee7165695b27f Mon Sep 17 00:00:00 2001 From: Adir Amsalem Date: Tue, 26 May 2026 08:49:01 +0300 Subject: [PATCH 5/7] feat(files): validate ttlSeconds locally with zod Catches "forever" / -1 / out-of-range values at the call site instead of after a round-trip. Matches the pattern in realtime/methods.ts where client-side input is parsed with zod before being sent. --- packages/sdk/src/files/client.ts | 16 +++++++++++++++- packages/sdk/tests/unit.test.ts | 21 +++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/packages/sdk/src/files/client.ts b/packages/sdk/src/files/client.ts index 8a2f6484..8345ceb2 100644 --- a/packages/sdk/src/files/client.ts +++ b/packages/sdk/src/files/client.ts @@ -1,7 +1,12 @@ +import { z } from "zod"; import { buildAuthHeaders } from "../shared/request"; -import { createSDKError } from "../utils/errors"; +import { createInvalidInputError, createSDKError } from "../utils/errors"; import type { FileReference, FileUploadInput } from "./types"; +const MAX_TTL_SECONDS = 30 * 24 * 60 * 60; // 30 days; matches the bouncer's ceiling. + +const ttlSecondsSchema = z.union([z.number().int().min(60).max(MAX_TTL_SECONDS), z.literal("persistent")]); + export type FilesClientOptions = { baseUrl: string; apiKey: string; @@ -41,6 +46,15 @@ export const createFilesClient = (opts: FilesClientOptions): FilesClient => { const { baseUrl, apiKey, integration } = opts; const upload = async (file: FileUploadInput, options?: UploadFileOptions): Promise => { + if (options?.ttlSeconds !== undefined) { + const parsed = ttlSecondsSchema.safeParse(options.ttlSeconds); + if (!parsed.success) { + throw createInvalidInputError( + `ttlSeconds must be an integer in [60, ${MAX_TTL_SECONDS}] or the literal "persistent"`, + ); + } + } + const formData = new FormData(); formData.append("file", file as Blob); if (options?.ttlSeconds !== undefined) formData.append("ttl_seconds", String(options.ttlSeconds)); diff --git a/packages/sdk/tests/unit.test.ts b/packages/sdk/tests/unit.test.ts index 27748d20..095a6477 100644 --- a/packages/sdk/tests/unit.test.ts +++ b/packages/sdk/tests/unit.test.ts @@ -1192,6 +1192,27 @@ describe("Files API", () => { await expect(decart.files.upload(new Blob(["x"]))).rejects.toThrow("Failed to upload file"); }); + it("rejects bad ttlSeconds locally without hitting the network", async () => { + let hit = false; + server.use( + http.post("http://localhost/v1/files", () => { + hit = true; + return HttpResponse.json({}, { status: 200 }); + }), + ); + + await expect( + // @ts-expect-error – intentionally invalid value + decart.files.upload(new Blob(["x"]), { ttlSeconds: "forever" }), + ).rejects.toMatchObject({ code: "INVALID_INPUT" }); + + await expect(decart.files.upload(new Blob(["x"]), { ttlSeconds: 30 })).rejects.toMatchObject({ + code: "INVALID_INPUT", + }); + + expect(hit).toBe(false); + }); + it("forwards a numeric ttlSeconds", async () => { let receivedForm: FormData | null = null; server.use( From 20c79fc879c0e15ac59dd7ad837a17e237c8cbf4 Mon Sep 17 00:00:00 2001 From: Adir Amsalem Date: Tue, 26 May 2026 08:49:36 +0300 Subject: [PATCH 6/7] chore(files): drop internal-detail comment on MAX_TTL_SECONDS --- packages/sdk/src/files/client.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/sdk/src/files/client.ts b/packages/sdk/src/files/client.ts index 8345ceb2..6070e56c 100644 --- a/packages/sdk/src/files/client.ts +++ b/packages/sdk/src/files/client.ts @@ -3,7 +3,7 @@ import { buildAuthHeaders } from "../shared/request"; import { createInvalidInputError, createSDKError } from "../utils/errors"; import type { FileReference, FileUploadInput } from "./types"; -const MAX_TTL_SECONDS = 30 * 24 * 60 * 60; // 30 days; matches the bouncer's ceiling. +const MAX_TTL_SECONDS = 30 * 24 * 60 * 60; // 30 days const ttlSecondsSchema = z.union([z.number().int().min(60).max(MAX_TTL_SECONDS), z.literal("persistent")]); From 0f37b6be3b25a11d7e17124ab95362e1ba6bc09c Mon Sep 17 00:00:00 2001 From: Adir Amsalem Date: Tue, 26 May 2026 09:12:40 +0300 Subject: [PATCH 7/7] chore(playground): add Files API section for upload/get/delete + image ref reuse --- packages/sdk/index.html | 222 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 221 insertions(+), 1 deletion(-) diff --git a/packages/sdk/index.html b/packages/sdk/index.html index c9d5c3a2..02dcd652 100644 --- a/packages/sdk/index.html +++ b/packages/sdk/index.html @@ -450,6 +450,50 @@

Image Reference (Style Transfer)

+ +
+

Files API (Upload & Reuse Refs)

+
+ + +
+
+ + +
+ +
+ +
+ +
+ + +
+ +
+ + + +
+
+

Video File Processing

@@ -562,7 +606,26 @@

Console Logs

processStatusText: document.getElementById('process-status-text'), processedContainer: document.getElementById('processed-container'), originalVideo: document.getElementById('original-video'), - processedVideo: document.getElementById('processed-video') + processedVideo: document.getElementById('processed-video'), + // Files API elements + uploadFile: document.getElementById('upload-file'), + ttlMode: document.getElementById('ttl-mode'), + ttlCustomGroup: document.getElementById('ttl-custom-group'), + ttlCustom: document.getElementById('ttl-custom'), + uploadFileBtn: document.getElementById('upload-file-btn'), + uploadStatus: document.getElementById('upload-status'), + uploadStatusText: document.getElementById('upload-status-text'), + fileRefId: document.getElementById('file-ref-id'), + fileRefInfo: document.getElementById('file-ref-info'), + refIdText: document.getElementById('ref-id-text'), + refFilenameText: document.getElementById('ref-filename-text'), + refMimeText: document.getElementById('ref-mime-text'), + refSizeText: document.getElementById('ref-size-text'), + refCreatedText: document.getElementById('ref-created-text'), + refExpiresText: document.getElementById('ref-expires-text'), + getFileBtn: document.getElementById('get-file-btn'), + deleteFileBtn: document.getElementById('delete-file-btn'), + useRefAsImageBtn: document.getElementById('use-ref-as-image-btn'), }; // Pre-populate API key from environment variable if available @@ -869,6 +932,7 @@

Console Logs

addLog('Successfully connected to Decart!', 'success'); addLog(`Session ID: ${currentSessionId || '(pending)'}`, 'success'); + updateUseRefAsImageBtnState(); } else { throw new Error('Connection established but not in connected state'); } @@ -1022,6 +1086,7 @@

Console Logs

elements.referenceImage.value = ''; elements.imageStatus.style.display = 'none'; elements.imagePreview.style.display = 'none'; + updateUseRefAsImageBtnState(); addLog('Disconnected successfully', 'info'); }); @@ -1287,6 +1352,161 @@

Console Logs

} }); + // ---------- Files API (Upload & Reuse Refs) ---------- + + function ensureDecartClient() { + if (decartClient) return decartClient; + const apiKey = elements.apiKey.value.trim(); + if (!apiKey) { + addLog('Please enter an API key', 'error'); + return null; + } + const realtimeBaseUrl = elements.realtimeBaseUrl.value.trim(); + decartClient = createDecartClient({ + apiKey, + ...(realtimeBaseUrl && { realtimeBaseUrl }), + logger: createDemoLogger("debug"), + }); + addLog('Initialized Decart client (for files API)', 'info'); + return decartClient; + } + + function updateUseRefAsImageBtnState() { + const hasRef = elements.fileRefId.value.trim().length > 0; + const canSetImage = isConnected && activeConnectionMode === 'publisher'; + elements.useRefAsImageBtn.disabled = !hasRef || !canSetImage; + } + + function getTtlOption() { + const mode = elements.ttlMode.value; + if (mode === 'default') return undefined; + if (mode === 'persistent') return 'persistent'; + const n = Number.parseInt(elements.ttlCustom.value, 10); + return Number.isFinite(n) && n > 0 ? n : undefined; + } + + function setRefInfo(ref) { + elements.fileRefId.value = ref.id; + elements.refIdText.textContent = ref.id; + elements.refFilenameText.textContent = ref.filename ?? '(null)'; + elements.refMimeText.textContent = ref.mime_type; + elements.refSizeText.textContent = `${ref.size_bytes} bytes`; + elements.refCreatedText.textContent = ref.created_at; + elements.refExpiresText.textContent = ref.expires_at ?? 'persistent (never expires)'; + elements.fileRefInfo.style.display = 'block'; + updateUseRefAsImageBtnState(); + } + + elements.ttlMode.addEventListener('change', () => { + elements.ttlCustomGroup.style.display = elements.ttlMode.value === 'custom' ? 'block' : 'none'; + }); + + elements.uploadFile.addEventListener('change', (e) => { + elements.uploadFileBtn.disabled = !e.target.files[0]; + }); + + elements.fileRefId.addEventListener('input', updateUseRefAsImageBtnState); + + elements.uploadFileBtn.addEventListener('click', async () => { + const file = elements.uploadFile.files[0]; + if (!file) { + addLog('Please select a file first', 'error'); + return; + } + const client = ensureDecartClient(); + if (!client) return; + + const ttlSeconds = getTtlOption(); + + try { + elements.uploadStatus.style.display = 'block'; + elements.uploadStatusText.textContent = '⏳ Uploading...'; + elements.uploadStatusText.style.color = '#ff9800'; + elements.uploadFileBtn.disabled = true; + + addLog(`Uploading ${file.name} (${file.size} bytes), ttlSeconds=${ttlSeconds ?? 'default'}`, 'info'); + const ref = await client.files.upload( + file, + ttlSeconds !== undefined ? { ttlSeconds } : undefined, + ); + + setRefInfo(ref); + elements.uploadStatusText.textContent = `✅ Uploaded: ${ref.id}`; + elements.uploadStatusText.style.color = '#4CAF50'; + addLog(`File uploaded: ${ref.id} (expires ${ref.expires_at ?? 'never'})`, 'success'); + } catch (error) { + elements.uploadStatusText.textContent = `❌ Failed: ${error.message ?? error}`; + elements.uploadStatusText.style.color = '#f44336'; + addLog(`Upload failed: ${error.message ?? error}`, 'error'); + } finally { + elements.uploadFileBtn.disabled = false; + } + }); + + elements.getFileBtn.addEventListener('click', async () => { + const id = elements.fileRefId.value.trim(); + if (!id) { + addLog('Please enter or upload a file ref id first', 'error'); + return; + } + const client = ensureDecartClient(); + if (!client) return; + + try { + addLog(`Fetching file ${id}...`, 'info'); + const ref = await client.files.get(id); + setRefInfo(ref); + addLog(`Got file ${id}`, 'success'); + } catch (error) { + addLog(`Get failed: ${error.message ?? error}`, 'error'); + } + }); + + elements.deleteFileBtn.addEventListener('click', async () => { + const id = elements.fileRefId.value.trim(); + if (!id) { + addLog('Please enter or upload a file ref id first', 'error'); + return; + } + const client = ensureDecartClient(); + if (!client) return; + + if (!confirm(`Delete file ${id}? This cannot be undone.`)) return; + + try { + addLog(`Deleting file ${id}...`, 'info'); + await client.files.delete(id); + addLog(`Deleted file ${id}`, 'success'); + elements.fileRefInfo.style.display = 'none'; + elements.fileRefId.value = ''; + updateUseRefAsImageBtnState(); + } catch (error) { + addLog(`Delete failed: ${error.message ?? error}`, 'error'); + } + }); + + elements.useRefAsImageBtn.addEventListener('click', async () => { + const id = elements.fileRefId.value.trim(); + if (!id) { + addLog('No file ref id to send', 'error'); + return; + } + if (!decartRealtime || !isConnected || activeConnectionMode !== 'publisher') { + addLog('Not connected to Decart as a publisher', 'error'); + return; + } + try { + elements.useRefAsImageBtn.disabled = true; + addLog(`Setting realtime image to ref ${id}...`, 'info'); + await decartRealtime.setImage(id); + addLog(`Realtime image set to ref ${id}`, 'success'); + } catch (error) { + addLog(`Failed to set image ref: ${error.message ?? error}`, 'error'); + } finally { + updateUseRefAsImageBtnState(); + } + }); + // Initialize addLog('Decart SDK Test Page loaded', 'info'); addLog('Click "Start Camera" for real-time or select a video file for processing', 'info');