Skip to content

fix(webview): let a proxied single-page app route on its own path, and recover a frame that reloads - #402

Open
shenlvkang-collab wants to merge 2 commits into
Ark0N:masterfrom
shenlvkang-collab:pr/webview-route-masking
Open

fix(webview): let a proxied single-page app route on its own path, and recover a frame that reloads#402
shenlvkang-collab wants to merge 2 commits into
Ark0N:masterfrom
shenlvkang-collab:pr/webview-route-masking

Conversation

@shenlvkang-collab

Copy link
Copy Markdown
Contributor

Summary

Stacked on #401 (its commit is the first in this branch; only the second commit is new). It fixes a bug that exists independently of #401: any single-page app opened as a web tab.

Symptom. A dashboard served through a web tab paints its HTML and CSS, then replaces them with its own "page not found" the moment its script runs. Reproduced with a minimal history-routed page: through the proxy, location.pathname is /webview/<cap>/, which no React Router / Vue Router / Vite dev-server app has a route for.

Fix, two halves.

  1. Route masking in the runtime shim. Before any page script runs, the shim rewrites the history entry to the path the page would see on its own origin (history.replaceState, which browsers allow inside the sandboxed frame — verified in Chromium). Only what the page reads changes: <base> still resolves relative URLs inside the prefix and every root-absolute sink is rewritten back into it. Because the masked document URL means the Referer-keyed 404 rescue can no longer help a request the shim misses, the remaining URL-taking entry points (Worker, SharedWorker, navigator.sendBeacon, window.open) are now covered by the shim as well.
  2. Lost-frame recovery. A navigation the page starts itself afterwards — location.reload() (a dev server's full-reload HMR), a root-absolute location.href — lands on Codeman's root with no capability anywhere (no prefix, no cookie in an opaque-origin frame, a Referer naming the masked page). It is recognised by shape only (Sec-Fetch-Dest: iframe, Accept: text/html, a path Codeman does not serve) and answered with a static page whose only script posts {type:'codeman:webview-lost', path} to the parent. The owning tab matches the frame by event.source (never by the payload), sanitises the path to a same-origin one, and remounts the frame inside the prefix at that path, bounded to 5 recoveries per minute per frame. The unauthenticated form is answered in the auth middleware before the credential checks, so a dev server that reloads on every save cannot rate-limit its own user out of Codeman; the authenticated form (Basic auth, trusted mode) is answered by the 404 handler. The page carries default-src 'none' plus the hash of its one script, and referrer: no-referrer.

This also turns the documented "root-absolute location navigation escapes the prefix" limit into a recovered case.

Verified end to end (Playwright, sandboxed frame, a history-routed test page): boots on / and its fetch('/api/data') succeeds; a reload inside the frame comes back routed on the path it had pushed; location.href = '/about' comes back on /about; a deep link opens on its path; no 4xx on the wire.

Docs: docs/web-tabs.md (layers 5–6, Known limits). Changeset: patch.

Test plan

  • test/webview-proxy.test.ts: masking (path/search/hash, landing page → /, non-prefixed document untouched), rewrites still applied after masking, window.open patch; isLostWebviewFrameNavigation shape checks; the recovery page's script hash matches its CSP.
  • test/webview-auth-exemption.test.ts: a lost frame gets the recovery page (200, HTML, CSP); never for /, a registered route, or a plain navigation; 20 recoveries do not trip the auth failure limit.
  • test/webview-loopback-links.test.ts: remount at the lost path inside the prefix, bare reload → landing page, foreign event.source / malformed payload ignored, //host/x cannot jump the frame, loop bounded.
  • Full test/webview-* set green; tsc --noEmit, eslint, prettier.

🤖 Generated with Claude Code

shenlvkang-collab and others added 2 commits September 10, 2026 12:48
…other device

An agent prints `http://localhost:5173/` (a dev server, a preview it just
served) and the user taps it on a phone. That address only exists on the
Codeman box, so the link was a guaranteed connection error from any other
device — while the web-tab proxy fetches from the server, where it works.

A loopback link (`localhost`, `*.localhost`, 127/8, 0.0.0.0, ::1) activated
in the terminal or clicked in the Response Viewer now opens as a proxied
web tab whenever the Codeman page itself is not on that box. A saved
proxied dashboard on the same origin is reused, with the link's own path,
query and fragment opened inside it (a mounted frame is navigated, not torn
down, so its state survives); otherwise one is saved under its host:port,
sandboxed like any other web tab, so it is in the Run dropdown next time.

Only loopback is routed this way. A LAN or tailnet address may well be
reachable from the device (a VPN, the same Wi-Fi) and a direct open is the
cheaper, richer path, so those keep opening in a new browser tab; on the
box itself every link opens directly. The terminal link provider and the
viewer's click handler consult one hook and fall through to their existing
behaviour when it declines.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01McLWqCWBuQYGuPMScb4Aou
…d recover a frame that reloads

A dashboard served through a web tab saw `/webview/<cap>/` as its
`location.pathname`, and no app has a route for that: a React Router, Vue
Router or Vite dev-server page painted its HTML and CSS and then replaced
them with its own "page not found" the moment its script ran (reproduced
with a minimal history-routed page).

The proxy's runtime shim now rewrites the history entry to the path the
page would see on its own origin, before any page script runs. The base
element still resolves relative URLs inside the prefix and every root-
absolute sink is rewritten back into it, so only what the page READS
changes. With the document URL masked the Referer-keyed 404 rescue can no
longer help a request the shim misses, so the remaining URL-taking entry
points (`Worker`, `SharedWorker`, `navigator.sendBeacon`, `window.open`)
are covered by the shim as well.

A navigation the page starts itself afterwards — `location.reload()`
(a dev server's full-reload HMR), a root-absolute `location.href` — lands
on Codeman's root with no capability anywhere: no prefix in the path, no
cookie in an opaque-origin frame, a Referer naming the masked page. It is
recognised by shape (a top-level iframe navigation asking for HTML, for a
path Codeman does not serve) and answered with a static page whose only
script posts `{type:'codeman:webview-lost', path}` to the parent; the tab
that owns the frame (matched by `event.source`, never by the payload)
remounts it inside the prefix at that path, bounded per frame. The
unauthenticated form is answered in the auth middleware before the
credential checks, so a dev server that reloads on every save cannot
rate-limit its own user out of Codeman; the authenticated form (Basic
auth, trusted mode) is answered by the 404 handler.

Verified end to end against a history-routed page: boots on `/`, its
API call succeeds, a reload inside the frame comes back routed on the
path it had pushed, `location.href = '/about'` comes back on `/about`,
and a deep link opens on its path.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
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.

1 participant