add client, pass along query params

This commit is contained in:
Chris Wanstrath 2026-02-11 16:19:28 -08:00
parent f70a92b14a
commit 25e46dd8a5
4 changed files with 179 additions and 1 deletions

View File

@ -1,5 +1,6 @@
{ {
"lockfileVersion": 1, "lockfileVersion": 1,
"configVersion": 0,
"workspaces": { "workspaces": {
"": { "": {
"name": "sneaker", "name": "sneaker",

View File

@ -3,6 +3,10 @@
"module": "src/server.tsx", "module": "src/server.tsx",
"type": "module", "type": "module",
"private": true, "private": true,
"exports": {
".": "./src/server.tsx",
"./client": "./src/client.ts"
},
"scripts": { "scripts": {
"dev": "bun --hot src/server.tsx", "dev": "bun --hot src/server.tsx",
"prod": "env NODE_ENV=production bun src/server.tsx", "prod": "env NODE_ENV=production bun src/server.tsx",

173
src/client.ts Normal file
View File

@ -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<string, string>
body: string
}
export type TunnelResponse = {
id: string
status: number
headers: Record<string, string>
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<TunnelResponse> {
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<string, string> = {}
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()
},
}
}

View File

@ -104,7 +104,7 @@ app.all("*", async c => {
id, id,
app, app,
method: c.req.method, method: c.req.method,
path: c.req.path, path: url.pathname + url.search,
headers, headers,
body body
}) })