Files
heygen-com__hyperframes/docs/sdk/reference/adapters.mdx
ukimsanov 7a91b93dd6 docs: correct four developer-reference claims the source contradicts
Miguel's three P2s and Rames' one finding on #2974, all verified in source
before changing anything.

**`render --json` is not a progress stream.** It prints exactly one
`batch-complete` document at the end (`batchRender.ts:408-418`), asserted as a
single `console.log` in `batchRender.test.ts`. Described as a final result now.

**The iframe drag example never captured the pointer.** `event.target` comes
from `iframe.contentDocument`, so `instanceof Element` against this window's
constructor is always false for a cross-realm node and `setPointerCapture()`
never ran — a pointer leaving the frame then loses `pointerup` and drag state
sticks. Structural feature detection instead, with the reason in a comment so it
does not get "simplified" back.

**The preview adapter example did not compile under strict TypeScript.** `comp`
was captured by the callback before definite assignment (TS2454). Optional, with
`comp?.dispatch(op)`.

**`ORIGIN_APPLY_PATCHES` was imported in a fence that did not use it and used in
fences that did not import it.** Imports do not cross fences, so both examples
were wrong in opposite directions. Rames found the pair in
`open-composition.mdx`; the same shape is in `composition.mdx:630`, which he did
not name. All three fences are self-contained now.

**And `types.mdx` claimed coverage it does not have.** It promised "every type
exported from `@hyperframes/sdk`" while omitting 13 of 42. Eleven are documented
on sibling pages, so the sentence now points at those instead of overclaiming.
The two with no home anywhere — `CompositionVariableType` and
`VariableUsageScan`, both re-exported from the barrel — have entries. The second
is worth having written down: `scanIncomplete` means `usedIds` is a lower bound,
so an id missing from it is unknown rather than unused.
2026-08-04 02:45:21 -07:00

342 lines
16 KiB
Plaintext

---
title: "Adapters"
description: "Persistence and preview adapter interfaces, contracts, and the built-in factory functions."
---
The SDK decouples editing sessions from storage and preview surfaces through two injectable interfaces: `PersistAdapter` and `PreviewAdapter`. Both ship with concrete factory functions you pass to `openComposition()`. You can also implement either interface directly for custom storage backends (S3, IndexedDB, HTTP) or custom preview surfaces.
## PersistAdapter
```typescript
import type { PersistAdapter } from "@hyperframes/sdk";
```
Injectable storage adapter. Decouples the SDK from the underlying persistence mechanism so the same session code runs in tests (memory), local dev (filesystem), and production (cloud storage).
### Interface
```typescript
interface PersistAdapter {
read(path: string): Promise<string | undefined>;
write(path: string, content: string): Promise<void>;
flush(): Promise<void>;
listVersions(path: string): Promise<PersistVersionEntry[]>;
loadFrom(path: string, versionKey: string): Promise<string | undefined>;
on(event: "persist:error", handler: (event: PersistErrorEvent) => void): () => void;
}
```
<ParamField path="read" type="(path: string) => Promise<string | undefined>">
Returns the stored content for `path`, or `undefined` for a path that has never been written. Never throws for a missing path.
</ParamField>
<ParamField path="write" type="(path: string, content: string) => Promise<void>">
Persists `content` at `path`. Idempotent — a second call with the same path overwrites the prior value. Write failures must not propagate as thrown exceptions; fire `persist:error` instead.
</ParamField>
<ParamField path="flush" type="() => Promise<void>">
Forces any queued or in-flight writes to commit before resolving. Call before process exit or navigation to prevent data loss.
</ParamField>
<ParamField path="listVersions" type="(path: string) => Promise<PersistVersionEntry[]>">
Returns the version history for `path` ordered newest-first. Returns an empty array when no versions exist. See `PersistVersionEntry` below.
</ParamField>
<ParamField path="loadFrom" type="(path: string, versionKey: string) => Promise<string | undefined>">
Returns the HTML content for a specific version identified by `versionKey`. Returns `undefined` when the key does not exist.
</ParamField>
<ParamField path="on" type='(event: "persist:error", handler) => () => void'>
Subscribes to write failures. Returns an unsubscribe function. Adapters must emit this event — not throw — when a write fails, so the session continues running even when storage is temporarily unavailable.
</ParamField>
### Contract summary
- `read()` returns `undefined` for a path that has never been written — never throws ENOENT or a 404 equivalent.
- `write()` is idempotent; a second write to the same path replaces the stored content.
- `flush()` resolves when any pending writes are committed to durable storage.
- `listVersions()` returns entries newest-first; `loadFrom()` uses the keys from those entries.
- Write errors are emitted via `on('persist:error')`, never thrown — the session keeps running.
### PersistVersionEntry
```typescript
interface PersistVersionEntry {
/** Opaque key identifying this version (adapter-defined format). */
key: string;
/** Full HTML content — may be omitted by adapters that load content lazily via loadFrom(). */
content?: string;
timestamp?: number;
}
```
The `key` is adapter-defined and opaque to callers — pass it directly to `loadFrom()`. The filesystem adapter encodes milliseconds and a counter into the key; the memory adapter uses an incrementing `"v1"`, `"v2"` … scheme.
---
## PreviewAdapter
```typescript
import type { PreviewAdapter } from "@hyperframes/sdk";
```
Injectable preview surface adapter. Decouples the SDK from the host's rendering layer. The SDK is **not** in the 60fps draft loop: your pointer-move handler calls `applyDraft()` directly on the adapter at 60fps, and the SDK only gets involved once per gesture when `commitPreview()` fires to derive and dispatch the resulting op.
### Interface
```typescript
interface PreviewAdapter {
elementAtPoint(x: number, y: number, opts?: { atTime?: number }): ElementAtPointResult | null;
applyDraft(id: string, props: DraftProps): void;
commitPreview(): void;
cancelPreview(): void;
select(ids: string[], opts?: { additive?: boolean }): void;
on(event: "selection", handler: (ids: string[]) => void): () => void;
attachSync(comp: Composition): () => void;
}
```
<ParamField path="elementAtPoint" type="(x, y, opts?) => ElementAtPointResult | null">
Synchronous hit-test at composition coordinates `(x, y)`. Returns the nearest `[data-hf-id]` element under the point, or `null` for a transparent hit (the composition root, an opacity-0 element, or nothing at all). Requires a same-origin iframe — cross-origin access throws a DOMException. The `atTime` option reflects GSAP state at the current playhead; seeking to a speculative time is not supported.
</ParamField>
<ParamField path="applyDraft" type="(id: string, props: DraftProps) => void">
Visually translates the preview element at 60fps during a drag: sets the element's CSS `translate` to its pre-drag value composed with the accumulated delta. Works on GSAP-animated elements (a `translate` set after GSAP's first parse composes with the animated transform). The **SDK is not called here** — this is a direct write to the preview surface by your pointer-move handler. Switching `id` mid-drag reverts the previous element's draft first.
</ParamField>
<ParamField path="commitPreview" type="() => void">
Called once on pointer-up. Reads the accumulated draft delta, derives a `moveElement` op from it, dispatches it into the SDK, emits a patch event, and mirrors the committed position onto the live element (so it holds without a reload). This is the only moment the SDK becomes aware of a drag. If dispatch throws, the draft translate is reverted and the error propagates.
</ParamField>
<ParamField path="cancelPreview" type="() => void">
Restores the element's pre-drag `translate` without dispatching any op. The model is never changed. Call this on `Escape` keydown or when a drag is aborted.
</ParamField>
<ParamField path="select" type="(ids: string[], opts?: { additive?: boolean }) => void">
Sets the preview selection and fires `selectionchange` on the session. Pass `{ additive: true }` to merge `ids` into the current selection rather than replacing it.
</ParamField>
<ParamField path="on" type='(event: "selection", handler: (ids: string[]) => void) => () => void'>
Fired when the preview host changes the selection (for example, the user clicks an element). Returns an unsubscribe function. In the current release, callers listen to the session's own `selectionchange` event instead — this hook is wired in a future stage.
</ParamField>
<ParamField path="attachSync" type="(comp: Composition) => () => void">
Mirrors a composition's edits onto the adapter's own live document: an immediate full sync of the composition's current overrides, then a subscription that replays every future `patch` event — including undo/redo, since both fire through the same event with forward or inverse patches. Calling `attachSync` again while already attached detaches the previous subscription first. The full-override sync also re-runs on every iframe `load`, so a `srcdoc` navigation that races the attach (or drops patches committed during the load window) converges once the new document arrives. Returns an unsubscribe function.
Script-tag patches (`/script/gsap` and any future `/script/*` path) are never mirrored — rewriting a live `<script>` tag's content doesn't re-execute it, and re-running GSAP setup from scratch would conflict with running timeline state. Every other patch kind (style, text, attribute, timing, hold, element add/remove, stylesheet, variable value, variable declaration) mirrors as-is.
```typescript
const adapter = createIframePreviewAdapter(iframe, dispatch);
const comp = await openComposition(html, { preview: adapter });
const detach = adapter.attachSync(comp);
// later, if the host tears down the preview:
detach();
```
</ParamField>
### ElementAtPointResult
```typescript
interface ElementAtPointResult {
id: string;
tag: string;
}
```
The `id` is the element's `data-hf-id` value; `tag` is its lowercase tag name (e.g. `"div"`, `"img"`).
### DraftProps
```typescript
interface DraftProps {
dx?: number;
dy?: number;
width?: number;
height?: number;
}
```
`dx` and `dy` are the accumulated drag deltas in composition pixels. `width` and `height` are defined in the interface for forward compatibility but are not yet wired to any op.
<Note>
`ElementAtPointResult` and `DraftProps` are the structural shapes a `PreviewAdapter` produces and consumes. They are **not** re-exported from the `@hyperframes/sdk` barrel — you implement against these shapes rather than importing them.
</Note>
---
## Factory Functions
### createMemoryAdapter
```typescript
import { createMemoryAdapter } from "@hyperframes/sdk";
function createMemoryAdapter(): PersistAdapter & { injectFault(message: string): void };
```
Returns a `PersistAdapter` backed by an in-process `Map`. Writes are synchronous; `flush()` is a no-op. Versions are keyed `"v1"`, `"v2"` … and stored in memory with full content.
The returned value also exposes `injectFault(message)` — a test helper that causes the **next** `write()` call to fire a `persist:error` event with `message` instead of committing. Use this in unit tests to verify your error-handling code path.
```typescript
const persist = createMemoryAdapter();
const comp = await openComposition(html, { persist });
comp.setText("hf-title", "Hello");
await comp.flush();
const saved = await persist.read("composition.html");
```
<Note>
`createMemoryAdapter()` is best suited for tests, demos, and ephemeral in-process sessions. For local development, use `createFsAdapter()` so edits survive restarts.
</Note>
---
### createFsAdapter
```typescript
import { createFsAdapter } from "@hyperframes/sdk/adapters/fs";
function createFsAdapter(opts: FsAdapterOptions): PersistAdapter;
```
**Node.js only.** Returns a `PersistAdapter` that reads and writes files under a root directory. Import from the `@hyperframes/sdk/adapters/fs` subpath — this module uses Node `fs/promises` and is excluded from the browser-safe main bundle.
#### FsAdapterOptions
```typescript
interface FsAdapterOptions {
/** Root directory for composition files. */
root: string;
/** Max versions to keep per file. Default: 20. */
maxVersions?: number;
}
```
<ParamField path="root" type="string" required>
Absolute or relative path to the directory where composition files are written. Created with `mkdir -p` on first write.
</ParamField>
<ParamField path="maxVersions" type="number">
Maximum number of historical versions retained per file. Oldest versions are pruned automatically when the limit is exceeded. Defaults to `20`.
</ParamField>
The adapter writes the current composition at `{root}/{path}` and stores version snapshots in `{root}/.hf-versions/{path}/`. Version keys encode `Date.now()` and a monotonic counter (`"1750000000000-0001"`), so `listVersions()` returns them newest-first by lexicographic descending sort.
```typescript
import { openComposition } from "@hyperframes/sdk";
import { createFsAdapter } from "@hyperframes/sdk/adapters/fs";
const comp = await openComposition(html, {
persist: createFsAdapter({ root: "./project", maxVersions: 50 }),
persistPath: "index.html",
});
comp.setText("hf-title", "Saved");
await comp.flush();
// List saved versions
const adapter = createFsAdapter({ root: "./project" });
const versions = await adapter.listVersions("index.html");
const previous = await adapter.loadFrom("index.html", versions[1].key);
```
<Warning>
`createFsAdapter` uses Node.js `fs/promises`. Do not import it in browser or edge environments — import from `@hyperframes/sdk/adapters/fs` (the subpath) so bundlers can tree-shake it.
</Warning>
---
### createHeadlessAdapter
```typescript
import { createHeadlessAdapter } from "@hyperframes/sdk";
function createHeadlessAdapter(): PreviewAdapter;
```
Returns a no-op `PreviewAdapter` for headless use: agents, CI pipelines, and server-side rendering. All methods are stubs — `elementAtPoint` always returns `null`, `applyDraft` and `commitPreview` are no-ops, and the `"selection"` event never fires.
Pass this adapter when you open a composition for programmatic editing and do not need a live preview surface.
```typescript
import { openComposition, createHeadlessAdapter } from "@hyperframes/sdk";
const comp = await openComposition(html, {
preview: createHeadlessAdapter(),
});
```
<Note>
`openComposition` does not create a preview adapter automatically. Omit `preview` when no preview
surface is needed, or pass `createHeadlessAdapter()` when an explicit no-op adapter makes shared
code clearer.
</Note>
---
### createIframePreviewAdapter
```typescript
import { createIframePreviewAdapter } from "@hyperframes/sdk";
function createIframePreviewAdapter(
iframe: HTMLIFrameElement,
dispatch?: (op: EditOp) => void,
): PreviewAdapter;
```
Returns a `PreviewAdapter` that bridges the SDK to a same-origin `<iframe>` containing the composition. Provides real hit-testing via `elementsFromPoint` (z-stack aware), draft drag support, and selection management.
**Requirements:**
- The iframe must be same-origin (e.g. a `srcdoc` or `blob:` URL). Cross-origin access to `contentDocument` throws a `DOMException`.
- Pass your session's `dispatch` callback to enable `commitPreview()` — without it, pointer-up is a no-op on the model.
**Image-alpha hit-testing:** For `<img>` elements, the adapter samples the alpha channel of the pixel under the pointer using an `OffscreenCanvas`. Transparent pixels fall through to the element behind. Cross-origin images that taint the canvas are treated as opaque (safe fallback, logged once per src).
```typescript
import { openComposition, createIframePreviewAdapter } from "@hyperframes/sdk";
const iframe = document.querySelector<HTMLIFrameElement>("#preview-frame")!;
// Optional, and dispatched with `?.` — the callback cannot fire before
// openComposition() resolves, but strict TypeScript cannot prove that and
// rejects a definite `let` captured before assignment (TS2454).
let comp: Awaited<ReturnType<typeof openComposition>> | undefined;
const preview = createIframePreviewAdapter(iframe, (op) => comp?.dispatch(op));
comp = await openComposition(html, { preview });
// Hit-test at pointer position
const hit = preview.elementAtPoint(pointerX, pointerY);
if (hit) {
preview.select([hit.id]);
// Drag: call applyDraft at 60fps, commitPreview on pointer-up
preview.applyDraft(hit.id, { dx: 12, dy: -5 });
preview.commitPreview();
}
```
---
## Export Map
| Symbol | Imported from |
|--------|---------------|
| `PersistAdapter`, `PreviewAdapter`, `PersistVersionEntry` | `@hyperframes/sdk` (types only) |
| `createMemoryAdapter` | `@hyperframes/sdk` |
| `createHeadlessAdapter` | `@hyperframes/sdk` |
| `createIframePreviewAdapter`, `resolveNearestHfElement` | `@hyperframes/sdk` |
| `createFsAdapter`, `FsAdapterOptions` | `@hyperframes/sdk/adapters/fs` |
<CardGroup cols={2}>
<Card title="Persistence Guide" icon="floppy-disk" href="/sdk/guides/persistence">
How to wire adapters into openComposition, handle errors, and restore versions.
</Card>
<Card title="Canvas Integration" icon="browser" href="/sdk/guides/canvas-integration">
Building a visual editor canvas with the iframe preview adapter and hit-testing.
</Card>
</CardGroup>