@rubriclab/bunl 0.2.0 → 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 CHANGED
@@ -1,4 +1,4 @@
1
1
  PORT=1234
2
2
  SCHEME=https
3
- DOMAIN=example.so
4
- HOST=localhost
3
+ DOMAIN=bunl.sh
4
+ HOST=localhost
package/CHANGELOG.md ADDED
@@ -0,0 +1,3 @@
1
+ - [2026-02-06] [Bump version](https://github.com/rubriclab/bunl/commit/0043caed41428d060517223cbb8d72fe52813692)
2
+ # Changelog
3
+
package/README.md CHANGED
@@ -1,78 +1,26 @@
1
1
  # bunl
2
2
 
3
- ## A Bun WebSocket re-write of LocalTunnel
4
-
5
- ### Usage
6
-
7
- To try it:
8
-
9
- ```bash
10
- bun x bunl -p 3000 -d dev.rubric.me -s my-name
11
- ```
12
-
13
- ### Development
14
-
15
- To install dependencies:
16
-
17
- ```bash
18
- bun i
19
- ```
20
-
21
- To run the server:
22
-
23
- ```bash
24
- bun dev:server
25
- ```
26
-
27
- (Optional) to run a dummy process on localhost:3000:
3
+ Expose localhost to the world. Bun-native WebSocket tunnel.
28
4
 
29
5
  ```bash
30
- bun demo
6
+ bun x bunl -p 3000
31
7
  ```
32
8
 
33
- To run the client:
9
+ ## Options
34
10
 
35
- ```bash
36
- bun client -p 3000
37
- ```
11
+ | Flag | Short | Default | Description |
12
+ | --- | --- | --- | --- |
13
+ | `--port` | `-p` | `3000` | Local port to expose |
14
+ | `--domain` | `-d` | `bunl.sh` | Tunnel server |
15
+ | `--subdomain` | `-s` | random | Requested subdomain |
16
+ | `--open` | `-o` | `false` | Open URL in browser |
38
17
 
39
- With full args:
18
+ ## Development
40
19
 
41
20
  ```bash
42
- bun client --port 3000 --domain example.so --subdomain my-subdomain --open
43
- ```
44
-
45
- Or in shortform:
46
-
47
- ```bash
48
- bun client -p 3000 -d example.so -s my-subdomain -o
49
- ```
50
-
51
- The options:
52
-
53
- - `port` / `p` the localhost port to expose eg. **3000**
54
- - `domain` / `d` the hostname of the server Bunl is running on eg. **example.so**
55
- - `subdomain` / `s` the public URL to request eg. **my-subdomain**.example.so
56
- - `open` / `o` to auto-open your public URL in the browser
57
-
58
- ### [WIP] Deployment
59
-
60
- To build the client code:
61
-
62
- ```bash
63
- bun run build
64
- ```
65
-
66
- To deploy the server, for example on [Fly](https://fly.io):
67
-
68
- ```bash
69
- fly launch && fly deploy
70
- ```
71
-
72
- Making sure to set `DOMAIN` to your domain:
73
-
74
- ```bash
75
- fly secrets set DOMAIN=example.so
21
+ bun i
22
+ bun dev:server # tunnel server on :1234
23
+ bun demo # demo app on :3000
24
+ bun client # connect demo to server
25
+ bun test:e2e # end-to-end tests
76
26
  ```
77
-
78
- Open to PRs!
package/build/client.js CHANGED
@@ -19,8 +19,46 @@ function fromBase64(str) {
19
19
  const buf = Buffer.from(str, "base64");
20
20
  return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
21
21
  }
22
+ function page(title, body) {
23
+ return `<!DOCTYPE html>
24
+ <html lang="en">
25
+ <head>
26
+ <meta charset="utf-8">
27
+ <meta name="viewport" content="width=device-width, initial-scale=1">
28
+ <title>${title} \u2014 bunl</title>
29
+ <style>
30
+ * { margin: 0; padding: 0; box-sizing: border-box; }
31
+ body {
32
+ font-family: system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
33
+ background: #fff;
34
+ color: #000;
35
+ min-height: 100vh;
36
+ display: flex;
37
+ align-items: center;
38
+ justify-content: center;
39
+ padding: 2rem;
40
+ }
41
+ main { max-width: 480px; width: 100%; }
42
+ h1 { font-size: 1.5rem; font-weight: 600; letter-spacing: -0.02em; margin-bottom: 1rem; }
43
+ p { color: #666; line-height: 1.6; margin-bottom: 0.75rem; }
44
+ code { background: #f5f5f5; padding: 0.15em 0.4em; border-radius: 3px; font-size: 0.9em; }
45
+ a { color: #000; }
46
+ </style>
47
+ </head>
48
+ <body>
49
+ <main>
50
+ ${body}
51
+ </main>
52
+ </body>
53
+ </html>`;
54
+ }
22
55
 
23
56
  // client.ts
57
+ function badGatewayHtml(port) {
58
+ return page("Bad Gateway", `<h1>Bad Gateway</h1>
59
+ <p>Could not reach <code>localhost:${port}</code>.</p>
60
+ <p>Make sure your local server is running.</p>`);
61
+ }
24
62
  async function main({
25
63
  port,
26
64
  domain,
@@ -80,9 +118,10 @@ async function main({
80
118
  socket.send(JSON.stringify(response));
81
119
  } catch (err) {
82
120
  console.error(`\x1B[31mERR\x1B[0m ${req.method} ${req.pathname}: ${err}`);
121
+ const html = badGatewayHtml(port || "3000");
83
122
  const response = {
84
- body: toBase64(new TextEncoder().encode(`Failed to reach localhost:${port} \u2014 ${err}`).buffer),
85
- headers: { "content-type": "text/plain" },
123
+ body: toBase64(new TextEncoder().encode(html).buffer),
124
+ headers: { "content-type": "text/html; charset=utf-8" },
86
125
  id: req.id,
87
126
  status: 502,
88
127
  statusText: "Bad Gateway",
@@ -108,7 +147,7 @@ var { values } = parseArgs({
108
147
  allowPositionals: true,
109
148
  args: process.argv,
110
149
  options: {
111
- domain: { default: "localhost:1234", short: "d", type: "string" },
150
+ domain: { default: "bunl.sh", short: "d", type: "string" },
112
151
  open: { short: "o", type: "boolean" },
113
152
  port: { default: "3000", short: "p", type: "string" },
114
153
  subdomain: { short: "s", type: "string" },
@@ -121,7 +160,7 @@ if (values.version) {
121
160
  process.exit();
122
161
  }
123
162
  main({
124
- domain: values.domain || "localhost:1234",
163
+ domain: values.domain || "bunl.sh",
125
164
  open: values.open || false,
126
165
  port: values.port || "3000",
127
166
  subdomain: values.subdomain || ""
package/bun.lock CHANGED
@@ -6,10 +6,14 @@
6
6
  "name": "@rubriclab/bunl",
7
7
  "dependencies": {
8
8
  "@rubriclab/config": "^0.0.24",
9
+ "react": "^19.2.4",
10
+ "react-dom": "^19.2.4",
9
11
  },
10
12
  "devDependencies": {
11
13
  "@rubriclab/package": "^0.0.124",
12
14
  "@types/bun": "latest",
15
+ "@types/react": "^19.2.13",
16
+ "@types/react-dom": "^19.2.3",
13
17
  },
14
18
  },
15
19
  },
@@ -84,8 +88,14 @@
84
88
 
85
89
  "@types/node": ["@types/node@25.2.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-CPrnr8voK8vC6eEtyRzvMpgp3VyVRhgclonE7qYi6P9sXwYb59ucfrnmFBTaP0yUi8Gk4yZg/LlTJULGxvTNsg=="],
86
90
 
91
+ "@types/react": ["@types/react@19.2.13", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-KkiJeU6VbYbUOp5ITMIc7kBfqlYkKA5KhEHVrGMmUUMt7NeaZg65ojdPk+FtNrBAOXNVM5QM72jnADjM+XVRAQ=="],
92
+
93
+ "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
94
+
87
95
  "bun-types": ["bun-types@1.3.8", "", { "dependencies": { "@types/node": "*" } }, "sha512-fL99nxdOWvV4LqjmC+8Q9kW3M4QTtTR1eePs94v5ctGqU8OeceWrSUaRw3JYb7tU3FkMIAjkueehrHPPPGKi5Q=="],
88
96
 
97
+ "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
98
+
89
99
  "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
90
100
 
91
101
  "enhanced-resolve": ["enhanced-resolve@5.19.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg=="],
@@ -126,6 +136,12 @@
126
136
 
127
137
  "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
128
138
 
139
+ "react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="],
140
+
141
+ "react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="],
142
+
143
+ "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
144
+
129
145
  "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
130
146
 
131
147
  "tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="],
package/client.ts CHANGED
@@ -1,6 +1,15 @@
1
1
  import { parseArgs } from 'node:util'
2
2
  import type { TunnelInit, TunnelRequest, TunnelResponse } from './types'
3
- import { fromBase64, openBrowser, toBase64 } from './utils'
3
+ import { fromBase64, openBrowser, page, toBase64 } from './utils'
4
+
5
+ function badGatewayHtml(port: string) {
6
+ return page(
7
+ 'Bad Gateway',
8
+ `<h1>Bad Gateway</h1>
9
+ <p>Could not reach <code>localhost:${port}</code>.</p>
10
+ <p>Make sure your local server is running.</p>`
11
+ )
12
+ }
4
13
 
5
14
  async function main({
6
15
  port,
@@ -18,8 +27,6 @@ async function main({
18
27
  ...(subdomain ? { subdomain } : {})
19
28
  }).toString()
20
29
 
21
- // Auto-detect ws:// vs wss:// — use secure WebSocket for anything
22
- // that isn't localhost or an explicit IP
23
30
  const isLocal = /^(localhost|127\.|0\.0\.0\.0|\[::1\])/.test(domain || '')
24
31
  const wsScheme = isLocal ? 'ws' : 'wss'
25
32
  const serverUrl = `${wsScheme}://${domain}?${params}`
@@ -30,7 +37,6 @@ async function main({
30
37
  socket.addEventListener('message', async event => {
31
38
  const data = JSON.parse(event.data as string)
32
39
 
33
- // Initial connection — server tells us our public URL
34
40
  if (data.type === 'init') {
35
41
  const init = data as TunnelInit
36
42
  console.log(`\n↪ Your URL: \x1b[32m${init.url}\x1b[0m\n`)
@@ -38,17 +44,14 @@ async function main({
38
44
  return
39
45
  }
40
46
 
41
- // Incoming tunnel request — proxy to local server
42
47
  if (data.type === 'request') {
43
48
  const req = data as TunnelRequest
44
49
  const now = performance.now()
45
50
 
46
51
  try {
47
- // Decode base64 request body
48
52
  const reqBody =
49
53
  req.body && req.method !== 'GET' && req.method !== 'HEAD' ? fromBase64(req.body) : null
50
54
 
51
- // Remove headers that would conflict with the local fetch
52
55
  const fwdHeaders = { ...req.headers }
53
56
  delete fwdHeaders.host
54
57
  delete fwdHeaders.connection
@@ -63,7 +66,6 @@ async function main({
63
66
  const elapsed = (performance.now() - now).toFixed(1)
64
67
  console.log(`\x1b[32m${req.method}\x1b[0m ${req.pathname} → ${res.status} (${elapsed}ms)`)
65
68
 
66
- // Read response as binary, encode to base64
67
69
  const resBody = await res.arrayBuffer()
68
70
  const headers: Record<string, string> = {}
69
71
  res.headers.forEach((v, k) => {
@@ -83,9 +85,10 @@ async function main({
83
85
  } catch (err) {
84
86
  console.error(`\x1b[31mERR\x1b[0m ${req.method} ${req.pathname}: ${err}`)
85
87
 
88
+ const html = badGatewayHtml(port || '3000')
86
89
  const response: TunnelResponse = {
87
- body: toBase64(new TextEncoder().encode(`Failed to reach localhost:${port} — ${err}`).buffer),
88
- headers: { 'content-type': 'text/plain' },
90
+ body: toBase64(new TextEncoder().encode(html).buffer),
91
+ headers: { 'content-type': 'text/html; charset=utf-8' },
89
92
  id: req.id,
90
93
  status: 502,
91
94
  statusText: 'Bad Gateway',
@@ -115,7 +118,7 @@ const { values } = parseArgs({
115
118
  allowPositionals: true,
116
119
  args: process.argv,
117
120
  options: {
118
- domain: { default: 'localhost:1234', short: 'd', type: 'string' },
121
+ domain: { default: 'bunl.sh', short: 'd', type: 'string' },
119
122
  open: { short: 'o', type: 'boolean' },
120
123
  port: { default: '3000', short: 'p', type: 'string' },
121
124
  subdomain: { short: 's', type: 'string' },
@@ -130,7 +133,7 @@ if (values.version) {
130
133
  }
131
134
 
132
135
  main({
133
- domain: values.domain || 'localhost:1234',
136
+ domain: values.domain || 'bunl.sh',
134
137
  open: values.open || false,
135
138
  port: values.port || '3000',
136
139
  subdomain: values.subdomain || ''
package/demo.tsx ADDED
@@ -0,0 +1,127 @@
1
+ import { serve } from 'bun'
2
+ import { renderToStaticMarkup } from 'react-dom/server'
3
+
4
+ const port = Number(Bun.env.DEMO_PORT) || 3000
5
+
6
+ const PNG_1PX = Buffer.from(
7
+ 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==',
8
+ 'base64'
9
+ )
10
+
11
+ const FAKE_FONT = (() => {
12
+ const buf = Buffer.alloc(256)
13
+ for (let i = 0; i < 256; i++) buf[i] = i
14
+ return buf
15
+ })()
16
+
17
+ const css = `* {
18
+ margin: 0;
19
+ padding: 0;
20
+ box-sizing: border-box;
21
+ }
22
+
23
+ body {
24
+ font-family: system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
25
+ background: #fff;
26
+ color: #000;
27
+ min-height: 100vh;
28
+ display: flex;
29
+ align-items: center;
30
+ justify-content: center;
31
+ padding: 2rem;
32
+ }
33
+
34
+ main {
35
+ max-width: 480px;
36
+ width: 100%;
37
+ }
38
+
39
+ h1 {
40
+ font-size: 1.5rem;
41
+ font-weight: 600;
42
+ letter-spacing: -0.02em;
43
+ margin-bottom: 1rem;
44
+ }
45
+
46
+ p {
47
+ color: #666;
48
+ margin-bottom: 1.5rem;
49
+ line-height: 1.6;
50
+ }
51
+
52
+ img {
53
+ display: block;
54
+ margin-bottom: 1.5rem;
55
+ border: 1px solid #eee;
56
+ }
57
+
58
+ nav a {
59
+ color: #000;
60
+ text-decoration: none;
61
+ border-bottom: 1px solid #ccc;
62
+ }
63
+
64
+ nav a:hover {
65
+ border-color: #000;
66
+ }
67
+
68
+ nav span {
69
+ color: #ccc;
70
+ margin: 0 0.5rem;
71
+ }`
72
+
73
+ function Page() {
74
+ return (
75
+ <html lang="en">
76
+ <head>
77
+ <meta charSet="utf-8" />
78
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
79
+ <title>bunl</title>
80
+ <link rel="stylesheet" href="/style.css" />
81
+ </head>
82
+ <body>
83
+ <main>
84
+ <h1>bunl</h1>
85
+ <p>Served through a tunnel.</p>
86
+ <img src="/image.png" alt="test" width={100} height={100} />
87
+ <nav>
88
+ <a href="/api/health">API</a>
89
+ <span>·</span>
90
+ <a href="/font.woff2">Font</a>
91
+ </nav>
92
+ </main>
93
+ </body>
94
+ </html>
95
+ )
96
+ }
97
+
98
+ const html = `<!DOCTYPE html>${renderToStaticMarkup(<Page />)}`
99
+
100
+ serve({
101
+ fetch(req) {
102
+ const { pathname } = new URL(req.url)
103
+
104
+ if (pathname === '/style.css')
105
+ return new Response(css, { headers: { 'content-type': 'text/css; charset=utf-8' } })
106
+
107
+ if (pathname === '/image.png')
108
+ return new Response(new Uint8Array(PNG_1PX), { headers: { 'content-type': 'image/png' } })
109
+
110
+ if (pathname === '/font.woff2')
111
+ return new Response(new Uint8Array(FAKE_FONT), { headers: { 'content-type': 'font/woff2' } })
112
+
113
+ if (pathname === '/api/health') return Response.json({ status: 'ok', timestamp: Date.now() })
114
+
115
+ if (pathname === '/echo' && req.method === 'POST')
116
+ return new Response(req.body, {
117
+ headers: {
118
+ 'content-type': req.headers.get('content-type') || 'application/octet-stream'
119
+ }
120
+ })
121
+
122
+ return new Response(html, { headers: { 'content-type': 'text/html; charset=utf-8' } })
123
+ },
124
+ port
125
+ })
126
+
127
+ console.log(`Demo server at http://localhost:${port}`)
package/e2e.test.ts CHANGED
@@ -5,11 +5,6 @@ const SERVER_PORT = 9100
5
5
  const DEMO_PORT = 9101
6
6
  const SUBDOMAIN = 'e2e'
7
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
8
  function tunnelFetch(path: string, init?: RequestInit): Promise<Response> {
14
9
  const headers = new Headers(init?.headers as HeadersInit)
15
10
  headers.set('host', `${SUBDOMAIN}.localhost:${SERVER_PORT}`)
@@ -23,7 +18,6 @@ let serverProc: Subprocess
23
18
  let demoProc: Subprocess
24
19
  let clientProc: Subprocess
25
20
 
26
- /** Wait until a URL responds (or timeout). */
27
21
  async function waitFor(url: string, timeoutMs = 10_000): Promise<void> {
28
22
  const start = Date.now()
29
23
  while (Date.now() - start < timeoutMs) {
@@ -37,7 +31,6 @@ async function waitFor(url: string, timeoutMs = 10_000): Promise<void> {
37
31
  throw new Error(`Timed out waiting for ${url}`)
38
32
  }
39
33
 
40
- /** Wait until a tunnel subdomain is connected (server returns non-404). */
41
34
  async function waitForTunnel(timeoutMs = 15_000): Promise<void> {
42
35
  const start = Date.now()
43
36
  while (Date.now() - start < timeoutMs) {
@@ -45,7 +38,7 @@ async function waitForTunnel(timeoutMs = 15_000): Promise<void> {
45
38
  const res = await tunnelFetch('/')
46
39
  if (res.status !== 404) return
47
40
  } catch {
48
- // not ready yet
41
+ /* not ready */
49
42
  }
50
43
  await Bun.sleep(300)
51
44
  }
@@ -53,7 +46,6 @@ async function waitForTunnel(timeoutMs = 15_000): Promise<void> {
53
46
  }
54
47
 
55
48
  beforeAll(async () => {
56
- // 1. Start the tunnel server
57
49
  serverProc = Bun.spawn(['bun', 'run', 'server.ts'], {
58
50
  cwd: import.meta.dir,
59
51
  env: {
@@ -66,21 +58,18 @@ beforeAll(async () => {
66
58
  stdout: 'pipe'
67
59
  })
68
60
 
69
- // 2. Start the demo webserver
70
- demoProc = Bun.spawn(['bun', 'run', 'demo.ts'], {
61
+ demoProc = Bun.spawn(['bun', 'run', 'demo.tsx'], {
71
62
  cwd: import.meta.dir,
72
63
  env: { ...process.env, DEMO_PORT: String(DEMO_PORT) },
73
64
  stderr: 'pipe',
74
65
  stdout: 'pipe'
75
66
  })
76
67
 
77
- // Wait for both to be up
78
68
  await Promise.all([
79
69
  waitFor(`http://localhost:${SERVER_PORT}/?new`),
80
70
  waitFor(`http://localhost:${DEMO_PORT}/api/health`)
81
71
  ])
82
72
 
83
- // 3. Start the tunnel client
84
73
  clientProc = Bun.spawn(
85
74
  [
86
75
  'bun',
@@ -101,7 +90,6 @@ beforeAll(async () => {
101
90
  }
102
91
  )
103
92
 
104
- // Wait for the tunnel to be live
105
93
  await waitForTunnel()
106
94
  })
107
95
 
@@ -117,7 +105,7 @@ describe('e2e tunnel', () => {
117
105
  expect(res.status).toBe(200)
118
106
  expect(res.headers.get('content-type')).toContain('text/html')
119
107
  const body = await res.text()
120
- expect(body).toContain('<h1>bunl tunnel works!</h1>')
108
+ expect(body).toContain('<h1>bunl</h1>')
121
109
  })
122
110
 
123
111
  test('serves CSS through tunnel', async () => {
@@ -134,7 +122,6 @@ describe('e2e tunnel', () => {
134
122
  expect(res.headers.get('content-type')).toBe('image/png')
135
123
  const buf = await res.arrayBuffer()
136
124
  const bytes = new Uint8Array(buf)
137
- // PNG magic bytes: 0x89 0x50 0x4E 0x47
138
125
  expect(bytes[0]).toBe(0x89)
139
126
  expect(bytes[1]).toBe(0x50)
140
127
  expect(bytes[2]).toBe(0x4e)
@@ -147,7 +134,6 @@ describe('e2e tunnel', () => {
147
134
  expect(res.headers.get('content-type')).toBe('font/woff2')
148
135
  const buf = await res.arrayBuffer()
149
136
  const bytes = new Uint8Array(buf)
150
- // Our fake font is 256 bytes: [0, 1, 2, ..., 255]
151
137
  expect(bytes.length).toBe(256)
152
138
  expect(bytes[0]).toBe(0)
153
139
  expect(bytes[127]).toBe(127)
@@ -185,5 +171,17 @@ describe('e2e tunnel', () => {
185
171
  headers: { host: `nonexistent.localhost:${SERVER_PORT}` }
186
172
  })
187
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')
188
186
  })
189
187
  })
package/package.json CHANGED
@@ -3,12 +3,16 @@
3
3
  "bunl": "build/client.js"
4
4
  },
5
5
  "dependencies": {
6
- "@rubriclab/config": "^0.0.24"
6
+ "@rubriclab/config": "^0.0.24",
7
+ "react": "^19.2.4",
8
+ "react-dom": "^19.2.4"
7
9
  },
8
10
  "description": "Expose localhost to the world. Bun-native localtunnel.",
9
11
  "devDependencies": {
10
12
  "@rubriclab/package": "^0.0.124",
11
- "@types/bun": "latest"
13
+ "@types/bun": "latest",
14
+ "@types/react": "^19.2.13",
15
+ "@types/react-dom": "^19.2.3"
12
16
  },
13
17
  "homepage": "https://github.com/RubricLab/bunl#readme",
14
18
  "keywords": [
@@ -32,11 +36,12 @@
32
36
  "build": "bun build client.ts --outdir build --target bun && printf '#! /usr/bin/env bun\\n%s' \"$(cat build/client.js)\" > build/client.js",
33
37
  "clean": "rm -rf .next && rm -rf node_modules",
34
38
  "client": "bun --watch client.ts",
35
- "demo": "bun --watch demo.ts",
39
+ "demo": "bun --watch demo.tsx",
36
40
  "dev:server": "bun --watch server.ts",
37
41
  "format": "bun x biome format --write .",
38
42
  "lint": "bun x biome check .",
39
43
  "lint:fix": "bun x biome lint . --write --unsafe",
44
+ "prepare": "bun x @rubriclab/package prepare",
40
45
  "server": "bun server.ts",
41
46
  "test": "bun test",
42
47
  "test:e2e": "bun test e2e.test.ts"
@@ -45,5 +50,5 @@
45
50
  "post-commit": "rubriclab-package post-commit"
46
51
  },
47
52
  "type": "module",
48
- "version": "0.2.0"
53
+ "version": "0.2.1"
49
54
  }
package/server.ts CHANGED
@@ -1,18 +1,13 @@
1
1
  import { type ServerWebSocket, serve } from 'bun'
2
2
  import type { Client, TunnelInit, TunnelRequest, TunnelResponse } from './types'
3
- import { fromBase64, toBase64, uid } from './utils'
3
+ import { fromBase64, page, toBase64, uid } from './utils'
4
4
 
5
5
  const port = Number(Bun.env.PORT) || 1234
6
6
  const scheme = Bun.env.SCHEME || 'http'
7
7
  const domain = Bun.env.DOMAIN || `localhost:${port}`
8
-
9
- /** The hostname portion of DOMAIN (no port) used for subdomain extraction */
10
8
  const domainHost = domain.replace(/:\d+$/, '')
11
9
 
12
- /** Connected tunnel clients keyed by subdomain */
13
10
  const clients = new Map<string, ServerWebSocket<Client>>()
14
-
15
- /** Pending HTTP requests waiting for a tunnel response, keyed by request ID */
16
11
  const pending = new Map<
17
12
  string,
18
13
  {
@@ -23,15 +18,35 @@ const pending = new Map<
23
18
 
24
19
  const TIMEOUT_MS = 30_000
25
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
+ )
42
+
26
43
  serve<Client>({
27
44
  fetch: async (req, server) => {
28
45
  const reqUrl = new URL(req.url)
29
46
 
30
- // Client wants to register a new tunnel
31
47
  if (reqUrl.searchParams.has('new')) {
32
48
  const requested = reqUrl.searchParams.get('subdomain')
33
49
  let id = requested || uid()
34
- // Avoid collisions — if taken, generate a fresh one
35
50
  if (clients.has(id)) id = uid()
36
51
 
37
52
  const upgraded = server.upgrade(req, { data: { id } })
@@ -39,28 +54,31 @@ serve<Client>({
39
54
  return new Response('WebSocket upgrade failed', { status: 500 })
40
55
  }
41
56
 
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
57
  const host = (req.headers.get('host') || reqUrl.hostname).replace(/:\d+$/, '')
48
58
  const subdomain = host.endsWith(`.${domainHost}`) ? host.slice(0, -(domainHost.length + 1)) : ''
49
- const client = subdomain ? clients.get(subdomain) : undefined
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)
50
67
 
51
68
  if (!client) {
52
- return new Response(`Tunnel "${subdomain}" not found`, { status: 404 })
69
+ return new Response(notFoundHtml(subdomain), {
70
+ headers: { 'content-type': 'text/html; charset=utf-8' },
71
+ status: 404
72
+ })
53
73
  }
54
74
 
55
75
  const id = crypto.randomUUID()
56
76
  const { method } = req
57
77
  const pathname = reqUrl.pathname + reqUrl.search
58
78
 
59
- // Read request body as binary and encode to base64
60
79
  const rawBody = await req.arrayBuffer()
61
80
  const body = rawBody.byteLength > 0 ? toBase64(rawBody) : ''
62
81
 
63
- // Flatten request headers
64
82
  const headers: Record<string, string> = {}
65
83
  req.headers.forEach((v, k) => {
66
84
  headers[k] = v
@@ -75,7 +93,6 @@ serve<Client>({
75
93
  type: 'request'
76
94
  }
77
95
 
78
- // Create a promise that will be resolved when the client responds
79
96
  const response = await new Promise<TunnelResponse>((resolve, reject) => {
80
97
  const timer = setTimeout(() => {
81
98
  pending.delete(id)
@@ -84,11 +101,10 @@ serve<Client>({
84
101
 
85
102
  pending.set(id, { resolve, timer })
86
103
  client.send(JSON.stringify(message))
87
- }).catch((err: unknown): TunnelResponse => {
88
- const message = err instanceof Error ? err.message : String(err)
104
+ }).catch((): TunnelResponse => {
89
105
  return {
90
- body: Buffer.from(message).toString('base64'),
91
- headers: { 'content-type': 'text/plain' },
106
+ body: Buffer.from(timeoutHtml).toString('base64'),
107
+ headers: { 'content-type': 'text/html; charset=utf-8' },
92
108
  id,
93
109
  status: 504,
94
110
  statusText: 'Gateway Timeout',
@@ -96,14 +112,11 @@ serve<Client>({
96
112
  }
97
113
  })
98
114
 
99
- // Decode base64 response body back to binary
100
115
  const resBody = response.body ? fromBase64(response.body) : null
101
116
 
102
- // Build response headers, removing problematic ones
103
117
  const resHeaders = { ...response.headers }
104
118
  delete resHeaders['content-encoding']
105
119
  delete resHeaders['transfer-encoding']
106
- // Fix content-length to match the actual decoded body
107
120
  if (resBody) {
108
121
  resHeaders['content-length'] = String(resBody.byteLength)
109
122
  }
@@ -120,7 +133,6 @@ serve<Client>({
120
133
  console.log(`\x1b[31m- ${ws.data.id}\x1b[0m (${clients.size - 1} connected)`)
121
134
  clients.delete(ws.data.id)
122
135
  },
123
-
124
136
  message(_ws, raw) {
125
137
  const msg = JSON.parse(
126
138
  typeof raw === 'string' ? raw : new TextDecoder().decode(raw)
package/tsconfig.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "exclude": ["node_modules"],
3
3
  "extends": "@rubriclab/config/tsconfig",
4
- "include": ["**/*.ts"]
4
+ "include": ["**/*.ts", "**/*.tsx"]
5
5
  }
package/types.ts CHANGED
@@ -1,26 +1,23 @@
1
1
  export type Client = { id: string }
2
2
 
3
- /** Server → Client: incoming HTTP request to proxy */
4
3
  export type TunnelRequest = {
5
4
  type: 'request'
6
5
  id: string
7
6
  method: string
8
7
  pathname: string
9
8
  headers: Record<string, string>
10
- body: string // base64-encoded
9
+ body: string
11
10
  }
12
11
 
13
- /** Client → Server: proxied HTTP response */
14
12
  export type TunnelResponse = {
15
13
  type: 'response'
16
14
  id: string
17
15
  status: number
18
16
  statusText: string
19
17
  headers: Record<string, string>
20
- body: string // base64-encoded
18
+ body: string
21
19
  }
22
20
 
23
- /** Server → Client: initial connection info */
24
21
  export type TunnelInit = {
25
22
  type: 'init'
26
23
  url: string
package/utils.ts CHANGED
@@ -92,12 +92,10 @@ function pick<T>(arr: T[]): T {
92
92
  return arr[index]
93
93
  }
94
94
 
95
- /** Generate a human-readable unique identifier, e.g. "bold-calm-fox" */
96
95
  export function uid(): string {
97
96
  return `${pick(adjectives)}-${pick(adjectives)}-${pick(nouns)}`
98
97
  }
99
98
 
100
- /** Open a URL in the default browser using platform-native commands */
101
99
  export function openBrowser(url: string): void {
102
100
  const cmds: Record<string, string[]> = {
103
101
  darwin: ['open', url],
@@ -107,13 +105,45 @@ export function openBrowser(url: string): void {
107
105
  Bun.spawn(args, { stdio: ['ignore', 'ignore', 'ignore'] })
108
106
  }
109
107
 
110
- /** Encode an ArrayBuffer to base64 */
111
108
  export function toBase64(buf: ArrayBuffer): string {
112
109
  return Buffer.from(buf).toString('base64')
113
110
  }
114
111
 
115
- /** Decode a base64 string to a Uint8Array */
116
112
  export function fromBase64(str: string): Uint8Array<ArrayBuffer> {
117
113
  const buf = Buffer.from(str, 'base64')
118
114
  return new Uint8Array(buf.buffer as ArrayBuffer, buf.byteOffset, buf.byteLength)
119
115
  }
116
+
117
+ export function page(title: string, body: string): string {
118
+ return `<!DOCTYPE html>
119
+ <html lang="en">
120
+ <head>
121
+ <meta charset="utf-8">
122
+ <meta name="viewport" content="width=device-width, initial-scale=1">
123
+ <title>${title} — bunl</title>
124
+ <style>
125
+ * { margin: 0; padding: 0; box-sizing: border-box; }
126
+ body {
127
+ font-family: system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
128
+ background: #fff;
129
+ color: #000;
130
+ min-height: 100vh;
131
+ display: flex;
132
+ align-items: center;
133
+ justify-content: center;
134
+ padding: 2rem;
135
+ }
136
+ main { max-width: 480px; width: 100%; }
137
+ h1 { font-size: 1.5rem; font-weight: 600; letter-spacing: -0.02em; margin-bottom: 1rem; }
138
+ p { color: #666; line-height: 1.6; margin-bottom: 0.75rem; }
139
+ code { background: #f5f5f5; padding: 0.15em 0.4em; border-radius: 3px; font-size: 0.9em; }
140
+ a { color: #000; }
141
+ </style>
142
+ </head>
143
+ <body>
144
+ <main>
145
+ ${body}
146
+ </main>
147
+ </body>
148
+ </html>`
149
+ }
package/demo.ts DELETED
@@ -1,85 +0,0 @@
1
- /**
2
- * Demo webserver that serves multiple content types for testing the tunnel.
3
- * Exercises: HTML, CSS, JSON API, binary images (PNG), and binary fonts (WOFF2-like).
4
- */
5
-
6
- import { serve } from 'bun'
7
-
8
- const port = Number(Bun.env.DEMO_PORT) || 3000
9
-
10
- // 1x1 red PNG pixel (67 bytes) — smallest valid PNG
11
- const PNG_1PX = Buffer.from(
12
- 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==',
13
- 'base64'
14
- )
15
-
16
- // Generate a binary blob to simulate a font file
17
- const FAKE_FONT = (() => {
18
- const buf = Buffer.alloc(256)
19
- for (let i = 0; i < 256; i++) buf[i] = i
20
- return buf
21
- })()
22
-
23
- const CSS_CONTENT = `body { font-family: sans-serif; background: #111; color: #0f0; padding: 2rem; }
24
- img { border: 2px solid #0f0; margin: 1rem 0; }`
25
-
26
- const HTML = `<!DOCTYPE html>
27
- <html>
28
- <head>
29
- <meta charset="utf-8">
30
- <title>bunl demo</title>
31
- <link rel="stylesheet" href="/style.css">
32
- </head>
33
- <body>
34
- <h1>bunl tunnel works!</h1>
35
- <p>This page was served through the tunnel.</p>
36
- <img src="/image.png" alt="test pixel" width="100" height="100">
37
- <p><a href="/api/health">JSON API</a> · <a href="/font.woff2">Font binary</a></p>
38
- </body>
39
- </html>`
40
-
41
- serve({
42
- fetch(req) {
43
- const url = new URL(req.url)
44
- const { pathname } = url
45
-
46
- console.log(`${req.method} ${pathname}`)
47
-
48
- if (pathname === '/style.css') {
49
- return new Response(CSS_CONTENT, {
50
- headers: { 'content-type': 'text/css; charset=utf-8' }
51
- })
52
- }
53
-
54
- if (pathname === '/image.png') {
55
- return new Response(new Uint8Array(PNG_1PX), {
56
- headers: { 'content-type': 'image/png' }
57
- })
58
- }
59
-
60
- if (pathname === '/font.woff2') {
61
- return new Response(new Uint8Array(FAKE_FONT), {
62
- headers: { 'content-type': 'font/woff2' }
63
- })
64
- }
65
-
66
- if (pathname === '/api/health') {
67
- return Response.json({ status: 'ok', timestamp: Date.now() })
68
- }
69
-
70
- if (pathname === '/echo' && req.method === 'POST') {
71
- return new Response(req.body, {
72
- headers: {
73
- 'content-type': req.headers.get('content-type') || 'application/octet-stream'
74
- }
75
- })
76
- }
77
-
78
- return new Response(HTML, {
79
- headers: { 'content-type': 'text/html; charset=utf-8' }
80
- })
81
- },
82
- port
83
- })
84
-
85
- console.log(`Demo server at http://localhost:${port}`)