diff --git a/Cargo.lock b/Cargo.lock index 13aedd6..79f4fe1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -776,6 +776,15 @@ dependencies = [ "num-traits", ] +[[package]] +name = "eval-probe" +version = "0.1.0" +dependencies = [ + "dioxus", + "polyengine-dioxus", + "serde_json", +] + [[package]] name = "fnv" version = "1.0.7" @@ -1555,9 +1564,12 @@ dependencies = [ "dioxus", "dioxus-core", "dioxus-core-types", + "dioxus-document", "dioxus-html", "dioxus-ssr", + "generational-box", "rustc-hash 2.1.3", + "serde_json", "wit-bindgen 0.60.0", ] diff --git a/Cargo.toml b/Cargo.toml index 1f1bd0b..8177caa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,6 +26,21 @@ dioxus-html = { version = "=0.7.10", default-features = false } # already in the tree via dioxus-core, so naming it costs nothing; used for # hot-path interning maps. rustc-hash = "2" +# `eval` only; see src/document.rs and the `eval` interface doc in +# wit/world.wit. Optional so that a renderer built without the feature names +# nothing from the interface and so emits no import for it. +dioxus-document = { version = "=0.7.10", optional = true } +serde_json = { version = "1", optional = true } +# `Owner`/`UnsyncStorage`: dioxus-document's `Eval::new` takes a +# `GenerationalBox` but re-exports neither, and dioxus-core exposes the crate +# only behind a `#[doc(hidden)]` module. Already in the tree via dioxus-core. +generational-box = { version = "=0.7.10", optional = true } + +[features] +# Opt in to `document::eval`. OFF by default: a component built with it +# imports `polymorph:dioxus/eval` and will not instantiate against a host +# that did not also opt in (`MountOptions.eval`). +eval = ["dep:dioxus-document", "dep:serde_json", "dep:generational-box"] [dev-dependencies] # Native-only: `rsx!` for driving a real VirtualDom through the writer in unit @@ -41,7 +56,7 @@ dioxus = { version = "=0.7.10", default-features = false, features = [ dioxus-ssr = "=0.7.9" [workspace] -members = [".", "ssr", "fixtures/surface-probe", "examples/counter", "examples/bench-rows", "examples/todomvc", "examples/components", "examples/primitives"] +members = [".", "ssr", "fixtures/surface-probe", "fixtures/eval-probe", "examples/counter", "examples/bench-rows", "examples/todomvc", "examples/components", "examples/primitives"] # `dioxus-sdk-time` waits by calling the browser's `setTimeout` through # wasm-bindgen, which on wasm32-wasip2 compiles to off-target stubs that abort diff --git a/examples/primitives/src/lib.rs b/examples/primitives/src/lib.rs index 56a8dbe..8b8c4b3 100644 --- a/examples/primitives/src/lib.rs +++ b/examples/primitives/src/lib.rs @@ -14,22 +14,24 @@ //! //! # Compatibility matrix //! -//! This renderer has no JS boundary: `document::eval` resolves to dioxus's -//! `NoOpDocument`, which answers every eval with `EvalError::Unsupported` -//! (dioxus-document-0.7.10 src/document.rs:121-145). That is a *graceful* -//! failure — nothing traps — so eval-dependent primitives still render and -//! still work for everything driven from Rust; they lose only the parts -//! implemented in JavaScript. They are included here with the loss named. +//! `document::eval` works only when BOTH sides opt in: the renderer must be +//! built with its `eval` feature and the host must grant the import +//! (`MountOptions.eval`) — see the `eval` interface doc in `wit/world.wit`. +//! This gallery is built without the feature, so here `document::eval` +//! resolves to dioxus's `NoOpDocument`, which answers every eval with +//! `EvalError::Unsupported` (dioxus-document-0.7.10 src/document.rs:121-145). +//! That is a *graceful* failure — nothing traps — so eval-dependent +//! primitives still render and still work for everything driven from Rust; +//! they lose only the parts implemented in JavaScript. They are included +//! here with the loss named. //! -//! **This is permanent, not a gap awaiting work.** Implementing `eval` is -//! technically possible — the host is JavaScript, and dioxus-desktop does -//! exactly this over IPC — but a primary consumer of this renderer -//! (polyvisor) cannot permit arbitrary JS evaluation at all, so the -//! capability will not be added. Anything below marked degraded is degraded -//! for good on this renderer. The remedy for a behaviour you actually need -//! is to reimplement it with Dioxus event handlers plus CSS, as -//! `examples/components/src/jsfree.rs` does for a dialog and a tooltip — -//! not to wait for eval. +//! **Assume the degradations below apply to your app too.** They hold for +//! this gallery, and for any app running under polyvisor, which never grants +//! eval to an app: a browser cannot sandbox arbitrary JS well enough for a +//! host that runs untrusted components. The remedy for a behaviour you +//! actually need is to reimplement it with Dioxus event handlers plus CSS, +//! as `examples/components/src/jsfree.rs` does for a dialog and a tooltip — +//! not to turn the feature on. //! //! ## Included and fully functional //! diff --git a/fixtures/eval-probe/Cargo.toml b/fixtures/eval-probe/Cargo.toml new file mode 100644 index 0000000..42a589a --- /dev/null +++ b/fixtures/eval-probe/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "eval-probe" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +polyengine-dioxus = { path = "../..", features = ["eval"] } +dioxus = { version = "=0.7.10", default-features = false, features = [ + "document", + "macro", + "html", + "signals", + "hooks", +] } +serde_json = "1" diff --git a/fixtures/eval-probe/src/lib.rs b/fixtures/eval-probe/src/lib.rs new file mode 100644 index 0000000..b1e9f07 --- /dev/null +++ b/fixtures/eval-probe/src/lib.rs @@ -0,0 +1,73 @@ +//! Host test fixture for the world's `eval` interface (wit/world.wit): an app +//! that drives every path `document::eval` has. +//! +//! Built with the renderer's `eval` feature (see Cargo.toml), so this +//! component imports `polymorph:dioxus/eval` and only instantiates against a +//! host that opted in (`MountOptions.eval`). +//! +//! What the host test asserts, by class: +//! - `globalThis.__evalProbe === "fired"` — set by a fire-and-forget eval +//! nobody awaits. +//! - `.recv` becomes `{"echo":{"n":41},"n":42}` — a guest→script→guest +//! round trip through `dioxus.recv`/`dioxus.send`. +//! - `.join` becomes `done-41` — the script's return value. +//! - clicking `.bad` sets `.join` to `err:communication` (script threw); +//! clicking `.invalid` sets it to `err:invalid-js` (script did not +//! compile). + +use dioxus::document::{self, EvalError}; +use dioxus::prelude::*; + +#[allow(non_snake_case)] +pub fn App() -> Element { + let mut recv = use_signal(String::new); + let mut join = use_signal(String::new); + + use_future(move || async move { + // Fire-and-forget: never awaited, so it also exercises the + // never-polled release path in the renderer's evaluator. + document::eval("globalThis.__evalProbe = 'fired';"); + + let mut e = document::eval( + "const x = await dioxus.recv(); dioxus.send({ echo: x, n: x.n + 1 }); return 'done-' + x.n;", + ); + e.send(serde_json::json!({"n": 41})).unwrap(); + let got: serde_json::Value = e.recv().await.unwrap(); + recv.set(got.to_string()); + let joined: String = e.join().await.unwrap(); + join.set(joined); + }); + + rsx! { + section { + span { class: "fired", "-" } + span { class: "recv", "{recv}" } + span { class: "join", "{join}" } + button { + class: "bad", + onclick: move |_| async move { + match document::eval("throw new Error('boom');") + .join::() + .await + { + Err(EvalError::Communication(_)) => join.set("err:communication".into()), + other => join.set(format!("unexpected:{other:?}")), + } + }, + "bad" + } + button { + class: "invalid", + onclick: move |_| async move { + match document::eval("this is not js").join::().await { + Err(EvalError::InvalidJs(_)) => join.set("err:invalid-js".into()), + other => join.set(format!("unexpected:{other:?}")), + } + }, + "invalid" + } + } + } +} + +polyengine_dioxus::launch!(App); diff --git a/host/src/eval.ts b/host/src/eval.ts new file mode 100644 index 0000000..4627f5a --- /dev/null +++ b/host/src/eval.ts @@ -0,0 +1,193 @@ +// Host side of `polymorph:dioxus/eval` (wit/world.wit, `interface eval`) — +// arbitrary JS evaluation backing dioxus's `document::eval`. OPT-IN: see +// the wit doc comment and `MountOptions.eval` (host.ts) for why this is +// wired only when asked. +// +// Governing docs: wit/world.wit `interface eval` (normative for behavior — +// the async-function-body shape, the JSON both ways, `undefined` -> "null", +// the constructor's synchronous-prefix bracket, the error cases), and +// .deps/polyengine/contracts/embedder-api.md "Error model" (host import +// with `result` throws `ComponentException`, never a bare value) and +// "Resources" (host-implemented resource = plain class; WIT constructor = +// JS constructor). Cited inline as `contract:
`. + +import { ComponentException } from "@deltic/protocol"; +import type { DispatchGate } from "./dispatch.ts"; + +/** wit `eval.error` variant payload shapes (contract:"Value mapping", + * `variant` row: `{ kind, value }`, `value` absent for payloadless cases). */ +type EvalError = + | { kind: "invalid-js"; value: string } + | { kind: "communication"; value: string } + | { kind: "finished" }; + +const FINISHED: EvalError = { kind: "finished" }; + +/** Sentinel `recv()` result meaning "the script has completed and no more + * values are coming" — used only to let `recv` race a queued value against + * completion without leaking a permanently-parked waiter's identity. */ +const DONE = Symbol("eval-channel-done"); + +// WORKAROUND for a polyengine runtime liveness gap (see +// .deps/polyengine/runtime/src/exec/boundary.ts, "The settlement pump: +// liveness between export calls"). `recv`/`join` are async host imports; if +// the guest calls one from an export's INITIAL activation (its very first +// callback, before any earlier park) and the returned promise settles in +// the microtask checkpoint right after that activation returns +// `task.return`, the export's driver has already exited (`EXIT-done`) with +// no settlement pump armed to drive the store, and the guest's callback is +// never resumed. Observed matrix: a script that settles synchronously, or +// after `await null`, reproduces the hang; a script that settles after a +// real macrotask (`setTimeout(r, 0)`) does not — by then the export call +// has been outstanding across a macrotask boundary, so the pump is armed +// and drives resumption when the promise settles. `use_future`'s eval calls +// happen to dodge this (they start inside a later callback, past the +// first stream-write park), but `handle-event`'s own initial activation +// does not, so a `document::eval(...).join()` awaited straight from a +// dioxus event handler hits it directly. Forcing every settlement here to +// cross at least one real macrotask sidesteps the gap unconditionally, at +// the cost of one macrotask of latency per `recv`/`join` resolution. This +// comes out once polyengine's driver arms the pump for calls settling in +// this window, tracked upstream — not a permanent fixture of this API. +const macrotask = () => new Promise((r) => setTimeout(r, 0)); + +/** A single-type mailbox: `push` either satisfies the oldest pending + * waiter or queues the value; `next` returns a queued value immediately or + * parks a waiter. Never both queues non-empty at once. `finish` resolves + * every currently-parked waiter (and all future `next()` calls, since + * nothing more will ever be pushed) with `DONE`. */ +class Channel { + #queued: T[] = []; + #waiting: Array<(v: T | typeof DONE) => void> = []; + #finished = false; + + push(v: T): void { + const w = this.#waiting.shift(); + if (w) w(v); + else this.#queued.push(v); + } + + next(): Promise { + if (this.#queued.length > 0) return Promise.resolve(this.#queued.shift()!); + if (this.#finished) return Promise.resolve(DONE); + return new Promise((resolve) => this.#waiting.push(resolve)); + } + + finish(): void { + this.#finished = true; + for (const w of this.#waiting.splice(0)) w(DONE); + } +} + +/** + * Build the host side of `polymorph:dioxus/eval` — a single `Evaluation` + * resource class closing over the dispatch gate. + * + * Split out as a factory (matching `createDomImports`, host.ts) so + * `Evaluation` can be unit-tested against a bare `DispatchGate`, with no + * component in the loop (host/tests/eval_test.ts). + */ +export function createEvalImports(gate: DispatchGate) { + class Evaluation { + /** Values the script sends via `dioxus.send`, waiting for the guest's + * `recv`. Finished once the script's promise settles: nothing more + * will ever be pushed. */ + #jsToRust = new Channel(); + /** Values the guest sends via `Evaluation.send`, waiting for the + * script's `await dioxus.recv()`. */ + #rustToJs = new Channel(); + /** Settled once the script's promise settles — never a live rejected + * promise, so nothing here becomes an unhandled rejection. */ + #outcome: Promise<{ ok: string } | { err: EvalError }>; + /** `join` a second time reports `finished` (wit doc: one of the two + * ways to reach it); flips once the first `join` observes `#outcome`. */ + #joined = false; + + constructor(js: string) { + let body: (dioxus: unknown) => Promise; + let ctorError: EvalError | undefined; + try { + // wit: "the script is the BODY of an async function taking one + // parameter, `dioxus`" (dioxus-desktop's own shape, src/query.rs). + const AsyncFunction = async function () {}.constructor as new ( + ...args: string[] + ) => (dioxus: unknown) => Promise; + body = new AsyncFunction("dioxus", js); + } catch (e) { + // A SyntaxError constructing the function. wit: "a constructor + // cannot fail" — remembered as the join outcome, not thrown here + // (a throw from a host resource constructor is a trap, and the + // WIT constructor has no result type). + ctorError = { kind: "invalid-js", value: String(e) }; + body = () => Promise.resolve(undefined); + } + + const dioxusObj = { + send: (v: unknown) => this.#jsToRust.push(JSON.stringify(v) ?? "null"), + recv: async () => { + const v = await this.#rustToJs.next(); + return v === DONE ? undefined : v; + }, + // dioxus-web's PROMISE_WRAPPER calls `dioxus.close()` itself, but + // only from ITS wrapper script, never from user code — this host + // runs the app's script body directly (no such wrapper), so + // `close` has no caller and is omitted. + }; + + if (ctorError) { + this.#outcome = Promise.resolve({ err: ctorError }); + this.#jsToRust.finish(); + } else { + // wit: "the synchronous prefix ... runs INSIDE the constructor, on + // the calling guest's stack — so it is bracketed by the host's + // dispatch gate like `dom.set-focus` is" (dispatch.ts window 3): + // this call synchronously runs everything up to the script's + // first `await`, and that prefix can mutate the DOM / fire + // delegated events. + gate.beginApply(); + let p: Promise; + try { + p = body(dioxusObj); + } finally { + gate.endApply(); + } + // `JSON.stringify` itself can throw (a BigInt, a cycle): that is the + // wit's "a value could not be represented as JSON", so it lands in + // the same `communication` arm as a throwing script rather than + // rejecting `#outcome` with an unbranded error (which the runtime + // would turn into a trap — contract:"Error model"). + this.#outcome = p.then((v) => JSON.stringify(v) ?? "null").then( + (ok) => ({ ok }), + (e) => ({ + err: { kind: "communication", value: "Error running JS: " + e } as EvalError, + }), + ); + // Whichever way it settles, nothing more will ever cross via + // `dioxus.send` — unblock any `recv` still parked. + this.#outcome.then(() => this.#jsToRust.finish()); + } + } + + send(json: string): void { + this.#rustToJs.push(JSON.parse(json)); + } + + async recv(): Promise { + const v = await this.#jsToRust.next(); + await macrotask(); + if (v === DONE) throw new ComponentException(FINISHED); + return v; + } + + async join(): Promise { + if (this.#joined) throw new ComponentException(FINISHED); + this.#joined = true; + const r = await this.#outcome; + await macrotask(); + if ("err" in r) throw new ComponentException(r.err); + return r.ok; + } + } + + return { Evaluation }; +} diff --git a/host/src/host.ts b/host/src/host.ts index 6083359..fe7a9ad 100644 --- a/host/src/host.ts +++ b/host/src/host.ts @@ -13,6 +13,7 @@ import type { Stream } from "@deltic/protocol"; import { DomApplier } from "./applier.ts"; import { DispatchGate } from "./dispatch.ts"; +import { createEvalImports } from "./eval.ts"; import { EventDispatcher, serializePayload } from "./events.ts"; import type { NativeEventLike } from "./events.ts"; import { applyOperations } from "./operations.ts"; @@ -44,6 +45,17 @@ export interface MountOptions { * routed here: `await exports.run(mode)` rejects and `mountApp` throws it to * the caller. */ onError?: (err: unknown) => void; + /** Supply `polymorph:dioxus/eval@0.6.0` (wit/world.wit `interface eval`) + * so guest `document::eval` calls run arbitrary JS in this page. + * Defaults to `false`. OPT-IN ON BOTH SIDES (the interface doc is + * normative): a component whose renderer was built without the `eval` + * Cargo feature never imports the interface at all, so this flag is + * moot for it either way; a component that DOES import it fails to + * instantiate against a host that leaves this `false` — failure is the + * safe direction. polyvisor sets this only for its own visor, never for + * an untrusted app: a browser cannot sandbox arbitrary JS, so this is a + * trusted-computing-base decision, not a per-mount convenience. */ + eval?: boolean; } export interface Mounted { @@ -400,6 +412,13 @@ export async function mountApp(opts: MountOptions): Promise { // bindgen-emitted lowerCamel names. "polymorph:dioxus/events@0.6.0": { DomEvent }, "polymorph:dioxus/dom@0.6.0": createDomImports(applier, gate), + // Present only when the caller opted in (`MountOptions.eval` doc + // above). Absent otherwise — the world's `import eval` still exists + // in every `app`-world component, but wit-component only encodes the + // interfaces the guest's core module actually imports, so a + // non-`eval`-feature build never asks for this key and its absence + // here is never noticed. + ...(opts.eval ? { "polymorph:dioxus/eval@0.6.0": createEvalImports(gate) } : {}), }; const instance = await instantiate(opts.source, imports); diff --git a/host/tests/eval_component_test.ts b/host/tests/eval_component_test.ts new file mode 100644 index 0000000..5440131 --- /dev/null +++ b/host/tests/eval_component_test.ts @@ -0,0 +1,107 @@ +// Full-stack host-runtime test for the `eval` fixture +// (fixtures/eval-probe/src/lib.rs — the authority for markup and behavior), +// exercising `MountOptions.eval` end to end: fire-and-forget eval, a +// recv/send round trip, join, and both error paths (a throwing script and +// a script that fails to compile). Requires `just fixtures` to have built +// fixtures/build/eval-probe.component.wasm first. + +import { assertEquals, assertRejects, assertStringIncludes } from "jsr:@std/assert@1"; +import { parseHTML } from "linkedom"; +import { defaultTranslator } from "@deltic/translator"; +import { mountApp } from "../src/host.ts"; + +const FIXTURE_PATH = "../../fixtures/build/eval-probe.component.wasm"; + +async function waitFor(cond: () => boolean, what: string, maxIters = 2000): Promise { + for (let i = 0; i < maxIters; i++) { + if (cond()) return; + await new Promise((r) => setTimeout(r, 0)); + } + throw new Error(`waitFor timed out: ${what}`); +} + +function makeRoot() { + const { document } = parseHTML("
"); + const root = document.getElementById("root")!; + return root; +} + +async function loadComponentBytes(): Promise { + const url = new URL(FIXTURE_PATH, import.meta.url); + try { + return await Deno.readFile(url); + } catch (e) { + if (e instanceof Deno.errors.NotFound) { + throw new Error( + `component not found at ${url}. Run \`just fixtures\` first ` + + `(builds fixtures/build/eval-probe.component.wasm).`, + ); + } + throw e; + } +} + +function click(target: Element): { type: string; preventDefault(): void; stopPropagation(): void } { + void target; + return { + type: "click", + preventDefault() {}, + stopPropagation() {}, + }; +} + +Deno.test("eval fixture: fire-and-forget eval, a recv/send/join round trip, and both error paths", async () => { + const root = makeRoot(); + const componentBytes = await loadComponentBytes(); + const translator = await defaultTranslator(); + const errors: unknown[] = []; + + try { + const mounted = await mountApp({ + source: { componentBytes, translator }, + root, + eval: true, + onError: (err) => errors.push(err), + }); + + const recv = () => root.querySelector(".recv")!; + const join = () => root.querySelector(".join")!; + + await waitFor(() => join()?.textContent === "done-41", "initial join"); + assertEquals(recv().textContent, '{"echo":{"n":41},"n":42}'); + assertEquals((globalThis as Record).__evalProbe, "fired"); + + const bad = root.querySelector(".bad")!; + mounted.dispatch(bad, "click", click(bad)); + await waitFor(() => join()?.textContent === "err:communication", "bad click"); + + const invalid = root.querySelector(".invalid")!; + mounted.dispatch(invalid, "click", click(invalid)); + await waitFor(() => join()?.textContent === "err:invalid-js", "invalid click"); + + assertEquals(errors, []); + mounted.dispose(); + } finally { + delete (globalThis as Record).__evalProbe; + } +}); + +Deno.test("eval fixture: mounting without `eval: true` fails (host did not opt in)", async () => { + const root = makeRoot(); + const componentBytes = await loadComponentBytes(); + const translator = await defaultTranslator(); + + // wit/world.wit's eval interface doc: "A component that imports it + // against a host that did not opt in fails to instantiate. Failure is + // the safe direction, and it is loud." No `eval` key in `imports` means + // `instantiate` can't satisfy the guest's `import eval` at link time. + // Observed: the runtime's `PlanError`, naming the unprovided import. + const err = await assertRejects(() => + mountApp({ + source: { componentBytes, translator }, + root, + onError: () => {}, + }) + ); + assertStringIncludes((err as Error).message, "polymorph:dioxus/eval@0.6.0"); +}); diff --git a/host/tests/eval_test.ts b/host/tests/eval_test.ts new file mode 100644 index 0000000..489e338 --- /dev/null +++ b/host/tests/eval_test.ts @@ -0,0 +1,96 @@ +// Unit coverage for the host side of `polymorph:dioxus/eval` +// (wit/world.wit `interface eval`), driven directly against +// `createEvalImports` + a bare `DispatchGate` — no component, no +// instantiation (mirrors host_dom_test.ts's approach for `dom`). + +import { assertEquals, assertRejects } from "jsr:@std/assert@1"; +import { ComponentException } from "@deltic/protocol"; +import { DispatchGate } from "../src/dispatch.ts"; +import { createEvalImports } from "../src/eval.ts"; + +function setup() { + const errors: unknown[] = []; + const gate = new DispatchGate((e) => errors.push(e)); + const { Evaluation } = createEvalImports(gate); + return { gate, Evaluation, errors }; +} + +Deno.test("eval: a returning script joins with its JSON-stringified value", async () => { + const { Evaluation } = setup(); + const e = new Evaluation("return 1 + 1"); + assertEquals(await e.join(), "2"); +}); + +Deno.test("eval: a script with no return joins 'null' (wit: undefined -> \"null\")", async () => { + const { Evaluation } = setup(); + const e = new Evaluation("// no return"); + assertEquals(await e.join(), "null"); +}); + +Deno.test("eval: a script that fails to compile joins rejected with kind invalid-js", async () => { + const { Evaluation } = setup(); + const e = new Evaluation("this is not valid js at all ((("); + const err = await assertRejects(() => e.join(), ComponentException); + assertEquals((err.payload as { kind: string }).kind, "invalid-js"); +}); + +Deno.test("eval: a script that throws joins rejected with kind communication", async () => { + const { Evaluation } = setup(); + const e = new Evaluation("throw new Error('boom');"); + const err = await assertRejects(() => e.join(), ComponentException); + assertEquals((err.payload as { kind: string }).kind, "communication"); +}); + +Deno.test("eval: send/recv round trip through dioxus.recv/dioxus.send", async () => { + const { Evaluation } = setup(); + const e = new Evaluation("const x = await dioxus.recv(); dioxus.send(x * 2); return 'ok';"); + e.send("21"); + assertEquals(await e.recv(), "42"); + assertEquals(await e.join(), "\"ok\""); +}); + +Deno.test("eval: recv after completion with an empty queue reports finished", async () => { + const { Evaluation } = setup(); + const e = new Evaluation("return 1;"); + await e.join(); + const err = await assertRejects(() => e.recv(), ComponentException); + assertEquals((err.payload as { kind: string }).kind, "finished"); +}); + +Deno.test("eval: a second join reports finished", async () => { + const { Evaluation } = setup(); + const e = new Evaluation("return 1;"); + assertEquals(await e.join(), "1"); + const err = await assertRejects(() => e.join(), ComponentException); + assertEquals((err.payload as { kind: string }).kind, "finished"); +}); + +Deno.test("eval: recv still parked when the script completes also reports finished", async () => { + const { Evaluation } = setup(); + // Never sends; `recv()`'s await is still pending when the script's + // `return` settles the outcome. + const e = new Evaluation("return 1;"); + const err = await assertRejects(() => e.recv(), ComponentException); + assertEquals((err.payload as { kind: string }).kind, "finished"); + assertEquals(await e.join(), "1"); +}); + +Deno.test("eval: the constructor's synchronous prefix runs before `new` returns", () => { + const { Evaluation } = setup(); + try { + // wit: "everything before the script's first `await` runs INSIDE the + // constructor". Observable independent of any gate bracket: `__t` must + // already be set the instant `new Evaluation` returns. + new Evaluation("globalThis.__evalSyncPrefix = 1; return 2;"); + assertEquals((globalThis as Record).__evalSyncPrefix, 1); + } finally { + delete (globalThis as Record).__evalSyncPrefix; + } +}); + +Deno.test("eval: a return value JSON cannot represent joins rejected with kind communication", async () => { + const { Evaluation } = setup(); + const e = new Evaluation("return 1n;"); + const err = await assertRejects(() => e.join(), ComponentException); + assertEquals((err.payload as { kind: string }).kind, "communication"); +}); diff --git a/justfile b/justfile index 3d2dc98..f08d339 100644 --- a/justfile +++ b/justfile @@ -45,6 +45,9 @@ check: # expansion. Checking the example covers both. cargo clippy -p counter-example --no-default-features --features ssr \ --target wasm32-wasip2 -- -D warnings + # Explicit: whether the workspace pass above unifies the `eval` feature + # onto the renderer depends on which crates are in the graph. + cargo clippy -p polyengine-dioxus --features eval --target wasm32-wasip2 -- -D warnings deno task check test: @@ -54,8 +57,8 @@ test: cargo test -p polyengine-dioxus-ssr deno task test -# Build the surface-probe fixture component into fixtures/build/. The -# full-stack host tests load it. wasm32-wasip2 emits a component directly. +# Build the fixture components into fixtures/build/. The full-stack host +# tests load them. wasm32-wasip2 emits a component directly. fixtures: #!/usr/bin/env bash set -euo pipefail @@ -64,6 +67,14 @@ fixtures: cp target/wasm32-wasip2/release/surface_probe.wasm \ fixtures/build/surface-probe.component.wasm wasm-tools validate --features component-model,cm-async fixtures/build/surface-probe.component.wasm + cargo build -p eval-probe --target wasm32-wasip2 --release + cp target/wasm32-wasip2/release/eval_probe.wasm \ + fixtures/build/eval-probe.component.wasm + wasm-tools validate --features component-model,cm-async fixtures/build/eval-probe.component.wasm + # Built with the renderer's `eval` feature, so the import must be there: + # the host tests mount it with `MountOptions.eval` (wit/world.wit, the + # `eval` interface doc). + if ! wasm-tools component wit fixtures/build/eval-probe.component.wasm | grep -q 'polymorph:dioxus/eval@'; then echo "eval-probe does not import polymorph:dioxus/eval" >&2; exit 1; fi # Build an example app component into examples/build/. # @@ -82,6 +93,9 @@ example name: component="target/wasm32-wasip2/release/$(echo {{name}} | tr - _)_example.wasm" cp "$component" examples/build/{{name}}.component.wasm wasm-tools validate --features component-model,cm-async examples/build/{{name}}.component.wasm + # Built without the renderer's `eval` feature, so the import must be + # absent (wit/world.wit, the `eval` interface doc: opt-in on both sides). + if wasm-tools component wit examples/build/{{name}}.component.wasm | grep -q 'polymorph:dioxus/eval@'; then echo "{{name}} imports polymorph:dioxus/eval without the eval feature" >&2; exit 1; fi # Build-time translation (embedder-api.md amendment A4): the translation # ENVELOPE is the blessed deploy artifact, so the deployed site ships # component.wasm + envelope + runtime and NO translator. diff --git a/src/document.rs b/src/document.rs new file mode 100644 index 0000000..4142045 --- /dev/null +++ b/src/document.rs @@ -0,0 +1,189 @@ +//! `document::eval` over the world's `eval` interface: the opt-in JS bridge. +//! +//! Compiled only under the crate's `eval` feature; without it nothing here +//! exists, no `polymorph:dioxus/eval` import is emitted, and dioxus falls +//! back to `NoOpDocument` (`EvalError::Unsupported`). See the `eval` +//! interface doc in `wit/world.wit` for why the capability is opt-in on both +//! sides. +//! +//! # The owner-lifetime problem +//! +//! `dioxus_document::Eval` is `Copy` and holds only a +//! `GenerationalBox>` (dioxus-document-0.7.10 +//! src/eval.rs:10-12), so the evaluator stays alive exactly as long as the +//! `generational_box::Owner` its box was inserted into. dioxus-web hands +//! that owner to JavaScript and lets the browser's GC of the channel object +//! release it (dioxus-web-0.7.10 src/document.rs:196-200); we have no GC +//! hook, and simply leaking one owner per eval is unbounded growth in a UI +//! loop — dioxus-primitives evals on every close animation. +//! +//! So the owner lives in a slot the evaluator itself holds +//! (`Rc>>`, a deliberate reference cycle) and is taken +//! out — freeing the evaluator, whose `Rc` drop releases the +//! host resource — at whichever of these comes first: +//! +//! - the script's result arrives and nothing has ever polled the evaluator: +//! the fire-and-forget case (`document::eval("document.title = ...")`), +//! which is the common one and the only one that would otherwise +//! accumulate; +//! - `poll_join` returns `Ready`. The drop is scheduled through +//! `spawn_local` rather than done inline, because `Eval::join` calls +//! `poll_join` while holding a `try_write` guard on the very box the owner +//! would free (src/eval.rs:22-27). +//! +//! An evaluation that is `recv`'d but never joined therefore keeps its owner +//! for the life of the instance: any poll marks the evaluator as observed, +//! since an eval a component is still talking to must not be freed out from +//! under it. That is one bounded leak per such eval, against silently +//! answering `Finished` to a `join` that was about to happen. +//! +//! Once the owner is gone the box is dead and dioxus's own `try_write`/ +//! `try_read` failure path answers `EvalError::Finished` — which is also +//! what a second `join` gets, matching the WIT's `finished` case. + +use std::cell::RefCell; +use std::future::Future; +use std::pin::Pin; +use std::rc::Rc; +use std::task::{Context, Poll, Waker}; + +use dioxus_document::{Document, Eval, EvalError, Evaluator}; +use generational_box::{AnyStorage, GenerationalBox, Owner, UnsyncStorage}; +use wit_bindgen::rt::async_support::spawn_local; + +use crate::bindings::polymorph::dioxus::eval; + +/// The polyengine document provider, installed as `Rc` root +/// context by [`crate::driver::run`] (dioxus-document's `document()` looks it +/// up with `try_consume_context`, dioxus-document-0.7.10 src/lib.rs:14). +pub struct WitDocument; + +impl Document for WitDocument { + fn eval(&self, js: String) -> Eval { + Eval::new(WitEvaluator::create(js)) + } +} + +/// Where the completed `join` result lands, and how the awaiting task (if +/// any) learns about it. +#[derive(Default)] +struct JoinState { + result: Option>, + waker: Option, + /// Whether anything has ever polled the evaluator. Gates the + /// fire-and-forget release — see the module doc. + polled: bool, +} + +/// The owner slot; `None` once released. +type OwnerSlot = Rc>>>; + +type NextRecv = Pin>>>; + +struct WitEvaluator { + handle: Rc, + state: Rc>, + owner: OwnerSlot, + /// Lazily constructed `recv` future, dropped on completion — dioxus-web's + /// `next_future` pattern (dioxus-web-0.7.10 src/document.rs:277-294). + next_recv: Option, +} + +impl WitEvaluator { + fn create(js: String) -> GenerationalBox> { + // Construction starts the script: its synchronous prefix runs here, + // on this stack (wit/world.wit, `resource evaluation`). + let handle = Rc::new(eval::Evaluation::new(&js)); + let state = Rc::new(RefCell::new(JoinState::default())); + let owner_storage = UnsyncStorage::owner(); + let owner: OwnerSlot = Rc::new(RefCell::new(None)); + + let boxed = owner_storage.insert(Box::new(Self { + handle: handle.clone(), + state: state.clone(), + owner: owner.clone(), + next_recv: None, + }) as Box); + // Filled before the join task can run, so its release path never + // finds an empty slot. + *owner.borrow_mut() = Some(owner_storage); + + spawn_local(async move { + let result = match handle.join().await { + Ok(json) => serde_json::from_str(&json).map_err(EvalError::Serialization), + Err(e) => Err(eval_error(e)), + }; + let (waker, unobserved) = { + let mut state = state.borrow_mut(); + state.result = Some(result); + (state.waker.take(), !state.polled) + }; + match waker { + Some(waker) => waker.wake(), + // Nobody is awaiting and nobody ever polled: fire-and-forget. + // Release now rather than hold the host resource for the life + // of the instance. + None if unobserved => drop(owner.borrow_mut().take()), + None => {} + } + }); + + boxed + } +} + +impl Evaluator for WitEvaluator { + fn poll_join(&mut self, cx: &mut Context<'_>) -> Poll> { + let mut state = self.state.borrow_mut(); + state.polled = true; + match state.result.take() { + Some(result) => { + drop(state); + // Not inline: we are inside `Eval::join`'s `try_write` guard + // on the box this owner holds (dioxus-document-0.7.10 + // src/eval.rs:22-27). + let owner = self.owner.clone(); + spawn_local(async move { drop(owner.borrow_mut().take()) }); + Poll::Ready(result) + } + None => { + state.waker = Some(cx.waker().clone()); + Poll::Pending + } + } + } + + fn poll_recv(&mut self, cx: &mut Context<'_>) -> Poll> { + self.state.borrow_mut().polled = true; + if self.next_recv.is_none() { + let handle = self.handle.clone(); + self.next_recv = Some(Box::pin(async move { + match handle.recv().await { + Ok(json) => serde_json::from_str(&json).map_err(EvalError::Serialization), + Err(e) => Err(eval_error(e)), + } + })); + } + let result = self.next_recv.as_mut().unwrap().as_mut().poll(cx); + if result.is_ready() { + self.next_recv = None; + } + result + } + + fn send(&self, data: serde_json::Value) -> Result<(), EvalError> { + // Values cross as JSON text both ways (wit/world.wit, `interface + // eval`), so there is no serialization step left to fail. + self.handle.send(&data.to_string()); + Ok(()) + } +} + +/// The WIT `error` cases onto dioxus's `EvalError`, one for one. +fn eval_error(error: eval::Error) -> EvalError { + match error { + eval::Error::InvalidJs(message) => EvalError::InvalidJs(message), + eval::Error::Communication(message) => EvalError::Communication(message), + eval::Error::Finished => EvalError::Finished, + } +} diff --git a/src/driver.rs b/src/driver.rs index 78e31ac..1773f6b 100644 --- a/src/driver.rs +++ b/src/driver.rs @@ -258,6 +258,13 @@ pub async fn run(root: fn() -> Element, mode: RenderMode) -> MutationStream { dioxus_html::set_event_converter(Box::new(WitEventConverter)); let dom = VirtualDom::new(root); + // dioxus-document's `document()` resolves `Rc` out of the + // root scope's context (dioxus-document-0.7.10 src/lib.rs:14); without + // this it falls back to `NoOpDocument`. + #[cfg(feature = "eval")] + dom.provide_root_context( + Rc::new(crate::document::WitDocument) as Rc + ); let interner = Rc::new(RefCell::new(Interner::new())); RUNTIME.set(Some(dom.runtime())); INTERNER.set(Some(interner.clone())); diff --git a/src/lib.rs b/src/lib.rs index 8619ca5..a5b8497 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -16,12 +16,18 @@ //! it. //! //! An application crate wires itself up with [`launch!`]. +//! +//! Under the optional `eval` feature, `document` (wasm32 only) adds the +//! `Document` provider backing `document::eval` over the world's opt-in +//! `eval` interface. pub mod hydrate; pub mod interner; #[cfg(target_arch = "wasm32")] pub mod bindings; +#[cfg(all(target_arch = "wasm32", feature = "eval"))] +pub mod document; #[cfg(target_arch = "wasm32")] pub mod driver; #[cfg(target_arch = "wasm32")] diff --git a/wit/world.wit b/wit/world.wit index b6616be..7675da7 100644 --- a/wit/world.wit +++ b/wit/world.wit @@ -544,6 +544,79 @@ interface dom { set-focus: func(target: u32, focus: bool) -> bool; } +/// Arbitrary JavaScript evaluation in the host page: the back end of +/// dioxus's `document::eval` (dioxus-document-0.7.10 src/document.rs, +/// `Document::eval`; src/eval.rs, `Evaluator`). +/// +/// OPT-IN, ON BOTH SIDES. A browser cannot sandbox arbitrary JS well enough +/// for a host that runs untrusted components — polyvisor permits eval to the +/// visor, which is part of its trusted computing base, and to no app, ever. +/// So: +/// +/// - Guest: the renderer crate references this interface only under its +/// `eval` Cargo feature. A component built without that feature does not +/// import `polymorph:dioxus/eval` at all (wit-component encodes only the +/// interfaces the core module actually imports); `document::eval` there +/// resolves to dioxus's `NoOpDocument` and answers `EvalError::Unsupported`. +/// The justfile's `example` recipe asserts the absence. +/// - Host: `mountApp` supplies this import only when asked +/// (`MountOptions.eval`, host/src/host.ts). A component that imports it +/// against a host that did not opt in fails to instantiate. Failure is the +/// safe direction, and it is loud. +/// +/// Semantics follow dioxus-web's `WebEvaluator` and dioxus-desktop's +/// `DesktopEvaluator` where the two agree, so app code written for those +/// renderers behaves the same here: +/// +/// - The script is the BODY of an async function taking one parameter, +/// `dioxus`, so `await` and `return` are legal at top level. `dioxus.send(v)` +/// queues `v` for the guest's `recv`; `await dioxus.recv()` yields the next +/// value the guest `send`s. (dioxus-web: `PROMISE_WRAPPER`, src/document.rs; +/// dioxus-desktop: `AsyncFunction("dioxus", script)`, src/query.rs.) +/// - Values cross as JSON text, both ways. The script's return value is +/// `JSON.stringify`'d; `undefined` (a script with no `return`) crosses as +/// `null`, as dioxus-desktop's does, so `eval("...").await` on a statement +/// script succeeds. +/// - The script runs in the host's realm with whatever that realm can reach — +/// `document`, `window`, the mount root. That is the point, and the reason +/// for the opt-in. +interface eval { + /// Why an evaluation could not deliver a value. The cases the host can + /// actually produce; the guest maps them onto dioxus-document's + /// `EvalError`. + variant error { + /// The script did not compile (a `SyntaxError` constructing the + /// function). Reported by `join` — a constructor cannot fail. + invalid-js(string), + /// The script threw, or a value could not be represented as JSON. + communication(string), + /// The script has completed and nothing more will arrive: `recv` with an + /// empty queue after completion, or `join` a second time. + finished, + } + + /// One running script. Construction starts it; the synchronous prefix + /// (everything before the script's first `await`) runs INSIDE the + /// constructor, on the calling guest's stack — so it is bracketed by the + /// host's dispatch gate like `dom.set-focus` is (host/src/dispatch.ts, + /// window 3), since that prefix can mutate the DOM and fire delegated + /// events. Continuations after an `await` run on their own microtasks, + /// outside any guest turn, and need no bracket. + /// + /// Dropping the handle does not stop the script (nothing could — this is + /// a JS promise, not a task); it only releases the host's queues. + resource evaluation { + constructor(js: string); + /// Queue a JSON value for the script's next `await dioxus.recv()`. + send: func(json: string); + /// The next value the script passed to `dioxus.send`, as JSON. Parks + /// until one is available. + recv: async func() -> result; + /// The script's completion: its return value as JSON, or how it failed. + join: async func() -> result; + } +} + /// A Dioxus application component. The host instantiates it, calls `run` /// once — which returns the mutation stream the app renders through — and /// dispatches DOM events via `handle-event`. @@ -553,6 +626,9 @@ world app { import events; import dom; + /// Opt-in; see the interface doc. Present in a component's imports only + /// when the renderer was built with its `eval` feature. + import eval; /// How the app's initial render meets the mount root. variant render-mode {