Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -390,9 +390,17 @@ Mechanics:
everything variable arrives by `postMessage`.
- The app UI frame gets **zero direct network**; assets arrive via
RPC/blob injection from the component's embedded bundle.
- App logic runs in workers on the framework side (polyengine,
runtime-linked); UI ↔ shell ↔ component is a two-hop RPC path,
acceptable for UI latencies.
- **The app's component runs INSIDE the frame** (ruled 2026-09-05,
[#142](../../issues/142), [#147](../../pull/147); spikes 2–4 measured
it in Chromium 143 and Firefox 144). `polyvisor:surface` binds to the
frame's own DOM same-realm and synchronously, so the two-hop op path
this bullet used to describe is gone from the app path; every OTHER
import is proxied to the visor over its own `MessagePort`, and the
visor calls the app's exports over a control port. The linker rule
that follows: **an import that crosses a realm must be async-declared
in the WIT** — the guest then suspends through the component model's
async ABI, so the frame needs no JSPI (the runtime SharedWorker's
requirement is untouched; the frame's floor is lower).

Residual channels, each needing a recorded ruling (allow/block/why) in
a **ruling table per sandbox flag and CSP directive** — the same
Expand Down
6 changes: 3 additions & 3 deletions demo/host/demo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2310,7 +2310,7 @@ async function boot() {
}
| null = null;
/** The live panel surface's sandboxed frame, if any (see
* frame-backend.ts). Teardown must destroy it explicitly: clearing the
* visor/frame/mount.ts). Teardown must destroy it explicitly: clearing the
* region would orphan the port and the window listener. */
let panelFrame: { destroy(): Promise<void> } | null = null;

Expand All @@ -2334,7 +2334,7 @@ async function boot() {
* caused it returns. Frame teardown is the same shape and had no
* signal at all: `destroy()` returned void, so a remount had no way to
* ask "is the old surface actually gone?" and simply hoped. It is a
* promise now (frame-backend.ts's `destroy`), and this holds the
* promise now (the mount's `destroy`), and this holds the
* in-flight one so `mountPanel` can await it.
*
* Null when no teardown is outstanding. */
Expand Down Expand Up @@ -2428,7 +2428,7 @@ async function boot() {
// THE PREVIOUS SURFACE MUST BE ACTUALLY GONE before this one is
// stood up. Teardown does not finish when `teardownPanel()` returns
// — the old frame's window can still have messages in flight toward
// the visor (frame-backend.ts's `destroy`), and creating the next frame
// the visor (the mount's `destroy`), and creating the next frame
// inside that window is how a stale delivery ends up attributed to
// the new surface. Awaiting the completion is what turns "reopen
// immediately after ESC" from a race into an ordering.
Expand Down
11 changes: 7 additions & 4 deletions demo/justfile
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ check:
# Assemble the servable demo directory.
site: translate app panels check
mkdir -p serve
deno check host/demo.ts host/solo.ts host/solo-worker.ts ../visor/frame/frame.ts ../visor/frame/frame-backend.ts
deno check host/demo.ts host/solo.ts host/solo-worker.ts ../visor/frame/frame.ts ../visor/frame/mount.ts
deno bundle --platform browser --minify --external node-datachannel --external "node-datachannel/polyfill" --external werift -o serve/demo.js host/demo.ts
# THE SOLO PAGE: the same served artifacts, a second and much smaller
# embedder (host/solo.ts).
Expand Down Expand Up @@ -172,8 +172,11 @@ site: translate app panels check
! grep -q 'node:' serve/solo-worker.js || { echo "serve/solo-worker.js imports a node: builtin — check the --external set" >&2; exit 1; }
! grep -q 'node:' serve/demo.js || { echo "serve/demo.js imports a node: builtin — check the --external set" >&2; exit 1; }
# The sandboxed surface frame (#16): its own entry, its own document.
# No --external flags needed — the frame imports only the applier and
# never touches a node backend.
# It now carries the polyengine embedder too — since #142 the app's
# component is instantiated INSIDE the frame — so this bundle is
# ~190 KB rather than ~3 KB. Still no --external flags: the embedder
# pulls in no node backend (verified with the same `node:` grep the
# three bundles above run).
deno bundle --platform browser --minify -o serve/frame.js ../visor/frame/frame.ts
cp ../visor/frame/frame.html serve/
cp build/app.component.wasm build/app.plan.json serve/
Expand All @@ -200,7 +203,7 @@ serve: site
# ?relay=&s3= overrides — Pages hosts the artifact, not the network.
pages: translate app panels check
mkdir -p ../docs/demo
deno check host/demo.ts host/solo.ts host/solo-worker.ts ../visor/frame/frame.ts ../visor/frame/frame-backend.ts
deno check host/demo.ts host/solo.ts host/solo-worker.ts ../visor/frame/frame.ts ../visor/frame/mount.ts
deno bundle --platform browser --minify --external node-datachannel --external "node-datachannel/polyfill" --external werift -o ../docs/demo/demo.js host/demo.ts
# THE SOLO PAGE (see `site` above for why the --external set must
# match demo.js): same two bundles, same node: tripwire.
Expand Down
11 changes: 8 additions & 3 deletions demo/scripts/check-invariants.sh
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,10 @@ echo " (the visor's word for a component is never readable or influenceable
# that layer grows rather than need this list edited. It grew one
# already: frame/mount.ts, the app-mount seam (#142), which holds the
# mount options and is therefore exactly where a caller would hand a
# component something it must not have.
# component something it must not have. Since the instance moved INTO
# the frame the seam is mount.ts + frame.ts + frame.html — frame-backend.ts
# is gone — and checks (f) and (i1) below scan the same glob for the
# same reason.
FRAME_SEAM="../visor/frame/*.ts ../visor/frame/*.html"
# shellcheck disable=SC2086
hits=$(grep -n "petname" $FRAME_SEAM 2>/dev/null)
Expand Down Expand Up @@ -181,8 +184,9 @@ fi
# a rogue definition anywhere on either side fails here.
echo "[6/9] pairing code and SAS render only in visor-owned surfaces"
echo " (renderPairingCode()/renderSas() are defined and called only in ../visor/ui/pairing.ts)"
# shellcheck disable=SC2086
outside=$(grep -rln "renderPairingCode(\|renderSas(" \
../visor/frame/frame.ts ../visor/frame/frame-backend.ts ../visor/frame/frame.html web/frame.js \
$FRAME_SEAM web/frame.js \
../examples/todomvc/guest ../providers/s3/panel ../providers/dropbox/panel \
2>/dev/null)
if [ -n "$outside" ]; then
Expand Down Expand Up @@ -390,8 +394,9 @@ fi
# would be believed.
echo "[9/9] the entry ceremonies render only in the visor's drawer"
echo " (mountDevicePicker()/offerFirstRun() live in ../visor/ui/entry.ts; no page markup below the strip)"
# shellcheck disable=SC2086
outside=$(grep -rln "mountDevicePicker(\|offerFirstRun(" \
../visor/frame/frame.ts ../visor/frame/frame-backend.ts ../visor/frame/frame.html web/frame.js \
$FRAME_SEAM web/frame.js \
../examples/todomvc/guest ../providers/s3/panel ../providers/dropbox/panel \
2>/dev/null)
if [ -n "$outside" ]; then
Expand Down
6 changes: 5 additions & 1 deletion spikes/todomvc/deno.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,17 @@
"demo/, and demo/deno.json calls the embedder @polyengine/runtime/embedder.",
"Both keys resolve to one URL, so the graph still holds exactly one",
"embedder and one exception brand — which is the property that",
"mattered, not the spelling."
"mattered, not the spelling. Same for @polyengine/protocol beside",
"@deltic/protocol: the frame runtime carries WIT errors across its",
"ports as the protocol's cloneable form, and one copy of it is what",
"makes the rehydrated exception the real brand on both sides."
],
"imports": {
"@deltic/runtime/embedder": "../../../polyengine-dioxus/.deps/polyengine/runtime/src/embedder/mod.ts",
"@polyengine/runtime/embedder": "../../../polyengine-dioxus/.deps/polyengine/runtime/src/embedder/mod.ts",
"@deltic/runtime/shim": "../../../polyengine-dioxus/.deps/polyengine/runtime/src/shim/mod.ts",
"@deltic/protocol": "../../../polyengine-dioxus/.deps/polyengine/protocol/src/mod.ts",
"@polyengine/protocol": "../../../polyengine-dioxus/.deps/polyengine/protocol/src/mod.ts",
"@deltic/translator": "../../../polyengine-dioxus/.deps/polyengine/translator/mod.ts",
"@polyengine/wasi": "../../../polyengine-dioxus/.deps/polyengine/wasi/src/mod.ts",
"@polyengine/dioxus-host/": "../../../polyengine-dioxus/host/src/",
Expand Down
56 changes: 35 additions & 21 deletions visor/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,28 +18,42 @@ Three layers, one trust story:
suspension), and the `events` records. Everything an app's DOM ops
and events cross, validated on both sides of every seam.

- **`frame/`** — iframe isolation. `frame-backend.ts` (trusted side)
creates a `sandbox="allow-scripts"` iframe — no `allow-same-origin`,
- **`frame/`** — iframe isolation, and since #142 the place the app
actually RUNS. `mount.ts` (visor side) is the layer's **app-mount
seam**: `mountApp()` creates a
`sandbox="allow-scripts allow-forms"` iframe — no `allow-same-origin`,
so the app's document gets an OPAQUE ORIGIN and structurally cannot
read the visor's DOM, styles, or storage. It gets no `src`: the
backend assembles the frame's document as a `srcdoc` string from
`frame.html` (a template), the stylesheet, and the bundled
`frame.js`, all fetched in the visor's own realm — pinned by value,
where a real URL would be invisible to the verifying service worker
and unpinnable (#142). Into that document it inserts a `<meta>` CSP
of `default-src 'none'` with the script hash-listed; CSP policies
compose, so the frame is network-dead whatever the visor's own policy
allows. `frame.ts` is the code that runs there: the applier wired to
a MessagePort, height reporting, coarse theme (never the anchor
colour). The queued-op protocol is identical to `channel`; only the
realm changes. `mount.ts` is the layer's **app-mount seam**:
`mountApp()` stands a component up on one of these frames — frame,
surface, imports, the serialized guest-call chain — and hands back
only what a visor needs (the exports, the route, input suspension,
teardown). Every embedder mounts through it, so the placement change
#142 rules for (the wasm instance moving INSIDE the frame, surface
imports bound to its real DOM, everything else proxied over a port)
is a change of this file's internals and of nothing above it.
read the visor's DOM, styles, or storage. It gets no `src`: mount
assembles the document as a `srcdoc` string from `frame.html` (a
template), the stylesheet, and the bundled `frame.js`, all fetched in
the visor's own realm — pinned by value, where a real URL would be
invisible to the verifying service worker and unpinnable. Into it goes
a `<meta>` CSP of `default-src 'none'` (plus the script hash,
`'wasm-unsafe-eval'`, `blob:`/`data:` media and `form-action 'none'`);
CSP policies compose, so the frame is network-dead whatever the
visor's own policy allows.

`frame.ts` is the code that runs there, and it is now the app's
embedder: it instantiates the guest IN THE FRAME and binds
`polyvisor:surface` straight to that document's DOM through
`createDirectBackend` — same realm, synchronous. **The queued op
protocol is gone from the app path**: no op arrays, no structured
clone per DOM call, no second validation pass, and no `applier.ts` in
a mounted app's graph (both survive in `surface/` as the differential
harness's instrument and as the trusted-territory backend). What
crosses a realm now is only what must — the app's OTHER imports, one
MessagePort each, and the visor's calls into its exports on a control
port. WIT errors round-trip as errors and traps as rejections, in the
embedder's cloneable form.

The linker rule that keeps this cheap: **an import that crosses to the
visor must be async-declared in the WIT**, so the guest suspends
through the component model's async ABI and the frame needs no JSPI.

`mountApp()` hands a visor back only what it needs: the exports, the
route, input suspension (`inert` on the iframe plus the frame's own
runner gate and focus drop — neither is sufficient alone) and
teardown.

- **`ui/`** — the system UI core. `initVisor()` renders the strip
(two-line context, identity cluster), announcements
Expand Down
Loading
Loading