@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/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
- "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
+ "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 { serve, type ServerWebSocket } from "bun";
2
- import { uid } from "./utils";
3
- import type { Client, Payload } from "./types";
4
-
5
- const port = Bun.env.PORT || 1234;
6
- const scheme = Bun.env.SCHEME || "http";
7
- const domain = Bun.env.DOMAIN || `localhost:${port}`;
8
-
9
- const clients = new Map<string, ServerWebSocket<Client>>();
10
- const requesters = new Map<string, WritableStream>();
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
- 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}`);
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
@@ -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", "**/*.tsx"]
5
5
  }
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 Payload = {
4
- status?: number;
5
- statusText?: string;
6
- method?: string;
7
- pathname?: string;
8
- body: string;
9
- headers: object;
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
+ }