Skip to content

Commit 9bb242f

Browse files
vanceingallsclaude
andcommitted
feat(sdk): stage 7 step 1 — http persist adapter
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 <noreply@anthropic.com>
1 parent b158870 commit 9bb242f

4 files changed

Lines changed: 291 additions & 0 deletions

File tree

packages/sdk/package.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@
2929
"./adapters/headless": {
3030
"import": "./src/adapters/headless.ts",
3131
"types": "./src/adapters/headless.ts"
32+
},
33+
"./adapters/http": {
34+
"import": "./src/adapters/http.ts",
35+
"types": "./src/adapters/http.ts"
3236
}
3337
},
3438
"publishConfig": {
@@ -49,6 +53,10 @@
4953
"./adapters/headless": {
5054
"import": "./dist/adapters/headless.js",
5155
"types": "./dist/adapters/headless.d.ts"
56+
},
57+
"./adapters/http": {
58+
"import": "./dist/adapters/http.js",
59+
"types": "./dist/adapters/http.d.ts"
5260
}
5361
},
5462
"main": "./dist/index.js",
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
/**
2+
* Unit tests for createHttpAdapter.
3+
*
4+
* Mocks global `fetch` to verify URL construction, method/headers, error routing,
5+
* and flush semantics without a real server.
6+
*/
7+
8+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
9+
import { createHttpAdapter } from "./http.js";
10+
11+
const BASE = "/api/projects/proj-abc";
12+
13+
// ── fetch mock helpers ────────────────────────────────────────────────────────
14+
15+
function stubFetch(
16+
handler: (url: string, init?: RequestInit) => { ok: boolean; status?: number; body?: unknown },
17+
): ReturnType<typeof vi.fn> {
18+
const mock = vi.fn(async (url: string, init?: RequestInit) => {
19+
const r = handler(url, init);
20+
return {
21+
ok: r.ok,
22+
status: r.status ?? (r.ok ? 200 : 500),
23+
json: async () => r.body ?? {},
24+
};
25+
});
26+
vi.stubGlobal("fetch", mock);
27+
return mock;
28+
}
29+
30+
beforeEach(() => {
31+
stubFetch(() => ({ ok: true, body: { content: "" } }));
32+
});
33+
34+
afterEach(() => {
35+
vi.unstubAllGlobals();
36+
});
37+
38+
// ── read() ────────────────────────────────────────────────────────────────────
39+
40+
describe("read()", () => {
41+
it("fetches the correct URL with ?optional=1", async () => {
42+
const mock = stubFetch(() => ({ ok: true, body: { content: "<html/>" } }));
43+
const adapter = createHttpAdapter({ projectFilesUrl: BASE });
44+
await adapter.read("comp.html");
45+
expect(mock).toHaveBeenCalledWith(
46+
`${BASE}/files/${encodeURIComponent("comp.html")}?optional=1`,
47+
);
48+
});
49+
50+
it("returns content on success", async () => {
51+
stubFetch(() => ({ ok: true, body: { content: "<html>hello</html>" } }));
52+
const adapter = createHttpAdapter({ projectFilesUrl: BASE });
53+
expect(await adapter.read("comp.html")).toBe("<html>hello</html>");
54+
});
55+
56+
it("returns undefined when response body lacks content field", async () => {
57+
stubFetch(() => ({ ok: true, body: {} }));
58+
const adapter = createHttpAdapter({ projectFilesUrl: BASE });
59+
expect(await adapter.read("missing.html")).toBeUndefined();
60+
});
61+
62+
it("returns undefined on non-ok response", async () => {
63+
stubFetch(() => ({ ok: false, status: 404 }));
64+
const adapter = createHttpAdapter({ projectFilesUrl: BASE });
65+
expect(await adapter.read("gone.html")).toBeUndefined();
66+
});
67+
});
68+
69+
// ── write() ───────────────────────────────────────────────────────────────────
70+
71+
describe("write()", () => {
72+
it("PUTs to the correct URL with text/plain body", async () => {
73+
const mock = stubFetch(() => ({ ok: true }));
74+
const adapter = createHttpAdapter({ projectFilesUrl: BASE });
75+
await adapter.write("comp.html", "<html>new</html>");
76+
expect(mock).toHaveBeenCalledWith(
77+
`${BASE}/files/${encodeURIComponent("comp.html")}`,
78+
expect.objectContaining({
79+
method: "PUT",
80+
headers: expect.objectContaining({ "Content-Type": "text/plain" }),
81+
body: "<html>new</html>",
82+
}),
83+
);
84+
});
85+
86+
it("fires persist:error on non-ok response without throwing", async () => {
87+
stubFetch(() => ({ ok: false, status: 503 }));
88+
const adapter = createHttpAdapter({ projectFilesUrl: BASE });
89+
const onError = vi.fn();
90+
adapter.on("persist:error", onError);
91+
await expect(adapter.write("comp.html", "x")).resolves.toBeUndefined();
92+
expect(onError).toHaveBeenCalledWith(
93+
expect.objectContaining({ error: expect.objectContaining({ message: "HTTP 503" }) }),
94+
);
95+
});
96+
97+
it("fires persist:error on network error without throwing", async () => {
98+
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new TypeError("network down")));
99+
const adapter = createHttpAdapter({ projectFilesUrl: BASE });
100+
const onError = vi.fn();
101+
adapter.on("persist:error", onError);
102+
await expect(adapter.write("comp.html", "x")).resolves.toBeUndefined();
103+
expect(onError).toHaveBeenCalledWith(
104+
expect.objectContaining({
105+
error: expect.objectContaining({ message: expect.stringContaining("network down") }),
106+
}),
107+
);
108+
});
109+
110+
it("does not fire persist:error on success", async () => {
111+
stubFetch(() => ({ ok: true }));
112+
const adapter = createHttpAdapter({ projectFilesUrl: BASE });
113+
const onError = vi.fn();
114+
adapter.on("persist:error", onError);
115+
await adapter.write("comp.html", "x");
116+
expect(onError).not.toHaveBeenCalled();
117+
});
118+
});
119+
120+
// ── flush() ───────────────────────────────────────────────────────────────────
121+
122+
describe("flush()", () => {
123+
it("resolves immediately when no writes are in flight", async () => {
124+
const adapter = createHttpAdapter({ projectFilesUrl: BASE });
125+
await expect(adapter.flush()).resolves.toBeUndefined();
126+
});
127+
128+
it("waits for an in-flight write before resolving", async () => {
129+
let resolveFetch!: () => void;
130+
vi.stubGlobal(
131+
"fetch",
132+
vi.fn().mockImplementation(async (_url: string, init?: RequestInit) => {
133+
if (init?.method === "PUT") {
134+
await new Promise<void>((r) => {
135+
resolveFetch = r;
136+
});
137+
}
138+
return { ok: true, status: 200, json: async () => ({}) };
139+
}),
140+
);
141+
const adapter = createHttpAdapter({ projectFilesUrl: BASE });
142+
void adapter.write("comp.html", "x"); // intentionally not awaited
143+
let flushed = false;
144+
const flushDone = adapter.flush().then(() => {
145+
flushed = true;
146+
});
147+
expect(flushed).toBe(false);
148+
resolveFetch();
149+
await flushDone;
150+
expect(flushed).toBe(true);
151+
});
152+
});
153+
154+
// ── listVersions() / loadFrom() ───────────────────────────────────────────────
155+
156+
describe("listVersions()", () => {
157+
it("returns empty array (server versioning not exposed by this adapter)", async () => {
158+
const adapter = createHttpAdapter({ projectFilesUrl: BASE });
159+
expect(await adapter.listVersions("comp.html")).toEqual([]);
160+
});
161+
});
162+
163+
describe("loadFrom()", () => {
164+
it("returns undefined (server versioning not exposed by this adapter)", async () => {
165+
const adapter = createHttpAdapter({ projectFilesUrl: BASE });
166+
expect(await adapter.loadFrom("comp.html", "v1")).toBeUndefined();
167+
});
168+
});
169+
170+
// ── on() / unsubscribe ────────────────────────────────────────────────────────
171+
172+
describe("on() / unsubscribe", () => {
173+
it("unsubscribe removes the listener", async () => {
174+
stubFetch(() => ({ ok: false, status: 500 }));
175+
const adapter = createHttpAdapter({ projectFilesUrl: BASE });
176+
const onError = vi.fn();
177+
const unsub = adapter.on("persist:error", onError);
178+
unsub();
179+
await adapter.write("comp.html", "x");
180+
expect(onError).not.toHaveBeenCalled();
181+
});
182+
183+
it("multiple listeners all fire", async () => {
184+
stubFetch(() => ({ ok: false, status: 500 }));
185+
const adapter = createHttpAdapter({ projectFilesUrl: BASE });
186+
const a = vi.fn();
187+
const b = vi.fn();
188+
adapter.on("persist:error", a);
189+
adapter.on("persist:error", b);
190+
await adapter.write("comp.html", "x");
191+
expect(a).toHaveBeenCalledOnce();
192+
expect(b).toHaveBeenCalledOnce();
193+
});
194+
});

packages/sdk/src/adapters/http.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import type { PersistAdapter, PersistVersionEntry } from "./types.js";
2+
import type { PersistErrorEvent } from "../types.js";
3+
4+
export interface HttpAdapterOptions {
5+
/**
6+
* Base URL for the project files REST API, no trailing slash.
7+
* E.g. "/api/projects/proj-abc"
8+
*/
9+
projectFilesUrl: string;
10+
}
11+
12+
class HttpAdapter implements PersistAdapter {
13+
private readonly baseUrl: string;
14+
private readonly errorListeners: Array<(e: PersistErrorEvent) => void> = [];
15+
private readonly inflightWrites = new Set<Promise<void>>();
16+
17+
constructor(opts: HttpAdapterOptions) {
18+
this.baseUrl = opts.projectFilesUrl;
19+
}
20+
21+
async read(path: string): Promise<string | undefined> {
22+
const url = `${this.baseUrl}/files/${encodeURIComponent(path)}?optional=1`;
23+
const res = await fetch(url);
24+
if (!res.ok) return undefined;
25+
const data = (await res.json()) as { content?: string };
26+
return typeof data.content === "string" ? data.content : undefined;
27+
}
28+
29+
async write(path: string, content: string): Promise<void> {
30+
const p = this.doWrite(path, content);
31+
this.inflightWrites.add(p);
32+
try {
33+
await p;
34+
} finally {
35+
this.inflightWrites.delete(p);
36+
}
37+
}
38+
39+
private async doWrite(path: string, content: string): Promise<void> {
40+
const url = `${this.baseUrl}/files/${encodeURIComponent(path)}`;
41+
let res: Response;
42+
try {
43+
res = await fetch(url, {
44+
method: "PUT",
45+
headers: { "Content-Type": "text/plain" },
46+
body: content,
47+
});
48+
} catch (err) {
49+
this.fireError(String(err), err);
50+
return;
51+
}
52+
if (!res.ok) {
53+
this.fireError(`HTTP ${res.status}`);
54+
}
55+
}
56+
57+
async flush(): Promise<void> {
58+
await Promise.all([...this.inflightWrites]);
59+
}
60+
61+
async listVersions(_path: string): Promise<PersistVersionEntry[]> {
62+
return [];
63+
}
64+
65+
async loadFrom(_path: string, _versionKey: string): Promise<string | undefined> {
66+
return undefined;
67+
}
68+
69+
on(event: "persist:error", handler: (e: PersistErrorEvent) => void): () => void {
70+
if (event !== "persist:error") return () => {};
71+
this.errorListeners.push(handler);
72+
return () => {
73+
const idx = this.errorListeners.indexOf(handler);
74+
if (idx !== -1) this.errorListeners.splice(idx, 1);
75+
};
76+
}
77+
78+
private fireError(message: string, cause?: unknown): void {
79+
const error: PersistErrorEvent["error"] =
80+
cause !== undefined ? { message, cause } : { message };
81+
for (const l of this.errorListeners) l({ error });
82+
}
83+
}
84+
85+
export function createHttpAdapter(opts: HttpAdapterOptions): PersistAdapter {
86+
return new HttpAdapter(opts);
87+
}

packages/sdk/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,3 +39,5 @@ export { createMemoryAdapter } from "./adapters/memory.js";
3939
export { createHeadlessAdapter } from "./adapters/headless.js";
4040
export { createFsAdapter } from "./adapters/fs.js";
4141
export type { FsAdapterOptions } from "./adapters/fs.js";
42+
export { createHttpAdapter } from "./adapters/http.js";
43+
export type { HttpAdapterOptions } from "./adapters/http.js";

0 commit comments

Comments
 (0)