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 + + + + + + + + +
+
+
+

Tunnel

+
+
+ + +
+ + +
+ + + + +
+ + +
+ + +
+
+ +
+
+

Fetch Handler

+
+
+ +
+
+ +
+
+

Probe

+
+
+
+
+ + +
+ + + +
+
+
+ +
+
+

Log

+ +
+
+
    +
    +
    +
    + + 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

    -