Skip to content

Commit 9e58fd1

Browse files
fix(sdk): http adapter — headers option, listVersions/loadFrom doc, flush-drains-two test
- 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 <noreply@anthropic.com> Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
1 parent 91f99c0 commit 9e58fd1

2 files changed

Lines changed: 75 additions & 1 deletion

File tree

packages/sdk/src/adapters/http.test.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,39 @@ describe("write()", () => {
117117
});
118118
});
119119

120+
// ── headers option ───────────────────────────────────────────────────────────
121+
122+
describe("headers option", () => {
123+
it("merges static headers into every PUT request", async () => {
124+
const mock = stubFetch(() => ({ ok: true }));
125+
const adapter = createHttpAdapter({
126+
projectFilesUrl: BASE,
127+
headers: { Authorization: "Bearer tok" },
128+
});
129+
await adapter.write("comp.html", "x");
130+
expect(mock).toHaveBeenCalledWith(
131+
expect.any(String),
132+
expect.objectContaining({
133+
headers: expect.objectContaining({ Authorization: "Bearer tok" }),
134+
}),
135+
);
136+
});
137+
138+
it("calls a headers function lazily on each write", async () => {
139+
const mock = stubFetch(() => ({ ok: true }));
140+
let n = 0;
141+
const adapter = createHttpAdapter({
142+
projectFilesUrl: BASE,
143+
headers: () => ({ Authorization: `Bearer tok${++n}` }),
144+
});
145+
await adapter.write("comp.html", "a");
146+
await adapter.write("comp.html", "b");
147+
const calls = mock.mock.calls.filter((c) => c[1]?.method === "PUT");
148+
expect((calls[0][1]?.headers as Record<string, string>)?.["Authorization"]).toBe("Bearer tok1");
149+
expect((calls[1][1]?.headers as Record<string, string>)?.["Authorization"]).toBe("Bearer tok2");
150+
});
151+
});
152+
120153
// ── flush() ───────────────────────────────────────────────────────────────────
121154

122155
describe("flush()", () => {
@@ -150,6 +183,35 @@ describe("flush()", () => {
150183
await flushDone;
151184
expect(flushed).toBe(true);
152185
});
186+
187+
it("waits for two concurrent in-flight writes before resolving", async () => {
188+
const resolvers: Array<() => void> = [];
189+
vi.stubGlobal(
190+
"fetch",
191+
vi.fn().mockImplementation(async (_url: string, init?: RequestInit) => {
192+
if (init?.method === "PUT") {
193+
await new Promise<void>((r) => resolvers.push(r));
194+
}
195+
return { ok: true, status: 200, json: async () => ({}) };
196+
}),
197+
);
198+
const adapter = createHttpAdapter({ projectFilesUrl: BASE });
199+
void adapter.write("a.html", "1");
200+
void adapter.write("b.html", "2");
201+
await Promise.resolve(); // let both start
202+
await Promise.resolve();
203+
let flushed = false;
204+
const flushDone = adapter.flush().then(() => {
205+
flushed = true;
206+
});
207+
expect(flushed).toBe(false);
208+
resolvers[0]();
209+
await Promise.resolve();
210+
expect(flushed).toBe(false); // still waiting for second write
211+
resolvers[1]();
212+
await flushDone;
213+
expect(flushed).toBe(true);
214+
});
153215
});
154216

155217
// ── listVersions() / loadFrom() ───────────────────────────────────────────────

packages/sdk/src/adapters/http.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,24 @@ export interface HttpAdapterOptions {
77
* E.g. "/api/projects/proj-abc"
88
*/
99
projectFilesUrl: string;
10+
/**
11+
* Extra headers to include on every PUT write request.
12+
* Pass a function to compute them lazily (e.g. to refresh a bearer token on each request).
13+
* Useful for cross-origin or CLI contexts where ambient cookies are not available.
14+
*/
15+
headers?: HeadersInit | (() => HeadersInit);
1016
}
1117

1218
class HttpAdapter implements PersistAdapter {
1319
private readonly baseUrl: string;
20+
private readonly extraHeaders?: HttpAdapterOptions["headers"];
1421
private readonly errorListeners: Array<(e: PersistErrorEvent) => void> = [];
1522
private readonly inflightWrites = new Set<Promise<void>>();
1623
private readonly pathQueues = new Map<string, Promise<void>>();
1724

1825
constructor(opts: HttpAdapterOptions) {
1926
this.baseUrl = opts.projectFilesUrl;
27+
this.extraHeaders = opts.headers;
2028
}
2129

2230
async read(path: string): Promise<string | undefined> {
@@ -46,9 +54,11 @@ class HttpAdapter implements PersistAdapter {
4654
const url = `${this.baseUrl}/files/${encodeURIComponent(path)}`;
4755
let res: Response;
4856
try {
57+
const extra =
58+
typeof this.extraHeaders === "function" ? this.extraHeaders() : this.extraHeaders;
4959
res = await fetch(url, {
5060
method: "PUT",
51-
headers: { "Content-Type": "text/plain" },
61+
headers: { "Content-Type": "text/plain", ...extra },
5262
body: content,
5363
});
5464
} catch (err) {
@@ -64,10 +74,12 @@ class HttpAdapter implements PersistAdapter {
6474
await Promise.all([...this.inflightWrites]);
6575
}
6676

77+
/** Server-side versioning is not exposed by this adapter; returns [] intentionally. */
6778
async listVersions(_path: string): Promise<PersistVersionEntry[]> {
6879
return [];
6980
}
7081

82+
/** Server-side versioning is not exposed by this adapter; returns undefined intentionally. */
7183
async loadFrom(_path: string, _versionKey: string): Promise<string | undefined> {
7284
return undefined;
7385
}

0 commit comments

Comments
 (0)