Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,27 @@ 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.

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:
Expand Down
87 changes: 87 additions & 0 deletions docs/browser-demo-client.js
Original file line number Diff line number Diff line change
@@ -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);
},
};
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dropped socket still shows connected

Medium Severity

After the tunnel opens successfully, neither the browser client nor the demo listens for WebSocket close or RPC breakage. If the Worker or network drops the session, the UI can stay on Connected with a stale public URL while inbound requests stop working.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 9cb9180. Configure here.

}

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]();
}
}
Loading
Loading