Skip to content

dor iframe: transparent proxy for the iframe surface (VS Code + standalone)#143

Merged
nedtwigg merged 11 commits into
mainfrom
iframe-transparent-proxy
Jun 17, 2026
Merged

dor iframe: transparent proxy for the iframe surface (VS Code + standalone)#143
nedtwigg merged 11 commits into
mainfrom
iframe-transparent-proxy

Conversation

@nedtwigg

Copy link
Copy Markdown
Member

dor iframe: transparent proxy for the iframe surface (VS Code + standalone)

Implements the "Transparent Proxy (Instrumented Iframe)" section of docs/specs/dor-iframe.md. dor iframe <url> used to point a raw <iframe> at the target — a separate browsing context Dormouse couldn't observe or control (focus left Dormouse, blocked pages went blank, no error signals). It now fronts the target with a host-owned loopback proxy so Dormouse serves the bytes, gaining a keyboard side-channel, an accurate focus model, and real error pages. Works for loopback dev servers on both hosts.

How it works

IframePanel asks the host for a proxy URL (createIframeProxyUrl) and frames that. The proxy fetches the upstream and serves it back on loopback:

Target Behavior
Loopback http Full instrument: strip X-Frame-Options, drop page CSP, inject the shim
Remote http, frameable Best-effort render with the shim
Remote http, refuses framing Error page → dor ab open <url> (scoped CSP frame-ancestors * = frameable; https://*.x.com = restrictive)
Unreachable / stalled Error page ("is the dev server running?" / "isn't responding")
https Deferred — panel shows a dor ab hint
  • Keyboard side-channel + click adoption: a fixed Dormouse-owned shim (injected before </head>, never user-supplied) posts only the reserved leader chord (dual-tap ⌘/⇧) and a pointerdown (a cross-origin click reaches only the frame). The Wall validates event.origin and re-enters the same dispatch / selects the pane.
  • Focus model: a focused iframe keeps document.hasFocus() true, so the app doesn't read it as backgrounded; focusSession focuses the frame via a registered handle and parks focus back on the pane on exit.
  • Streaming: the shim is injected into the <head> as bytes arrive (latin1-preserving across chunk boundaries), then the rest is piped — no full-document buffering.
  • Hardening: upstream idle-timeout → served error page (no silent blank), Location rewrite onto the proxy origin, abort-on-disconnect, WebSocket passthrough (HMR), sandbox without allow-top-navigation (anti-framebust), SSRF guard on link-local/metadata.
  • Cursor alignment: translateZ(0) on the frame container + renderer: 'always' (dockview's onlyWhenVisible detach reloads iframes) keep clicks aligned and the frame from reloading on (de)activation.

Shared across both hosts

The host-agnostic proxy lives once in lib/src/host/ as TypeScript: iframe-proxy-rewrite.ts (pure, dependency-free policy/instrumentation, unit-tested) + iframe-proxy.ts (the Node http/net server, logger injected). Consumed by:

  • VS Code — a thin wrapper injects the logger; esbuild bundles it into the extension host.
  • Standalone/Tauribuild-sidecar-proxy.mjs esbuilds it into the sidecar; a Rust iframe_create_proxy_url command bridges it; the Tauri adapter implements createIframeProxyUrl; the Tauri CSP narrows to the loopback origin.

Because IframePanel is shared, the webview needed no per-host changes. Deliberate design notes (in the spec): per-grant dedicated loopback server (the grant's origin is the grant → root-relative subresources proxy with zero rewriting), and no token in the URL (it would land in location.pathname and break client routers).

Also includes the standalone Vite dor alias fix and the Tauri window permissions (is-focused, internal-toggle-maximize) shaken out during testing.

Testing

  • Pure rewrite helpers unit-tested (incl. CSP frame-ancestors parsing); an integration test fronts a real loopback upstream and fetches through the proxy, covering streaming shim injection with </head> and a multibyte char split across chunk boundaries, non-HTML passthrough, Location rewrite, scheme/SSRF rejects, and the unreachable error page; an IframePanel component test covers raw-fallback click adoption.
  • lib tsc -b clean, full lib suite green (618 tests), VS Code extension bundles, sidecar proxy builds + runtime-proxies, standalone tsc -b clean, and the Rust crate cargo checks.
  • Manually verified live in both VS Code and the standalone app (render, click-to-select, leader round-trip, command-mode navigation, cursor alignment).

Known v1 gaps

  • https upstreams deferred; absolute-origin sub-resources (e.g. Vite's ws://localhost:5173 HMR) bypass the proxy (harmless on loopback); a Vite optimize-deps 504 on cold start is a dev-server quirk (warm Vite avoids it); no teardown-on-kill hook yet (idle sweep reaps the proxy server).

🤖 Generated with Claude Code

nedtwigg and others added 10 commits June 16, 2026 23:24
Move the host-agnostic proxy into lib/src/host as one TypeScript source so
both hosts run the same code instead of reimplementing it:

  - iframe-proxy-rewrite.ts — pure policy/rewriting (shim, instrumentHtml,
    refusesFraming/hasRestrictiveFrameAncestors, loopback/SSRF checks, error
    pages). Now unit-tested (17 cases), incl. the frame-ancestors logic.
  - iframe-proxy.ts — the Node http/net server, logger injected so it depends
    on neither host.
  - IframeProxyResult moves to a dependency-free leaf (platform/
    iframe-proxy-types.ts, re-exported) so the Node code doesn't pull the
    browser type-graph into a Node compile.

VS Code: iframe-proxy-host.ts collapses to a thin wrapper that injects `log`.

Standalone: build-sidecar-proxy.mjs esbuilds the shared TS into
sidecar/iframe-proxy.cjs (wired into stage/build/tauri, gitignored); the
sidecar handles iframe:createProxyUrl over stdio; a Rust
iframe_create_proxy_url command bridges it; tauri-adapter implements
createIframeProxyUrl; and the Tauri CSP gains frame-src for the loopback
proxy. IframePanel is shared, so the webview needs no changes.

Verified: lib tsc + 609 tests, VS Code bundle, sidecar cjs builds and
runtime-proxies, standalone tsc. The Rust command needs a machine with the
Rust toolchain to compile/run (cargo absent here).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Standalone's Vite aliased `dormouse-lib` to lib source but not `dor`, and
leaned on tsconfig `paths` for it — which Vite doesn't apply to lib's own
files (governed by lib's paths-less tsconfig), and `dor` has no package
`exports` to fall back on. So any lib file importing `dor/*` (e.g. Wall.tsx
→ `dor/commands/shell-quote`) failed to resolve, and the standalone app's
full UI never booted in dev. Add the `dor` → ../dor/src alias and an
fs.allow entry, mirroring how `dormouse-lib` is already handled.

Verified: standalone `vite build` transforms all modules with no resolution
errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…webview

Shaking out the iframe surface in the standalone (WKWebView) host surfaced
several interaction bugs the Chromium webview hid:

- Click now selects the pane. WebKit doesn't fire the iframe element's
  `focus` for a cross-origin click, and dockview's `api.isActive` is true
  per-group (so the old guard skipped adoption in a split). The shim — which
  runs inside the frame — posts `pointerdown`; the panel adopts it as
  entering the pane (select + passthrough). Navigation is keyboard-only, so
  it never triggers this.

- Command-mode keys work after the leader. Exiting passthrough now blurs the
  frame *and* parks focus on the pane root (top document), since blurring a
  cross-origin frame doesn't reliably hand focus back on WebKit; without it
  the frame kept focus and arrow navigation never reached the Wall.

- The frame no longer reloads when its pane is (de)activated. dockview's
  default `onlyWhenVisible` renderer detaches/reattaches panel DOM, which
  reloads an <iframe>; iframe panels now use `renderer: 'always'`.

- Clicking no longer blanks the frame / traps focus. Adoption keys off the
  frame's own focus/pointer signals (rising-edge) rather than a window-`blur`
  heuristic that also fired on the transition away, and the focus handle
  never re-focuses an already-focused frame.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…sions

The AppBar calls getCurrentWindow().isFocused() and toggleMaximize(); in
Tauri v2 the JS toggleMaximize() invokes the `internal_toggle_maximize`
command, which `allow-toggle-maximize` doesn't cover. Both were denied at
runtime ("not allowed" — core:window:allow-is-focused /
allow-internal-toggle-maximize). Add the two permissions to the default
capability. Validated by cargo check.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The proxy had no upstream timeout, so a hung dev server left the frame
blank indefinitely with no feedback. Harden it:

- 30s idle timeout on the upstream socket → a served, actionable error
  page. Idle-based, so streaming/SSR responses are never cut off.
- Distinct pages: "isn't responding (may be optimizing) — try reloading"
  vs "couldn't connect — is the dev server running?".
- Guard against a double writeHead crash if the upstream errors after we've
  started streaming the response.
- Abort the in-flight upstream fetch when the frame navigates away/closes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Inject the shim into the <head> as bytes arrive, then pipe the rest
untouched, rather than buffering the entire response before serving. Cuts
first-paint latency on large/streaming-SSR pages (e.g. a 200KB React Router
document) and bounds memory to the <head> region.

Subtleties handled: the insertion point (</head>, else <body>, else a cap)
can split across chunk boundaries, so we accumulate until it appears; the
head prefix is processed as latin1 (byte-preserving) so rewriting ASCII tags
and re-encoding can't corrupt a multibyte char split mid-byte; after
injection we hand the remainder to a raw pipe (backpressure + end for free),
with a `handled` flag so the manual end path and the pipe don't both close
the response. The response is now chunked (instrumentation changes length).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Node proxy server was esbuild-only (no tsc/unit coverage); add an
end-to-end test that fronts a real loopback upstream with createIframeProxyUrl
and fetches through the proxy. Covers the parts most likely to rot:

- streaming shim injection when </head> and a multibyte char both split
  across chunk boundaries (the latin1 byte-preservation path),
- loopback instrumentation (XFO/CSP stripped, shim before </head>, content
  and chunked encoding preserved),
- non-HTML passthrough, Location rewrite onto the proxy origin,
- scheme + SSRF admission rejects, and the unreachable error page.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Each proxied surface gets its own ephemeral loopback origin (a unique
OS-assigned port per grant), so an origin is only ever held by one live
surface — the reference count could never exceed 1. Drop the
Map<string,number> (and its `?? 0`/`?? 1` asymmetry) for a plain Set. API
and behavior unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jun 17, 2026

Copy link
Copy Markdown

Deploying mouseterm with  Cloudflare Pages  Cloudflare Pages

Latest commit: 9c68840
Status: ✅  Deploy successful!
Preview URL: https://a922fa14.mouseterm.pages.dev
Branch Preview URL: https://iframe-transparent-proxy.mouseterm.pages.dev

View logs

main landed the original iframe surface (#142 squash) plus two follow-ups
that taught the monolithic vscode-ext/src/iframe-proxy-host.ts to serve an
error page when collectBody truncated an over-limit HTML body.

This branch refactored that file into a thin wrapper — the proxy now lives in
lib/src/host/iframe-proxy.ts (shared with the Tauri sidecar) and streams the
HTML instead of buffering it. With streaming there is no full-body buffer and
thus no truncation failure mode, so main's truncation handling is superseded;
the conflict is resolved by keeping the thin wrapper. Merged tree is otherwise
identical to this branch's tip.

Verified: lib tsc + 620 tests, VS Code bundle, standalone tsc, sidecar build.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@nedtwigg nedtwigg merged commit 5e9978d into main Jun 17, 2026
9 checks passed
@nedtwigg nedtwigg deleted the iframe-transparent-proxy branch June 17, 2026 18:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants