add client, pass along query params
This commit is contained in:
parent
f70a92b14a
commit
25e46dd8a5
1
bun.lock
1
bun.lock
|
|
@ -1,5 +1,6 @@
|
|||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 0,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "sneaker",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
173
src/client.ts
Normal file
173
src/client.ts
Normal 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()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user