# Tunnel

**Page:** kit/tunnel

[Download Raw Markdown](./kit/tunnel.md)

---

# Tunnel

**Your laptop is already a server. You just haven't plugged it in yet.**

hoody-tunnel pushes a local HTTP service out through your container's public domain — or pulls a container-side TCP service down onto your laptop's loopback. Both directions. One multiplexed WebSocket. Zero certificate dance, zero DNS edits, zero signup flow.

## Why This Matters

Exposing `localhost:3000` to the internet has always been a side quest. Install ngrok. Sign up for an account. Configure a tunnel. Bump into the rate limit. Pay for a custom domain. Then, when you need the reverse — pulling your container's Postgres down to a local GUI — that's a completely different tool.

We rebuilt both directions as one thing.

```typescript


// Your laptop is now live on the internet
const app = await tunnel.expose({ url, token, containerPort: 3000, to: { host: '127.0.0.1', port: 3000 } });

// Your container's Postgres is now on 127.0.0.1:5432 inside the container
const db = await tunnel.pull({ url, token, containerPort: 5432, to: { host: '127.0.0.1', port: 5432 } });
```

**One session carries both.** TLS, ACME, and custom domains are already handled by Hoody Proxy upstream — the tunnel never touches a certificate. Your laptop never opens a port to the outside world. The WebSocket does all the work.

---

## Two Modes, One Session

### EXPOSE — Local to Public

Push a local HTTP or WebSocket server out through your container to the public internet:

```
[Your laptop :3000] → WebSocket → [Container :3000] → Hoody Proxy → [Public internet]
```

Visitors hit `https://myapp-3000.hoody.icu` and land on your laptop's port 3000. HTTP/1.1 requests and WebSocket upgrades flow transparently. Your laptop can be behind NAT, a corporate firewall, or a hotel Wi-Fi captive portal — it's the client, not the server.

### PULL — Remote to Local

Pull a TCP service from your laptop into the container's loopback:

```
[Your laptop :5432] ← WebSocket ← [Container 127.0.0.1:5432]
```

Container-side code connects to `127.0.0.1:5432` and hits your laptop's Postgres. Raw TCP — the tunnel doesn't inspect or parse anything. Databases, Redis, gRPC, SSH — whatever speaks TCP works.


A single tunnel session holds **multiple EXPOSE and PULL bindings at the same time**. Expose your frontend on port 3000 and your API on port 5000 while pulling your database on port 5432 — all over one WebSocket. One session. Many bindings. No extra cost.


---

## Quick Start

### 1. Expose a Local HTTP Server


  
    ```typescript
    import { tunnel } from '@hoody-ai/hoody-tunnel-sdk';

    await using app = await tunnel.expose({
      url: 'wss://PROJECT-CONTAINER-tunnel-1.SERVER.containers.hoody.icu/api/v1/tunnel/connect',
      token: process.env.HOODY_TUNNEL_TOKEN!,
      containerPort: 3000,
      to: { host: '127.0.0.1', port: 3000 },
    });

    console.log(app.publicUrl); // https://myapp-3000.hoody.icu
    // Visitors now reach your laptop's port 3000 via this URL
    ```
  
  
    ```bash
    # The tunnel data plane is a WebSocket with a binary framing protocol —
    # use the SDK above. The REST endpoints are for inspection (see below).

    # List active bindings to confirm the expose landed
    curl "https://PROJECT-CONTAINER-tunnel-1.SERVER.containers.hoody.icu/api/v1/tunnel/bindings"
    ```
  


### 2. Pull a Remote Service to Localhost


  
    ```typescript
    import { tunnel } from '@hoody-ai/hoody-tunnel-sdk';

    await using db = await tunnel.pull({
      url: 'wss://PROJECT-CONTAINER-tunnel-1.SERVER.containers.hoody.icu/api/v1/tunnel/connect',
      token: process.env.HOODY_TUNNEL_TOKEN!,
      containerPort: 5432,
      to: { host: '127.0.0.1', port: 5432 }, // your local Postgres
    });

    // Container-side code can now: psql -h 127.0.0.1 -p 5432
    ```
  
  
    ```bash
    # Inspect active pull bindings from the container
    curl "https://PROJECT-CONTAINER-tunnel-1.SERVER.containers.hoody.icu/api/v1/tunnel/bindings"
    ```
  


### 3. Run an Inline HTTP Handler

Skip starting a local server entirely — pass a `fetch` handler and the SDK wraps it:

```typescript


await using server = await tunnel.serve({
  url: 'wss://PROJECT-CONTAINER-tunnel-1.SERVER.containers.hoody.icu/api/v1/tunnel/connect',
  token: process.env.HOODY_TUNNEL_TOKEN!,
  containerPort: 3000,
  fetch(req) {
    return new Response('Hello from my laptop!');
  },
});

console.log(server.url); // public URL serving your handler
```

Internally, `tunnel.serve()` boots a `Bun.serve` on a random local port and exposes it — the kit never runs user code. You write a standard `Request`/`Response` handler. The internet gets a URL.


`tunnel.serve()` requires the **Bun** runtime (it uses `Bun.serve` internally). On Node, use `tunnel.expose()` with your own `http.createServer()` — it's the same flow, one extra line.


---

## Multiple Bindings in One Session

A single session can hold many bindings simultaneously. Drop down to the low-level `TunnelSession` API when you want full control:

```typescript


const session = new TunnelSession({ url, token });
await session.connect();

// Expose two HTTP services
const web = await session.bind({ kind: 'http', mode: 'expose', containerPort: 3000 });
const api = await session.bind({ kind: 'http', mode: 'expose', containerPort: 5000 });

// Pull a database
const pg = await session.bind({ kind: 'tcp', mode: 'pull', containerPort: 5432 });

console.log(web.publicUrl);  // https://myapp-3000.hoody.icu
console.log(api.publicUrl);  // https://myapp-5000.hoody.icu
// Container code reaches your Postgres at 127.0.0.1:5432

// ... run until done ...
await session.close();
```

### Random Port Assignment

Pass `containerPort: 0` (or omit it on the low-level `bind`) and the kit picks a free port for you — in **both** directions:

- **PULL** — the kit grabs a kernel-ephemeral loopback port.
- **EXPOSE** — the kit picks a random available port in the **20000-65534** range.

```typescript
await using db = await tunnel.pull({
  url, token,
  containerPort: 0, // kit assigns a kernel-ephemeral loopback port
  to: { host: '127.0.0.1', port: 5432 },
});

console.log(db.bind.containerPort); // e.g. 43217
// Container code reaches your Postgres at 127.0.0.1:43217
```

Either way the chosen port comes back in the `BIND_OK` response (`bind.containerPort`), and for EXPOSE the resulting `publicUrl` reflects that port.


**Pick your own EXPOSE port for stable URLs.** Random assignment is handy for one-off PULL bindings, but if you want a predictable public hostname (`myapp-<port>.hoody.icu`) across restarts, pass an explicit `containerPort` in the **20000-65534** range instead of `0`.


---

## Multi-WebSocket (v2 Protocol)

For high-throughput workloads, opt into `hoody-tunnel.v2` and the SDK spreads streams across multiple parallel WebSockets:

```typescript
const session = new TunnelSession({
  url, token,
  maxConnections: 4, // negotiate v2; open up to 4 parallel WebSockets
});
await session.connect();

console.log(session.hello?.connectionsGranted); // kit may clamp lower
```

Frames are pinned to the WebSocket that delivered each `STREAM_OPEN`, preserving per-stream ordering while letting unrelated streams parallelize across connections. v1 sessions keep working unchanged when `maxConnections` is omitted.

---

## Session Resilience

### Takeover Grace

If your WebSocket drops (laptop sleeps, network blip, train tunnel) listeners stay up for 60 seconds by default. EXPOSE listeners return `503 Service Unavailable` with `Retry-After: 5` to visitors during the gap. Reconnect within the grace period and everything resumes exactly where it left off.

### Session Resume

Reconnect with the same session ID and the kit atomically rehydrates all bindings under the new WebSocket:

```typescript
const session = new TunnelSession({ url, token });
await session.connect();

// Later, after a disconnect...
const resumed = new TunnelSession({
  url, token,
  resumeSessionId: session.id,
});
await resumed.connect();
// All bindings restored — no visitor downtime
```

### Bind Takeover

A new session can steal an EXPOSE binding from another session (or from an orphaned grace-period session) by setting `takeover: true`:

```typescript
const binding = await session.bind({
  kind: 'http', mode: 'expose',
  containerPort: 3000,
  takeover: true, // atomically claim port 3000 from any prior holder
});
```

The old session's streams on that binding receive `RESET(BIND_TAKEOVER)` and the new owner starts serving immediately.

---

## Inspection & Control

Six HTTP endpoints let you observe and manage the tunnel from anywhere: the container, another container, your laptop, an AI agent with a `fetch` call, or the Hoody dashboard. Same endpoints, same auth model — because everything in Hoody is HTTP.


  
    ```bash
    # Liveness + version
    hoody tunnel health

    # Prometheus-format metrics
    hoody tunnel metrics

    # Unified view: sessions + bindings + stream counts + FD budget
    hoody tunnel list

    # Just the sessions
    hoody tunnel sessions list

    # Just the bindings (EXPOSE + PULL)
    hoody tunnel bindings list

    # Kill a session (grace_ms: 0-5000, default 50)
    hoody tunnel sessions kill sess_abc123 --grace-ms 100
    ```
  
  
    ```typescript
    import { HoodyClient } from '@hoody-ai/hoody-sdk';

    const client = new HoodyClient({ baseURL: 'https://api.hoody.icu', token: process.env.HOODY_TOKEN });
    const containerClient = await client.withContainer({
      id: CONTAINER_ID,
      project_id: PROJECT_ID,
      server: SERVER,
    });

    // Liveness + version
    const health = await containerClient.tunnel.health.check();

    // Prometheus-format metrics
    const metrics = await containerClient.tunnel.getMetrics();

    // Unified view: sessions + bindings + stream counts + FD budget
    const tunnels = await containerClient.tunnel.listTunnels();

    // Just the sessions
    const sessions = await containerClient.tunnel.listSessions();

    // Just the bindings (EXPOSE + PULL)
    const bindings = await containerClient.tunnel.listBindings();

    // Kill a session — session_id is positional, grace_ms is an option
    await containerClient.tunnel.killSession('sess_abc123', {
      grace_ms: 100,
    });
    ```
  
  
    ```bash
    BASE="https://PROJECT-CONTAINER-tunnel-1.SERVER.containers.hoody.icu/api/v1/tunnel"

    # Liveness + version
    curl "$BASE/health"

    # Prometheus-format metrics
    curl "$BASE/metrics"

    # Unified view: sessions + bindings + stream counts + FD budget
    curl "$BASE/tunnels"

    # Just the sessions
    curl "$BASE/sessions"

    # Just the bindings (EXPOSE + PULL)
    curl "$BASE/bindings"

    # Kill a session (grace_ms: 0-5000, default 50)
    curl -X DELETE "$BASE/sessions/sess_abc123?grace_ms=100"
    ```
  


A `GET /tunnels` response looks like this:

```json
{
  "sessions": [{
    "sessionId": "a1b2c3",
    "peerAddr": "203.0.113.42:51234",
    "protocol": "v1",
    "connectionsGranted": 1,
    "activeStreams": 3,
    "exposeBindings": [{ "bindId": 1, "containerPort": 3000 }],
    "pullBindings": [{ "bindId": 2, "containerPort": 5432 }]
  }],
  "orphanedSessions": 0,
  "totalStreams": 3,
  "totalBindings": 2,
  "fdPermitsAvailable": 4094
}
```


`killSession` is non-resumable. The kit sends a `GOAWAY` frame directly on the WebSocket, drains for up to `grace_ms` milliseconds (0–5000, default 50), then force-closes. Orphan parking is skipped — the client cannot reconnect with `resume`.


For the full request/response schema of every endpoint, jump to the API reference:



---

## Architecture

```
                [Visitor browser / curl / agent]
                             │  https://myapp-3000.hoody.icu
                             ▼
                ┌───────────────────────────┐
                │     Hoody Proxy (nginx)   │  TLS termination, SNI routing
                └─────────────┬─────────────┘
                              │ HTTP/1.1 or WS upgrade
                              ▼
    ┌────────────────────────────────────────────────────┐
    │  Container                                          │
    │   ┌─────────────────────────────────────────┐      │
    │   │  hoody-tunnel  (Rust / axum)            │      │
    │   │                                         │      │
    │   │  Base listener :50 (control plane)      │      │
    │   │   └── /api/v1/tunnel/connect  (WS)     │      │
    │   │                                         │      │
    │   │  EXPOSE listeners (dynamic)             │      │
    │   │   :3000 → tunneled to laptop            │      │
    │   │   :5000 → tunneled to laptop            │      │
    │   │                                         │      │
    │   │  PULL listeners (dynamic, loopback)     │      │
    │   │   127.0.0.1:5432 → tunneled to laptop   │      │
    │   └────────────────┬────────────────────────┘      │
    └────────────────────┼───────────────────────────────┘
                         │ multiplexed WebSocket
                         ▼
                ┌────────────────────────────┐
                │  Tunnel SDK (your laptop)  │
                │  Bun 1.3+ / Node 20+       │
                └────────────────┬───────────┘
                                 ▼
                [Local services: :3000, :5000, :5432]
```

The tunnel is a stateless multiplexer. Flow control runs per-stream and per-session so a slow visitor never blocks the others. Certificates live upstream in Hoody Proxy — hoody-tunnel forwards bytes.

---

## Limits

| Limit | Value |
|-------|-------|
| Max sessions | 8 (default; `--max-sessions`) |
| Max bindings per session | 8 (default; `--max-bindings-per-session`) |
| Max concurrent streams per session | 1024 (default; `--max-streams-per-session`) |
| Max frame payload | 65,536 bytes |
| Per-stream flow control window | 1 MiB (default; `--stream-initial-window`) |
| Per-session flow control window | 16 MiB (default; `--session-initial-window`) |
| Hello timeout | 5 seconds (default; `--hello-timeout`) |
| Ping interval | 30 seconds of receive inactivity |
| Pong timeout | 60 seconds (default; `--pong-timeout`) |
| Takeover grace period | 60 seconds (default; `--takeover-grace`) |
| Idle timeout | 300 seconds (default; `--idle-timeout`) |
| `killSession` drain budget | 0–5000 ms (default 50) |

---

## Use Cases

- **Local development** — Share your dev server with teammates or test webhooks without deploying anywhere
- **Database access** — Pull your container's production Postgres to a local GUI (DBeaver, pgAdmin, TablePlus)
- **AI agent tooling** — Let an AI agent running in your container call services running on your laptop
- **Demo & review** — Show a client your work-in-progress without pushing to staging
- **Hybrid workflows** — Run heavy GPU workloads locally, expose them through your container's public domain
- **Webhook development** — Receive GitHub, Stripe, or GitLab webhooks on your laptop during development

---

## What's Next


  
  
  
  


---

> **Your laptop just became a server.**
> **Expose local services. Pull remote databases. One WebSocket. Zero configuration.**
> **ngrok is a product. Tunneling is a feature.**