From fac226a78e46d66fcb2d46b98b9c4b80ca624dbb Mon Sep 17 00:00:00 2001 From: Lann Martin Date: Sun, 6 Sep 2026 14:35:53 -0400 Subject: [PATCH 1/2] Test dependency: linkedom, a DOM for receiver unit tests under deno test --- deno.json | 3 +- deno.lock | 111 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 113 insertions(+), 1 deletion(-) diff --git a/deno.json b/deno.json index 80f1665..66bff1a 100644 --- a/deno.json +++ b/deno.json @@ -14,7 +14,8 @@ "@std/path": "jsr:@std/path@^1", "@std/fs": "jsr:@std/fs@^1", "@std/http/file-server": "jsr:@std/http@^1/file-server", - "playwright": "npm:playwright@^1.58" + "playwright": "npm:playwright@^1.58", + "linkedom": "npm:linkedom@^0.18" }, "compilerOptions": { "lib": ["deno.ns", "dom", "dom.iterable", "esnext"], diff --git a/deno.lock b/deno.lock index cd2891d..7ff2d65 100644 --- a/deno.lock +++ b/deno.lock @@ -25,6 +25,7 @@ "jsr:@std/path@^1.1.6": "1.1.6", "jsr:@std/streams@^1.1.2": "1.1.2", "npm:@remote-dom/core@^1.11.1": "1.11.1", + "npm:linkedom@0.18": "0.18.13", "npm:playwright@1.58": "1.58.2", "npm:playwright@^1.58.0": "1.58.2" }, @@ -119,6 +120,84 @@ "@remote-dom/polyfill@1.5.1": { "integrity": "sha512-eaWdIVKZpNfbqspKkRQLVxiFv/7vIw8u0FVA5oy52YANFbO/WVT0GU+PQmRt/QUSijaB36HBAqx7stjo8HGpVQ==" }, + "boolbase@2.0.0": { + "integrity": "sha512-DkVaaQHymRhpYEYo9x1oo7Q7B0Y6KJUsjm3c9eTyFDby4MHLBTwZ6ZDWBel5zrYxj1WsZgC5oLpiz+93MluXeA==" + }, + "css-select@7.0.0": { + "integrity": "sha512-snmjEVXy+1LnwXdxhYvTMj1d9tOh4HxkA1YmoayVBeeyR2C14Pum7fcxJIm4SswYspVy866eYNwlH6xC3/VH5g==", + "dependencies": [ + "boolbase", + "css-what", + "domhandler@6.0.1", + "domutils@4.0.2", + "nth-check" + ] + }, + "css-what@8.0.0": { + "integrity": "sha512-DH0Bqq3DNp5tdOReuNyAA+Ev4Y2GS5FMbZpeTLP6C4CDi0h5nL0BmUPChXw3o/qbHLDWHl49sbNqQVY7bMSDdw==" + }, + "cssom@0.5.0": { + "integrity": "sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==" + }, + "dom-serializer@2.0.0": { + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dependencies": [ + "domelementtype@2.3.0", + "domhandler@5.0.3", + "entities@4.5.0" + ] + }, + "dom-serializer@3.1.1": { + "integrity": "sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==", + "dependencies": [ + "domelementtype@3.0.0", + "domhandler@6.0.1", + "entities@8.0.0" + ] + }, + "domelementtype@2.3.0": { + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==" + }, + "domelementtype@3.0.0": { + "integrity": "sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==" + }, + "domhandler@5.0.3": { + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dependencies": [ + "domelementtype@2.3.0" + ] + }, + "domhandler@6.0.1": { + "integrity": "sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==", + "dependencies": [ + "domelementtype@3.0.0" + ] + }, + "domutils@3.2.2": { + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "dependencies": [ + "dom-serializer@2.0.0", + "domelementtype@2.3.0", + "domhandler@5.0.3" + ] + }, + "domutils@4.0.2": { + "integrity": "sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==", + "dependencies": [ + "dom-serializer@3.1.1", + "domelementtype@3.0.0", + "domhandler@6.0.1" + ] + }, + "entities@4.5.0": { + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==" + }, + "entities@7.0.1": { + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==" + }, + "entities@8.0.0": { + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==" + }, "fsevents@2.3.2": { "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", "os": ["darwin"], @@ -127,6 +206,34 @@ "htm@3.1.1": { "integrity": "sha512-983Vyg8NwUE7JkZ6NmOqpCZ+sh1bKv2iYTlUkzlWmA5JD2acKoxd4KVxbMmxX/85mtfdnDmTFoNKcg5DGAvxNQ==" }, + "html-escaper@3.0.3": { + "integrity": "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==" + }, + "htmlparser2@10.1.0": { + "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", + "dependencies": [ + "domelementtype@2.3.0", + "domhandler@5.0.3", + "domutils@3.2.2", + "entities@7.0.1" + ] + }, + "linkedom@0.18.13": { + "integrity": "sha512-ES/o9qotMpzpN2MHs+Iq/JcVoOj8Fa5wiQYrTdFpvAnwXL0g66XHHUc9WUMk6nAlBtGsFQ24ne+SYnvnaQ2FSw==", + "dependencies": [ + "css-select", + "cssom", + "html-escaper", + "htmlparser2", + "uhyphen" + ] + }, + "nth-check@3.0.1": { + "integrity": "sha512-GX0gsdbGVCgnRgbeGaubfjpBXyYRWOOCVeYh08bSQvDZqxz5ndXs1OTfAt/h36G1xvI94YIspsI0sVFqAV9+RQ==", + "dependencies": [ + "boolbase" + ] + }, "playwright-core@1.58.2": { "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", "bin": true @@ -140,6 +247,9 @@ "fsevents" ], "bin": true + }, + "uhyphen@0.2.0": { + "integrity": "sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA==" } }, "workspace": { @@ -153,6 +263,7 @@ "jsr:@std/http@1", "jsr:@std/path@1", "npm:@remote-dom/core@^1.11.1", + "npm:linkedom@0.18", "npm:playwright@^1.58.0" ] } From 46b0a5250048375179c2342068426750f4a2b0a3 Mon Sep 17 00:00:00 2001 From: Lann Martin Date: Sun, 6 Sep 2026 15:15:40 -0400 Subject: [PATCH 2/2] Receiver: fail closed on hostile streams; split the DOM driver out of mount MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two tracks, one purpose: a policy (docs/design.md "Policy") is only as good as the receiver behind it, and a receiver that cannot be driven without a component cannot sit behind a frame boundary at all. Hardening (native.ts, receiver.ts, frames.ts): - Mount root (id 0) is structurally inviolable: no create/re-register/ alias/move/remove, and not usable as an insert anchor — with `parent` omitted, anchor 0 implied the embedder's container as parent and put a producer node beside the mount; the fuzzer found this. - Ids: re-registering a live id or binding a node that already has an id throws. Reuse after `remove` is documented as undetectable. - Type checks: set-text requires a text/comment node; set-attribute and set-property require an element. - ListenerRegistry.stringFor throws on an unknown ref instead of returning "" (define-before-use per the proto; re-interning a live slot stays legal per the proto's "Define (or overwrite)"). - FrameDecoder: MAX_FRAME_BYTES (16 MiB) ceiling on the length prefix, so a hostile length no longer buffers forever. #forgetSubtree is iterative. - Tests: native_test.ts (the native receiver's first unit tests, under linkedom with a shim reproducing the browser's HierarchyRequestError guarantee) and hostile_test.ts (seeded structured-op fuzzer, 3000 runs; byte-mutation fuzzer over basic.pb, 20000 runs; root and sibling sentinel invariants after every push or throw). Driver split (driver.ts new, mount.ts 629 -> 208 lines): - createDriver({ root, receiver, policy, onError, handleEvent }) owns backend selection, policy compilation, decoding under the dispatch gate, listener delegation/attach/detach, synthetic navigation, payload encoding, and the policy-gated queries. Bytes in via push(), events out via the callback. A move, not a rewrite; MountOptions/Mounted unchanged. - mount.ts is the component glue: instantiate, wasi imports, the dom-event resource, run/handle-event, the two read transports, stream.drop(). - driver_test.ts: bytes in / DOM out with no component, events out with and without an event-field filter, declarative prevent_default, gated queries, PolicyError and unknown-id aborts, teardown. Design record: "Policy" now states what the native receiver guarantees underneath a policy and what it cannot see; the driver makes the Transports claim about non-component receivers true. linkedom added as a test dependency. Gates: deno check/test (92), fmt, lint, just e2e (both receivers x both producers), just bench-wire (all wire shapes match baseline). --- docs/design.md | 37 ++- receiver/src/driver.ts | 525 +++++++++++++++++++++++++++++ receiver/src/frames.ts | 19 ++ receiver/src/mod.ts | 2 + receiver/src/mount.ts | 523 +++-------------------------- receiver/src/native.ts | 145 +++++++- receiver/src/receiver.ts | 14 +- receiver/tests/driver_test.ts | 437 ++++++++++++++++++++++++ receiver/tests/hostile_test.ts | 569 ++++++++++++++++++++++++++++++++ receiver/tests/native_test.ts | 514 +++++++++++++++++++++++++++++ receiver/tests/receiver_test.ts | 5 +- 11 files changed, 2289 insertions(+), 501 deletions(-) create mode 100644 receiver/src/driver.ts create mode 100644 receiver/tests/driver_test.ts create mode 100644 receiver/tests/hostile_test.ts create mode 100644 receiver/tests/native_test.ts diff --git a/docs/design.md b/docs/design.md index fce428f..d9c1b77 100644 --- a/docs/design.md +++ b/docs/design.md @@ -763,13 +763,32 @@ have. Hence the evolution rule: new meaning is a new field, existing fields never change semantics, tags are never reused (protobuf already requires the last). Implementation: `receiver/src/policy.ts`. -**What the receiver does not yet guarantee.** Fail-closed on -*malformed* streams — ids that do not resolve, cycles, out-of-range -intern or template references, property names that walk the prototype, -unbounded lengths. `templates.ts` validates the arena; the rest of the -receiver was written for a trusted spike producer. Hardening it against -hostile bytes is separate work, and a policy is only as good as the -receiver behind it. +**What the receiver guarantees underneath a policy.** A policy is only +as good as the receiver behind it, so the native receiver fails closed on +malformed streams independently of any declaration: an id that does not +resolve, a re-registered live id, a node given two ids, a structural op +on the mount root (create, move, remove, or the root as an insert anchor +— the one way to put a producer node beside the mount rather than inside +it), an intern ref never defined, a `set-text` on an element, an +attribute or property on a text node, a frame announcing more than +`MAX_FRAME_BYTES`, a sub-message truncated inside a complete frame. +Acyclicity is the DOM's own guarantee (`insertBefore` of an ancestor +throws `HierarchyRequestError`) and is relied on rather than duplicated +on the insert path. Two things it does not see: an id reused after the +`remove` that freed it (no record of forgotten ids is kept, and ids need +not be monotonic), and a template arena deep enough to overflow the +stack at registration. Gate: `receiver/tests/hostile_test.ts` — a +structured op fuzzer and a byte-mutation fuzzer over the fixture stream, +each asserting the mount root and its siblings are untouched after any +rejection. The remote-dom backend is not hardened. + +**Driving the receiver without a component.** `receiver/src/driver.ts` +is the DOM side on its own — bytes in through `push`, events out through +a callback, `queries` as plain functions — and `mount.ts` is the +component glue over it. This is what makes the Transports claim true +that a receiver outside any component is a first-class implementation: +a frame applier fed over a `MessagePort`, a replay of a recorded stream, +and the polyengine mount all drive the same object. ## Prior art @@ -1111,8 +1130,8 @@ event families beyond mouse/keyboard/form, files and `DataTransfer`. transformer, and remote-dom's reason for existing. *Resolved, see "Policy":* the allowlist is the embedder's, not the protocol's; the receiver ships the seam and a fail-safe declaration mechanism, no - `sanitize` transformer. Hardening the receiver against malformed - streams remains open. + `sanitize` transformer, and the native receiver fails closed on + malformed streams underneath it. 11. **View-transition batches.** A receiver must call `document.startViewTransition` before the first mutation of a batch that should animate, so the producer has to say so at the batch start. diff --git a/receiver/src/driver.ts b/receiver/src/driver.ts new file mode 100644 index 0000000..7924247 --- /dev/null +++ b/receiver/src/driver.ts @@ -0,0 +1,525 @@ +// The DOM-side driver: everything a `polymorph:stream-dom` receiver needs +// that does NOT require a wasm component instance — backend selection, +// policy compilation, frame decoding, dispatch-gate bracketing of byte +// application, event listener delegation/attach/detach, synthetic +// navigation, event payload encoding, and the policy-gated `queries` +// implementations. `mount.ts` is a thin component-glue layer over this: +// it builds a `Driver`, wires its `handleEvent` callback to the guest's +// `handle-event` export, and feeds it bytes off the mutation stream. +// +// Governing docs: docs/design.md "Architecture" ("a receiver running +// outside any component ... is a first-class implementation of the +// protocol, not an emulation" — this module is what makes that true), +// "Events" (delegation, declarative flags, synthetic navigation), "Policy" +// (the seam; abort semantics). wit/stream-dom.wit for the `queries` shapes. + +import { DispatchGate } from "./dispatch.ts"; +import { encodePayload } from "./events.ts"; +import { FrameDecoder } from "./frames.ts"; +import type { Listener, ListenerTarget } from "./frames.ts"; +import { NativeDomReceiver } from "./native.ts"; +import { compilePolicy, queryAllowed } from "./policy.ts"; +import type { CompiledPolicy, Policy } from "./policy.ts"; +import { createRemoteReceiver } from "./remote.ts"; +import type { Receiver } from "./receiver.ts"; + +/** WIT `queries.point`. */ +export interface Point { + x: number; + y: number; +} +/** WIT `queries.size`. */ +export interface Size { + width: number; + height: number; +} +/** WIT `queries.rect` — nested record, `{ origin: point, size: size }`. */ +export interface Rect { + origin: Point; + size: Size; +} + +type ElementLike = Element & { + scrollLeft?: number; + scrollTop?: number; + scrollWidth?: number; + scrollHeight?: number; + getBoundingClientRect?: () => { + x: number; + y: number; + width: number; + height: number; + }; + focus?: () => void; + blur?: () => void; +}; + +function isNum(v: unknown): v is number { + return typeof v === "number"; +} + +/** wit `event-target` (wit/stream-dom.wit `types.event-target`), the shape + * `handle-event`'s `target` param lowers as (embedder-api.md "Value + * mapping": a payload-carrying variant case is `{ kind, value }`, a + * payload-less one is `{ kind }` with `value` absent). Built from a + * `ListenerTarget` (frames.ts's own decode of `Listener.target`) at + * dispatch time. Named `ProducerEventTarget` (not `EventTarget`) because + * the DOM already has a global `EventTarget`. */ +export type ProducerEventTarget = + | { kind: "node"; value: number } + | { kind: "window" } + | { kind: "document" }; + +function witTarget(target: ListenerTarget): ProducerEventTarget { + return target.kind === "node" + ? { kind: "node", value: target.id } + : { kind: target.kind }; +} + +export interface DriverOptions { + root: Element; + /** Which DOM backend applies frames (docs/design.md "Spike"): `"native"` + * (default) writes straight to real nodes; `"remote"` replays into + * Shopify remote-dom's `DOMRemoteReceiver` (the original bring-up + * receiver — kept for comparison and for hosts that already speak + * remote-dom). */ + receiver?: "native" | "remote"; + /** Policy: the surface this embedder accepts, declared by proto name + * (policy.ts). THIS is the mechanism labelled fail-safe — undeclared + * mutation-stream surface is rejected, undeclared event payload fields + * are not encoded, undeclared `queries` refuse. Compiling the policy + * validates every name; a policy naming something unknown makes + * `createDriver` throw. */ + policy?: Policy; + /** Asynchronous failure after mount: a dispatch-gate error (a + * `handleEvent` call rejecting or throwing synchronously). */ + onError?(err: unknown): void; + /** Deliver one event to the producer. `target`/`nameRef`/`payload` are + * exactly what `handle-event` takes; `ev` is the live native Event, lent + * for the synchronous prefix (the component glue wraps it in the WIT + * `dom-event` resource; a port-based consumer cannot forward it and + * relies on the declarative flags). Runs inside the dispatch gate. */ + handleEvent( + target: ProducerEventTarget, + nameRef: number, + payload: Uint8Array, + ev: Event, + ): unknown; +} + +export interface Driver { + /** Apply stream bytes: gate-bracketed decode; counts stats. Throws on + * protocol/policy violation — the caller must then stop feeding and + * dispose. A no-op after `dispose()`. */ + push(bytes: Uint8Array): void; + /** WIT `queries` implementations, policy-gated. */ + readonly queries: { + getClientRect(target: number): Rect | undefined; + getScrollOffset(target: number): Point | undefined; + getScrollSize(target: number): Size | undefined; + setFocus(target: number, focus: boolean): boolean; + }; + readonly stats: { batches: number; frames: number; bytes: number }; + /** Resolves after the NEXT `onCommit` finishes — including this + * module's own listener attach/detach bookkeeping, not just the + * backend's own op application. */ + nextCommit(): Promise; + /** Listeners torn down, receiver disposed. Idempotent. */ + dispose(): void; +} + +/** + * Build a `Driver` over `opts.root`: the requested `Receiver` backend, the + * compiled policy, and delegated event dispatch — with no dependency on a + * wasm component instance. + */ +export function createDriver(opts: DriverOptions): Driver { + let disposed = false; + const onError = opts.onError ?? (() => {}); + const gate = new DispatchGate(onError); + // Construction errors (a name this build does not know) propagate out of + // `createDriver` — see policy.ts `compilePolicy`. + const policy: CompiledPolicy | undefined = opts.policy + ? compilePolicy(opts.policy) + : undefined; + + const receiver: Receiver = opts.receiver === "remote" + ? createRemoteReceiver(opts.root) + : new NativeDomReceiver(opts.root); + + const stats = { batches: 0, frames: 0, bytes: 0 }; + let commitWaiters: Array<() => void> = []; + function nextCommit(): Promise { + return new Promise((resolve) => commitWaiters.push(resolve)); + } + + /** Real DOM node -> producer node id, for walking a native event's + * bubble path back to a registered listener. Populated only when a + * listener is actually attached to a node (`receiver.onCommit` below) — + * there is no minting fallback: a node with no entry here can hold no + * `listenerFor` match either, since every registration goes through the + * same attach step. */ + const nodeToId = new WeakMap(); + + // -- queries -------------------------------------------------------------- + // + // `setFocus` fires focusin/focusout synchronously (wit doc, + // docs/design.md "Reentrancy"), so its body is bracketed with the gate: + // a dispatch raised from inside queues and drains once this call + // (itself running inside the guest's turn — a host import invoked BY the + // guest) unwinds. The read queries fire nothing and need no bracket. + + function getClientRect(target: number): Rect | undefined { + if (!queryAllowed(policy, "get-client-rect")) return undefined; + const node = receiver.resolveNode(target) as ElementLike | undefined; + if (!node || typeof node.getBoundingClientRect !== "function") { + return undefined; + } + const r = node.getBoundingClientRect(); + return { + origin: { x: r.x, y: r.y }, + size: { width: r.width, height: r.height }, + }; + } + + function getScrollOffset(target: number): Point | undefined { + if (!queryAllowed(policy, "get-scroll-offset")) return undefined; + const node = receiver.resolveNode(target) as ElementLike | undefined; + if (!node || !isNum(node.scrollLeft) || !isNum(node.scrollTop)) { + return undefined; + } + return { x: node.scrollLeft, y: node.scrollTop }; + } + + function getScrollSize(target: number): Size | undefined { + if (!queryAllowed(policy, "get-scroll-size")) return undefined; + const node = receiver.resolveNode(target) as ElementLike | undefined; + if (!node || !isNum(node.scrollWidth) || !isNum(node.scrollHeight)) { + return undefined; + } + return { width: node.scrollWidth, height: node.scrollHeight }; + } + + function setFocus(target: number, focus: boolean): boolean { + if (!queryAllowed(policy, "set-focus")) return false; + const node = receiver.resolveNode(target) as ElementLike | undefined; + const fn = focus ? node?.focus : node?.blur; + if (typeof fn !== "function") return false; + gate.beginApply(); + try { + fn.call(node); + return true; + } finally { + gate.endApply(); + } + } + + // -- event delegation ------------------------------------------------------- + // + // Bubbling listeners are delegated at `root`: one native listener per + // event name, refcounted across registrations. Non-bubbling listeners + // attach directly to the element (docs/design.md "Events": "Delegation: + // bubbling events are delegated at the mount root; non-bubbling ones are + // attached per element"). + + function fire( + target: ProducerEventTarget, + nameRef: number, + name: string, + ev: Event, + listener: Listener, + ): void { + // Declarative flags (docs/design.md "Events", option C): honored + // unconditionally, before the guest is entered, since a remote + // receiver could not wait for a round trip either. + if (listener.preventDefault) ev.preventDefault(); + if (listener.stopPropagation) ev.stopPropagation(); + if (disposed) return; + const payload = encodePayload(name, ev, policy?.events); + gate.dispatch(() => opts.handleEvent(target, nameRef, payload, ev)); + } + + function dispatchDelegated(name: string, ev: Event): void { + const nameRef = receiver.listeners.refFor(name); + if (nameRef === undefined) return; + let node: Node | null = ev.target as Node | null; + while (node) { + const id = nodeToId.get(node); + if (id !== undefined) { + const listener = receiver.listeners.listenerFor(id, nameRef); + if (listener) { + fire({ kind: "node", value: id }, nameRef, name, ev, listener); + return; + } + } + if (node === opts.root) return; + node = node.parentNode; + } + } + + /** One entry per distinct `(name, capture)` pair currently delegated at + * the root. Keyed by a compound string rather than a nested map: the + * pair is what native `addEventListener`/`removeEventListener` actually + * distinguish (two listeners for the same name differing only in + * `capture` are NOT the same registration), so it is what has to be + * refcounted and removed together. `capture` and the currently- + * registered `passive` flag are stored on the entry (not re-derived from + * whichever `Listener` happens to be passed to `release`/`dispose`), so + * removal always targets the exact registration this module made. */ + interface RootEntry { + name: string; + refcount: number; + /** How many current registrants for this key are NON-passive — used + * to decide whether the native listener must be (or must become) + * `passive: false`. */ + nonPassiveCount: number; + capture: boolean; + passive: boolean; + handler: (e: Event) => void; + } + const rootListeners = new Map(); + + function rootKey(name: string, capture: boolean): string { + return `${name}\u0000${capture}`; + } + + function ensureRootListener(listener: Listener): void { + const name = receiver.listeners.stringFor(listener.name); + const key = rootKey(name, listener.capture); + let entry = rootListeners.get(key); + if (!entry) { + const handler = (e: Event) => dispatchDelegated(name, e); + // The FIRST registrant for this (name, capture) pair decides the + // initial `passive` value; a later non-passive registrant upgrades + // it below rather than being silently ignored (a shared passive + // native listener would make that registrant's `preventDefault()` + // a no-op — the bug this entry-per-key, upgrade-on-demand scheme + // exists to avoid). + entry = { + name, + refcount: 0, + nonPassiveCount: 0, + capture: listener.capture, + passive: listener.passive, + handler, + }; + rootListeners.set(key, entry); + opts.root.addEventListener(name, handler, { + capture: entry.capture, + passive: entry.passive, + }); + } + entry.refcount++; + if (!listener.passive) { + entry.nonPassiveCount++; + if (entry.passive) { + // Crossing 0 -> 1 non-passive registrants while the native + // listener is still registered passive: re-register non-passive + // so this (and every other) registrant's `preventDefault()` is + // honored by the browser. + opts.root.removeEventListener(name, entry.handler, { + capture: entry.capture, + }); + entry.passive = false; + opts.root.addEventListener(name, entry.handler, { + capture: entry.capture, + passive: false, + }); + } + } + } + + function releaseRootListener(listener: Listener): void { + const name = receiver.listeners.stringFor(listener.name); + const key = rootKey(name, listener.capture); + const entry = rootListeners.get(key); + if (!entry) return; + entry.refcount--; + if (!listener.passive) entry.nonPassiveCount--; + if (entry.refcount <= 0) { + opts.root.removeEventListener(name, entry.handler, { + capture: entry.capture, + }); + rootListeners.delete(key); + } + } + + interface DirectEntry { + handler: (e: Event) => void; + capture: boolean; + } + /** Non-delegated listeners: per-node ones (non-bubbling `Listener`s) and + * global ones (`window`/`document` — "always attached directly, + * regardless of `bubbles`", docs/design.md "Global listeners"), keyed by + * the real `EventTarget` — `Node`, `Window` and `Document` all satisfy + * that interface uniformly, so one map and one pair of functions serve + * both. */ + const directListeners = new Map>(); + + function attachDirectListener( + target: EventTarget, + witTgt: ProducerEventTarget, + name: string, + listener: Listener, + ): void { + let byName = directListeners.get(target); + if (!byName) { + byName = new Map(); + directListeners.set(target, byName); + } + if (byName.has(listener.name)) return; + const handler = (e: Event) => + fire(witTgt, listener.name, name, e, listener); + byName.set(listener.name, { handler, capture: listener.capture }); + target.addEventListener(name, handler, { + capture: listener.capture, + passive: listener.passive, + }); + } + + function detachDirectListener(target: EventTarget, listener: Listener): void { + const byName = directListeners.get(target); + const entry = byName?.get(listener.name); + if (!entry) return; + const name = receiver.listeners.stringFor(listener.name); + target.removeEventListener(name, entry.handler, { + capture: entry.capture, + }); + byName!.delete(listener.name); + } + + /** Dispatch the synthetic initial-navigation event (docs/design.md + * "Global listeners" territory): a `window` listener for + * `hashchange`/`popstate` fires once, right after it is attached, with + * the CURRENT `location.href` — the producer has no `location` to read + * at mount, so without this a deep link renders the default route until + * the first real navigation. Mirrors the synthetic `mounted` event's + * "once per registration" contract. */ + function dispatchSyntheticNavigation(name: string, listener: Listener): void { + fire({ kind: "window" }, listener.name, name, new Event(name), listener); + } + + // Nodes only exist for `resolveNode` once the backend has applied the + // batch, so listener attach/detach happens in the `onCommit` hook, + // after (both backends fire it at the same point — see receiver.ts). + receiver.onCommit = () => { + // An entry whose node does not resolve yet (e.g. a listener add-op + // that landed in the same batch as the insert, ordered before it, or + // a node briefly unreachable via the receiver's `call`) is carried + // over to the NEXT `onCommit` rather than dropped — dropping it would + // permanently lose that registration even once the node exists. Only + // node targets can miss this way; `window`/`document` always resolve. + const stillPending: Array<{ target: ListenerTarget; listener: Listener }> = + []; + for (const entry of receiver.listeners.pendingAttach) { + const { target, listener } = entry; + if (target.kind === "node") { + const node = receiver.resolveNode(target.id); + if (!node) { + stillPending.push(entry); + continue; + } + nodeToId.set(node, target.id); + if (listener.bubbles) { + ensureRootListener(listener); + } else { + const name = receiver.listeners.stringFor(listener.name); + attachDirectListener(node, witTarget(target), name, listener); + } + continue; + } + // Global: always attached directly, regardless of `bubbles` + // (proto/stream-dom.proto Listener doc, docs/design.md "Global + // listeners"). + const globalObj = target.kind === "window" ? window : document; + const name = receiver.listeners.stringFor(listener.name); + attachDirectListener(globalObj, witTarget(target), name, listener); + if ( + target.kind === "window" && + (name === "hashchange" || name === "popstate") + ) { + dispatchSyntheticNavigation(name, listener); + } + } + receiver.listeners.pendingAttach.length = 0; + receiver.listeners.pendingAttach.push(...stillPending); + + for (const { target, listener } of receiver.listeners.pendingDetach) { + if (target.kind === "node") { + if (listener.bubbles) { + releaseRootListener(listener); + } else { + const node = receiver.resolveNode(target.id); + if (node) detachDirectListener(node, listener); + } + continue; + } + const globalObj = target.kind === "window" ? window : document; + detachDirectListener(globalObj, listener); + } + receiver.listeners.pendingDetach.length = 0; + + stats.batches++; + const waiters = commitWaiters; + commitWaiters = []; + for (const w of waiters) w(); + }; + + // -- bytes in --------------------------------------------------------------- + + // `Policy.sink` wraps the receiver's sink: ops reach it only if the + // wrapper forwards them, and only after `accept` has already rejected + // anything undeclared. + const sink = opts.policy?.sink + ? opts.policy.sink(receiver.sink) + : receiver.sink; + const decoder = new FrameDecoder(sink, { accept: policy?.accept }); + + function push(bytes: Uint8Array): void { + if (disposed) return; // no-op after dispose — see `Driver.push`'s doc. + gate.beginApply(); + try { + stats.bytes += bytes.length; + decoder.push(bytes); + stats.frames = decoder.frameCount; + } finally { + gate.endApply(); + } + } + + function dispose(): void { + if (disposed) return; + disposed = true; + gate.dispose(); + for (const entry of rootListeners.values()) { + opts.root.removeEventListener(entry.name, entry.handler, { + capture: entry.capture, + }); + } + rootListeners.clear(); + // Global listeners (window/document) are never delegated, so they need + // their own teardown here — unlike per-node direct listeners, which + // die with their (already-detached-or-GC'd) nodes. + for (const globalObj of [window, document] as const) { + const byName = directListeners.get(globalObj); + if (!byName) continue; + for (const [nameRef, entry] of byName) { + globalObj.removeEventListener( + receiver.listeners.stringFor(nameRef), + entry.handler, + { capture: entry.capture }, + ); + } + directListeners.delete(globalObj); + } + receiver.dispose(); + } + + return { + push, + queries: { getClientRect, getScrollOffset, getScrollSize, setFocus }, + stats, + nextCommit, + dispose, + }; +} diff --git a/receiver/src/frames.ts b/receiver/src/frames.ts index b749259..d4f30a9 100644 --- a/receiver/src/frames.ts +++ b/receiver/src/frames.ts @@ -457,6 +457,16 @@ function decodeTemplateNode( return node; } +/** Hard ceiling on one frame's length prefix. A `uint32` varint can + * announce up to 4 GiB, and `#drain` would otherwise buffer every + * subsequent chunk forever waiting for bytes a hostile (or broken) + * producer never sends — an unbounded-memory hang rather than an abort. + * 16 MiB is far above any frame a real producer emits (the largest is a + * `RegisterTemplate` arena) and far below a memory problem; over it the + * decoder throws immediately, which aborts the stream + * (docs/design.md "Policy", "The seam"). */ +export const MAX_FRAME_BYTES = 16 * 1024 * 1024; + /** * Feeds arbitrary byte chunks (a frame may straddle chunks per * docs/design.md: "Rendezvous copies split at byte granularity") and @@ -515,10 +525,19 @@ export class FrameDecoder { if (err instanceof TruncatedError) break; throw err; } + if (len > MAX_FRAME_BYTES) { + throw new Error( + `stream-dom: frame length ${len} exceeds MAX_FRAME_BYTES (${MAX_FRAME_BYTES})`, + ); + } if (offset + len > buf.length) { offset = lenStart; // Frame body straddles the buffer — wait for more. break; } + // Only the length probe above is allowed to treat a `TruncatedError` + // as "wait for more". Inside a frame whose bytes are all here, a + // truncated sub-message is a malformed frame and propagates out of + // `push`, aborting the stream. const frame = new Reader(buf, offset, offset + len); offset += len; this.#decodeFrame(frame); diff --git a/receiver/src/mod.ts b/receiver/src/mod.ts index 7f4559c..ade962e 100644 --- a/receiver/src/mod.ts +++ b/receiver/src/mod.ts @@ -41,3 +41,5 @@ export type { export { DispatchGate } from "./dispatch.ts"; export { mount } from "./mount.ts"; export type { Mounted, MountOptions } from "./mount.ts"; +export { createDriver } from "./driver.ts"; +export type { Driver, DriverOptions, ProducerEventTarget } from "./driver.ts"; diff --git a/receiver/src/mount.ts b/receiver/src/mount.ts index 026003c..df24d7f 100644 --- a/receiver/src/mount.ts +++ b/receiver/src/mount.ts @@ -1,28 +1,22 @@ -// Host glue: instantiates a `polymorph:stream-dom` producer component, -// reads its mutation stream into a `Receiver` (either backend — see -// receiver.ts), and dispatches DOM events back through `handle-event`. -// Backend-agnostic by construction: everything here goes through the -// `Receiver` seam (`sink`, `resolveNode`, `listeners`, `onCommit`, -// `dispose`), never through `RemoteDomTranscoder` or `NativeDomReceiver` -// by name. Governing docs: wit/stream-dom.wit (world `producer`), -// docs/design.md "Events" (delegation, declarative flags) and -// contracts/embedder-api.md "Module wiring and instantiation" / "Streams -// and futures" (cited inline as `contract:
`). +// Host glue: instantiates a `polymorph:stream-dom` producer component and +// wires it to a `Driver` (driver.ts), which owns everything that does NOT +// need a component instance — backend selection, policy, frame decoding, +// event delegation, `queries`. This module is only the component-specific +// remainder: `instantiate`, the `wasi()` imports, the WIT `dom-event` +// resource, calling the `run` export to get the mutation stream, the two +// read transports, calling `handle-event`, and `stream.drop()`. Governing +// docs: wit/stream-dom.wit (world `producer`), docs/design.md "Events" +// (delegation, declarative flags) and contracts/embedder-api.md "Module +// wiring and instantiation" / "Streams and futures" (cited inline as +// `contract:
`). import { instantiate } from "@polyengine/runtime/embedder"; import type { InstantiateSource } from "@polyengine/runtime/embedder"; import type { Stream } from "@polyengine/protocol"; import { wasi } from "@polyengine/wasi"; -import { DispatchGate } from "./dispatch.ts"; -import { encodePayload } from "./events.ts"; -import { FrameDecoder } from "./frames.ts"; -import type { Listener, ListenerTarget } from "./frames.ts"; -import { NativeDomReceiver } from "./native.ts"; -import { compilePolicy, queryAllowed } from "./policy.ts"; -import type { CompiledPolicy, Policy } from "./policy.ts"; -import { createRemoteReceiver } from "./remote.ts"; -import type { Receiver } from "./receiver.ts"; +import { createDriver } from "./driver.ts"; +import type { Policy } from "./policy.ts"; export interface MountOptions { /** Component artifacts, passed through verbatim to `instantiate` @@ -81,22 +75,6 @@ export interface Mounted { nextCommit(): Promise; } -/** WIT `queries.point`. */ -interface Point { - x: number; - y: number; -} -/** WIT `queries.size`. */ -interface Size { - width: number; - height: number; -} -/** WIT `queries.rect` — nested record, `{ origin: point, size: size }`. */ -interface Rect { - origin: Point; - size: Size; -} - /** Host-implemented `events.dom-event` resource (wit/stream-dom.wit * `interface events`): lent to the guest for its synchronous prefix inside * `handle-event`. */ @@ -113,403 +91,45 @@ class DomEvent { } } -type ElementLike = Element & { - scrollLeft?: number; - scrollTop?: number; - scrollWidth?: number; - scrollHeight?: number; - getBoundingClientRect?: () => { - x: number; - y: number; - width: number; - height: number; - }; - focus?: () => void; - blur?: () => void; -}; - -function isNum(v: unknown): v is number { - return typeof v === "number"; -} - -/** wit `event-target` (wit/stream-dom.wit `types.event-target`), the - * shape `handle-event`'s `target` param lowers as (embedder-api.md "Value - * mapping": a payload-carrying variant case is `{ kind, value }`, a - * payload-less one is `{ kind }` with `value` absent). Built from a - * `ListenerTarget` (frames.ts's own decode of `Listener.target`) at - * dispatch time. */ -type WitEventTarget = - | { kind: "node"; value: number } - | { kind: "window" } - | { kind: "document" }; - -function witTarget(target: ListenerTarget): WitEventTarget { - return target.kind === "node" - ? { kind: "node", value: target.id } - : { kind: target.kind }; -} - /** * Mount a `polymorph:stream-dom` producer component into `opts.root`. * - * Builds the requested `Receiver` backend over `opts.root`, instantiates - * the component with `queries`/`events` imports wired per + * Builds a `Driver` over `opts.root` (driver.ts), instantiates the + * component with `queries`/`events` imports wired per * contracts/embedder-api.md "Module wiring and instantiation" (imports * keyed by the verbatim interface id), reads the mutation stream `run` - * returns, and delegates DOM events back into `handle-event`. + * returns into the driver, and delegates DOM events back into + * `handle-event`. */ export async function mount(opts: MountOptions): Promise { let disposed = false; const onError = opts.onError ?? (() => {}); - const gate = new DispatchGate(onError); - // Construction errors (a name this build does not know) propagate out of - // `mount` — see policy.ts `compilePolicy`. - const policy: CompiledPolicy | undefined = opts.policy - ? compilePolicy(opts.policy) - : undefined; - - const receiver: Receiver = opts.receiver === "remote" - ? createRemoteReceiver(opts.root) - : new NativeDomReceiver(opts.root); - - const stats = { batches: 0, frames: 0, bytes: 0 }; - let commitWaiters: Array<() => void> = []; - function nextCommit(): Promise { - return new Promise((resolve) => commitWaiters.push(resolve)); - } - - /** Real DOM node -> producer node id, for walking a native event's - * bubble path back to a registered listener. Populated only when a - * listener is actually attached to a node (`receiver.onCommit` below) — - * there is no minting fallback: a node with no entry here can hold no - * `listenerFor` match either, since every registration goes through the - * same attach step. */ - const nodeToId = new WeakMap(); - - // -- queries -------------------------------------------------------------- - // - // `setFocus` fires focusin/focusout synchronously (wit doc, - // docs/design.md "Reentrancy"), so its body is bracketed with the gate: - // a dispatch raised from inside queues and drains once this call - // (itself running inside the guest's turn — a host import invoked BY the - // guest) unwinds. The read queries fire nothing and need no bracket. - - function getClientRect(target: number): Rect | undefined { - if (!queryAllowed(policy, "get-client-rect")) return undefined; - const node = receiver.resolveNode(target) as ElementLike | undefined; - if (!node || typeof node.getBoundingClientRect !== "function") { - return undefined; - } - const r = node.getBoundingClientRect(); - return { - origin: { x: r.x, y: r.y }, - size: { width: r.width, height: r.height }, - }; - } - - function getScrollOffset(target: number): Point | undefined { - if (!queryAllowed(policy, "get-scroll-offset")) return undefined; - const node = receiver.resolveNode(target) as ElementLike | undefined; - if (!node || !isNum(node.scrollLeft) || !isNum(node.scrollTop)) { - return undefined; - } - return { x: node.scrollLeft, y: node.scrollTop }; - } - - function getScrollSize(target: number): Size | undefined { - if (!queryAllowed(policy, "get-scroll-size")) return undefined; - const node = receiver.resolveNode(target) as ElementLike | undefined; - if (!node || !isNum(node.scrollWidth) || !isNum(node.scrollHeight)) { - return undefined; - } - return { width: node.scrollWidth, height: node.scrollHeight }; - } - - function setFocus(target: number, focus: boolean): boolean { - if (!queryAllowed(policy, "set-focus")) return false; - const node = receiver.resolveNode(target) as ElementLike | undefined; - const fn = focus ? node?.focus : node?.blur; - if (typeof fn !== "function") return false; - gate.beginApply(); - try { - fn.call(node); - return true; - } finally { - gate.endApply(); - } - } - - // -- event delegation ------------------------------------------------------- - // - // Bubbling listeners are delegated at `root`: one native listener per - // event name, refcounted across registrations. Non-bubbling listeners - // attach directly to the element (docs/design.md "Events": "Delegation: - // bubbling events are delegated at the mount root; non-bubbling ones are - // attached per element"). // Populated once, after `instantiate()` below; a mutable field on a - // `const` holder (rather than a reassigned `let`) so `fire` can close - // over it before it exists. + // `const` holder (rather than a reassigned `let`) so the driver's + // `handleEvent` callback can close over it before it exists. `handle- + // event` may be invoked (by a synthetic navigation dispatch during + // `onCommit`, itself driven by the FIRST batch of stream bytes) before + // `run`'s export even resolves — dropping the event in that case (and + // after `dispose()`) is today's behaviour, preserved here. const exports_: { handleEvent?: (...a: unknown[]) => unknown } = {}; - function fire( - target: WitEventTarget, - nameRef: number, - name: string, - ev: Event, - listener: Listener, - ): void { - // Declarative flags (docs/design.md "Events", option C): honored - // unconditionally, before the guest is entered, since a remote - // receiver could not wait for a round trip either. - if (listener.preventDefault) ev.preventDefault(); - if (listener.stopPropagation) ev.stopPropagation(); - if (!exports_.handleEvent || disposed) return; - const payload = encodePayload(name, ev, policy?.events); - const domEvent = new DomEvent(ev); - gate.dispatch(() => - exports_.handleEvent!(target, nameRef, payload, domEvent) - ); - } - - function dispatchDelegated(name: string, ev: Event): void { - const nameRef = receiver.listeners.refFor(name); - if (nameRef === undefined) return; - let node: Node | null = ev.target as Node | null; - while (node) { - const id = nodeToId.get(node); - if (id !== undefined) { - const listener = receiver.listeners.listenerFor(id, nameRef); - if (listener) { - fire({ kind: "node", value: id }, nameRef, name, ev, listener); - return; - } - } - if (node === opts.root) return; - node = node.parentNode; - } - } - - /** One entry per distinct `(name, capture)` pair currently delegated at - * the root. Keyed by a compound string rather than a nested map: the - * pair is what native `addEventListener`/`removeEventListener` actually - * distinguish (two listeners for the same name differing only in - * `capture` are NOT the same registration), so it is what has to be - * refcounted and removed together. `capture` and the currently- - * registered `passive` flag are stored on the entry (not re-derived from - * whichever `Listener` happens to be passed to `release`/`dispose`), so - * removal always targets the exact registration this module made. */ - interface RootEntry { - name: string; - refcount: number; - /** How many current registrants for this key are NON-passive — used - * to decide whether the native listener must be (or must become) - * `passive: false`. */ - nonPassiveCount: number; - capture: boolean; - passive: boolean; - handler: (e: Event) => void; - } - const rootListeners = new Map(); - - function rootKey(name: string, capture: boolean): string { - return `${name}\u0000${capture}`; - } - - function ensureRootListener(listener: Listener): void { - const name = receiver.listeners.stringFor(listener.name); - const key = rootKey(name, listener.capture); - let entry = rootListeners.get(key); - if (!entry) { - const handler = (e: Event) => dispatchDelegated(name, e); - // The FIRST registrant for this (name, capture) pair decides the - // initial `passive` value; a later non-passive registrant upgrades - // it below rather than being silently ignored (a shared passive - // native listener would make that registrant's `preventDefault()` - // a no-op — the bug this entry-per-key, upgrade-on-demand scheme - // exists to avoid). - entry = { - name, - refcount: 0, - nonPassiveCount: 0, - capture: listener.capture, - passive: listener.passive, - handler, - }; - rootListeners.set(key, entry); - opts.root.addEventListener(name, handler, { - capture: entry.capture, - passive: entry.passive, - }); - } - entry.refcount++; - if (!listener.passive) { - entry.nonPassiveCount++; - if (entry.passive) { - // Crossing 0 -> 1 non-passive registrants while the native - // listener is still registered passive: re-register non-passive - // so this (and every other) registrant's `preventDefault()` is - // honored by the browser. - opts.root.removeEventListener(name, entry.handler, { - capture: entry.capture, - }); - entry.passive = false; - opts.root.addEventListener(name, entry.handler, { - capture: entry.capture, - passive: false, - }); - } - } - } - - function releaseRootListener(listener: Listener): void { - const name = receiver.listeners.stringFor(listener.name); - const key = rootKey(name, listener.capture); - const entry = rootListeners.get(key); - if (!entry) return; - entry.refcount--; - if (!listener.passive) entry.nonPassiveCount--; - if (entry.refcount <= 0) { - opts.root.removeEventListener(name, entry.handler, { - capture: entry.capture, - }); - rootListeners.delete(key); - } - } - - interface DirectEntry { - handler: (e: Event) => void; - capture: boolean; - } - /** Non-delegated listeners: per-node ones (non-bubbling `Listener`s) and - * global ones (`window`/`document` — "always attached directly, - * regardless of `bubbles`", docs/design.md "Global listeners"), keyed by - * the real `EventTarget` — `Node`, `Window` and `Document` all satisfy - * that interface uniformly, so one map and one pair of functions serve - * both. */ - const directListeners = new Map>(); - - function attachDirectListener( - target: EventTarget, - witTgt: WitEventTarget, - name: string, - listener: Listener, - ): void { - let byName = directListeners.get(target); - if (!byName) { - byName = new Map(); - directListeners.set(target, byName); - } - if (byName.has(listener.name)) return; - const handler = (e: Event) => - fire(witTgt, listener.name, name, e, listener); - byName.set(listener.name, { handler, capture: listener.capture }); - target.addEventListener(name, handler, { - capture: listener.capture, - passive: listener.passive, - }); - } - - function detachDirectListener(target: EventTarget, listener: Listener): void { - const byName = directListeners.get(target); - const entry = byName?.get(listener.name); - if (!entry) return; - const name = receiver.listeners.stringFor(listener.name); - target.removeEventListener(name, entry.handler, { - capture: entry.capture, - }); - byName!.delete(listener.name); - } - - /** Dispatch the synthetic initial-navigation event (docs/design.md - * "Global listeners" territory): a `window` listener for - * `hashchange`/`popstate` fires once, right after it is attached, with - * the CURRENT `location.href` — the producer has no `location` to read - * at mount, so without this a deep link renders the default route until - * the first real navigation. Mirrors the synthetic `mounted` event's - * "once per registration" contract. */ - function dispatchSyntheticNavigation(name: string, listener: Listener): void { - fire({ kind: "window" }, listener.name, name, new Event(name), listener); - } - - // Nodes only exist for `resolveNode` once the backend has applied the - // batch, so listener attach/detach happens in the `onCommit` hook, - // after (both backends fire it at the same point — see receiver.ts). - receiver.onCommit = () => { - // An entry whose node does not resolve yet (e.g. a listener add-op - // that landed in the same batch as the insert, ordered before it, or - // a node briefly unreachable via the receiver's `call`) is carried - // over to the NEXT `onCommit` rather than dropped — dropping it would - // permanently lose that registration even once the node exists. Only - // node targets can miss this way; `window`/`document` always resolve. - const stillPending: Array<{ target: ListenerTarget; listener: Listener }> = - []; - for (const entry of receiver.listeners.pendingAttach) { - const { target, listener } = entry; - if (target.kind === "node") { - const node = receiver.resolveNode(target.id); - if (!node) { - stillPending.push(entry); - continue; - } - nodeToId.set(node, target.id); - if (listener.bubbles) { - ensureRootListener(listener); - } else { - const name = receiver.listeners.stringFor(listener.name); - attachDirectListener(node, witTarget(target), name, listener); - } - continue; - } - // Global: always attached directly, regardless of `bubbles` - // (proto/stream-dom.proto Listener doc, docs/design.md "Global - // listeners"). - const globalObj = target.kind === "window" ? window : document; - const name = receiver.listeners.stringFor(listener.name); - attachDirectListener(globalObj, witTarget(target), name, listener); - if ( - target.kind === "window" && - (name === "hashchange" || name === "popstate") - ) { - dispatchSyntheticNavigation(name, listener); - } - } - receiver.listeners.pendingAttach.length = 0; - receiver.listeners.pendingAttach.push(...stillPending); - - for (const { target, listener } of receiver.listeners.pendingDetach) { - if (target.kind === "node") { - if (listener.bubbles) { - releaseRootListener(listener); - } else { - const node = receiver.resolveNode(target.id); - if (node) detachDirectListener(node, listener); - } - continue; - } - const globalObj = target.kind === "window" ? window : document; - detachDirectListener(globalObj, listener); - } - receiver.listeners.pendingDetach.length = 0; - - stats.batches++; - const waiters = commitWaiters; - commitWaiters = []; - for (const w of waiters) w(); - }; - - // -- instantiation + mutation stream ---------------------------------------- + const driver = createDriver({ + root: opts.root, + receiver: opts.receiver, + policy: opts.policy, + onError, + handleEvent: (target, nameRef, payload, ev) => { + if (!exports_.handleEvent || disposed) return; + return exports_.handleEvent(target, nameRef, payload, new DomEvent(ev)); + }, + }); const imports = { // wasip2 components import wasi:cli/io/clocks/random/filesystem // whether or not the app calls them. ...wasi(), - "polymorph:stream-dom/queries@0.1.0": { - getClientRect, - getScrollOffset, - getScrollSize, - setFocus, - }, + "polymorph:stream-dom/queries@0.1.0": driver.queries, "polymorph:stream-dom/events@0.1.0": { DomEvent }, }; @@ -522,57 +142,26 @@ export async function mount(opts: MountOptions): Promise { hydrate: boolean, ) => Promise>)(false); - // `Policy.sink` wraps the receiver's sink: ops reach it only if the - // wrapper forwards them, and only after `accept` has already rejected - // anything undeclared. - const sink = opts.policy?.sink - ? opts.policy.sink(receiver.sink) - : receiver.sink; - const decoder = new FrameDecoder(sink, { accept: policy?.accept }); - function dispose(): void { if (disposed) return; disposed = true; - gate.dispose(); - for (const entry of rootListeners.values()) { - opts.root.removeEventListener(entry.name, entry.handler, { - capture: entry.capture, - }); - } - rootListeners.clear(); - // Global listeners (window/document) are never delegated, so they need - // their own teardown here — unlike per-node direct listeners, which - // die with their (already-detached-or-GC'd) nodes. - for (const globalObj of [window, document] as const) { - const byName = directListeners.get(globalObj); - if (!byName) continue; - for (const [nameRef, entry] of byName) { - globalObj.removeEventListener( - receiver.listeners.stringFor(nameRef), - entry.handler, - { capture: entry.capture }, - ); - } - directListeners.delete(globalObj); - } stream.drop(); - receiver.dispose(); + driver.dispose(); } - /** Feed `bytes` to the decoder and update `stats.bytes`/`stats.frames`; - * called from both transports so the counting is identical either way. */ + /** Feed `bytes` to the driver; called from both transports so the + * tap/copy behaviour is identical either way. */ function consume(bytes: Uint8Array): void { - stats.bytes += bytes.length; opts.onChunk?.(bytes.slice()); // a COPY — see MountOptions.onChunk's doc. - decoder.push(bytes); - stats.frames = decoder.frameCount; + driver.push(bytes); } if (opts.transport === "chunked") { // Ported from polyengine-dioxus host.ts:540-565: `stream.read(max)` - // copies a chunk out instead of aliasing guest memory. Same gate - // bracketing as the direct path — DOM mutation can still fire - // synchronous events (a removed, focused input firing `blur`). + // copies a chunk out instead of aliasing guest memory. `driver.push` + // brackets application with the dispatch gate itself — DOM mutation + // can still fire synchronous events (a removed, focused input firing + // `blur`). const MAX_READ = 1 << 22; (async () => { while (!disposed) { @@ -582,12 +171,7 @@ export async function mount(opts: MountOptions): Promise { // stream, so the cast is just recovering what's already true. const chunk = await stream.read(MAX_READ) as Uint8Array; if (chunk.length === 0) break; // end of stream - gate.beginApply(); - try { - consume(chunk); - } finally { - gate.endApply(); - } + consume(chunk); } })().catch((err: unknown) => { if (disposed) return; @@ -598,20 +182,15 @@ export async function mount(opts: MountOptions): Promise { // Direct-access byte edge (contract:"Streams and futures", "Direct- // access byte edges"): `consume` runs synchronously inside the // rendezvous with a view over the writer's unread bytes; pushing it - // into the decoder copies what it keeps before `markRead` releases the + // into the driver copies what it keeps before `markRead` releases the // view. One producer write is normally one whole batch, so this - // callback normally applies one batch; wrapped in the dispatch gate - // because DOM mutation can fire synchronous events (e.g. a removed, - // focused input firing `blur`). + // callback normally applies one batch; `driver.push` wraps its body in + // the dispatch gate because DOM mutation can fire synchronous events + // (e.g. a removed, focused input firing `blur`). const readLoop = stream.readDirect((src) => { - gate.beginApply(); - try { - const view = src.remaining(); - consume(view); - src.markRead(view.length); - } finally { - gate.endApply(); - } + const view = src.remaining(); + consume(view); + src.markRead(view.length); return "more"; }); readLoop.catch((err: unknown) => { @@ -625,5 +204,5 @@ export async function mount(opts: MountOptions): Promise { }); } - return { dispose, stats, nextCommit }; + return { dispose, stats: driver.stats, nextCommit: driver.nextCommit }; } diff --git a/receiver/src/native.ts b/receiver/src/native.ts index 056dd79..c5a890c 100644 --- a/receiver/src/native.ts +++ b/receiver/src/native.ts @@ -46,6 +46,24 @@ interface CompiledTemplate { prototypes: Node[]; } +/** The mount root's producer id (proto/stream-dom.proto: "0 is the mount + * root"). The embedder owns that node, not the producer: it is registered + * once by the constructor and is structurally inviolable thereafter — no + * op may create, re-register, alias, move or remove it. Leaf ops + * (attributes, properties, text) on it stay legal protocol; denying those + * is a policy's job, not this receiver's. */ +const ROOT_ID = 0; + +/** `Node.nodeType` values used for the op-target type checks below. + * Compared numerically rather than with `instanceof Element` / + * `instanceof CharacterData` because those constructors are not global in + * every realm this receiver runs in (Deno's test runtime has no DOM + * globals), and a `Node` handed in by the embedder may come from another + * document anyway. */ +const ELEMENT_NODE = 1; +const TEXT_NODE = 3; +const COMMENT_NODE = 8; + /** A duck-typed `Node.moveBefore` (Chrome 133+, 2025): preserves iframe * state, focus, selection and running animations that `insertBefore` * resets, but throws under conditions `insertBefore` tolerates (crossing @@ -74,7 +92,9 @@ export class NativeDomReceiver implements Receiver, FrameSink { constructor(root: Element) { this.#doc = root.ownerDocument; - this.#register(0, root); + // The one binding of `ROOT_ID` that ever happens: `#register` rejects + // it from here on. + this.#bind(ROOT_ID, root); } get sink(): FrameSink { @@ -91,11 +111,45 @@ export class NativeDomReceiver implements Receiver, FrameSink { * listeners it attached separately. */ dispose(): void {} - #register(id: number, node: Node): void { + #bind(id: number, node: Node): void { this.#nodes.set(id, node); this.#ids.set(node, id); } + /** Bind a producer-allocated id to a node, rejecting everything the + * proto's id rules forbid: + * + * - `ROOT_ID` — the mount root is the embedder's node, never one the + * producer may (re-)name. + * - an id that is CURRENTLY registered. The proto says ids are "never + * reused" within a stream, but `remove` frees the subtree's nodes and + * this receiver keeps no record of ids it has forgotten (and ids are + * not required to be monotonic, so a high-water mark would reject + * legal streams). So the detectable half of the rule is enforced and + * the other half is not: re-registering a live id throws; reusing an + * id freed by an earlier `remove` is a protocol violation this + * receiver cannot see. + * - a node that already carries an id (aliasing). Two ids for one node + * would make `remove` free only one of them and leave the other + * pointing into a detached tree; `bind-path` with an empty path onto + * an already-registered node is the way to ask for it. + */ + #register(id: number, node: Node): void { + if (id === ROOT_ID) { + throw new Error(`stream-dom: id ${ROOT_ID} is the mount root`); + } + if (this.#nodes.has(id)) { + throw new Error(`stream-dom: node id ${id} is already registered`); + } + const existing = this.#ids.get(node); + if (existing !== undefined) { + throw new Error( + `stream-dom: node id ${id} would alias node ${existing}`, + ); + } + this.#bind(id, node); + } + #resolve(id: number): Node { const node = this.#nodes.get(id); if (!node) throw new Error(`stream-dom: unknown node id ${id}`); @@ -106,6 +160,25 @@ export class NativeDomReceiver implements Receiver, FrameSink { return this.listeners.stringFor(ref); } + /** `#resolve` plus the op's node-type precondition. Without it + * `setAttribute` would fail with an incidental `TypeError` on a text + * node and `setProperty` would quietly install an expando. */ + #element(opName: string, id: number): Element { + const node = this.#resolve(id); + if (node.nodeType !== ELEMENT_NODE) { + throw new Error(`stream-dom: ${opName} target ${id} is not an element`); + } + return node as Element; + } + + /** The mount root is not a valid target for a structural op — see + * `ROOT_ID`. */ + #rejectRoot(opName: string, id: number): void { + if (id === ROOT_ID) { + throw new Error(`stream-dom: ${opName} may not target the mount root`); + } + } + // -- interning / creation ------------------------------------------------- internString(id: number, s: string): void { @@ -140,6 +213,17 @@ export class NativeDomReceiver implements Receiver, FrameSink { parentId: number | undefined, anchorId: number | undefined, ): Node { + // The mount root is nobody's sibling. An anchor names the node the + // insert lands beside, so an anchor of 0 addresses the EMBEDDER's + // container — and with `parent` omitted the container becomes the + // implied parent silently, putting a producer node outside the mount. + // See ROOT_ID. (With `parent` named this is already caught below as a + // parent/anchor disagreement; rejecting it here says why.) + if (anchorId === ROOT_ID) { + throw new Error( + `stream-dom: ${opName} may not use the mount root as an anchor`, + ); + } if (parentId === undefined && anchorId === undefined) { throw new Error(`stream-dom: ${opName} has neither parent nor anchor`); } @@ -189,6 +273,7 @@ export class NativeDomReceiver implements Receiver, FrameSink { id: number, anchorId: number | undefined, ): void { + this.#rejectRoot("insert-before", id); if (anchorId === id) return; // no-op — see RemoteDomTranscoder's doc. const parent = this.#resolveInsertParent( "insert-before", @@ -210,6 +295,7 @@ export class NativeDomReceiver implements Receiver, FrameSink { id: number, anchorId: number, ): void { + this.#rejectRoot("insert-after", id); if (anchorId === id) return; // no-op — see RemoteDomTranscoder's doc. const parent = this.#resolveInsertParent( "insert-after", @@ -227,30 +313,49 @@ export class NativeDomReceiver implements Receiver, FrameSink { } remove(id: number): void { + this.#rejectRoot("remove", id); const node = this.#resolve(id); node.parentNode?.removeChild(node); this.#forgetSubtree(node); } - /** Forget `node` and its descendants by walking the REMOVED subtree - * (`node.childNodes` recursion), never by scanning the id map: a 10k-row - * clear removes one subtree of ~10k nodes, and the id map can hold many - * unrelated ids, so scanning it per removed node would be - * O(ids × nodes) instead of O(nodes). The reverse map (`#ids`) makes - * each node's own forgetting O(1). */ - #forgetSubtree(node: Node): void { - const id = this.#ids.get(node); - if (id !== undefined) { - this.#nodes.delete(id); - this.#ids.delete(node); + /** Forget `node` and its descendants by walking the REMOVED subtree, + * never by scanning the id map: a 10k-row clear removes one subtree of + * ~10k nodes, and the id map can hold many unrelated ids, so scanning it + * per removed node would be O(ids × nodes) instead of O(nodes). The + * reverse map (`#ids`) makes each node's own forgetting O(1). The walk + * is an explicit stack rather than recursion: a legal-but-deep tree + * (tens of thousands of nested nodes, which no rule forbids a producer + * from building) would otherwise overflow the JS stack on removal. */ + #forgetSubtree(root: Node): void { + const stack: Node[] = [root]; + while (stack.length > 0) { + const node = stack.pop()!; + const id = this.#ids.get(node); + if (id !== undefined) { + this.#nodes.delete(id); + this.#ids.delete(node); + } + for (const child of node.childNodes) stack.push(child); } - for (const child of node.childNodes) this.#forgetSubtree(child); } // -- leaf ops ------------------------------------------------------------- setText(id: number, text: string): void { - (this.#resolve(id) as CharacterData).data = text; + const node = this.#resolve(id); + // proto SetText: "Set a text node's content". `CharacterData` is the + // only thing with a `data` property; on an `Element` the assignment + // this used to do unconditionally installed a silent expando instead + // of rendering anything. Text and comment nodes are the two this + // receiver ever creates (`create-text`, `create-placeholder`) and the + // two a template arena can produce. + if (node.nodeType !== TEXT_NODE && node.nodeType !== COMMENT_NODE) { + throw new Error( + `stream-dom: set-text target ${id} is not a text or comment node`, + ); + } + (node as CharacterData).data = text; } setAttribute( @@ -259,7 +364,7 @@ export class NativeDomReceiver implements Receiver, FrameSink { ns: number | undefined, value: string | undefined, ): void { - const el = this.#resolve(id) as Element; + const el = this.#element("set-attribute", id); const attrName = this.#str(name); if (value === undefined) { if (ns === undefined) el.removeAttribute(attrName); @@ -271,7 +376,10 @@ export class NativeDomReceiver implements Receiver, FrameSink { } setProperty(id: number, name: number, value: PropertyValue): void { - const el = this.#resolve(id) as unknown as Record; + const el = this.#element("set-property", id) as unknown as Record< + string, + unknown + >; const propName = this.#str(name); // An absent value "deletes / sets undefined" (proto SetProperty). On // the DOM that must be `null`, not `undefined`: the string-typed @@ -347,6 +455,9 @@ export class NativeDomReceiver implements Receiver, FrameSink { } node = next; } + // `#register` is what rejects `id == 0` and the aliasing case here: an + // empty `path` (or one that walks back onto an already-bound interior + // node) resolves to a node that already carries an id. this.#register(id, node); } diff --git a/receiver/src/receiver.ts b/receiver/src/receiver.ts index 8cb3cfb..961c309 100644 --- a/receiver/src/receiver.ts +++ b/receiver/src/receiver.ts @@ -64,8 +64,20 @@ export class ListenerRegistry { this.#strings.set(id, s); } + /** Resolve an interned slot. Throws on an unknown ref rather than + * returning `""`: interning is define-before-use + * (proto/stream-dom.proto: "An Intern precedes the first use of its slot + * in the same stream"), so an unresolved ref is a malformed stream, and + * an empty string would silently become an element with no tag name or + * an attribute called "". Re-defining a live slot IS legal and is not + * checked here — the proto's `Intern` reads "Define (or overwrite) + * interned slot `id`". */ stringFor(ref: number): string { - return this.#strings.get(ref) ?? ""; + const s = this.#strings.get(ref); + if (s === undefined) { + throw new Error(`stream-dom: unknown string ref ${ref}`); + } + return s; } /** The reverse of `internString`: the ref a string was interned under, diff --git a/receiver/tests/driver_test.ts b/receiver/tests/driver_test.ts new file mode 100644 index 0000000..f92e50d --- /dev/null +++ b/receiver/tests/driver_test.ts @@ -0,0 +1,437 @@ +// `Driver` (driver.ts) under a real-ish DOM (linkedom): bytes-in/DOM-out +// with no component involved, event delegation back out, declarative +// flags, policy-gated queries, and abort/dispose semantics. See +// receiver/tests/native_test.ts for the DOM-setup style this borrows +// (hierarchy guard is not needed here — nothing here builds cycles). + +import { assertEquals, assertThrows } from "@std/assert"; +import { parseHTML } from "linkedom"; +import { createDriver } from "../src/driver.ts"; +import type { ProducerEventTarget } from "../src/driver.ts"; +import { PolicyError } from "../src/policy.ts"; +import type { Policy } from "../src/policy.ts"; +import { Writer } from "../src/proto.ts"; +import { SURFACE_V1 } from "../src/policy.ts"; + +// -- harness ---------------------------------------------------------------- + +function fixture() { + const win = parseHTML( + `
`, + ); + const doc = win.document as unknown as Document; + const root = doc.getElementById("root")!; + return { win, doc, root }; +} + +/** `driver.dispose()` unconditionally touches the bare `window`/`document` + * globals (global-listener teardown) even when nothing registered one, and + * Deno has neither — referencing them is a `ReferenceError`, not merely + * `undefined`. Tests that call `dispose()` (or register a window/document + * listener) install linkedom's window/document as `globalThis.window`/ + * `globalThis.document` for their duration and restore afterward; no test + * here exercises delegated GLOBAL listeners themselves (only root- + * delegated node listeners), so this is purely to make `dispose()` safe to + * call, not a claim about global-listener behaviour under test. + */ +function withGlobalWindow(win: unknown, fn: () => T): T { + const hadWindow = "window" in globalThis; + const hadDocument = "document" in globalThis; + const prevWindow = (globalThis as Record).window; + const prevDocument = (globalThis as Record).document; + (globalThis as Record).window = win; + (globalThis as Record).document = + (win as { document: unknown }).document; + try { + return fn(); + } finally { + if (hadWindow) (globalThis as Record).window = prevWindow; + else delete (globalThis as Record).window; + if (hadDocument) { + (globalThis as Record).document = prevDocument; + } else delete (globalThis as Record).document; + } +} + +/** `driver.push` can synchronously trigger `onCommit` (a whole batch, + * including its `commit` frame, in one push); calling `nextCommit()` + * AFTER that push is too late, the promise never resolves (the earlier + * commit already drained). This captures the promise first. */ +async function pushAndAwaitCommit( + driver: { push(b: Uint8Array): void; nextCommit(): Promise }, + bytes: Uint8Array, +): Promise { + const p = driver.nextCommit(); + driver.push(bytes); + await p; +} + +// Frame field numbers, transcribed from proto/stream-dom.proto (see +// policy_test.ts's header comment for why these are independent literals +// rather than imports from src/). +const FRAME_COMMIT = 1; +const FRAME_INSERT_BEFORE = 2; +const FRAME_SET_TEXT = 3; +const FRAME_CREATE_ELEMENT = 6; +const FRAME_CREATE_TEXT = 7; +const FRAME_ADD_LISTENER = 12; +const FRAME_INTERN = 14; + +const INTERN_ID = 1; +const INTERN_S = 2; +const CREATE_ELEMENT_ID = 1; +const CREATE_ELEMENT_TAG = 2; +const CREATE_TEXT_ID = 1; +const CREATE_TEXT_TEXT = 2; +const INSERT_BEFORE_PARENT = 1; +const INSERT_BEFORE_ID = 2; +const SET_TEXT_ID = 1; +const SET_TEXT_TEXT = 2; +const LISTENER_ID = 1; +const LISTENER_NAME = 2; +const LISTENER_BUBBLES = 3; +const LISTENER_PREVENT_DEFAULT = 6; +const ADD_LISTENER_LISTENER = 1; + +/** One length-delimited `Frame` message. `build` writes the op field(s); + * `commit` sets `Frame.commit`. */ +function frame(commit: boolean, build?: (w: Writer) => void): Uint8Array { + const inner = new Writer(); + if (commit) inner.writeBool(FRAME_COMMIT, true); + build?.(inner); + const body = inner.finish(); + const framed = new Writer(); + framed.writeVarint32(body.length); + const lenBytes = framed.finish(); + const out = new Uint8Array(lenBytes.length + body.length); + out.set(lenBytes, 0); + out.set(body, lenBytes.length); + return out; +} + +function concat(chunks: Uint8Array[]): Uint8Array { + const total = chunks.reduce((n, c) => n + c.length, 0); + const out = new Uint8Array(total); + let off = 0; + for (const c of chunks) { + out.set(c, off); + off += c.length; + } + return out; +} + +const DIV = 1, CLICK = 2; + +/** intern(div), create-element(10, div), insert(root, 10), create-text(11, + * "hi"), insert(10, 11), set-text(11, "hi there"), commit. */ +function basicFrames(): Uint8Array[] { + return [ + frame(false, (w) => { + w.writeMessage(FRAME_INTERN, (m) => { + m.writeUint32(INTERN_ID, DIV); + m.writeString(INTERN_S, "div"); + }); + }), + frame(false, (w) => { + w.writeMessage(FRAME_INTERN, (m) => { + m.writeUint32(INTERN_ID, CLICK); + m.writeString(INTERN_S, "click"); + }); + }), + frame(false, (w) => { + w.writeMessage(FRAME_CREATE_ELEMENT, (m) => { + m.writeUint32(CREATE_ELEMENT_ID, 10); + m.writeUint32(CREATE_ELEMENT_TAG, DIV); + }); + }), + frame(false, (w) => { + w.writeMessage(FRAME_INSERT_BEFORE, (m) => { + m.writeUint32(INSERT_BEFORE_PARENT, 0); + m.writeUint32(INSERT_BEFORE_ID, 10); + }); + }), + frame(false, (w) => { + w.writeMessage(FRAME_CREATE_TEXT, (m) => { + m.writeUint32(CREATE_TEXT_ID, 11); + m.writeString(CREATE_TEXT_TEXT, "hi"); + }); + }), + frame(false, (w) => { + w.writeMessage(FRAME_INSERT_BEFORE, (m) => { + m.writeUint32(INSERT_BEFORE_PARENT, 10); + m.writeUint32(INSERT_BEFORE_ID, 11); + }); + }), + frame(true, (w) => { + w.writeMessage(FRAME_SET_TEXT, (m) => { + m.writeUint32(SET_TEXT_ID, 11); + m.writeString(SET_TEXT_TEXT, "hi there"); + }); + }), + ]; +} + +/** A bubbling `click` listener on node 10, in its own commit. */ +function addClickListenerFrame( + opts?: { preventDefault?: boolean }, +): Uint8Array { + return frame(true, (w) => { + w.writeMessage(FRAME_ADD_LISTENER, (m) => { + m.writeMessage(ADD_LISTENER_LISTENER, (l) => { + l.writeUint32(LISTENER_ID, 10); + l.writeUint32(LISTENER_NAME, CLICK); + l.writeBool(LISTENER_BUBBLES, true); + if (opts?.preventDefault) l.writeBool(LISTENER_PREVENT_DEFAULT, true); + }); + }); + }); +} + +// -- 1. bytes in, DOM out, no component -------------------------------------- + +Deno.test("Driver: bytes in, DOM out, split mid-frame, no component", async () => { + const { win, root } = fixture(); + const calls: unknown[] = []; + const driver = createDriver({ + root, + handleEvent: (...a) => { + calls.push(a); + }, + }); + + const all = concat(basicFrames()); + // Split mid-frame: partway through the fixed-point of the sequence, + // well inside a frame's body rather than on a frame boundary. + const mid = Math.floor(all.length / 2); + const committed = driver.nextCommit(); + driver.push(all.slice(0, mid)); + driver.push(all.slice(mid)); + await committed; + + assertEquals(root.innerHTML, "
hi there
"); + assertEquals(driver.stats.batches, 1); + assertEquals(driver.stats.frames, 7); + assertEquals(driver.stats.bytes, all.length); + assertEquals(calls.length, 0); + + withGlobalWindow(win, () => driver.dispose()); +}); + +// -- 2. events out ------------------------------------------------------------ + +Deno.test("Driver: a bubbling click listener fires handleEvent with the right target/nameRef/payload", async () => { + const { win, root } = fixture(); + const calls: Array< + [ProducerEventTarget, number, Uint8Array] + > = []; + const driver = createDriver({ + root, + handleEvent: (target, nameRef, payload) => { + calls.push([target, nameRef, payload]); + }, + }); + await pushAndAwaitCommit(driver, concat(basicFrames())); + await pushAndAwaitCommit(driver, addClickListenerFrame()); + + const div = root.querySelector("div")!; + div.dispatchEvent( + new (win as unknown as { Event: typeof Event }).Event( + "click", + { bubbles: true }, + ), + ); + + assertEquals(calls.length, 1); + const [target, nameRef, payload] = calls[0]; + assertEquals(target, { kind: "node", value: 10 }); + assertEquals(nameRef, CLICK); + assertEquals(payload.length > 0, true); // full policy: mouse payload encoded + + withGlobalWindow(win, () => driver.dispose()); +}); + +Deno.test("Driver: a policy whose events omit EventPayload.mouse encodes an empty payload", async () => { + const { win, root } = fixture(); + const calls: Uint8Array[] = []; + const policy: Policy = { + accept: SURFACE_V1.accept, + events: SURFACE_V1.events.filter((f) => f !== "EventPayload.mouse"), + queries: SURFACE_V1.queries, + }; + const driver = createDriver({ + root, + policy, + handleEvent: (_t, _n, payload) => { + calls.push(payload); + }, + }); + await pushAndAwaitCommit(driver, concat(basicFrames())); + await pushAndAwaitCommit(driver, addClickListenerFrame()); + + const div = root.querySelector("div")!; + div.dispatchEvent( + new (win as unknown as { Event: typeof Event }).Event( + "click", + { bubbles: true }, + ), + ); + + assertEquals(calls.length, 1); + assertEquals(calls[0].length, 0); + + withGlobalWindow(win, () => driver.dispose()); +}); + +// -- 3. declarative flags ----------------------------------------------------- + +Deno.test("Driver: prevent_default is honored even though handleEvent does nothing", async () => { + const { win, root } = fixture(); + const driver = createDriver({ + root, + handleEvent: () => {}, // does nothing — flag must still land + }); + await pushAndAwaitCommit(driver, concat(basicFrames())); + await pushAndAwaitCommit( + driver, + addClickListenerFrame({ preventDefault: true }), + ); + + const div = root.querySelector("div")!; + const ev = new (win as unknown as { Event: typeof Event }).Event("click", { + bubbles: true, + cancelable: true, + }); + div.dispatchEvent(ev); + + assertEquals(ev.defaultPrevented, true); + + withGlobalWindow(win, () => driver.dispose()); +}); + +// -- 4. queries gated ---------------------------------------------------------- + +Deno.test("Driver: getClientRect refuses under a policy that omits it and answers with it declared", async () => { + // No policy at all means "no policy" allows everything (policy.ts + // `queryAllowed`'s "undeclared -> allow"), so refusal needs an EXPLICIT + // policy whose `queries` omits `get-client-rect`. + const { win, root } = fixture(); + const refusing: Policy = { + accept: SURFACE_V1.accept, + events: SURFACE_V1.events, + queries: [], + }; + const noPolicy = createDriver({ + root, + policy: refusing, + handleEvent: () => {}, + }); + await pushAndAwaitCommit(noPolicy, concat(basicFrames())); + assertEquals(noPolicy.queries.getClientRect(10), undefined); + withGlobalWindow(win, () => noPolicy.dispose()); + + const { win: win2, root: root2 } = fixture(); + const policy: Policy = { + accept: SURFACE_V1.accept, + events: SURFACE_V1.events, + queries: ["get-client-rect"], + }; + const withPolicy = createDriver({ + root: root2, + policy, + handleEvent: () => {}, + }); + await pushAndAwaitCommit(withPolicy, concat(basicFrames())); + const rect = withPolicy.queries.getClientRect(10); + assertEquals(rect, { origin: { x: 0, y: 0 }, size: { width: 0, height: 0 } }); + withGlobalWindow(win2, () => withPolicy.dispose()); +}); + +Deno.test("Driver: setFocus refuses under a policy that omits it and answers with it declared", async () => { + const { win, root } = fixture(); + const refusing: Policy = { + accept: SURFACE_V1.accept, + events: SURFACE_V1.events, + queries: [], + }; + const noPolicy = createDriver({ + root, + policy: refusing, + handleEvent: () => {}, + }); + await pushAndAwaitCommit(noPolicy, concat(basicFrames())); + assertEquals(noPolicy.queries.setFocus(10, true), false); + withGlobalWindow(win, () => noPolicy.dispose()); + + const { win: win2, root: root2 } = fixture(); + const policy: Policy = { + accept: SURFACE_V1.accept, + events: SURFACE_V1.events, + queries: ["set-focus"], + }; + const withPolicy = createDriver({ + root: root2, + policy, + handleEvent: () => {}, + }); + await pushAndAwaitCommit(withPolicy, concat(basicFrames())); + assertEquals(withPolicy.queries.setFocus(10, true), true); + withGlobalWindow(win2, () => withPolicy.dispose()); +}); + +// -- 5. abort semantics --------------------------------------------------------- + +Deno.test("Driver: a policy violation (undeclared field) throws PolicyError", () => { + const { win, root } = fixture(); + const policy: Policy = { + // Missing "Frame.create_element" — the frame sequence below uses it. + accept: SURFACE_V1.accept.filter((f) => f !== "Frame.create_element"), + events: SURFACE_V1.events, + queries: SURFACE_V1.queries, + }; + const driver = createDriver({ root, policy, handleEvent: () => {} }); + assertThrows( + () => driver.push(concat(basicFrames())), + PolicyError, + ); + withGlobalWindow(win, () => driver.dispose()); +}); + +Deno.test("Driver: an unknown node id throws", () => { + const { win, root } = fixture(); + const driver = createDriver({ root, handleEvent: () => {} }); + const bad = frame(true, (w) => { + w.writeMessage(FRAME_SET_TEXT, (m) => { + m.writeUint32(SET_TEXT_ID, 999); + m.writeString(SET_TEXT_TEXT, "x"); + }); + }); + assertThrows(() => driver.push(bad), Error, "unknown node id 999"); + withGlobalWindow(win, () => driver.dispose()); +}); + +Deno.test("Driver: after dispose, root listeners are gone (dispatch no longer calls handleEvent)", async () => { + const { win, root } = fixture(); + const calls: unknown[] = []; + const driver = createDriver({ + root, + handleEvent: (...a) => { + calls.push(a); + }, + }); + await pushAndAwaitCommit(driver, concat(basicFrames())); + await pushAndAwaitCommit(driver, addClickListenerFrame()); + + withGlobalWindow(win, () => driver.dispose()); + + const div = root.querySelector("div")!; + div.dispatchEvent( + new (win as unknown as { Event: typeof Event }).Event( + "click", + { bubbles: true }, + ), + ); + assertEquals(calls.length, 0); + + // push is a no-op after dispose (Driver.push's documented contract). + driver.push(concat(basicFrames())); +}); diff --git a/receiver/tests/hostile_test.ts b/receiver/tests/hostile_test.ts new file mode 100644 index 0000000..b8df31c --- /dev/null +++ b/receiver/tests/hostile_test.ts @@ -0,0 +1,569 @@ +// Two deterministic fuzzers over the decode+apply path, checking one +// property: however malformed or hostile the bytes, the receiver either +// applies them or throws an `Error` — and never damages the DOM the +// embedder owns around the mount root (docs/design.md "Policy": a throwing +// sink aborts the stream, and "Partial-batch DOM state at that point is +// the embedder's to tear down"). +// +// Field numbers below are transcribed from proto/stream-dom.proto (the +// normative file) rather than imported from the source under test, as +// policy_test.ts does. + +import { assert } from "@std/assert"; +import { parseHTML } from "linkedom"; +import { FrameDecoder, MAX_FRAME_BYTES } from "../src/frames.ts"; +import { NativeDomReceiver } from "../src/native.ts"; +import { Writer } from "../src/proto.ts"; + +// -- deterministic PRNG --------------------------------------------------- + +/** mulberry32: 32-bit state, no dependencies, reproducible from a seed — + * every failure below is replayable by re-running with the same seed. */ +function prng(seed: number): () => number { + let a = seed >>> 0; + return () => { + a = (a + 0x6d2b79f5) >>> 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +// -- harness -------------------------------------------------------------- + +/** See native_test.ts: linkedom does not enforce the DOM's tree-acyclicity + * invariant, which the receiver relies on the browser for. The shim + * reproduces the browser's `HierarchyRequestError`; without it a cyclic + * tree built by a hostile stream hangs the first traversal, and a fuzzer + * that hangs reports nothing. */ +const guarded = new WeakSet(); +function installHierarchyGuard(doc: Document): void { + let proto: object | null = Object.getPrototypeOf(doc.createElement("div")); + for (; proto !== null; proto = Object.getPrototypeOf(proto)) { + if (guarded.has(proto)) continue; + guarded.add(proto); + const target = proto as unknown as Record; + for (const name of ["insertBefore", "appendChild", "moveBefore"]) { + if (!Object.getOwnPropertyDescriptor(proto, name)) continue; + const orig = target[name]; + if (typeof orig !== "function") continue; + const call = orig as (this: Node, ...args: unknown[]) => unknown; + target[name] = function (this: Node, node: Node, ...rest: unknown[]) { + for (let p: Node | null = this; p !== null; p = p.parentNode) { + if (p === node) { + throw new DOMException( + `${name}: the node is an ancestor of the parent`, + "HierarchyRequestError", + ); + } + } + return call.call(this, node, ...rest); + }; + } + } +} + +const SENTINEL_HTML = `untouched`; + +interface Harness { + root: Element; + container: Element; + sentinel: Element; + recv: NativeDomReceiver; + decoder: FrameDecoder; +} + +function harness(): Harness { + const win = parseHTML( + `
${SENTINEL_HTML}
`, + ); + const doc = win.document as unknown as Document; + installHierarchyGuard(doc); + const root = doc.getElementById("root")!; + const recv = new NativeDomReceiver(root); + return { + root, + container: doc.getElementById("container")!, + sentinel: doc.getElementById("sentinel")!, + recv, + decoder: new FrameDecoder(recv.sink), + }; +} + +/** The embedder's DOM around the mount is intact and the mount root is + * still addressable as id 0. Checked after every `push` and after every + * throw. */ +function assertMountIntact(h: Harness, where: string): void { + // Identity comparisons use `assert`, not `assertStrictEquals`: on + // failure the latter formats both DOM nodes into a diff, and stringifying + // a linkedom node graph is slow enough to look like a hang — which would + // hide exactly the failure this fuzzer exists to report. + assert(h.recv.resolveNode(0) === h.root, `${where}: root is no longer id 0`); + assert(h.root.parentNode === h.container, `${where}: root left its parent`); + assert(h.container.childNodes[0] === h.root, `${where}: root moved`); + assert(h.container.childNodes[1] === h.sentinel, `${where}: sentinel moved`); + assert( + h.sentinel.outerHTML === SENTINEL_HTML, + `${where}: sentinel mutated: ${h.sentinel.outerHTML}`, + ); +} + +// -- frame encoding ------------------------------------------------------- + +/** One length-delimited `Frame`, as the stream layout specifies (varint + * byte length, then the message). */ +function frame(build: (w: Writer) => void): Uint8Array { + const body = (() => { + const w = new Writer(); + build(w); + return w.finish(); + })(); + const lp = new Writer(); + lp.writeVarint32(body.length); + const prefix = lp.finish(); + const out = new Uint8Array(prefix.length + body.length); + out.set(prefix, 0); + out.set(body, prefix.length); + return out; +} + +function concat(parts: Uint8Array[]): Uint8Array { + const total = parts.reduce((n, p) => n + p.length, 0); + const out = new Uint8Array(total); + let at = 0; + for (const p of parts) { + out.set(p, at); + at += p.length; + } + return out; +} + +// Frame.op field numbers (proto/stream-dom.proto). +const F_COMMIT = 1, + F_INSERT_BEFORE = 2, + F_SET_TEXT = 3, + F_SET_ATTRIBUTE = 4, + F_SET_PROPERTY = 5, + F_CREATE_ELEMENT = 6, + F_CREATE_TEXT = 7, + F_REMOVE = 8, + F_CLONE_TEMPLATE = 9, + F_BIND_PATH = 10, + F_CREATE_PLACEHOLDER = 11, + F_ADD_LISTENER = 12, + F_REMOVE_LISTENER = 13, + F_INTERN = 14, + F_REGISTER_TEMPLATE = 15, + F_INSERT_AFTER = 16, + F_BIND_MARKER = 17; + +// -- structured fuzz ------------------------------------------------------ + +const STRUCTURED_RUNS = 3000; +const OPS_PER_RUN = 40; + +Deno.test("hostile: random op sequences never damage the embedder's DOM", () => { + const rand = prng(0x5eed_1234); + const pick = (xs: readonly T[]): T => xs[Math.floor(rand() * xs.length)]; + const int = (n: number) => Math.floor(rand() * n); + + // Interned refs the generator defines up front (1..4), plus refs it + // never defines and the always-absent slot 0. + const goodRefs = [1, 2, 3, 4] as const; + const badRefs = [0, 90, 91, 4096] as const; + // The generator keeps an approximate model of which ids are live so that + // a run gets DEEP before it trips: a purely random target would make + // almost every first op an unknown id, and a run stops at the first + // throw (the mount would have aborted the stream). Hostile targets — the + // mount root, never-created ids, ids just removed — are mixed in at + // `HOSTILE_RATE`, which is what the fuzzer is actually probing. + const HOSTILE_RATE = 0.15; + const badIds = [0, 700, 900, 65_535] as const; + /** Distinct rejection messages seen (digits normalized away). A floor on + * its size below is the guard against the generator silently + * degenerating into "every run dies the same way on op 1" — a fuzzer + * that stops reaching the rules would otherwise still pass. */ + const rejections = new Map(); + + for (let run = 0; run < STRUCTURED_RUNS; run++) { + const h = harness(); + // The model tracks live ids, and separately those that are ELEMENTS, + // so a generated `parent` is usually something that can hold children. + // It is deliberately approximate (a removed subtree's descendants stay + // in it, for instance): its job is to keep runs deep enough to reach + // interesting states, not to predict the receiver. + const live: number[] = []; + const liveElements: number[] = [0]; + let nextId = 1; + const some = (xs: readonly number[]) => xs[Math.floor(rand() * xs.length)]; + /** An existing node: usually a live one, sometimes hostile (the mount + * root, a never-created id, one just removed). */ + const target = () => + rand() < HOSTILE_RATE || live.length === 0 ? pick(badIds) : some(live); + /** Something to insert into. Element-valued far more often than not. */ + const parent = () => + rand() < HOSTILE_RATE ? pick(badIds) : some(liveElements); + /** A fresh id for a create/clone/bind op — sometimes a duplicate + * registration or the mount root instead. */ + const newId = (isElement: boolean) => { + if (rand() < HOSTILE_RATE) return pick(badIds); + const id = nextId++; + live.push(id); + if (isElement) liveElements.push(id); + return id; + }; + const ref = () => rand() < HOSTILE_RATE ? pick(badRefs) : pick(goodRefs); + const frames: Uint8Array[] = [ + frame((w) => + w.writeMessage(F_INTERN, (m) => { + m.writeUint32(1, 1); + m.writeString(2, "div"); + }) + ), + frame((w) => + w.writeMessage(F_INTERN, (m) => { + m.writeUint32(1, 2); + m.writeString(2, "span"); + }) + ), + frame((w) => + w.writeMessage(F_INTERN, (m) => { + m.writeUint32(1, 3); + m.writeString(2, "class"); + }) + ), + frame((w) => + w.writeMessage(F_INTERN, (m) => { + m.writeUint32(1, 4); + m.writeString(2, "click"); + }) + ), + ]; + // Seed a small live tree so the generated ops below have something + // real to address from op 1: with an empty model every early op names + // an unknown id and the run dies before it explores anything. + for (let k = 0; k < 3; k++) { + const id = newId(true); + frames.push(frame((w) => + w.writeMessage(F_CREATE_ELEMENT, (m) => { + m.writeUint32(1, id); + m.writeUint32(2, 1); + }) + )); + frames.push(frame((w) => + w.writeMessage(F_INSERT_BEFORE, (m) => { + m.writeUint32(1, k === 0 ? 0 : k); + m.writeUint32(2, id); + }) + )); + } + + for (let i = 0; i < OPS_PER_RUN; i++) { + switch (int(15)) { + case 0: + frames.push(frame((w) => + w.writeMessage(F_CREATE_ELEMENT, (m) => { + m.writeUint32(1, newId(true)); + m.writeUint32(2, ref()); + if (rand() < 0.3) m.writeUint32(3, ref()); + }) + )); + break; + case 1: + frames.push(frame((w) => + w.writeMessage(F_CREATE_TEXT, (m) => { + m.writeUint32(1, newId(false)); + m.writeString(2, "t" + i); + }) + )); + break; + case 2: + frames.push( + frame((w) => + w.writeMessage( + F_CREATE_PLACEHOLDER, + (m) => m.writeUint32(1, newId(false)), + ) + ), + ); + break; + case 3: + frames.push(frame((w) => + w.writeMessage(F_INSERT_BEFORE, (m) => { + if (rand() < 0.75) m.writeUint32(1, parent()); + m.writeUint32(2, target()); + if (rand() < 0.25) m.writeUint32(3, target()); + }) + )); + break; + case 4: + frames.push(frame((w) => + w.writeMessage(F_INSERT_AFTER, (m) => { + if (rand() < 0.75) m.writeUint32(1, parent()); + m.writeUint32(2, target()); + m.writeUint32(3, target()); + }) + )); + break; + case 5: { + const gone = target(); + const at = live.indexOf(gone); + if (at >= 0) live.splice(at, 1); + frames.push( + frame((w) => + w.writeMessage(F_REMOVE, (m) => m.writeUint32(1, gone)) + ), + ); + break; + } + case 6: + frames.push(frame((w) => + w.writeMessage(F_SET_TEXT, (m) => { + m.writeUint32(1, target()); + m.writeString(2, "x" + i); + }) + )); + break; + case 7: + frames.push(frame((w) => + w.writeMessage(F_SET_ATTRIBUTE, (m) => { + m.writeUint32(1, target()); + m.writeUint32(2, ref()); + if (rand() < 0.3) m.writeUint32(3, ref()); + if (rand() < 0.7) m.writeString(4, "v" + i); + }) + )); + break; + case 8: + frames.push(frame((w) => + w.writeMessage(F_SET_PROPERTY, (m) => { + m.writeUint32(1, target()); + m.writeUint32(2, ref()); + if (rand() < 0.5) m.writeString(3, "v" + i); + else m.writeBool(6, rand() < 0.5); + }) + )); + break; + case 9: + frames.push(frame((w) => + w.writeMessage( + rand() < 0.5 ? F_ADD_LISTENER : F_REMOVE_LISTENER, + (m) => + m.writeMessage(1, (l) => { + if (rand() < 0.8) l.writeUint32(1, target()); + else l.writeUint32(8, int(3)); // Global, sometimes unknown + l.writeUint32(2, ref()); + l.writeBool(3, rand() < 0.5); + }), + ) + )); + break; + case 10: + // A template arena with random (often out-of-range or cyclic) + // child indices. + frames.push(frame((w) => + w.writeMessage(F_REGISTER_TEMPLATE, (m) => { + m.writeUint32(1, int(3)); + const n = 1 + int(4); + for (let k = 0; k < n; k++) { + m.writeMessage(2, (tn) => { + if (rand() < 0.6) { + tn.writeMessage(1, (el) => { + el.writeUint32(1, ref()); + if (rand() < 0.5) { + el.writeMessage(3, (at) => { + at.writeUint32(1, ref()); + at.writeString(3, "a"); + }); + } + for (let c = 0; c < int(3); c++) { + el.writeUint32(4, int(6)); + } + }); + } else if (rand() < 0.5) tn.writeString(2, "t"); + else tn.writeMessage(3, () => {}); + }); + } + m.writeUint32(3, int(4)); + }) + )); + break; + case 11: + frames.push(frame((w) => + w.writeMessage(F_CLONE_TEMPLATE, (m) => { + m.writeUint32(1, int(4)); + m.writeUint32(2, int(4)); + m.writeUint32(3, newId(true)); + }) + )); + break; + case 12: + frames.push(frame((w) => + w.writeMessage(F_BIND_PATH, (m) => { + m.writeUint32(1, target()); + // BindPath.path is `bytes`; each step is written as a + // one-byte varint, which is byte-identical to the raw byte + // for values < 128 — the only range used here. + m.writeMessage(2, (p) => { + for (let s = 0; s < int(4); s++) p.writeVarint32(int(4)); + }); + m.writeUint32(3, newId(true)); + }) + )); + break; + case 13: + frames.push(frame((w) => + w.writeMessage(F_BIND_MARKER, (m) => { + m.writeUint32(1, int(4)); + m.writeUint32(2, newId(false)); + }) + )); + break; + default: + frames.push(frame((w) => w.writeBool(F_COMMIT, true))); + } + } + + // Feed the whole stream in chunks split at random byte boundaries: a + // frame straddling a chunk must behave exactly as one that does not. + const bytes = concat(frames); + let at = 0; + let threw = false; + while (at < bytes.length && !threw) { + const next = Math.min(bytes.length, at + 1 + int(24)); + try { + h.decoder.push(bytes.subarray(at, next)); + } catch (err) { + // A throw aborts the stream (mount.ts drops the read end), so the + // run stops here — exactly as production would. + assert( + err instanceof Error, + `run ${run}: thrown value is not an Error`, + ); + threw = true; + const kind = err.message.replace(/[0-9]+/g, "N"); + rejections.set(kind, (rejections.get(kind) ?? 0) + 1); + } + assertMountIntact(h, `run ${run} @${at}`); + at = next; + } + } + assert( + rejections.size >= 10, + `generator degenerated: only ${rejections.size} distinct rejections ` + + `(${[...rejections.keys()].join(" | ")})`, + ); +}); + +// -- byte-mutation fuzz --------------------------------------------------- + +const MUTATION_RUNS = 20_000; +/** A single mutated fixture is a few hundred bytes; anything near a second + * means the decoder or the receiver is looping, which is the failure this + * fuzzer exists to catch. */ +const PER_ITERATION_BUDGET_MS = 1000; + +const fixtureBytes = await Deno.readFile( + new URL("../../crates/stream-dom-guest/fixtures/basic.pb", import.meta.url), +); + +Deno.test("hostile: byte mutations of basic.pb neither hang nor damage the mount", () => { + const rand = prng(0xf1ee_2024); + const int = (n: number) => Math.floor(rand() * n); + + for (let run = 0; run < MUTATION_RUNS; run++) { + let bytes: Uint8Array = Uint8Array.from(fixtureBytes); + switch (int(4)) { + case 0: { // flip bits in a few bytes + for (let k = 0; k < 1 + int(4); k++) { + const at = int(bytes.length); + bytes[at] ^= 1 << int(8); + } + break; + } + case 1: // truncate + bytes = bytes.subarray(0, int(bytes.length)); + break; + case 2: { // insert junk + const at = int(bytes.length); + const junk = new Uint8Array(1 + int(8)); + for (let k = 0; k < junk.length; k++) junk[k] = int(256); + bytes = concat([bytes.subarray(0, at), junk, bytes.subarray(at)]); + break; + } + default: { // replace a byte outright (hits length prefixes hardest) + bytes[int(bytes.length)] = int(256); + break; + } + } + + const h = harness(); + const started = performance.now(); + let at = 0; + let threw = false; + while (at < bytes.length && !threw) { + const next = Math.min(bytes.length, at + 1 + int(32)); + try { + h.decoder.push(bytes.subarray(at, next)); + } catch (err) { + assert( + err instanceof Error, + `run ${run}: thrown value is not an Error`, + ); + threw = true; + } + assertMountIntact(h, `mutation run ${run} @${at}`); + at = next; + } + const elapsed = performance.now() - started; + assert( + elapsed < PER_ITERATION_BUDGET_MS, + `mutation run ${run} took ${ + elapsed.toFixed(0) + }ms (budget ${PER_ITERATION_BUDGET_MS}ms)`, + ); + } +}); + +// -- decoder length bound ------------------------------------------------- + +Deno.test("FrameDecoder: a length prefix over MAX_FRAME_BYTES throws instead of buffering forever", () => { + const h = harness(); + const lp = new Writer(); + lp.writeVarint32(MAX_FRAME_BYTES + 1); + let caught: unknown; + try { + h.decoder.push(lp.finish()); + } catch (err) { + caught = err; + } + assert(caught instanceof Error, "expected an Error"); + assert( + caught.message.includes("MAX_FRAME_BYTES"), + `unexpected message: ${caught.message}`, + ); + // A length just under the bound is still "wait for more bytes", not an + // error — the bound is a ceiling, not a size limit on real frames. + const ok = new Writer(); + ok.writeVarint32(MAX_FRAME_BYTES); + harness().decoder.push(ok.finish()); +}); + +Deno.test("FrameDecoder: a truncated sub-message inside a COMPLETE frame is an error, not a wait", () => { + const h = harness(); + // Frame { create_text: }, wrapped + // in a frame length prefix that is itself correct. Only the length probe + // in `#drain` may treat a truncation as "wait for more"; inside a frame + // whose bytes are all present, it is a malformed frame. + const body = Uint8Array.of((F_CREATE_TEXT << 3) | 2, 20, 0x08, 0x01); + const lp = new Writer(); + lp.writeVarint32(body.length); + let caught: unknown; + try { + h.decoder.push(concat([lp.finish(), body])); + } catch (err) { + caught = err; + } + assert(caught instanceof Error, "expected an Error, got " + String(caught)); + assertMountIntact(h, "truncated sub-message"); +}); diff --git a/receiver/tests/native_test.ts b/receiver/tests/native_test.ts new file mode 100644 index 0000000..e211472 --- /dev/null +++ b/receiver/tests/native_test.ts @@ -0,0 +1,514 @@ +// `NativeDomReceiver` under a real-ish DOM (linkedom), covering the happy +// path of every op and each fail-closed rule the receiver owns +// (docs/design.md "Policy", "What the receiver does not yet guarantee"). +// +// Everything here throws a plain `Error`, which is the whole contract: +// anything the sink throws propagates out of `FrameDecoder.push` and the +// mount aborts the stream (docs/design.md "Policy", "The seam"). + +import { assertEquals, assertStrictEquals, assertThrows } from "@std/assert"; +import { parseHTML } from "linkedom"; +import { NativeDomReceiver } from "../src/native.ts"; +import type { TemplateNode } from "../src/frames.ts"; + +// -- harness -------------------------------------------------------------- + +/** A real browser DOM enforces tree acyclicity itself: `insertBefore` / + * `appendChild` / `moveBefore` throw `HierarchyRequestError` when the node + * being inserted IS the parent or an ancestor of it. The receiver relies + * on that guarantee and deliberately does not re-walk ancestors on the + * insert hot path. linkedom only rejects the self-append case and will + * happily build a cycle out of a node and its own parent, after which any + * traversal hangs — so the test DOM gets a shim that reproduces the + * browser's guarantee. This is emulating a browser invariant, not testing + * receiver code. + */ +const guarded = new WeakSet(); +function installHierarchyGuard(doc: Document): void { + // linkedom mixes `insertBefore`/`appendChild` into several prototypes in + // the chain (its ParentNode mixin lands on `Element.prototype`, shadowing + // `Node.prototype`), so patch every own definition along the chain of a + // sample element rather than assuming one home. + let proto: object | null = Object.getPrototypeOf(doc.createElement("div")); + for (; proto !== null; proto = Object.getPrototypeOf(proto)) { + if (guarded.has(proto)) continue; + guarded.add(proto); + const target = proto as unknown as Record; + for (const name of ["insertBefore", "appendChild", "moveBefore"]) { + if (!Object.getOwnPropertyDescriptor(proto, name)) continue; + const orig = target[name]; + if (typeof orig !== "function") continue; + const call = orig as (this: Node, ...args: unknown[]) => unknown; + target[name] = function (this: Node, node: Node, ...rest: unknown[]) { + for (let p: Node | null = this; p !== null; p = p.parentNode) { + if (p === node) { + throw new DOMException( + `${name}: the node is an ancestor of the parent`, + "HierarchyRequestError", + ); + } + } + return call.call(this, node, ...rest); + }; + } + } +} + +interface Fixture { + doc: Document; + /** The mount root — producer id 0. */ + root: Element; + /** The root's parent, owned by the embedder. */ + container: Element; + /** A sibling of the root that no op may ever touch. */ + sentinel: Element; + recv: NativeDomReceiver; +} + +export function fixture(): Fixture { + const win = parseHTML( + `
untouched
`, + ); + const doc = win.document as unknown as Document; + installHierarchyGuard(doc); + const container = doc.getElementById("container")!; + const root = doc.getElementById("root")!; + const sentinel = doc.getElementById("sentinel")!; + return { doc, root, container, sentinel, recv: new NativeDomReceiver(root) }; +} + +/** Intern refs used across the tests below. */ +const DIV = 1, SPAN = 2, CLASS = 3, CLICK = 4, SVG_NS = 5; + +function interned(recv: NativeDomReceiver): void { + recv.internString(DIV, "div"); + recv.internString(SPAN, "span"); + recv.internString(CLASS, "class"); + recv.internString(CLICK, "click"); + recv.internString(SVG_NS, "http://www.w3.org/2000/svg"); +} + +// -- happy path ----------------------------------------------------------- + +Deno.test("NativeDomReceiver: every op on the happy path", () => { + const { recv, root } = fixture(); + interned(recv); + + recv.createElement(1, DIV, undefined); + recv.createText(2, "hello"); + recv.createPlaceholder(3); + recv.insertBefore(0, 1, undefined); // append to the mount root + recv.insertBefore(1, 2, undefined); + recv.insertAfter(1, 3, 2); + recv.setText(2, "hello, world"); + recv.setAttribute(1, CLASS, undefined, "greeting"); + recv.setProperty(1, CLASS, { kind: "text", value: "prop" }); + recv.addListener({ + target: { kind: "node", id: 1 }, + name: CLICK, + bubbles: true, + capture: false, + passive: false, + preventDefault: false, + stopPropagation: false, + }); + assertEquals(recv.listeners.listenerFor(1, CLICK)?.name, CLICK); + + // A namespaced element: the reason this receiver exists beside the + // remote-dom one. + recv.createElement(4, SPAN, SVG_NS); + recv.insertBefore(0, 4, undefined); + assertEquals( + (recv.resolveNode(4) as Element).namespaceURI, + "http://www.w3.org/2000/svg", + ); + + // register-template / clone-template / bind-path. + const nodes: TemplateNode[] = [ + { + kind: "element", + element: { + tag: DIV, + ns: undefined, + attrs: [{ name: CLASS, ns: undefined, value: "row" }], + children: [1, 2], + }, + }, + { kind: "text", text: "cell" }, + { kind: "dynamic" }, + ]; + recv.registerTemplate(7, nodes, [0]); + recv.cloneTemplate(7, 0, 10); + recv.bindPath(10, Uint8Array.of(0), 11); // the "cell" text node + recv.setText(11, "bound"); + recv.insertBefore(0, 10, undefined); + + assertEquals( + root.innerHTML, + `
hello, world
` + + `` + // linkedom self-closes a foreign (SVG) element + `
bound
`, + ); + + recv.removeListener({ + target: { kind: "node", id: 1 }, + name: CLICK, + bubbles: true, + capture: false, + passive: false, + preventDefault: false, + stopPropagation: false, + }); + assertEquals(recv.listeners.listenerFor(1, CLICK), undefined); + + let commits = 0; + recv.onCommit = () => commits++; + recv.commit(); + assertEquals(commits, 1); + + recv.remove(1); + assertEquals(recv.resolveNode(1), undefined); + assertEquals(recv.resolveNode(2), undefined); // freed with the subtree + recv.dispose(); +}); + +// -- 1. the mount root is structurally inviolable ------------------------- + +Deno.test("NativeDomReceiver: no op may create, re-register or alias id 0", () => { + const { recv, root } = fixture(); + interned(recv); + recv.registerTemplate(7, [{ kind: "text", text: "t" }], [0]); + recv.createElement(1, DIV, undefined); + + assertThrows( + () => recv.createElement(0, DIV, undefined), + Error, + "mount root", + ); + assertThrows(() => recv.createText(0, "x"), Error, "mount root"); + assertThrows(() => recv.createPlaceholder(0), Error, "mount root"); + assertThrows(() => recv.cloneTemplate(7, 0, 0), Error, "mount root"); + assertThrows( + () => recv.bindPath(1, new Uint8Array(0), 0), + Error, + "mount root", + ); + // Aliasing the root under a second id: bind-path with an empty path. + assertThrows(() => recv.bindPath(0, new Uint8Array(0), 99), Error, "alias"); + + assertStrictEquals(recv.resolveNode(0), root); +}); + +Deno.test("NativeDomReceiver: no op may move or remove id 0", () => { + const { recv, root, container, sentinel } = fixture(); + interned(recv); + recv.createElement(1, DIV, undefined); + recv.insertBefore(0, 1, undefined); + + assertThrows(() => recv.insertBefore(1, 0, undefined), Error, "mount root"); + assertThrows(() => recv.insertAfter(1, 0, 1), Error, "mount root"); + assertThrows(() => recv.remove(0), Error, "mount root"); + + assertStrictEquals(root.parentNode, container); + assertStrictEquals(container.firstElementChild, root); + assertEquals(sentinel.outerHTML, `untouched`); +}); + +Deno.test("NativeDomReceiver: the mount root may not be used as an insert anchor", () => { + const { recv, root, container, sentinel } = fixture(); + interned(recv); + recv.createElement(1, DIV, undefined); + + // With `parent` omitted the parent is implied from the anchor, so anchor + // 0 would resolve to the EMBEDDER's container and drop a producer node + // beside the mount root, outside the mount entirely. + assertThrows(() => recv.insertBefore(undefined, 1, 0), Error, "anchor"); + assertThrows(() => recv.insertAfter(undefined, 1, 0), Error, "anchor"); + // Naming a parent does not rehabilitate it. + assertThrows(() => recv.insertBefore(0, 1, 0), Error, "anchor"); + assertThrows(() => recv.insertAfter(0, 1, 0), Error, "anchor"); + + assertEquals(container.childNodes.length, 2); + assertStrictEquals(container.childNodes[0], root); + assertEquals(sentinel.outerHTML, `untouched`); + assertEquals(root.innerHTML, ""); +}); + +Deno.test("NativeDomReceiver: leaf ops on the root stay legal", () => { + const { recv, root } = fixture(); + interned(recv); + recv.setAttribute(0, CLASS, undefined, "mounted"); + assertEquals(root.getAttribute("class"), "mounted"); + recv.setProperty(0, CLASS, { kind: "boolean", value: true }); + recv.setAttribute(0, CLASS, undefined, undefined); + assertEquals(root.hasAttribute("class"), false); + // ...and the root is still the root; a leaf op registers nothing. + assertStrictEquals(recv.resolveNode(0), root); +}); + +// -- 2. ids are not reused ------------------------------------------------ + +Deno.test("NativeDomReceiver: a currently-registered id cannot be re-registered", () => { + const { recv } = fixture(); + interned(recv); + recv.createElement(1, DIV, undefined); + const first = recv.resolveNode(1); + + assertThrows( + () => recv.createElement(1, SPAN, undefined), + Error, + "already registered", + ); + assertThrows(() => recv.createText(1, "x"), Error, "already registered"); + assertThrows(() => recv.createPlaceholder(1), Error, "already registered"); + assertStrictEquals(recv.resolveNode(1), first); +}); + +Deno.test("NativeDomReceiver: remove frees the id, and a fresh create may take it", () => { + const { recv, root } = fixture(); + interned(recv); + recv.createElement(1, DIV, undefined); + recv.insertBefore(0, 1, undefined); + recv.remove(1); + assertEquals(recv.resolveNode(1), undefined); + // Only CURRENTLY registered ids are rejected: this receiver keeps no + // record of ids a `remove` forgot, so the proto's "never reused" rule is + // only half-enforceable (see `#register`'s doc). Re-creating id 1 here + // is a protocol violation the receiver cannot see, and it must not + // corrupt anything. + recv.createElement(2, SPAN, undefined); + recv.insertBefore(0, 2, undefined); + assertEquals(root.innerHTML, ""); +}); + +Deno.test("NativeDomReceiver: bind-path may not alias an already-registered node", () => { + const { recv } = fixture(); + interned(recv); + recv.registerTemplate(7, [{ + kind: "element", + element: { tag: DIV, ns: undefined, attrs: [], children: [1] }, + }, { kind: "text", text: "t" }], [0]); + recv.cloneTemplate(7, 0, 10); + recv.bindPath(10, Uint8Array.of(0), 11); + + assertThrows(() => recv.bindPath(10, Uint8Array.of(0), 12), Error, "alias"); + assertThrows(() => recv.bindPath(10, new Uint8Array(0), 13), Error, "alias"); + assertEquals(recv.resolveNode(12), undefined); +}); + +// -- 3. node-type checks -------------------------------------------------- + +Deno.test("NativeDomReceiver: set-text requires a character-data node", () => { + const { recv } = fixture(); + interned(recv); + recv.createElement(1, DIV, undefined); + recv.createText(2, "a"); + recv.createPlaceholder(3); + + assertThrows(() => recv.setText(1, "x"), Error, "not a text or comment"); + assertThrows(() => recv.setText(0, "x"), Error, "not a text or comment"); + // The element gained no expando from the rejected op. + assertEquals( + (recv.resolveNode(1) as unknown as { data?: unknown }).data, + undefined, + ); + recv.setText(2, "b"); + recv.setText(3, "c"); // comment nodes are character data too + assertEquals((recv.resolveNode(2) as CharacterData).data, "b"); + assertEquals((recv.resolveNode(3) as CharacterData).data, "c"); +}); + +Deno.test("NativeDomReceiver: set-attribute / set-property require an element", () => { + const { recv } = fixture(); + interned(recv); + recv.createText(2, "a"); + + assertThrows( + () => recv.setAttribute(2, CLASS, undefined, "x"), + Error, + "set-attribute target 2 is not an element", + ); + assertThrows( + () => recv.setAttribute(2, CLASS, undefined, undefined), + Error, + "not an element", + ); + assertThrows( + () => recv.setProperty(2, CLASS, { kind: "text", value: "x" }), + Error, + "set-property target 2 is not an element", + ); +}); + +// -- 4. interned refs must resolve ---------------------------------------- + +Deno.test("NativeDomReceiver: an unresolved string ref throws", () => { + const { recv } = fixture(); + recv.internString(DIV, "div"); + + assertThrows( + () => recv.createElement(1, 77, undefined), + Error, + "string ref 77", + ); + assertThrows( + () => recv.createElement(1, DIV, 77), + Error, + "string ref 77", + ); + assertThrows( + () => recv.setAttribute(0, 77, undefined, "x"), + Error, + "string ref 77", + ); + assertThrows( + () => recv.setProperty(0, 77, { kind: "none" }), + Error, + "string ref 77", + ); + assertThrows( + () => + recv.registerTemplate( + 7, + [{ + kind: "element", + element: { tag: 77, ns: undefined, attrs: [], children: [] }, + }], + [0], + ), + Error, + "string ref 77", + ); + // The failed create registered nothing. + assertEquals(recv.resolveNode(1), undefined); +}); + +Deno.test("NativeDomReceiver: re-interning a live slot is legal (proto Intern: 'define or overwrite')", () => { + const { recv, root } = fixture(); + recv.internString(DIV, "div"); + recv.createElement(1, DIV, undefined); + recv.internString(DIV, "span"); + recv.createElement(2, DIV, undefined); + recv.insertBefore(0, 1, undefined); + recv.insertBefore(0, 2, undefined); + assertEquals(root.innerHTML, "
"); +}); + +// -- 6. insert / remove sanity -------------------------------------------- + +Deno.test("NativeDomReceiver: a node cannot be inserted into itself or its own subtree", () => { + const { recv, root, sentinel } = fixture(); + interned(recv); + recv.createElement(1, DIV, undefined); + recv.createElement(2, DIV, undefined); + recv.insertBefore(0, 1, undefined); + recv.insertBefore(1, 2, undefined); + + // The DOM itself is the authority here (HierarchyRequestError); the + // receiver does not re-walk ancestors on the insert path. + assertThrows(() => recv.insertBefore(1, 1, undefined)); + assertThrows(() => recv.insertBefore(2, 1, undefined)); + assertEquals(root.innerHTML, "
"); + assertEquals(sentinel.outerHTML, `untouched`); +}); + +Deno.test("NativeDomReceiver: unknown ids and bad anchors throw", () => { + const { recv, root } = fixture(); + interned(recv); + recv.createElement(1, DIV, undefined); + recv.createElement(2, DIV, undefined); + recv.createElement(3, DIV, undefined); + recv.insertBefore(0, 1, undefined); + recv.insertBefore(0, 2, undefined); + + assertThrows( + () => recv.insertBefore(0, 99, undefined), + Error, + "unknown node id 99", + ); + assertThrows(() => recv.remove(99), Error, "unknown node id 99"); + assertThrows(() => recv.setText(99, "x"), Error, "unknown node id 99"); + // An anchor that is not a child of the named parent. + assertThrows( + () => recv.insertBefore(1, 3, 2), + Error, + "disagrees with anchor", + ); + assertThrows(() => recv.insertAfter(1, 3, 2), Error, "disagrees with anchor"); + // Neither parent nor anchor. + assertThrows( + () => recv.insertBefore(undefined, 3, undefined), + Error, + "neither parent nor anchor", + ); + // An anchor with no parent to imply. + assertThrows( + () => recv.insertBefore(undefined, 1, 3), + Error, + "no parent to imply", + ); + // A bad template ordinal. + recv.registerTemplate(7, [{ kind: "text", text: "t" }], [0]); + assertThrows(() => recv.cloneTemplate(7, 1, 20), Error, "out of range"); + assertThrows(() => recv.cloneTemplate(8, 0, 20), Error, "unknown template 8"); + // bind-path walking off the end. + recv.cloneTemplate(7, 0, 20); + assertThrows( + () => recv.bindPath(20, Uint8Array.of(5), 21), + Error, + "walked off", + ); + + assertEquals(root.innerHTML, "
"); +}); + +Deno.test("NativeDomReceiver: insert-after with anchor === id is a no-op; remove of a detached node is fine", () => { + const { recv, root } = fixture(); + interned(recv); + recv.createElement(1, DIV, undefined); + recv.insertBefore(0, 1, undefined); + recv.insertAfter(0, 1, 1); + recv.insertBefore(0, 1, 1); + assertEquals(root.innerHTML, "
"); + + recv.createElement(2, SPAN, undefined); // never inserted + recv.remove(2); + assertEquals(recv.resolveNode(2), undefined); + assertEquals(root.innerHTML, "
"); +}); + +Deno.test("NativeDomReceiver: moving a node between two producer-owned parents", () => { + const { recv, root } = fixture(); + interned(recv); + recv.createElement(1, DIV, undefined); + recv.createElement(2, DIV, undefined); + recv.createText(3, "x"); + recv.insertBefore(0, 1, undefined); + recv.insertBefore(0, 2, undefined); + recv.insertBefore(1, 3, undefined); + assertEquals(root.innerHTML, "
x
"); + recv.insertBefore(2, 3, undefined); + assertEquals(root.innerHTML, "
x
"); + assertStrictEquals(recv.resolveNode(3)?.parentNode, recv.resolveNode(2)); +}); + +Deno.test("NativeDomReceiver: removing a 50k-deep chain does not overflow the stack", () => { + const { recv, root } = fixture(); + interned(recv); + const depth = 50_000; + recv.createElement(1, DIV, undefined); + recv.insertBefore(0, 1, undefined); + for (let i = 2; i <= depth; i++) { + recv.createElement(i, DIV, undefined); + recv.insertBefore(i - 1, i, undefined); + } + recv.remove(1); + assertEquals(root.childNodes.length, 0); + assertEquals(recv.resolveNode(1), undefined); + assertEquals(recv.resolveNode(depth), undefined); +}); + +Deno.test("NativeDomReceiver: bind-marker is unsupported (hydration is not implemented here)", () => { + const { recv } = fixture(); + assertThrows(() => recv.bindMarker(1, 2), Error, "hydration"); +}); diff --git a/receiver/tests/receiver_test.ts b/receiver/tests/receiver_test.ts index a0471f5..e8887f8 100644 --- a/receiver/tests/receiver_test.ts +++ b/receiver/tests/receiver_test.ts @@ -1,4 +1,4 @@ -import { assertEquals } from "@std/assert"; +import { assertEquals, assertThrows } from "@std/assert"; import { ListenerRegistry } from "../src/receiver.ts"; import type { Listener } from "../src/frames.ts"; @@ -62,7 +62,8 @@ Deno.test("ListenerRegistry: string interning (internString/stringFor/refFor)", registry.internString(2, "hashchange"); assertEquals(registry.stringFor(1), "click"); assertEquals(registry.stringFor(2), "hashchange"); - assertEquals(registry.stringFor(99), ""); // unknown ref -> empty string + // Unknown ref -> throws: an unresolved str-ref is a protocol violation. + assertThrows(() => registry.stringFor(99), Error, "unknown string ref 99"); assertEquals(registry.refFor("hashchange"), 2); assertEquals(registry.refFor("nope"), undefined); });