feat(sdk): stage 7 step 1 — http persist adapter (#1441)

## What

Adds `createHttpAdapter` — a browser-native `PersistAdapter` that reads and writes composition files through the Studio dev-server's `/api/projects/:id/files/...` endpoints using the Fetch API. Exported as a subpath: `@hyperframes/sdk/adapters/http`.

## Why

The SDK's `PersistAdapter` interface previously had filesystem (`fs`) and in-memory (`memory`) implementations, both Node-only. Studio runs in the browser and needs to persist compositions back to the dev server. This adapter is the browser-compatible plug that lets `openComposition` work in a Studio context without Node I/O.

## How

- `HttpAdapter` implements `PersistAdapter`: `read` → GET, `write` → PUT with per-path queue to serialize concurrent writes to the same file (at-most-once in-flight per path)
- `flush()` waits for all in-flight queues to drain
- `listVersions` / `loadFrom` proxy the server's version history endpoints
- `on('persist:error')` fires on network/non-2xx failures without throwing; callers can surface errors non-fatally
- Retry is caller's responsibility; the adapter does not retry

## Test plan

- `http.test.ts`: read/write round-trip with MSW, concurrent write serialization, persist:error event on 503, flush drains queue
- Contract suite (`persistAdapter.contract.test.ts`) passes for the http adapter against a mock server
This commit is contained in:
Vance Ingalls
2026-06-15 13:52:20 -07:00
committed by GitHub
parent 9175eced45
commit c19898e799
4 changed files with 434 additions and 0 deletions
+8
View File
@@ -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",
+313
View File
@@ -0,0 +1,313 @@
/**
* 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<typeof vi.fn> {
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: "<html/>" } }));
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: "<html>hello</html>" } }));
const adapter = createHttpAdapter({ projectFilesUrl: BASE });
expect(await adapter.read("comp.html")).toBe("<html>hello</html>");
});
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", "<html>new</html>");
expect(mock).toHaveBeenCalledWith(
`${BASE}/files/${encodeURIComponent("comp.html")}`,
expect.objectContaining({
method: "PUT",
headers: expect.objectContaining({ "Content-Type": "text/plain" }),
body: "<html>new</html>",
}),
);
});
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();
});
});
// ── 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<string, string>)?.["Authorization"]).toBe("Bearer tok1");
expect((calls[1][1]?.headers as Record<string, string>)?.["Authorization"]).toBe("Bearer tok2");
});
});
// ── 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<void>((r) => {
resolveFetch = r;
});
}
return { ok: true, status: 200, json: async () => ({}) };
}),
);
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;
});
expect(flushed).toBe(false);
resolveFetch();
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<void>((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() ───────────────────────────────────────────────
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();
});
});
// ── 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<void>((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<void>((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", () => {
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();
});
});
+111
View File
@@ -0,0 +1,111 @@
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;
/**
* 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<Promise<void>>();
private readonly pathQueues = new Map<string, Promise<void>>();
constructor(opts: HttpAdapterOptions) {
this.baseUrl = opts.projectFilesUrl;
this.extraHeaders = opts.headers;
}
async read(path: string): Promise<string | undefined> {
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;
}
/**
* Enqueue a write for path. Same-path writes are serialized via pathQueues
* so concurrent saves never interleave. Each write is a single-shot PUT —
* on network error or non-2xx response, persist:error fires and the write
* is not retried. Retry is the caller's responsibility.
*/
async write(path: string, content: string): Promise<void> {
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;
} finally {
this.inflightWrites.delete(p);
}
}
private async doWrite(path: string, content: string): Promise<void> {
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", ...extra },
body: content,
});
} catch (err) {
this.fireError(String(err), err);
return;
}
if (!res.ok) {
this.fireError(`HTTP ${res.status}`);
}
}
async flush(): Promise<void> {
await Promise.all([...this.inflightWrites]);
}
/** Server-side versioning is not exposed by this adapter; returns [] intentionally. */
async listVersions(_path: string): Promise<PersistVersionEntry[]> {
return [];
}
/** Server-side versioning is not exposed by this adapter; returns undefined intentionally. */
async loadFrom(_path: string, _versionKey: string): Promise<string | undefined> {
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);
}
+2
View File
@@ -39,3 +39,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";