feat(sdk): file-backed fs adapter + setTiming GSAP sync; sdk-playground workspace (#1458)

* feat(sdk): file-backed fs adapter + setTiming GSAP-script sync; add sdk-playground

* fix(sdk): address PR #1423 review — oxfmt, PersistVersionEntry contract, race, comments

- bunx oxfmt packages/sdk-playground/index.html (unblocks CI)
- PersistVersionEntry.content is now optional; HTTP adapter omits it for lazy-load
- fs adapter: monotonic key (Date.now-NNNN) + per-path write serialization via promise chain
- mutate.ts: fix wrong comment on GSAP sync reason; add caveat to "pre-parse once" note

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>

* fix(core): oxfmt gsapSerialize.ts — unblocks Preflight across stack

Pre-existing format issue on the base; fixing here to unblock CI.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>

* chore: update bun.lock for sdk-playground workspace

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-06-15 01:55:19 -07:00
committed by GitHub
co-authored by Claude Sonnet 4.6 Miguel Ángel
parent 3bfb25efcf
commit 577a689860
12 changed files with 2535 additions and 51 deletions
+96 -14
View File
@@ -1,44 +1,126 @@
import type { PersistAdapter, PersistVersionEntry } from "./types.js";
import type { PersistErrorEvent } from "../types.js";
import { readFile, writeFile, mkdir, readdir, unlink } from "node:fs/promises";
import { join, dirname } from "node:path";
export interface FsAdapterOptions {
/** Root directory for composition files */
root: string;
/** Max versions to keep per file. Default: 20 */
maxVersions?: number;
}
// Phase 4 — fs adapter stub. Full implementation in SDK Phase 4 (adapters stage).
// Uses Node.js fs/promises; not browser-safe (must be conditionally imported by consumers).
const DEFAULT_MAX_VERSIONS = 20;
let _versionCounter = 0;
class FsAdapter implements PersistAdapter {
private readonly root: string;
private readonly maxVersions: number;
private errorHandlers: Array<(e: PersistErrorEvent) => void> = [];
private _writeLocks = new Map<string, Promise<void>>();
constructor(opts: FsAdapterOptions) {
this.root = opts.root;
this.maxVersions = opts.maxVersions ?? DEFAULT_MAX_VERSIONS;
}
async read(_path: string): Promise<string | undefined> {
throw new Error("FsAdapter: Phase 4 — not yet implemented");
async read(path: string): Promise<string | undefined> {
try {
return await readFile(this.abs(path), "utf8");
} catch (err: unknown) {
if (isNotFound(err)) return undefined;
throw err;
}
}
async write(_path: string, _content: string): Promise<void> {
throw new Error("FsAdapter: Phase 4 — not yet implemented");
async write(path: string, content: string): Promise<void> {
try {
const abs = this.abs(path);
await mkdir(dirname(abs), { recursive: true });
await writeFile(abs, content, "utf8");
await this.appendVersion(path, content);
} catch (err) {
for (const h of this.errorHandlers) h({ error: { message: String(err), cause: err } });
}
}
async flush(): Promise<void> {
throw new Error("FsAdapter: Phase 4 — not yet implemented");
async flush(): Promise<void> {}
async listVersions(path: string): Promise<PersistVersionEntry[]> {
const dir = this.versionsDir(path);
try {
const entries = await readdir(dir);
const sorted = entries
.filter((f) => f.endsWith(".html"))
.sort()
.reverse();
return Promise.all(
sorted.map(async (f) => {
const key = f.replace(/\.html$/, "");
return {
key,
content: await readFile(join(dir, f), "utf8"),
timestamp: Number(key.split("-")[0]),
};
}),
);
} catch {
return [];
}
}
async listVersions(_path: string): Promise<PersistVersionEntry[]> {
throw new Error("FsAdapter: Phase 4 — not yet implemented");
async loadFrom(path: string, versionKey: string): Promise<string | undefined> {
try {
return await readFile(join(this.versionsDir(path), `${versionKey}.html`), "utf8");
} catch {
return undefined;
}
}
async loadFrom(_path: string, _versionKey: string): Promise<string | undefined> {
throw new Error("FsAdapter: Phase 4 — not yet implemented");
on(event: "persist:error", handler: (e: PersistErrorEvent) => void): () => void {
if (event !== "persist:error") return () => {};
this.errorHandlers.push(handler);
return () => {
const i = this.errorHandlers.indexOf(handler);
if (i !== -1) this.errorHandlers.splice(i, 1);
};
}
on(_event: "persist:error", _handler: (e: PersistErrorEvent) => void): () => void {
return () => {};
private abs(path: string): string {
return join(this.root, path);
}
private versionsDir(path: string): string {
return join(this.root, ".hf-versions", path);
}
private async appendVersion(path: string, content: string): Promise<void> {
const prior = this._writeLocks.get(path) ?? Promise.resolve();
const next = prior.then(() => this._doAppendVersion(path, content));
this._writeLocks.set(
path,
next.catch(() => {}),
);
return next;
}
private async _doAppendVersion(path: string, content: string): Promise<void> {
const dir = this.versionsDir(path);
await mkdir(dir, { recursive: true });
const key = `${Date.now()}-${String(++_versionCounter).padStart(4, "0")}`;
await writeFile(join(dir, `${key}.html`), content, "utf8");
// prune oldest beyond maxVersions
const all = (await readdir(dir)).filter((f) => f.endsWith(".html")).sort();
const excess = all.length - this.maxVersions;
if (excess > 0) {
await Promise.all(all.slice(0, excess).map((f) => unlink(join(dir, f)).catch(() => {})));
}
}
}
function isNotFound(err: unknown): boolean {
return (err as NodeJS.ErrnoException)?.code === "ENOENT";
}
export function createFsAdapter(opts: FsAdapterOptions): PersistAdapter {
+2 -1
View File
@@ -5,7 +5,8 @@ import type { PersistErrorEvent } from "../types.js";
export interface PersistVersionEntry {
/** Opaque key identifying this version (adapter-defined format) */
key: string;
content: string;
/** Full HTML content — may be omitted by adapters that load content lazily via loadFrom() */
content?: string;
timestamp?: number;
}
+32
View File
@@ -285,6 +285,13 @@ function handleSetTiming(
timing: { start?: number; duration?: number; trackIndex?: number },
): MutationResult {
const result: MutationResult = { forward: [], inverse: [] };
// Parse GSAP script once; updateAnimationInScript re-parses internally per call but
// we avoid re-fetching the script element on every iteration.
const origScript = getGsapScript(parsed.document);
const parsedGsap = origScript ? parseGsapScriptAcornForWrite(origScript) : null;
let currentScript = origScript;
for (const id of ids) {
const el = findById(parsed.document, id);
if (!el) continue;
@@ -332,7 +339,32 @@ function handleSetTiming(
result.inverse.push(p.inverse);
el.setAttribute("data-track-index", String(newTrack));
}
// Sync GSAP tween positions: the GSAP script is the source of truth at play time —
// the timeline rebuilds from it on every seek. Without this, DOM attribute edits
// have zero playback effect; the script's position/duration silently overrides them.
if (parsedGsap && currentScript) {
for (const { id: animId, animation } of parsedGsap.located) {
const sel = animation.targetSelector;
if (sel !== `[data-hf-id="${id}"]` && sel !== `[data-hf-id='${id}']` && sel !== `#${id}`)
continue;
const updates: Partial<GsapAnimation> = {};
if (timing.start !== undefined && newStart !== null) updates.position = newStart;
if (timing.duration !== undefined && newDuration !== null) updates.duration = newDuration;
if (Object.keys(updates).length === 0) continue;
currentScript = updateAnimationInScript(currentScript, animId, updates);
}
}
}
// Flush accumulated GSAP script changes as a single patch pair.
if (origScript && currentScript && currentScript !== origScript) {
setGsapScript(parsed.document, currentScript);
const gsapResult = gsapScriptChange(origScript, currentScript);
result.forward.push(...gsapResult.forward);
result.inverse.push(...gsapResult.inverse);
}
return result;
}