From 817db25696ebf4c808843ba3fef4e4466665a10e Mon Sep 17 00:00:00 2001
From: Misha Kaletsky <15040698+mmkal@users.noreply.github.com>
Date: Tue, 19 May 2026 23:56:43 +0100
Subject: [PATCH 1/3] Specify browser demo page task
---
tasks/browser-demo-page.md | 24 ++++++++++++++++++++++++
1 file changed, 24 insertions(+)
create mode 100644 tasks/browser-demo-page.md
diff --git a/tasks/browser-demo-page.md b/tasks/browser-demo-page.md
new file mode 100644
index 0000000..37ffe36
--- /dev/null
+++ b/tasks/browser-demo-page.md
@@ -0,0 +1,24 @@
+---
+status: in-progress
+size: medium
+---
+
+Summary: Spec fleshed out for bedtime implementation. Build a static, GitHub-Pages-friendly demo page that lets someone create a Captun tunnel from the browser by editing a fetch handler in a textarea, without introducing a framework or build step.
+
+- [ ] Add a static demo page under `docs/` that can be served by GitHub Pages or opened locally.
+- [ ] Let the user enter a Captun server URL, tunnel name, optional bearer secret, and fetch handler source.
+- [ ] Use the browser session to create a real tunnel and expose the public tunnel URL when connected.
+- [ ] Provide a sensible default handler that returns JSON/text so the demo is useful immediately.
+- [ ] Show connection, request, response, and error logs in the page without requiring devtools.
+- [ ] Document how to open/use the demo from `README.md`.
+- [ ] Avoid adding a new app framework, bundler, or deployment dependency for this task.
+
+## Assumptions
+
+- The first version can depend on the published Captun browser entrypoint or a CDN import, but the page should make that dependency obvious in the source.
+- Browser WebSocket clients cannot send arbitrary headers, so bearer auth should be handled only if the selected browser/runtime import path supports it; otherwise the UI should explain that browser demos need a non-secret demo Worker.
+- The page is a product demo/tool, not a marketing landing page. The first screen should be the runnable tunnel controls.
+
+## Notes
+
+Original prompt: "We could probably have a github-pages page which shows a tunnel from a browser session. Write a fetch handler in a textarea, and that can start serving real http requests."
From 9cb9180bba31ef7f15bb8ba4d0c8cd9daacbe09b Mon Sep 17 00:00:00 2001
From: Misha Kaletsky <15040698+mmkal@users.noreply.github.com>
Date: Wed, 20 May 2026 00:10:25 +0100
Subject: [PATCH 2/3] Add static browser tunnel demo
---
README.md | 12 +
docs/browser-demo-client.js | 87 ++++
docs/browser-demo.css | 359 ++++++++++++++
docs/browser-demo.html | 126 +++++
docs/browser-demo.js | 464 ++++++++++++++++++
tasks/browser-demo-page.md | 24 -
.../complete/2026-05-19-browser-demo-page.md | 38 ++
7 files changed, 1086 insertions(+), 24 deletions(-)
create mode 100644 docs/browser-demo-client.js
create mode 100644 docs/browser-demo.css
create mode 100644 docs/browser-demo.html
create mode 100644 docs/browser-demo.js
delete mode 100644 tasks/browser-demo-page.md
create mode 100644 tasks/complete/2026-05-19-browser-demo-page.md
diff --git a/README.md b/README.md
index 17cef7d..a588199 100644
--- a/README.md
+++ b/README.md
@@ -64,6 +64,18 @@ await new Promise(() => {}) // stay alive until killed
That's all you need! No local ports, just a fetch function.
+### Browser demo
+
+There is also a static browser demo in [docs/browser-demo.html](./docs/browser-demo.html). It runs without a framework or build step, so you can serve it directly while developing:
+
+```bash
+python3 -m http.server 8080 --directory docs
+```
+
+Then open `http://127.0.0.1:8080/browser-demo.html`, enter the origin of a deployed Captun Worker, choose a tunnel name, and edit the in-page fetch handler. The page connects from the browser session and shows the public tunnel URL plus request, response, and error logs.
+
+Browser WebSocket APIs cannot send arbitrary `Authorization` headers, so the demo needs a Captun Worker without `CAPTUN_SECRET`. Use the Node CLI for secret-protected tunnels.
+
## Advanced usage
The captun [worker.ts](./src/worker.ts) implementation has useful opinions about "named tunnels", but you can also take full control of the server implementation (which is what we do in [iterate/iterate](https://github.com/iterate/iterate)). For example, here's a weather application which allows mocking its egress to the weather API:
diff --git a/docs/browser-demo-client.js b/docs/browser-demo-client.js
new file mode 100644
index 0000000..1bbe3c4
--- /dev/null
+++ b/docs/browser-demo-client.js
@@ -0,0 +1,87 @@
+// Browser-only Captun client for docs/browser-demo.html.
+// This mirrors src/client.ts, but avoids depending on an unpublished captun npm package.
+window.createBrowserCaptunTunnel = createBrowserCaptunTunnel;
+
+async function createBrowserCaptunTunnel(options) {
+ const { newWebSocketRpcSession, RpcTarget } = await import(
+ "https://esm.sh/capnweb@0.8.0?bundle"
+ );
+ const socket = new WebSocket(toWebSocketUrl(options.url));
+ const session = newWebSocketRpcSession(
+ socket,
+ new (browserLocalFetcher(RpcTarget))(options.fetch),
+ );
+
+ try {
+ await waitUntilOpen(socket);
+ } catch (error) {
+ disposeSession(session);
+ throw error;
+ }
+
+ return {
+ [Symbol.dispose]() {
+ disposeSession(session);
+ },
+ close() {
+ disposeSession(session);
+ },
+ };
+}
+
+function browserLocalFetcher(RpcTarget) {
+ return class BrowserLocalFetcher extends RpcTarget {
+ constructor(fetchHandler) {
+ super();
+ this.fetchHandler = fetchHandler;
+ }
+
+ fetch(request) {
+ return this.fetchHandler(request);
+ }
+ };
+}
+
+function toWebSocketUrl(input) {
+ const url = new URL(input);
+ if (url.protocol === "https:") url.protocol = "wss:";
+ else if (url.protocol === "http:") url.protocol = "ws:";
+ return url.href;
+}
+
+async function waitUntilOpen(socket) {
+ if (socket.readyState === WebSocket.OPEN) return;
+ if (socket.readyState !== WebSocket.CONNECTING) {
+ throw new Error("WebSocket closed before opening");
+ }
+
+ const listeners = new AbortController();
+ await new Promise((resolve, reject) => {
+ const settle = (callback) => {
+ listeners.abort();
+ callback();
+ };
+
+ socket.addEventListener("open", () => settle(resolve), { signal: listeners.signal });
+ socket.addEventListener(
+ "error",
+ () => settle(() => reject(new Error("WebSocket connection failed"))),
+ { signal: listeners.signal },
+ );
+ socket.addEventListener(
+ "close",
+ (event) => {
+ settle(() => {
+ reject(new Error(`WebSocket closed before opening: ${event.code} ${event.reason}`));
+ });
+ },
+ { signal: listeners.signal },
+ );
+ });
+}
+
+function disposeSession(session) {
+ if (session && Symbol.dispose in session) {
+ session[Symbol.dispose]();
+ }
+}
diff --git a/docs/browser-demo.css b/docs/browser-demo.css
new file mode 100644
index 0000000..d66f1e7
--- /dev/null
+++ b/docs/browser-demo.css
@@ -0,0 +1,359 @@
+:root {
+ color-scheme: light;
+ --bg: #f6f7f9;
+ --panel: #ffffff;
+ --panel-alt: #f0f4f5;
+ --border: #cfd8dc;
+ --border-strong: #9aa9af;
+ --text: #152026;
+ --muted: #5b6970;
+ --accent: #0f766e;
+ --accent-strong: #115e59;
+ --danger: #b42318;
+ --warning: #9a6700;
+ --success: #166534;
+ --code: #111827;
+ font-family:
+ Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+body {
+ min-width: 320px;
+ margin: 0;
+ color: var(--text);
+ background: var(--bg);
+}
+
+button,
+input,
+select,
+textarea {
+ font: inherit;
+}
+
+button,
+.button-link {
+ min-height: 2.25rem;
+ border: 1px solid var(--border-strong);
+ border-radius: 6px;
+ padding: 0.45rem 0.75rem;
+ color: var(--text);
+ background: #ffffff;
+ line-height: 1.1;
+ text-align: center;
+ text-decoration: none;
+ white-space: nowrap;
+ cursor: pointer;
+}
+
+button:hover:not(:disabled),
+.button-link:hover {
+ border-color: var(--accent);
+}
+
+button:disabled,
+.button-link[aria-disabled="true"] {
+ color: #8a969c;
+ border-color: var(--border);
+ background: #eef1f2;
+ cursor: not-allowed;
+}
+
+.primary {
+ color: #ffffff;
+ border-color: var(--accent);
+ background: var(--accent);
+}
+
+.primary:hover:not(:disabled) {
+ border-color: var(--accent-strong);
+ background: var(--accent-strong);
+}
+
+.page-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 1rem;
+ min-height: 4rem;
+ padding: 0.8rem clamp(1rem, 3vw, 2rem);
+ border-bottom: 1px solid var(--border);
+ background: var(--panel);
+}
+
+.page-header h1 {
+ margin: 0;
+ font-size: 1.15rem;
+ line-height: 1.2;
+}
+
+.status {
+ min-width: 8.75rem;
+ border: 1px solid var(--border);
+ border-radius: 999px;
+ padding: 0.35rem 0.75rem;
+ color: var(--muted);
+ background: var(--panel-alt);
+ font-size: 0.875rem;
+ text-align: center;
+}
+
+.status[data-status="connecting"] {
+ color: var(--warning);
+ border-color: #d6a400;
+ background: #fff8d8;
+}
+
+.status[data-status="connected"] {
+ color: var(--success);
+ border-color: #86b888;
+ background: #e9f8ed;
+}
+
+.status[data-status="error"] {
+ color: var(--danger);
+ border-color: #e5aaa5;
+ background: #fff0ef;
+}
+
+.workspace {
+ display: grid;
+ grid-template-columns: minmax(19rem, 24rem) minmax(0, 1fr);
+ gap: 1rem;
+ width: min(96rem, 100%);
+ margin: 0 auto;
+ padding: 1rem;
+}
+
+.panel {
+ min-width: 0;
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ background: var(--panel);
+}
+
+.panel-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.75rem;
+ min-height: 3.25rem;
+ padding: 0.75rem 1rem;
+ border-bottom: 1px solid var(--border);
+}
+
+.panel-header h2 {
+ margin: 0;
+ font-size: 0.95rem;
+ line-height: 1.2;
+}
+
+.panel-body {
+ display: grid;
+ gap: 0.85rem;
+ padding: 1rem;
+}
+
+.field {
+ display: grid;
+ gap: 0.35rem;
+}
+
+.field span,
+.inline-label span {
+ color: var(--muted);
+ font-size: 0.82rem;
+ font-weight: 600;
+}
+
+.inline-field {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ gap: 0.5rem;
+ align-items: end;
+}
+
+.inline-label {
+ display: grid;
+ min-width: 0;
+ gap: 0.35rem;
+}
+
+input,
+select,
+textarea {
+ width: 100%;
+ min-width: 0;
+ border: 1px solid var(--border-strong);
+ border-radius: 6px;
+ padding: 0.55rem 0.65rem;
+ color: var(--text);
+ background: #ffffff;
+}
+
+textarea {
+ resize: vertical;
+}
+
+input:focus,
+select:focus,
+textarea:focus,
+button:focus-visible,
+.button-link:focus-visible {
+ outline: 3px solid #9dd7d0;
+ outline-offset: 1px;
+}
+
+.actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+}
+
+.notice {
+ margin: 0;
+ border-left: 3px solid var(--warning);
+ padding: 0.5rem 0.65rem;
+ color: #5f4100;
+ background: #fff8d8;
+ font-size: 0.86rem;
+ line-height: 1.35;
+}
+
+.readonly-row {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto auto;
+ gap: 0.5rem;
+}
+
+.handler-grid {
+ display: grid;
+ grid-template-rows: minmax(22rem, 1fr);
+ min-height: 28rem;
+}
+
+.code-editor,
+.probe-output {
+ font-family:
+ "SFMono-Regular", Consolas, "Liberation Mono", Menlo, ui-monospace, monospace;
+ font-size: 0.86rem;
+ line-height: 1.45;
+}
+
+.code-editor {
+ height: 100%;
+ min-height: 22rem;
+ color: #e5edf0;
+ border-color: #24323a;
+ background: var(--code);
+ tab-size: 2;
+}
+
+.lower-grid {
+ display: grid;
+ grid-template-columns: minmax(18rem, 0.7fr) minmax(20rem, 1fr);
+ gap: 1rem;
+}
+
+.probe-form {
+ display: grid;
+ gap: 0.75rem;
+}
+
+.probe-row {
+ display: grid;
+ grid-template-columns: 7.5rem minmax(0, 1fr);
+ gap: 0.5rem;
+}
+
+.probe-output {
+ min-height: 9rem;
+ color: var(--text);
+ background: #fbfcfd;
+}
+
+.log-list {
+ display: grid;
+ align-content: start;
+ gap: 0.35rem;
+ max-height: 18rem;
+ min-height: 9rem;
+ margin: 0;
+ padding: 0;
+ overflow: auto;
+ list-style: none;
+}
+
+.log-row {
+ display: grid;
+ grid-template-columns: 5.75rem 5.25rem minmax(0, 1fr);
+ gap: 0.5rem;
+ align-items: start;
+ border: 1px solid #e1e7ea;
+ border-radius: 6px;
+ padding: 0.45rem 0.55rem;
+ background: #fbfcfd;
+ font-size: 0.83rem;
+}
+
+.log-row time {
+ color: var(--muted);
+ font-variant-numeric: tabular-nums;
+}
+
+.log-kind {
+ color: var(--muted);
+ font-weight: 700;
+ text-transform: uppercase;
+}
+
+.log-row[data-kind="error"] {
+ border-color: #f0bbb6;
+ background: #fff7f6;
+}
+
+.log-row[data-kind="request"] {
+ border-color: #b6d3df;
+}
+
+.log-row[data-kind="response"] {
+ border-color: #bdd8be;
+}
+
+.log-row pre {
+ grid-column: 1 / -1;
+ max-height: 8rem;
+ margin: 0;
+ overflow: auto;
+ white-space: pre-wrap;
+}
+
+@media (max-width: 840px) {
+ .page-header {
+ align-items: stretch;
+ flex-direction: column;
+ }
+
+ .status {
+ width: 100%;
+ }
+
+ .workspace,
+ .lower-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .readonly-row,
+ .probe-row {
+ grid-template-columns: 1fr;
+ }
+
+ .button-link,
+ button {
+ width: 100%;
+ }
+}
diff --git a/docs/browser-demo.html b/docs/browser-demo.html
new file mode 100644
index 0000000..a9c7ec1
--- /dev/null
+++ b/docs/browser-demo.html
@@ -0,0 +1,126 @@
+
+
+
+
+
+ Captun Browser Demo
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/browser-demo.js b/docs/browser-demo.js
new file mode 100644
index 0000000..5dfc67e
--- /dev/null
+++ b/docs/browser-demo.js
@@ -0,0 +1,464 @@
+const storagePrefix = "captun-browser-demo:";
+const defaultHandler = `async function fetch(request, context) {
+ const url = new URL(request.url);
+ const headers = new Headers(context.corsHeaders);
+ headers.set("content-type", "application/json; charset=utf-8");
+
+ if (request.method === "OPTIONS") {
+ return new Response(null, { status: 204, headers: context.corsHeaders });
+ }
+
+ const body = request.method === "GET" || request.method === "HEAD"
+ ? null
+ : await request.text();
+
+ context.log("handled " + request.method + " " + url.pathname);
+
+ return new Response(JSON.stringify({
+ ok: true,
+ message: "Hello from a Captun browser tunnel",
+ tunnel: context.tunnelName,
+ method: request.method,
+ pathname: url.pathname,
+ search: url.search,
+ body
+ }, null, 2), { headers });
+}`;
+
+const elements = {
+ serverUrl: document.querySelector("#serverUrl"),
+ tunnelName: document.querySelector("#tunnelName"),
+ secret: document.querySelector("#secret"),
+ secretNotice: document.querySelector("#secretNotice"),
+ connectionForm: document.querySelector("#connectionForm"),
+ handlerSource: document.querySelector("#handlerSource"),
+ connect: document.querySelector("#connect"),
+ disconnect: document.querySelector("#disconnect"),
+ status: document.querySelector("#status"),
+ publicUrl: document.querySelector("#publicUrl"),
+ openPublicUrl: document.querySelector("#openPublicUrl"),
+ copyPublicUrl: document.querySelector("#copyPublicUrl"),
+ newName: document.querySelector("#newName"),
+ probeMethod: document.querySelector("#probeMethod"),
+ probePath: document.querySelector("#probePath"),
+ probeBody: document.querySelector("#probeBody"),
+ sendProbe: document.querySelector("#sendProbe"),
+ probeOutput: document.querySelector("#probeOutput"),
+ logList: document.querySelector("#logList"),
+ clearLog: document.querySelector("#clearLog"),
+};
+
+let activeTunnel;
+let activePublicUrl = "";
+let requestCount = 0;
+let createTunnel;
+
+initialize();
+
+function initialize() {
+ elements.serverUrl.value = storedValue("serverUrl", "");
+ elements.tunnelName.value = storedValue("tunnelName", randomTunnelName());
+ elements.handlerSource.value = storedValue("handlerSource", defaultHandler);
+ elements.probePath.value = storedValue("probePath", "/health");
+
+ elements.serverUrl.addEventListener("input", persistSettings);
+ elements.tunnelName.addEventListener("input", persistSettings);
+ elements.handlerSource.addEventListener("input", persistSettings);
+ elements.probePath.addEventListener("input", persistSettings);
+ elements.secret.addEventListener("input", updateSecretNotice);
+ elements.connectionForm.addEventListener("submit", (event) => event.preventDefault());
+ elements.connect.addEventListener("click", connect);
+ elements.disconnect.addEventListener("click", disconnect);
+ elements.copyPublicUrl.addEventListener("click", copyPublicUrl);
+ elements.openPublicUrl.addEventListener("click", openPublicUrl);
+ elements.newName.addEventListener("click", useNewTunnelName);
+ elements.sendProbe.addEventListener("click", sendProbe);
+ elements.clearLog.addEventListener("click", clearLog);
+ elements.handlerSource.addEventListener("keydown", insertTabInTextarea);
+ window.addEventListener("beforeunload", disposeActiveTunnel);
+
+ updateSecretNotice();
+ setDisconnected();
+ appendLog("connection", "Ready");
+}
+
+async function connect() {
+ if (activeTunnel) return;
+
+ const serverUrl = elements.serverUrl.value.trim();
+ const tunnelName = elements.tunnelName.value.trim();
+ const secret = elements.secret.value.trim();
+
+ if (!serverUrl) {
+ setError("Enter a Captun server URL");
+ return;
+ }
+
+ if (!isValidTunnelName(tunnelName)) {
+ setError("Use a lowercase tunnel name with letters, numbers, and dashes");
+ return;
+ }
+
+ if (secret) {
+ setError("Browser WebSocket clients cannot send bearer headers");
+ appendLog(
+ "error",
+ "Bearer secret was not sent",
+ "Use a Captun Worker without CAPTUN_SECRET for this browser demo, or use the Node CLI for secret-protected tunnels.",
+ );
+ return;
+ }
+
+ try {
+ compileHandler();
+ } catch (error) {
+ setError("Handler did not compile");
+ appendLog("error", "Handler did not compile", formatError(error));
+ return;
+ }
+
+ let publicUrl;
+ try {
+ publicUrl = buildPublicUrl(serverUrl, tunnelName);
+ } catch (error) {
+ setError("Server URL is invalid");
+ appendLog("error", "Server URL is invalid", formatError(error));
+ return;
+ }
+
+ setConnecting();
+ appendLog("connection", "Connecting to " + connectUrl(publicUrl));
+
+ try {
+ const createBrowserCaptunTunnel = await loadBrowserClient();
+ activeTunnel = await createBrowserCaptunTunnel({
+ url: connectUrl(publicUrl),
+ fetch: (request) => handleTunnelRequest(request, tunnelName),
+ });
+ activePublicUrl = publicUrl;
+ setConnected(publicUrl);
+ persistSettings();
+ appendLog("connection", "Connected", publicUrl);
+ } catch (error) {
+ activeTunnel = undefined;
+ activePublicUrl = "";
+ setError("Connection failed");
+ appendLog("error", "Connection failed", formatError(error));
+ }
+}
+
+function disconnect() {
+ if (!activeTunnel) return;
+ disposeActiveTunnel();
+ appendLog("connection", "Disconnected");
+ setDisconnected();
+}
+
+async function handleTunnelRequest(request, tunnelName) {
+ const id = requestCount + 1;
+ requestCount = id;
+
+ const url = new URL(request.url);
+ appendLog("request", "#" + id + " " + request.method + " " + url.pathname + url.search);
+
+ try {
+ const handler = compileHandler();
+ const response = await normalizeResponse(
+ await handler(request, {
+ corsHeaders: corsHeaders(),
+ log(message) {
+ appendLog("handler", "#" + id + " " + message);
+ },
+ publicUrl: activePublicUrl,
+ requestId: id,
+ tunnelName,
+ }),
+ );
+ appendLog("response", "#" + id + " " + response.status + " " + response.statusText);
+ return response;
+ } catch (error) {
+ appendLog("error", "#" + id + " handler error", formatError(error));
+ return jsonErrorResponse(error);
+ }
+}
+
+function compileHandler() {
+ const source = elements.handlerSource.value.trim();
+ const value = Function('"use strict"; return (' + source + ");")();
+
+ if (typeof value === "function") return value;
+ if (value && typeof value.fetch === "function") return value.fetch.bind(value);
+
+ throw new Error("Handler source must evaluate to a function or an object with fetch().");
+}
+
+async function normalizeResponse(value) {
+ const resolved = await value;
+ if (resolved instanceof Response) return resolved;
+ if (
+ typeof resolved === "string" ||
+ resolved instanceof Blob ||
+ resolved instanceof ReadableStream ||
+ resolved instanceof Uint8Array
+ ) {
+ return new Response(resolved);
+ }
+
+ throw new Error("Handler must return a Response, string, Blob, ReadableStream, or Uint8Array.");
+}
+
+async function sendProbe() {
+ if (!activePublicUrl) {
+ appendLog("error", "Connect before sending a probe request");
+ return;
+ }
+
+ const method = elements.probeMethod.value;
+ const url = probeUrl(activePublicUrl, elements.probePath.value);
+ const init = { method };
+
+ if (method !== "GET" && method !== "HEAD") {
+ init.body = elements.probeBody.value;
+ init.headers = { "content-type": "text/plain; charset=utf-8" };
+ }
+
+ elements.sendProbe.disabled = true;
+ elements.probeOutput.value = "Sending " + method + " " + url;
+ appendLog("request", "probe " + method + " " + url);
+
+ try {
+ const response = await fetch(url, init);
+ const text = method === "HEAD" ? "" : await response.text();
+ elements.probeOutput.value = [
+ response.status + " " + response.statusText,
+ "",
+ text,
+ ].join("\n");
+ appendLog("response", "probe " + response.status + " " + response.statusText);
+ } catch (error) {
+ elements.probeOutput.value = formatError(error);
+ appendLog("error", "Probe failed", formatError(error));
+ } finally {
+ elements.sendProbe.disabled = false;
+ }
+}
+
+async function copyPublicUrl() {
+ if (!activePublicUrl) return;
+
+ try {
+ await navigator.clipboard.writeText(activePublicUrl);
+ appendLog("connection", "Copied public URL");
+ } catch {
+ elements.publicUrl.select();
+ document.execCommand("copy");
+ appendLog("connection", "Copied public URL");
+ }
+}
+
+function openPublicUrl(event) {
+ if (!activePublicUrl) {
+ event.preventDefault();
+ return;
+ }
+ elements.openPublicUrl.href = activePublicUrl;
+}
+
+function useNewTunnelName() {
+ elements.tunnelName.value = randomTunnelName();
+ persistSettings();
+}
+
+function clearLog() {
+ elements.logList.replaceChildren();
+ appendLog("connection", "Log cleared");
+}
+
+async function loadBrowserClient() {
+ if (createTunnel) return createTunnel;
+ createTunnel = window.createBrowserCaptunTunnel;
+ if (typeof createTunnel !== "function") {
+ throw new Error("Browser Captun client did not load.");
+ }
+ return createTunnel;
+}
+
+function setConnecting() {
+ elements.status.textContent = "Connecting";
+ elements.status.dataset.status = "connecting";
+ elements.connect.disabled = true;
+ elements.disconnect.disabled = true;
+ elements.sendProbe.disabled = true;
+}
+
+function setConnected(publicUrl) {
+ elements.status.textContent = "Connected";
+ elements.status.dataset.status = "connected";
+ elements.connect.disabled = true;
+ elements.disconnect.disabled = false;
+ elements.sendProbe.disabled = false;
+ elements.publicUrl.value = publicUrl;
+ elements.openPublicUrl.href = publicUrl;
+ elements.openPublicUrl.setAttribute("aria-disabled", "false");
+ elements.copyPublicUrl.disabled = false;
+}
+
+function setDisconnected() {
+ elements.status.textContent = "Disconnected";
+ elements.status.dataset.status = "disconnected";
+ elements.connect.disabled = false;
+ elements.disconnect.disabled = true;
+ elements.sendProbe.disabled = true;
+ elements.publicUrl.value = "";
+ elements.openPublicUrl.href = "#";
+ elements.openPublicUrl.setAttribute("aria-disabled", "true");
+ elements.copyPublicUrl.disabled = true;
+}
+
+function setError(message) {
+ elements.status.textContent = message;
+ elements.status.dataset.status = "error";
+ elements.connect.disabled = false;
+ elements.disconnect.disabled = !activeTunnel;
+ elements.sendProbe.disabled = !activePublicUrl;
+}
+
+function disposeActiveTunnel() {
+ const tunnel = activeTunnel;
+ activeTunnel = undefined;
+ activePublicUrl = "";
+ if (!tunnel) return;
+ if (typeof tunnel.close === "function") tunnel.close();
+ else if (Symbol.dispose in tunnel) tunnel[Symbol.dispose]();
+}
+
+function updateSecretNotice() {
+ const hasSecret = Boolean(elements.secret.value.trim());
+ elements.secretNotice.hidden = !hasSecret;
+}
+
+function buildPublicUrl(serverUrl, tunnelName) {
+ if (serverUrl.includes("{name}")) {
+ return trimTrailingSlash(serverUrl.replaceAll("{name}", encodeURIComponent(tunnelName)));
+ }
+
+ const url = new URL(serverUrl);
+ url.hash = "";
+ url.search = "";
+ url.pathname = "/" + encodeURIComponent(tunnelName);
+ return trimTrailingSlash(url.href);
+}
+
+function connectUrl(publicUrl) {
+ return publicUrl + "/__captun-connect";
+}
+
+function probeUrl(publicUrl, inputPath) {
+ const url = new URL(publicUrl);
+ const path = inputPath.trim() || "/";
+ const suffix = path.startsWith("/") ? path : "/" + path;
+ const basePath = trimTrailingSlash(url.pathname);
+
+ if (suffix === "/") url.pathname = basePath || "/";
+ else url.pathname = (basePath + suffix).replace(/\/{2,}/g, "/");
+
+ return url.href;
+}
+
+function corsHeaders() {
+ return {
+ "access-control-allow-headers": "*",
+ "access-control-allow-methods": "GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS",
+ "access-control-allow-origin": "*",
+ };
+}
+
+function jsonErrorResponse(error) {
+ const headers = new Headers(corsHeaders());
+ headers.set("content-type", "application/json; charset=utf-8");
+ return new Response(
+ JSON.stringify(
+ {
+ error: formatError(error),
+ },
+ null,
+ 2,
+ ),
+ { status: 500, headers },
+ );
+}
+
+function appendLog(kind, message, detail) {
+ const row = document.createElement("li");
+ const timestamp = document.createElement("time");
+ const label = document.createElement("span");
+ const text = document.createElement("span");
+
+ row.className = "log-row";
+ row.dataset.kind = kind;
+ timestamp.dateTime = new Date().toISOString();
+ timestamp.textContent = new Date().toLocaleTimeString();
+ label.className = "log-kind";
+ label.textContent = kind;
+ text.textContent = message;
+
+ row.append(timestamp, label, text);
+
+ if (detail) {
+ const detailBlock = document.createElement("pre");
+ detailBlock.textContent = detail;
+ row.append(detailBlock);
+ }
+
+ elements.logList.prepend(row);
+
+ while (elements.logList.children.length > 120) {
+ elements.logList.lastElementChild.remove();
+ }
+}
+
+function persistSettings() {
+ localStorage.setItem(storagePrefix + "serverUrl", elements.serverUrl.value.trim());
+ localStorage.setItem(storagePrefix + "tunnelName", elements.tunnelName.value.trim());
+ localStorage.setItem(storagePrefix + "handlerSource", elements.handlerSource.value);
+ localStorage.setItem(storagePrefix + "probePath", elements.probePath.value.trim());
+}
+
+function storedValue(key, fallback) {
+ return localStorage.getItem(storagePrefix + key) || fallback;
+}
+
+function isValidTunnelName(name) {
+ return /^[a-z0-9][a-z0-9-]{0,62}$/.test(name) && !name.endsWith("-");
+}
+
+function randomTunnelName() {
+ const adjectives = ["amber", "brisk", "clear", "direct", "fresh", "plain"];
+ const nouns = ["bridge", "hook", "path", "relay", "route", "signal"];
+ const adjective = adjectives[Math.floor(Math.random() * adjectives.length)];
+ const noun = nouns[Math.floor(Math.random() * nouns.length)];
+ const suffix = Math.random().toString(36).slice(2, 8);
+ return adjective + "-" + noun + "-" + suffix;
+}
+
+function insertTabInTextarea(event) {
+ if (event.key !== "Tab") return;
+
+ event.preventDefault();
+ const textarea = event.currentTarget;
+ const start = textarea.selectionStart;
+ const end = textarea.selectionEnd;
+ textarea.value = textarea.value.slice(0, start) + " " + textarea.value.slice(end);
+ textarea.selectionStart = start + 2;
+ textarea.selectionEnd = start + 2;
+ persistSettings();
+}
+
+function trimTrailingSlash(value) {
+ return value.replace(/\/+$/, "");
+}
+
+function formatError(error) {
+ if (error instanceof Error) return error.message;
+ return String(error);
+}
diff --git a/tasks/browser-demo-page.md b/tasks/browser-demo-page.md
deleted file mode 100644
index 37ffe36..0000000
--- a/tasks/browser-demo-page.md
+++ /dev/null
@@ -1,24 +0,0 @@
----
-status: in-progress
-size: medium
----
-
-Summary: Spec fleshed out for bedtime implementation. Build a static, GitHub-Pages-friendly demo page that lets someone create a Captun tunnel from the browser by editing a fetch handler in a textarea, without introducing a framework or build step.
-
-- [ ] Add a static demo page under `docs/` that can be served by GitHub Pages or opened locally.
-- [ ] Let the user enter a Captun server URL, tunnel name, optional bearer secret, and fetch handler source.
-- [ ] Use the browser session to create a real tunnel and expose the public tunnel URL when connected.
-- [ ] Provide a sensible default handler that returns JSON/text so the demo is useful immediately.
-- [ ] Show connection, request, response, and error logs in the page without requiring devtools.
-- [ ] Document how to open/use the demo from `README.md`.
-- [ ] Avoid adding a new app framework, bundler, or deployment dependency for this task.
-
-## Assumptions
-
-- The first version can depend on the published Captun browser entrypoint or a CDN import, but the page should make that dependency obvious in the source.
-- Browser WebSocket clients cannot send arbitrary headers, so bearer auth should be handled only if the selected browser/runtime import path supports it; otherwise the UI should explain that browser demos need a non-secret demo Worker.
-- The page is a product demo/tool, not a marketing landing page. The first screen should be the runnable tunnel controls.
-
-## Notes
-
-Original prompt: "We could probably have a github-pages page which shows a tunnel from a browser session. Write a fetch handler in a textarea, and that can start serving real http requests."
diff --git a/tasks/complete/2026-05-19-browser-demo-page.md b/tasks/complete/2026-05-19-browser-demo-page.md
new file mode 100644
index 0000000..913e151
--- /dev/null
+++ b/tasks/complete/2026-05-19-browser-demo-page.md
@@ -0,0 +1,38 @@
+---
+status: done
+size: medium
+---
+
+Summary: Spec fleshed out for bedtime implementation. Build a static, GitHub-Pages-friendly demo page that lets someone create a Captun tunnel from the browser by editing a fetch handler in a textarea, without introducing a framework or build step.
+
+Status summary: Done. The static docs demo can connect a browser session to a Captun Worker, expose the public tunnel URL, handle probe requests with the editable fetch handler, and show connection/request/response/error logs. Local verification passed through repository checks and a browser smoke test against a local Worker.
+
+- [x] Add a static demo page under `docs/` that can be served by GitHub Pages or opened locally. _Implemented as `docs/browser-demo.html`, `docs/browser-demo.css`, `docs/browser-demo.js`, and a tiny docs-side browser client._
+- [x] Let the user enter a Captun server URL, tunnel name, optional bearer secret, and fetch handler source. _The tunnel form includes all four fields; the secret field shows the browser WebSocket limitation when populated._
+- [x] Use the browser session to create a real tunnel and expose the public tunnel URL when connected. _`docs/browser-demo.js` calls the browser client and fills the public URL controls after the WebSocket session opens._
+- [x] Provide a sensible default handler that returns JSON/text so the demo is useful immediately. _The default textarea handler returns JSON and handles CORS preflights for the built-in probe._
+- [x] Show connection, request, response, and error logs in the page without requiring devtools. _The log panel records connection events, inbound tunnel requests, handler responses, probe responses, and errors._
+- [x] Document how to open/use the demo from `README.md`. _Added a Browser demo section with static server instructions and the bearer-secret limitation._
+- [x] Avoid adding a new app framework, bundler, or deployment dependency for this task. _The demo is plain HTML/CSS/scripts and lazy-loads only pinned `capnweb@0.8.0` from a CDN in the browser support module._
+
+## Assumptions
+
+- The first version can depend on the published Captun browser entrypoint or a CDN import, but the page should make that dependency obvious in the source.
+- Browser WebSocket clients cannot send arbitrary headers, so bearer auth should be handled only if the selected browser/runtime import path supports it; otherwise the UI should explain that browser demos need a non-secret demo Worker.
+- The page is a product demo/tool, not a marketing landing page. The first screen should be the runnable tunnel controls.
+
+## Notes
+
+Original prompt: "We could probably have a github-pages page which shows a tunnel from a browser session. Write a fetch handler in a textarea, and that can start serving real http requests."
+
+## Implementation Log
+
+- Added the static docs demo and README usage notes.
+- `node --check docs/browser-demo.js && node --check docs/browser-demo-client.js` passed.
+- `pnpm run check` passed.
+- `pnpm test` passed with 29 tests across 3 files.
+- Served `docs/` with `python3 -m http.server 8080 --bind 127.0.0.1 --directory docs`.
+- Started a local Captun Worker on `http://localhost:8788` after `8787` was already in use.
+- Browser smoke test connected the demo to `http://127.0.0.1:8788`, exposed `http://127.0.0.1:8788/fresh-bridge-2ro4t7`, sent the built-in `/health` probe, and received `200 OK` JSON from the browser fetch handler.
+- Mobile viewport check at 390x844 showed the tool controls stacking cleanly.
+- Browser automation could not navigate to a `file://` URL, so local browser verification used the static HTTP server path.
From 0fa0fe0fba6bd9d02fdef326efe74cb412908cca Mon Sep 17 00:00:00 2001
From: Misha Kaletsky <15040698+mmkal@users.noreply.github.com>
Date: Wed, 20 May 2026 00:17:50 +0100
Subject: [PATCH 3/3] Address browser demo review notes
---
README.md | 9 +++++++++
docs/browser-demo.css | 7 ++++---
docs/browser-demo.html | 10 +++++++---
tasks/complete/2026-05-19-browser-demo-page.md | 1 +
4 files changed, 21 insertions(+), 6 deletions(-)
diff --git a/README.md b/README.md
index a588199..859f16d 100644
--- a/README.md
+++ b/README.md
@@ -76,6 +76,15 @@ Then open `http://127.0.0.1:8080/browser-demo.html`, enter the origin of a deplo
Browser WebSocket APIs cannot send arbitrary `Authorization` headers, so the demo needs a Captun Worker without `CAPTUN_SECRET`. Use the Node CLI for secret-protected tunnels.
+The normal `npx captun deploy` command creates a secret-protected Worker. For a throwaway browser demo Worker from this repo, deploy one without a secret under a separate Worker name:
+
+```bash
+pnpm install
+npx wrangler deploy --name captun-browser-demo
+```
+
+Handler source in the demo runs as first-party JavaScript in the page and is saved in local storage. Only paste handler code you wrote or trust.
+
## Advanced usage
The captun [worker.ts](./src/worker.ts) implementation has useful opinions about "named tunnels", but you can also take full control of the server implementation (which is what we do in [iterate/iterate](https://github.com/iterate/iterate)). For example, here's a weather application which allows mocking its egress to the weather API:
diff --git a/docs/browser-demo.css b/docs/browser-demo.css
index d66f1e7..c281369 100644
--- a/docs/browser-demo.css
+++ b/docs/browser-demo.css
@@ -232,7 +232,7 @@ button:focus-visible,
.handler-grid {
display: grid;
- grid-template-rows: minmax(22rem, 1fr);
+ grid-template-rows: auto minmax(22rem, 1fr);
min-height: 28rem;
}
@@ -290,8 +290,8 @@ button:focus-visible,
.log-row {
display: grid;
- grid-template-columns: 5.75rem 5.25rem minmax(0, 1fr);
- gap: 0.5rem;
+ grid-template-columns: 5.75rem 7rem minmax(0, 1fr);
+ gap: 0.75rem;
align-items: start;
border: 1px solid #e1e7ea;
border-radius: 6px;
@@ -309,6 +309,7 @@ button:focus-visible,
color: var(--muted);
font-weight: 700;
text-transform: uppercase;
+ white-space: nowrap;
}
.log-row[data-kind="error"] {
diff --git a/docs/browser-demo.html b/docs/browser-demo.html
index a9c7ec1..1637822 100644
--- a/docs/browser-demo.html
+++ b/docs/browser-demo.html
@@ -53,14 +53,14 @@ Tunnel
-