From a66d7111d34daa2bebd0b50abef9f1adecabbac5 Mon Sep 17 00:00:00 2001 From: Misha Kaletsky <15040698+mmkal@users.noreply.github.com> Date: Tue, 26 May 2026 18:24:21 +0100 Subject: [PATCH 1/9] Add browser-tab MCP demo --- src/hosted/site.ts | 196 +++++++++++++++++++++++++++++++++---- src/hosted/worker.ts | 25 ++++- src/routing.ts | 9 ++ test/hosted-worker.test.ts | 173 ++++++++++++++++++++++++++++---- wrangler.hosted.jsonc | 5 + 5 files changed, 365 insertions(+), 43 deletions(-) diff --git a/src/hosted/site.ts b/src/hosted/site.ts index d9e2df7..bacb0eb 100644 --- a/src/hosted/site.ts +++ b/src/hosted/site.ts @@ -1,7 +1,11 @@ -import { HOSTED_CAPTUN_HOSTNAME, RESERVED_TUNNEL_NAMES } from "../routing.js"; +import { HOSTED_CAPTUN_HOSTNAME, isLoopbackHostname, RESERVED_TUNNEL_NAMES } from "../routing.js"; export function hostedCaptunResponse(request: Request): Response | undefined { const url = new URL(request.url); + if (isLoopbackHostname(url.hostname) && isWwwCaptunPath(url.pathname)) { + return wwwCaptunResponse(url); + } + if (url.hostname === HOSTED_CAPTUN_HOSTNAME) { return Response.redirect( `https://www.${HOSTED_CAPTUN_HOSTNAME}${url.pathname}${url.search}`, @@ -22,6 +26,10 @@ export function hostedCaptunResponse(request: Request): Response | undefined { } } +function isWwwCaptunPath(pathname: string) { + return pathname === "/" || pathname === "/captun.browser.js" || pathname === "/favicon.svg"; +} + function wwwCaptunResponse(url: URL): Response { if (url.pathname === "/captun.browser.js") { return new Response(WWW_BROWSER_MODULE, { @@ -72,6 +80,11 @@ const WWW_LANDING_PAGE = ` #from-code-editor .cm-editor, #demo-editor .cm-editor { background: #f4f4f4; font: 16px/1.45 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } #from-code-editor .cm-scroller { min-height: 210px; } #demo-editor .cm-scroller { min-height: 300px; } + .snippet-tabs { display: flex; align-items: stretch; margin: 12px 0 0; border: 1px solid #ddd; border-bottom: 0; background: #eee; } + .snippet-tab { margin: 0; padding: 7px 10px; color: #111; background: #fff; border: 0; border-right: 1px solid #ddd; } + .snippet-tab[aria-pressed="true"] { color: #fff; background: #111; } + .snippet-tabs + #demo-source { border-top: 0; } + .snippet-tabs + #demo-source.enhanced + #demo-editor.enhanced { border-top: 0; } button { margin: 12px 0; padding: 9px 12px; font: inherit; color: #fff; background: #111; border: 1px solid #111; cursor: pointer; } button:disabled { opacity: 0.55; cursor: wait; } .status-group { display: inline-flex; align-items: center; gap: 8px; margin-left: auto; white-space: nowrap; } @@ -109,8 +122,17 @@ console.log(tunnel.url);
This works in any environment supported by capnweb, so you can run a "server" basically anywhere, even the browser.
-Edit the fetch function, create a tunnel, then the iframe below will load the public URL.
+Return a tiny text response from this browser tab.
+\${messages}
-
- \`, { headers: { "content-type": "text/html; charset=utf-8" } });
- }
-});
-
+}
+`;
+
+const LANDING_PAGE_SCRIPT_SOURCE = js`
+const fromCodeSource = document.querySelector("#from-code-source");
+const fromCodeEditorHost = document.querySelector("#from-code-editor");
+const description = document.querySelector("#demo-description");
+const source = document.querySelector("#demo-source");
+const editorHost = document.querySelector("#demo-editor");
+const snippetButtons = Array.from(document.querySelectorAll("[data-demo-snippet]"));
+const button = document.querySelector("#demo-create");
+const reload = document.querySelector("#demo-reload");
+const status = document.querySelector("#demo-status");
+const urlRow = document.querySelector("#demo-url");
+const link = document.querySelector("#demo-link");
+const frame = document.querySelector("#demo-frame");
+const error = document.querySelector("#demo-error");
+let tunnel;
+let activeFetch;
+let editor;
+void enhanceEditor();
+const captunBrowser = import("/captun.browser.js");
+const snippets = {
+ hello: {
+ description: "Return a tiny text response from this browser tab.",
+ path: "",
+ source: source.value,
+ },
+ chat: {
+ description: "Run the chat room currently on captun.sh from this browser tab.",
+ path: "",
+ source: document.querySelector("#demo-chat-source").value,
+ },
+ mcp: {
+ description: "Run an MCP server from this browser tab. Use the shown URL in MCP Inspector.",
+ path: "/mcp",
+ source: document.querySelector("#demo-mcp-source").value,
+ },
+};
+let activeSnippet = "hello";
+
+function currentSource() {
+ return editor ? editor.state.doc.toString() : source.value;
+}
+
+async function evaluateDemo() {
+ let capturedFetch;
+ const createCaptunTunnel = (options) => {
+ capturedFetch = options.fetch;
+ return Promise.resolve({ url: tunnel ? tunnel.url : "https://pending.captun.sh" });
+ };
+ await new Function(
+ "createCaptunTunnel",
+ "return (async () => {\n" + currentSource() + "\n})()",
+ )(createCaptunTunnel);
+ if (typeof capturedFetch !== "function") {
+ throw new Error("Call createCaptunTunnel({ fetch }) in the editor.");
+ }
+ activeFetch = capturedFetch;
+}
+
+async function refreshTunnelFromSource() {
+ if (!tunnel) return;
+ try {
+ await evaluateDemo();
+ status.textContent = "updated";
+ error.textContent = "";
+ showTunnelTarget(tunnel.url);
+ } catch (caught) {
+ status.textContent = "edit has an error";
+ error.textContent = caught && caught.stack ? caught.stack : String(caught);
+ }
+}
+
+function switchSnippet(name) {
+ const snippet = snippets[name];
+ if (!snippet) return;
+ activeSnippet = name;
+ description.innerText = snippet.description;
+ for (const snippetButton of snippetButtons) {
+ snippetButton.setAttribute("aria-pressed", String(snippetButton.dataset.demoSnippet === name));
+ }
+ setSource(snippet.source);
+ if (!editor) void refreshTunnelFromSource();
+}
+
+function setSource(nextSource) {
+ if (!editor) {
+ source.value = nextSource;
+ return;
+ }
+ editor.dispatch({
+ changes: { from: 0, to: editor.state.doc.length, insert: nextSource },
+ });
+}
+
+function tunnelUrlForActiveSnippet(tunnelUrl) {
+ return tunnelUrl.replace(/\/$/, "") + snippets[activeSnippet].path;
+}
+
+function showTunnelTarget(tunnelUrl) {
+ const url = tunnelUrlForActiveSnippet(tunnelUrl);
+ link.href = url;
+ link.textContent = url;
+ if (snippets[activeSnippet].path === "/mcp") {
+ frame.removeAttribute("src");
+ frame.srcdoc = previewHtml(url);
+ return;
+ }
+
+ frame.removeAttribute("srcdoc");
+ frame.src = url;
+}
+
+source.addEventListener("input", () => void refreshTunnelFromSource());
+for (const snippetButton of snippetButtons) {
+ snippetButton.addEventListener("click", () => switchSnippet(snippetButton.dataset.demoSnippet));
+}
+reload.addEventListener("click", () => {
+ if (tunnel) showTunnelTarget(tunnel.url);
+});
+
+button.addEventListener("click", async () => {
+ const startedAt = performance.now();
+ button.disabled = true;
+ reload.disabled = true;
+ status.textContent = "connecting";
+ error.textContent = "";
+
+ try {
+ if (tunnel) tunnel.close();
+ await evaluateDemo();
+ const { createCaptunTunnel } = await captunBrowser;
+ const options = { fetch: (request) => activeFetch(request) };
+ tunnel = await createCaptunTunnel(options);
+ urlRow.classList.remove("hidden");
+ showTunnelTarget(tunnel.url);
+ status.textContent = "connected in " + Math.round(performance.now() - startedAt) + "ms";
+ reload.disabled = false;
+ } catch (caught) {
+ status.textContent = "failed";
+ error.textContent = caught && caught.stack ? caught.stack : String(caught);
+ } finally {
+ button.disabled = false;
+ }
+});
+
+function previewHtml(url) {
+ return "MCP server listening at\n" + escapeHtml(url) + "\n\nUse ask_question to prompt this browser tab and return the answer."; +} + +function escapeHtml(value) { + return value.replace(/&/g, "&").replace(//g, ">"); +} + +async function enhanceEditor() { + try { + const [{ EditorView, basicSetup }, { javascript }] = await Promise.all([ + import("https://esm.sh/codemirror@6.0.1"), + import("https://esm.sh/@codemirror/lang-javascript@6.2.4"), + ]); + new EditorView({ + doc: fromCodeSource.value, + extensions: [basicSetup, javascript(), EditorView.editable.of(false)], + parent: fromCodeEditorHost, + }); + editor = new EditorView({ + doc: source.value, + extensions: [basicSetup, javascript(), EditorView.updateListener.of((update) => { + if (update.docChanged) void refreshTunnelFromSource(); + })], + parent: editorHost, + }); + fromCodeSource.classList.add("enhanced"); + fromCodeEditorHost.classList.add("enhanced"); + source.classList.add("enhanced"); + editorHost.classList.add("enhanced"); + } catch (caught) { + console.warn("CodeMirror failed to load; using textarea editor.", caught); + } +} +`; + +const WWW_LANDING_PAGE = landingPageHtml(); + +function landingPageHtml() { + return ` + + + + +
cap[nweb] tun[nel]: a tiny, fast public tunnel for local HTTP servers.
+Run this with something listening on port 3000:
+npx captun 3000+
You get a URL like:
+https://abc123.captun.sh+
Requests to that URL are forwarded to your local server until you stop the process.
+ +You don't need to run a local server. Just a fetch function:
+ + + +This works in any environment supported by capnweb, so you can run a "server" basically anywhere, even the browser.
+Return a tiny text response from this browser tab.
+npx captun deploy
Source: github.com/iterate/captun
- + +`; +} - function showTunnelTarget(tunnelUrl) { - const url = tunnelUrlForActiveSnippet(tunnelUrl); - link.href = url; - link.textContent = url; - if (snippets[activeSnippet].path === "/mcp") { - frame.removeAttribute("src"); - frame.srcdoc = previewHtml(url); - return; - } - - frame.removeAttribute("srcdoc"); - frame.src = url; - } +function htmlText(value: string) { + return value.replace(/&/g, "&").replace(//g, ">"); +} - source.addEventListener("input", () => void refreshTunnelFromSource()); - for (const snippetButton of snippetButtons) { - snippetButton.addEventListener("click", () => switchSnippet(snippetButton.dataset.demoSnippet)); - } - reload.addEventListener("click", () => { - if (tunnel) showTunnelTarget(tunnel.url); - }); +function htmlScript(value: string) { + return value.replace(/<\/script/gi, "<\\/script"); +} - button.addEventListener("click", async () => { - const startedAt = performance.now(); - button.disabled = true; - reload.disabled = true; - status.textContent = "connecting"; - error.textContent = ""; - - try { - if (tunnel) tunnel.close(); - await evaluateDemo(); - const { createCaptunTunnel } = await captunBrowser; - const options = { fetch: (request) => activeFetch(request) }; - tunnel = await createCaptunTunnel(options); - urlRow.classList.remove("hidden"); - showTunnelTarget(tunnel.url); - status.textContent = "connected in " + Math.round(performance.now() - startedAt) + "ms"; - reload.disabled = false; - } catch (caught) { - status.textContent = "failed"; - error.textContent = caught && caught.stack ? caught.stack : String(caught); - } finally { - button.disabled = false; - } +function stripCommonIndent(source: string) { + const lines = source + .replace(/^\n/, "") + .replace(/\n\s*$/, "") + .split("\n"); + const indents = lines + .filter((line) => line.trim()) + .map((line) => { + const match = line.match(/^[ \t]*/); + return match ? match[0].length : 0; }); + if (!indents.length) return ""; - function previewHtml(url) { - return "MCP server listening at\\n" + escapeHtml(url) + "\\n\\nUse ask_question to prompt this browser tab and return the answer."; - } - - function escapeHtml(value) { - return value.replace(/&/g, "&").replace(//g, ">"); - } + const indent = Math.min(...indents); + return lines.map((line) => (line.trim() ? line.slice(indent) : "")).join("\n"); +} - async function enhanceEditor() { - try { - const [{ EditorView, basicSetup }, { javascript }] = await Promise.all([ - import("https://esm.sh/codemirror@6.0.1"), - import("https://esm.sh/@codemirror/lang-javascript@6.2.4"), - ]); - new EditorView({ - doc: fromCodeSource.value, - extensions: [basicSetup, javascript(), EditorView.editable.of(false)], - parent: fromCodeEditorHost, - }); - editor = new EditorView({ - doc: source.value, - extensions: [basicSetup, javascript(), EditorView.updateListener.of((update) => { - if (update.docChanged) void refreshTunnelFromSource(); - })], - parent: editorHost, - }); - fromCodeSource.classList.add("enhanced"); - fromCodeEditorHost.classList.add("enhanced"); - source.classList.add("enhanced"); - editorHost.classList.add("enhanced"); - } catch (caught) { - console.warn("CodeMirror failed to load; using textarea editor.", caught); - } - } - -