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 +21 -0
- package/package.json +43 -0
- package/src/cli.ts +52 -0
- package/src/client.ts +326 -0
- package/src/run-tunnel.ts +155 -0
- package/src/tunnel.ts +574 -0
- package/src/types.ts +152 -0
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
|
+
}
|