mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
## 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
112 lines
3.7 KiB
TypeScript
112 lines
3.7 KiB
TypeScript
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);
|
|
}
|