Open Source · Zero Dependencies · TypeScript

Expose localhost.
Effortlessly.

A typed, event-driven API and CLI over all three Cloudflare tunnel modes — Quick, Remote, and Local. Manages the binary, persists your tunnels, fires typed events. Zero dependencies. Runs on Node 18+ and Bun.

quick-start.ts
import { TunnelKit } from 'tunnelkit';

const tk = new TunnelKit();

await tk.bin.ensure();

const { publicUrl } = await tk.quick.start({ service: 3000 });

// → https://random-words.trycloudflare.com
console.log('Exposed at:', publicUrl);

Everything you need to tunnel,
nothing you don't

A complete, typed wrapper around Cloudflare's three tunnel modes — built to drop into any Node or Bun project without ceremony.

Three Tunnel Modes
Quick (TryCloudflare), Remote (token), and Local (named) — each behind its own namespace: tk.quick · tk.remote · tk.local.
Fully Typed, End-to-End
TunnelKit and CloudflaredTunnel are typed EventEmitters. Every method, every event — autocomplete, no any.
Manages the Binary
Downloads cloudflared on demand, or reuses one already on PATH. Call tk.bin.ensure() for the common case, or pin a version with tk.bin.install().
Built-in Persistence
TunnelStore is on by default. Remote and local tunnels you start are saved to config.json so you can restore them on the next boot. Read them back via tk.store.
Live Multi-Tunnel CLI
Run tunnelkit with no args for a full-screen control panel — manage several tunnels at once: [n] new tunnel, [↑/↓] select, [x] stop, [c] copy URL, [m] manage saved.
Zero Runtime Dependencies
Pure Node built-ins. No node_modules to audit, no supply-chain to worry about. The library's headline property.
Cross-Runtime
Runs identically on Node 18+ and Bun. Same API, same behavior, no runtime-specific branches leaking into your code.
Smart Lifecycle
Auto-stop quick tunnels after N minutes, configurable URL/connect timeouts, a multi-tunnel registry — the glue you would write anyway, baked in.
Live Status & Connections
status-changed fires on every start/stop and on every edge connection. tk.list() carries live connections[] per tunnel.
Auto-Stop
Quick tunnels can self-terminate with autoStopMinutes — perfect for demos, webhooks, and ephemeral previews.
Orphan Recovery
tk.local.create detects a same-named tunnel on Cloudflare that isn't tracked locally and has no active connections — then cleans it up and retries.
Low-level Escape Hatch
Drop down to CloudflaredTunnel for direct child-process control — url, connected, stdout/stderr events on the raw process.

Three modes,
one unified API

One tree, three tunnels

Every mode lives under the namespace that fits it. tk.quick for demos and webhooks, tk.remote for token-based tunnels, tk.local for fully-named tunnels you control. Same patterns, same shapes.

  • Quick: tk.quick.start / tk.quick.stop
  • Remote: tk.remote.start / tk.remote.stop
  • Local: tk.local.login · create · routeDns · start
  • service can be a port, a localhost URL, or a full private IP:port
tk-modes.ts
// Quick — no account, random *.trycloudflare.com
const { publicUrl } = await tk.quick.start({
  service: 3000,
  autoStopMinutes: 30,
});

// Remote — token from Cloudflare dashboard
const { ingress } = await tk.remote.start({
  id: 'my-app',
  token: process.env.CF_TUNNEL_TOKEN!,
});

// Local — named, full ingress control
await tk.local.start({
  id: 'storefront',  // your handle
  name: 'acme-prod',  // Cloudflare name
  tunnelId, credentialsFile,
  ingress: [{
    hostname: 'app.example.com',
    service: 'http://localhost:3000',
  }],
});

A full-screen control panel

Run tunnelkit with no arguments and you get a live TUI for managing every tunnel on the machine. Pick one, stop it, copy its URL, or open breadcrumbed wizards for new and saved tunnels — without leaving the terminal.

  • Listens to status-changed events to keep the list in sync
  • [↑/↓] select · [n] new tunnel · [x] stop · [c] copy URL · [m] manage saved
  • Breadcrumbed wizards use one-step Esc back navigation and persistent progress logs for create/route/start steps
  • Live status dot per tunnel — connected · disconnected · blinks while connecting
  • Confirms before x/q when tunnels have live traffic
tunnelkit — control panel
  tunnelkit > tunnels

     quick-5173       https://abc.trycloudflare.com
      quick-3000       https://xyz.trycloudflare.com
      remotely-prod    2 routes
        - http://localhost:4001  →  app.example.com
        - http://localhost:4002  →  api.example.com
      locally-prod     2 routes
        - http://localhost:5001  →  shop.example.com
        - http://localhost:5002  →  blog.example.com

  [↑/↓] select   [n] new tunnel   [x] stop   [c] copy URL
  [m] manage saved   [q] quit

Save by default, restore on startup

Remote and local tunnels you start are saved to ~/.tunnelkit/config.json by default (written 0600). Change the location with a custom dataDir, or pass store: false to disable persistence.

  • On by default — no extra setup
  • Quick tunnels are ephemeral and never saved
  • Read saved tunnels back via tk.store at any time
  • Restore everything on startup with tk.restoreAll()
restore-on-startup.ts
import { TunnelKit } from 'tunnelkit';

// Saves to ~/.tunnelkit/config.json by default.
const tk = new TunnelKit();

// Inspect what's saved
console.log(tk.store.getRemotes());
// → [{ id: 'prod', name: 'prod', token: '***' }]

// Restore everything on startup — one call
await tk.restoreAll();

Events you can actually subscribe to

A TunnelKit instance is a typed EventEmitter with three high-level events. status-changed is enough for a health view; connection gives per-edge detail (IP, location) as it happens.

  • status-changed — fires on every start, stop, and connection change
  • ingress-update — remote tunnel's ingress rules synced from Cloudflare
  • connection — an edge connection up/down, with info.location
  • Low-level CloudflaredTunnel emits url, connected, stdout/stderr
events.ts
const tk = new TunnelKit();

tk.on('status-changed', (tunnels) => {
  console.log('Active:', tunnels.map(t => t.id));
});

tk.on('ingress-update', ({ id, ingress }) => {
  console.log(id, 'ingress:', ingress);
});

tk.on('connection', ({ id, info, status }) => {
  console.log(id, status, info.location.ip);
});

The binary is handled for you

Everything shells out to cloudflared. tunnelkit resolves it from the managed installDir (default ~/.tunnelkit/bin), then from PATH. The library never downloads on its own — you call tk.bin.ensure() when you want it. The CLI does it for you on first use.

  • tk.bin.ensure() — resolve, downloading on first use
  • tk.bin.status(){ installed, version, path }
  • tk.bin.install('2024.12.2') — pin a specific version
  • Reuses an existing cloudflared from brew/apt/winget if found
terminal — tunnelkit install & status
$ tunnelkit install
Downloading cloudflared 2024.12.2…
 Installed to ~/.tunnelkit/bin/cloudflared

$ tunnelkit status
  binary   cloudflared 2024.12.2
  path     ~/.tunnelkit/bin/cloudflared

$ tunnelkit saved
    storefront   local   → app.example.com
    remote-prod  remote  remote-prod

Install & start tunneling

Requires Node.js v18+ or Bun. The CLI auto-downloads cloudflared on first use, or call tk.bin.ensure() programmatically.

terminal
01
Install tunnelkit (global, for the CLI)
$ npm install -g tunnelkit
02
Expose a local port — done
$ tunnelkit quick 3000

Or as a library: npm install tunnelkit · bun add tunnelkit

Built with zero dependencies

Pure Node built-ins. TypeScript end-to-end. Runs identically on Node 18+ and Bun — no shims, no polyfills, no magic.

Node.js 18+ Bun TypeScript cloudflared EventEmitter ESM Zero runtime deps Typed events TryCloudflare Ingress rules JSON store

Ready to expose
your localhost?

Join developers shipping webhooks, demos, and remote previews with one typed, event-driven API over all three Cloudflare tunnel modes.