Skip to content

Commit a0ee972

Browse files
vanceingallsclaude
andauthored
fix(sdk,core): css tokenizer, override-set replay, setattribute safety, persist errors (#1350)
* fix(sdk,core): css tokenizer, override-set replay, setattribute safety, persist errors * test(sdk,ci): smoke test + explicit sdk-tests CI gate Smoke test covers the full public surface: openComposition → setStyle/setText/dispatch(moveElement) → serialize applyPatches + ORIGIN_APPLY_PATCHES tagging batch() coalescing + transactional rollback on throw undo/redo round-trip persist adapter write + persist:error surfacing T3 embedded mode: override-set apply on open + getOverrides round-trip Adds sdk-tests CI job so SDK coverage is explicitly named and required — prevents a repeat of the demo-next vitest-never-ran incident. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(sdk): export adapter types, awaitable flush(), never-coalesce mode - Export PersistAdapter, PreviewAdapter, PersistVersionEntry from package root — callers can now write typed fakes without reaching into internals - Add flush(): Promise<void> to Composition interface + CompositionImpl — app-close handlers can await a clean drain of the persist queue - coalesceMs <= 0 disables coalescing entirely in createHistory — enables deterministic test scenarios without per-entry timestamp manipulation Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test(sdk): p2 edge cases — setText no-text-node, override-remove non-existent, flush in smoke - setText on element with no prior text node (firstTextIdx=-1 path) - applyOverrideSet null removal on non-existent prop is a no-op (no throw) - smoke persist test uses comp.flush() instead of setTimeout - can() JSDoc clarifies Phase 3b false-return is intentional feature-detection Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * ci: trigger regression suite * fix(ci): add packages/sdk/package.json to Dockerfile.test workspace copy bun install --frozen-lockfile fails in the regression Docker build because the lockfile references the sdk workspace member but its package.json was not copied into the image before the install step. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 69d67f1 commit a0ee972

14 files changed

Lines changed: 436 additions & 28 deletions

File tree

.github/workflows/ci.yml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,18 @@ jobs:
218218
- run: bun run --cwd packages/core build:hyperframes-runtime
219219
- run: bun run --filter '!@hyperframes/producer' test
220220

221+
sdk-tests:
222+
name: "SDK: unit + contract + smoke"
223+
needs: changes
224+
if: needs.changes.outputs.code == 'true'
225+
runs-on: ubuntu-latest
226+
timeout-minutes: 5
227+
steps:
228+
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
229+
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
230+
- run: bun install --frozen-lockfile
231+
- run: bun run --filter @hyperframes/sdk test
232+
221233
test-runtime-contract:
222234
name: "Test: runtime contract"
223235
needs: changes

Dockerfile.test

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ COPY packages/studio/package.json packages/studio/package.json
8080
COPY packages/shader-transitions/package.json packages/shader-transitions/package.json
8181
COPY packages/aws-lambda/package.json packages/aws-lambda/package.json
8282
COPY packages/gcp-cloud-run/package.json packages/gcp-cloud-run/package.json
83+
COPY packages/sdk/package.json packages/sdk/package.json
8384
RUN bun install --frozen-lockfile
8485

8586
# Copy source

packages/core/src/studio-api/helpers/sourceMutation.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -223,10 +223,35 @@ function isSafeAttributeValue(name: string, value: string): boolean {
223223
return true;
224224
}
225225

226+
// fallow-ignore-next-line complexity
226227
function patchStyleAttrString(style: string, property: string, value: string | null): string {
227228
const props = new Map<string, string>();
228229
const order: string[] = [];
229-
for (const decl of style.split(";")) {
230+
// Tokenize declarations robustly: values can contain ';' inside quoted strings
231+
// (e.g. content: ';') and ':' inside values (data URIs, url(), etc.).
232+
// Split on ';' only when outside quotes and balanced parens; the first ':' in
233+
// the resulting segment is the property/value separator (property names never
234+
// contain ':').
235+
let i = 0;
236+
while (i < style.length) {
237+
let depth = 0;
238+
let inSingle = false;
239+
let inDouble = false;
240+
const start = i;
241+
while (i < style.length) {
242+
const ch = style[i];
243+
if (ch === "'" && !inDouble) inSingle = !inSingle;
244+
else if (ch === '"' && !inSingle) inDouble = !inDouble;
245+
else if (!inSingle && !inDouble) {
246+
if (ch === "(") depth++;
247+
else if (ch === ")") depth = Math.max(0, depth - 1);
248+
else if (ch === ";" && depth === 0) break;
249+
}
250+
i++;
251+
}
252+
const decl = style.slice(start, i).trim();
253+
i++; // advance past ';'
254+
if (!decl) continue;
230255
const colon = decl.indexOf(":");
231256
if (colon < 0) continue;
232257
const key = decl.slice(0, colon).trim();

packages/sdk/src/engine/apply-patches.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -59,16 +59,17 @@ function parsePath(path: string): ParsedPath | null {
5959

6060
/**
6161
* Replay a stored override-set onto a freshly-parsed base document (T3 init).
62-
* Property keys with null mean "restore base value" — a no-op on a fresh base.
63-
* Bare element keys with null are removal markers — the element is removed.
62+
* A null value means the property was explicitly deleted — emit a remove patch
63+
* so the base document matches the session state. (Removing a non-existent
64+
* property is a no-op in applyOne, so this is safe against fresh-base misses.)
6465
*/
6566
export function applyOverrideSet(parsed: ParsedDocument, overrides: OverrideSet): void {
6667
const patches: JsonPatchOp[] = [];
6768
for (const [key, value] of Object.entries(overrides)) {
6869
const path = keyToPath(key);
6970
if (!path) continue;
7071
if (value === null) {
71-
if (!key.includes(".")) patches.push({ op: "remove", path });
72+
patches.push({ op: "remove", path });
7273
continue;
7374
}
7475
patches.push({ op: "replace", path, value });

packages/sdk/src/engine/mutate.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,17 @@ describe("setText", () => {
140140
expect(serializeDocument(parsed)).toBe(before);
141141
});
142142

143+
it("creates text node when element has no existing text node", () => {
144+
const parsed = parseMutable(
145+
'<div data-hf-id="hf-s" data-hf-root><span data-hf-id="hf-empty"></span></div>',
146+
);
147+
const result = applyOp(parsed, { type: "setText", target: "hf-empty", value: "Added" });
148+
const el = parsed.document.querySelector('[data-hf-id="hf-empty"]');
149+
expect(el?.textContent).toBe("Added");
150+
expect(result.forward[0]?.op).toBe("replace");
151+
expect(result.forward[0]?.value).toBe("Added");
152+
});
153+
143154
it("override-set key maps correctly", () => {
144155
expect(pathToKey("/elements/hf-title/text")).toBe("hf-title.text");
145156
});

packages/sdk/src/engine/mutate.ts

Lines changed: 59 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -40,19 +40,57 @@ export interface MutationResult {
4040

4141
const EMPTY: MutationResult = { forward: [], inverse: [] };
4242

43-
/** Ops that require the Phase 3b parser-backed engine (meriyah/css-tree). */
44-
const PHASE3B_OPS = new Set([
45-
"setClassStyle",
46-
"addGsapTween",
47-
"setGsapTween",
48-
"setGsapKeyframe",
49-
"addGsapKeyframe",
50-
"removeGsapKeyframe",
51-
"removeGsapTween",
52-
"addLabel",
53-
"removeLabel",
43+
// ─── setAttribute safety ────────────────────────────────────────────────────
44+
45+
// Composition-reserved attributes — changing these breaks element identity or
46+
// the core/studio data model. Reject before mutating.
47+
const RESERVED_ATTRS = new Set([
48+
"data-hf-id",
49+
"data-composition-id",
50+
"data-width",
51+
"data-height",
52+
"data-start",
53+
"data-end",
54+
"data-track-index",
55+
"data-hold-start",
56+
"data-hold-end",
57+
"data-hold-fill",
5458
]);
5559

60+
const DANGEROUS_URI_SCHEMES = /^(?:javascript|vbscript):/i;
61+
const DANGEROUS_DATA_URI = /^data\s*:\s*text\/html/i;
62+
const URI_BEARING_ATTRS = new Set([
63+
"src",
64+
"href",
65+
"action",
66+
"formaction",
67+
"poster",
68+
"srcset",
69+
"xlink:href",
70+
]);
71+
72+
function validateSetAttribute(name: string, value: string | null): void {
73+
const lower = name.toLowerCase();
74+
if (RESERVED_ATTRS.has(lower)) {
75+
throw new Error(
76+
`setAttribute: "${name}" is a reserved composition attribute and cannot be reassigned. ` +
77+
`Use the appropriate typed method (setTiming, setHold, etc.) instead.`,
78+
);
79+
}
80+
if (lower.startsWith("on")) {
81+
throw new Error(
82+
`setAttribute: event-handler attributes ("${name}") are not permitted — ` +
83+
`they produce executable HTML that cannot be safely serialized.`,
84+
);
85+
}
86+
if (value !== null && URI_BEARING_ATTRS.has(lower)) {
87+
const trimmed = value.trim();
88+
if (DANGEROUS_URI_SCHEMES.test(trimmed) || DANGEROUS_DATA_URI.test(trimmed)) {
89+
throw new Error(`setAttribute: unsafe URI value for "${name}".`);
90+
}
91+
}
92+
}
93+
5694
export class UnsupportedOpError extends Error {
5795
// Stable error code — part of the public API contract (F7); hosts switch on
5896
// err.code rather than the message.
@@ -169,7 +207,11 @@ function handleSetText(parsed: ParsedDocument, ids: HfId[], value: string): Muta
169207
const oldText = getOwnText(el);
170208
setOwnText(el, value);
171209
const path = textPath(id);
172-
const p = scalarChange(path, oldText || null, value);
210+
// getOwnText always returns string ("" for empty) — use it directly so
211+
// the forward patch is always op:'replace', not op:'add'. An op:'add' on
212+
// a text path is semantically wrong for external JSON-patch consumers
213+
// (the path already exists; add would fail on strict appliers).
214+
const p = scalarChange(path, oldText, value);
173215
result.forward.push(p.forward);
174216
result.inverse.push(p.inverse);
175217
}
@@ -182,6 +224,7 @@ function handleSetAttribute(
182224
name: string,
183225
value: string | null,
184226
): MutationResult {
227+
validateSetAttribute(name, value);
185228
const result: MutationResult = { forward: [], inverse: [] };
186229
for (const id of ids) {
187230
const el = findById(parsed.document, id);
@@ -400,9 +443,10 @@ export function validateOp(parsed: ParsedDocument, op: EditOp): boolean {
400443
return findRoot(parsed.document) !== null;
401444
case "setCompositionMetadata":
402445
return true;
403-
// Phase 3b — not implemented yet; can() must report false so callers
404-
// can feature-detect instead of hitting UnsupportedOpError.
446+
// Phase 3b and unknown ops — report false so callers can feature-detect.
447+
// An unknown op type must never silently pass validation only to no-op
448+
// or throw in applyOp (which would violate the can() contract).
405449
default:
406-
return !PHASE3B_OPS.has(op.type);
450+
return false;
407451
}
408452
}

packages/sdk/src/engine/patches.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,9 @@ export function keyToPath(key: string): string | null {
115115
if (text?.[1]) return textPath(text[1]);
116116

117117
const attr = /^([^.]+)\.attr\.(.+)$/.exec(key);
118-
if (attr?.[1] && attr[2]) return attrPath(attr[1], attr[2]);
118+
// pathToKey stores the RFC 6902-encoded segment verbatim; do NOT call attrPath()
119+
// here (it would re-escape '~' → '~0'), just reconstruct the path directly.
120+
if (attr?.[1] && attr[2]) return `/elements/${attr[1]}/attributes/${attr[2]}`;
119121

120122
const timing = /^([^.]+)\.timing\.(start|end|trackIndex)$/.exec(key);
121123
if (timing?.[1]) return timingPath(timing[1], timing[2] as "start" | "end" | "trackIndex");

packages/sdk/src/history.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ export function createHistory(session: Composition, opts: HistoryOptions = {}):
6868
}
6969

7070
function shouldCoalesce(entry: HistoryEntry, incoming: PatchEvent): boolean {
71+
if (coalesceMs <= 0) return false;
7172
if (opTypesKey(entry.opTypes) !== opTypesKey(incoming.opTypes)) return false;
7273
if (entry.origin !== incoming.origin) return false;
7374
// Coalesce only when the SAME paths are touched (e.g. slider drag on one

packages/sdk/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,5 @@ export type { HistoryModule, HistoryOptions, HistoryEntry } from "./history.js";
3030

3131
export { createPersistQueue } from "./persist-queue.js";
3232
export type { PersistQueueModule, PersistQueueOptions } from "./persist-queue.js";
33+
34+
export type { PersistAdapter, PreviewAdapter, PersistVersionEntry } from "./adapters/types.js";

packages/sdk/src/persist-queue.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
* T3 (embedded) hosts own persistence — do not use this module.
99
*/
1010

11-
import type { Composition } from "./types.js";
11+
import type { Composition, PersistErrorEvent } from "./types.js";
1212
import type { PersistAdapter } from "./adapters/types.js";
1313

1414
export interface PersistQueueModule {
@@ -20,6 +20,8 @@ export interface PersistQueueModule {
2020
export interface PersistQueueOptions {
2121
/** Adapter path to write to. Default: "composition.html" */
2222
path?: string;
23+
/** Called when adapter.write() rejects. */
24+
onError?: (e: PersistErrorEvent) => void;
2325
}
2426

2527
export function createPersistQueue(
@@ -48,8 +50,9 @@ export function createPersistQueue(
4850
if (disposed) return;
4951
try {
5052
await adapter.write(path, content);
51-
} catch {
52-
// error already surfaced via persist:error on the adapter
53+
} catch (err) {
54+
const message = err instanceof Error ? err.message : String(err);
55+
opts.onError?.({ error: { message, cause: err } });
5356
}
5457
});
5558
return writeChain;

0 commit comments

Comments
 (0)