traforo 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Kimaki
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "traforo",
3
+ "version": "0.0.1",
4
+ "description": "HTTP tunnel via Cloudflare Durable Objects and WebSockets",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "repository": "https://github.com/remorses/kimaki",
8
+ "bin": {
9
+ "traforo": "./src/cli.ts"
10
+ },
11
+ "files": [
12
+ "src",
13
+ "dist"
14
+ ],
15
+ "exports": {
16
+ ".": "./src/tunnel.ts",
17
+ "./client": "./src/client.ts",
18
+ "./types": "./src/types.ts",
19
+ "./run-tunnel": "./src/run-tunnel.ts"
20
+ },
21
+ "devDependencies": {
22
+ "@cloudflare/workers-types": "^4.20250712.0",
23
+ "@types/node": "^22.0.0",
24
+ "@types/ws": "^8.18.1",
25
+ "tsx": "^4.20.5",
26
+ "typescript": "^5.7.0",
27
+ "vitest": "^3.2.4",
28
+ "wrangler": "^4.24.3"
29
+ },
30
+ "dependencies": {
31
+ "cac": "^6.7.14",
32
+ "ws": "^8.19.0"
33
+ },
34
+ "scripts": {
35
+ "dev": "wrangler dev",
36
+ "deploy": "wrangler deploy",
37
+ "deploy:preview": "wrangler deploy --env preview",
38
+ "typecheck": "tsc --noEmit",
39
+ "typecheck:client": "tsc --noEmit -p tsconfig.client.json",
40
+ "cli": "tsx src/cli.ts",
41
+ "test": "vitest --run"
42
+ }
43
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,52 @@
1
+ #!/usr/bin/env node
2
+ import { cac } from 'cac'
3
+ import { CLI_NAME, runTunnel, parseCommandFromArgv } from './run-tunnel.js'
4
+
5
+ const { command, argv } = parseCommandFromArgv(process.argv)
6
+
7
+ const cli = cac(CLI_NAME)
8
+
9
+ cli
10
+ .command('', 'Expose a local port via tunnel')
11
+ .option('-p, --port <port>', 'Local port to expose (required)')
12
+ .option('-t, --tunnel-id [id]', 'Tunnel ID (random if omitted)')
13
+ .option('-h, --host [host]', 'Local host (default: localhost)')
14
+ .option('-s, --server [url]', 'Tunnel server URL')
15
+ .example(`${CLI_NAME} -p 3000`)
16
+ .example(`${CLI_NAME} -p 3000 -- next start`)
17
+ .example(`${CLI_NAME} -p 3000 -- pnpm dev`)
18
+ .example(`${CLI_NAME} -p 5173 -t my-app -- vite`)
19
+ .action(
20
+ async (options: {
21
+ port?: string
22
+ tunnelId?: string
23
+ host?: string
24
+ server?: string
25
+ }) => {
26
+ if (!options.port) {
27
+ console.error('Error: --port is required')
28
+ console.error(`\nUsage: ${CLI_NAME} -p <port> [-- command]`)
29
+ process.exit(1)
30
+ }
31
+
32
+ const port = parseInt(options.port, 10)
33
+ if (isNaN(port) || port < 1 || port > 65535) {
34
+ console.error(`Error: Invalid port number: ${options.port}`)
35
+ process.exit(1)
36
+ }
37
+
38
+ await runTunnel({
39
+ port,
40
+ tunnelId: options.tunnelId,
41
+ localHost: options.host,
42
+ serverUrl: options.server,
43
+ command: command.length > 0 ? command : undefined,
44
+ })
45
+ }
46
+ )
47
+
48
+ cli.help()
49
+ cli.version('0.0.1')
50
+
51
+ // Parse the modified argv (without the command after --)
52
+ cli.parse(argv)
package/src/client.ts ADDED
@@ -0,0 +1,326 @@
1
+ /**
2
+ * Local tunnel client - runs on user's machine to expose a local server.
3
+ */
4
+
5
+ import WebSocket from 'ws'
6
+ import type {
7
+ UpstreamMessage,
8
+ DownstreamMessage,
9
+ HttpRequestMessage,
10
+ HttpResponseMessage,
11
+ HttpErrorMessage,
12
+ WsOpenMessage,
13
+ WsFrameMessage,
14
+ WsCloseMessage,
15
+ WsOpenedMessage,
16
+ WsFrameResponseMessage,
17
+ WsClosedMessage,
18
+ WsErrorMessage,
19
+ } from './types.js'
20
+
21
+ type TunnelClientOptions = {
22
+ /** Local port to proxy to */
23
+ localPort: number
24
+ /** Local host (default: localhost) */
25
+ localHost?: string
26
+ /** Tunnel server URL (default: wss://{tunnelId}-tunnel.kimaki.xyz) */
27
+ serverUrl?: string
28
+ /** Tunnel ID */
29
+ tunnelId: string
30
+ /** Use HTTPS for local connections */
31
+ localHttps?: boolean
32
+ /** Reconnect on disconnect */
33
+ autoReconnect?: boolean
34
+ /** Reconnect delay in ms */
35
+ reconnectDelay?: number
36
+ }
37
+
38
+ export class TunnelClient {
39
+ private options: Required<TunnelClientOptions>
40
+ private ws: WebSocket | null = null
41
+ private localWsConnections: Map<string, WebSocket> = new Map()
42
+ private closed = false
43
+
44
+ constructor(options: TunnelClientOptions) {
45
+ this.options = {
46
+ localHost: 'localhost',
47
+ serverUrl: `wss://${options.tunnelId}-tunnel.kimaki.xyz`,
48
+ localHttps: false,
49
+ autoReconnect: true,
50
+ reconnectDelay: 3000,
51
+ ...options,
52
+ }
53
+ }
54
+
55
+ get url(): string {
56
+ return `https://${this.options.tunnelId}-tunnel.kimaki.xyz`
57
+ }
58
+
59
+ async connect(): Promise<void> {
60
+ if (this.closed) {
61
+ throw new Error('Client is closed')
62
+ }
63
+
64
+ const wsUrl = `${this.options.serverUrl}/upstream?_tunnelId=${this.options.tunnelId}`
65
+ console.log(`Connecting to ${wsUrl}...`)
66
+
67
+ return new Promise((resolve, reject) => {
68
+ this.ws = new WebSocket(wsUrl)
69
+
70
+ this.ws.on('open', () => {
71
+ console.log(`Connected! Tunnel URL: ${this.url}`)
72
+ resolve()
73
+ })
74
+
75
+ this.ws.on('error', (err: Error) => {
76
+ console.error('WebSocket error:', err.message)
77
+ reject(new Error('WebSocket connection failed'))
78
+ })
79
+
80
+ this.ws.on('close', (code: number, reason: Buffer) => {
81
+ console.log(`Disconnected: ${code} ${reason.toString()}`)
82
+ this.ws = null
83
+
84
+ // Close all local WS connections
85
+ for (const [, localWs] of this.localWsConnections) {
86
+ try {
87
+ localWs.close()
88
+ } catch {}
89
+ }
90
+ this.localWsConnections.clear()
91
+
92
+ // Auto-reconnect
93
+ if (this.options.autoReconnect && !this.closed) {
94
+ console.log(`Reconnecting in ${this.options.reconnectDelay}ms...`)
95
+ setTimeout(() => {
96
+ this.connect().catch(console.error)
97
+ }, this.options.reconnectDelay)
98
+ }
99
+ })
100
+
101
+ this.ws.on('message', (data: WebSocket.RawData) => {
102
+ this.handleMessage(data.toString())
103
+ })
104
+ })
105
+ }
106
+
107
+ close(): void {
108
+ this.closed = true
109
+ if (this.ws) {
110
+ this.ws.close()
111
+ this.ws = null
112
+ }
113
+ for (const [, localWs] of this.localWsConnections) {
114
+ try {
115
+ localWs.close()
116
+ } catch {}
117
+ }
118
+ this.localWsConnections.clear()
119
+ }
120
+
121
+ private handleMessage(rawMessage: string): void {
122
+ let msg: UpstreamMessage
123
+ try {
124
+ msg = JSON.parse(rawMessage) as UpstreamMessage
125
+ } catch {
126
+ console.error('Failed to parse message:', rawMessage)
127
+ return
128
+ }
129
+
130
+ switch (msg.type) {
131
+ case 'http_request':
132
+ this.handleHttpRequest(msg)
133
+ break
134
+ case 'ws_open':
135
+ this.handleWsOpen(msg)
136
+ break
137
+ case 'ws_frame':
138
+ this.handleWsFrame(msg)
139
+ break
140
+ case 'ws_close':
141
+ this.handleWsClose(msg)
142
+ break
143
+ }
144
+ }
145
+
146
+ private async handleHttpRequest(msg: HttpRequestMessage): Promise<void> {
147
+ const { localHost, localPort, localHttps } = this.options
148
+ const protocol = localHttps ? 'https' : 'http'
149
+ const url = `${protocol}://${localHost}:${localPort}${msg.path}`
150
+
151
+ console.log(`HTTP ${msg.method} ${msg.path}`)
152
+
153
+ try {
154
+ // Decode body
155
+ let body: Buffer | undefined
156
+ if (msg.body) {
157
+ body = Buffer.from(msg.body, 'base64')
158
+ }
159
+
160
+ // Make local request
161
+ const res = await fetch(url, {
162
+ method: msg.method,
163
+ headers: msg.headers,
164
+ body: msg.method !== 'GET' && msg.method !== 'HEAD' ? body : undefined,
165
+ })
166
+
167
+ // Read response body
168
+ const resBuffer = await res.arrayBuffer()
169
+ const resBody =
170
+ resBuffer.byteLength > 0
171
+ ? Buffer.from(resBuffer).toString('base64')
172
+ : null
173
+
174
+ // Build response headers
175
+ const resHeaders: Record<string, string> = {}
176
+ res.headers.forEach((value, key) => {
177
+ resHeaders[key] = value
178
+ })
179
+
180
+ // Send response
181
+ const response: HttpResponseMessage = {
182
+ type: 'http_response',
183
+ id: msg.id,
184
+ status: res.status,
185
+ headers: resHeaders,
186
+ body: resBody,
187
+ }
188
+
189
+ this.send(response)
190
+ } catch (err) {
191
+ console.error(`HTTP request failed:`, err)
192
+
193
+ const errorResponse: HttpErrorMessage = {
194
+ type: 'http_error',
195
+ id: msg.id,
196
+ error: err instanceof Error ? err.message : 'Unknown error',
197
+ }
198
+
199
+ this.send(errorResponse)
200
+ }
201
+ }
202
+
203
+ private handleWsOpen(msg: WsOpenMessage): void {
204
+ const { localHost, localPort, localHttps } = this.options
205
+ const protocol = localHttps ? 'wss' : 'ws'
206
+ const url = `${protocol}://${localHost}:${localPort}${msg.path}`
207
+
208
+ console.log(`WS OPEN ${msg.path} (${msg.connId})`)
209
+
210
+ try {
211
+ const localWs = new WebSocket(url)
212
+
213
+ localWs.on('open', () => {
214
+ console.log(`WS CONNECTED ${msg.connId}`)
215
+ this.localWsConnections.set(msg.connId, localWs)
216
+
217
+ const opened: WsOpenedMessage = {
218
+ type: 'ws_opened',
219
+ connId: msg.connId,
220
+ }
221
+ this.send(opened)
222
+ })
223
+
224
+ localWs.on('error', (err: Error) => {
225
+ console.error(`WS ERROR ${msg.connId}:`, err.message)
226
+
227
+ const errorMsg: WsErrorMessage = {
228
+ type: 'ws_error',
229
+ connId: msg.connId,
230
+ error: err.message || 'Connection failed',
231
+ }
232
+ this.send(errorMsg)
233
+
234
+ this.localWsConnections.delete(msg.connId)
235
+ })
236
+
237
+ localWs.on('close', (code: number, reason: Buffer) => {
238
+ console.log(`WS CLOSED ${msg.connId}: ${code} ${reason.toString()}`)
239
+
240
+ const closed: WsClosedMessage = {
241
+ type: 'ws_closed',
242
+ connId: msg.connId,
243
+ code,
244
+ reason: reason.toString(),
245
+ }
246
+ this.send(closed)
247
+
248
+ this.localWsConnections.delete(msg.connId)
249
+ })
250
+
251
+ localWs.on('message', (data: WebSocket.RawData, isBinary: boolean) => {
252
+ let frameData: string
253
+ let binary = false
254
+
255
+ if (isBinary || data instanceof Buffer) {
256
+ frameData = Buffer.isBuffer(data)
257
+ ? data.toString('base64')
258
+ : Buffer.from(data as ArrayBuffer).toString('base64')
259
+ binary = true
260
+ } else {
261
+ frameData = data.toString()
262
+ }
263
+
264
+ const frame: WsFrameResponseMessage = {
265
+ type: 'ws_frame',
266
+ connId: msg.connId,
267
+ data: frameData,
268
+ binary,
269
+ }
270
+ this.send(frame)
271
+ })
272
+ } catch (err) {
273
+ console.error(`WS OPEN FAILED ${msg.connId}:`, err)
274
+
275
+ const errorMsg: WsErrorMessage = {
276
+ type: 'ws_error',
277
+ connId: msg.connId,
278
+ error: err instanceof Error ? err.message : 'Unknown error',
279
+ }
280
+ this.send(errorMsg)
281
+ }
282
+ }
283
+
284
+ private handleWsFrame(msg: WsFrameMessage): void {
285
+ const localWs = this.localWsConnections.get(msg.connId)
286
+ if (!localWs) {
287
+ console.warn(`WS FRAME for unknown connection: ${msg.connId}`)
288
+ return
289
+ }
290
+
291
+ try {
292
+ if (msg.binary) {
293
+ const buffer = Buffer.from(msg.data, 'base64')
294
+ localWs.send(buffer)
295
+ } else {
296
+ localWs.send(msg.data)
297
+ }
298
+ } catch (err) {
299
+ console.error(`WS SEND FAILED ${msg.connId}:`, err)
300
+ }
301
+ }
302
+
303
+ private handleWsClose(msg: WsCloseMessage): void {
304
+ const localWs = this.localWsConnections.get(msg.connId)
305
+ if (!localWs) {
306
+ return
307
+ }
308
+
309
+ console.log(`WS CLOSE ${msg.connId}: ${msg.code} ${msg.reason}`)
310
+
311
+ try {
312
+ localWs.close(msg.code, msg.reason)
313
+ } catch {}
314
+
315
+ this.localWsConnections.delete(msg.connId)
316
+ }
317
+
318
+ private send(msg: DownstreamMessage): void {
319
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
320
+ console.warn('Cannot send: WebSocket not connected')
321
+ return
322
+ }
323
+
324
+ this.ws.send(JSON.stringify(msg))
325
+ }
326
+ }
@@ -0,0 +1,155 @@
1
+ import { spawn, type ChildProcess } from 'node:child_process'
2
+ import net from 'node:net'
3
+ import { TunnelClient } from './client.js'
4
+
5
+ export const CLI_NAME = 'traforo'
6
+
7
+ export type RunTunnelOptions = {
8
+ port: number
9
+ tunnelId?: string
10
+ localHost?: string
11
+ serverUrl?: string
12
+ command?: string[]
13
+ }
14
+
15
+ /**
16
+ * Wait for a port to be available (accepting connections).
17
+ * Used when spawning a child process to wait for the server to start.
18
+ */
19
+ async function waitForPort(
20
+ port: number,
21
+ host = 'localhost',
22
+ timeoutMs = 60_000
23
+ ): Promise<void> {
24
+ const start = Date.now()
25
+ const checkInterval = 500
26
+
27
+ return new Promise((resolve, reject) => {
28
+ const check = () => {
29
+ if (Date.now() - start > timeoutMs) {
30
+ reject(new Error(`Timeout waiting for port ${port} to be available`))
31
+ return
32
+ }
33
+
34
+ const socket = new net.Socket()
35
+
36
+ socket.once('connect', () => {
37
+ socket.destroy()
38
+ resolve()
39
+ })
40
+
41
+ socket.once('error', () => {
42
+ socket.destroy()
43
+ setTimeout(check, checkInterval)
44
+ })
45
+
46
+ socket.connect(port, host)
47
+ }
48
+
49
+ check()
50
+ })
51
+ }
52
+
53
+ /**
54
+ * Parse argv to extract command after `--` separator.
55
+ * Returns the command array and remaining argv without the command.
56
+ */
57
+ export function parseCommandFromArgv(argv: string[]): {
58
+ command: string[]
59
+ argv: string[]
60
+ } {
61
+ const dashDashIndex = argv.indexOf('--')
62
+
63
+ if (dashDashIndex === -1) {
64
+ return { command: [], argv }
65
+ }
66
+
67
+ return {
68
+ command: argv.slice(dashDashIndex + 1),
69
+ argv: argv.slice(0, dashDashIndex),
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Run the tunnel, optionally spawning a child process first.
75
+ */
76
+ export async function runTunnel(options: RunTunnelOptions): Promise<void> {
77
+ const tunnelId = options.tunnelId || crypto.randomUUID().slice(0, 8)
78
+ const localHost = options.localHost || 'localhost'
79
+ const port = options.port
80
+
81
+ let child: ChildProcess | null = null
82
+
83
+ // If command provided, spawn child process with PORT env
84
+ if (options.command && options.command.length > 0) {
85
+ const cmd = options.command[0]!
86
+ const args = options.command.slice(1)
87
+
88
+ console.log(`Starting: ${options.command.join(' ')}`)
89
+ console.log(`PORT=${port}\n`)
90
+
91
+ const spawnedChild = spawn(cmd, args, {
92
+ stdio: 'inherit',
93
+ env: {
94
+ ...process.env,
95
+ PORT: String(port),
96
+ // Disable clear/animations for common tools without lying about CI
97
+ FORCE_COLOR: '1',
98
+ VITE_CLS: 'false',
99
+ NEXT_TELEMETRY_DISABLED: '1',
100
+ },
101
+ })
102
+ child = spawnedChild
103
+
104
+ spawnedChild.on('error', (err) => {
105
+ console.error(`Failed to start command: ${err.message}`)
106
+ process.exit(1)
107
+ })
108
+
109
+ spawnedChild.on('exit', (code) => {
110
+ console.log(`\nCommand exited with code ${code}`)
111
+ process.exit(code || 0)
112
+ })
113
+
114
+ // Wait for port to be available before connecting tunnel
115
+ console.log(`Waiting for port ${port}...`)
116
+ try {
117
+ await waitForPort(port, localHost)
118
+ console.log(`Port ${port} is ready!\n`)
119
+ } catch (err) {
120
+ console.error(err instanceof Error ? err.message : String(err))
121
+ spawnedChild.kill()
122
+ process.exit(1)
123
+ }
124
+ }
125
+
126
+ const client = new TunnelClient({
127
+ localPort: port,
128
+ tunnelId,
129
+ localHost,
130
+ serverUrl: options.serverUrl,
131
+ })
132
+
133
+ // Handle shutdown
134
+ const cleanup = () => {
135
+ console.log('\nShutting down...')
136
+ client.close()
137
+ if (child) {
138
+ child.kill()
139
+ }
140
+ process.exit(0)
141
+ }
142
+
143
+ process.on('SIGINT', cleanup)
144
+ process.on('SIGTERM', cleanup)
145
+
146
+ try {
147
+ await client.connect()
148
+ } catch (err) {
149
+ console.error('Failed to connect:', err instanceof Error ? err.message : String(err))
150
+ if (child) {
151
+ child.kill()
152
+ }
153
+ process.exit(1)
154
+ }
155
+ }
package/src/tunnel.ts ADDED
@@ -0,0 +1,574 @@
1
+ import type {
2
+ UpstreamMessage,
3
+ DownstreamMessage,
4
+ HttpRequestMessage,
5
+ HttpResponseMessage,
6
+ HttpErrorMessage,
7
+ WsOpenMessage,
8
+ WsFrameMessage,
9
+ WsCloseMessage,
10
+ WsOpenedMessage,
11
+ WsFrameResponseMessage,
12
+ WsClosedMessage,
13
+ WsErrorMessage,
14
+ } from './types.js'
15
+
16
+ // Cloudflare-specific types
17
+ export type Env = {
18
+ TUNNEL_DO: DurableObjectNamespace
19
+ }
20
+
21
+ type Attachment = {
22
+ role: 'upstream' | 'downstream'
23
+ tunnelId: string
24
+ }
25
+
26
+ type PendingHttpRequest = {
27
+ resolve: (response: Response) => void
28
+ reject: (error: Error) => void
29
+ timeout: ReturnType<typeof setTimeout>
30
+ }
31
+
32
+ type PendingWsConnection = {
33
+ userWs: WebSocket
34
+ timeout: ReturnType<typeof setTimeout>
35
+ }
36
+
37
+ const HTTP_TIMEOUT_MS = 30_000
38
+ const WS_OPEN_TIMEOUT_MS = 10_000
39
+
40
+ // Worker entrypoint
41
+ export default {
42
+ async fetch(req: Request, env: Env): Promise<Response> {
43
+ if (req.method === 'OPTIONS') {
44
+ return addCors(new Response(null, { status: 204 }))
45
+ }
46
+
47
+ const url = new URL(req.url)
48
+ const host = url.hostname
49
+
50
+ // Extract tunnel ID from subdomain: {tunnelId}-tunnel.kimaki.xyz
51
+ const tunnelId = extractTunnelId(host)
52
+ if (!tunnelId) {
53
+ return addCors(new Response('Invalid tunnel URL', { status: 400 }))
54
+ }
55
+
56
+ // Get the Durable Object for this tunnel
57
+ const doId = env.TUNNEL_DO.idFromName(tunnelId)
58
+ const stub = env.TUNNEL_DO.get(doId)
59
+
60
+ // Forward request to DO
61
+ const doUrl = new URL(req.url)
62
+ doUrl.searchParams.set('_tunnelId', tunnelId)
63
+ const res = await stub.fetch(new Request(doUrl.toString(), req))
64
+
65
+ return addCors(res)
66
+ },
67
+ }
68
+
69
+ function extractTunnelId(host: string): string | null {
70
+ // Match: {tunnelId}-tunnel.kimaki.xyz or {tunnelId}-tunnel.localhost
71
+ const match = host.match(/^([a-z0-9-]+)-tunnel\./)
72
+ if (!match) {
73
+ return null
74
+ }
75
+ return match[1]
76
+ }
77
+
78
+ // Durable Object
79
+ export class Tunnel {
80
+ private ctx: DurableObjectState
81
+ private env: Env
82
+ private pendingHttpRequests: Map<string, PendingHttpRequest> = new Map()
83
+ private pendingWsConnections: Map<string, PendingWsConnection> = new Map()
84
+
85
+ constructor(state: DurableObjectState, env: Env) {
86
+ this.ctx = state
87
+ this.env = env
88
+
89
+ // Auto-respond to ping messages without waking DO
90
+ this.ctx.setWebSocketAutoResponse(
91
+ new WebSocketRequestResponsePair('ping', 'pong')
92
+ )
93
+ this.ctx.setWebSocketAutoResponse(
94
+ new WebSocketRequestResponsePair('{"type":"ping"}', '{"type":"pong"}')
95
+ )
96
+ }
97
+
98
+ async fetch(req: Request): Promise<Response> {
99
+ const url = new URL(req.url)
100
+ const tunnelId = url.searchParams.get('_tunnelId') || 'default'
101
+ const isUpgrade = req.headers.get('Upgrade') === 'websocket'
102
+
103
+ // WebSocket upgrade requests
104
+ if (isUpgrade) {
105
+ if (url.pathname === '/upstream') {
106
+ return this.handleUpstreamConnection(tunnelId)
107
+ }
108
+ // User WebSocket connection to be proxied
109
+ return this.handleUserWsConnection(tunnelId, url.pathname, req.headers)
110
+ }
111
+
112
+ // Status endpoint
113
+ if (url.pathname === '/status') {
114
+ const upstream = this.getUpstream(tunnelId)
115
+ return Response.json({
116
+ online: !!upstream,
117
+ tunnelId,
118
+ })
119
+ }
120
+
121
+ // HTTP request to be proxied
122
+ return this.handleHttpProxy(tunnelId, req)
123
+ }
124
+
125
+ // ============================================
126
+ // Upstream (local client) connection
127
+ // ============================================
128
+
129
+ private handleUpstreamConnection(tunnelId: string): Response {
130
+ // Close any existing upstream connection
131
+ const existing = this.getUpstream(tunnelId)
132
+ if (existing) {
133
+ try {
134
+ existing.close(4009, 'Replaced by new connection')
135
+ } catch {}
136
+ }
137
+
138
+ const pair = new WebSocketPair()
139
+ const [client, server] = Object.values(pair)
140
+
141
+ this.ctx.acceptWebSocket(server, [`upstream:${tunnelId}`])
142
+ server.serializeAttachment({
143
+ role: 'upstream',
144
+ tunnelId,
145
+ } satisfies Attachment)
146
+
147
+ // Notify any waiting downstream connections
148
+ const downstreams = this.ctx.getWebSockets(`downstream:${tunnelId}`)
149
+ for (const ws of downstreams) {
150
+ try {
151
+ ws.send(JSON.stringify({ event: 'upstream_connected' }))
152
+ } catch {}
153
+ }
154
+
155
+ return new Response(null, { status: 101, webSocket: client })
156
+ }
157
+
158
+ private getUpstream(tunnelId: string): WebSocket | null {
159
+ const sockets = this.ctx.getWebSockets(`upstream:${tunnelId}`)
160
+ return sockets[0] || null
161
+ }
162
+
163
+ // ============================================
164
+ // HTTP Proxy
165
+ // ============================================
166
+
167
+ private async handleHttpProxy(
168
+ tunnelId: string,
169
+ req: Request
170
+ ): Promise<Response> {
171
+ const upstream = this.getUpstream(tunnelId)
172
+ if (!upstream) {
173
+ return new Response('Tunnel offline', { status: 503 })
174
+ }
175
+
176
+ const reqId = crypto.randomUUID()
177
+ const url = new URL(req.url)
178
+
179
+ // Read request body
180
+ let body: string | null = null
181
+ if (req.body) {
182
+ const buffer = await req.arrayBuffer()
183
+ if (buffer.byteLength > 0) {
184
+ body = arrayBufferToBase64(buffer)
185
+ }
186
+ }
187
+
188
+ // Build headers object
189
+ const headers: Record<string, string> = {}
190
+ req.headers.forEach((value, key) => {
191
+ // Skip hop-by-hop headers
192
+ if (!isHopByHopHeader(key)) {
193
+ headers[key] = value
194
+ }
195
+ })
196
+
197
+ // Send request to local client
198
+ const message: HttpRequestMessage = {
199
+ type: 'http_request',
200
+ id: reqId,
201
+ method: req.method,
202
+ path: url.pathname + url.search,
203
+ headers,
204
+ body,
205
+ }
206
+
207
+ try {
208
+ upstream.send(JSON.stringify(message) satisfies string)
209
+ } catch {
210
+ return new Response('Failed to send to tunnel', { status: 502 })
211
+ }
212
+
213
+ // Wait for response
214
+ return new Promise<Response>((resolve, reject) => {
215
+ const timeout = setTimeout(() => {
216
+ this.pendingHttpRequests.delete(reqId)
217
+ resolve(new Response('Tunnel timeout', { status: 504 }))
218
+ }, HTTP_TIMEOUT_MS)
219
+
220
+ this.pendingHttpRequests.set(reqId, { resolve, reject, timeout })
221
+ })
222
+ }
223
+
224
+ // ============================================
225
+ // User WebSocket Proxy
226
+ // ============================================
227
+
228
+ private handleUserWsConnection(
229
+ tunnelId: string,
230
+ path: string,
231
+ reqHeaders: Headers
232
+ ): Response {
233
+ const upstream = this.getUpstream(tunnelId)
234
+ if (!upstream) {
235
+ const pair = new WebSocketPair()
236
+ const [client, server] = Object.values(pair)
237
+ server.accept()
238
+ server.close(4008, 'Tunnel offline')
239
+ return new Response(null, { status: 101, webSocket: client })
240
+ }
241
+
242
+ const pair = new WebSocketPair()
243
+ const [client, server] = Object.values(pair)
244
+
245
+ const connId = crypto.randomUUID()
246
+
247
+ this.ctx.acceptWebSocket(server, [`downstream:${tunnelId}`, `ws:${connId}`])
248
+ server.serializeAttachment({
249
+ role: 'downstream',
250
+ tunnelId,
251
+ } satisfies Attachment)
252
+
253
+ // Build headers object
254
+ const headers: Record<string, string> = {}
255
+ reqHeaders.forEach((value, key) => {
256
+ if (!isHopByHopHeader(key) && key.toLowerCase() !== 'upgrade') {
257
+ headers[key] = value
258
+ }
259
+ })
260
+
261
+ // Request local client to open WebSocket
262
+ const message: WsOpenMessage = {
263
+ type: 'ws_open',
264
+ connId,
265
+ path,
266
+ headers,
267
+ }
268
+
269
+ try {
270
+ upstream.send(JSON.stringify(message) satisfies string)
271
+ } catch {
272
+ server.close(4009, 'Failed to contact tunnel')
273
+ return new Response(null, { status: 101, webSocket: client })
274
+ }
275
+
276
+ // Set timeout for WS open
277
+ const timeout = setTimeout(() => {
278
+ this.pendingWsConnections.delete(connId)
279
+ try {
280
+ server.close(4010, 'Local connection timeout')
281
+ } catch {}
282
+ }, WS_OPEN_TIMEOUT_MS)
283
+
284
+ this.pendingWsConnections.set(connId, { userWs: server, timeout })
285
+
286
+ return new Response(null, { status: 101, webSocket: client })
287
+ }
288
+
289
+ // ============================================
290
+ // WebSocket Hibernation Handlers
291
+ // ============================================
292
+
293
+ async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer) {
294
+ if (typeof message !== 'string') {
295
+ return
296
+ }
297
+
298
+ const attachment = ws.deserializeAttachment() as Attachment | undefined
299
+ if (!attachment) {
300
+ return
301
+ }
302
+
303
+ if (attachment.role === 'upstream') {
304
+ this.handleUpstreamMessage(attachment.tunnelId, message)
305
+ } else if (attachment.role === 'downstream') {
306
+ this.handleDownstreamMessage(attachment.tunnelId, ws, message)
307
+ }
308
+ }
309
+
310
+ async webSocketClose(
311
+ ws: WebSocket,
312
+ code: number,
313
+ reason: string,
314
+ _wasClean: boolean
315
+ ) {
316
+ const attachment = ws.deserializeAttachment() as Attachment | undefined
317
+ if (!attachment) {
318
+ return
319
+ }
320
+
321
+ if (attachment.role === 'upstream') {
322
+ // Upstream disconnected - notify all downstream connections
323
+ const downstreams = this.ctx.getWebSockets(
324
+ `downstream:${attachment.tunnelId}`
325
+ )
326
+ for (const down of downstreams) {
327
+ try {
328
+ down.send(JSON.stringify({ event: 'upstream_disconnected' }))
329
+ down.close(1012, 'Upstream disconnected')
330
+ } catch {}
331
+ }
332
+
333
+ // Reject all pending HTTP requests
334
+ for (const [reqId, pending] of this.pendingHttpRequests) {
335
+ clearTimeout(pending.timeout)
336
+ pending.resolve(new Response('Tunnel disconnected', { status: 502 }))
337
+ }
338
+ this.pendingHttpRequests.clear()
339
+
340
+ // Close all pending WS connections
341
+ for (const [connId, pending] of this.pendingWsConnections) {
342
+ clearTimeout(pending.timeout)
343
+ try {
344
+ pending.userWs.close(4011, 'Tunnel disconnected')
345
+ } catch {}
346
+ }
347
+ this.pendingWsConnections.clear()
348
+ }
349
+ }
350
+
351
+ async webSocketError(ws: WebSocket, error: unknown) {
352
+ // Treat errors same as close
353
+ await this.webSocketClose(ws, 1011, 'WebSocket error', false)
354
+ }
355
+
356
+ // ============================================
357
+ // Message Handlers
358
+ // ============================================
359
+
360
+ private handleUpstreamMessage(tunnelId: string, rawMessage: string) {
361
+ let msg: DownstreamMessage
362
+ try {
363
+ msg = JSON.parse(rawMessage) as DownstreamMessage
364
+ } catch {
365
+ return
366
+ }
367
+
368
+ switch (msg.type) {
369
+ case 'http_response':
370
+ this.handleHttpResponse(msg)
371
+ break
372
+ case 'http_error':
373
+ this.handleHttpError(msg)
374
+ break
375
+ case 'ws_opened':
376
+ this.handleWsOpened(msg)
377
+ break
378
+ case 'ws_frame':
379
+ this.handleWsFrame(tunnelId, msg)
380
+ break
381
+ case 'ws_closed':
382
+ this.handleWsClosed(msg)
383
+ break
384
+ case 'ws_error':
385
+ this.handleWsError(msg)
386
+ break
387
+ }
388
+ }
389
+
390
+ private handleDownstreamMessage(
391
+ tunnelId: string,
392
+ ws: WebSocket,
393
+ rawMessage: string
394
+ ) {
395
+ // Forward message from user WebSocket to upstream
396
+ const upstream = this.getUpstream(tunnelId)
397
+ if (!upstream) {
398
+ return
399
+ }
400
+
401
+ // Try to parse to get connId for routing
402
+ let parsed: { connId?: string; data?: string }
403
+ try {
404
+ parsed = JSON.parse(rawMessage)
405
+ } catch {
406
+ return
407
+ }
408
+
409
+ // Find the connId for this downstream WebSocket
410
+ const tags = this.ctx.getTags(ws)
411
+ const wsTag = tags.find((t) => t.startsWith('ws:'))
412
+ if (!wsTag) {
413
+ return
414
+ }
415
+ const connId = wsTag.replace('ws:', '')
416
+
417
+ // Forward as WsFrameMessage
418
+ const message: WsFrameMessage = {
419
+ type: 'ws_frame',
420
+ connId,
421
+ data: rawMessage,
422
+ }
423
+
424
+ try {
425
+ upstream.send(JSON.stringify(message) satisfies string)
426
+ } catch {}
427
+ }
428
+
429
+ private handleHttpResponse(msg: HttpResponseMessage) {
430
+ const pending = this.pendingHttpRequests.get(msg.id)
431
+ if (!pending) {
432
+ return
433
+ }
434
+
435
+ clearTimeout(pending.timeout)
436
+ this.pendingHttpRequests.delete(msg.id)
437
+
438
+ // Decode body
439
+ let body: BodyInit | null = null
440
+ if (msg.body) {
441
+ body = base64ToArrayBuffer(msg.body)
442
+ }
443
+
444
+ // Build response headers
445
+ const headers = new Headers()
446
+ for (const [key, value] of Object.entries(msg.headers)) {
447
+ if (!isHopByHopHeader(key)) {
448
+ headers.set(key, value)
449
+ }
450
+ }
451
+
452
+ pending.resolve(new Response(body, { status: msg.status, headers }))
453
+ }
454
+
455
+ private handleHttpError(msg: HttpErrorMessage) {
456
+ const pending = this.pendingHttpRequests.get(msg.id)
457
+ if (!pending) {
458
+ return
459
+ }
460
+
461
+ clearTimeout(pending.timeout)
462
+ this.pendingHttpRequests.delete(msg.id)
463
+
464
+ pending.resolve(new Response(msg.error, { status: 502 }))
465
+ }
466
+
467
+ private handleWsOpened(msg: WsOpenedMessage) {
468
+ const pending = this.pendingWsConnections.get(msg.connId)
469
+ if (!pending) {
470
+ return
471
+ }
472
+
473
+ clearTimeout(pending.timeout)
474
+ this.pendingWsConnections.delete(msg.connId)
475
+ // WebSocket is now fully connected, messages will flow via webSocketMessage
476
+ }
477
+
478
+ private handleWsFrame(tunnelId: string, msg: WsFrameResponseMessage) {
479
+ const sockets = this.ctx.getWebSockets(`ws:${msg.connId}`)
480
+ for (const ws of sockets) {
481
+ try {
482
+ if (msg.binary) {
483
+ ws.send(base64ToArrayBuffer(msg.data))
484
+ } else {
485
+ ws.send(msg.data)
486
+ }
487
+ } catch {}
488
+ }
489
+ }
490
+
491
+ private handleWsClosed(msg: WsClosedMessage) {
492
+ // Clear pending if still waiting
493
+ const pending = this.pendingWsConnections.get(msg.connId)
494
+ if (pending) {
495
+ clearTimeout(pending.timeout)
496
+ this.pendingWsConnections.delete(msg.connId)
497
+ }
498
+
499
+ // Close user WebSocket
500
+ const sockets = this.ctx.getWebSockets(`ws:${msg.connId}`)
501
+ for (const ws of sockets) {
502
+ try {
503
+ ws.close(msg.code, msg.reason)
504
+ } catch {}
505
+ }
506
+ }
507
+
508
+ private handleWsError(msg: WsErrorMessage) {
509
+ // Clear pending if still waiting
510
+ const pending = this.pendingWsConnections.get(msg.connId)
511
+ if (pending) {
512
+ clearTimeout(pending.timeout)
513
+ this.pendingWsConnections.delete(msg.connId)
514
+ }
515
+
516
+ // Close user WebSocket with error
517
+ const sockets = this.ctx.getWebSockets(`ws:${msg.connId}`)
518
+ for (const ws of sockets) {
519
+ try {
520
+ ws.close(4012, msg.error)
521
+ } catch {}
522
+ }
523
+ }
524
+ }
525
+
526
+ // ============================================
527
+ // Utilities
528
+ // ============================================
529
+
530
+ function addCors(res: Response): Response {
531
+ const headers = new Headers(res.headers)
532
+ headers.set('Access-Control-Allow-Origin', '*')
533
+ headers.set('Access-Control-Allow-Headers', '*')
534
+ headers.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS')
535
+ return new Response(res.body, {
536
+ status: res.status,
537
+ statusText: res.statusText,
538
+ headers,
539
+ webSocket: (res as Response & { webSocket?: WebSocket }).webSocket,
540
+ })
541
+ }
542
+
543
+ function arrayBufferToBase64(buffer: ArrayBuffer): string {
544
+ const bytes = new Uint8Array(buffer)
545
+ let binary = ''
546
+ for (let i = 0; i < bytes.byteLength; i++) {
547
+ binary += String.fromCharCode(bytes[i])
548
+ }
549
+ return btoa(binary)
550
+ }
551
+
552
+ function base64ToArrayBuffer(base64: string): ArrayBuffer {
553
+ const binary = atob(base64)
554
+ const bytes = new Uint8Array(binary.length)
555
+ for (let i = 0; i < binary.length; i++) {
556
+ bytes[i] = binary.charCodeAt(i)
557
+ }
558
+ return bytes.buffer
559
+ }
560
+
561
+ const HOP_BY_HOP_HEADERS = new Set([
562
+ 'connection',
563
+ 'keep-alive',
564
+ 'proxy-authenticate',
565
+ 'proxy-authorization',
566
+ 'te',
567
+ 'trailers',
568
+ 'transfer-encoding',
569
+ 'upgrade',
570
+ ])
571
+
572
+ function isHopByHopHeader(header: string): boolean {
573
+ return HOP_BY_HOP_HEADERS.has(header.toLowerCase())
574
+ }
package/src/types.ts ADDED
@@ -0,0 +1,152 @@
1
+ // ============================================
2
+ // Messages: Worker/DO → Local Client (upstream)
3
+ // ============================================
4
+
5
+ // HTTP request to be proxied to local server
6
+ export type HttpRequestMessage = {
7
+ type: 'http_request'
8
+ id: string
9
+ method: string
10
+ path: string
11
+ headers: Record<string, string>
12
+ body: string | null // base64 encoded for binary safety
13
+ }
14
+
15
+ // WebSocket connection request from remote user
16
+ export type WsOpenMessage = {
17
+ type: 'ws_open'
18
+ connId: string
19
+ path: string
20
+ headers: Record<string, string>
21
+ }
22
+
23
+ // WebSocket frame from remote user
24
+ export type WsFrameMessage = {
25
+ type: 'ws_frame'
26
+ connId: string
27
+ data: string // text or base64 for binary
28
+ binary?: boolean
29
+ }
30
+
31
+ // WebSocket close from remote user
32
+ export type WsCloseMessage = {
33
+ type: 'ws_close'
34
+ connId: string
35
+ code: number
36
+ reason: string
37
+ }
38
+
39
+ // All messages that can be sent TO the local client
40
+ export type UpstreamMessage =
41
+ | HttpRequestMessage
42
+ | WsOpenMessage
43
+ | WsFrameMessage
44
+ | WsCloseMessage
45
+
46
+ // ============================================
47
+ // Messages: Local Client → Worker/DO
48
+ // ============================================
49
+
50
+ // HTTP response from local server
51
+ export type HttpResponseMessage = {
52
+ type: 'http_response'
53
+ id: string
54
+ status: number
55
+ headers: Record<string, string>
56
+ body: string | null // base64 encoded
57
+ }
58
+
59
+ // HTTP error (local server unavailable, timeout, etc)
60
+ export type HttpErrorMessage = {
61
+ type: 'http_error'
62
+ id: string
63
+ error: string
64
+ }
65
+
66
+ // WebSocket opened successfully to local server
67
+ export type WsOpenedMessage = {
68
+ type: 'ws_opened'
69
+ connId: string
70
+ }
71
+
72
+ // WebSocket frame from local server
73
+ export type WsFrameResponseMessage = {
74
+ type: 'ws_frame'
75
+ connId: string
76
+ data: string
77
+ binary?: boolean
78
+ }
79
+
80
+ // WebSocket closed by local server
81
+ export type WsClosedMessage = {
82
+ type: 'ws_closed'
83
+ connId: string
84
+ code: number
85
+ reason: string
86
+ }
87
+
88
+ // WebSocket error connecting to local server
89
+ export type WsErrorMessage = {
90
+ type: 'ws_error'
91
+ connId: string
92
+ error: string
93
+ }
94
+
95
+ // All messages that can be sent FROM the local client
96
+ export type DownstreamMessage =
97
+ | HttpResponseMessage
98
+ | HttpErrorMessage
99
+ | WsOpenedMessage
100
+ | WsFrameResponseMessage
101
+ | WsClosedMessage
102
+ | WsErrorMessage
103
+
104
+ // ============================================
105
+ // Events: DO → Remote Users (downstream WebSocket)
106
+ // ============================================
107
+
108
+ export type UpstreamConnectedEvent = {
109
+ event: 'upstream_connected'
110
+ }
111
+
112
+ export type UpstreamDisconnectedEvent = {
113
+ event: 'upstream_disconnected'
114
+ }
115
+
116
+ export type DownstreamEvent = UpstreamConnectedEvent | UpstreamDisconnectedEvent
117
+
118
+ // ============================================
119
+ // Helper functions
120
+ // ============================================
121
+
122
+ // Helper to create type-safe messages
123
+ export function createMessage<T extends UpstreamMessage | DownstreamMessage>(
124
+ msg: T
125
+ ): string {
126
+ return JSON.stringify(msg)
127
+ }
128
+
129
+ // Helper to parse messages with type narrowing
130
+ export function parseUpstreamMessage(data: string): UpstreamMessage | null {
131
+ try {
132
+ const msg = JSON.parse(data) as UpstreamMessage
133
+ if (!msg.type) {
134
+ return null
135
+ }
136
+ return msg
137
+ } catch {
138
+ return null
139
+ }
140
+ }
141
+
142
+ export function parseDownstreamMessage(data: string): DownstreamMessage | null {
143
+ try {
144
+ const msg = JSON.parse(data) as DownstreamMessage
145
+ if (!msg.type) {
146
+ return null
147
+ }
148
+ return msg
149
+ } catch {
150
+ return null
151
+ }
152
+ }