From 0df6457137ff83e5c41cccb524013bd9b0903076 Mon Sep 17 00:00:00 2001 From: Lann Martin Date: Sun, 6 Sep 2026 16:25:38 -0400 Subject: [PATCH] Receiver policy: version pin + per-op callback replaces per-field declaration; asset handles Replaces #13's declaration model (accept/events/queries name lists, SURFACE_V1, compilePolicy, bitmask strict decoding, event-field filter) with a smaller mechanism of the same fail-safe property; keeps #14's hardening and driver split. - Policy { version, check(op), query?(name) }. `version` pins the `PROTOCOL VERSION` from the proto header; createDriver refuses any other, so a receiver upgrade cannot silently widen what a policy reviewed. `check` sees createElement/setAttribute/setProperty/ addListener/bindMarker with interned strings resolved and the element's tag (tracked through clone-template/bind-path); templates are checked once at register-template, flattened into the same shapes. A rejection aborts the stream with a PolicyError. `query` gates the WIT queries; refusal answers none/false. - Strict decoding whenever a policy is present: one boolean per skip branch; unknown ops, fields and enum values reject. - `PROTOCOL VERSION: 1` in the proto header, mirrored as stream_dom_proto::PROTOCOL_VERSION (build.rs) and the receiver's PROTOCOL_VERSION (test re-reads the .proto). - SetAttribute.value / TemplateAttr.value gain an `asset` arm resolved by `resolveAsset`; wire-compatible, bench baseline unchanged. Batch::set_attribute_asset on the Rust side. - Backends and PolicySink pin template tags, attribute names and values at registration, so a later re-intern cannot make the applied DOM diverge from what the policy approved. remote.ts now rejects an un-interned template ref at registration, as native always has. - events.ts restored byte-for-byte to its pre-#13 form. - docs/design.md: Policy section rewritten for this model (records the declaration alternative and why it was removed); new "Assets are handles" decision; open question 10 updated. --- crates/stream-dom-dioxus/src/writer.rs | 2 +- .../stream-dom-dioxus/tests/writer_stream.rs | 7 +- crates/stream-dom-guest/fixtures/basic.txt | 2 +- crates/stream-dom-guest/src/lib.rs | 38 +- crates/stream-dom-proto/build.rs | 30 + crates/stream-dom-proto/src/lib.rs | 1 + docs/design.md | 150 ++- guests/web-sys/dominator-bench/tests/mount.rs | 11 +- .../web-sys/dominator-todomvc/tests/mount.rs | 19 +- guests/web-sys/fakedom/tests/producer.rs | 5 +- proto/stream-dom.proto | 27 +- receiver/src/driver.ts | 74 +- receiver/src/events.ts | 221 +--- receiver/src/frames.ts | 268 ++-- receiver/src/mod.ts | 26 +- receiver/src/mount.ts | 21 +- receiver/src/native.ts | 35 +- receiver/src/policy.ts | 934 +++++--------- receiver/src/remote.ts | 78 +- receiver/tests/driver_test.ts | 147 +-- receiver/tests/frames_test.ts | 7 +- receiver/tests/hostile_test.ts | 22 +- receiver/tests/native_test.ts | 135 +- receiver/tests/policy_test.ts | 1115 +++++++---------- receiver/tests/remote_test.ts | 38 +- 25 files changed, 1543 insertions(+), 1870 deletions(-) diff --git a/crates/stream-dom-dioxus/src/writer.rs b/crates/stream-dom-dioxus/src/writer.rs index f56bf63..7da9d97 100644 --- a/crates/stream-dom-dioxus/src/writer.rs +++ b/crates/stream-dom-dioxus/src/writer.rs @@ -382,7 +382,7 @@ impl MutationWriter { out_attrs.push(proto::TemplateAttr { name, ns, - value: (*value).to_string(), + value: Some(proto::template_attr::Value::Text((*value).to_string())), }); } } diff --git a/crates/stream-dom-dioxus/tests/writer_stream.rs b/crates/stream-dom-dioxus/tests/writer_stream.rs index 4a305a7..99c2bf0 100644 --- a/crates/stream-dom-dioxus/tests/writer_stream.rs +++ b/crates/stream-dom-dioxus/tests/writer_stream.rs @@ -493,7 +493,10 @@ fn attribute_property_table_matches_dioxus_web() { )), Some(proto::frame::Op::SetAttribute(a)) => attrs.push(( interner.borrow().resolve(a.name).unwrap().to_string(), - a.value, + match a.value { + Some(proto::set_attribute::Value::Text(s)) => Some(s), + _ => None, + }, )), _ => {} } @@ -583,7 +586,7 @@ fn removing_a_node_forgets_its_descendants() { for f in decode_all(&bytes) { if let Some(proto::frame::Op::SetAttribute(a)) = f.op { if interner.borrow().resolve(a.name) == Some("class") { - if let Some(v) = a.value { + if let Some(proto::set_attribute::Value::Text(v)) = a.value { buttons.push((v, a.id)); } } diff --git a/crates/stream-dom-guest/fixtures/basic.txt b/crates/stream-dom-guest/fixtures/basic.txt index a237385..f31d8c1 100644 --- a/crates/stream-dom-guest/fixtures/basic.txt +++ b/crates/stream-dom-guest/fixtures/basic.txt @@ -10,7 +10,7 @@ Frame { commit: false, op: Some(CreateText(CreateText { id: 3, text: "!" })) } Frame { commit: false, op: Some(InsertAfter(InsertAfter { parent: None, id: 3, anchor: 2 })) } Frame { commit: false, op: Some(CreateText(CreateText { id: 4, text: "?" })) } Frame { commit: false, op: Some(InsertBefore(InsertBefore { parent: None, id: 4, anchor: Some(3) })) } -Frame { commit: false, op: Some(SetAttribute(SetAttribute { id: 1, name: 3, ns: None, value: Some("greeting") })) } +Frame { commit: false, op: Some(SetAttribute(SetAttribute { id: 1, name: 3, ns: None, value: Some(Text("greeting")) })) } Frame { commit: false, op: Some(AddListener(AddListener { listener: Some(Listener { name: 2, bubbles: true, capture: false, passive: false, prevent_default: false, stop_propagation: false, target: Some(Id(1)) }) })) } Frame { commit: false, op: Some(AddListener(AddListener { listener: Some(Listener { name: 4, bubbles: false, capture: false, passive: false, prevent_default: false, stop_propagation: false, target: Some(Global(Window)) }) })) } Frame { commit: false, op: Some(SetText(SetText { id: 2, text: "hello, world" })) } diff --git a/crates/stream-dom-guest/src/lib.rs b/crates/stream-dom-guest/src/lib.rs index 515eb1a..d0de16f 100644 --- a/crates/stream-dom-guest/src/lib.rs +++ b/crates/stream-dom-guest/src/lib.rs @@ -181,7 +181,18 @@ impl Batch { id, name, ns, - value: value.map(str::to_string), + value: value.map(|v| proto::set_attribute::Value::Text(v.to_owned())), + })); + } + + /// Set an attribute to an opaque asset handle (proto/stream-dom.proto + /// `SetAttribute.asset`) rather than a text value. + pub fn set_attribute_asset(&mut self, id: NodeId, name: StrRef, ns: Option, handle: &[u8]) { + self.push(proto::frame::Op::SetAttribute(proto::SetAttribute { + id, + name, + ns, + value: Some(proto::set_attribute::Value::Asset(handle.to_vec())), })); } @@ -494,4 +505,29 @@ mod tests { "fixtures/basic.txt is stale; regenerate with UPDATE_FIXTURES=1" ); } + + #[test] + fn set_attribute_asset_round_trips() { + let mut interner = Interner::new(); + let mut b = Batch::new(); + let src = interner.intern("src", &mut b); + + b.set_attribute_asset(1, src, None, b"deadbeef"); + + let bytes = b.finish().expect("non-empty batch"); + let frames = decode_all(&bytes); + + let set_attribute = frames + .iter() + .find_map(|f| match &f.op { + Some(proto::frame::Op::SetAttribute(sa)) => Some(sa), + _ => None, + }) + .expect("a SetAttribute frame"); + + assert_eq!( + set_attribute.value, + Some(proto::set_attribute::Value::Asset(b"deadbeef".to_vec())) + ); + } } diff --git a/crates/stream-dom-proto/build.rs b/crates/stream-dom-proto/build.rs index acec9a5..c2759be 100644 --- a/crates/stream-dom-proto/build.rs +++ b/crates/stream-dom-proto/build.rs @@ -1,6 +1,12 @@ //! Compiles proto/stream-dom.proto and proto/stream-dom-events.proto with //! protox (a pure-Rust protoc replacement, so no system protoc is needed — //! required for a clean wasm32-wasip2 build environment) and prost-build. +//! +//! Also extracts the `// PROTOCOL VERSION: ` line from +//! proto/stream-dom.proto (see that file's header) and emits it as +//! `PROTOCOL_VERSION` for lib.rs to `include!`. + +use std::path::Path; fn main() { let proto_dir = "../../proto"; @@ -14,4 +20,28 @@ fn main() { prost_build::Config::new() .compile_fds(file_descriptor_set) .expect("prost-build: compile failed"); + + let stream_dom_proto = + std::fs::read_to_string(format!("{proto_dir}/stream-dom.proto")).expect("read stream-dom.proto"); + let mut matches = stream_dom_proto + .lines() + .filter_map(|line| line.strip_prefix("// PROTOCOL VERSION: ")); + let version = matches + .next() + .unwrap_or_else(|| panic!("no `// PROTOCOL VERSION: ` line found in {proto_dir}/stream-dom.proto")); + if matches.next().is_some() { + panic!("more than one `// PROTOCOL VERSION: ` line found in {proto_dir}/stream-dom.proto"); + } + let version: u32 = version + .parse() + .unwrap_or_else(|e| panic!("`// PROTOCOL VERSION: {version}` is not a valid u32: {e}")); + + let out_dir = std::env::var("OUT_DIR").expect("OUT_DIR"); + std::fs::write( + Path::new(&out_dir).join("protocol_version.rs"), + format!( + "/// Mirrors the `// PROTOCOL VERSION: ` line in proto/stream-dom.proto.\npub const PROTOCOL_VERSION: u32 = {version};\n" + ), + ) + .expect("write protocol_version.rs"); } diff --git a/crates/stream-dom-proto/src/lib.rs b/crates/stream-dom-proto/src/lib.rs index a583115..1572778 100644 --- a/crates/stream-dom-proto/src/lib.rs +++ b/crates/stream-dom-proto/src/lib.rs @@ -11,3 +11,4 @@ #![allow(clippy::doc_markdown)] include!(concat!(env!("OUT_DIR"), "/polymorph.stream_dom.rs")); +include!(concat!(env!("OUT_DIR"), "/protocol_version.rs")); diff --git a/docs/design.md b/docs/design.md index d9c1b77..66b14a6 100644 --- a/docs/design.md +++ b/docs/design.md @@ -177,10 +177,10 @@ processing may start before production finishes. `proto/stream-dom.proto` (proto3) is normative for every byte on the stream and in event payloads. The stream is the standard length-delimited form — varint byte length, then one `Frame`, repeated. No header; the package version is -digest-checked at instantiation, and additive schema change (new `oneof` -cases, new fields) needs no version bump because receivers skip what they -do not know — except a receiver under a declared policy, which rejects -what its embedder has not named ("Policy" below). +digest-checked at instantiation. Additive schema change (a new `oneof` +case, field or enum value) bumps the `PROTOCOL VERSION` number in the +proto's header, and an *open* receiver skips what it does not know — a +receiver enforcing a policy does not ("Policy" below). Chosen over a hand-rolled positional layout, which was the previous draft, for four things it could not offer cheaply: @@ -481,6 +481,38 @@ text, Vue/Svelte ``…``, Solid `data-hk`, Dioxus The marker syntax is an agreement between the SSR renderer and the receiver, not part of the op stream; the op carries only the key. +### Assets are handles, not bytes and not URLs + +`SetAttribute.value` and `TemplateAttr.value` are a `oneof` of `text` and +`asset`: an opaque byte handle (a content hash by convention) that the +receiver materializes into a URL through a host-supplied `resolveAsset` +hook. The protocol does not carry asset bytes and does not interpret the +handle. + +Not bytes, because assets and mutations have opposite shapes. The stream is +small, ordered, latency-sensitive and rendezvous-paced; a multi-megabyte +image on it is head-of-line blocking for every frame behind it, and with +unbuffered writes the producer stalls too. Blobs are bulk, cacheable, +content-addressable, and need kind-specific handling (image re-encode, +CSS parse/serialize, font sanitizing) whose cost scales with size. How +bytes reach the receiver — bundled with the app, fetched by hash, published +at runtime through a separate import — is the host's business; a +recording of the stream plus the asset set it references is the complete +artifact. + +Not only URLs, because a host that forbids producer-chosen URLs (see +Policy) needs a value the producer *cannot* turn into one. A typed arm is +that value: the schema documents the rule, a policy's "URL-kind attributes +take `asset` only" is a structural check rather than a regex over a +string, and headless receivers and recorders see a typed ref without +knowing any grammar. Text URLs remain legal protocol — most producers and +hosts want them, and whether they are allowed is a policy decision, not a +wire rule. + +`asset` values in templates resolve once at `register-template`, not per +clone. A receiver with no `resolveAsset` configured treats an asset value +as an error. + ## Events Dispatch: `handle-event(target, name, payload: list, ev)` export. @@ -705,67 +737,64 @@ allowlist, no `sanitize` transformer and no default vocabulary an element registry into the host). What it ships is the seam a policy plugs into and one property the seam can guarantee. -**The seam.** The decoder calls a `FrameSink`, one method per op, with -`intern` and template trees included; a policy is a sink that wraps the -receiver's and forwards what it allows. A throwing sink aborts the stream: -no later op is applied, the mount's `onError` fires, the read end is -dropped and the producer sees a dead channel on its next write. -Partial-batch DOM state at that point is the embedder's to tear down, and -for an untrusted producer teardown is the right answer — a conforming -producer never emits a violating op, so a violation is a bug or hostile -either way, and silently dropping it would hide exactly the signal the -embedder wants. A recording of the stream (`onChunk`) reproduces any -rejection offline. - -**Fail-safe by declaration.** A wrapper alone fails *open* when the -protocol grows. The decoder skips unknown fields, as protobuf receivers -do, and hands records to the wrapper whole: a new op, a new field on an -existing message (`Listener.once`), or a new enum value reaches the -wrapped receiver without the policy ever having a case for it. Type -checking does not close this — an added optional field is -type-compatible, JS consumers have no types, and a "bump the version -constant" ritual gets performed without the review it was meant to force. -So the receiver offers a second mechanism, the only one this document -labels fail-safe: the embedder **declares by string every piece of -protocol surface it accepts**, in the .proto's own names -(`SetAttribute.value`, `Listener.prevent_default`, `Global.WINDOW`), and -the decoder enforces the declaration before any value is consumed. Three -directions, two behaviours: - -- Mutation stream (producer → receiver): an undeclared or unknown - field, op or enum value is a `PolicyError` and aborts the stream. - Unknown tags are *rejected*, not skipped — the protobuf convention is - right for cooperating peers and wrong for an adversarial one, and it - also closes the case of a producer newer than the receiver. -- Event payloads (receiver → producer): an undeclared field is not - encoded. The receiver authors payloads, so there is no violator; the - producer merely learns less. A payload family not declared is the - empty payload. (Which event *names* a producer may subscribe to is - vocabulary, checked by the sink like any other value.) -- `queries` (producer asks receiver): an undeclared query answers - `none` / `false`. WIT-level growth — a new import, a new `dom-event` - method — already fails closed by construction, since the embedder - supplies the imports. - -The declared lists are data: reviewable in a diff, and the same list -serves a host in one realm and a re-validating applier in another. -`SURFACE_V1` is a frozen snapshot of today's full surface, so -`{ ...SURFACE_V1, sink }` is the one-line policy; it never grows, and an -embedder that updates this dependency keeps exactly the exposure it -reviewed until it names the new fields itself. Unknown or removed names -fail at construction, not at first frame. This is not a compatibility -promise — an embedder updates its policy when it updates the receiver — -only a guarantee that the update cannot widen exposure silently. +**The seam.** `MountOptions.policy` (and `createDriver`'s) is a +`check(op)` callback. The receiver wraps its `FrameSink` in a +`PolicySink` that shows the callback each vocabulary-bearing op — +`createElement`, `setAttribute`, `setProperty`, `addListener`, +`bindMarker` — with interned strings resolved and, for attribute and +property ops, the element's tag, tracked through `clone-template` and +`bind-path`. Templates are checked once at `register-template`, flattened +into the same `createElement` / `setAttribute` shapes, so cloning and +binding check nothing; and every template string (tag, attribute name, +value) is pinned at registration in both the policy and the backends, so a +later `intern` overwrite cannot make the applied DOM diverge from what the +policy approved. A returned reason rejects: the op is not applied, the +stream is closed (the producer sees a dead channel on its next write), +and a `PolicyError` naming the op, its index and the reason reaches +`onError`. There is no drop-and-continue. A conforming producer never +emits a violating op, so a violation is a bug or hostile either way, and +silently dropping it would hide exactly the signal the embedder wants. A +recording of the stream (`onChunk`) reproduces any rejection offline. +Which `queries` a producer may call is the same policy's optional +`query(name)`; a refusal answers `none` / `false`, never throws, because +the WIT signatures already carry "no answer". + +**Fail-safe against protocol growth: strict decoding and a version pin.** +A wrapper alone fails *open* when the protocol grows. The decoder skips +unknown fields, as protobuf receivers do: a new op, a new field on an +existing message (`Listener.once`), or a new enum value would reach the +receiver without the policy ever having a case for it. So with a policy +present the decoder runs *strict* — an unknown op, field or enum value is +an error, not a skip — which closes both the newer-producer case and the +adversarial one. That leaves the case where the receiver library itself is +upgraded and now knows vocabulary the policy has never seen. The policy +carries the `PROTOCOL VERSION` it was written against, and `createDriver` +refuses any other. This is a forced acknowledgment, not a compatibility +promise: bumping the pin is the embedder saying "I have read what +changed", and the mechanism only guarantees the upgrade cannot widen +exposure silently. Do not automate the bump. + +The alternative considered was a per-field declaration — the embedder +names every accepted proto field, with a frozen snapshot of today's +surface as the one-line policy. It is strictly finer-grained (a +mid-version producer using no new field would pass) and it was +implemented and then removed: the whole-protocol pin gives the same +guarantee against silent widening for one integer instead of a +hundred-name list in two languages, and the finer grain buys nothing +until protocol versions are released faster than embedders review them. **What this imposes on the protocol.** The mechanism catches *new* surface; it cannot catch an existing field acquiring meaning it did not 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`. +requires the last), and every addition bumps `PROTOCOL VERSION`. +Implementation: `receiver/src/policy.ts`; the version constant is mirrored +into Rust by `stream-dom-proto`'s build script and into the receiver by a +test that re-reads the .proto. **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 +malformed streams independently of any policy: 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 @@ -1129,9 +1158,10 @@ event families beyond mouse/keyboard/form, files and `DataTransfer`. third-party (a plugin), the receiver needs an allowlist — which is a 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, and the native receiver fails closed on - malformed streams underneath it. + receiver ships the seam (a per-op callback with strings resolved), + strict decoding and a protocol-version pin, no `sanitize` transformer, + and the native receiver fails closed on malformed streams underneath + it. Still open: budgets (node, byte and event-rate ceilings). 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/guests/web-sys/dominator-bench/tests/mount.rs b/guests/web-sys/dominator-bench/tests/mount.rs index 5afdb42..85ce99e 100644 --- a/guests/web-sys/dominator-bench/tests/mount.rs +++ b/guests/web-sys/dominator-bench/tests/mount.rs @@ -17,6 +17,13 @@ use stream_dom_fakedom::dom; use stream_dom_fakedom::event::{self, Target, Verdict}; use stream_dom_guest::proto::{self, frame::Op}; +fn text_value(a: &proto::SetAttribute) -> Option<&str> { + match &a.value { + Some(proto::set_attribute::Value::Text(s)) => Some(s.as_str()), + _ => None, + } +} + fn decode(bytes: &[u8]) -> Vec { let mut buf = bytes; let mut frames = Vec::new(); @@ -48,7 +55,7 @@ fn element_with_id( ) -> Option { let slot = *interns.get("id")?; frames.iter().find_map(|f| match &f.op { - Some(Op::SetAttribute(a)) if a.name == slot && a.value.as_deref() == Some(id) => Some(a.id), + Some(Op::SetAttribute(a)) if a.name == slot && text_value(a) == Some(id) => Some(a.id), _ => None, }) } @@ -190,7 +197,7 @@ fn create_1k_emits_a_thousand_rows_with_the_shared_label_sequence() { !frames.iter().any(|f| matches!(&f.op, Some(Op::SetAttribute(a)) if a.name == class - && a.value.as_deref().is_some_and(|v| v.split(' ').any(|t| t == "selected")) + && text_value(a).is_some_and(|v| v.split(' ').any(|t| t == "selected")) )), "no row should be selected after create-1k" ); diff --git a/guests/web-sys/dominator-todomvc/tests/mount.rs b/guests/web-sys/dominator-todomvc/tests/mount.rs index bc8cbe6..bfe078b 100644 --- a/guests/web-sys/dominator-todomvc/tests/mount.rs +++ b/guests/web-sys/dominator-todomvc/tests/mount.rs @@ -13,6 +13,13 @@ use prost::Message; use stream_dom_fakedom::dom; use stream_dom_guest::proto::{self, frame::Op}; +fn text_value(a: &proto::SetAttribute) -> Option<&str> { + match &a.value { + Some(proto::set_attribute::Value::Text(s)) => Some(s.as_str()), + _ => None, + } +} + fn mount_and_take_frames() -> Vec { // `web_sys::window()` reads `globalThis`, which the fake DOM installs // when its singleton is created; the driver does this in `run`. @@ -52,7 +59,7 @@ fn todomvc_mounts_and_emits_its_markup() { let classes: Vec<&str> = frames .iter() .filter_map(|f| match &f.op { - Some(Op::SetAttribute(a)) if a.name == class_slot => a.value.as_deref(), + Some(Op::SetAttribute(a)) if a.name == class_slot => text_value(a), _ => None, }) .collect(); @@ -212,9 +219,7 @@ fn element_with_class( ) -> Option { let slot = *interns.get("class")?; frames.iter().find_map(|f| match &f.op { - Some(Op::SetAttribute(a)) if a.name == slot => a - .value - .as_deref() + Some(Op::SetAttribute(a)) if a.name == slot => text_value(a) .filter(|v| v.split(' ').any(|t| t == class)) .map(|_| a.id), _ => None, @@ -325,7 +330,7 @@ fn a_hashchange_on_window_moves_the_selected_filter() { Some(Op::SetAttribute(a)) if a.id == all_link && a.name == class_slot - && !a.value.as_deref().unwrap_or("").split(' ').any(|t| t == "selected") + && !text_value(a).unwrap_or("").split(' ').any(|t| t == "selected") )), "`All` should have had `selected` removed" ); @@ -337,9 +342,7 @@ fn selected_ids(frames: &[proto::Frame], class_slot: u32) -> Vec { for f in frames { if let Some(Op::SetAttribute(a)) = &f.op { if a.name == class_slot { - let on = a - .value - .as_deref() + let on = text_value(a) .unwrap_or("") .split(' ') .any(|t| t == "selected"); diff --git a/guests/web-sys/fakedom/tests/producer.rs b/guests/web-sys/fakedom/tests/producer.rs index 88f0b42..3f41fcc 100644 --- a/guests/web-sys/fakedom/tests/producer.rs +++ b/guests/web-sys/fakedom/tests/producer.rs @@ -151,7 +151,10 @@ fn attribute_writes(frames: &[proto::Frame], name_slot: u32) -> Vec frames .iter() .filter_map(|f| match &f.op { - Some(Op::SetAttribute(a)) if a.name == name_slot => Some(a.value.as_deref()), + Some(Op::SetAttribute(a)) if a.name == name_slot => Some(match &a.value { + Some(proto::set_attribute::Value::Text(s)) => Some(s.as_str()), + _ => None, + }), _ => None, }) .collect() diff --git a/proto/stream-dom.proto b/proto/stream-dom.proto index 4b68633..18257c8 100644 --- a/proto/stream-dom.proto +++ b/proto/stream-dom.proto @@ -7,11 +7,15 @@ // in both places, except that the observer payloads carry a `Rect` / `Size` // structurally equal to the `queries` records. // +// PROTOCOL VERSION: 1 +// // Stream layout: standard length-delimited messages — varint byte length, -// then one Frame, repeated. No header. Additive change (new oneof cases, -// new fields) needs no version bump; receivers skip what they do not know. -// Changing an existing field does bump the package version, and -// instantiation is digest-checked. +// then one Frame, repeated. No header. Additive change (a new oneof case, +// field or enum value) bumps PROTOCOL VERSION above; changing an existing +// field also bumps the package version, and instantiation is +// digest-checked. An open receiver (no policy) skips wire content it does +// not know; a receiver enforcing a policy rejects it, and a policy pins +// the version it was written against — docs/design.md "Policy". // // Invariants every producer upholds and every transformer preserves: // - Every op names its target(s) explicitly. No implicit stack, cursor or @@ -129,12 +133,18 @@ message SetText { } // Set or remove (no `value`) an attribute. The producer adapter decides -// attribute vs property using its framework's own table. +// attribute vs property using its framework's own table. `asset` is an +// opaque handle the receiver materializes into a URL (a content hash by +// convention, but the receiver's `resolveAsset` decides); the producer +// never names a URL through it — docs/design.md "Assets". message SetAttribute { uint32 id = 1; uint32 name = 2; optional uint32 ns = 3; - optional string value = 4; + oneof value { + string text = 4; + bytes asset = 5; + } } // Set a DOM property (`value`, `checked`, `innerHTML`, ...) to a typed @@ -198,7 +208,10 @@ message RemoveListener { message TemplateAttr { uint32 name = 1; optional uint32 ns = 2; - string value = 3; + oneof value { + string text = 3; + bytes asset = 4; + } } // Children are indices into the enclosing RegisterTemplate.nodes. diff --git a/receiver/src/driver.ts b/receiver/src/driver.ts index 7924247..bb9b059 100644 --- a/receiver/src/driver.ts +++ b/receiver/src/driver.ts @@ -1,6 +1,6 @@ // 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 +// the policy seam, 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: @@ -18,8 +18,8 @@ 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 { assertPolicyVersion, PolicySink } from "./policy.ts"; +import type { Policy } from "./policy.ts"; import { createRemoteReceiver } from "./remote.ts"; import type { Receiver } from "./receiver.ts"; @@ -84,15 +84,22 @@ export interface DriverOptions { * 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. */ + /** Host vocabulary policy (policy.ts): it sees each vocabulary-bearing + * op with interned strings resolved and may reject it. Present also + * means STRICT decoding — wire content this receiver does not know is + * rejected instead of skipped, so the protocol growing cannot widen + * what a policy never reviewed. `createDriver` throws synchronously if + * the policy pins a different `PROTOCOL_VERSION`. */ policy?: Policy; + /** Asset handle -> URL, for `SetAttribute`/`TemplateAttr` asset values + * (proto: the producer never names a URL itself). Required if the stream + * ever carries one; absent + an asset value is an error on the normal + * abort path. */ + resolveAsset?(handle: Uint8Array): string; /** Asynchronous failure after mount: a dispatch-gate error (a - * `handleEvent` call rejecting or throwing synchronously). */ + * `handleEvent` call rejecting or throwing synchronously). Policy + * rejections and strict-decode errors surface synchronously out of + * `push` instead — see `Driver.push`. */ 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 @@ -109,8 +116,9 @@ export interface DriverOptions { 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()`. */ + * protocol violation, on a strict-decode rejection, and on a + * `PolicyError` — the caller must then stop feeding and dispose. A + * no-op after `dispose()`. */ push(bytes: Uint8Array): void; /** WIT `queries` implementations, policy-gated. */ readonly queries: { @@ -130,22 +138,22 @@ export interface Driver { /** * 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. + * policy seam, and delegated event dispatch — with no dependency on a wasm + * component instance. */ export function createDriver(opts: DriverOptions): Driver { + // Before anything else: a policy written against another protocol + // version has not reviewed what this receiver would now accept. + const policy = opts.policy; + if (policy) assertPolicyVersion(policy); + 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); + ? createRemoteReceiver(opts.root, opts.resolveAsset) + : new NativeDomReceiver(opts.root, opts.resolveAsset); const stats = { batches: 0, frames: 0, bytes: 0 }; let commitWaiters: Array<() => void> = []; @@ -170,7 +178,7 @@ export function createDriver(opts: DriverOptions): Driver { // 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; + if (policy?.query && !policy.query("get-client-rect")) return undefined; const node = receiver.resolveNode(target) as ElementLike | undefined; if (!node || typeof node.getBoundingClientRect !== "function") { return undefined; @@ -183,7 +191,7 @@ export function createDriver(opts: DriverOptions): Driver { } function getScrollOffset(target: number): Point | undefined { - if (!queryAllowed(policy, "get-scroll-offset")) return undefined; + if (policy?.query && !policy.query("get-scroll-offset")) return undefined; const node = receiver.resolveNode(target) as ElementLike | undefined; if (!node || !isNum(node.scrollLeft) || !isNum(node.scrollTop)) { return undefined; @@ -192,7 +200,7 @@ export function createDriver(opts: DriverOptions): Driver { } function getScrollSize(target: number): Size | undefined { - if (!queryAllowed(policy, "get-scroll-size")) return undefined; + if (policy?.query && !policy.query("get-scroll-size")) return undefined; const node = receiver.resolveNode(target) as ElementLike | undefined; if (!node || !isNum(node.scrollWidth) || !isNum(node.scrollHeight)) { return undefined; @@ -201,7 +209,7 @@ export function createDriver(opts: DriverOptions): Driver { } function setFocus(target: number, focus: boolean): boolean { - if (!queryAllowed(policy, "set-focus")) return false; + if (policy?.query && !policy.query("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; @@ -235,7 +243,7 @@ export function createDriver(opts: DriverOptions): Driver { if (listener.preventDefault) ev.preventDefault(); if (listener.stopPropagation) ev.stopPropagation(); if (disposed) return; - const payload = encodePayload(name, ev, policy?.events); + const payload = encodePayload(name, ev); gate.dispatch(() => opts.handleEvent(target, nameRef, payload, ev)); } @@ -467,13 +475,13 @@ export function createDriver(opts: DriverOptions): Driver { // -- 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 }); + // With a policy: ops reach the backend only if `PolicySink` forwards + // them, and the decoder rejects unknown wire content rather than + // skipping it (a wrapper alone would fail OPEN as the protocol grows — + // docs/design.md "Policy"). + const decoder = policy + ? new FrameDecoder(new PolicySink(receiver.sink, policy), { strict: true }) + : new FrameDecoder(receiver.sink); function push(bytes: Uint8Array): void { if (disposed) return; // no-op after dispose — see `Driver.push`'s doc. diff --git a/receiver/src/events.ts b/receiver/src/events.ts index a5dc72e..4ad8a8c 100644 --- a/receiver/src/events.ts +++ b/receiver/src/events.ts @@ -9,7 +9,6 @@ // carry"). import { Writer } from "./proto.ts"; -import type { EventFieldSet } from "./policy.ts"; const EVENT_PAYLOAD_MOUSE = 1; const EVENT_PAYLOAD_KEYBOARD = 2; @@ -58,65 +57,6 @@ const FORM_FIELD_VALUE = 2; const NAVIGATION_HREF = 1; -/** Every field number above, for policy.ts's `Message.field` name table — - * same reason as frames.ts's `STREAM_FIELD_NUMBERS`. */ -export const EVENT_FIELD_NUMBERS = { - EVENT_PAYLOAD_MOUSE, - EVENT_PAYLOAD_KEYBOARD, - EVENT_PAYLOAD_FORM, - EVENT_PAYLOAD_NAVIGATION, - MOUSE_CLIENT_X, - MOUSE_CLIENT_Y, - MOUSE_PAGE_X, - MOUSE_PAGE_Y, - MOUSE_SCREEN_X, - MOUSE_SCREEN_Y, - MOUSE_OFFSET_X, - MOUSE_OFFSET_Y, - MOUSE_BUTTON, - MOUSE_PRIMARY, - MOUSE_SECONDARY, - MOUSE_AUXILIARY, - MOUSE_BACK, - MOUSE_FORWARD, - MOUSE_MODIFIERS, - MODIFIERS_ALT, - MODIFIERS_CTRL, - MODIFIERS_META, - MODIFIERS_SHIFT, - KEYBOARD_KEY, - KEYBOARD_CODE, - KEYBOARD_LOCATION, - KEYBOARD_REPEAT, - KEYBOARD_IS_COMPOSING, - KEYBOARD_MODIFIERS, - FORM_VALUE, - FORM_CHECKED, - FORM_FIELDS, - FORM_FIELD_NAME, - FORM_FIELD_VALUE, - NAVIGATION_HREF, -} as const; - -/** No filter: every field declared. Keeps the write sites uniform, so - * unfiltered encoding is bit-identical to what this module emitted before - * policies existed. */ -const ALL_DECLARED: EventFieldSet = { - EventPayload: -1, - MouseData: -1, - Modifiers: -1, - KeyboardData: -1, - FormData: -1, - FormField: -1, - NavigationData: -1, -}; - -/** Undeclared -> DROP, silently: the receiver authors payloads, so there - * is no violator to report (policy.ts header). */ -function on(mask: number, field: number): boolean { - return (mask & (1 << field)) !== 0; -} - const MOUSE_EVENTS = new Set([ "click", "dblclick", @@ -174,48 +114,31 @@ function writeModifiers( w: Writer, fieldNumber: number, ev: MouseEvent | KeyboardEvent, - mods: number, ): void { w.writeMessage(fieldNumber, (m) => { - if (ev.altKey && on(mods, MODIFIERS_ALT)) m.writeBool(MODIFIERS_ALT, true); - if (ev.ctrlKey && on(mods, MODIFIERS_CTRL)) { - m.writeBool(MODIFIERS_CTRL, true); - } - if (ev.metaKey && on(mods, MODIFIERS_META)) { - m.writeBool(MODIFIERS_META, true); - } - if (ev.shiftKey && on(mods, MODIFIERS_SHIFT)) { - m.writeBool(MODIFIERS_SHIFT, true); - } + if (ev.altKey) m.writeBool(MODIFIERS_ALT, true); + if (ev.ctrlKey) m.writeBool(MODIFIERS_CTRL, true); + if (ev.metaKey) m.writeBool(MODIFIERS_META, true); + if (ev.shiftKey) m.writeBool(MODIFIERS_SHIFT, true); }); } -function writeMouseData( - w: Writer, - name: string, - ev: MouseEvent, - ef: EventFieldSet, -): void { - const m = ef.MouseData; - if (on(m, MOUSE_CLIENT_X)) w.writeDouble(MOUSE_CLIENT_X, ev.clientX); - if (on(m, MOUSE_CLIENT_Y)) w.writeDouble(MOUSE_CLIENT_Y, ev.clientY); - if (on(m, MOUSE_PAGE_X)) w.writeDouble(MOUSE_PAGE_X, ev.pageX); - if (on(m, MOUSE_PAGE_Y)) w.writeDouble(MOUSE_PAGE_Y, ev.pageY); - if (on(m, MOUSE_SCREEN_X)) w.writeDouble(MOUSE_SCREEN_X, ev.screenX); - if (on(m, MOUSE_SCREEN_Y)) w.writeDouble(MOUSE_SCREEN_Y, ev.screenY); - if (on(m, MOUSE_OFFSET_X)) { - w.writeDouble( - MOUSE_OFFSET_X, - (ev as MouseEvent & { offsetX?: number }).offsetX ?? 0, - ); - } - if (on(m, MOUSE_OFFSET_Y)) { - w.writeDouble( - MOUSE_OFFSET_Y, - (ev as MouseEvent & { offsetY?: number }).offsetY ?? 0, - ); - } - if (BUTTON_EVENTS.has(name) && on(m, MOUSE_BUTTON)) { +function writeMouseData(w: Writer, name: string, ev: MouseEvent): void { + w.writeDouble(MOUSE_CLIENT_X, ev.clientX); + w.writeDouble(MOUSE_CLIENT_Y, ev.clientY); + w.writeDouble(MOUSE_PAGE_X, ev.pageX); + w.writeDouble(MOUSE_PAGE_Y, ev.pageY); + w.writeDouble(MOUSE_SCREEN_X, ev.screenX); + w.writeDouble(MOUSE_SCREEN_Y, ev.screenY); + w.writeDouble( + MOUSE_OFFSET_X, + (ev as MouseEvent & { offsetX?: number }).offsetX ?? 0, + ); + w.writeDouble( + MOUSE_OFFSET_Y, + (ev as MouseEvent & { offsetY?: number }).offsetY ?? 0, + ); + if (BUTTON_EVENTS.has(name)) { // MouseButton's case values (PRIMARY=0, AUXILIARY=1, SECONDARY=2, // BACK=3, FORWARD=4) coincide numerically with MouseEvent.button's // own encoding, so the raw DOM value is the wire value with no @@ -223,38 +146,21 @@ function writeMouseData( w.writeUint32(MOUSE_BUTTON, ev.button); } const buttons = ev.buttons; - if (buttons & 1 && on(m, MOUSE_PRIMARY)) w.writeBool(MOUSE_PRIMARY, true); - if (buttons & 2 && on(m, MOUSE_SECONDARY)) { - w.writeBool(MOUSE_SECONDARY, true); - } - if (buttons & 4 && on(m, MOUSE_AUXILIARY)) { - w.writeBool(MOUSE_AUXILIARY, true); - } - if (buttons & 8 && on(m, MOUSE_BACK)) w.writeBool(MOUSE_BACK, true); - if (buttons & 16 && on(m, MOUSE_FORWARD)) w.writeBool(MOUSE_FORWARD, true); - if (on(m, MOUSE_MODIFIERS)) { - writeModifiers(w, MOUSE_MODIFIERS, ev, ef.Modifiers); - } + if (buttons & 1) w.writeBool(MOUSE_PRIMARY, true); + if (buttons & 2) w.writeBool(MOUSE_SECONDARY, true); + if (buttons & 4) w.writeBool(MOUSE_AUXILIARY, true); + if (buttons & 8) w.writeBool(MOUSE_BACK, true); + if (buttons & 16) w.writeBool(MOUSE_FORWARD, true); + writeModifiers(w, MOUSE_MODIFIERS, ev); } -function writeKeyboardData( - w: Writer, - ev: KeyboardEvent, - ef: EventFieldSet, -): void { - const k = ef.KeyboardData; - if (on(k, KEYBOARD_KEY)) w.writeString(KEYBOARD_KEY, ev.key ?? ""); - if (on(k, KEYBOARD_CODE)) w.writeString(KEYBOARD_CODE, ev.code ?? ""); - if (on(k, KEYBOARD_LOCATION)) { - w.writeUint32(KEYBOARD_LOCATION, ev.location ?? 0); - } - if (ev.repeat && on(k, KEYBOARD_REPEAT)) w.writeBool(KEYBOARD_REPEAT, true); - if (ev.isComposing && on(k, KEYBOARD_IS_COMPOSING)) { - w.writeBool(KEYBOARD_IS_COMPOSING, true); - } - if (on(k, KEYBOARD_MODIFIERS)) { - writeModifiers(w, KEYBOARD_MODIFIERS, ev, ef.Modifiers); - } +function writeKeyboardData(w: Writer, ev: KeyboardEvent): void { + w.writeString(KEYBOARD_KEY, ev.key ?? ""); + w.writeString(KEYBOARD_CODE, ev.code ?? ""); + w.writeUint32(KEYBOARD_LOCATION, ev.location ?? 0); + if (ev.repeat) w.writeBool(KEYBOARD_REPEAT, true); + if (ev.isComposing) w.writeBool(KEYBOARD_IS_COMPOSING, true); + writeModifiers(w, KEYBOARD_MODIFIERS, ev); } interface FormControlLike { @@ -263,37 +169,21 @@ interface FormControlLike { type?: string; } -function writeFormData( - w: Writer, - name: string, - ev: Event, - ef: EventFieldSet, -): void { - const fd = ef.FormData; +function writeFormData(w: Writer, name: string, ev: Event): void { const target = ev.target as (EventTarget & FormControlLike) | null; - if (on(fd, FORM_VALUE)) w.writeString(FORM_VALUE, target?.value ?? ""); - if ( - on(fd, FORM_CHECKED) && - (target?.type === "checkbox" || target?.type === "radio") - ) { + w.writeString(FORM_VALUE, target?.value ?? ""); + if (target?.type === "checkbox" || target?.type === "radio") { w.writeBool(FORM_CHECKED, target.checked === true); } - if ( - on(fd, FORM_FIELDS) && name === "submit" && - target instanceof HTMLFormElement - ) { + if (name === "submit" && target instanceof HTMLFormElement) { // FormData(form).entries() on submit only — proto comment: "dioxus-web // populates them on every event inside a form, which serializes the // whole form per keystroke"; this receiver follows the proto's // narrower contract instead. for (const [fieldName, value] of new FormData(target).entries()) { w.writeMessage(FORM_FIELDS, (f) => { - if (on(ef.FormField, FORM_FIELD_NAME)) { - f.writeString(FORM_FIELD_NAME, fieldName); - } - if (on(ef.FormField, FORM_FIELD_VALUE)) { - f.writeString(FORM_FIELD_VALUE, String(value)); - } + f.writeString(FORM_FIELD_NAME, fieldName); + f.writeString(FORM_FIELD_VALUE, String(value)); }); } } @@ -308,8 +198,7 @@ function writeFormData( * and behaves sanely under `deno test`), but a non-browser embedding of * this module is still conceivable, and a `TypeError` here would be a * strange way to lose an otherwise-fine event. */ -function writeNavigationData(w: Writer, ef: EventFieldSet): void { - if (!on(ef.NavigationData, NAVIGATION_HREF)) return; +function writeNavigationData(w: Writer): void { let href = ""; try { href = globalThis.location?.href ?? ""; @@ -324,35 +213,23 @@ function writeNavigationData(w: Writer, ef: EventFieldSet): void { * mouse/keyboard/form (including focus/blur and every family this receiver * does not yet implement) get the empty payload — zero bytes, which is a * valid `EventPayload` with no `family` case (proto3 default). */ -export function encodePayload( - name: string, - ev: Event, - events?: EventFieldSet, -): Uint8Array { - const ef = events ?? ALL_DECLARED; - const p = ef.EventPayload; +export function encodePayload(name: string, ev: Event): Uint8Array { const w = new Writer(); const family = familyFor(name); - // An undeclared family omits the whole family message — the empty - // payload, which is a legal `EventPayload` (proto header: "No case set - // is the empty payload"). - if (family === "mouse" && on(p, EVENT_PAYLOAD_MOUSE)) { + if (family === "mouse") { w.writeMessage( EVENT_PAYLOAD_MOUSE, - (m) => writeMouseData(m, name, ev as MouseEvent, ef), + (m) => writeMouseData(m, name, ev as MouseEvent), ); - } else if (family === "keyboard" && on(p, EVENT_PAYLOAD_KEYBOARD)) { + } else if (family === "keyboard") { w.writeMessage( EVENT_PAYLOAD_KEYBOARD, - (m) => writeKeyboardData(m, ev as KeyboardEvent, ef), - ); - } else if (family === "form" && on(p, EVENT_PAYLOAD_FORM)) { - w.writeMessage(EVENT_PAYLOAD_FORM, (m) => writeFormData(m, name, ev, ef)); - } else if (family === "navigation" && on(p, EVENT_PAYLOAD_NAVIGATION)) { - w.writeMessage( - EVENT_PAYLOAD_NAVIGATION, - (m) => writeNavigationData(m, ef), + (m) => writeKeyboardData(m, ev as KeyboardEvent), ); + } else if (family === "form") { + w.writeMessage(EVENT_PAYLOAD_FORM, (m) => writeFormData(m, name, ev)); + } else if (family === "navigation") { + w.writeMessage(EVENT_PAYLOAD_NAVIGATION, (m) => writeNavigationData(m)); } return w.finish(); } diff --git a/receiver/src/frames.ts b/receiver/src/frames.ts index d4f30a9..e2d4d5f 100644 --- a/receiver/src/frames.ts +++ b/receiver/src/frames.ts @@ -6,7 +6,22 @@ // track); each constant is named after its proto message and field. import { Reader, TruncatedError, WireType } from "./proto.ts"; -import type { AcceptSet, MessageAccept } from "./policy.ts"; + +/** The wire protocol version this receiver implements — the number on + * proto/stream-dom.proto's `// PROTOCOL VERSION: 1` header line, which is + * normative (a receiver test re-reads it from the .proto). An additive + * change (new oneof case, field or enum value) bumps it there and here. */ +export const PROTOCOL_VERSION = 1; + +/** Strict-mode rejection of wire content this receiver does not know: an + * open receiver skips it, a receiver enforcing a policy must not + * (proto/stream-dom.proto header, docs/design.md "Policy"). Off the hot + * path — the skip branches test one boolean and call this only to throw. */ +function unknownField(field: number, message: string): never { + throw new Error( + `stream-dom: unknown field ${field} in ${message} (receiver PROTOCOL_VERSION ${PROTOCOL_VERSION})`, + ); +} // -- Frame -------------------------------------------------------------- @@ -60,7 +75,8 @@ const SET_TEXT_TEXT = 2; const SET_ATTRIBUTE_ID = 1; const SET_ATTRIBUTE_NAME = 2; const SET_ATTRIBUTE_NS = 3; -const SET_ATTRIBUTE_VALUE = 4; +const SET_ATTRIBUTE_TEXT = 4; +const SET_ATTRIBUTE_ASSET = 5; const SET_PROPERTY_ID = 1; const SET_PROPERTY_NAME = 2; @@ -87,7 +103,8 @@ const REMOVE_LISTENER_LISTENER = 1; const TEMPLATE_ATTR_NAME = 1; const TEMPLATE_ATTR_NS = 2; -const TEMPLATE_ATTR_VALUE = 3; +const TEMPLATE_ATTR_TEXT = 3; +const TEMPLATE_ATTR_ASSET = 4; const TEMPLATE_ELEMENT_TAG = 1; const TEMPLATE_ELEMENT_NS = 2; @@ -113,97 +130,6 @@ const BIND_PATH_ID = 3; const BIND_MARKER_KEY = 1; const BIND_MARKER_ID = 2; -/** Every field number above, keyed by constant name, for policy.ts's - * `Message.field` name table. Exported so that table can reference these - * rather than repeat the literals: a new constant with no table entry is - * then visible in the same diff. */ -export const STREAM_FIELD_NUMBERS = { - FRAME_COMMIT, - FRAME_INSERT_BEFORE, - FRAME_SET_TEXT, - FRAME_SET_ATTRIBUTE, - FRAME_SET_PROPERTY, - FRAME_CREATE_ELEMENT, - FRAME_CREATE_TEXT, - FRAME_REMOVE, - FRAME_CLONE_TEMPLATE, - FRAME_BIND_PATH, - FRAME_CREATE_PLACEHOLDER, - FRAME_ADD_LISTENER, - FRAME_REMOVE_LISTENER, - FRAME_INTERN, - FRAME_REGISTER_TEMPLATE, - FRAME_INSERT_AFTER, - FRAME_BIND_MARKER, - INTERN_ID, - INTERN_S, - CREATE_ELEMENT_ID, - CREATE_ELEMENT_TAG, - CREATE_ELEMENT_NS, - CREATE_TEXT_ID, - CREATE_TEXT_TEXT, - CREATE_PLACEHOLDER_ID, - INSERT_BEFORE_PARENT, - INSERT_BEFORE_ID, - INSERT_BEFORE_ANCHOR, - INSERT_AFTER_PARENT, - INSERT_AFTER_ID, - INSERT_AFTER_ANCHOR, - REMOVE_ID, - SET_TEXT_ID, - SET_TEXT_TEXT, - SET_ATTRIBUTE_ID, - SET_ATTRIBUTE_NAME, - SET_ATTRIBUTE_NS, - SET_ATTRIBUTE_VALUE, - SET_PROPERTY_ID, - SET_PROPERTY_NAME, - SET_PROPERTY_TEXT, - SET_PROPERTY_INT, - SET_PROPERTY_FLOAT, - SET_PROPERTY_BOOLEAN, - LISTENER_ID, - LISTENER_NAME, - LISTENER_BUBBLES, - LISTENER_CAPTURE, - LISTENER_PASSIVE, - LISTENER_PREVENT_DEFAULT, - LISTENER_STOP_PROPAGATION, - LISTENER_GLOBAL, - GLOBAL_WINDOW, - GLOBAL_DOCUMENT, - ADD_LISTENER_LISTENER, - REMOVE_LISTENER_LISTENER, - TEMPLATE_ATTR_NAME, - TEMPLATE_ATTR_NS, - TEMPLATE_ATTR_VALUE, - TEMPLATE_ELEMENT_TAG, - TEMPLATE_ELEMENT_NS, - TEMPLATE_ELEMENT_ATTRS, - TEMPLATE_ELEMENT_CHILDREN, - TEMPLATE_NODE_ELEMENT, - TEMPLATE_NODE_TEXT, - TEMPLATE_NODE_DYNAMIC, - REGISTER_TEMPLATE_ID, - REGISTER_TEMPLATE_NODES, - REGISTER_TEMPLATE_ROOTS, - CLONE_TEMPLATE_TMPL, - CLONE_TEMPLATE_ROOT, - CLONE_TEMPLATE_ID, - BIND_PATH_ROOT, - BIND_PATH_PATH, - BIND_PATH_ID, - BIND_MARKER_KEY, - BIND_MARKER_ID, -} as const; - -/** Strict-mode gate: one bit test per field, on the hot path. A field - * number above 31 does not fit the mask and is undeclared by construction - * (policy.ts `MessageAccept`); `reject` builds the message and throws. */ -function check(m: MessageAccept, field: number): void { - if (field > 31 || (m.mask & (1 << field)) === 0) m.reject(field); -} - // -- decoded shapes ------------------------------------------------------- /** `Listener.target`'s oneof (proto/stream-dom.proto): a node id, or one @@ -227,6 +153,14 @@ export interface Listener { stopPropagation: boolean; } +/** `SetAttribute.value` / `TemplateAttr.value`'s oneof + * (proto/stream-dom.proto): a literal string, or an opaque asset handle + * the receiver materializes into a URL through its `resolveAsset` hook — + * the producer never names a URL through it. */ +export type AttrValue = + | { kind: "text"; value: string } + | { kind: "asset"; handle: Uint8Array }; + export type PropertyValue = | { kind: "text"; value: string } | { kind: "int"; value: number } @@ -237,7 +171,7 @@ export type PropertyValue = export interface TemplateAttr { name: number; ns: number | undefined; - value: string; + value: AttrValue; } export interface TemplateElement { @@ -280,7 +214,7 @@ export interface FrameSink { id: number, name: number, ns: number | undefined, - value: string | undefined, + value: AttrValue | undefined, ): void; setProperty(id: number, name: number, value: PropertyValue): void; addListener(listener: Listener): void; @@ -292,7 +226,7 @@ export interface FrameSink { commit(): void; } -function decodeListener(r: Reader, a: AcceptSet | undefined): Listener { +function decodeListener(r: Reader, strict: boolean): Listener { let target: ListenerTarget | undefined; let name = 0; let bubbles = false; @@ -302,17 +236,12 @@ function decodeListener(r: Reader, a: AcceptSet | undefined): Listener { let stopPropagation = false; while (!r.finished()) { const [field, wireType] = r.readTag(); - if (a) check(a.Listener, field); switch (field) { case LISTENER_ID: target = { kind: "node", id: r.readVarint32() }; break; case LISTENER_GLOBAL: { const g = r.readVarint32(); - // Gated by enum VALUE: declaring `Listener.global` says nothing - // about which singletons the producer may name. An unknown value - // is a mask miss too, so strict mode reports `Global.`. - if (a) check(a.Global, g); if (g === GLOBAL_WINDOW) target = { kind: "window" }; else if (g === GLOBAL_DOCUMENT) target = { kind: "document" }; else throw new Error(`stream-dom: Listener.global unknown value ${g}`); @@ -337,6 +266,7 @@ function decodeListener(r: Reader, a: AcceptSet | undefined): Listener { stopPropagation = r.readBool(); break; default: + if (strict) unknownField(field, "Listener"); r.skip(wireType); } } @@ -375,14 +305,14 @@ function readPackedOrRepeatedUint32( } } -function decodeTemplateAttr( - r: Reader, - acc: AcceptSet | undefined, -): TemplateAttr { - const a: TemplateAttr = { name: 0, ns: undefined, value: "" }; +function decodeTemplateAttr(r: Reader, strict: boolean): TemplateAttr { + const a: TemplateAttr = { + name: 0, + ns: undefined, + value: { kind: "text", value: "" }, + }; while (!r.finished()) { const [field, wireType] = r.readTag(); - if (acc) check(acc.TemplateAttr, field); switch (field) { case TEMPLATE_ATTR_NAME: a.name = r.readVarint32(); @@ -390,24 +320,28 @@ function decodeTemplateAttr( case TEMPLATE_ATTR_NS: a.ns = r.readVarint32(); break; - case TEMPLATE_ATTR_VALUE: - a.value = r.readString(); + case TEMPLATE_ATTR_TEXT: + a.value = { kind: "text", value: r.readString() }; + break; + case TEMPLATE_ATTR_ASSET: + // `readBytes` COPIES (proto.ts) rather than returning a view over + // the decode buffer: under the direct transport those bytes alias + // guest memory and are invalid once the read callback returns, so + // a handle retained past this call must own them. + a.value = { kind: "asset", handle: r.readBytes() }; break; default: + if (strict) unknownField(field, "TemplateAttr"); r.skip(wireType); } } return a; } -function decodeTemplateElement( - r: Reader, - acc: AcceptSet | undefined, -): TemplateElement { +function decodeTemplateElement(r: Reader, strict: boolean): TemplateElement { const e: TemplateElement = { tag: 0, ns: undefined, attrs: [], children: [] }; while (!r.finished()) { const [field, wireType] = r.readTag(); - if (acc) check(acc.TemplateElement, field); switch (field) { case TEMPLATE_ELEMENT_TAG: e.tag = r.readVarint32(); @@ -416,44 +350,55 @@ function decodeTemplateElement( e.ns = r.readVarint32(); break; case TEMPLATE_ELEMENT_ATTRS: - e.attrs.push(decodeTemplateAttr(r.readMessage(), acc)); + e.attrs.push(decodeTemplateAttr(r.readMessage(), strict)); break; case TEMPLATE_ELEMENT_CHILDREN: readPackedOrRepeatedUint32(r, wireType, e.children); break; default: + if (strict) unknownField(field, "TemplateElement"); r.skip(wireType); } } return e; } -function decodeTemplateNode( - r: Reader, - acc: AcceptSet | undefined, -): TemplateNode { +function decodeTemplateNode(r: Reader, strict: boolean): TemplateNode { let node: TemplateNode = { kind: "text", text: "" }; + let sawKind = false; while (!r.finished()) { const [field, wireType] = r.readTag(); - if (acc) check(acc.TemplateNode, field); switch (field) { case TEMPLATE_NODE_ELEMENT: node = { kind: "element", - element: decodeTemplateElement(r.readMessage(), acc), + element: decodeTemplateElement(r.readMessage(), strict), }; + sawKind = true; break; case TEMPLATE_NODE_TEXT: node = { kind: "text", text: r.readString() }; + sawKind = true; break; case TEMPLATE_NODE_DYNAMIC: r.readMessage(); // Dynamic {} — no fields to read. node = { kind: "dynamic" }; + sawKind = true; break; default: + if (strict) unknownField(field, "TemplateNode"); r.skip(wireType); } } + // A `TemplateNode` naming no kind is wire content this receiver cannot + // act on, so strict mode rejects it — with its own message, since an + // absent oneof has no field number to report. Non-strict keeps the + // proto3 default (an empty text node). + if (strict && !sawKind) { + throw new Error( + `stream-dom: TemplateNode has no kind set (receiver PROTOCOL_VERSION ${PROTOCOL_VERSION})`, + ); + } return node; } @@ -479,16 +424,16 @@ export class FrameDecoder { /** Frame messages decoded so far, whether or not they carried an op — * a benchmark harness's `Mounted.stats.frames` (mount.ts). */ #frameCount = 0; - /** Strict mode when present: every field tag in every message is checked - * against its message's declared mask BEFORE its value is consumed, and - * an undeclared or unknown one throws a `PolicyError` (policy.ts). - * Absent (the default) is exactly the tolerant proto3 behaviour - * docs/design.md describes: "receivers skip what they do not know". */ - #accept: AcceptSet | undefined; - - constructor(sink: FrameSink, options?: { accept?: AcceptSet }) { + /** Reject wire content this receiver does not know instead of skipping + * it — see `unknownField`. `createDriver` turns this on whenever a + * policy is configured; the default `false` is exactly the tolerant + * proto3 behaviour docs/design.md describes ("receivers skip what they + * do not know"), which is what an open receiver wants. */ + #strict: boolean; + + constructor(sink: FrameSink, options?: { strict?: boolean }) { this.#sink = sink; - this.#accept = options?.accept; + this.#strict = options?.strict ?? false; } get frameCount(): number { @@ -547,22 +492,18 @@ export class FrameDecoder { #decodeFrame(r: Reader): void { this.#frameCount++; - const A = this.#accept; let commit = false; let sawAnyOpField = false; let dispatch: (() => void) | undefined; while (!r.finished()) { const [field, wireType] = r.readTag(); - // Covers `commit`, every op case, the sub-`FRAME_OP_FIELD_MIN` skip - // below and the `default` branch in one test: a tag not in the mask - // is undeclared whether or not this decoder knows it. - if (A) check(A.Frame, field); if (field === FRAME_COMMIT) { commit = r.readBool(); continue; } if (field < FRAME_OP_FIELD_MIN) { + if (this.#strict) unknownField(field, "Frame"); r.skip(wireType); continue; } @@ -573,9 +514,9 @@ export class FrameDecoder { let id = 0, s = ""; while (!sub.finished()) { const [f, wt] = sub.readTag(); - if (A) check(A.Intern, f); if (f === INTERN_ID) id = sub.readVarint32(); else if (f === INTERN_S) s = sub.readString(); + else if (this.#strict) unknownField(f, "Intern"); else sub.skip(wt); } dispatch = () => this.#sink.internString(id, s); @@ -586,10 +527,10 @@ export class FrameDecoder { let id = 0, tag = 0, ns: number | undefined; while (!sub.finished()) { const [f, wt] = sub.readTag(); - if (A) check(A.CreateElement, f); if (f === CREATE_ELEMENT_ID) id = sub.readVarint32(); else if (f === CREATE_ELEMENT_TAG) tag = sub.readVarint32(); else if (f === CREATE_ELEMENT_NS) ns = sub.readVarint32(); + else if (this.#strict) unknownField(f, "CreateElement"); else sub.skip(wt); } dispatch = () => this.#sink.createElement(id, tag, ns); @@ -600,9 +541,9 @@ export class FrameDecoder { let id = 0, text = ""; while (!sub.finished()) { const [f, wt] = sub.readTag(); - if (A) check(A.CreateText, f); if (f === CREATE_TEXT_ID) id = sub.readVarint32(); else if (f === CREATE_TEXT_TEXT) text = sub.readString(); + else if (this.#strict) unknownField(f, "CreateText"); else sub.skip(wt); } dispatch = () => this.#sink.createText(id, text); @@ -613,8 +554,8 @@ export class FrameDecoder { let id = 0; while (!sub.finished()) { const [f, wt] = sub.readTag(); - if (A) check(A.CreatePlaceholder, f); if (f === CREATE_PLACEHOLDER_ID) id = sub.readVarint32(); + else if (this.#strict) unknownField(f, "CreatePlaceholder"); else sub.skip(wt); } dispatch = () => this.#sink.createPlaceholder(id); @@ -625,10 +566,10 @@ export class FrameDecoder { let parent: number | undefined, id = 0, anchor: number | undefined; while (!sub.finished()) { const [f, wt] = sub.readTag(); - if (A) check(A.InsertBefore, f); if (f === INSERT_BEFORE_PARENT) parent = sub.readVarint32(); else if (f === INSERT_BEFORE_ID) id = sub.readVarint32(); else if (f === INSERT_BEFORE_ANCHOR) anchor = sub.readVarint32(); + else if (this.#strict) unknownField(f, "InsertBefore"); else sub.skip(wt); } dispatch = () => this.#sink.insertBefore(parent, id, anchor); @@ -639,10 +580,10 @@ export class FrameDecoder { let parent: number | undefined, id = 0, anchor = 0; while (!sub.finished()) { const [f, wt] = sub.readTag(); - if (A) check(A.InsertAfter, f); if (f === INSERT_AFTER_PARENT) parent = sub.readVarint32(); else if (f === INSERT_AFTER_ID) id = sub.readVarint32(); else if (f === INSERT_AFTER_ANCHOR) anchor = sub.readVarint32(); + else if (this.#strict) unknownField(f, "InsertAfter"); else sub.skip(wt); } dispatch = () => this.#sink.insertAfter(parent, id, anchor); @@ -653,8 +594,8 @@ export class FrameDecoder { let id = 0; while (!sub.finished()) { const [f, wt] = sub.readTag(); - if (A) check(A.Remove, f); if (f === REMOVE_ID) id = sub.readVarint32(); + else if (this.#strict) unknownField(f, "Remove"); else sub.skip(wt); } dispatch = () => this.#sink.remove(id); @@ -665,9 +606,9 @@ export class FrameDecoder { let id = 0, text = ""; while (!sub.finished()) { const [f, wt] = sub.readTag(); - if (A) check(A.SetText, f); if (f === SET_TEXT_ID) id = sub.readVarint32(); else if (f === SET_TEXT_TEXT) text = sub.readString(); + else if (this.#strict) unknownField(f, "SetText"); else sub.skip(wt); } dispatch = () => this.#sink.setText(id, text); @@ -678,14 +619,18 @@ export class FrameDecoder { let id = 0, name = 0, ns: number | undefined, - value: string | undefined; + value: AttrValue | undefined; while (!sub.finished()) { const [f, wt] = sub.readTag(); - if (A) check(A.SetAttribute, f); if (f === SET_ATTRIBUTE_ID) id = sub.readVarint32(); else if (f === SET_ATTRIBUTE_NAME) name = sub.readVarint32(); else if (f === SET_ATTRIBUTE_NS) ns = sub.readVarint32(); - else if (f === SET_ATTRIBUTE_VALUE) value = sub.readString(); + else if (f === SET_ATTRIBUTE_TEXT) { + value = { kind: "text", value: sub.readString() }; + } else if (f === SET_ATTRIBUTE_ASSET) { + // Copied, not aliased — see decodeTemplateAttr's note. + value = { kind: "asset", handle: sub.readBytes() }; + } else if (this.#strict) unknownField(f, "SetAttribute"); else sub.skip(wt); } dispatch = () => this.#sink.setAttribute(id, name, ns, value); @@ -697,7 +642,6 @@ export class FrameDecoder { let value: PropertyValue = { kind: "none" }; while (!sub.finished()) { const [f, wt] = sub.readTag(); - if (A) check(A.SetProperty, f); if (f === SET_PROPERTY_ID) id = sub.readVarint32(); else if (f === SET_PROPERTY_NAME) name = sub.readVarint32(); else if (f === SET_PROPERTY_TEXT) { @@ -708,7 +652,8 @@ export class FrameDecoder { value = { kind: "float", value: sub.readDouble() }; } else if (f === SET_PROPERTY_BOOLEAN) { value = { kind: "boolean", value: sub.readBool() }; - } else sub.skip(wt); + } else if (this.#strict) unknownField(f, "SetProperty"); + else sub.skip(wt); } dispatch = () => this.#sink.setProperty(id, name, value); break; @@ -718,10 +663,10 @@ export class FrameDecoder { let listener: Listener | undefined; while (!sub.finished()) { const [f, wt] = sub.readTag(); - if (A) check(A.AddListener, f); if (f === ADD_LISTENER_LISTENER) { - listener = decodeListener(sub.readMessage(), A); - } else sub.skip(wt); + listener = decodeListener(sub.readMessage(), this.#strict); + } else if (this.#strict) unknownField(f, "AddListener"); + else sub.skip(wt); } if (listener) { const l = listener; @@ -734,10 +679,10 @@ export class FrameDecoder { let listener: Listener | undefined; while (!sub.finished()) { const [f, wt] = sub.readTag(); - if (A) check(A.RemoveListener, f); if (f === REMOVE_LISTENER_LISTENER) { - listener = decodeListener(sub.readMessage(), A); - } else sub.skip(wt); + listener = decodeListener(sub.readMessage(), this.#strict); + } else if (this.#strict) unknownField(f, "RemoveListener"); + else sub.skip(wt); } if (listener) { const l = listener; @@ -752,13 +697,13 @@ export class FrameDecoder { const roots: number[] = []; while (!sub.finished()) { const [f, wt] = sub.readTag(); - if (A) check(A.RegisterTemplate, f); if (f === REGISTER_TEMPLATE_ID) id = sub.readVarint32(); else if (f === REGISTER_TEMPLATE_NODES) { - nodes.push(decodeTemplateNode(sub.readMessage(), A)); + nodes.push(decodeTemplateNode(sub.readMessage(), this.#strict)); } else if (f === REGISTER_TEMPLATE_ROOTS) { readPackedOrRepeatedUint32(sub, wt, roots); - } else sub.skip(wt); + } else if (this.#strict) unknownField(f, "RegisterTemplate"); + else sub.skip(wt); } dispatch = () => this.#sink.registerTemplate(id, nodes, roots); break; @@ -768,10 +713,10 @@ export class FrameDecoder { let tmpl = 0, root = 0, id = 0; while (!sub.finished()) { const [f, wt] = sub.readTag(); - if (A) check(A.CloneTemplate, f); if (f === CLONE_TEMPLATE_TMPL) tmpl = sub.readVarint32(); else if (f === CLONE_TEMPLATE_ROOT) root = sub.readVarint32(); else if (f === CLONE_TEMPLATE_ID) id = sub.readVarint32(); + else if (this.#strict) unknownField(f, "CloneTemplate"); else sub.skip(wt); } dispatch = () => this.#sink.cloneTemplate(tmpl, root, id); @@ -783,10 +728,10 @@ export class FrameDecoder { let path: Uint8Array = new Uint8Array(0); while (!sub.finished()) { const [f, wt] = sub.readTag(); - if (A) check(A.BindPath, f); if (f === BIND_PATH_ROOT) root = sub.readVarint32(); else if (f === BIND_PATH_PATH) path = sub.readBytes(); else if (f === BIND_PATH_ID) id = sub.readVarint32(); + else if (this.#strict) unknownField(f, "BindPath"); else sub.skip(wt); } dispatch = () => this.#sink.bindPath(root, path, id); @@ -797,9 +742,9 @@ export class FrameDecoder { let key = 0, id = 0; while (!sub.finished()) { const [f, wt] = sub.readTag(); - if (A) check(A.BindMarker, f); if (f === BIND_MARKER_KEY) key = sub.readVarint32(); else if (f === BIND_MARKER_ID) id = sub.readVarint32(); + else if (this.#strict) unknownField(f, "BindMarker"); else sub.skip(wt); } dispatch = () => this.#sink.bindMarker(key, id); @@ -807,6 +752,7 @@ export class FrameDecoder { } default: // Unknown op field: skip the frame's bytes, still honor commit. + if (this.#strict) unknownField(field, "Frame"); r.skip(wireType); } } diff --git a/receiver/src/mod.ts b/receiver/src/mod.ts index ade962e..a4a1d69 100644 --- a/receiver/src/mod.ts +++ b/receiver/src/mod.ts @@ -1,8 +1,9 @@ // Re-exports for `@polymorph/stream-dom-receiver`. export { Reader, WireType, Writer } from "./proto.ts"; -export { FrameDecoder } from "./frames.ts"; +export { FrameDecoder, PROTOCOL_VERSION } from "./frames.ts"; export type { + AttrValue, FrameSink, Listener, ListenerTarget, @@ -17,27 +18,8 @@ export { validateTemplateArena } from "./templates.ts"; export { createRemoteReceiver, RemoteDomTranscoder } from "./remote.ts"; export { NativeDomReceiver } from "./native.ts"; export { encodePayload } from "./events.ts"; -export { - ALL_EVENT_FIELDS, - ALL_QUERIES, - ALL_STREAM_FIELDS, - compilePolicy, - PolicyError, - queryAllowed, - SURFACE_V1, -} from "./policy.ts"; -export type { - AcceptSet, - CompiledPolicy, - EventField, - EventFieldSet, - EventMessage, - MessageAccept, - Policy, - Query, - StreamField, - StreamMessage, -} from "./policy.ts"; +export { assertPolicyVersion, PolicyError, PolicySink } from "./policy.ts"; +export type { Policy, PolicyOp } from "./policy.ts"; export { DispatchGate } from "./dispatch.ts"; export { mount } from "./mount.ts"; export type { Mounted, MountOptions } from "./mount.ts"; diff --git a/receiver/src/mount.ts b/receiver/src/mount.ts index df24d7f..f780557 100644 --- a/receiver/src/mount.ts +++ b/receiver/src/mount.ts @@ -48,17 +48,17 @@ export interface MountOptions { * uniform across transports rather than aliasing-safe in one and not * the other. */ onChunk?(bytes: Uint8Array): void; - /** 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. A bare `FrameSink` - * wrapper (`Policy.sink`) or a byte-level transformer in front of the - * decoder is also possible and useful for semantic checks, but is NOT - * fail-safe: both pass surface they have never seen straight through. - * - * Compiling the policy validates every name; a policy naming something - * unknown makes `mount` reject. */ + /** Host vocabulary policy (policy.ts). Present => strict decoding plus + * a `PolicySink` in front of the backend: the policy sees each + * vocabulary-bearing op with interned strings resolved and may reject + * it, and wire content this receiver does not know is rejected rather + * than skipped. `mount` rejects synchronously if the policy pins a + * different `PROTOCOL_VERSION`. */ policy?: Policy; + /** Asset handle -> URL. Required if the stream ever carries an asset + * attribute value; absent + an asset value is an error through the + * normal error path. */ + resolveAsset?(handle: Uint8Array): string; } export interface Mounted { @@ -118,6 +118,7 @@ export async function mount(opts: MountOptions): Promise { root: opts.root, receiver: opts.receiver, policy: opts.policy, + resolveAsset: opts.resolveAsset, onError, handleEvent: (target, nameRef, payload, ev) => { if (!exports_.handleEvent || disposed) return; diff --git a/receiver/src/native.ts b/receiver/src/native.ts index c5a890c..dec60b0 100644 --- a/receiver/src/native.ts +++ b/receiver/src/native.ts @@ -28,6 +28,7 @@ // animations that `insertBefore` resets"). import type { + AttrValue, FrameSink, Listener, PropertyValue, @@ -84,14 +85,19 @@ type MoveCapableParent = Node & { */ export class NativeDomReceiver implements Receiver, FrameSink { #doc: Document; + #resolveAsset: ((handle: Uint8Array) => string) | undefined; #nodes = new Map(); #ids = new WeakMap(); #templates = new Map(); readonly listeners: ListenerRegistry = new ListenerRegistry(); onCommit: (() => void) | null = null; - constructor(root: Element) { + /** `resolveAsset` materializes an `AttrValue` asset handle into a URL + * (proto `SetAttribute.asset`); without it, a stream carrying an asset + * value is an error. */ + constructor(root: Element, resolveAsset?: (handle: Uint8Array) => string) { this.#doc = root.ownerDocument; + this.#resolveAsset = resolveAsset; // The one binding of `ROOT_ID` that ever happens: `#register` rejects // it from here on. this.#bind(ROOT_ID, root); @@ -160,6 +166,18 @@ export class NativeDomReceiver implements Receiver, FrameSink { return this.listeners.stringFor(ref); } + /** The string an attribute value sets: a literal, or the URL the host's + * `resolveAsset` hook returns for an asset handle. */ + #attrText(value: AttrValue): string { + if (value.kind === "text") return value.value; + if (!this.#resolveAsset) { + throw new Error( + "stream-dom: asset attribute value but no resolveAsset configured", + ); + } + return this.#resolveAsset(value.handle); + } + /** `#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. */ @@ -362,7 +380,7 @@ export class NativeDomReceiver implements Receiver, FrameSink { id: number, name: number, ns: number | undefined, - value: string | undefined, + value: AttrValue | undefined, ): void { const el = this.#element("set-attribute", id); const attrName = this.#str(name); @@ -370,8 +388,9 @@ export class NativeDomReceiver implements Receiver, FrameSink { if (ns === undefined) el.removeAttribute(attrName); else el.removeAttributeNS(this.#str(ns), attrName); } else { - if (ns === undefined) el.setAttribute(attrName, value); - else el.setAttributeNS(this.#str(ns), attrName, value); + const text = this.#attrText(value); + if (ns === undefined) el.setAttribute(attrName, text); + else el.setAttributeNS(this.#str(ns), attrName, text); } } @@ -417,8 +436,12 @@ export class NativeDomReceiver implements Receiver, FrameSink { this.#str(n.element.tag), ); for (const a of n.element.attrs) { - if (a.ns === undefined) el.setAttribute(this.#str(a.name), a.value); - else el.setAttributeNS(this.#str(a.ns), this.#str(a.name), a.value); + // Asset handles resolve HERE, at registration: the prototype is + // built once and cloned, so a later re-intern (or a later hook) + // cannot make a clone diverge from what was registered. + const text = this.#attrText(a.value); + if (a.ns === undefined) el.setAttribute(this.#str(a.name), text); + else el.setAttributeNS(this.#str(a.ns), this.#str(a.name), text); } for (const childIdx of n.element.children) { el.appendChild(build(childIdx)); diff --git a/receiver/src/policy.ts b/receiver/src/policy.ts index 631b211..3a74aa9 100644 --- a/receiver/src/policy.ts +++ b/receiver/src/policy.ts @@ -1,639 +1,353 @@ -// Policy declaration: the embedder names, by string, every piece of -// protocol surface it accepts. Nothing it has not named crosses the -// boundary. -// -// Why declaration rather than a `FrameSink` wrapper: a wrapper fails OPEN -// when the protocol grows. The decoder skips unknown fields and forwards -// records whole, so a new op, a new field on an existing message -// (`Listener.once`) or a new enum value reaches a wrapper's `inner` -// unchecked — the wrapper has no case for what it has never seen. A -// declared list fails CLOSED: surface absent from the list is rejected -// whether or not this receiver understood it. -// -// This is not a backward-compatibility guarantee. An embedder updates its -// policy when it updates this dependency; the mechanism only guarantees -// that the update cannot silently widen exposure. -// -// Three directions, two behaviours: -// - Mutation stream (producer -> receiver): undeclared -> REJECT -// (`PolicyError`; the mount aborts). There is a violator to name. -// - Event payloads (receiver -> producer): undeclared -> DROP (not -// encoded). The receiver authors payloads, so there is no violator. -// - `queries` host imports (producer asks receiver): undeclared -> REFUSE -// (`undefined` / `false`), never throw — the WIT signatures already -// carry "no answer". -// -// Names are proto names verbatim: `Message.field` in snake_case exactly as -// in proto/stream-dom.proto and proto/stream-dom-events.proto, `Enum.VALUE` -// for enum values, and the WIT function names of wit/stream-dom.wit's -// `queries` interface for queries. - -import { STREAM_FIELD_NUMBERS as S } from "./frames.ts"; -import type { FrameSink } from "./frames.ts"; -import { EVENT_FIELD_NUMBERS as E } from "./events.ts"; - -// -- errors --------------------------------------------------------------- - -/** Thrown by strict decoding when the stream carries surface the policy did - * not declare. `field` is the offending `Message.field` or `Enum.VALUE`, - * or `Message.` for a field number this decoder has no name for. */ +// Host vocabulary policy: a `FrameSink` wrapper that shows each +// vocabulary-bearing op to a host-supplied callback with interned strings +// resolved, and forwards it to the real backend only if the callback +// allows it (docs/design.md "Policy"). Confinement (node ids, template +// arena validity) is protocol and stays with the backends — this seam is +// only about WHAT vocabulary a producer may name. + +import { PROTOCOL_VERSION } from "./frames.ts"; +import type { + AttrValue, + FrameSink, + Listener, + PropertyValue, + TemplateNode, +} from "./frames.ts"; + +export interface Policy { + /** Pins the protocol version this policy was written against. `mount` + * throws synchronously unless it equals the receiver's PROTOCOL_VERSION: + * upgrading the receiver library must not silently widen what a policy + * never reviewed. */ + readonly version: number; + /** Return `undefined` to allow; a reason string rejects, which closes the + * stream and reports a PolicyError through `onError`. */ + check(op: PolicyOp): string | undefined; + /** May the producer call this `queries` import (wit/stream-dom.wit + * `interface queries`)? Absent means allow. A refusal answers + * `undefined`/`false` rather than throwing — the WIT signatures already + * carry "no answer", and there is no violator to name: asking is legal, + * it is the answer this host declines to give. */ + query?( + name: + | "get-client-rect" + | "get-scroll-offset" + | "get-scroll-size" + | "set-focus", + ): boolean; +} + +export type PolicyOp = + | { op: "createElement"; tag: string; ns: string | undefined } + | { + op: "setAttribute"; + tag: string | undefined; + name: string; + ns: string | undefined; + value: AttrValue | undefined; + } + | { + op: "setProperty"; + tag: string | undefined; + name: string; + value: PropertyValue; + } + | { + op: "addListener"; + target: "node" | "window" | "document"; + name: string; + capture: boolean; + passive: boolean; + preventDefault: boolean; + stopPropagation: boolean; + } + | { op: "bindMarker" }; + export class PolicyError extends Error { - readonly field: string; - constructor(field: string) { - super(`stream-dom: policy rejected undeclared ${field}`); + /** Ops the sink had seen before this one (`commit` not counted), 0-based. */ + readonly opIndex: number; + readonly op: PolicyOp; + readonly reason: string; + + constructor(opIndex: number, op: PolicyOp, reason: string) { + super(`stream-dom: policy rejected ${op.op} #${opIndex}: ${reason}`); this.name = "PolicyError"; - this.field = field; + this.opIndex = opIndex; + this.op = op; + this.reason = reason; } } -// -- declared surface ----------------------------------------------------- - -/** Every field the frames.ts decoder knows how to decode, plus both - * `Global` enum values. A name here is a name an embedder may declare; a - * name not here is a construction error (`compilePolicy`). */ -export const ALL_STREAM_FIELDS = [ - // Frame: `commit` plus all 16 `op` oneof cases. - "Frame.commit", - "Frame.insert_before", - "Frame.set_text", - "Frame.set_attribute", - "Frame.set_property", - "Frame.create_element", - "Frame.create_text", - "Frame.remove", - "Frame.clone_template", - "Frame.bind_path", - "Frame.create_placeholder", - "Frame.add_listener", - "Frame.remove_listener", - "Frame.intern", - "Frame.register_template", - "Frame.insert_after", - "Frame.bind_marker", - "Intern.id", - "Intern.s", - "CreateElement.id", - "CreateElement.tag", - "CreateElement.ns", - "CreateText.id", - "CreateText.text", - "CreatePlaceholder.id", - "InsertBefore.parent", - "InsertBefore.id", - "InsertBefore.anchor", - "InsertAfter.parent", - "InsertAfter.id", - "InsertAfter.anchor", - "Remove.id", - "SetText.id", - "SetText.text", - "SetAttribute.id", - "SetAttribute.name", - "SetAttribute.ns", - "SetAttribute.value", - "SetProperty.id", - "SetProperty.name", - "SetProperty.text", - "SetProperty.int", - "SetProperty.float", - "SetProperty.boolean", - "Listener.id", - "Listener.name", - "Listener.bubbles", - "Listener.capture", - "Listener.passive", - "Listener.prevent_default", - "Listener.stop_propagation", - "Listener.global", - "AddListener.listener", - "RemoveListener.listener", - "TemplateAttr.name", - "TemplateAttr.ns", - "TemplateAttr.value", - "TemplateElement.tag", - "TemplateElement.ns", - "TemplateElement.attrs", - "TemplateElement.children", - // `Dynamic` has no fields; `TemplateNode.dynamic` is the whole of it. - "TemplateNode.element", - "TemplateNode.text", - "TemplateNode.dynamic", - "RegisterTemplate.id", - "RegisterTemplate.nodes", - "RegisterTemplate.roots", - "CloneTemplate.tmpl", - "CloneTemplate.root", - "CloneTemplate.id", - "BindPath.root", - "BindPath.path", - "BindPath.id", - "BindMarker.key", - "BindMarker.id", - // Enum VALUES, not fields: `Listener.global` being declared says nothing - // about which singletons the producer may name. - "Global.WINDOW", - "Global.DOCUMENT", -] as const; - -export type StreamField = (typeof ALL_STREAM_FIELDS)[number]; - -/** Every event payload field the events.ts encoder can currently EMIT. - * `MouseData.related_target` is absent: the encoder never populates it (see - * events.ts), so declaring it would name surface that cannot exist. */ -export const ALL_EVENT_FIELDS = [ - "EventPayload.mouse", - "EventPayload.keyboard", - "EventPayload.form", - "EventPayload.navigation", - "MouseData.client_x", - "MouseData.client_y", - "MouseData.page_x", - "MouseData.page_y", - "MouseData.screen_x", - "MouseData.screen_y", - "MouseData.offset_x", - "MouseData.offset_y", - "MouseData.button", - "MouseData.primary", - "MouseData.secondary", - "MouseData.auxiliary", - "MouseData.back", - "MouseData.forward", - "MouseData.modifiers", - "Modifiers.alt", - "Modifiers.ctrl", - "Modifiers.meta", - "Modifiers.shift", - "KeyboardData.key", - "KeyboardData.code", - "KeyboardData.location", - "KeyboardData.repeat", - "KeyboardData.is_composing", - "KeyboardData.modifiers", - "FormData.value", - "FormData.checked", - "FormData.fields", - "FormField.name", - "FormField.value", - "NavigationData.href", -] as const; - -export type EventField = (typeof ALL_EVENT_FIELDS)[number]; - -/** wit/stream-dom.wit `interface queries`. */ -export const ALL_QUERIES = [ - "get-client-rect", - "get-scroll-offset", - "get-scroll-size", - "set-focus", -] as const; - -export type Query = (typeof ALL_QUERIES)[number]; - -// -- the policy ----------------------------------------------------------- +/** Throws unless `policy.version` is this receiver's `PROTOCOL_VERSION` — + * called by `mount` before anything else happens. */ +export function assertPolicyVersion(policy: Policy): void { + if (policy.version !== PROTOCOL_VERSION) { + throw new Error( + `stream-dom: policy pins protocol version ${policy.version}, receiver is ${PROTOCOL_VERSION}`, + ); + } +} -export interface Policy { - /** Mutation-stream surface. Anything else on the stream is a - * `PolicyError` and aborts the mount. */ - accept: readonly StreamField[]; - /** Event payload fields the receiver may encode. Anything else is - * silently not written. */ - events: readonly EventField[]; - /** `queries` imports the producer may call. Others answer "no". */ - queries: readonly Query[]; - /** The embedder's own semantic checks over the ops that survived - * `accept`: vocabulary (which tags/attributes), values, budgets. Ops - * reach `inner` only if this wrapper forwards them. Optional — the - * declared lists above are the fail-safe part; this is the part that - * needs to know what the ops MEAN. */ - sink?(inner: FrameSink): FrameSink; +/** Where a node id landed inside a registered template arena: which + * template, and which node index within it. Recorded by `cloneTemplate` + * for the clone's root and extended by `bindPath`, which walks the arena's + * `children` in the same order the backends walk real `childNodes` — so a + * path step crossing a text node or a dynamic hole lands on the same node + * either way. */ +interface ArenaSite { + tmpl: number; + index: number; } -// == FROZEN SNAPSHOT ====================================================== -// -// SURFACE_V1: today's full protocol surface, written out literally. -// -// FROZEN — never add to this list. New protocol surface gets a NEW -// snapshot (SURFACE_V2) and embedders opt in by naming the new fields. -// That is the whole mechanism: an embedder that spreads V1 and updates -// this dependency keeps exactly the exposure it reviewed. -// -// Deliberately NOT aliased to `ALL_STREAM_FIELDS` / `ALL_EVENT_FIELDS` / -// `ALL_QUERIES`: those grow with the protocol, and this must not move. -// (A field REMOVED from the protocol makes `compilePolicy` throw here, -// loudly, which is the intended failure.) -// -// Usage: `{ ...SURFACE_V1, sink }`. - -export const SURFACE_V1: { - readonly accept: readonly StreamField[]; - readonly events: readonly EventField[]; - readonly queries: readonly Query[]; -} = Object.freeze({ - accept: Object.freeze( - [ - "Frame.commit", - "Frame.insert_before", - "Frame.set_text", - "Frame.set_attribute", - "Frame.set_property", - "Frame.create_element", - "Frame.create_text", - "Frame.remove", - "Frame.clone_template", - "Frame.bind_path", - "Frame.create_placeholder", - "Frame.add_listener", - "Frame.remove_listener", - "Frame.intern", - "Frame.register_template", - "Frame.insert_after", - "Frame.bind_marker", - "Intern.id", - "Intern.s", - "CreateElement.id", - "CreateElement.tag", - "CreateElement.ns", - "CreateText.id", - "CreateText.text", - "CreatePlaceholder.id", - "InsertBefore.parent", - "InsertBefore.id", - "InsertBefore.anchor", - "InsertAfter.parent", - "InsertAfter.id", - "InsertAfter.anchor", - "Remove.id", - "SetText.id", - "SetText.text", - "SetAttribute.id", - "SetAttribute.name", - "SetAttribute.ns", - "SetAttribute.value", - "SetProperty.id", - "SetProperty.name", - "SetProperty.text", - "SetProperty.int", - "SetProperty.float", - "SetProperty.boolean", - "Listener.id", - "Listener.name", - "Listener.bubbles", - "Listener.capture", - "Listener.passive", - "Listener.prevent_default", - "Listener.stop_propagation", - "Listener.global", - "AddListener.listener", - "RemoveListener.listener", - "TemplateAttr.name", - "TemplateAttr.ns", - "TemplateAttr.value", - "TemplateElement.tag", - "TemplateElement.ns", - "TemplateElement.attrs", - "TemplateElement.children", - "TemplateNode.element", - "TemplateNode.text", - "TemplateNode.dynamic", - "RegisterTemplate.id", - "RegisterTemplate.nodes", - "RegisterTemplate.roots", - "CloneTemplate.tmpl", - "CloneTemplate.root", - "CloneTemplate.id", - "BindPath.root", - "BindPath.path", - "BindPath.id", - "BindMarker.key", - "BindMarker.id", - "Global.WINDOW", - "Global.DOCUMENT", - ] as const satisfies readonly StreamField[], - ), - events: Object.freeze( - [ - "EventPayload.mouse", - "EventPayload.keyboard", - "EventPayload.form", - "EventPayload.navigation", - "MouseData.client_x", - "MouseData.client_y", - "MouseData.page_x", - "MouseData.page_y", - "MouseData.screen_x", - "MouseData.screen_y", - "MouseData.offset_x", - "MouseData.offset_y", - "MouseData.button", - "MouseData.primary", - "MouseData.secondary", - "MouseData.auxiliary", - "MouseData.back", - "MouseData.forward", - "MouseData.modifiers", - "Modifiers.alt", - "Modifiers.ctrl", - "Modifiers.meta", - "Modifiers.shift", - "KeyboardData.key", - "KeyboardData.code", - "KeyboardData.location", - "KeyboardData.repeat", - "KeyboardData.is_composing", - "KeyboardData.modifiers", - "FormData.value", - "FormData.checked", - "FormData.fields", - "FormField.name", - "FormField.value", - "NavigationData.href", - ] as const satisfies readonly EventField[], - ), - queries: Object.freeze( - [ - "get-client-rect", - "get-scroll-offset", - "get-scroll-size", - "set-focus", - ] as const satisfies readonly Query[], - ), -}); - -// == end FROZEN SNAPSHOT ================================================== - -// -- compiled form -------------------------------------------------------- - -/** Messages the strict decoder gates. `Global` is an enum, gated by VALUE - * rather than by field number, and rides in the same structure. */ -export type StreamMessage = - | "Frame" - | "Intern" - | "CreateElement" - | "CreateText" - | "CreatePlaceholder" - | "InsertBefore" - | "InsertAfter" - | "Remove" - | "SetText" - | "SetAttribute" - | "SetProperty" - | "Listener" - | "AddListener" - | "RemoveListener" - | "TemplateAttr" - | "TemplateElement" - | "TemplateNode" - | "RegisterTemplate" - | "CloneTemplate" - | "BindPath" - | "BindMarker" - | "Global"; - -/** One message's declared field numbers as a bitmask (every field number - * on this wire is <= 31; a number above that is undeclared by - * construction). `reject` builds the error message — off the hot path, so - * the decoder's cost per field is one bit test. */ -export interface MessageAccept { - readonly mask: number; - reject(field: number): never; +/** A registered template as this sink remembers it: the arena, its root + * ordinals, and each node's tag resolved AT REGISTRATION. Intern slots can + * be overwritten (proto `Intern`: "Define (or overwrite) interned slot"), + * and the backends build their prototypes with the strings current at + * `registerTemplate` — so re-resolving a tag ref later, when a clone or a + * bind-path lands on the node, could name a different element than the one + * actually in the DOM, and attributes would be judged against the wrong + * tag. `tags[i]` is `undefined` for non-element nodes. */ +interface RegisteredTemplate { + nodes: TemplateNode[]; + roots: number[]; + tags: (string | undefined)[]; } -export type AcceptSet = { readonly [M in StreamMessage]: MessageAccept }; +/** + * FrameSink wrapper: resolves interned strings, tracks element tags by + * node id (createElement, cloneTemplate, bindPath through the template + * arena), flattens registerTemplate into synthetic createElement / + * setAttribute checks (tag known, no id), calls policy.check, forwards to + * the inner sink only on allow. Throws PolicyError on reject. + */ +export class PolicySink implements FrameSink { + #inner: FrameSink; + #policy: Policy; + /** This sink's OWN copy of the intern table: it must resolve tag, + * attribute, property and event-name refs before the backend has seen + * the op, and the backends' tables are private to them. */ + #strings = new Map(); + /** Node id -> tag name, for ids known to be elements. */ + #tags = new Map(); + #templates = new Map(); + #sites = new Map(); + #ops = 0; + + constructor(inner: FrameSink, policy: Policy) { + this.#inner = inner; + this.#policy = policy; + } -/** Messages the event encoder gates, by field-number bitmask. */ -export type EventMessage = - | "EventPayload" - | "MouseData" - | "Modifiers" - | "KeyboardData" - | "FormData" - | "FormField" - | "NavigationData"; + /** The index this call reports to the policy: every FrameSink call the + * sink received before it, `commit` excluded. */ + #next(): number { + return this.#ops++; + } -export type EventFieldSet = { readonly [M in EventMessage]: number }; + /** A ref this stream never interned resolves to `""` here: the policy + * still gets a well-formed op to judge, and if it allows it the op + * reaches the backend, whose own `stringFor` rejects the unknown ref + * (receiver.ts) — so an un-interned ref aborts the stream through the + * protocol error it already is, rather than through a TypeError raised + * inside the policy seam. */ + #str(ref: number): string { + return this.#strings.get(ref) ?? ""; + } -export interface CompiledPolicy { - accept: AcceptSet; - events: EventFieldSet; - queries: ReadonlySet; -} + #ns(ref: number | undefined): string | undefined { + return ref === undefined ? undefined : this.#str(ref); + } -// -- name tables ---------------------------------------------------------- -// -// Field numbers come from frames.ts / events.ts rather than being repeated -// as literals here: a new constant without an entry in these tables is -// visible in the same diff. - -const STREAM_TABLE: { [K in StreamField]: readonly [StreamMessage, number] } = { - "Frame.commit": ["Frame", S.FRAME_COMMIT], - "Frame.insert_before": ["Frame", S.FRAME_INSERT_BEFORE], - "Frame.set_text": ["Frame", S.FRAME_SET_TEXT], - "Frame.set_attribute": ["Frame", S.FRAME_SET_ATTRIBUTE], - "Frame.set_property": ["Frame", S.FRAME_SET_PROPERTY], - "Frame.create_element": ["Frame", S.FRAME_CREATE_ELEMENT], - "Frame.create_text": ["Frame", S.FRAME_CREATE_TEXT], - "Frame.remove": ["Frame", S.FRAME_REMOVE], - "Frame.clone_template": ["Frame", S.FRAME_CLONE_TEMPLATE], - "Frame.bind_path": ["Frame", S.FRAME_BIND_PATH], - "Frame.create_placeholder": ["Frame", S.FRAME_CREATE_PLACEHOLDER], - "Frame.add_listener": ["Frame", S.FRAME_ADD_LISTENER], - "Frame.remove_listener": ["Frame", S.FRAME_REMOVE_LISTENER], - "Frame.intern": ["Frame", S.FRAME_INTERN], - "Frame.register_template": ["Frame", S.FRAME_REGISTER_TEMPLATE], - "Frame.insert_after": ["Frame", S.FRAME_INSERT_AFTER], - "Frame.bind_marker": ["Frame", S.FRAME_BIND_MARKER], - "Intern.id": ["Intern", S.INTERN_ID], - "Intern.s": ["Intern", S.INTERN_S], - "CreateElement.id": ["CreateElement", S.CREATE_ELEMENT_ID], - "CreateElement.tag": ["CreateElement", S.CREATE_ELEMENT_TAG], - "CreateElement.ns": ["CreateElement", S.CREATE_ELEMENT_NS], - "CreateText.id": ["CreateText", S.CREATE_TEXT_ID], - "CreateText.text": ["CreateText", S.CREATE_TEXT_TEXT], - "CreatePlaceholder.id": ["CreatePlaceholder", S.CREATE_PLACEHOLDER_ID], - "InsertBefore.parent": ["InsertBefore", S.INSERT_BEFORE_PARENT], - "InsertBefore.id": ["InsertBefore", S.INSERT_BEFORE_ID], - "InsertBefore.anchor": ["InsertBefore", S.INSERT_BEFORE_ANCHOR], - "InsertAfter.parent": ["InsertAfter", S.INSERT_AFTER_PARENT], - "InsertAfter.id": ["InsertAfter", S.INSERT_AFTER_ID], - "InsertAfter.anchor": ["InsertAfter", S.INSERT_AFTER_ANCHOR], - "Remove.id": ["Remove", S.REMOVE_ID], - "SetText.id": ["SetText", S.SET_TEXT_ID], - "SetText.text": ["SetText", S.SET_TEXT_TEXT], - "SetAttribute.id": ["SetAttribute", S.SET_ATTRIBUTE_ID], - "SetAttribute.name": ["SetAttribute", S.SET_ATTRIBUTE_NAME], - "SetAttribute.ns": ["SetAttribute", S.SET_ATTRIBUTE_NS], - "SetAttribute.value": ["SetAttribute", S.SET_ATTRIBUTE_VALUE], - "SetProperty.id": ["SetProperty", S.SET_PROPERTY_ID], - "SetProperty.name": ["SetProperty", S.SET_PROPERTY_NAME], - "SetProperty.text": ["SetProperty", S.SET_PROPERTY_TEXT], - "SetProperty.int": ["SetProperty", S.SET_PROPERTY_INT], - "SetProperty.float": ["SetProperty", S.SET_PROPERTY_FLOAT], - "SetProperty.boolean": ["SetProperty", S.SET_PROPERTY_BOOLEAN], - "Listener.id": ["Listener", S.LISTENER_ID], - "Listener.name": ["Listener", S.LISTENER_NAME], - "Listener.bubbles": ["Listener", S.LISTENER_BUBBLES], - "Listener.capture": ["Listener", S.LISTENER_CAPTURE], - "Listener.passive": ["Listener", S.LISTENER_PASSIVE], - "Listener.prevent_default": ["Listener", S.LISTENER_PREVENT_DEFAULT], - "Listener.stop_propagation": ["Listener", S.LISTENER_STOP_PROPAGATION], - "Listener.global": ["Listener", S.LISTENER_GLOBAL], - "AddListener.listener": ["AddListener", S.ADD_LISTENER_LISTENER], - "RemoveListener.listener": ["RemoveListener", S.REMOVE_LISTENER_LISTENER], - "TemplateAttr.name": ["TemplateAttr", S.TEMPLATE_ATTR_NAME], - "TemplateAttr.ns": ["TemplateAttr", S.TEMPLATE_ATTR_NS], - "TemplateAttr.value": ["TemplateAttr", S.TEMPLATE_ATTR_VALUE], - "TemplateElement.tag": ["TemplateElement", S.TEMPLATE_ELEMENT_TAG], - "TemplateElement.ns": ["TemplateElement", S.TEMPLATE_ELEMENT_NS], - "TemplateElement.attrs": ["TemplateElement", S.TEMPLATE_ELEMENT_ATTRS], - "TemplateElement.children": ["TemplateElement", S.TEMPLATE_ELEMENT_CHILDREN], - "TemplateNode.element": ["TemplateNode", S.TEMPLATE_NODE_ELEMENT], - "TemplateNode.text": ["TemplateNode", S.TEMPLATE_NODE_TEXT], - "TemplateNode.dynamic": ["TemplateNode", S.TEMPLATE_NODE_DYNAMIC], - "RegisterTemplate.id": ["RegisterTemplate", S.REGISTER_TEMPLATE_ID], - "RegisterTemplate.nodes": ["RegisterTemplate", S.REGISTER_TEMPLATE_NODES], - "RegisterTemplate.roots": ["RegisterTemplate", S.REGISTER_TEMPLATE_ROOTS], - "CloneTemplate.tmpl": ["CloneTemplate", S.CLONE_TEMPLATE_TMPL], - "CloneTemplate.root": ["CloneTemplate", S.CLONE_TEMPLATE_ROOT], - "CloneTemplate.id": ["CloneTemplate", S.CLONE_TEMPLATE_ID], - "BindPath.root": ["BindPath", S.BIND_PATH_ROOT], - "BindPath.path": ["BindPath", S.BIND_PATH_PATH], - "BindPath.id": ["BindPath", S.BIND_PATH_ID], - "BindMarker.key": ["BindMarker", S.BIND_MARKER_KEY], - "BindMarker.id": ["BindMarker", S.BIND_MARKER_ID], - // Enum values: the "field number" is the enum VALUE. - "Global.WINDOW": ["Global", S.GLOBAL_WINDOW], - "Global.DOCUMENT": ["Global", S.GLOBAL_DOCUMENT], -}; - -const EVENT_TABLE: { [K in EventField]: readonly [EventMessage, number] } = { - "EventPayload.mouse": ["EventPayload", E.EVENT_PAYLOAD_MOUSE], - "EventPayload.keyboard": ["EventPayload", E.EVENT_PAYLOAD_KEYBOARD], - "EventPayload.form": ["EventPayload", E.EVENT_PAYLOAD_FORM], - "EventPayload.navigation": ["EventPayload", E.EVENT_PAYLOAD_NAVIGATION], - "MouseData.client_x": ["MouseData", E.MOUSE_CLIENT_X], - "MouseData.client_y": ["MouseData", E.MOUSE_CLIENT_Y], - "MouseData.page_x": ["MouseData", E.MOUSE_PAGE_X], - "MouseData.page_y": ["MouseData", E.MOUSE_PAGE_Y], - "MouseData.screen_x": ["MouseData", E.MOUSE_SCREEN_X], - "MouseData.screen_y": ["MouseData", E.MOUSE_SCREEN_Y], - "MouseData.offset_x": ["MouseData", E.MOUSE_OFFSET_X], - "MouseData.offset_y": ["MouseData", E.MOUSE_OFFSET_Y], - "MouseData.button": ["MouseData", E.MOUSE_BUTTON], - "MouseData.primary": ["MouseData", E.MOUSE_PRIMARY], - "MouseData.secondary": ["MouseData", E.MOUSE_SECONDARY], - "MouseData.auxiliary": ["MouseData", E.MOUSE_AUXILIARY], - "MouseData.back": ["MouseData", E.MOUSE_BACK], - "MouseData.forward": ["MouseData", E.MOUSE_FORWARD], - "MouseData.modifiers": ["MouseData", E.MOUSE_MODIFIERS], - "Modifiers.alt": ["Modifiers", E.MODIFIERS_ALT], - "Modifiers.ctrl": ["Modifiers", E.MODIFIERS_CTRL], - "Modifiers.meta": ["Modifiers", E.MODIFIERS_META], - "Modifiers.shift": ["Modifiers", E.MODIFIERS_SHIFT], - "KeyboardData.key": ["KeyboardData", E.KEYBOARD_KEY], - "KeyboardData.code": ["KeyboardData", E.KEYBOARD_CODE], - "KeyboardData.location": ["KeyboardData", E.KEYBOARD_LOCATION], - "KeyboardData.repeat": ["KeyboardData", E.KEYBOARD_REPEAT], - "KeyboardData.is_composing": ["KeyboardData", E.KEYBOARD_IS_COMPOSING], - "KeyboardData.modifiers": ["KeyboardData", E.KEYBOARD_MODIFIERS], - "FormData.value": ["FormData", E.FORM_VALUE], - "FormData.checked": ["FormData", E.FORM_CHECKED], - "FormData.fields": ["FormData", E.FORM_FIELDS], - "FormField.name": ["FormField", E.FORM_FIELD_NAME], - "FormField.value": ["FormField", E.FORM_FIELD_VALUE], - "NavigationData.href": ["NavigationData", E.NAVIGATION_HREF], -}; - -const STREAM_MESSAGES = Object.keys( - ALL_STREAM_FIELDS.reduce>((acc, name) => { - acc[STREAM_TABLE[name][0]] = true; - return acc; - }, {}), -) as StreamMessage[]; - -const EVENT_MESSAGES = Object.keys( - ALL_EVENT_FIELDS.reduce>((acc, name) => { - acc[EVENT_TABLE[name][0]] = true; - return acc; - }, {}), -) as EventMessage[]; - -/** `Message` -> field number -> full name, for `PolicyError`'s message. - * Static: every KNOWN field, declared or not. */ -const STREAM_NAMES: Record = {}; -for (const name of ALL_STREAM_FIELDS) { - const [msg, num] = STREAM_TABLE[name]; - (STREAM_NAMES[msg] ??= [])[num] = name; -} + #check(opIndex: number, op: PolicyOp): void { + const reason = this.#policy.check(op); + if (reason !== undefined) throw new PolicyError(opIndex, op, reason); + } -// -- compilation ---------------------------------------------------------- + // -- checked ops -------------------------------------------------------- + + createElement(id: number, tag: number, ns: number | undefined): void { + const opIndex = this.#next(); + const tagName = this.#str(tag); + this.#check(opIndex, { + op: "createElement", + tag: tagName, + ns: this.#ns(ns), + }); + this.#tags.set(id, tagName); + this.#inner.createElement(id, tag, ns); + } -function makeAccept(msg: StreamMessage, mask: number): MessageAccept { - return { - mask, - reject(field: number): never { - throw new PolicyError(STREAM_NAMES[msg]?.[field] ?? `${msg}.${field}`); - }, - }; -} + setAttribute( + id: number, + name: number, + ns: number | undefined, + value: AttrValue | undefined, + ): void { + const opIndex = this.#next(); + this.#check(opIndex, { + op: "setAttribute", + tag: this.#tags.get(id), + name: this.#str(name), + ns: this.#ns(ns), + value, + }); + this.#inner.setAttribute(id, name, ns, value); + } -/** - * Validate and compile a policy. - * - * Every name is checked against the tables above and an unknown one throws - * a plain `Error` naming it: JS consumers get no type check, and a field - * REMOVED from the protocol must fail at construction rather than quietly - * narrowing what the embedder believed it had declared. Duplicates are - * tolerated (spreading two snapshots is a normal thing to do). - */ -export function compilePolicy(p: Policy): CompiledPolicy { - const accept: Record = {}; - const events: Record = {}; - - const streamMasks: Record = {}; - for (const msg of STREAM_MESSAGES) streamMasks[msg] = 0; - // `Object.hasOwn`, not indexing: a JS caller passing "constructor" or - // "__proto__" would otherwise find Object.prototype and not throw. - for (const name of p.accept) { - if (!Object.hasOwn(STREAM_TABLE, name)) { - throw new Error(`stream-dom: policy names unknown stream field ${name}`); + setProperty(id: number, name: number, value: PropertyValue): void { + const opIndex = this.#next(); + this.#check(opIndex, { + op: "setProperty", + tag: this.#tags.get(id), + name: this.#str(name), + value, + }); + this.#inner.setProperty(id, name, value); + } + + addListener(listener: Listener): void { + const opIndex = this.#next(); + this.#check(opIndex, { + op: "addListener", + target: listener.target.kind, + name: this.#str(listener.name), + capture: listener.capture, + passive: listener.passive, + preventDefault: listener.preventDefault, + stopPropagation: listener.stopPropagation, + }); + this.#inner.addListener(listener); + } + + bindMarker(key: number, id: number): void { + const opIndex = this.#next(); + this.#check(opIndex, { op: "bindMarker" }); + this.#inner.bindMarker(key, id); + } + + /** Every element and attribute a template can ever stamp out is named + * ONCE here, at registration: the arena is flattened into synthetic + * `createElement` / `setAttribute` checks (tag known, no id yet). + * `cloneTemplate` therefore re-checks nothing. */ + registerTemplate(id: number, nodes: TemplateNode[], roots: number[]): void { + const opIndex = this.#next(); + const tags: (string | undefined)[] = []; + for (const node of nodes) { + if (node.kind !== "element") { + tags.push(undefined); + continue; + } + const tag = this.#str(node.element.tag); + tags.push(tag); + this.#check(opIndex, { + op: "createElement", + tag, + ns: this.#ns(node.element.ns), + }); + for (const attr of node.element.attrs) { + this.#check(opIndex, { + op: "setAttribute", + tag, + name: this.#str(attr.name), + ns: this.#ns(attr.ns), + value: attr.value, + }); + } } - const entry = STREAM_TABLE[name]; - streamMasks[entry[0]] |= 1 << entry[1]; + this.#templates.set(id, { nodes, roots, tags }); + this.#inner.registerTemplate(id, nodes, roots); } - for (const msg of STREAM_MESSAGES) { - accept[msg] = makeAccept(msg, streamMasks[msg]); + + // -- unchecked ops (bookkeeping only, then forwarded) -------------------- + + internString(id: number, s: string): void { + this.#next(); + this.#strings.set(id, s); + this.#inner.internString(id, s); } - for (const msg of EVENT_MESSAGES) events[msg] = 0; - for (const name of p.events) { - if (!Object.hasOwn(EVENT_TABLE, name)) { - throw new Error(`stream-dom: policy names unknown event field ${name}`); + cloneTemplate(tmpl: number, root: number, id: number): void { + this.#next(); + const template = this.#templates.get(tmpl); + // An unknown template or out-of-range root ordinal is the backend's + // error to raise (it does, with its own message); this sink just has + // no site to record. + const index = template?.roots[root]; + if (template && index !== undefined) { + this.#record(id, { tmpl, index }, template); } - const entry = EVENT_TABLE[name]; - events[entry[0]] |= 1 << entry[1]; + this.#inner.cloneTemplate(tmpl, root, id); } - const queries = new Set(); - for (const q of p.queries) { - if (!ALL_QUERIES.includes(q)) { - throw new Error(`stream-dom: policy names unknown query ${q}`); + bindPath(root: number, path: Uint8Array, id: number): void { + this.#next(); + const site = this.#sites.get(root); + const template = site && this.#templates.get(site.tmpl); + if (site && template) { + let index: number | undefined = site.index; + for (const step of path) { + const node: TemplateNode | undefined = template.nodes[index!]; + index = node?.kind === "element" + ? node.element.children[step] + : undefined; + if (index === undefined) break; + } + if (index !== undefined) { + this.#record(id, { tmpl: site.tmpl, index }, template); + } } - queries.add(q); + this.#inner.bindPath(root, path, id); } - return { - accept: accept as AcceptSet, - events: events as EventFieldSet, - queries, - }; -} + /** Bind `id` to an arena node, taking its tag from the template's + * registration-time table (see `RegisteredTemplate.tags`) rather than + * re-resolving the ref now. */ + #record(id: number, site: ArenaSite, template: RegisteredTemplate): void { + this.#sites.set(id, site); + const tag = template.tags[site.index]; + if (tag !== undefined) this.#tags.set(id, tag); + } -/** Query gate: undeclared -> refuse. `undefined` means "no policy", which - * is the unrestricted default (`mount` without `policy`). */ -export function queryAllowed( - compiled: CompiledPolicy | undefined, - query: Query, -): boolean { - return compiled === undefined || compiled.queries.has(query); + createText(id: number, text: string): void { + this.#next(); + this.#inner.createText(id, text); + } + + createPlaceholder(id: number): void { + this.#next(); + this.#inner.createPlaceholder(id); + } + + insertBefore( + parent: number | undefined, + id: number, + anchor: number | undefined, + ): void { + this.#next(); + this.#inner.insertBefore(parent, id, anchor); + } + + insertAfter(parent: number | undefined, id: number, anchor: number): void { + this.#next(); + this.#inner.insertAfter(parent, id, anchor); + } + + remove(id: number): void { + this.#next(); + this.#inner.remove(id); + } + + setText(id: number, text: string): void { + this.#next(); + this.#inner.setText(id, text); + } + + removeListener(listener: Listener): void { + this.#next(); + this.#inner.removeListener(listener); + } + + commit(): void { + this.#inner.commit(); + } } diff --git a/receiver/src/remote.ts b/receiver/src/remote.ts index 6cc9b1d..7dc1454 100644 --- a/receiver/src/remote.ts +++ b/receiver/src/remote.ts @@ -26,6 +26,7 @@ import type { import { DOMRemoteReceiver } from "@remote-dom/core/receivers"; import type { + AttrValue, FrameSink, Listener, PropertyValue, @@ -66,6 +67,24 @@ interface ShadowNode { interface Template { nodes: TemplateNode[]; roots: number[]; + /** Every string an element node in the arena names, resolved ONCE at + * `registerTemplate` time, per arena node index (`undefined` for + * non-element nodes). Interned slots can be overwritten (proto `Intern`: + * "Define (or overwrite) interned slot"), so resolving these per clone + * instead would let a re-intern between registration and clone stamp out + * a different element than the one registered — and under a policy, a + * different one than the policy approved (`PolicySink` judges templates + * with the strings current at registration). Asset handles resolve here + * too: once per template, not once per clone. */ + resolved: (ResolvedElement | undefined)[]; +} + +/** One arena element node with all of its refs already resolved. */ +interface ResolvedElement { + tag: string; + ns: string | undefined; + /** `[name, value]` in the node's own `attrs` order. */ + attrs: [string, string][]; } function rootShadow(): ShadowNode { @@ -126,6 +145,7 @@ export class RemoteDomTranscoder implements Receiver, FrameSink { #byProducerId = new Map(); #templates = new Map(); #ridCounter = 0; + #resolveAsset: ((handle: Uint8Array) => string) | undefined; #records: RemoteMutationRecord[] = []; readonly listeners: ListenerRegistry = new ListenerRegistry(); onCommit: (() => void) | null = null; @@ -133,9 +153,11 @@ export class RemoteDomTranscoder implements Receiver, FrameSink { constructor( connection: RemoteConnection, domReceiver: DOMRemoteReceiver | null = null, + resolveAsset?: (handle: Uint8Array) => string, ) { this.#connection = connection; this.#domReceiver = domReceiver; + this.#resolveAsset = resolveAsset; this.#bind(0, rootShadow()); } @@ -182,6 +204,18 @@ export class RemoteDomTranscoder implements Receiver, FrameSink { return this.listeners.stringFor(ref); } + /** The string an attribute value sets: a literal, or the URL the host's + * `resolveAsset` hook returns for an asset handle. */ + #attrText(value: AttrValue): string { + if (value.kind === "text") return value.value; + if (!this.#resolveAsset) { + throw new Error( + "stream-dom: asset attribute value but no resolveAsset configured", + ); + } + return this.#resolveAsset(value.handle); + } + /** The remote id (`RemoteMutationRecord`/`DOMRemoteReceiver` id space) * for a producer node id — exposed for tests, which assert on it * directly rather than reaching into the real DOM. */ @@ -524,7 +558,7 @@ export class RemoteDomTranscoder implements Receiver, FrameSink { id: number, name: number, ns: number | undefined, - value: string | undefined, + value: AttrValue | undefined, ): void { // remote-dom has no setAttributeNS equivalent (UPDATE_PROPERTY_TYPE_ATTRIBUTE // -> plain `setAttribute`/`removeAttribute` in DOMRemoteReceiver.ts). @@ -534,14 +568,15 @@ export class RemoteDomTranscoder implements Receiver, FrameSink { void ns; const attrName = this.#str(name); const node = this.#resolve(id); - if (value === undefined) node.attrs.delete(attrName); - else node.attrs.set(attrName, value); + const text = value === undefined ? undefined : this.#attrText(value); + if (text === undefined) node.attrs.delete(attrName); + else node.attrs.set(attrName, text); if (node.attached) { this.#records.push([ MUTATION_TYPE_UPDATE_PROPERTY, node.rid, attrName, - value ?? null, + text ?? null, UPDATE_PROPERTY_TYPE_ATTRIBUTE, ]); } @@ -582,7 +617,18 @@ export class RemoteDomTranscoder implements Receiver, FrameSink { registerTemplate(id: number, nodes: TemplateNode[], roots: number[]): void { validateTemplateArena(id, nodes, roots); - this.#templates.set(id, { nodes, roots }); + const resolved = nodes.map((n) => + n.kind === "element" + ? { + tag: this.#str(n.element.tag), + ns: n.element.ns === undefined ? undefined : this.#str(n.element.ns), + attrs: n.element.attrs.map((a) => + [this.#str(a.name), this.#attrText(a.value)] as [string, string] + ), + } + : undefined + ); + this.#templates.set(id, { nodes, roots, resolved }); } cloneTemplate(tmpl: number, root: number, id: number): void { @@ -636,14 +682,15 @@ export class RemoteDomTranscoder implements Receiver, FrameSink { ids: [], }; } - const attrs = new Map(); - for (const a of n.element.attrs) attrs.set(this.#str(a.name), a.value); + // Strings come from the registration-time table, never re-resolved + // here — see `Template.resolved`. + const el = template.resolved[idx]!; const shadow: ShadowNode = { rid: this.#nextRid(), kind: "element", - tag: this.#str(n.element.tag), - ns: n.element.ns === undefined ? undefined : this.#str(n.element.ns), - attrs, + tag: el.tag, + ns: el.ns, + attrs: new Map(el.attrs), props: new Map(), text: "", parent: null, @@ -694,7 +741,10 @@ export class RemoteDomTranscoder implements Receiver, FrameSink { * `DOMRemoteReceiver` over `root`. Unit tests construct * `RemoteDomTranscoder` directly with a fake `RemoteConnection` instead * (see the class doc) — this factory is what `mount.ts` calls. */ -export function createRemoteReceiver(root: Element): RemoteDomTranscoder { +export function createRemoteReceiver( + root: Element, + resolveAsset?: (handle: Uint8Array) => string, +): RemoteDomTranscoder { const domReceiver = new DOMRemoteReceiver({ root, call: (element, method, ...args) => @@ -703,5 +753,9 @@ export function createRemoteReceiver(root: Element): RemoteDomTranscoder { : (element as unknown as Record unknown>) [method](...args), }); - return new RemoteDomTranscoder(domReceiver.connection, domReceiver); + return new RemoteDomTranscoder( + domReceiver.connection, + domReceiver, + resolveAsset, + ); } diff --git a/receiver/tests/driver_test.ts b/receiver/tests/driver_test.ts index f92e50d..43d58eb 100644 --- a/receiver/tests/driver_test.ts +++ b/receiver/tests/driver_test.ts @@ -8,10 +8,15 @@ import { assertEquals, assertThrows } from "@std/assert"; import { parseHTML } from "linkedom"; import { createDriver } from "../src/driver.ts"; import type { ProducerEventTarget } from "../src/driver.ts"; +import { PROTOCOL_VERSION } from "../src/frames.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"; + +/** A policy that allows every op — the baseline these tests vary from. */ +function allowAll(extra?: Partial): Policy { + return { version: PROTOCOL_VERSION, check: () => undefined, ...extra }; +} // -- harness ---------------------------------------------------------------- @@ -245,39 +250,7 @@ Deno.test("Driver: a bubbling click listener fires handleEvent with the right ta 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); + assertEquals(payload.length > 0, true); // click -> mouse payload withGlobalWindow(win, () => driver.dispose()); }); @@ -310,92 +283,72 @@ Deno.test("Driver: prevent_default is honored even though handleEvent does nothi // -- 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`. +Deno.test("Driver: policy.query gates individual queries; absent means allow", async () => { + // A policy with no `query` method allows every import (policy.ts: + // "Absent means allow"), so a refusal needs an EXPLICIT refusing hook. const { win, root } = fixture(); - const refusing: Policy = { - accept: SURFACE_V1.accept, - events: SURFACE_V1.events, - queries: [], - }; - const noPolicy = createDriver({ + const gated = createDriver({ root, - policy: refusing, + policy: allowAll({ query: (name) => name !== "set-focus" }), 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(gated, concat(basicFrames())); + assertEquals(gated.queries.setFocus(10, true), false); + // ...while a query the same policy does not refuse still answers. + assertEquals(gated.queries.getClientRect(10), { + origin: { x: 0, y: 0 }, + size: { width: 0, height: 0 }, }); - 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()); + withGlobalWindow(win, () => gated.dispose()); const { win: win2, root: root2 } = fixture(); - const policy: Policy = { - accept: SURFACE_V1.accept, - events: SURFACE_V1.events, - queries: ["set-focus"], - }; - const withPolicy = createDriver({ + const open = createDriver({ root: root2, - policy, + policy: allowAll(), handleEvent: () => {}, }); - await pushAndAwaitCommit(withPolicy, concat(basicFrames())); - assertEquals(withPolicy.queries.setFocus(10, true), true); - withGlobalWindow(win2, () => withPolicy.dispose()); + await pushAndAwaitCommit(open, concat(basicFrames())); + assertEquals(open.queries.setFocus(10, true), true); + assertEquals(open.queries.getClientRect(10), { + origin: { x: 0, y: 0 }, + size: { width: 0, height: 0 }, + }); + withGlobalWindow(win2, () => open.dispose()); }); // -- 5. abort semantics --------------------------------------------------------- -Deno.test("Driver: a policy violation (undeclared field) throws PolicyError", () => { +Deno.test("Driver: a policy rejection throws PolicyError out of push", () => { 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 policy = allowAll({ + check: (op) => + op.op === "createElement" + ? "div is not in the host vocabulary" + : undefined, + }); const driver = createDriver({ root, policy, handleEvent: () => {} }); assertThrows( () => driver.push(concat(basicFrames())), PolicyError, + "div is not in the host vocabulary", ); withGlobalWindow(win, () => driver.dispose()); }); +Deno.test("Driver: a policy pinning another protocol version is refused at construction", () => { + const { root } = fixture(); + assertThrows( + () => + createDriver({ + root, + policy: { version: PROTOCOL_VERSION + 1, check: () => undefined }, + handleEvent: () => {}, + }), + Error, + `policy pins protocol version ${PROTOCOL_VERSION + 1}`, + ); +}); + Deno.test("Driver: an unknown node id throws", () => { const { win, root } = fixture(); const driver = createDriver({ root, handleEvent: () => {} }); diff --git a/receiver/tests/frames_test.ts b/receiver/tests/frames_test.ts index 390bb7d..09ad3b5 100644 --- a/receiver/tests/frames_test.ts +++ b/receiver/tests/frames_test.ts @@ -2,6 +2,7 @@ import { assertEquals, assertThrows } from "@std/assert"; import { FrameDecoder } from "../src/frames.ts"; import { Writer } from "../src/proto.ts"; import type { + AttrValue, FrameSink, Listener, PropertyValue, @@ -32,7 +33,7 @@ type Call = id: number; name: number; ns: number | undefined; - value: string | undefined; + value: AttrValue | undefined; } | { op: "setProperty"; id: number; name: number; value: PropertyValue } | { op: "addListener"; listener: Listener } @@ -82,7 +83,7 @@ class RecordingSink implements FrameSink { id: number, name: number, ns: number | undefined, - value: string | undefined, + value: AttrValue | undefined, ): void { this.calls.push({ op: "setAttribute", id, name, ns, value }); } @@ -179,7 +180,7 @@ Deno.test("FrameDecoder decodes basic.pb into basic.txt's 17 frames", async () = id: 1, name: 3, ns: undefined, - value: "greeting", + value: { kind: "text", value: "greeting" }, }); assertEquals(calls[13], { op: "addListener", diff --git a/receiver/tests/hostile_test.ts b/receiver/tests/hostile_test.ts index b8df31c..bac2ce0 100644 --- a/receiver/tests/hostile_test.ts +++ b/receiver/tests/hostile_test.ts @@ -36,6 +36,16 @@ function prng(seed: number): () => number { * 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. */ +/** Is `node` `parent` itself, or one of its ancestors? Takes `parent` as + * an argument rather than walking from `this` inside the patched method: + * `deno lint`'s no-this-alias forbids binding `this` to a local. */ +function containsOrIs(parent: Node, node: Node): boolean { + for (let p: Node | null = parent; p !== null; p = p.parentNode) { + if (p === node) return true; + } + return false; +} + const guarded = new WeakSet(); function installHierarchyGuard(doc: Document): void { let proto: object | null = Object.getPrototypeOf(doc.createElement("div")); @@ -49,13 +59,11 @@ function installHierarchyGuard(doc: Document): void { 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", - ); - } + if (containsOrIs(this, node)) { + throw new DOMException( + `${name}: the node is an ancestor of the parent`, + "HierarchyRequestError", + ); } return call.call(this, node, ...rest); }; diff --git a/receiver/tests/native_test.ts b/receiver/tests/native_test.ts index e211472..dfa492c 100644 --- a/receiver/tests/native_test.ts +++ b/receiver/tests/native_test.ts @@ -23,6 +23,16 @@ import type { TemplateNode } from "../src/frames.ts"; * browser's guarantee. This is emulating a browser invariant, not testing * receiver code. */ +/** Is `node` `parent` itself, or one of its ancestors? Takes `parent` as + * an argument rather than walking from `this` inside the patched method: + * `deno lint`'s no-this-alias forbids binding `this` to a local. */ +function containsOrIs(parent: Node, node: Node): boolean { + for (let p: Node | null = parent; p !== null; p = p.parentNode) { + if (p === node) return true; + } + return false; +} + const guarded = new WeakSet(); function installHierarchyGuard(doc: Document): void { // linkedom mixes `insertBefore`/`appendChild` into several prototypes in @@ -40,13 +50,11 @@ function installHierarchyGuard(doc: Document): void { 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", - ); - } + if (containsOrIs(this, node)) { + throw new DOMException( + `${name}: the node is an ancestor of the parent`, + "HierarchyRequestError", + ); } return call.call(this, node, ...rest); }; @@ -101,7 +109,7 @@ Deno.test("NativeDomReceiver: every op on the happy path", () => { recv.insertBefore(1, 2, undefined); recv.insertAfter(1, 3, 2); recv.setText(2, "hello, world"); - recv.setAttribute(1, CLASS, undefined, "greeting"); + recv.setAttribute(1, CLASS, undefined, { kind: "text", value: "greeting" }); recv.setProperty(1, CLASS, { kind: "text", value: "prop" }); recv.addListener({ target: { kind: "node", id: 1 }, @@ -130,7 +138,11 @@ Deno.test("NativeDomReceiver: every op on the happy path", () => { element: { tag: DIV, ns: undefined, - attrs: [{ name: CLASS, ns: undefined, value: "row" }], + attrs: [{ + name: CLASS, + ns: undefined, + value: { kind: "text", value: "row" }, + }], children: [1, 2], }, }, @@ -237,7 +249,7 @@ Deno.test("NativeDomReceiver: the mount root may not be used as an insert anchor Deno.test("NativeDomReceiver: leaf ops on the root stay legal", () => { const { recv, root } = fixture(); interned(recv); - recv.setAttribute(0, CLASS, undefined, "mounted"); + recv.setAttribute(0, CLASS, undefined, { kind: "text", value: "mounted" }); assertEquals(root.getAttribute("class"), "mounted"); recv.setProperty(0, CLASS, { kind: "boolean", value: true }); recv.setAttribute(0, CLASS, undefined, undefined); @@ -324,7 +336,7 @@ Deno.test("NativeDomReceiver: set-attribute / set-property require an element", recv.createText(2, "a"); assertThrows( - () => recv.setAttribute(2, CLASS, undefined, "x"), + () => recv.setAttribute(2, CLASS, undefined, { kind: "text", value: "x" }), Error, "set-attribute target 2 is not an element", ); @@ -357,7 +369,7 @@ Deno.test("NativeDomReceiver: an unresolved string ref throws", () => { "string ref 77", ); assertThrows( - () => recv.setAttribute(0, 77, undefined, "x"), + () => recv.setAttribute(0, 77, undefined, { kind: "text", value: "x" }), Error, "string ref 77", ); @@ -394,6 +406,105 @@ Deno.test("NativeDomReceiver: re-interning a live slot is legal (proto Intern: ' assertEquals(root.innerHTML, "
"); }); +// -- 5. asset attribute values -------------------------------------------- + +/** Like `fixture()`, but with a `resolveAsset` hook — the receiver turns + * an opaque asset handle into a URL through it (proto `SetAttribute.asset`: + * "the producer never names a URL"). */ +function assetFixture(resolveAsset?: (handle: Uint8Array) => string) { + const win = parseHTML( + `
`, + ); + const doc = win.document as unknown as Document; + const root = doc.getElementById("root")!; + return { root, recv: new NativeDomReceiver(root, resolveAsset) }; +} + +Deno.test("NativeDomReceiver: an asset attribute value is resolved through the hook", () => { + const seen: Uint8Array[] = []; + const { root, recv } = assetFixture((handle) => { + seen.push(handle); + return `/assets/${handle.join("-")}`; + }); + recv.internString(DIV, "div"); + recv.internString(CLASS, "src"); + recv.createElement(1, DIV, undefined); + recv.insertBefore(0, 1, undefined); + recv.setAttribute(1, CLASS, undefined, { + kind: "asset", + handle: Uint8Array.of(1, 2), + }); + + assertEquals(seen, [Uint8Array.of(1, 2)]); + assertEquals( + (recv.resolveNode(1) as Element).getAttribute("src"), + "/assets/1-2", + ); + assertEquals(root.children.length, 1); +}); + +Deno.test("NativeDomReceiver: a template attr asset resolves at registration", () => { + const { recv } = assetFixture((handle) => `/assets/${handle.join("-")}`); + recv.internString(DIV, "div"); + recv.internString(CLASS, "src"); + recv.registerTemplate(7, [{ + kind: "element", + element: { + tag: DIV, + ns: undefined, + attrs: [{ + name: CLASS, + ns: undefined, + value: { kind: "asset", handle: Uint8Array.of(9) }, + }], + children: [], + }, + }], [0]); + recv.cloneTemplate(7, 0, 20); + assertEquals( + (recv.resolveNode(20) as Element).getAttribute("src"), + "/assets/9", + ); +}); + +Deno.test("NativeDomReceiver: an asset value with no resolveAsset configured throws", () => { + const { recv } = assetFixture(); + recv.internString(DIV, "div"); + recv.internString(CLASS, "src"); + recv.createElement(1, DIV, undefined); + const message = + "stream-dom: asset attribute value but no resolveAsset configured"; + + assertThrows( + () => + recv.setAttribute(1, CLASS, undefined, { + kind: "asset", + handle: Uint8Array.of(7), + }), + Error, + message, + ); + // Template attrs resolve at registration, so the same error lands there. + assertThrows( + () => + recv.registerTemplate(8, [{ + kind: "element", + element: { + tag: DIV, + ns: undefined, + attrs: [{ + name: CLASS, + ns: undefined, + value: { kind: "asset", handle: Uint8Array.of(7) }, + }], + children: [], + }, + }], [0]), + Error, + message, + ); +}); + // -- 6. insert / remove sanity -------------------------------------------- Deno.test("NativeDomReceiver: a node cannot be inserted into itself or its own subtree", () => { diff --git a/receiver/tests/policy_test.ts b/receiver/tests/policy_test.ts index 74a9854..f17d8a6 100644 --- a/receiver/tests/policy_test.ts +++ b/receiver/tests/policy_test.ts @@ -1,99 +1,99 @@ -// Policy: the fail-safe property, tested field by field. -// -// The central test is per-field gating: for EVERY name in -// `ALL_STREAM_FIELDS`, the same bytes decode under the full snapshot and -// are rejected under the snapshot minus that one name. Field numbers below -// are transcribed independently from proto/stream-dom.proto and -// proto/stream-dom-events.proto (the normative files) rather than imported -// from the source under test, so a wrong number in src/ shows up here. +// Policy MVP: the protocol version constant, strict decoding, the +// `PolicySink` vocabulary seam, and the asset attribute-value arm. +// Governing docs: proto/stream-dom.proto (field numbers, the +// `// PROTOCOL VERSION` header line), docs/design.md "Policy". import { assertEquals, assertThrows } from "@std/assert"; -import { FrameDecoder } from "../src/frames.ts"; +import { FrameDecoder, PROTOCOL_VERSION } from "../src/frames.ts"; import type { + AttrValue, FrameSink, Listener, PropertyValue, TemplateNode, } from "../src/frames.ts"; -import { encodePayload } from "../src/events.ts"; -import { Reader, Writer } from "../src/proto.ts"; -import { - ALL_EVENT_FIELDS, - ALL_QUERIES, - ALL_STREAM_FIELDS, - compilePolicy, - PolicyError, - queryAllowed, - SURFACE_V1, -} from "../src/policy.ts"; -import type { EventField, Policy, Query, StreamField } from "../src/policy.ts"; - -// -- harness -------------------------------------------------------------- - -class RecordingSink implements FrameSink { - calls: string[] = []; - #rec(op: string, ...args: unknown[]): void { - this.calls.push(op + " " + JSON.stringify(args)); - } - internString(id: number, s: string) { - this.#rec("internString", id, s); - } - createElement(id: number, tag: number, ns: number | undefined) { - this.#rec("createElement", id, tag, ns); +import { assertPolicyVersion, PolicyError, PolicySink } from "../src/policy.ts"; +import type { Policy, PolicyOp } from "../src/policy.ts"; +import { Writer } from "../src/proto.ts"; +import { RemoteDomTranscoder } from "../src/remote.ts"; +import type { RemoteConnection, RemoteMutationRecord } from "@remote-dom/core"; + +// -- harness ------------------------------------------------------------- + +type Call = + | { op: "internString"; id: number; s: string } + | { op: "createElement"; id: number; tag: number; ns: number | undefined } + | { op: "createText"; id: number; text: string } + | { op: "createPlaceholder"; id: number } + | { + op: "setAttribute"; + id: number; + name: number; + ns: number | undefined; + value: AttrValue | undefined; } - createText(id: number, text: string) { - this.#rec("createText", id, text); + | { op: "setProperty"; id: number; name: number; value: PropertyValue } + | { op: "addListener"; listener: Listener } + | { + op: "registerTemplate"; + id: number; + nodes: TemplateNode[]; + roots: number[]; } - createPlaceholder(id: number) { - this.#rec("createPlaceholder", id); - } - insertBefore( - parent: number | undefined, - id: number, - anchor: number | undefined, - ) { - this.#rec("insertBefore", parent, id, anchor); + | { op: "cloneTemplate"; tmpl: number; root: number; id: number } + | { op: "bindPath"; root: number; path: Uint8Array; id: number } + | { op: "bindMarker"; key: number; id: number } + | { op: "commit" }; + +/** Records the calls that reach the INNER sink — same pattern as + * frames_test.ts's, trimmed to the ops these tests drive. */ +class RecordingSink implements FrameSink { + calls: Call[] = []; + internString(id: number, s: string): void { + this.calls.push({ op: "internString", id, s }); } - insertAfter(parent: number | undefined, id: number, anchor: number) { - this.#rec("insertAfter", parent, id, anchor); + createElement(id: number, tag: number, ns: number | undefined): void { + this.calls.push({ op: "createElement", id, tag, ns }); } - remove(id: number) { - this.#rec("remove", id); + createText(id: number, text: string): void { + this.calls.push({ op: "createText", id, text }); } - setText(id: number, text: string) { - this.#rec("setText", id, text); + createPlaceholder(id: number): void { + this.calls.push({ op: "createPlaceholder", id }); } + insertBefore(): void {} + insertAfter(): void {} + remove(): void {} + setText(): void {} setAttribute( id: number, name: number, ns: number | undefined, - value: string | undefined, - ) { - this.#rec("setAttribute", id, name, ns, value); + value: AttrValue | undefined, + ): void { + this.calls.push({ op: "setAttribute", id, name, ns, value }); } - setProperty(id: number, name: number, value: PropertyValue) { - this.#rec("setProperty", id, name, value); + setProperty(id: number, name: number, value: PropertyValue): void { + this.calls.push({ op: "setProperty", id, name, value }); } - addListener(l: Listener) { - this.#rec("addListener", l); + addListener(listener: Listener): void { + this.calls.push({ op: "addListener", listener }); } - removeListener(l: Listener) { - this.#rec("removeListener", l); + removeListener(): void {} + registerTemplate(id: number, nodes: TemplateNode[], roots: number[]): void { + this.calls.push({ op: "registerTemplate", id, nodes, roots }); } - registerTemplate(id: number, nodes: TemplateNode[], roots: number[]) { - this.#rec("registerTemplate", id, nodes, roots); + cloneTemplate(tmpl: number, root: number, id: number): void { + this.calls.push({ op: "cloneTemplate", tmpl, root, id }); } - cloneTemplate(tmpl: number, root: number, id: number) { - this.#rec("cloneTemplate", tmpl, root, id); + bindPath(root: number, path: Uint8Array, id: number): void { + this.calls.push({ op: "bindPath", root, path, id }); } - bindPath(root: number, path: Uint8Array, id: number) { - this.#rec("bindPath", root, Array.from(path), id); + bindMarker(key: number, id: number): void { + this.calls.push({ op: "bindMarker", key, id }); } - bindMarker(key: number, id: number) { - this.#rec("bindMarker", key, id); - } - commit() { - this.#rec("commit"); + commit(): void { + this.calls.push({ op: "commit" }); } } @@ -111,630 +111,463 @@ function frame(build: (w: Writer) => void): Uint8Array { return framed; } -/** A frame carrying one op (`Frame`'s oneof case `field`). */ -function op(field: number, build: (w: Writer) => void): Uint8Array { - return frame((w) => w.writeMessage(field, build)); -} - -function policyWithout(name: StreamField): Policy { +/** A policy that records what it saw and rejects whatever `reject` says. */ +function recordingPolicy( + reject: (op: PolicyOp) => string | undefined = () => undefined, +): { policy: Policy; seen: PolicyOp[] } { + const seen: PolicyOp[] = []; return { - accept: SURFACE_V1.accept.filter((n) => n !== name), - events: SURFACE_V1.events, - queries: SURFACE_V1.queries, + seen, + policy: { + version: PROTOCOL_VERSION, + check(op) { + seen.push(op); + return reject(op); + }, + }, }; } -function decodeStrict(bytes: Uint8Array, p: Policy): RecordingSink { - const sink = new RecordingSink(); - const decoder = new FrameDecoder(sink, { accept: compilePolicy(p).accept }); - decoder.push(bytes); - return sink; -} +const listener = (name: number, target: Listener["target"]): Listener => ({ + target, + name, + bubbles: true, + capture: true, + passive: false, + preventDefault: true, + stopPropagation: false, +}); -// -- stream fixtures ------------------------------------------------------ -// -// One fixture per name in `ALL_STREAM_FIELDS`: bytes that set exactly that -// field (plus whatever context the field needs to be reachable — the op -// wrapper, and a `Listener` target where the proto requires one). - -// Frame's oneof `op` case numbers (proto/stream-dom.proto). -const F_COMMIT = 1; -const F_INSERT_BEFORE = 2; -const F_SET_TEXT = 3; -const F_SET_ATTRIBUTE = 4; -const F_SET_PROPERTY = 5; -const F_CREATE_ELEMENT = 6; -const F_CREATE_TEXT = 7; -const F_REMOVE = 8; -const F_CLONE_TEMPLATE = 9; -const F_BIND_PATH = 10; -const F_CREATE_PLACEHOLDER = 11; -const F_ADD_LISTENER = 12; -const F_REMOVE_LISTENER = 13; -const F_INTERN = 14; -const F_REGISTER_TEMPLATE = 15; -const F_INSERT_AFTER = 16; -const F_BIND_MARKER = 17; - -/** `AddListener.listener` -> `Listener`, built by `build`. */ -function listenerFrame( - opField: number, - build: (l: Writer) => void, -): Uint8Array { - return op(opField, (w) => w.writeMessage(1, build)); -} +// -- PROTOCOL_VERSION ---------------------------------------------------- -/** `RegisterTemplate.nodes[0]` -> `TemplateNode`, built by `build`. */ -function templateNodeFrame(build: (n: Writer) => void): Uint8Array { - return op(F_REGISTER_TEMPLATE, (rt) => rt.writeMessage(2, build)); -} +Deno.test("PROTOCOL_VERSION matches proto/stream-dom.proto's header line", async () => { + const proto = await Deno.readTextFile( + new URL("../../proto/stream-dom.proto", import.meta.url), + ); + const matches = [...proto.matchAll(/^\/\/ PROTOCOL VERSION: (\d+)$/gm)]; + assertEquals(matches.length, 1); // machine-read: exactly one, exact form + assertEquals(Number(matches[0][1]), PROTOCOL_VERSION); +}); -/** ...-> `TemplateNode.element` -> `TemplateElement`. */ -function templateElementFrame(build: (e: Writer) => void): Uint8Array { - return templateNodeFrame((n) => n.writeMessage(1, build)); -} +// -- strict decoding ----------------------------------------------------- -/** ...-> `TemplateElement.attrs[0]` -> `TemplateAttr`. */ -function templateAttrFrame(build: (a: Writer) => void): Uint8Array { - return templateElementFrame((e) => e.writeMessage(3, build)); -} +Deno.test("strict decoder rejects an unknown Frame op field; open decoder skips it", () => { + // Field 30 is not an op this receiver knows (Frame's ops stop at 17). + const bytes = frame((w) => { + w.writeBool(1, true); // Frame.commit + w.writeMessage(30, (unknown) => unknown.writeUint32(1, 5)); + }); -const FIXTURES: { [K in StreamField]: () => Uint8Array } = { - "Frame.commit": () => frame((w) => w.writeBool(F_COMMIT, true)), - "Frame.insert_before": () => op(F_INSERT_BEFORE, () => {}), - "Frame.set_text": () => op(F_SET_TEXT, () => {}), - "Frame.set_attribute": () => op(F_SET_ATTRIBUTE, () => {}), - "Frame.set_property": () => op(F_SET_PROPERTY, () => {}), - "Frame.create_element": () => op(F_CREATE_ELEMENT, () => {}), - "Frame.create_text": () => op(F_CREATE_TEXT, () => {}), - "Frame.remove": () => op(F_REMOVE, () => {}), - "Frame.clone_template": () => op(F_CLONE_TEMPLATE, () => {}), - "Frame.bind_path": () => op(F_BIND_PATH, () => {}), - "Frame.create_placeholder": () => op(F_CREATE_PLACEHOLDER, () => {}), - "Frame.add_listener": () => op(F_ADD_LISTENER, () => {}), - "Frame.remove_listener": () => op(F_REMOVE_LISTENER, () => {}), - "Frame.intern": () => op(F_INTERN, () => {}), - "Frame.register_template": () => op(F_REGISTER_TEMPLATE, () => {}), - "Frame.insert_after": () => op(F_INSERT_AFTER, () => {}), - "Frame.bind_marker": () => op(F_BIND_MARKER, () => {}), - - "Intern.id": () => op(F_INTERN, (m) => m.writeUint32(1, 7)), - "Intern.s": () => op(F_INTERN, (m) => m.writeString(2, "div")), - - "CreateElement.id": () => op(F_CREATE_ELEMENT, (m) => m.writeUint32(1, 1)), - "CreateElement.tag": () => op(F_CREATE_ELEMENT, (m) => m.writeUint32(2, 1)), - "CreateElement.ns": () => op(F_CREATE_ELEMENT, (m) => m.writeUint32(3, 2)), - - "CreateText.id": () => op(F_CREATE_TEXT, (m) => m.writeUint32(1, 1)), - "CreateText.text": () => op(F_CREATE_TEXT, (m) => m.writeString(2, "hi")), - - "CreatePlaceholder.id": () => - op(F_CREATE_PLACEHOLDER, (m) => m.writeUint32(1, 1)), - - "InsertBefore.parent": () => op(F_INSERT_BEFORE, (m) => m.writeUint32(1, 0)), - "InsertBefore.id": () => op(F_INSERT_BEFORE, (m) => m.writeUint32(2, 1)), - "InsertBefore.anchor": () => op(F_INSERT_BEFORE, (m) => m.writeUint32(3, 2)), - - "InsertAfter.parent": () => op(F_INSERT_AFTER, (m) => m.writeUint32(1, 0)), - "InsertAfter.id": () => op(F_INSERT_AFTER, (m) => m.writeUint32(2, 1)), - "InsertAfter.anchor": () => op(F_INSERT_AFTER, (m) => m.writeUint32(3, 2)), - - "Remove.id": () => op(F_REMOVE, (m) => m.writeUint32(1, 1)), - - "SetText.id": () => op(F_SET_TEXT, (m) => m.writeUint32(1, 1)), - "SetText.text": () => op(F_SET_TEXT, (m) => m.writeString(2, "hi")), - - "SetAttribute.id": () => op(F_SET_ATTRIBUTE, (m) => m.writeUint32(1, 1)), - "SetAttribute.name": () => op(F_SET_ATTRIBUTE, (m) => m.writeUint32(2, 3)), - "SetAttribute.ns": () => op(F_SET_ATTRIBUTE, (m) => m.writeUint32(3, 4)), - "SetAttribute.value": () => - op(F_SET_ATTRIBUTE, (m) => m.writeString(4, "greeting")), - - "SetProperty.id": () => op(F_SET_PROPERTY, (m) => m.writeUint32(1, 1)), - "SetProperty.name": () => op(F_SET_PROPERTY, (m) => m.writeUint32(2, 3)), - "SetProperty.text": () => op(F_SET_PROPERTY, (m) => m.writeString(3, "v")), - "SetProperty.int": () => op(F_SET_PROPERTY, (m) => m.writeSInt32Field(4, -3)), - "SetProperty.float": () => op(F_SET_PROPERTY, (m) => m.writeDouble(5, 1.5)), - "SetProperty.boolean": () => op(F_SET_PROPERTY, (m) => m.writeBool(6, true)), - - "Listener.id": () => - listenerFrame(F_ADD_LISTENER, (l) => l.writeUint32(1, 5)), - "Listener.name": () => - listenerFrame(F_ADD_LISTENER, (l) => { - l.writeUint32(1, 5); - l.writeUint32(2, 9); - }), - "Listener.bubbles": () => - listenerFrame(F_ADD_LISTENER, (l) => { - l.writeUint32(1, 5); - l.writeBool(3, true); - }), - "Listener.capture": () => - listenerFrame(F_ADD_LISTENER, (l) => { - l.writeUint32(1, 5); - l.writeBool(4, true); - }), - "Listener.passive": () => - listenerFrame(F_ADD_LISTENER, (l) => { - l.writeUint32(1, 5); - l.writeBool(5, true); - }), - "Listener.prevent_default": () => - listenerFrame(F_ADD_LISTENER, (l) => { - l.writeUint32(1, 5); - l.writeBool(6, true); - }), - "Listener.stop_propagation": () => - listenerFrame(F_ADD_LISTENER, (l) => { - l.writeUint32(1, 5); - l.writeBool(7, true); - }), - "Listener.global": () => - listenerFrame(F_ADD_LISTENER, (l) => l.writeUint32(8, 0)), - - "AddListener.listener": () => - listenerFrame(F_ADD_LISTENER, (l) => l.writeUint32(1, 5)), - "RemoveListener.listener": () => - listenerFrame(F_REMOVE_LISTENER, (l) => l.writeUint32(1, 5)), - - "TemplateAttr.name": () => templateAttrFrame((a) => a.writeUint32(1, 3)), - "TemplateAttr.ns": () => templateAttrFrame((a) => a.writeUint32(2, 4)), - "TemplateAttr.value": () => templateAttrFrame((a) => a.writeString(3, "x")), - - "TemplateElement.tag": () => templateElementFrame((e) => e.writeUint32(1, 1)), - "TemplateElement.ns": () => templateElementFrame((e) => e.writeUint32(2, 2)), - "TemplateElement.attrs": () => - templateElementFrame((e) => e.writeMessage(3, () => {})), - "TemplateElement.children": () => - templateElementFrame((e) => e.writeUint32(4, 0)), - - "TemplateNode.element": () => - templateNodeFrame((n) => n.writeMessage(1, () => {})), - "TemplateNode.text": () => templateNodeFrame((n) => n.writeString(2, "t")), - "TemplateNode.dynamic": () => - templateNodeFrame((n) => n.writeMessage(3, () => {})), - - "RegisterTemplate.id": () => - op(F_REGISTER_TEMPLATE, (m) => m.writeUint32(1, 1)), - "RegisterTemplate.nodes": () => - op(F_REGISTER_TEMPLATE, (m) => m.writeMessage(2, () => {})), - "RegisterTemplate.roots": () => - op(F_REGISTER_TEMPLATE, (m) => m.writeUint32(3, 0)), - - "CloneTemplate.tmpl": () => op(F_CLONE_TEMPLATE, (m) => m.writeUint32(1, 1)), - "CloneTemplate.root": () => op(F_CLONE_TEMPLATE, (m) => m.writeUint32(2, 0)), - "CloneTemplate.id": () => op(F_CLONE_TEMPLATE, (m) => m.writeUint32(3, 9)), - - "BindPath.root": () => op(F_BIND_PATH, (m) => m.writeUint32(1, 1)), - "BindPath.path": () => op(F_BIND_PATH, (m) => m.writeString(2, "ab")), - "BindPath.id": () => op(F_BIND_PATH, (m) => m.writeUint32(3, 9)), - - "BindMarker.key": () => op(F_BIND_MARKER, (m) => m.writeUint32(1, 1)), - "BindMarker.id": () => op(F_BIND_MARKER, (m) => m.writeUint32(2, 9)), - - // Enum VALUES: the fixture is a `Listener` naming that singleton. - "Global.WINDOW": () => - listenerFrame(F_ADD_LISTENER, (l) => l.writeUint32(8, 0)), - "Global.DOCUMENT": () => - listenerFrame(F_ADD_LISTENER, (l) => l.writeUint32(8, 1)), -}; - -Deno.test("the fixture table covers ALL_STREAM_FIELDS exactly", () => { - assertEquals( - Object.keys(FIXTURES).sort(), - [...ALL_STREAM_FIELDS].sort(), + const open = new RecordingSink(); + new FrameDecoder(open).push(bytes); + assertEquals(open.calls, [{ op: "commit" }]); + + const strict = new RecordingSink(); + assertThrows( + () => new FrameDecoder(strict, { strict: true }).push(bytes), + Error, + `stream-dom: unknown field 30 in Frame (receiver PROTOCOL_VERSION ${PROTOCOL_VERSION})`, ); }); -Deno.test("per-field gating: every stream field decodes when declared and is rejected when not", () => { - const full = compilePolicy({ ...SURFACE_V1 }); - for (const name of ALL_STREAM_FIELDS) { - const bytes = FIXTURES[name](); - - // Declared: decodes. - const sink = new RecordingSink(); - new FrameDecoder(sink, { accept: full.accept }).push(bytes); - - // The SAME bytes with just that one name removed: rejected, and the - // error names the offending field. - const err = assertThrows( - () => decodeStrict(bytes, policyWithout(name)), - PolicyError, - undefined, - `expected ${name} to be rejected when undeclared`, - ) as PolicyError; - assertEquals(err.field, name); +Deno.test("strict decoder rejects an unknown sub-message field; open decoder skips it", () => { + // CreateElement { id: 7, tag: 1, }. + const bytes = frame((w) => { + w.writeMessage(6, (ce) => { + ce.writeUint32(1, 7); + ce.writeUint32(2, 1); + ce.writeUint32(9, 123); + }); + }); + + const open = new RecordingSink(); + new FrameDecoder(open).push(bytes); + assertEquals(open.calls, [ + { op: "createElement", id: 7, tag: 1, ns: undefined }, + ]); + + const strict = new RecordingSink(); + assertThrows( + () => new FrameDecoder(strict, { strict: true }).push(bytes), + Error, + `stream-dom: unknown field 9 in CreateElement (receiver PROTOCOL_VERSION ${PROTOCOL_VERSION})`, + ); +}); + +Deno.test("an unknown Global enum value is rejected in both modes", () => { + const bytes = frame((w) => { + w.writeMessage(12, (add) => { + add.writeMessage(1, (l) => l.writeUint32(8, 99)); // Listener.global = 99 + }); + }); + for (const strict of [false, true]) { + assertThrows( + () => new FrameDecoder(new RecordingSink(), { strict }).push(bytes), + Error, + "stream-dom: Listener.global unknown value 99", + ); } }); -// -- unknown tags --------------------------------------------------------- +// -- asset attribute values ---------------------------------------------- -Deno.test("strict: an unknown Frame op field number is Frame.", () => { - // Field 99 with a `commit` alongside: today's decoder skips the op and - // still honors the flag; a policy has no name for it, so it is rejected. +Deno.test("SetAttribute's asset arm (field 5) decodes to an asset handle", () => { + const handle = Uint8Array.of(0, 1, 2, 3); const bytes = frame((w) => { - w.writeBool(F_COMMIT, true); - w.writeMessage(99, () => {}); + w.writeMessage(4, (sa) => { + sa.writeUint32(1, 10); // id + sa.writeUint32(2, 3); // name + sa.writeTag(5, 2); // SetAttribute.asset + sa.writeVarint32(handle.length); + for (const b of handle) sa.writeVarint32(b); + }); }); - const err = assertThrows( - () => decodeStrict(bytes, { ...SURFACE_V1 }), - PolicyError, - ) as PolicyError; - assertEquals(err.field, "Frame.99"); + const sink = new RecordingSink(); + new FrameDecoder(sink).push(bytes); + assertEquals(sink.calls, [{ + op: "setAttribute", + id: 10, + name: 3, + ns: undefined, + value: { kind: "asset", handle }, + }]); }); -Deno.test("strict: an unknown sub-message field number is Message.", () => { - const bytes = op(F_SET_ATTRIBUTE, (m) => { - m.writeUint32(1, 1); - m.writeUint32(9, 123); +Deno.test("TemplateAttr's asset arm (field 4) decodes to an asset handle", () => { + const handle = Uint8Array.of(9, 8); + const bytes = frame((w) => { + w.writeMessage(15, (rt) => { + rt.writeUint32(1, 1); // RegisterTemplate.id + rt.writeMessage(2, (node) => { + node.writeMessage(1, (el) => { + el.writeUint32(1, 1); // TemplateElement.tag + el.writeMessage(3, (attr) => { + attr.writeUint32(1, 3); // TemplateAttr.name + attr.writeTag(4, 2); // TemplateAttr.asset + attr.writeVarint32(handle.length); + for (const b of handle) attr.writeVarint32(b); + }); + }); + }); + rt.writeUint32(3, 0); // roots = [0] + }); }); - const err = assertThrows( - () => decodeStrict(bytes, { ...SURFACE_V1 }), - PolicyError, - ) as PolicyError; - assertEquals(err.field, "SetAttribute.9"); + const sink = new RecordingSink(); + new FrameDecoder(sink).push(bytes); + const call = sink.calls[0]; + if (call.op !== "registerTemplate") throw new Error("expected a template"); + const node = call.nodes[0]; + if (node.kind !== "element") throw new Error("expected an element node"); + assertEquals(node.element.attrs[0].value, { kind: "asset", handle }); }); -Deno.test("non-strict: the same unknown tags are skipped as today", () => { - const frameBytes = frame((w) => { - w.writeBool(F_COMMIT, true); - w.writeMessage(99, () => {}); - }); - const s1 = new RecordingSink(); - new FrameDecoder(s1).push(frameBytes); - assertEquals(s1.calls, ["commit []"]); +// -- assertPolicyVersion -------------------------------------------------- - const attrBytes = op(F_SET_ATTRIBUTE, (m) => { - m.writeUint32(1, 1); - m.writeUint32(9, 123); - }); - const s2 = new RecordingSink(); - new FrameDecoder(s2).push(attrBytes); - assertEquals(s2.calls, ["setAttribute [1,0,null,null]"]); +Deno.test("assertPolicyVersion passes on a match and throws on a mismatch", () => { + assertPolicyVersion({ version: PROTOCOL_VERSION, check: () => undefined }); + assertThrows( + () => + assertPolicyVersion({ + version: PROTOCOL_VERSION + 1, + check: () => undefined, + }), + Error, + `stream-dom: policy pins protocol version ${ + PROTOCOL_VERSION + 1 + }, receiver is ${PROTOCOL_VERSION}`, + ); }); -Deno.test("strict: an unknown Global enum value is Global.", () => { - const bytes = listenerFrame(F_ADD_LISTENER, (l) => l.writeUint32(8, 7)); - const err = assertThrows( - () => decodeStrict(bytes, { ...SURFACE_V1 }), - PolicyError, - ) as PolicyError; - assertEquals(err.field, "Global.7"); +// -- PolicySink ----------------------------------------------------------- + +Deno.test("PolicySink resolves interned strings and the element tag by id", () => { + const inner = new RecordingSink(); + const { policy, seen } = recordingPolicy(); + const sink = new PolicySink(inner, policy); + + sink.internString(1, "div"); + sink.internString(2, "class"); + sink.internString(3, "http://www.w3.org/2000/svg"); + sink.internString(4, "click"); + sink.createElement(10, 1, 3); + sink.setAttribute(10, 2, 3, { kind: "text", value: "row" }); + sink.setProperty(10, 2, { kind: "boolean", value: true }); + sink.addListener(listener(4, { kind: "node", id: 10 })); + + assertEquals(seen, [ + { op: "createElement", tag: "div", ns: "http://www.w3.org/2000/svg" }, + { + op: "setAttribute", + tag: "div", + name: "class", + ns: "http://www.w3.org/2000/svg", + value: { kind: "text", value: "row" }, + }, + { + op: "setProperty", + tag: "div", + name: "class", + value: { kind: "boolean", value: true }, + }, + { + op: "addListener", + target: "node", + name: "click", + capture: true, + passive: false, + preventDefault: true, + stopPropagation: false, + }, + ]); + // Allowed ops forward with identical arguments. + assertEquals(inner.calls, [ + { op: "internString", id: 1, s: "div" }, + { op: "internString", id: 2, s: "class" }, + { op: "internString", id: 3, s: "http://www.w3.org/2000/svg" }, + { op: "internString", id: 4, s: "click" }, + { op: "createElement", id: 10, tag: 1, ns: 3 }, + { + op: "setAttribute", + id: 10, + name: 2, + ns: 3, + value: { kind: "text", value: "row" }, + }, + { + op: "setProperty", + id: 10, + name: 2, + value: { kind: "boolean", value: true }, + }, + { op: "addListener", listener: listener(4, { kind: "node", id: 10 }) }, + ]); }); -// -- non-strict is byte-for-byte today's behaviour ------------------------- - -Deno.test("non-strict: basic.pb decodes identically with and without an options object", async () => { - const bytes = await Deno.readFile( - new URL("../../crates/stream-dom-guest/fixtures/basic.pb", import.meta.url), - ); - const a = new RecordingSink(); - new FrameDecoder(a).push(bytes); - const b = new RecordingSink(); - new FrameDecoder(b, {}).push(bytes); - assertEquals(a.calls, b.calls); - assertEquals(a.calls.length, 18); +Deno.test("PolicySink maps window and document listener targets", () => { + const { policy, seen } = recordingPolicy(); + const sink = new PolicySink(new RecordingSink(), policy); + sink.internString(1, "popstate"); + sink.addListener(listener(1, { kind: "window" })); + sink.addListener(listener(1, { kind: "document" })); + assertEquals(seen.map((op) => op.op === "addListener" && op.target), [ + "window", + "document", + ]); }); -// -- event payload filtering ---------------------------------------------- -// -// (message, field number) transcribed independently from -// proto/stream-dom-events.proto, plus which fixture event populates each. - -type Fam = "mouse" | "keyboard" | "form" | "submit" | "navigation"; - -const EVENT_LAYOUT: { - [K in EventField]: readonly [string, number, Fam]; -} = { - "EventPayload.mouse": ["EventPayload", 1, "mouse"], - "EventPayload.keyboard": ["EventPayload", 2, "keyboard"], - "EventPayload.form": ["EventPayload", 3, "form"], - "EventPayload.navigation": ["EventPayload", 14, "navigation"], - "MouseData.client_x": ["MouseData", 1, "mouse"], - "MouseData.client_y": ["MouseData", 2, "mouse"], - "MouseData.page_x": ["MouseData", 3, "mouse"], - "MouseData.page_y": ["MouseData", 4, "mouse"], - "MouseData.screen_x": ["MouseData", 5, "mouse"], - "MouseData.screen_y": ["MouseData", 6, "mouse"], - "MouseData.offset_x": ["MouseData", 7, "mouse"], - "MouseData.offset_y": ["MouseData", 8, "mouse"], - "MouseData.button": ["MouseData", 9, "mouse"], - "MouseData.primary": ["MouseData", 10, "mouse"], - "MouseData.secondary": ["MouseData", 11, "mouse"], - "MouseData.auxiliary": ["MouseData", 12, "mouse"], - "MouseData.back": ["MouseData", 13, "mouse"], - "MouseData.forward": ["MouseData", 14, "mouse"], - "MouseData.modifiers": ["MouseData", 15, "mouse"], - "Modifiers.alt": ["Modifiers", 1, "mouse"], - "Modifiers.ctrl": ["Modifiers", 2, "mouse"], - "Modifiers.meta": ["Modifiers", 3, "mouse"], - "Modifiers.shift": ["Modifiers", 4, "mouse"], - "KeyboardData.key": ["KeyboardData", 1, "keyboard"], - "KeyboardData.code": ["KeyboardData", 2, "keyboard"], - "KeyboardData.location": ["KeyboardData", 3, "keyboard"], - "KeyboardData.repeat": ["KeyboardData", 4, "keyboard"], - "KeyboardData.is_composing": ["KeyboardData", 5, "keyboard"], - "KeyboardData.modifiers": ["KeyboardData", 6, "keyboard"], - "FormData.value": ["FormData", 1, "form"], - "FormData.checked": ["FormData", 2, "form"], - "FormData.fields": ["FormData", 3, "submit"], - "FormField.name": ["FormField", 1, "submit"], - "FormField.value": ["FormField", 2, "submit"], - "NavigationData.href": ["NavigationData", 1, "navigation"], -}; - -/** Which sub-message each length-delimited field opens, for the walk. */ -const EVENT_CHILDREN: Record> = { - EventPayload: { - 1: "MouseData", - 2: "KeyboardData", - 3: "FormData", - 14: "NavigationData", +/** Arena: div[class] > (text, span, dynamic hole) — a path step to the + * span crosses a non-element child, which is exactly what makes the + * arena walk have to mirror `childNodes` rather than "elements only". */ +const arena: TemplateNode[] = [ + { + kind: "element", + element: { + tag: 1, + ns: undefined, + attrs: [{ + name: 2, + ns: undefined, + value: { kind: "text", value: "row" }, + }], + children: [1, 2, 3], + }, }, - MouseData: { 15: "Modifiers" }, - KeyboardData: { 6: "Modifiers" }, - FormData: { 3: "FormField" }, -}; - -/** Every `Message.` present in an encoded payload. */ -function tagsPresent(bytes: Uint8Array): Set { - const found = new Set(); - const walk = (r: Reader, msg: string) => { - while (!r.finished()) { - const [field, wireType] = r.readTag(); - found.add(`${msg}.${field}`); - const child = EVENT_CHILDREN[msg]?.[field]; - if (child !== undefined && wireType === 2) walk(r.readMessage(), child); - else r.skip(wireType); - } - }; - walk(new Reader(bytes), "EventPayload"); - return found; -} - -const MOUSE_FIXTURE = { - clientX: 1, - clientY: 2, - pageX: 3, - pageY: 4, - screenX: 5, - screenY: 6, - offsetX: 7, - offsetY: 8, - button: 1, - // All five `buttons` bits, so every held-button field is populated. - buttons: 31, - altKey: true, - ctrlKey: true, - metaKey: true, - shiftKey: true, -} as unknown as Event; - -const KEYBOARD_FIXTURE = { - key: "a", - code: "KeyA", - location: 1, - repeat: true, - isComposing: true, - altKey: true, - ctrlKey: true, - metaKey: true, - shiftKey: true, -} as unknown as Event; - -const FORM_FIXTURE = { - target: { value: "v", type: "checkbox", checked: true }, -} as unknown as Event; - -/** `FormData.fields` is only populated on `submit` against a real - * `HTMLFormElement` whose entries `FormData(form)` enumerates — neither - * global exists under `deno test`, so both are stubbed for the duration of - * `fn`. Without this the three `FormData.fields` / `FormField.*` names - * would be untestable in the positive direction. */ -function withFormGlobals(fn: (ev: Event) => T): T { - const g = globalThis as unknown as Record; - const savedForm = g.HTMLFormElement; - const savedData = g.FormData; - class StubFormElement { - value = "v"; - type = "text"; - } - class StubFormData { - #form: StubFormElement; - constructor(form: StubFormElement) { - this.#form = form; - } - entries(): Array<[string, string]> { - return [["field", this.#form.value]]; - } - } - g.HTMLFormElement = StubFormElement; - g.FormData = StubFormData; - try { - return fn({ target: new StubFormElement() } as unknown as Event); - } finally { - g.HTMLFormElement = savedForm; - g.FormData = savedData; - } + { kind: "text", text: "x" }, + { + kind: "element", + element: { tag: 5, ns: undefined, attrs: [], children: [] }, + }, + { kind: "dynamic" }, +]; + +function templateSink(): { + sink: PolicySink; + inner: RecordingSink; + seen: PolicyOp[]; +} { + const inner = new RecordingSink(); + const { policy, seen } = recordingPolicy(); + const sink = new PolicySink(inner, policy); + sink.internString(1, "div"); + sink.internString(2, "class"); + sink.internString(5, "span"); + return { sink, inner, seen }; } -function encodeFixture( - fam: Fam, - events: ReturnType["events"], -): Uint8Array { - switch (fam) { - case "mouse": - return encodePayload("click", MOUSE_FIXTURE, events); - case "keyboard": - return encodePayload("keydown", KEYBOARD_FIXTURE, events); - case "form": - return encodePayload("change", FORM_FIXTURE, events); - case "submit": - return withFormGlobals((ev) => encodePayload("submit", ev, events)); - case "navigation": - return encodePayload("hashchange", new Event("hashchange"), events); - } -} +Deno.test("PolicySink flattens registerTemplate into per-element/per-attr checks, then forwards", () => { + const { sink, inner, seen } = templateSink(); + sink.registerTemplate(7, arena, [0]); + + assertEquals(seen, [ + { op: "createElement", tag: "div", ns: undefined }, + { + op: "setAttribute", + tag: "div", + name: "class", + ns: undefined, + value: { kind: "text", value: "row" }, + }, + { op: "createElement", tag: "span", ns: undefined }, + ]); + // ... and only THEN forwards, once. + assertEquals(inner.calls.slice(3), [ + { op: "registerTemplate", id: 7, nodes: arena, roots: [0] }, + ]); +}); -Deno.test("the event layout table covers ALL_EVENT_FIELDS exactly", () => { +Deno.test("PolicySink tracks tags through cloneTemplate and bindPath, undefined for a text node", () => { + const { sink, seen } = templateSink(); + sink.registerTemplate(7, arena, [0]); + const before = seen.length; + + sink.cloneTemplate(7, 0, 100); + sink.bindPath(100, Uint8Array.of(0), 101); // child 0: the text node + // Child 1 is the span — the step counts the text node before it, as a + // `childNodes` walk does, not "elements only". + sink.bindPath(100, Uint8Array.of(1), 102); + assertEquals(seen.length, before); // neither op is shown to the policy + + sink.setAttribute(100, 2, undefined, undefined); + sink.setAttribute(101, 2, undefined, undefined); + sink.setAttribute(102, 2, undefined, undefined); + sink.setAttribute(999, 2, undefined, undefined); // never-seen id assertEquals( - Object.keys(EVENT_LAYOUT).sort(), - [...ALL_EVENT_FIELDS].sort(), + seen.slice(before).map((op) => op.op === "setAttribute" && op.tag), + ["div", undefined, "span", undefined], ); }); -Deno.test("per-field drop: every event field is emitted when declared and absent when not", () => { - const full = compilePolicy({ ...SURFACE_V1 }); - for (const name of ALL_EVENT_FIELDS) { - const [msg, num, fam] = EVENT_LAYOUT[name]; - const tag = `${msg}.${num}`; - - const present = tagsPresent(encodeFixture(fam, full.events)); - assertEquals(present.has(tag), true, `${name} missing with full filter`); - - const without = compilePolicy({ - accept: SURFACE_V1.accept, - events: SURFACE_V1.events.filter((n) => n !== name), - queries: SURFACE_V1.queries, - }); - const bytes = encodeFixture(fam, without.events); - assertEquals( - tagsPresent(bytes).has(tag), - false, - `${name} still emitted when undeclared`, - ); - // An undeclared family omits the whole payload. - if (msg === "EventPayload") assertEquals(bytes.length, 0); - } +Deno.test("PolicySink takes template tags from registration, not from a later re-intern", () => { + const inner = new RecordingSink(); + const { policy, seen } = recordingPolicy(); + const sink = new PolicySink(inner, policy); + + sink.internString(9, "a"); + sink.registerTemplate(7, [{ + kind: "element", + element: { tag: 9, ns: undefined, attrs: [], children: [] }, + }], [0]); + // The producer overwrites the slot (proto `Intern`: "Define (or + // overwrite) interned slot"). The backend's prototype is still an — + // it was built at registration — so the policy must keep judging this + // node's attributes as an . + sink.internString(9, "div"); + sink.cloneTemplate(7, 0, 100); + sink.internString(2, "href"); + sink.setAttribute(100, 2, undefined, { kind: "text", value: "/x" }); + + assertEquals(seen.at(-1), { + op: "setAttribute", + tag: "a", + name: "href", + ns: undefined, + value: { kind: "text", value: "/x" }, + }); }); -Deno.test("no filter: encodePayload is unchanged by the full filter", () => { - const full = compilePolicy({ ...SURFACE_V1 }); - assertEquals( - encodePayload("click", MOUSE_FIXTURE, full.events), - encodePayload("click", MOUSE_FIXTURE), - ); - assertEquals( - encodePayload("keydown", KEYBOARD_FIXTURE, full.events), - encodePayload("keydown", KEYBOARD_FIXTURE), - ); - assertEquals( - encodePayload("change", FORM_FIXTURE, full.events), - encodePayload("change", FORM_FIXTURE), +Deno.test("PolicySink throws PolicyError with the op index and does not forward the op", () => { + const inner = new RecordingSink(); + const { policy } = recordingPolicy((op) => + op.op === "createElement" && op.tag === "script" + ? "script elements are not in the host vocabulary" + : undefined ); - assertEquals( - encodePayload("hashchange", new Event("hashchange"), full.events), - encodePayload("hashchange", new Event("hashchange")), - ); -}); + const sink = new PolicySink(inner, policy); -// -- queries -------------------------------------------------------------- + sink.internString(1, "div"); // op 0 + sink.internString(2, "script"); // op 1 + sink.createElement(10, 1, undefined); // op 2 — allowed + sink.createText(11, "hi"); // op 3 — never shown, but counted + sink.commit(); // NOT counted -Deno.test("queries: undeclared refuses, declared allows, no policy allows all", () => { - const none = compilePolicy({ accept: [], events: [], queries: [] }); - for (const q of ALL_QUERIES) { - assertEquals(queryAllowed(none, q), false); - assertEquals(queryAllowed(undefined, q), true); - } - const readOnly = compilePolicy({ - accept: [], - events: [], - queries: ["get-client-rect"], + const err = assertThrows( + () => sink.createElement(12, 2, undefined), // op 4 — rejected + PolicyError, + "stream-dom: policy rejected createElement #4: script elements are not in the host vocabulary", + ) as PolicyError; + assertEquals(err.opIndex, 4); + assertEquals(err.op, { + op: "createElement", + tag: "script", + ns: undefined, }); - assertEquals(queryAllowed(readOnly, "get-client-rect"), true); - assertEquals(queryAllowed(readOnly, "set-focus"), false); + assertEquals(err.reason, "script elements are not in the host vocabulary"); + + // The rejected op never reached the inner sink. + assertEquals(inner.calls.filter((c) => c.op === "createElement"), [ + { op: "createElement", id: 10, tag: 1, ns: undefined }, + ]); }); -// -- compilePolicy -------------------------------------------------------- +// -- backend asset resolution -------------------------------------------- +// +// The remote backend, over a fake `RemoteConnection` — the same harness +// remote_test.ts uses. The native backend's asset path is in +// native_test.ts, which has a real-ish DOM (linkedom) to assert against. + +class FakeConnection implements RemoteConnection { + batches: RemoteMutationRecord[][] = []; + mutate(records: readonly RemoteMutationRecord[]): void { + this.batches.push([...records]); + } + call(): unknown { + throw new Error("not used by these tests"); + } +} -Deno.test("compilePolicy rejects an unknown name, by name", () => { - assertThrows( - () => - compilePolicy({ - accept: ["Frame.nope" as StreamField], - events: [], - queries: [], - }), - Error, - "Frame.nope", - ); +Deno.test("RemoteDomTranscoder resolves an asset attribute value through the hook", () => { + const conn = new FakeConnection(); + const seen: Uint8Array[] = []; + const t = new RemoteDomTranscoder(conn, null, (handle) => { + seen.push(handle); + return `/assets/${handle.join("-")}`; + }); + t.internString(1, "img"); + t.internString(2, "src"); + t.createElement(10, 1, undefined); + t.insertBefore(0, 10, undefined); + t.setAttribute(10, 2, undefined, { + kind: "asset", + handle: Uint8Array.of(1, 2), + }); + t.commit(); + + assertEquals(seen, [Uint8Array.of(1, 2)]); + const record = conn.batches[0].at(-1)!; + assertEquals(record[3], "/assets/1-2"); +}); + +Deno.test("an asset value with no resolveAsset configured is an error", () => { + const t = new RemoteDomTranscoder(new FakeConnection()); + t.internString(1, "img"); + t.internString(2, "src"); + t.createElement(10, 1, undefined); assertThrows( () => - compilePolicy({ - accept: [], - events: ["MouseData.related_target" as EventField], - queries: [], + t.setAttribute(10, 2, undefined, { + kind: "asset", + handle: Uint8Array.of(7), }), Error, - "MouseData.related_target", + "stream-dom: asset attribute value but no resolveAsset configured", ); + // Template attrs resolve at registerTemplate time, same error. assertThrows( () => - compilePolicy({ accept: [], events: [], queries: ["evaluate" as Query] }), + t.registerTemplate(1, [{ + kind: "element", + element: { + tag: 1, + ns: undefined, + attrs: [{ + name: 2, + ns: undefined, + value: { kind: "asset", handle: Uint8Array.of(7) }, + }], + children: [], + }, + }], [0]), Error, - "evaluate", + "stream-dom: asset attribute value but no resolveAsset configured", ); - // Names that index Object.prototype must not be mistaken for entries. - for (const name of ["constructor", "__proto__", "toString"]) { - assertThrows( - () => - compilePolicy({ - accept: [name as StreamField], - events: [], - queries: [], - }), - Error, - name, - ); - assertThrows( - () => - compilePolicy({ - accept: [], - events: [name as EventField], - queries: [], - }), - Error, - name, - ); - } -}); - -Deno.test("compilePolicy tolerates duplicates", () => { - const c = compilePolicy({ - accept: ["Frame.commit", "Frame.commit"], - events: ["EventPayload.mouse", "EventPayload.mouse"], - queries: ["set-focus", "set-focus"], - }); - assertEquals(c.queries.size, 1); - const bytes = frame((w) => w.writeBool(F_COMMIT, true)); - const sink = new RecordingSink(); - new FrameDecoder(sink, { accept: c.accept }).push(bytes); - assertEquals(sink.calls, ["commit []"]); -}); - -Deno.test("SURFACE_V1 is a subset of the current surface (a removal breaks loudly)", () => { - // If this fails, something was REMOVED from the protocol: the snapshot - // now names surface that no longer exists, and `compilePolicy` throws - // for every embedder still spreading V1. That is the intended failure — - // fix by shipping a new snapshot, not by editing V1. - for (const n of SURFACE_V1.accept) { - assertEquals(ALL_STREAM_FIELDS.includes(n), true, `stale: ${n}`); - } - for (const n of SURFACE_V1.events) { - assertEquals(ALL_EVENT_FIELDS.includes(n), true, `stale: ${n}`); - } - for (const q of SURFACE_V1.queries) { - assertEquals(ALL_QUERIES.includes(q), true, `stale: ${q}`); - } - // And it compiles. - compilePolicy({ ...SURFACE_V1 }); -}); - -Deno.test("SURFACE_V1 is frozen at its published sizes", () => { - // A change to these numbers means the snapshot was edited, which is - // forbidden: new protocol surface gets a NEW snapshot (V2). See the - // FROZEN section in policy.ts. - assertEquals(SURFACE_V1.accept.length, 77); - assertEquals(SURFACE_V1.events.length, 35); - assertEquals(SURFACE_V1.queries.length, 4); }); diff --git a/receiver/tests/remote_test.ts b/receiver/tests/remote_test.ts index 4da1f51..6eb68b4 100644 --- a/receiver/tests/remote_test.ts +++ b/receiver/tests/remote_test.ts @@ -65,7 +65,7 @@ Deno.test("attribute update on an attached node", () => { t.commit(); conn.batches.length = 0; - t.setAttribute(10, 2, undefined, "greeting"); + t.setAttribute(10, 2, undefined, { kind: "text", value: "greeting" }); t.commit(); assertEquals(conn.batches.length, 1); @@ -392,6 +392,38 @@ Deno.test("remove detaches and forgets the subtree", () => { assertThrows(() => t.setText(11, "x")); }); +Deno.test("template strings are resolved at registration, not at clone time", () => { + const { t, conn } = transcoder(); + t.internString(1, "div"); + t.internString(2, "title"); + t.registerTemplate(100, [{ + kind: "element", + element: { + tag: 1, + ns: undefined, + attrs: [{ name: 2, ns: undefined, value: { kind: "text", value: "hi" } }], + children: [], + }, + }], [0]); + // The producer overwrites both slots (proto `Intern`: "Define (or + // overwrite) interned slot") before stamping the template out. The clone + // must still be the element that was registered, with the attribute name + // that was registered — that pair is what a policy approved. + t.internString(1, "section"); + t.internString(2, "onclick"); + t.cloneTemplate(100, 0, 20); + t.insertBefore(0, 20, undefined); + t.commit(); + + const [record] = conn.batches[0]; + const child = record[2] as unknown as { + element: string; + attributes: Record; + }; + assertEquals(child.element, "div"); + assertEquals(child.attributes, { title: "hi" }); +}); + Deno.test("register-template, clone, bind-path, then set-text on the bound interior node", () => { const { t, conn } = transcoder(); t.internString(1, "div"); @@ -477,6 +509,10 @@ Deno.test("clone-template's root is an ordinal into RegisterTemplate.roots, not Deno.test("clone-template throws when the root ordinal is out of range", () => { const { t } = transcoder(); + // Interned because this backend now resolves template strings at + // REGISTRATION (as the native one always has) — an un-interned tag ref + // would abort there, before this test reaches its subject. + t.internString(0, "div"); const nodes: TemplateNode[] = [ { kind: "element",