Skip to content

Add Bun, Deno, and Node weather adapter examples#3

Draft
mmkal wants to merge 46 commits into
mainfrom
mmkal/26/05/19/runtime-adapters
Draft

Add Bun, Deno, and Node weather adapter examples#3
mmkal wants to merge 46 commits into
mainfrom
mmkal/26/05/19/runtime-adapters

Conversation

@mmkal
Copy link
Copy Markdown
Contributor

@mmkal mmkal commented May 19, 2026

Summary

Adds runtime-specific server adapter entry points so Captun can accept egress tunnel WebSocket connections outside Cloudflare Workers:

  • captun/bun exposes createCaptunBunTunnelHandler() for Bun.serve({ fetch, websocket }).
  • captun/node exposes acceptCaptunNodeTunnel(socket) for Node HTTP upgrade handlers backed by ws; the example uses @whatwg-node/server for normal HTTP Fetch handling.
  • captun/deno exposes acceptCaptunDenoTunnel(socket) for Deno.upgradeWebSocket(request).

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

This is now aligned with PR #12's consolidated public API: captun stays as the root client/Cloudflare entry point, runtime adapters live under captun/bun, captun/deno, and captun/node, and there is no separate public types.ts surface or boilerplate tunnel-handle interface export.

Example Shapes

Bun

import { createCaptunBunTunnelHandler } from "captun/bun";

const captun = createCaptunBunTunnelHandler();
let egressTunnel: ReturnType<typeof captun.accept>;

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

    if (url.pathname === "/weather") {
      const city = url.searchParams.get("city") || "";
      const fetchImpl = egressTunnel?.fetch || fetch;
      const response = await fetchImpl(`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,
});

Node

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

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

server.on("upgrade", (request, socket, head) => {
  webSockets.handleUpgrade(request, socket, head, (webSocket) => {
    const tunnel = acceptCaptunNodeTunnel(webSocket, {
      onDisconnect: () => {
        if (egressTunnel === tunnel) egressTunnel = undefined;
      },
    });
    egressTunnel?.[Symbol.dispose]();
    egressTunnel = tunnel;
  });
});

Deno

import { acceptCaptunDenoTunnel } from "captun/deno";

let egressTunnel: ReturnType<typeof acceptCaptunDenoTunnel> | undefined;

Deno.serve((request) => {
  const url = new URL(request.url);
  if (url.pathname === "/__intercept-egress-traffic") {
    const { socket, response } = Deno.upgradeWebSocket(request);
    socket.addEventListener("open", () => {
      const tunnel = acceptCaptunDenoTunnel(socket, {
        onDisconnect: () => {
          if (egressTunnel === tunnel) egressTunnel = undefined;
        },
      });
      egressTunnel?.[Symbol.dispose]();
      egressTunnel = tunnel;
    });
    return response;
  }

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

Cloudflare

import { DurableObject } from "cloudflare:workers";
import { acceptCaptunTunnel } from "captun";

export class WeatherReporterEgressTunnel extends DurableObject<Env> {
  private egressTunnel: ReturnType<typeof acceptCaptunTunnel>["tunnel"] | undefined;

  async fetch(request: Request) {
    if (new URL(request.url).pathname === "/__intercept-egress-traffic") {
      this.egressTunnel?.[Symbol.dispose]();
      const { response, tunnel } = acceptCaptunTunnel({
        onDisconnect: () => {
          if (this.egressTunnel === tunnel) this.egressTunnel = undefined;
        },
      });
      this.egressTunnel = tunnel;
      return response;
    }

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

Verification

  • pnpm run check
  • pnpm run build
  • pnpm test
  • pnpm pack --pack-destination /tmp/captun-pack-ignoreme
  • npx --yes --package @arethetypeswrong/cli@0.18.2 --package fflate@0.8.2 attw /tmp/captun-pack-ignoreme/captun-0.0.1.tgz --profile esm-only

@mmkal mmkal changed the title Add runtime server adapter examples Add Bun, Deno, and Node weather adapter examples May 19, 2026
@mmkal mmkal marked this pull request as ready for review May 19, 2026 13:50
@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented May 19, 2026

Open in StackBlitz

npm i https://pkg.pr.new/captun@3

commit: 2830d50

Comment thread examples/deno/server.ts
},
});
egressTunnel?.[Symbol.dispose]();
egressTunnel = tunnel;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Deno tunnel assignment races with incoming requests

Low Severity

The egressTunnel variable is assigned inside the asynchronous "open" event listener, creating a window where an incoming /weather request could arrive before the tunnel is set. Unlike the Bun adapter (which sets egressTunnel synchronously and uses an internal promise to await the connection) and the Node adapter (which sets egressTunnel synchronously in the handleUpgrade callback), the Deno example defers assignment. If a request arrives before "open" fires, egressFetch falls through to the real fetch, bypassing the tunnel entirely.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 52b3fdf. Configure here.

@mmkal mmkal marked this pull request as draft May 19, 2026 14:50
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.

There are 2 total unresolved issues (including 1 from previous review).

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 5a87409. Configure here.

Comment thread src/worker.ts
diff |= left[i] ^ right[i];
}
return diff === 0;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Native constant-time comparison replaced with JS implementation

Medium Severity

The native crypto.subtle.timingSafeEqual (a Cloudflare Workers API providing hardware-backed constant-time comparison) was replaced with a custom JavaScript loop to fix a typecheck conflict. JavaScript provides no constant-time execution guarantees — V8's JIT may optimize the XOR/OR loop in data-dependent ways, making the comparison potentially vulnerable to timing side-channel attacks on the CAPTUN_SECRET authorization check. The type already exists in worker-configuration.d.ts; a type assertion or scoped tsconfig would preserve both type safety and the native constant-time guarantee.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 5a87409. Configure here.

@mmkal mmkal changed the base branch from mmkal/26/05/18/tweaks to main May 21, 2026 08:56
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