Skip to content

Add runtime Fetcher Capability adapters#21

Merged
mmkal merged 19 commits into
mainfrom
mmkal/26/05/26/runtime-adapters-on-hosted
May 26, 2026
Merged

Add runtime Fetcher Capability adapters#21
mmkal merged 19 commits into
mainfrom
mmkal/26/05/26/runtime-adapters-on-hosted

Conversation

@mmkal
Copy link
Copy Markdown
Contributor

@mmkal mmkal commented May 26, 2026

Summary

Adds runtime adapter entry points on top of the now-merged gateway-owned addressing model.

  • adds captun/node, captun/bun, and captun/deno runtime adapter entry points for accepting Fetcher Capabilities outside Cloudflare Workers
  • keeps captun as the high-level client/Cloudflare entry point while sharing the Fetcher Stub plumbing through src/server-core.ts
  • updates examples for Node, Bun, Deno, and Cloudflare so each accepts an egress Fetcher Capability, serves a tiny /weather app, and calls ready({ url }) before createCaptunTunnel({ gateway }) resolves
  • wires the examples into workspace typechecking/tests and package exports

The examples are intentionally repetitive: each runtime folder has a self-contained weather server plus its own test helper, so a reviewer can open one folder and see the complete runtime shape without following a shared app module or fixture module.

Example Shapes

Each example exposes a normal /weather route. When a Captun client connects to /__intercept-egress-traffic, outbound weather API calls go through that Fetcher Stub; otherwise they fall back to the runtime's regular fetch.

Node

import http from "node:http";
import { createServerAdapter } from "@whatwg-node/server";
import { acceptFetcherCapabilityFromNodeSocket } from "captun/node";
import { WebSocketServer } from "ws";

let egressTunnel: ReturnType<typeof acceptFetcherCapabilityFromNodeSocket> | undefined;
const webSockets = new WebSocketServer({ noServer: true });
const server = http.createServer(createServerAdapter(serverFetch));

async function serverFetch(request: Request): Promise<Response> {
  const url = new URL(request.url);

  if (url.pathname === "/weather") {
    const city = url.searchParams.get("city") || "";
    const weatherUrl = `https://wttr.in/${city}?format=j1`;
    const response = egressTunnel
      ? await egressTunnel.fetch(new Request(weatherUrl))
      : await fetch(weatherUrl);
    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`);
  }

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

server.on("upgrade", (request, socket, head) => {
  const url = new URL(request.url || "/", `http://${request.headers.host || "127.0.0.1"}`);
  if (url.pathname !== "/__intercept-egress-traffic") return socket.destroy();

  webSockets.handleUpgrade(request, socket, head, (webSocket) => {
    const fetcher = acceptFetcherCapabilityFromNodeSocket(webSocket, {
      onDisconnect: () => {
        if (egressTunnel === fetcher) egressTunnel = undefined;
      },
    });
    egressTunnel?.[Symbol.dispose]();
    egressTunnel = fetcher;
    void fetcher.ready({ url: `${url.protocol}//${url.host}` });
  });
});

Bun

import { createBunFetcherCapabilityHandler } from "captun/bun";

const captun = createBunFetcherCapabilityHandler();
let egressTunnel: ReturnType<typeof captun.accept> | undefined;

Bun.serve({
  async fetch(request, server) {
    const url = new URL(request.url);

    if (url.pathname === "/weather") {
      const city = url.searchParams.get("city") || "";
      const weatherUrl = `https://wttr.in/${city}?format=j1`;
      const response = egressTunnel
        ? await egressTunnel.fetch(new Request(weatherUrl))
        : await fetch(weatherUrl);
      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 fetcher = captun.accept(request, server, {
        onDisconnect: () => {
          if (egressTunnel === fetcher) egressTunnel = undefined;
        },
      });
      if (!fetcher) return new Response("WebSocket upgrade failed\n", { status: 500 });
      egressTunnel?.[Symbol.dispose]();
      egressTunnel = fetcher;
      void fetcher.ready({ url: url.origin });
      return;
    }

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

Deno

import { acceptFetcherCapabilityFromDenoSocket } from "captun/deno";

let egressTunnel: ReturnType<typeof acceptFetcherCapabilityFromDenoSocket> | undefined;

Deno.serve((request) => {
  const url = new URL(request.url);

  if (url.pathname === "/weather") {
    const city = url.searchParams.get("city") || "";
    const weatherUrl = `https://wttr.in/${city}?format=j1`;
    const responsePromise = egressTunnel
      ? egressTunnel.fetch(new Request(weatherUrl))
      : fetch(weatherUrl);
    return responsePromise.then(async (response) => {
      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 { socket, response } = Deno.upgradeWebSocket(request);
    socket.addEventListener("open", () => {
      const fetcher = acceptFetcherCapabilityFromDenoSocket(socket, {
        onDisconnect: () => {
          if (egressTunnel === fetcher) egressTunnel = undefined;
        },
      });
      egressTunnel?.[Symbol.dispose]();
      egressTunnel = fetcher;
      void fetcher.ready({ url: url.origin });
    });
    return response;
  }

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

Cloudflare Workers

import { DurableObject } from "cloudflare:workers";
import { acceptFetcherCapability, type FetcherStub } from "captun";

export class WeatherReporterEgressTunnel extends DurableObject<Env> {
  private egressTunnel: FetcherStub | undefined;

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

    if (url.pathname === "/weather") {
      const city = url.searchParams.get("city") || "";
      const weatherUrl = `https://wttr.in/${city}?format=j1`;
      const response = this.egressTunnel
        ? await this.egressTunnel.fetch(new Request(weatherUrl))
        : await fetch(weatherUrl);
      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 { response, fetcher } = acceptFetcherCapability({
        onDisconnect: () => {
          if (this.egressTunnel === fetcher) this.egressTunnel = undefined;
        },
      });
      this.egressTunnel?.[Symbol.dispose]();
      this.egressTunnel = fetcher;
      void fetcher.ready({ url: url.origin });
      return response;
    }

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

Verification

  • pnpm exec vitest run test/hosted-worker.test.ts
  • pnpm run check
  • pnpm test
  • pnpm run build
  • pnpm pack --pack-destination /tmp/captun-pack-runtime-ignoreme
  • npx --yes --package @arethetypeswrong/cli@0.18.2 --package fflate@0.8.2 attw /tmp/captun-pack-runtime-ignoreme/captun-0.0.2.tgz --profile esm-only

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented May 26, 2026

Open in StackBlitz

npx https://pkg.pr.new/captun@21

commit: bf848ee

Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 82ef44b. Configure here.

Comment thread src/bun.ts
@mmkal mmkal changed the base branch from hosted-captun-sh to main May 26, 2026 11:42
@mmkal mmkal merged commit 80c5813 into main May 26, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant