@rubriclab/bunl 0.1.25 → 0.2.0
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/.env.example +2 -1
- package/.github/workflows/publish.yml +16 -0
- package/.vscode/settings.json +16 -0
- package/Dockerfile +6 -8
- package/LICENSE +1 -1
- package/biome.json +3 -0
- package/build/client.js +93 -470
- package/bun.lock +153 -0
- package/client.ts +129 -85
- package/demo.ts +79 -12
- package/docker-compose.yml +36 -0
- package/e2e.test.ts +189 -0
- package/package.json +47 -44
- package/server.ts +146 -83
- package/tsconfig.json +3 -3
- package/types.ts +26 -9
- package/utils.ts +119 -6
- package/bun.lockb +0 -0
- package/fly.toml +0 -19
- package/index.ts +0 -67
package/e2e.test.ts
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from 'bun:test'
|
|
2
|
+
import type { Subprocess } from 'bun'
|
|
3
|
+
|
|
4
|
+
const SERVER_PORT = 9100
|
|
5
|
+
const DEMO_PORT = 9101
|
|
6
|
+
const SUBDOMAIN = 'e2e'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Make a request through the tunnel using Host-header routing.
|
|
10
|
+
* This avoids needing wildcard DNS (*.localhost) to resolve,
|
|
11
|
+
* which doesn't work in all environments.
|
|
12
|
+
*/
|
|
13
|
+
function tunnelFetch(path: string, init?: RequestInit): Promise<Response> {
|
|
14
|
+
const headers = new Headers(init?.headers as HeadersInit)
|
|
15
|
+
headers.set('host', `${SUBDOMAIN}.localhost:${SERVER_PORT}`)
|
|
16
|
+
return fetch(`http://localhost:${SERVER_PORT}${path}`, {
|
|
17
|
+
...init,
|
|
18
|
+
headers
|
|
19
|
+
})
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
let serverProc: Subprocess
|
|
23
|
+
let demoProc: Subprocess
|
|
24
|
+
let clientProc: Subprocess
|
|
25
|
+
|
|
26
|
+
/** Wait until a URL responds (or timeout). */
|
|
27
|
+
async function waitFor(url: string, timeoutMs = 10_000): Promise<void> {
|
|
28
|
+
const start = Date.now()
|
|
29
|
+
while (Date.now() - start < timeoutMs) {
|
|
30
|
+
try {
|
|
31
|
+
await fetch(url)
|
|
32
|
+
return
|
|
33
|
+
} catch {
|
|
34
|
+
await Bun.sleep(200)
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
throw new Error(`Timed out waiting for ${url}`)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Wait until a tunnel subdomain is connected (server returns non-404). */
|
|
41
|
+
async function waitForTunnel(timeoutMs = 15_000): Promise<void> {
|
|
42
|
+
const start = Date.now()
|
|
43
|
+
while (Date.now() - start < timeoutMs) {
|
|
44
|
+
try {
|
|
45
|
+
const res = await tunnelFetch('/')
|
|
46
|
+
if (res.status !== 404) return
|
|
47
|
+
} catch {
|
|
48
|
+
// not ready yet
|
|
49
|
+
}
|
|
50
|
+
await Bun.sleep(300)
|
|
51
|
+
}
|
|
52
|
+
throw new Error('Timed out waiting for tunnel to be ready')
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
beforeAll(async () => {
|
|
56
|
+
// 1. Start the tunnel server
|
|
57
|
+
serverProc = Bun.spawn(['bun', 'run', 'server.ts'], {
|
|
58
|
+
cwd: import.meta.dir,
|
|
59
|
+
env: {
|
|
60
|
+
...process.env,
|
|
61
|
+
DOMAIN: `localhost:${SERVER_PORT}`,
|
|
62
|
+
PORT: String(SERVER_PORT),
|
|
63
|
+
SCHEME: 'http'
|
|
64
|
+
},
|
|
65
|
+
stderr: 'pipe',
|
|
66
|
+
stdout: 'pipe'
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
// 2. Start the demo webserver
|
|
70
|
+
demoProc = Bun.spawn(['bun', 'run', 'demo.ts'], {
|
|
71
|
+
cwd: import.meta.dir,
|
|
72
|
+
env: { ...process.env, DEMO_PORT: String(DEMO_PORT) },
|
|
73
|
+
stderr: 'pipe',
|
|
74
|
+
stdout: 'pipe'
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
// Wait for both to be up
|
|
78
|
+
await Promise.all([
|
|
79
|
+
waitFor(`http://localhost:${SERVER_PORT}/?new`),
|
|
80
|
+
waitFor(`http://localhost:${DEMO_PORT}/api/health`)
|
|
81
|
+
])
|
|
82
|
+
|
|
83
|
+
// 3. Start the tunnel client
|
|
84
|
+
clientProc = Bun.spawn(
|
|
85
|
+
[
|
|
86
|
+
'bun',
|
|
87
|
+
'run',
|
|
88
|
+
'client.ts',
|
|
89
|
+
'-p',
|
|
90
|
+
String(DEMO_PORT),
|
|
91
|
+
'-d',
|
|
92
|
+
`localhost:${SERVER_PORT}`,
|
|
93
|
+
'-s',
|
|
94
|
+
SUBDOMAIN
|
|
95
|
+
],
|
|
96
|
+
{
|
|
97
|
+
cwd: import.meta.dir,
|
|
98
|
+
env: process.env,
|
|
99
|
+
stderr: 'pipe',
|
|
100
|
+
stdout: 'pipe'
|
|
101
|
+
}
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
// Wait for the tunnel to be live
|
|
105
|
+
await waitForTunnel()
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
afterAll(() => {
|
|
109
|
+
clientProc?.kill()
|
|
110
|
+
demoProc?.kill()
|
|
111
|
+
serverProc?.kill()
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
describe('e2e tunnel', () => {
|
|
115
|
+
test('serves HTML through tunnel', async () => {
|
|
116
|
+
const res = await tunnelFetch('/')
|
|
117
|
+
expect(res.status).toBe(200)
|
|
118
|
+
expect(res.headers.get('content-type')).toContain('text/html')
|
|
119
|
+
const body = await res.text()
|
|
120
|
+
expect(body).toContain('<h1>bunl tunnel works!</h1>')
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
test('serves CSS through tunnel', async () => {
|
|
124
|
+
const res = await tunnelFetch('/style.css')
|
|
125
|
+
expect(res.status).toBe(200)
|
|
126
|
+
expect(res.headers.get('content-type')).toContain('text/css')
|
|
127
|
+
const body = await res.text()
|
|
128
|
+
expect(body).toContain('font-family')
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
test('serves PNG image (binary) through tunnel', async () => {
|
|
132
|
+
const res = await tunnelFetch('/image.png')
|
|
133
|
+
expect(res.status).toBe(200)
|
|
134
|
+
expect(res.headers.get('content-type')).toBe('image/png')
|
|
135
|
+
const buf = await res.arrayBuffer()
|
|
136
|
+
const bytes = new Uint8Array(buf)
|
|
137
|
+
// PNG magic bytes: 0x89 0x50 0x4E 0x47
|
|
138
|
+
expect(bytes[0]).toBe(0x89)
|
|
139
|
+
expect(bytes[1]).toBe(0x50)
|
|
140
|
+
expect(bytes[2]).toBe(0x4e)
|
|
141
|
+
expect(bytes[3]).toBe(0x47)
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
test('serves binary font through tunnel', async () => {
|
|
145
|
+
const res = await tunnelFetch('/font.woff2')
|
|
146
|
+
expect(res.status).toBe(200)
|
|
147
|
+
expect(res.headers.get('content-type')).toBe('font/woff2')
|
|
148
|
+
const buf = await res.arrayBuffer()
|
|
149
|
+
const bytes = new Uint8Array(buf)
|
|
150
|
+
// Our fake font is 256 bytes: [0, 1, 2, ..., 255]
|
|
151
|
+
expect(bytes.length).toBe(256)
|
|
152
|
+
expect(bytes[0]).toBe(0)
|
|
153
|
+
expect(bytes[127]).toBe(127)
|
|
154
|
+
expect(bytes[255]).toBe(255)
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
test('serves JSON API through tunnel', async () => {
|
|
158
|
+
const res = await tunnelFetch('/api/health')
|
|
159
|
+
expect(res.status).toBe(200)
|
|
160
|
+
const json = await res.json()
|
|
161
|
+
expect(json.status).toBe('ok')
|
|
162
|
+
expect(typeof json.timestamp).toBe('number')
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
test('handles POST with body through tunnel', async () => {
|
|
166
|
+
const payload = JSON.stringify({ hello: 'world' })
|
|
167
|
+
const res = await tunnelFetch('/echo', {
|
|
168
|
+
body: payload,
|
|
169
|
+
headers: { 'content-type': 'application/json' },
|
|
170
|
+
method: 'POST'
|
|
171
|
+
})
|
|
172
|
+
expect(res.status).toBe(200)
|
|
173
|
+
const body = await res.text()
|
|
174
|
+
expect(body).toBe(payload)
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
test("concurrent requests don't collide", async () => {
|
|
178
|
+
const paths = ['/', '/style.css', '/image.png', '/api/health', '/font.woff2']
|
|
179
|
+
const results = await Promise.all(paths.map(p => tunnelFetch(p).then(r => r.status)))
|
|
180
|
+
expect(results).toEqual([200, 200, 200, 200, 200])
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
test('returns 404 for unknown subdomain', async () => {
|
|
184
|
+
const res = await fetch(`http://localhost:${SERVER_PORT}/`, {
|
|
185
|
+
headers: { host: `nonexistent.localhost:${SERVER_PORT}` }
|
|
186
|
+
})
|
|
187
|
+
expect(res.status).toBe(404)
|
|
188
|
+
})
|
|
189
|
+
})
|
package/package.json
CHANGED
|
@@ -1,46 +1,49 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
2
|
+
"bin": {
|
|
3
|
+
"bunl": "build/client.js"
|
|
4
|
+
},
|
|
5
|
+
"dependencies": {
|
|
6
|
+
"@rubriclab/config": "^0.0.24"
|
|
7
|
+
},
|
|
8
|
+
"description": "Expose localhost to the world. Bun-native localtunnel.",
|
|
9
|
+
"devDependencies": {
|
|
10
|
+
"@rubriclab/package": "^0.0.124",
|
|
11
|
+
"@types/bun": "latest"
|
|
12
|
+
},
|
|
13
|
+
"homepage": "https://github.com/RubricLab/bunl#readme",
|
|
14
|
+
"keywords": [
|
|
15
|
+
"local",
|
|
16
|
+
"tunnel",
|
|
17
|
+
"bun",
|
|
18
|
+
"websocket"
|
|
19
|
+
],
|
|
20
|
+
"license": "MIT",
|
|
21
|
+
"main": "build/client.js",
|
|
22
|
+
"name": "@rubriclab/bunl",
|
|
23
|
+
"publishConfig": {
|
|
24
|
+
"access": "public"
|
|
25
|
+
},
|
|
26
|
+
"repository": {
|
|
27
|
+
"type": "git",
|
|
28
|
+
"url": "git+https://github.com/RubricLab/bunl.git"
|
|
29
|
+
},
|
|
30
|
+
"scripts": {
|
|
31
|
+
"bleed": "bun x npm-check-updates -u",
|
|
32
|
+
"build": "bun build client.ts --outdir build --target bun && printf '#! /usr/bin/env bun\\n%s' \"$(cat build/client.js)\" > build/client.js",
|
|
33
|
+
"clean": "rm -rf .next && rm -rf node_modules",
|
|
34
|
+
"client": "bun --watch client.ts",
|
|
35
|
+
"demo": "bun --watch demo.ts",
|
|
36
|
+
"dev:server": "bun --watch server.ts",
|
|
37
|
+
"format": "bun x biome format --write .",
|
|
38
|
+
"lint": "bun x biome check .",
|
|
39
|
+
"lint:fix": "bun x biome lint . --write --unsafe",
|
|
40
|
+
"server": "bun server.ts",
|
|
41
|
+
"test": "bun test",
|
|
42
|
+
"test:e2e": "bun test e2e.test.ts"
|
|
43
|
+
},
|
|
44
|
+
"simple-git-hooks": {
|
|
45
|
+
"post-commit": "rubriclab-package post-commit"
|
|
46
|
+
},
|
|
47
|
+
"type": "module",
|
|
48
|
+
"version": "0.2.0"
|
|
46
49
|
}
|
package/server.ts
CHANGED
|
@@ -1,87 +1,150 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import
|
|
1
|
+
import { type ServerWebSocket, serve } from 'bun'
|
|
2
|
+
import type { Client, TunnelInit, TunnelRequest, TunnelResponse } from './types'
|
|
3
|
+
import { fromBase64, toBase64, uid } from './utils'
|
|
4
4
|
|
|
5
|
-
const port = Bun.env.PORT || 1234
|
|
6
|
-
const scheme = Bun.env.SCHEME ||
|
|
7
|
-
const domain = Bun.env.DOMAIN || `localhost:${port}
|
|
5
|
+
const port = Number(Bun.env.PORT) || 1234
|
|
6
|
+
const scheme = Bun.env.SCHEME || 'http'
|
|
7
|
+
const domain = Bun.env.DOMAIN || `localhost:${port}`
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
const
|
|
9
|
+
/** The hostname portion of DOMAIN (no port) used for subdomain extraction */
|
|
10
|
+
const domainHost = domain.replace(/:\d+$/, '')
|
|
11
|
+
|
|
12
|
+
/** Connected tunnel clients keyed by subdomain */
|
|
13
|
+
const clients = new Map<string, ServerWebSocket<Client>>()
|
|
14
|
+
|
|
15
|
+
/** Pending HTTP requests waiting for a tunnel response, keyed by request ID */
|
|
16
|
+
const pending = new Map<
|
|
17
|
+
string,
|
|
18
|
+
{
|
|
19
|
+
resolve: (res: TunnelResponse) => void
|
|
20
|
+
timer: Timer
|
|
21
|
+
}
|
|
22
|
+
>()
|
|
23
|
+
|
|
24
|
+
const TIMEOUT_MS = 30_000
|
|
11
25
|
|
|
12
26
|
serve<Client>({
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
27
|
+
fetch: async (req, server) => {
|
|
28
|
+
const reqUrl = new URL(req.url)
|
|
29
|
+
|
|
30
|
+
// Client wants to register a new tunnel
|
|
31
|
+
if (reqUrl.searchParams.has('new')) {
|
|
32
|
+
const requested = reqUrl.searchParams.get('subdomain')
|
|
33
|
+
let id = requested || uid()
|
|
34
|
+
// Avoid collisions — if taken, generate a fresh one
|
|
35
|
+
if (clients.has(id)) id = uid()
|
|
36
|
+
|
|
37
|
+
const upgraded = server.upgrade(req, { data: { id } })
|
|
38
|
+
if (upgraded) return undefined
|
|
39
|
+
return new Response('WebSocket upgrade failed', { status: 500 })
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Public HTTP request — route to the right tunnel client.
|
|
43
|
+
// Use the Host header (not reqUrl.hostname) so this works behind
|
|
44
|
+
// reverse proxies like Fly.io where reqUrl.hostname is internal.
|
|
45
|
+
// Strip the DOMAIN suffix to extract the tunnel subdomain, so it
|
|
46
|
+
// works when the server is itself on a subdomain (e.g. bunl.rubric.sh).
|
|
47
|
+
const host = (req.headers.get('host') || reqUrl.hostname).replace(/:\d+$/, '')
|
|
48
|
+
const subdomain = host.endsWith(`.${domainHost}`) ? host.slice(0, -(domainHost.length + 1)) : ''
|
|
49
|
+
const client = subdomain ? clients.get(subdomain) : undefined
|
|
50
|
+
|
|
51
|
+
if (!client) {
|
|
52
|
+
return new Response(`Tunnel "${subdomain}" not found`, { status: 404 })
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const id = crypto.randomUUID()
|
|
56
|
+
const { method } = req
|
|
57
|
+
const pathname = reqUrl.pathname + reqUrl.search
|
|
58
|
+
|
|
59
|
+
// Read request body as binary and encode to base64
|
|
60
|
+
const rawBody = await req.arrayBuffer()
|
|
61
|
+
const body = rawBody.byteLength > 0 ? toBase64(rawBody) : ''
|
|
62
|
+
|
|
63
|
+
// Flatten request headers
|
|
64
|
+
const headers: Record<string, string> = {}
|
|
65
|
+
req.headers.forEach((v, k) => {
|
|
66
|
+
headers[k] = v
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
const message: TunnelRequest = {
|
|
70
|
+
body,
|
|
71
|
+
headers,
|
|
72
|
+
id,
|
|
73
|
+
method,
|
|
74
|
+
pathname,
|
|
75
|
+
type: 'request'
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Create a promise that will be resolved when the client responds
|
|
79
|
+
const response = await new Promise<TunnelResponse>((resolve, reject) => {
|
|
80
|
+
const timer = setTimeout(() => {
|
|
81
|
+
pending.delete(id)
|
|
82
|
+
reject(new Error('Tunnel request timed out'))
|
|
83
|
+
}, TIMEOUT_MS)
|
|
84
|
+
|
|
85
|
+
pending.set(id, { resolve, timer })
|
|
86
|
+
client.send(JSON.stringify(message))
|
|
87
|
+
}).catch((err: unknown): TunnelResponse => {
|
|
88
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
89
|
+
return {
|
|
90
|
+
body: Buffer.from(message).toString('base64'),
|
|
91
|
+
headers: { 'content-type': 'text/plain' },
|
|
92
|
+
id,
|
|
93
|
+
status: 504,
|
|
94
|
+
statusText: 'Gateway Timeout',
|
|
95
|
+
type: 'response'
|
|
96
|
+
}
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
// Decode base64 response body back to binary
|
|
100
|
+
const resBody = response.body ? fromBase64(response.body) : null
|
|
101
|
+
|
|
102
|
+
// Build response headers, removing problematic ones
|
|
103
|
+
const resHeaders = { ...response.headers }
|
|
104
|
+
delete resHeaders['content-encoding']
|
|
105
|
+
delete resHeaders['transfer-encoding']
|
|
106
|
+
// Fix content-length to match the actual decoded body
|
|
107
|
+
if (resBody) {
|
|
108
|
+
resHeaders['content-length'] = String(resBody.byteLength)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return new Response(resBody as Uint8Array<ArrayBuffer> | null, {
|
|
112
|
+
headers: resHeaders,
|
|
113
|
+
status: response.status,
|
|
114
|
+
statusText: response.statusText
|
|
115
|
+
})
|
|
116
|
+
},
|
|
117
|
+
port,
|
|
118
|
+
websocket: {
|
|
119
|
+
close(ws) {
|
|
120
|
+
console.log(`\x1b[31m- ${ws.data.id}\x1b[0m (${clients.size - 1} connected)`)
|
|
121
|
+
clients.delete(ws.data.id)
|
|
122
|
+
},
|
|
123
|
+
|
|
124
|
+
message(_ws, raw) {
|
|
125
|
+
const msg = JSON.parse(
|
|
126
|
+
typeof raw === 'string' ? raw : new TextDecoder().decode(raw)
|
|
127
|
+
) as TunnelResponse
|
|
128
|
+
|
|
129
|
+
if (msg.type !== 'response' || !msg.id) return
|
|
130
|
+
|
|
131
|
+
const entry = pending.get(msg.id)
|
|
132
|
+
if (!entry) return
|
|
133
|
+
|
|
134
|
+
clearTimeout(entry.timer)
|
|
135
|
+
pending.delete(msg.id)
|
|
136
|
+
entry.resolve(msg)
|
|
137
|
+
},
|
|
138
|
+
open(ws) {
|
|
139
|
+
clients.set(ws.data.id, ws)
|
|
140
|
+
console.log(`\x1b[32m+ ${ws.data.id}\x1b[0m (${clients.size} connected)`)
|
|
141
|
+
const init: TunnelInit = {
|
|
142
|
+
type: 'init',
|
|
143
|
+
url: `${scheme}://${ws.data.id}.${domain}`
|
|
144
|
+
}
|
|
145
|
+
ws.send(JSON.stringify(init))
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
console.log(`bunl server listening on :${port} (${scheme}://*.${domain})`)
|
package/tsconfig.json
CHANGED
package/types.ts
CHANGED
|
@@ -1,10 +1,27 @@
|
|
|
1
|
-
export type Client = { id: string }
|
|
1
|
+
export type Client = { id: string }
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
3
|
+
/** Server → Client: incoming HTTP request to proxy */
|
|
4
|
+
export type TunnelRequest = {
|
|
5
|
+
type: 'request'
|
|
6
|
+
id: string
|
|
7
|
+
method: string
|
|
8
|
+
pathname: string
|
|
9
|
+
headers: Record<string, string>
|
|
10
|
+
body: string // base64-encoded
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Client → Server: proxied HTTP response */
|
|
14
|
+
export type TunnelResponse = {
|
|
15
|
+
type: 'response'
|
|
16
|
+
id: string
|
|
17
|
+
status: number
|
|
18
|
+
statusText: string
|
|
19
|
+
headers: Record<string, string>
|
|
20
|
+
body: string // base64-encoded
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Server → Client: initial connection info */
|
|
24
|
+
export type TunnelInit = {
|
|
25
|
+
type: 'init'
|
|
26
|
+
url: string
|
|
27
|
+
}
|