@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/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
- "bin": {
3
- "bunl": "build/client.js"
4
- },
5
- "name": "@rubriclab/bunl",
6
- "description": "Expose localhost to the world",
7
- "version": "0.1.25",
8
- "license": "MIT",
9
- "repository": {
10
- "type": "git",
11
- "url": "git+https://github.com/RubricLab/bunl.git"
12
- },
13
- "dependencies": {
14
- "human-id": "^4.1.1",
15
- "open": "^10.1.0"
16
- },
17
- "devDependencies": {
18
- "@rubriclab/typescript-config": "^1.0.0",
19
- "@types/bun": "latest"
20
- },
21
- "main": "build/client.js",
22
- "scripts": {
23
- "server": "bun server.ts",
24
- "dev:server": "bun --watch server.ts",
25
- "client": "bun --watch client.ts",
26
- "demo": "bun --watch demo.ts",
27
- "client:upgrade": "bun rm -g @rubriclab/bunl && bun i -g @rubriclab/bunl@latest",
28
- "build": "BUILD=build/client.js && bun build client.ts --outdir build --target bun && echo -e \"#! /usr/bin/env bun\n$(cat $BUILD)\" > $BUILD",
29
- "npm:publish": "bun run build && npm publish"
30
- },
31
- "type": "module",
32
- "peerDependencies": {
33
- "typescript": "^5.0.0"
34
- },
35
- "publishConfig": {
36
- "@RubricLab:registry": "https://registry.npmjs.org",
37
- "access": "public",
38
- "registry": "https://registry.npmjs.org"
39
- },
40
- "homepage": "https://github.com/RubricLab/bunl#readme",
41
- "keywords": [
42
- "local",
43
- "tunnel",
44
- "rubric"
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 { serve, type ServerWebSocket } from "bun";
2
- import { uid } from "./utils";
3
- import type { Client, Payload } from "./types";
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 || "http";
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
- const clients = new Map<string, ServerWebSocket<Client>>();
10
- const requesters = new Map<string, WritableStream>();
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
- port,
14
- fetch: async (req, server) => {
15
- const reqUrl = new URL(req.url);
16
-
17
- if (reqUrl.searchParams.has("new")) {
18
- const requested = reqUrl.searchParams.get("subdomain");
19
- let id = requested || uid();
20
- if (clients.has(id)) id = uid();
21
-
22
- const upgraded = server.upgrade(req, { data: { id } });
23
- if (upgraded) return;
24
- else return new Response("upgrade failed", { status: 500 });
25
- }
26
-
27
- const subdomain = reqUrl.hostname.split(".")[0];
28
-
29
- if (!clients.has(subdomain)) {
30
- return new Response(`${subdomain} not found`, { status: 404 });
31
- }
32
-
33
- // The magic: forward the req to the client
34
- const client = clients.get(subdomain)!;
35
- const { method, url, headers: reqHeaders } = req;
36
- const reqBody = await req.text();
37
- const pathname = new URL(url).pathname;
38
- const payload: Payload = {
39
- method,
40
- pathname,
41
- body: reqBody,
42
- headers: reqHeaders,
43
- };
44
-
45
- const { writable, readable } = new TransformStream();
46
-
47
- requesters.set(`${method}:${subdomain}${pathname}`, writable);
48
- client.send(JSON.stringify(payload));
49
-
50
- const res = await readable.getReader().read();
51
- const { status, statusText, headers, body } = JSON.parse(res.value);
52
-
53
- delete headers["content-encoding"]; // remove problematic header
54
-
55
- return new Response(body, { status, statusText, headers });
56
- },
57
- websocket: {
58
- open(ws) {
59
- clients.set(ws.data.id, ws);
60
- console.log(`\x1b[32m+ ${ws.data.id} (${clients.size} total)\x1b[0m`);
61
- ws.send(
62
- JSON.stringify({
63
- url: `${scheme}://${ws.data.id}.${domain}`,
64
- })
65
- );
66
- },
67
- message: async ({ data: { id } }, message: string) => {
68
- console.log("message from", id);
69
-
70
- const { method, pathname } = JSON.parse(message) as Payload;
71
- const writable = requesters.get(`${method}:${id}${pathname}`);
72
- if (!writable) throw "connection not found";
73
-
74
- if (writable.locked) return;
75
-
76
- const writer = writable.getWriter();
77
- await writer.write(message);
78
- await writer.close();
79
- },
80
- close({ data }) {
81
- console.log("closing", data.id);
82
- clients.delete(data.id);
83
- },
84
- },
85
- });
86
-
87
- console.log(`websocket server up at ws://${domain}`);
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
@@ -1,5 +1,5 @@
1
1
  {
2
- "compilerOptions": { "baseUrl": "." },
3
- "exclude": ["node_modules"],
4
- "extends": "@rubriclab/typescript-config/base"
2
+ "exclude": ["node_modules"],
3
+ "extends": "@rubriclab/config/tsconfig",
4
+ "include": ["**/*.ts"]
5
5
  }
package/types.ts CHANGED
@@ -1,10 +1,27 @@
1
- export type Client = { id: string };
1
+ export type Client = { id: string }
2
2
 
3
- export type Payload = {
4
- status?: number;
5
- statusText?: string;
6
- method?: string;
7
- pathname?: string;
8
- body: string;
9
- headers: object;
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
+ }