From 25e46dd8a56e75c2788b0200eb88c37316037df1 Mon Sep 17 00:00:00 2001 From: Chris Wanstrath <2+defunkt@users.noreply.github.com> Date: Wed, 11 Feb 2026 16:19:28 -0800 Subject: [PATCH] add client, pass along query params --- bun.lock | 1 + package.json | 4 ++ src/client.ts | 173 +++++++++++++++++++++++++++++++++++++++++++++++++ src/server.tsx | 2 +- 4 files changed, 179 insertions(+), 1 deletion(-) create mode 100644 src/client.ts diff --git a/bun.lock b/bun.lock index f5ca7ce..3701ab1 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "sneaker", diff --git a/package.json b/package.json index 5629dd4..617b556 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,10 @@ "module": "src/server.tsx", "type": "module", "private": true, + "exports": { + ".": "./src/server.tsx", + "./client": "./src/client.ts" + }, "scripts": { "dev": "bun --hot src/server.tsx", "prod": "env NODE_ENV=production bun src/server.tsx", diff --git a/src/client.ts b/src/client.ts new file mode 100644 index 0000000..529f762 --- /dev/null +++ b/src/client.ts @@ -0,0 +1,173 @@ +export interface TunnelOptions { + server: string + app: string + target: string + subdomain?: string + reconnect?: boolean + onOpen?: (subdomain: string) => void + onClose?: () => void + onRequest?: (req: TunnelRequest) => void + onError?: (error: Error) => void +} + +export interface Tunnel { + readonly subdomain: string | null + close(): void +} + +export type TunnelRequest = { + id: string + app: string + method: string + path: string + headers: Record + body: string +} + +export type TunnelResponse = { + id: string + status: number + headers: Record + body: string + isBinary?: boolean +} + +const BACKOFF = [1000, 2000, 4000, 8000, 16000, 30000] + +const isText = (contentType: string | null): boolean => { + if (!contentType) return true + const ct = contentType.toLowerCase() + return ( + ct.startsWith("text/") || + ct.includes("json") || + ct.includes("xml") || + ct.includes("javascript") || + ct.includes("css") || + ct.includes("html") || + ct.includes("urlencoded") + ) +} + +export function connect(options: TunnelOptions): Tunnel { + const { + server, + app, + target, + reconnect = true, + onOpen, + onClose, + onRequest, + onError, + } = options + + let subdomain: string | null = null + let ws: WebSocket | null = null + let closed = false + let attempts = 0 + let reconnectTimer: Timer | null = null + + function open() { + const params = new URLSearchParams({ app }) + if (options.subdomain) params.set("subdomain", options.subdomain) + + const url = `${server}/tunnel?${params}` + ws = new WebSocket(url) + + ws.onopen = () => { + attempts = 0 + } + + ws.onmessage = async (event) => { + try { + const msg = JSON.parse(event.data as string) + + if (msg.subdomain) { + subdomain = msg.subdomain as string + onOpen?.(subdomain) + return + } + + if (msg.id) { + onRequest?.(msg as TunnelRequest) + const res = await proxy(msg as TunnelRequest) + ws?.send(JSON.stringify(res)) + } + } catch (err) { + onError?.(err instanceof Error ? err : new Error(String(err))) + } + } + + ws.onclose = () => { + subdomain = null + onClose?.() + if (!closed && reconnect) scheduleReconnect() + } + + ws.onerror = () => { + onError?.(new Error("WebSocket error")) + } + } + + async function proxy(req: TunnelRequest): Promise { + try { + const url = `${target}${req.path}` + const hasBody = req.method !== "GET" && req.method !== "HEAD" && req.body + + const response = await fetch(url, { + method: req.method, + headers: req.headers, + body: hasBody ? req.body : undefined, + }) + + const contentType = response.headers.get("content-type") + const headers: Record = {} + response.headers.forEach((value, key) => { + headers[key] = value + }) + + if (!isText(contentType)) { + const buffer = await response.arrayBuffer() + return { + id: req.id, + status: response.status, + headers, + body: Buffer.from(buffer).toString("base64"), + isBinary: true, + } + } + + return { + id: req.id, + status: response.status, + headers, + body: await response.text(), + } + } catch (err) { + return { + id: req.id, + status: 502, + headers: { "content-type": "text/plain" }, + body: `Tunnel error: ${err instanceof Error ? err.message : String(err)}`, + } + } + } + + function scheduleReconnect() { + const delay = BACKOFF[Math.min(attempts, BACKOFF.length - 1)]! + attempts++ + reconnectTimer = setTimeout(open, delay) + } + + open() + + return { + get subdomain() { + return subdomain + }, + close() { + closed = true + if (reconnectTimer) clearTimeout(reconnectTimer) + ws?.close() + }, + } +} diff --git a/src/server.tsx b/src/server.tsx index 6f7849c..4b99dab 100644 --- a/src/server.tsx +++ b/src/server.tsx @@ -104,7 +104,7 @@ app.all("*", async c => { id, app, method: c.req.method, - path: c.req.path, + path: url.pathname + url.search, headers, body })