@opentunnel/client
v0.5.1
Published
OpenTunnel SDK for Bun: expose local services through a blind TLS tunnel
Readme
OpenTunnel Client
@opentunnel/client is a TypeScript SDK that creates tunnels and forwards them
to local services from inside your process. It ships compiled JavaScript with
type declarations and runs on Bun, Node, and Deno. effect is a peer
dependency, so an app that already uses Effect shares one copy with the SDK. TLS terminates in
your process, so the relay never sees plaintext or your private key.
It implements the same protocol and on-disk layout as the Rust client and CLI
(see docs/protocol.md), so a tunnel created by the CLI can be used here and
vice versa.
Quick start
import { create } from "@opentunnel/client"
const client = create()
const connection = await client.tunnel.connect({
routes: { api: "127.0.0.1:3000" },
})
console.log(`https://api.${connection.tunnel.hostname}`)
for await (const event of connection.events) console.log(event)connect creates the profile's tunnel if it has none, resolves once the bridge
first attaches, and reconnects with backoff until you call close(). It
rejects on fatal errors such as an invalid token.
The Effect interface exposes the same capabilities, with scoped connections and
events as a Stream:
import { OpenTunnelClient } from "@opentunnel/client/effect"Routes
Routes map a name to a host:port target. A name is a subdomain label, or @
for the tunnel hostname itself. Path routing is not supported.
await connection.setRoutes({ api: "127.0.0.1:4000", "@": "127.0.0.1:8080" })Changing only targets applies to new connections immediately. Adding or removing names re-attaches the bridge.
A route can also be an object with options:
await connection.setRoutes({
web: "127.0.0.1:3000",
api: { target: "127.0.0.1:4000", proxyProtocol: "v2" },
})proxyProtocol ("v1" or "v2") writes a PROXY protocol header with the
visitor's address and port, and (v2) the requested hostname, before each
connection's data, then forwards the data unchanged. Enable it only for a
target that expects the header, listening on loopback. Unknown keys and values
are rejected. Routes are not stored; they are the ones you pass to connect
and setRoutes.
A target receives the decrypted bytes unchanged from a loopback connection, so
it cannot tell a tunneled request from a local one by peer address or Host.
The visitor's address is the peer field of each connection-opened event,
and is delivered to the target only with proxyProtocol.
See docs/trust-boundary.md in the repository for what a target can trust.
API
interface Client {
profile: { list(): Promise<string[]> }
tunnel: {
list(): Promise<StoredTunnel[]>
get(options?: { profile?: string }): Promise<Identity | undefined>
pending(options?): Promise<{ id: string; hostname: string } | undefined>
create(options?: { profile?: string; onProgress?(stage): void }): Promise<Identity>
resume(options?): Promise<Identity | undefined>
ensure(options?: { profile?: string }): Promise<Identity>
remove(options?: { profile?: string }): Promise<void>
connect(options: { profile?: string; routes: Routes; signal?: AbortSignal }): Promise<Connection>
}
dispose(): Promise<void>
}
interface Connection {
tunnel: Identity
events: AsyncIterable<ClientEvent>
status(): Status
setRoutes(routes: Routes): Promise<void>
closed: Promise<void>
close(): Promise<void>
}Events are connecting, connected, disconnected, reconnecting,
connection-opened, connection-closed, and stopped.
Storage
create() stores identities under $XDG_DATA_HOME/opentunnel/<profile>/, the
same files the CLI uses. Pass a store to isolate or own persistence:
import { create, OpenTunnelStorage } from "@opentunnel/client"
const client = create({ store: OpenTunnelStorage.memory() })A memory store loses the tunnel's token when the process exits, and tunnels do
not expire, so a tunnel it created can no longer be deleted. Call
client.tunnel.remove() before exiting, or use the default store for tunnels
that should outlive the process.
Backpressure
The SDK stops reading from a local socket while more than 1 MiB is queued on the bridge WebSocket, and resets a connection whose local side stops reading for long enough to buffer 8 MiB. The protocol has no per-connection flow control yet, so one slow public reader can delay others on the same bridge.
