From 561d10e05be577ad8087a9ee7df34a9c473e244a Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Sun, 14 Jun 2026 14:17:27 -0700 Subject: [PATCH 1/3] =?UTF-8?q?feat(sdk):=20stage=207=20step=201=20?= =?UTF-8?q?=E2=80=94=20http=20persist=20adapter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Browser-fetch-based PersistAdapter for Studio REST file API. Uses fetch-only (no node:fs) so it is safe to bundle in Vite. - packages/sdk/src/adapters/http.ts — HttpAdapter class + createHttpAdapter factory - packages/sdk/src/adapters/http.test.ts — 14 unit tests (fetch mocked via vi.stubGlobal) - packages/sdk/src/index.ts — re-export createHttpAdapter + HttpAdapterOptions - packages/sdk/package.json — ./adapters/http subpath export (dev + publishConfig) Co-Authored-By: Claude Opus 4.8 --- packages/sdk/package.json | 8 + packages/sdk/src/adapters/http.test.ts | 194 +++++++++++++++++++++++++ packages/sdk/src/adapters/http.ts | 87 +++++++++++ packages/sdk/src/index.ts | 2 + 4 files changed, 291 insertions(+) create mode 100644 packages/sdk/src/adapters/http.test.ts create mode 100644 packages/sdk/src/adapters/http.ts diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 0cda1239e..7bb7cd133 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -29,6 +29,10 @@ "./adapters/headless": { "import": "./src/adapters/headless.ts", "types": "./src/adapters/headless.ts" + }, + "./adapters/http": { + "import": "./src/adapters/http.ts", + "types": "./src/adapters/http.ts" } }, "publishConfig": { @@ -49,6 +53,10 @@ "./adapters/headless": { "import": "./dist/adapters/headless.js", "types": "./dist/adapters/headless.d.ts" + }, + "./adapters/http": { + "import": "./dist/adapters/http.js", + "types": "./dist/adapters/http.d.ts" } }, "main": "./dist/index.js", diff --git a/packages/sdk/src/adapters/http.test.ts b/packages/sdk/src/adapters/http.test.ts new file mode 100644 index 000000000..7fd75741d --- /dev/null +++ b/packages/sdk/src/adapters/http.test.ts @@ -0,0 +1,194 @@ +/** + * Unit tests for createHttpAdapter. + * + * Mocks global `fetch` to verify URL construction, method/headers, error routing, + * and flush semantics without a real server. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { createHttpAdapter } from "./http.js"; + +const BASE = "/api/projects/proj-abc"; + +// ── fetch mock helpers ──────────────────────────────────────────────────────── + +function stubFetch( + handler: (url: string, init?: RequestInit) => { ok: boolean; status?: number; body?: unknown }, +): ReturnType { + const mock = vi.fn(async (url: string, init?: RequestInit) => { + const r = handler(url, init); + return { + ok: r.ok, + status: r.status ?? (r.ok ? 200 : 500), + json: async () => r.body ?? {}, + }; + }); + vi.stubGlobal("fetch", mock); + return mock; +} + +beforeEach(() => { + stubFetch(() => ({ ok: true, body: { content: "" } })); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +// ── read() ──────────────────────────────────────────────────────────────────── + +describe("read()", () => { + it("fetches the correct URL with ?optional=1", async () => { + const mock = stubFetch(() => ({ ok: true, body: { content: "" } })); + const adapter = createHttpAdapter({ projectFilesUrl: BASE }); + await adapter.read("comp.html"); + expect(mock).toHaveBeenCalledWith( + `${BASE}/files/${encodeURIComponent("comp.html")}?optional=1`, + ); + }); + + it("returns content on success", async () => { + stubFetch(() => ({ ok: true, body: { content: "hello" } })); + const adapter = createHttpAdapter({ projectFilesUrl: BASE }); + expect(await adapter.read("comp.html")).toBe("hello"); + }); + + it("returns undefined when response body lacks content field", async () => { + stubFetch(() => ({ ok: true, body: {} })); + const adapter = createHttpAdapter({ projectFilesUrl: BASE }); + expect(await adapter.read("missing.html")).toBeUndefined(); + }); + + it("returns undefined on non-ok response", async () => { + stubFetch(() => ({ ok: false, status: 404 })); + const adapter = createHttpAdapter({ projectFilesUrl: BASE }); + expect(await adapter.read("gone.html")).toBeUndefined(); + }); +}); + +// ── write() ─────────────────────────────────────────────────────────────────── + +describe("write()", () => { + it("PUTs to the correct URL with text/plain body", async () => { + const mock = stubFetch(() => ({ ok: true })); + const adapter = createHttpAdapter({ projectFilesUrl: BASE }); + await adapter.write("comp.html", "new"); + expect(mock).toHaveBeenCalledWith( + `${BASE}/files/${encodeURIComponent("comp.html")}`, + expect.objectContaining({ + method: "PUT", + headers: expect.objectContaining({ "Content-Type": "text/plain" }), + body: "new", + }), + ); + }); + + it("fires persist:error on non-ok response without throwing", async () => { + stubFetch(() => ({ ok: false, status: 503 })); + const adapter = createHttpAdapter({ projectFilesUrl: BASE }); + const onError = vi.fn(); + adapter.on("persist:error", onError); + await expect(adapter.write("comp.html", "x")).resolves.toBeUndefined(); + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ error: expect.objectContaining({ message: "HTTP 503" }) }), + ); + }); + + it("fires persist:error on network error without throwing", async () => { + vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new TypeError("network down"))); + const adapter = createHttpAdapter({ projectFilesUrl: BASE }); + const onError = vi.fn(); + adapter.on("persist:error", onError); + await expect(adapter.write("comp.html", "x")).resolves.toBeUndefined(); + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ + error: expect.objectContaining({ message: expect.stringContaining("network down") }), + }), + ); + }); + + it("does not fire persist:error on success", async () => { + stubFetch(() => ({ ok: true })); + const adapter = createHttpAdapter({ projectFilesUrl: BASE }); + const onError = vi.fn(); + adapter.on("persist:error", onError); + await adapter.write("comp.html", "x"); + expect(onError).not.toHaveBeenCalled(); + }); +}); + +// ── flush() ─────────────────────────────────────────────────────────────────── + +describe("flush()", () => { + it("resolves immediately when no writes are in flight", async () => { + const adapter = createHttpAdapter({ projectFilesUrl: BASE }); + await expect(adapter.flush()).resolves.toBeUndefined(); + }); + + it("waits for an in-flight write before resolving", async () => { + let resolveFetch!: () => void; + vi.stubGlobal( + "fetch", + vi.fn().mockImplementation(async (_url: string, init?: RequestInit) => { + if (init?.method === "PUT") { + await new Promise((r) => { + resolveFetch = r; + }); + } + return { ok: true, status: 200, json: async () => ({}) }; + }), + ); + const adapter = createHttpAdapter({ projectFilesUrl: BASE }); + void adapter.write("comp.html", "x"); // intentionally not awaited + let flushed = false; + const flushDone = adapter.flush().then(() => { + flushed = true; + }); + expect(flushed).toBe(false); + resolveFetch(); + await flushDone; + expect(flushed).toBe(true); + }); +}); + +// ── listVersions() / loadFrom() ─────────────────────────────────────────────── + +describe("listVersions()", () => { + it("returns empty array (server versioning not exposed by this adapter)", async () => { + const adapter = createHttpAdapter({ projectFilesUrl: BASE }); + expect(await adapter.listVersions("comp.html")).toEqual([]); + }); +}); + +describe("loadFrom()", () => { + it("returns undefined (server versioning not exposed by this adapter)", async () => { + const adapter = createHttpAdapter({ projectFilesUrl: BASE }); + expect(await adapter.loadFrom("comp.html", "v1")).toBeUndefined(); + }); +}); + +// ── on() / unsubscribe ──────────────────────────────────────────────────────── + +describe("on() / unsubscribe", () => { + it("unsubscribe removes the listener", async () => { + stubFetch(() => ({ ok: false, status: 500 })); + const adapter = createHttpAdapter({ projectFilesUrl: BASE }); + const onError = vi.fn(); + const unsub = adapter.on("persist:error", onError); + unsub(); + await adapter.write("comp.html", "x"); + expect(onError).not.toHaveBeenCalled(); + }); + + it("multiple listeners all fire", async () => { + stubFetch(() => ({ ok: false, status: 500 })); + const adapter = createHttpAdapter({ projectFilesUrl: BASE }); + const a = vi.fn(); + const b = vi.fn(); + adapter.on("persist:error", a); + adapter.on("persist:error", b); + await adapter.write("comp.html", "x"); + expect(a).toHaveBeenCalledOnce(); + expect(b).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/sdk/src/adapters/http.ts b/packages/sdk/src/adapters/http.ts new file mode 100644 index 000000000..a9e3d52ca --- /dev/null +++ b/packages/sdk/src/adapters/http.ts @@ -0,0 +1,87 @@ +import type { PersistAdapter, PersistVersionEntry } from "./types.js"; +import type { PersistErrorEvent } from "../types.js"; + +export interface HttpAdapterOptions { + /** + * Base URL for the project files REST API, no trailing slash. + * E.g. "/api/projects/proj-abc" + */ + projectFilesUrl: string; +} + +class HttpAdapter implements PersistAdapter { + private readonly baseUrl: string; + private readonly errorListeners: Array<(e: PersistErrorEvent) => void> = []; + private readonly inflightWrites = new Set>(); + + constructor(opts: HttpAdapterOptions) { + this.baseUrl = opts.projectFilesUrl; + } + + async read(path: string): Promise { + const url = `${this.baseUrl}/files/${encodeURIComponent(path)}?optional=1`; + const res = await fetch(url); + if (!res.ok) return undefined; + const data = (await res.json()) as { content?: string }; + return typeof data.content === "string" ? data.content : undefined; + } + + async write(path: string, content: string): Promise { + const p = this.doWrite(path, content); + this.inflightWrites.add(p); + try { + await p; + } finally { + this.inflightWrites.delete(p); + } + } + + private async doWrite(path: string, content: string): Promise { + const url = `${this.baseUrl}/files/${encodeURIComponent(path)}`; + let res: Response; + try { + res = await fetch(url, { + method: "PUT", + headers: { "Content-Type": "text/plain" }, + body: content, + }); + } catch (err) { + this.fireError(String(err), err); + return; + } + if (!res.ok) { + this.fireError(`HTTP ${res.status}`); + } + } + + async flush(): Promise { + await Promise.all([...this.inflightWrites]); + } + + async listVersions(_path: string): Promise { + return []; + } + + async loadFrom(_path: string, _versionKey: string): Promise { + return undefined; + } + + on(event: "persist:error", handler: (e: PersistErrorEvent) => void): () => void { + if (event !== "persist:error") return () => {}; + this.errorListeners.push(handler); + return () => { + const idx = this.errorListeners.indexOf(handler); + if (idx !== -1) this.errorListeners.splice(idx, 1); + }; + } + + private fireError(message: string, cause?: unknown): void { + const error: PersistErrorEvent["error"] = + cause !== undefined ? { message, cause } : { message }; + for (const l of this.errorListeners) l({ error }); + } +} + +export function createHttpAdapter(opts: HttpAdapterOptions): PersistAdapter { + return new HttpAdapter(opts); +} diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 06486655a..57ef673c7 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -38,3 +38,5 @@ export { createMemoryAdapter } from "./adapters/memory.js"; export { createHeadlessAdapter } from "./adapters/headless.js"; export { createFsAdapter } from "./adapters/fs.js"; export type { FsAdapterOptions } from "./adapters/fs.js"; +export { createHttpAdapter } from "./adapters/http.js"; +export type { HttpAdapterOptions } from "./adapters/http.js"; From 757ec4ceb61fb98bb1981e2086f2123c7335610c Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Sun, 14 Jun 2026 21:51:16 -0700 Subject: [PATCH 2/3] fix(sdk/http): serialize concurrent writes to same path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add per-path promise queue so rapid successive writes to the same composition file cannot race at the server. Different paths still write concurrently. Two new tests (RED→GREEN verified). Co-Authored-By: Claude Sonnet 4.6 --- packages/sdk/src/adapters/http.test.ts | 57 ++++++++++++++++++++++++++ packages/sdk/src/adapters/http.ts | 8 +++- 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/packages/sdk/src/adapters/http.test.ts b/packages/sdk/src/adapters/http.test.ts index 7fd75741d..6d466d227 100644 --- a/packages/sdk/src/adapters/http.test.ts +++ b/packages/sdk/src/adapters/http.test.ts @@ -140,6 +140,7 @@ describe("flush()", () => { ); const adapter = createHttpAdapter({ projectFilesUrl: BASE }); void adapter.write("comp.html", "x"); // intentionally not awaited + await Promise.resolve(); // let path-queue microtask fire so doWrite starts let flushed = false; const flushDone = adapter.flush().then(() => { flushed = true; @@ -167,6 +168,62 @@ describe("loadFrom()", () => { }); }); +// ── write() — per-path serialization ───────────────────────────────────────── + +describe("write() — per-path serialization", () => { + it("serializes concurrent writes to the same path (second waits for first)", async () => { + const starts: number[] = []; + let resolveFirst!: () => void; + let callCount = 0; + vi.stubGlobal( + "fetch", + vi.fn().mockImplementation(async (_url: string, init?: RequestInit) => { + if (init?.method === "PUT") { + const n = ++callCount; + starts.push(n); + if (n === 1) await new Promise((r) => (resolveFirst = r)); + } + return { ok: true, status: 200, json: async () => ({}) }; + }), + ); + const adapter = createHttpAdapter({ projectFilesUrl: BASE }); + const write1 = adapter.write("comp.html", "v1"); + await Promise.resolve(); // let write1 start + const write2 = adapter.write("comp.html", "v2"); + await Promise.resolve(); // let write2 attempt to start + expect(starts).toEqual([1]); // write2 has NOT started yet + resolveFirst(); + await write1; + await write2; + expect(starts).toEqual([1, 2]); // write2 started only after write1 finished + }); + + it("does not block writes to different paths", async () => { + const starts: string[] = []; + let resolveFirst!: () => void; + let callCount = 0; + vi.stubGlobal( + "fetch", + vi.fn().mockImplementation(async (url: string, init?: RequestInit) => { + if (init?.method === "PUT") { + const n = ++callCount; + starts.push(`${n}:${url.split("/").pop()}`); + if (n === 1) await new Promise((r) => (resolveFirst = r)); + } + return { ok: true, status: 200, json: async () => ({}) }; + }), + ); + const adapter = createHttpAdapter({ projectFilesUrl: BASE }); + const write1 = adapter.write("a.html", "v1"); + await Promise.resolve(); + void adapter.write("b.html", "v2"); // different path — must not wait for write1 + await Promise.resolve(); + expect(starts.length).toBe(2); // both started concurrently + resolveFirst(); + await write1; + }); +}); + // ── on() / unsubscribe ──────────────────────────────────────────────────────── describe("on() / unsubscribe", () => { diff --git a/packages/sdk/src/adapters/http.ts b/packages/sdk/src/adapters/http.ts index a9e3d52ca..666fef918 100644 --- a/packages/sdk/src/adapters/http.ts +++ b/packages/sdk/src/adapters/http.ts @@ -13,6 +13,7 @@ class HttpAdapter implements PersistAdapter { private readonly baseUrl: string; private readonly errorListeners: Array<(e: PersistErrorEvent) => void> = []; private readonly inflightWrites = new Set>(); + private readonly pathQueues = new Map>(); constructor(opts: HttpAdapterOptions) { this.baseUrl = opts.projectFilesUrl; @@ -27,7 +28,12 @@ class HttpAdapter implements PersistAdapter { } async write(path: string, content: string): Promise { - const p = this.doWrite(path, content); + const prev = this.pathQueues.get(path) ?? Promise.resolve(); + const p = prev.then(() => this.doWrite(path, content)); + this.pathQueues.set( + path, + p.catch(() => {}), + ); this.inflightWrites.add(p); try { await p; From 0f6f62bab15295a28cf020e3673b3717d79ca884 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Sun, 14 Jun 2026 22:41:01 -0700 Subject: [PATCH 3/3] =?UTF-8?q?fix(sdk):=20http=20adapter=20=E2=80=94=20he?= =?UTF-8?q?aders=20option,=20listVersions/loadFrom=20doc,=20flush-drains-t?= =?UTF-8?q?wo=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add optional headers?: HeadersInit | (() => HeadersInit) to HttpAdapterOptions for cross-origin / CLI / auth injection (function form refreshes on each PUT) - Document listVersions/loadFrom as intentional no-ops (server versioning not exposed) - Add flush-drains-two-concurrent-writes test to mirror fs adapter T13 coverage Co-Authored-By: Claude Sonnet 4.6 Co-authored-by: Miguel Ángel --- packages/sdk/src/adapters/http.test.ts | 62 ++++++++++++++++++++++++++ packages/sdk/src/adapters/http.ts | 14 +++++- 2 files changed, 75 insertions(+), 1 deletion(-) diff --git a/packages/sdk/src/adapters/http.test.ts b/packages/sdk/src/adapters/http.test.ts index 6d466d227..19c39bb98 100644 --- a/packages/sdk/src/adapters/http.test.ts +++ b/packages/sdk/src/adapters/http.test.ts @@ -117,6 +117,39 @@ describe("write()", () => { }); }); +// ── headers option ─────────────────────────────────────────────────────────── + +describe("headers option", () => { + it("merges static headers into every PUT request", async () => { + const mock = stubFetch(() => ({ ok: true })); + const adapter = createHttpAdapter({ + projectFilesUrl: BASE, + headers: { Authorization: "Bearer tok" }, + }); + await adapter.write("comp.html", "x"); + expect(mock).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + headers: expect.objectContaining({ Authorization: "Bearer tok" }), + }), + ); + }); + + it("calls a headers function lazily on each write", async () => { + const mock = stubFetch(() => ({ ok: true })); + let n = 0; + const adapter = createHttpAdapter({ + projectFilesUrl: BASE, + headers: () => ({ Authorization: `Bearer tok${++n}` }), + }); + await adapter.write("comp.html", "a"); + await adapter.write("comp.html", "b"); + const calls = mock.mock.calls.filter((c) => c[1]?.method === "PUT"); + expect((calls[0][1]?.headers as Record)?.["Authorization"]).toBe("Bearer tok1"); + expect((calls[1][1]?.headers as Record)?.["Authorization"]).toBe("Bearer tok2"); + }); +}); + // ── flush() ─────────────────────────────────────────────────────────────────── describe("flush()", () => { @@ -150,6 +183,35 @@ describe("flush()", () => { await flushDone; expect(flushed).toBe(true); }); + + it("waits for two concurrent in-flight writes before resolving", async () => { + const resolvers: Array<() => void> = []; + vi.stubGlobal( + "fetch", + vi.fn().mockImplementation(async (_url: string, init?: RequestInit) => { + if (init?.method === "PUT") { + await new Promise((r) => resolvers.push(r)); + } + return { ok: true, status: 200, json: async () => ({}) }; + }), + ); + const adapter = createHttpAdapter({ projectFilesUrl: BASE }); + void adapter.write("a.html", "1"); + void adapter.write("b.html", "2"); + await Promise.resolve(); // let both start + await Promise.resolve(); + let flushed = false; + const flushDone = adapter.flush().then(() => { + flushed = true; + }); + expect(flushed).toBe(false); + resolvers[0](); + await Promise.resolve(); + expect(flushed).toBe(false); // still waiting for second write + resolvers[1](); + await flushDone; + expect(flushed).toBe(true); + }); }); // ── listVersions() / loadFrom() ─────────────────────────────────────────────── diff --git a/packages/sdk/src/adapters/http.ts b/packages/sdk/src/adapters/http.ts index 666fef918..ed50a17fe 100644 --- a/packages/sdk/src/adapters/http.ts +++ b/packages/sdk/src/adapters/http.ts @@ -7,16 +7,24 @@ export interface HttpAdapterOptions { * E.g. "/api/projects/proj-abc" */ projectFilesUrl: string; + /** + * Extra headers to include on every PUT write request. + * Pass a function to compute them lazily (e.g. to refresh a bearer token on each request). + * Useful for cross-origin or CLI contexts where ambient cookies are not available. + */ + headers?: HeadersInit | (() => HeadersInit); } class HttpAdapter implements PersistAdapter { private readonly baseUrl: string; + private readonly extraHeaders?: HttpAdapterOptions["headers"]; private readonly errorListeners: Array<(e: PersistErrorEvent) => void> = []; private readonly inflightWrites = new Set>(); private readonly pathQueues = new Map>(); constructor(opts: HttpAdapterOptions) { this.baseUrl = opts.projectFilesUrl; + this.extraHeaders = opts.headers; } async read(path: string): Promise { @@ -46,9 +54,11 @@ class HttpAdapter implements PersistAdapter { const url = `${this.baseUrl}/files/${encodeURIComponent(path)}`; let res: Response; try { + const extra = + typeof this.extraHeaders === "function" ? this.extraHeaders() : this.extraHeaders; res = await fetch(url, { method: "PUT", - headers: { "Content-Type": "text/plain" }, + headers: { "Content-Type": "text/plain", ...extra }, body: content, }); } catch (err) { @@ -64,10 +74,12 @@ class HttpAdapter implements PersistAdapter { await Promise.all([...this.inflightWrites]); } + /** Server-side versioning is not exposed by this adapter; returns [] intentionally. */ async listVersions(_path: string): Promise { return []; } + /** Server-side versioning is not exposed by this adapter; returns undefined intentionally. */ async loadFrom(_path: string, _versionKey: string): Promise { return undefined; }