@rubriclab/bunl 0.1.25 → 0.2.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/.env.example +2 -1
- package/.github/workflows/publish.yml +16 -0
- package/.vscode/settings.json +16 -0
- package/CHANGELOG.md +3 -0
- package/Dockerfile +6 -8
- package/LICENSE +1 -1
- package/README.md +15 -67
- package/biome.json +3 -0
- package/build/client.js +130 -468
- package/bun.lock +169 -0
- package/client.ts +132 -85
- package/demo.tsx +127 -0
- package/docker-compose.yml +36 -0
- package/e2e.test.ts +187 -0
- package/package.json +52 -44
- package/server.ts +160 -85
- package/tsconfig.json +3 -3
- package/types.ts +23 -9
- package/utils.ts +149 -6
- package/bun.lockb +0 -0
- package/demo.ts +0 -18
- package/fly.toml +0 -19
- package/index.ts +0 -67
package/e2e.test.ts
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
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
|
+
function tunnelFetch(path: string, init?: RequestInit): Promise<Response> {
|
|
9
|
+
const headers = new Headers(init?.headers as HeadersInit)
|
|
10
|
+
headers.set('host', `${SUBDOMAIN}.localhost:${SERVER_PORT}`)
|
|
11
|
+
return fetch(`http://localhost:${SERVER_PORT}${path}`, {
|
|
12
|
+
...init,
|
|
13
|
+
headers
|
|
14
|
+
})
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
let serverProc: Subprocess
|
|
18
|
+
let demoProc: Subprocess
|
|
19
|
+
let clientProc: Subprocess
|
|
20
|
+
|
|
21
|
+
async function waitFor(url: string, timeoutMs = 10_000): Promise<void> {
|
|
22
|
+
const start = Date.now()
|
|
23
|
+
while (Date.now() - start < timeoutMs) {
|
|
24
|
+
try {
|
|
25
|
+
await fetch(url)
|
|
26
|
+
return
|
|
27
|
+
} catch {
|
|
28
|
+
await Bun.sleep(200)
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
throw new Error(`Timed out waiting for ${url}`)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function waitForTunnel(timeoutMs = 15_000): Promise<void> {
|
|
35
|
+
const start = Date.now()
|
|
36
|
+
while (Date.now() - start < timeoutMs) {
|
|
37
|
+
try {
|
|
38
|
+
const res = await tunnelFetch('/')
|
|
39
|
+
if (res.status !== 404) return
|
|
40
|
+
} catch {
|
|
41
|
+
/* not ready */
|
|
42
|
+
}
|
|
43
|
+
await Bun.sleep(300)
|
|
44
|
+
}
|
|
45
|
+
throw new Error('Timed out waiting for tunnel to be ready')
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
beforeAll(async () => {
|
|
49
|
+
serverProc = Bun.spawn(['bun', 'run', 'server.ts'], {
|
|
50
|
+
cwd: import.meta.dir,
|
|
51
|
+
env: {
|
|
52
|
+
...process.env,
|
|
53
|
+
DOMAIN: `localhost:${SERVER_PORT}`,
|
|
54
|
+
PORT: String(SERVER_PORT),
|
|
55
|
+
SCHEME: 'http'
|
|
56
|
+
},
|
|
57
|
+
stderr: 'pipe',
|
|
58
|
+
stdout: 'pipe'
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
demoProc = Bun.spawn(['bun', 'run', 'demo.tsx'], {
|
|
62
|
+
cwd: import.meta.dir,
|
|
63
|
+
env: { ...process.env, DEMO_PORT: String(DEMO_PORT) },
|
|
64
|
+
stderr: 'pipe',
|
|
65
|
+
stdout: 'pipe'
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
await Promise.all([
|
|
69
|
+
waitFor(`http://localhost:${SERVER_PORT}/?new`),
|
|
70
|
+
waitFor(`http://localhost:${DEMO_PORT}/api/health`)
|
|
71
|
+
])
|
|
72
|
+
|
|
73
|
+
clientProc = Bun.spawn(
|
|
74
|
+
[
|
|
75
|
+
'bun',
|
|
76
|
+
'run',
|
|
77
|
+
'client.ts',
|
|
78
|
+
'-p',
|
|
79
|
+
String(DEMO_PORT),
|
|
80
|
+
'-d',
|
|
81
|
+
`localhost:${SERVER_PORT}`,
|
|
82
|
+
'-s',
|
|
83
|
+
SUBDOMAIN
|
|
84
|
+
],
|
|
85
|
+
{
|
|
86
|
+
cwd: import.meta.dir,
|
|
87
|
+
env: process.env,
|
|
88
|
+
stderr: 'pipe',
|
|
89
|
+
stdout: 'pipe'
|
|
90
|
+
}
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
await waitForTunnel()
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
afterAll(() => {
|
|
97
|
+
clientProc?.kill()
|
|
98
|
+
demoProc?.kill()
|
|
99
|
+
serverProc?.kill()
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
describe('e2e tunnel', () => {
|
|
103
|
+
test('serves HTML through tunnel', async () => {
|
|
104
|
+
const res = await tunnelFetch('/')
|
|
105
|
+
expect(res.status).toBe(200)
|
|
106
|
+
expect(res.headers.get('content-type')).toContain('text/html')
|
|
107
|
+
const body = await res.text()
|
|
108
|
+
expect(body).toContain('<h1>bunl</h1>')
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
test('serves CSS through tunnel', async () => {
|
|
112
|
+
const res = await tunnelFetch('/style.css')
|
|
113
|
+
expect(res.status).toBe(200)
|
|
114
|
+
expect(res.headers.get('content-type')).toContain('text/css')
|
|
115
|
+
const body = await res.text()
|
|
116
|
+
expect(body).toContain('font-family')
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
test('serves PNG image (binary) through tunnel', async () => {
|
|
120
|
+
const res = await tunnelFetch('/image.png')
|
|
121
|
+
expect(res.status).toBe(200)
|
|
122
|
+
expect(res.headers.get('content-type')).toBe('image/png')
|
|
123
|
+
const buf = await res.arrayBuffer()
|
|
124
|
+
const bytes = new Uint8Array(buf)
|
|
125
|
+
expect(bytes[0]).toBe(0x89)
|
|
126
|
+
expect(bytes[1]).toBe(0x50)
|
|
127
|
+
expect(bytes[2]).toBe(0x4e)
|
|
128
|
+
expect(bytes[3]).toBe(0x47)
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
test('serves binary font through tunnel', async () => {
|
|
132
|
+
const res = await tunnelFetch('/font.woff2')
|
|
133
|
+
expect(res.status).toBe(200)
|
|
134
|
+
expect(res.headers.get('content-type')).toBe('font/woff2')
|
|
135
|
+
const buf = await res.arrayBuffer()
|
|
136
|
+
const bytes = new Uint8Array(buf)
|
|
137
|
+
expect(bytes.length).toBe(256)
|
|
138
|
+
expect(bytes[0]).toBe(0)
|
|
139
|
+
expect(bytes[127]).toBe(127)
|
|
140
|
+
expect(bytes[255]).toBe(255)
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
test('serves JSON API through tunnel', async () => {
|
|
144
|
+
const res = await tunnelFetch('/api/health')
|
|
145
|
+
expect(res.status).toBe(200)
|
|
146
|
+
const json = await res.json()
|
|
147
|
+
expect(json.status).toBe('ok')
|
|
148
|
+
expect(typeof json.timestamp).toBe('number')
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
test('handles POST with body through tunnel', async () => {
|
|
152
|
+
const payload = JSON.stringify({ hello: 'world' })
|
|
153
|
+
const res = await tunnelFetch('/echo', {
|
|
154
|
+
body: payload,
|
|
155
|
+
headers: { 'content-type': 'application/json' },
|
|
156
|
+
method: 'POST'
|
|
157
|
+
})
|
|
158
|
+
expect(res.status).toBe(200)
|
|
159
|
+
const body = await res.text()
|
|
160
|
+
expect(body).toBe(payload)
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
test("concurrent requests don't collide", async () => {
|
|
164
|
+
const paths = ['/', '/style.css', '/image.png', '/api/health', '/font.woff2']
|
|
165
|
+
const results = await Promise.all(paths.map(p => tunnelFetch(p).then(r => r.status)))
|
|
166
|
+
expect(results).toEqual([200, 200, 200, 200, 200])
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
test('returns 404 for unknown subdomain', async () => {
|
|
170
|
+
const res = await fetch(`http://localhost:${SERVER_PORT}/`, {
|
|
171
|
+
headers: { host: `nonexistent.localhost:${SERVER_PORT}` }
|
|
172
|
+
})
|
|
173
|
+
expect(res.status).toBe(404)
|
|
174
|
+
const body = await res.text()
|
|
175
|
+
expect(body).toContain('Not Found')
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
test('returns landing page for root domain', async () => {
|
|
179
|
+
const res = await fetch(`http://localhost:${SERVER_PORT}/`, {
|
|
180
|
+
headers: { host: `localhost:${SERVER_PORT}` }
|
|
181
|
+
})
|
|
182
|
+
expect(res.status).toBe(200)
|
|
183
|
+
const body = await res.text()
|
|
184
|
+
expect(body).toContain('bunl')
|
|
185
|
+
expect(body).toContain('Expose localhost to the world')
|
|
186
|
+
})
|
|
187
|
+
})
|
package/package.json
CHANGED
|
@@ -1,46 +1,54 @@
|
|
|
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
|
+
"react": "^19.2.4",
|
|
8
|
+
"react-dom": "^19.2.4"
|
|
9
|
+
},
|
|
10
|
+
"description": "Expose localhost to the world. Bun-native localtunnel.",
|
|
11
|
+
"devDependencies": {
|
|
12
|
+
"@rubriclab/package": "^0.0.124",
|
|
13
|
+
"@types/bun": "latest",
|
|
14
|
+
"@types/react": "^19.2.13",
|
|
15
|
+
"@types/react-dom": "^19.2.3"
|
|
16
|
+
},
|
|
17
|
+
"homepage": "https://github.com/RubricLab/bunl#readme",
|
|
18
|
+
"keywords": [
|
|
19
|
+
"local",
|
|
20
|
+
"tunnel",
|
|
21
|
+
"bun",
|
|
22
|
+
"websocket"
|
|
23
|
+
],
|
|
24
|
+
"license": "MIT",
|
|
25
|
+
"main": "build/client.js",
|
|
26
|
+
"name": "@rubriclab/bunl",
|
|
27
|
+
"publishConfig": {
|
|
28
|
+
"access": "public"
|
|
29
|
+
},
|
|
30
|
+
"repository": {
|
|
31
|
+
"type": "git",
|
|
32
|
+
"url": "git+https://github.com/RubricLab/bunl.git"
|
|
33
|
+
},
|
|
34
|
+
"scripts": {
|
|
35
|
+
"bleed": "bun x npm-check-updates -u",
|
|
36
|
+
"build": "bun build client.ts --outdir build --target bun && printf '#! /usr/bin/env bun\\n%s' \"$(cat build/client.js)\" > build/client.js",
|
|
37
|
+
"clean": "rm -rf .next && rm -rf node_modules",
|
|
38
|
+
"client": "bun --watch client.ts",
|
|
39
|
+
"demo": "bun --watch demo.tsx",
|
|
40
|
+
"dev:server": "bun --watch server.ts",
|
|
41
|
+
"format": "bun x biome format --write .",
|
|
42
|
+
"lint": "bun x biome check .",
|
|
43
|
+
"lint:fix": "bun x biome lint . --write --unsafe",
|
|
44
|
+
"prepare": "bun x @rubriclab/package prepare",
|
|
45
|
+
"server": "bun server.ts",
|
|
46
|
+
"test": "bun test",
|
|
47
|
+
"test:e2e": "bun test e2e.test.ts"
|
|
48
|
+
},
|
|
49
|
+
"simple-git-hooks": {
|
|
50
|
+
"post-commit": "rubriclab-package post-commit"
|
|
51
|
+
},
|
|
52
|
+
"type": "module",
|
|
53
|
+
"version": "0.2.1"
|
|
46
54
|
}
|
package/server.ts
CHANGED
|
@@ -1,87 +1,162 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import
|
|
4
|
-
|
|
5
|
-
const port = Bun.env.PORT || 1234
|
|
6
|
-
const scheme = Bun.env.SCHEME ||
|
|
7
|
-
const domain = Bun.env.DOMAIN || `localhost:${port}
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
const
|
|
1
|
+
import { type ServerWebSocket, serve } from 'bun'
|
|
2
|
+
import type { Client, TunnelInit, TunnelRequest, TunnelResponse } from './types'
|
|
3
|
+
import { fromBase64, page, toBase64, uid } from './utils'
|
|
4
|
+
|
|
5
|
+
const port = Number(Bun.env.PORT) || 1234
|
|
6
|
+
const scheme = Bun.env.SCHEME || 'http'
|
|
7
|
+
const domain = Bun.env.DOMAIN || `localhost:${port}`
|
|
8
|
+
const domainHost = domain.replace(/:\d+$/, '')
|
|
9
|
+
|
|
10
|
+
const clients = new Map<string, ServerWebSocket<Client>>()
|
|
11
|
+
const pending = new Map<
|
|
12
|
+
string,
|
|
13
|
+
{
|
|
14
|
+
resolve: (res: TunnelResponse) => void
|
|
15
|
+
timer: Timer
|
|
16
|
+
}
|
|
17
|
+
>()
|
|
18
|
+
|
|
19
|
+
const TIMEOUT_MS = 30_000
|
|
20
|
+
|
|
21
|
+
const landingHtml = page(
|
|
22
|
+
'bunl',
|
|
23
|
+
`<h1>bunl</h1>
|
|
24
|
+
<p>Expose localhost to the world.</p>
|
|
25
|
+
<p><code>bun x bunl -p 3000</code></p>`
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
function notFoundHtml(subdomain: string) {
|
|
29
|
+
return page(
|
|
30
|
+
'Not Found',
|
|
31
|
+
`<h1>Not Found</h1>
|
|
32
|
+
<p>No tunnel is connected for <code>${subdomain}</code>.</p>
|
|
33
|
+
<p>Make sure your client is running.</p>`
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const timeoutHtml = page(
|
|
38
|
+
'Gateway Timeout',
|
|
39
|
+
`<h1>Gateway Timeout</h1>
|
|
40
|
+
<p>The tunnel client didn't respond in time.</p>`
|
|
41
|
+
)
|
|
11
42
|
|
|
12
43
|
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
|
-
|
|
44
|
+
fetch: async (req, server) => {
|
|
45
|
+
const reqUrl = new URL(req.url)
|
|
46
|
+
|
|
47
|
+
if (reqUrl.searchParams.has('new')) {
|
|
48
|
+
const requested = reqUrl.searchParams.get('subdomain')
|
|
49
|
+
let id = requested || uid()
|
|
50
|
+
if (clients.has(id)) id = uid()
|
|
51
|
+
|
|
52
|
+
const upgraded = server.upgrade(req, { data: { id } })
|
|
53
|
+
if (upgraded) return undefined
|
|
54
|
+
return new Response('WebSocket upgrade failed', { status: 500 })
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const host = (req.headers.get('host') || reqUrl.hostname).replace(/:\d+$/, '')
|
|
58
|
+
const subdomain = host.endsWith(`.${domainHost}`) ? host.slice(0, -(domainHost.length + 1)) : ''
|
|
59
|
+
|
|
60
|
+
if (!subdomain) {
|
|
61
|
+
return new Response(landingHtml, {
|
|
62
|
+
headers: { 'content-type': 'text/html; charset=utf-8' }
|
|
63
|
+
})
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const client = clients.get(subdomain)
|
|
67
|
+
|
|
68
|
+
if (!client) {
|
|
69
|
+
return new Response(notFoundHtml(subdomain), {
|
|
70
|
+
headers: { 'content-type': 'text/html; charset=utf-8' },
|
|
71
|
+
status: 404
|
|
72
|
+
})
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const id = crypto.randomUUID()
|
|
76
|
+
const { method } = req
|
|
77
|
+
const pathname = reqUrl.pathname + reqUrl.search
|
|
78
|
+
|
|
79
|
+
const rawBody = await req.arrayBuffer()
|
|
80
|
+
const body = rawBody.byteLength > 0 ? toBase64(rawBody) : ''
|
|
81
|
+
|
|
82
|
+
const headers: Record<string, string> = {}
|
|
83
|
+
req.headers.forEach((v, k) => {
|
|
84
|
+
headers[k] = v
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
const message: TunnelRequest = {
|
|
88
|
+
body,
|
|
89
|
+
headers,
|
|
90
|
+
id,
|
|
91
|
+
method,
|
|
92
|
+
pathname,
|
|
93
|
+
type: 'request'
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const response = await new Promise<TunnelResponse>((resolve, reject) => {
|
|
97
|
+
const timer = setTimeout(() => {
|
|
98
|
+
pending.delete(id)
|
|
99
|
+
reject(new Error('Tunnel request timed out'))
|
|
100
|
+
}, TIMEOUT_MS)
|
|
101
|
+
|
|
102
|
+
pending.set(id, { resolve, timer })
|
|
103
|
+
client.send(JSON.stringify(message))
|
|
104
|
+
}).catch((): TunnelResponse => {
|
|
105
|
+
return {
|
|
106
|
+
body: Buffer.from(timeoutHtml).toString('base64'),
|
|
107
|
+
headers: { 'content-type': 'text/html; charset=utf-8' },
|
|
108
|
+
id,
|
|
109
|
+
status: 504,
|
|
110
|
+
statusText: 'Gateway Timeout',
|
|
111
|
+
type: 'response'
|
|
112
|
+
}
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
const resBody = response.body ? fromBase64(response.body) : null
|
|
116
|
+
|
|
117
|
+
const resHeaders = { ...response.headers }
|
|
118
|
+
delete resHeaders['content-encoding']
|
|
119
|
+
delete resHeaders['transfer-encoding']
|
|
120
|
+
if (resBody) {
|
|
121
|
+
resHeaders['content-length'] = String(resBody.byteLength)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return new Response(resBody as Uint8Array<ArrayBuffer> | null, {
|
|
125
|
+
headers: resHeaders,
|
|
126
|
+
status: response.status,
|
|
127
|
+
statusText: response.statusText
|
|
128
|
+
})
|
|
129
|
+
},
|
|
130
|
+
port,
|
|
131
|
+
websocket: {
|
|
132
|
+
close(ws) {
|
|
133
|
+
console.log(`\x1b[31m- ${ws.data.id}\x1b[0m (${clients.size - 1} connected)`)
|
|
134
|
+
clients.delete(ws.data.id)
|
|
135
|
+
},
|
|
136
|
+
message(_ws, raw) {
|
|
137
|
+
const msg = JSON.parse(
|
|
138
|
+
typeof raw === 'string' ? raw : new TextDecoder().decode(raw)
|
|
139
|
+
) as TunnelResponse
|
|
140
|
+
|
|
141
|
+
if (msg.type !== 'response' || !msg.id) return
|
|
142
|
+
|
|
143
|
+
const entry = pending.get(msg.id)
|
|
144
|
+
if (!entry) return
|
|
145
|
+
|
|
146
|
+
clearTimeout(entry.timer)
|
|
147
|
+
pending.delete(msg.id)
|
|
148
|
+
entry.resolve(msg)
|
|
149
|
+
},
|
|
150
|
+
open(ws) {
|
|
151
|
+
clients.set(ws.data.id, ws)
|
|
152
|
+
console.log(`\x1b[32m+ ${ws.data.id}\x1b[0m (${clients.size} connected)`)
|
|
153
|
+
const init: TunnelInit = {
|
|
154
|
+
type: 'init',
|
|
155
|
+
url: `${scheme}://${ws.data.id}.${domain}`
|
|
156
|
+
}
|
|
157
|
+
ws.send(JSON.stringify(init))
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
console.log(`bunl server listening on :${port} (${scheme}://*.${domain})`)
|
package/tsconfig.json
CHANGED
package/types.ts
CHANGED
|
@@ -1,10 +1,24 @@
|
|
|
1
|
-
export type Client = { id: string }
|
|
1
|
+
export type Client = { id: string }
|
|
2
2
|
|
|
3
|
-
export type
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
}
|
|
3
|
+
export type TunnelRequest = {
|
|
4
|
+
type: 'request'
|
|
5
|
+
id: string
|
|
6
|
+
method: string
|
|
7
|
+
pathname: string
|
|
8
|
+
headers: Record<string, string>
|
|
9
|
+
body: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export type TunnelResponse = {
|
|
13
|
+
type: 'response'
|
|
14
|
+
id: string
|
|
15
|
+
status: number
|
|
16
|
+
statusText: string
|
|
17
|
+
headers: Record<string, string>
|
|
18
|
+
body: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type TunnelInit = {
|
|
22
|
+
type: 'init'
|
|
23
|
+
url: string
|
|
24
|
+
}
|