Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
abc84f7
tweaks
mmkal May 18, 2026
4872c18
rm old cli
mmkal May 18, 2026
a1c8440
Rename CLI entrypoint
mmkal May 18, 2026
3e3e6ea
Align Captun CLI config handling
mmkal May 18, 2026
68292f6
Use disposal syntax in CLI
mmkal May 18, 2026
1477812
Replace Vite build with tsc and Miniflare tests
mmkal May 18, 2026
c90c117
Tighten build script and e2e env handling
mmkal May 18, 2026
9e2aea8
Refactor Captun e2e tunnel fixtures
mmkal May 18, 2026
d59f910
ignore .env
mmkal May 18, 2026
660c7eb
Stabilize Captun binary stream e2e
mmkal May 18, 2026
bdade8e
Add incremental stream e2e coverage
mmkal May 18, 2026
22df79d
Simplify source import paths
mmkal May 18, 2026
ffd1032
formatting
mmkal May 18, 2026
fbc070b
Simplify example response formatting
mmkal May 18, 2026
d7041cb
Start weather e2e worker automatically
mmkal May 19, 2026
d37c240
Address PR review feedback
mmkal May 19, 2026
53c92a8
Add GitHub Actions CI
mmkal May 19, 2026
e9c28b1
Use CLI-provided port number
mmkal May 19, 2026
ecac5e6
Use Miniflare for weather e2e
mmkal May 19, 2026
2e0d0f3
Fix ATTW CI check
mmkal May 19, 2026
a80b6be
Use pnpm pack for ATTW CI
mmkal May 19, 2026
1acd08d
Simplify tunnel URL callsites
mmkal May 19, 2026
5fcb9ec
minor
mmkal May 19, 2026
4d41bd6
Use js specifiers for local imports
mmkal May 19, 2026
227a9a1
Rename Captun connect endpoint
mmkal May 19, 2026
1a4300b
Add deploy shards option
mmkal May 19, 2026
79b2bdd
Address PR review comments
mmkal May 19, 2026
7745931
Merge latest main
mmkal May 19, 2026
80f585a
restructure a bit more
mmkal May 19, 2026
87ce54f
Specify runtime server adapter task
mmkal May 19, 2026
71f573b
Add Bun Deno and Node weather adapter examples
mmkal May 19, 2026
2a2d1bc
Install Bun and Deno in CI
mmkal May 19, 2026
cea5713
Simplify Bun tunnel accept flow
mmkal May 19, 2026
93efb4e
Align weather example route order
mmkal May 19, 2026
52b3fdf
Make runtime examples self-contained
mmkal May 19, 2026
5a87409
Simplify runtime examples
mmkal May 19, 2026
c751adf
Remove runtime ambient declarations from examples
mmkal May 19, 2026
4c15f52
Tidy Node WebSocket adapter types
mmkal May 19, 2026
02617b2
Centralize public tunnel types
mmkal May 19, 2026
39e8414
Simplify Bun tunnel handler
mmkal May 19, 2026
8efdb62
Trim Bun adapter type surface
mmkal May 19, 2026
759346f
Clarify Bun pending tunnel flow
mmkal May 19, 2026
e1950a5
Annotate performance comparisons
mmkal May 19, 2026
5924a6f
Merge origin/main into runtime adapters
mmkal May 21, 2026
1203587
Merge stacked base into runtime adapters
mmkal May 21, 2026
2830d50
Align runtime adapters with consolidated API types
mmkal May 21, 2026
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
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: 24
- uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.11
- uses: denoland/setup-deno@v2
with:
deno-version: 2.7.11
- run: corepack enable
- run: pnpm install
- run: pnpm build
Expand Down
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Captun (cap[tainweb] tun[nel])

Captun is a tiny reference implementation of a self-hosted ngrok or Cloudflare Tunnel alternative. It runs the public side on Cloudflare Workers and sends matching HTTP requests back to a Node process over [Cap'n Web](https://github.com/cloudflare/capnweb).
Captun is a tiny reference implementation of a self-hosted ngrok or Cloudflare Tunnel alternative. It runs the public side on Cloudflare Workers and sends matching HTTP requests back to a local fetcher over [Cap'n Web](https://github.com/cloudflare/capnweb).

## Quick start

Expand Down Expand Up @@ -126,6 +126,8 @@ export default {

The core client/server pieces (`createCaptunTunnel`, `acceptCaptunTunnel`, and the `Fetcher` type) live in [src/index.ts](./src/index.ts) — small TypeScript wrappers around [Cap'n Web](https://github.com/cloudflare/capnweb). For a deployable Cloudflare Worker, also copy or adapt [src/worker.ts](./src/worker.ts) and the Durable Object binding in [wrangler.jsonc](./wrangler.jsonc).

Runtime-specific server adapters are available from `captun/bun`, `captun/deno`, and `captun/node` when you already own the HTTP server and need to accept the tunnel WebSocket upgrade outside Cloudflare Workers. The examples in [examples/cloudflare](./examples/cloudflare), [examples/bun](./examples/bun), [examples/deno](./examples/deno), and [examples/node](./examples/node) show the same weather-reporter egress-tunnel pattern in each runtime.

## Advanced CLI Usage

The CLI is mostly focused on ngrok-style use-cases with our opinionated worker deployment. Once you have run `npx captun deploy`, further commands will pick up the server URL and connection secret from your machine's captun config. You can also pass them explicitly (for example, to create a tunnel using a deployment created from someone else's machine):
Expand Down Expand Up @@ -198,7 +200,7 @@ By default, all tunnel names live in one warm `CaptunServerShard` Durable Object
npx captun deploy --shards 256
```

All of captun's public API (both the client `createCaptunTunnel` and the server-side `acceptCaptunTunnel`) is exported from the single `captun` entry point. `acceptCaptunTunnelFromSocket(socket)` is also exported for Workers that have already performed the WebSocket upgrade themselves.
Captun's core public API (the client `createCaptunTunnel`, the Cloudflare server-side `acceptCaptunTunnel`, and shared public types) is exported from the `captun` entry point. `acceptCaptunTunnelFromSocket(socket)` is also exported for Workers that have already performed the WebSocket upgrade themselves. Runtime-specific server adapters are exported from `captun/bun`, `captun/deno`, and `captun/node`.

## Performance

Expand Down Expand Up @@ -245,7 +247,7 @@ sequenceDiagram
Server-->>HTTP: Response
```

See [examples/weather-reporter](./examples/weather-reporter) for a small workspace package that imports `captun` and has its own e2e tests.
See the runtime example packages under [examples](./examples) for small workspace packages that import `captun` and have their own e2e tests.

## Development

Expand Down
163 changes: 163 additions & 0 deletions examples/bun/bun.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import { spawn, type ChildProcessByStdio } from "node:child_process";
import net from "node:net";
import { dirname, resolve } from "node:path";
import type { Readable } from "node:stream";
import { fileURLToPath } from "node:url";

import { expect, test, vi } from "vitest";

import { createCaptunTunnel } from "../../src/index.js";

vi.setConfig({ testTimeout: 20_000 });

test("returns nicely formatted weather report from a Bun server", async () => {
await using app = await createBunWeatherReporterFixture();
using _tunnel = await createCaptunTunnel({
url: `${app.url}/__intercept-egress-traffic`,
fetch(request) {
if (request.url === "https://wttr.in/london?format=j1") {
return Response.json({ current_condition: [{ temp_C: "18" }] });
}
if (request.url === "https://wttr.in/paris?format=j1") {
return Response.json({ current_condition: [{ temp_C: "22" }] });
}
return new Response("Unexpected egress", { status: 500 });
},
});

const london = await fetch(`${app.url}/weather?city=london`);
expect(await london.text()).toBe("The temperature in london is 18 celsius");

const paris = await fetch(`${app.url}/weather?city=paris`);
expect(await paris.text()).toBe("The temperature in paris is 22 celsius");
});

async function createBunWeatherReporterFixture() {
const port = await getAvailablePort();
const url = `http://127.0.0.1:${port}`;
const server = spawn("bun", ["run", "examples/bun/server.ts"], {
cwd: resolve(dirname(fileURLToPath(import.meta.url)), "../.."),
env: { ...process.env, PORT: String(port) },
stdio: ["ignore", "pipe", "pipe"],
});
const output = captureOutput(server);

try {
await waitForTcp(port, server, output);
return {
url,
async [Symbol.asyncDispose]() {
await stopProcess(server);
},
};
} catch (error) {
await stopProcess(server);
throw new Error(
formatFixtureFailure(error instanceof Error ? error.message : String(error), output.logs()),
);
}
}

type ServerProcess = ChildProcessByStdio<null, Readable, Readable>;

async function getAvailablePort(): Promise<number> {
return new Promise<number>((resolve, reject) => {
const server = net.createServer();
server.listen(0, "127.0.0.1", () => {
const address = server.address();
if (!address || typeof address === "string") {
reject(new Error(`Failed to allocate a local port: ${String(address)}`));
return;
}

server.close((error) => {
if (error) reject(error);
else resolve(address.port);
});
});
server.on("error", reject);
});
}

async function waitForTcp(port: number, server: ServerProcess, output: CapturedProcessOutput) {
const startedAt = Date.now();
while (Date.now() - startedAt < 15_000) {
const error = output.error();
if (error) throw error;
if (server.exitCode !== null || server.signalCode) {
throw new Error(
`Bun server exited before port ${port} accepted connections\n\n${output.logs().trim() || "(none)"}`,
);
}

if (await canConnect(port)) return;

await delay(100);
}

throw new Error(`Timed out waiting for Bun server to accept connections on port ${port}`);
}

async function canConnect(port: number) {
return new Promise<boolean>((resolve) => {
const socket = net.connect(port, "127.0.0.1");
socket.once("connect", () => {
socket.destroy();
resolve(true);
});
socket.once("error", () => {
socket.destroy();
resolve(false);
});
});
}

function captureOutput(child: ServerProcess) {
const chunks: string[] = [];
let processError: Error | undefined;
const capture = (chunk: string | Buffer) => {
chunks.push(String(chunk));
if (chunks.length > 200) chunks.shift();
};
child.stdout.on("data", capture);
child.stderr.on("data", capture);
child.on("error", (error) => {
processError = error;
chunks.push(error.stack || error.message);
});

return {
logs: () => chunks.join(""),
error: () => processError,
};
}

interface CapturedProcessOutput {
logs(): string;
error(): Error | undefined;
}

function formatFixtureFailure(message: string, serverLogs: string) {
return [message, "", "Server logs:", serverLogs.trim() || "(none)"].join("\n");
}

async function stopProcess(child: ServerProcess): Promise<void> {
if (child.exitCode !== null || child.killed) return;

child.kill("SIGINT");
const exited = await Promise.race([
new Promise<boolean>((resolve) => child.once("exit", () => resolve(true))),
delay(5_000).then(() => false),
]);

if (!exited && child.exitCode === null && !child.killed) {
child.kill("SIGKILL");
await new Promise<void>((resolve) => child.once("exit", () => resolve()));
}
}

function delay(ms: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
15 changes: 15 additions & 0 deletions examples/bun/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"name": "@captun/bun-example",
"private": true,
"type": "module",
"scripts": {
"typecheck": "tsc --noEmit"
},
"dependencies": {
"captun": "workspace:*"
},
"devDependencies": {
"@types/bun": "^1.3.14",
"typescript": "^6.0.3"
}
}
52 changes: 52 additions & 0 deletions examples/bun/server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { createCaptunBunTunnelHandler } from "captun/bun";

const captun = createCaptunBunTunnelHandler();

let egressTunnel: ReturnType<typeof captun.accept>;
const egressFetch = async (input: string | URL | Request, init?: RequestInit) => {
if (egressTunnel) {
const request =
input instanceof Request ? new Request(input, init) : new Request(input.toString(), init);
return egressTunnel.fetch(request);
}
return fetch(input, init);
};

const server = Bun.serve({
hostname: "127.0.0.1",
port: Number(process.env.PORT),
async fetch(request, server) {
const url = new URL(request.url);

if (url.pathname === "/weather") {
const city = url.searchParams.get("city") || "";
const response = await egressFetch(`https://wttr.in/${city}?format=j1`);
const weather = (await response.json()) as {
current_condition: [{ temp_C: string }];
};
return new Response(
`The temperature in ${city} is ${weather.current_condition[0].temp_C} celsius`,
);
}

if (url.pathname === "/__intercept-egress-traffic") {
const tunnel = captun.accept(request, server, {
onDisconnect: () => {
if (egressTunnel === tunnel) egressTunnel = undefined;
},
});
if (!tunnel) return new Response("WebSocket upgrade failed\n", { status: 500 });
egressTunnel?.[Symbol.dispose]();
egressTunnel = tunnel;
return;
}

return new Response("Not found\n", { status: 404 });
},
websocket: captun.websocket,
});

process.on("SIGINT", () => {
server.stop(true);
process.exit(0);
});
8 changes: 8 additions & 0 deletions examples/bun/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"types": ["bun"]
},
"include": ["server.ts"],
"exclude": []
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
# Weather Reporter
# Cloudflare Weather Reporter

Tiny example app that uses Captun to mock outbound network egress in an e2e test.
The app fetches live weather from the free, no-key `wttr.in` API.
All requests proxy through one small Durable Object so the intercepted egress
tunnel is used from the same Worker request context.

This variant runs on a Cloudflare Worker plus a Durable Object, using
the root `captun` Worker helper.

## Run Locally

Expand Down Expand Up @@ -40,4 +41,4 @@ doppler run -- pnpm exec wrangler deploy
WEATHER_REPORTER_URL=https://weather-reporter.garple-pretend-customer-should-be-iterate-dev-stg-will-chan.workers.dev pnpm test
```

The test awaits `createCaptunTunnel()` at the Worker's `/__intercept-egress-traffic` route, mocks the `wttr.in` response, then calls `/weather/london` and `/weather/new+york` on the Worker.
The test awaits `createCaptunTunnel()` at the Worker's `/__intercept-egress-traffic` route, mocks the `wttr.in` response, then calls `/weather?city=london` and `/weather?city=paris`.
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ async function createWeatherReporterFixture() {
}

const worker = await createMiniflareWorkerFixture({
entryPoint: "examples/weather-reporter/worker.ts",
entryPoint: "examples/cloudflare/worker.ts",
durableObjects: {
WEATHER_REPORTER_EGRESS: { className: "WeatherReporterEgressTunnel" },
},
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"name": "@captun/weather-reporter",
"name": "@captun/cloudflare-example",
"private": true,
"type": "module",
"scripts": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,41 +7,40 @@ type WeatherReporterEnv = Env & {

export class WeatherReporterEgressTunnel extends DurableObject<WeatherReporterEnv> {
private egressTunnel: ReturnType<typeof acceptCaptunTunnel>["tunnel"] | undefined;
private readonly egressFetch: typeof fetch = async (input, init) => {
if (this.egressTunnel) return this.egressTunnel.fetch(new Request(input, init));
return fetch(input, init);
};

async fetch(request: Request) {
const url = new URL(request.url);

if (url.pathname === "/weather") {
// Here's the value our app provides: fetching and gorgeously formatting weather data
const city = url.searchParams.get("city");
const city = url.searchParams.get("city") || "";
const response = await this.egressFetch(`https://wttr.in/${city}?format=j1`);
const weather = await response.json<{ current_condition: [{ temp_C: string }] }>();
const weather = (await response.json()) as {
current_condition: [{ temp_C: string }];
};
return new Response(
`The temperature in ${city} is ${weather.current_condition[0].temp_C} celsius`,
);
}

if (url.pathname === "/__intercept-egress-traffic") {
// Here we set up our worker to allow clients/tests to intercept egress traffic
this.egressTunnel?.[Symbol.dispose]();
const { response, tunnel } = acceptCaptunTunnel({
onDisconnect: () => {
if (this.egressTunnel === tunnel) this.egressTunnel = undefined;
},
});
this.egressTunnel?.[Symbol.dispose]();
this.egressTunnel = tunnel;
return response;
}

return new Response("Not found\n", { status: 404 });
}

get egressFetch(): typeof fetch {
if (this.egressTunnel) {
return async (input, init) => this.egressTunnel!.fetch(new Request(input, init));
}
return fetch;
}
}

export default {
Expand Down
6 changes: 6 additions & 0 deletions examples/deno/deno.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"imports": {
"captun/deno": "../../src/deno.ts",
"capnweb": "npm:capnweb@0.8.0"
}
}
Loading
Loading