@rubriclab/bunl
Advanced tools
| - [2026-02-06] [Bump version](https://github.com/rubriclab/bunl/commit/0043caed41428d060517223cbb8d72fe52813692) | ||
| # Changelog | ||
+127
| import { serve } from 'bun' | ||
| import { renderToStaticMarkup } from 'react-dom/server' | ||
| const port = Number(Bun.env.DEMO_PORT) || 3000 | ||
| const PNG_1PX = Buffer.from( | ||
| 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==', | ||
| 'base64' | ||
| ) | ||
| const FAKE_FONT = (() => { | ||
| const buf = Buffer.alloc(256) | ||
| for (let i = 0; i < 256; i++) buf[i] = i | ||
| return buf | ||
| })() | ||
| const css = `* { | ||
| margin: 0; | ||
| padding: 0; | ||
| box-sizing: border-box; | ||
| } | ||
| body { | ||
| font-family: system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; | ||
| background: #fff; | ||
| color: #000; | ||
| min-height: 100vh; | ||
| display: flex; | ||
| align-items: center; | ||
| justify-content: center; | ||
| padding: 2rem; | ||
| } | ||
| main { | ||
| max-width: 480px; | ||
| width: 100%; | ||
| } | ||
| h1 { | ||
| font-size: 1.5rem; | ||
| font-weight: 600; | ||
| letter-spacing: -0.02em; | ||
| margin-bottom: 1rem; | ||
| } | ||
| p { | ||
| color: #666; | ||
| margin-bottom: 1.5rem; | ||
| line-height: 1.6; | ||
| } | ||
| img { | ||
| display: block; | ||
| margin-bottom: 1.5rem; | ||
| border: 1px solid #eee; | ||
| } | ||
| nav a { | ||
| color: #000; | ||
| text-decoration: none; | ||
| border-bottom: 1px solid #ccc; | ||
| } | ||
| nav a:hover { | ||
| border-color: #000; | ||
| } | ||
| nav span { | ||
| color: #ccc; | ||
| margin: 0 0.5rem; | ||
| }` | ||
| function Page() { | ||
| return ( | ||
| <html lang="en"> | ||
| <head> | ||
| <meta charSet="utf-8" /> | ||
| <meta name="viewport" content="width=device-width, initial-scale=1" /> | ||
| <title>bunl</title> | ||
| <link rel="stylesheet" href="/style.css" /> | ||
| </head> | ||
| <body> | ||
| <main> | ||
| <h1>bunl</h1> | ||
| <p>Served through a tunnel.</p> | ||
| <img src="/image.png" alt="test" width={100} height={100} /> | ||
| <nav> | ||
| <a href="/api/health">API</a> | ||
| <span>·</span> | ||
| <a href="/font.woff2">Font</a> | ||
| </nav> | ||
| </main> | ||
| </body> | ||
| </html> | ||
| ) | ||
| } | ||
| const html = `<!DOCTYPE html>${renderToStaticMarkup(<Page />)}` | ||
| serve({ | ||
| fetch(req) { | ||
| const { pathname } = new URL(req.url) | ||
| if (pathname === '/style.css') | ||
| return new Response(css, { headers: { 'content-type': 'text/css; charset=utf-8' } }) | ||
| if (pathname === '/image.png') | ||
| return new Response(new Uint8Array(PNG_1PX), { headers: { 'content-type': 'image/png' } }) | ||
| if (pathname === '/font.woff2') | ||
| return new Response(new Uint8Array(FAKE_FONT), { headers: { 'content-type': 'font/woff2' } }) | ||
| if (pathname === '/api/health') return Response.json({ status: 'ok', timestamp: Date.now() }) | ||
| if (pathname === '/echo' && req.method === 'POST') | ||
| return new Response(req.body, { | ||
| headers: { | ||
| 'content-type': req.headers.get('content-type') || 'application/octet-stream' | ||
| } | ||
| }) | ||
| return new Response(html, { headers: { 'content-type': 'text/html; charset=utf-8' } }) | ||
| }, | ||
| port | ||
| }) | ||
| console.log(`Demo server at http://localhost:${port}`) |
+43
-4
@@ -22,4 +22,42 @@ #! /usr/bin/env bun | ||
| } | ||
| function page(title, body) { | ||
| return `<!DOCTYPE html> | ||
| <html lang="en"> | ||
| <head> | ||
| <meta charset="utf-8"> | ||
| <meta name="viewport" content="width=device-width, initial-scale=1"> | ||
| <title>${title} \u2014 bunl</title> | ||
| <style> | ||
| * { margin: 0; padding: 0; box-sizing: border-box; } | ||
| body { | ||
| font-family: system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; | ||
| background: #fff; | ||
| color: #000; | ||
| min-height: 100vh; | ||
| display: flex; | ||
| align-items: center; | ||
| justify-content: center; | ||
| padding: 2rem; | ||
| } | ||
| main { max-width: 480px; width: 100%; } | ||
| h1 { font-size: 1.5rem; font-weight: 600; letter-spacing: -0.02em; margin-bottom: 1rem; } | ||
| p { color: #666; line-height: 1.6; margin-bottom: 0.75rem; } | ||
| code { background: #f5f5f5; padding: 0.15em 0.4em; border-radius: 3px; font-size: 0.9em; } | ||
| a { color: #000; } | ||
| </style> | ||
| </head> | ||
| <body> | ||
| <main> | ||
| ${body} | ||
| </main> | ||
| </body> | ||
| </html>`; | ||
| } | ||
| // client.ts | ||
| function badGatewayHtml(port) { | ||
| return page("Bad Gateway", `<h1>Bad Gateway</h1> | ||
| <p>Could not reach <code>localhost:${port}</code>.</p> | ||
| <p>Make sure your local server is running.</p>`); | ||
| } | ||
| async function main({ | ||
@@ -84,5 +122,6 @@ port, | ||
| console.error(`\x1B[31mERR\x1B[0m ${req.method} ${req.pathname}: ${err}`); | ||
| const html = badGatewayHtml(port || "3000"); | ||
| const response = { | ||
| body: toBase64(new TextEncoder().encode(`Failed to reach localhost:${port} \u2014 ${err}`).buffer), | ||
| headers: { "content-type": "text/plain" }, | ||
| body: toBase64(new TextEncoder().encode(html).buffer), | ||
| headers: { "content-type": "text/html; charset=utf-8" }, | ||
| id: req.id, | ||
@@ -113,3 +152,3 @@ status: 502, | ||
| options: { | ||
| domain: { default: "localhost:1234", short: "d", type: "string" }, | ||
| domain: { default: "bunl.sh", short: "d", type: "string" }, | ||
| open: { short: "o", type: "boolean" }, | ||
@@ -127,3 +166,3 @@ port: { default: "3000", short: "p", type: "string" }, | ||
| main({ | ||
| domain: values.domain || "localhost:1234", | ||
| domain: values.domain || "bunl.sh", | ||
| open: values.open || false, | ||
@@ -130,0 +169,0 @@ port: values.port || "3000", |
+16
-0
@@ -9,2 +9,4 @@ { | ||
| "@rubriclab/config": "^0.0.24", | ||
| "react": "^19.2.4", | ||
| "react-dom": "^19.2.4", | ||
| }, | ||
@@ -14,2 +16,4 @@ "devDependencies": { | ||
| "@types/bun": "latest", | ||
| "@types/react": "^19.2.13", | ||
| "@types/react-dom": "^19.2.3", | ||
| }, | ||
@@ -89,4 +93,10 @@ }, | ||
| "@types/react": ["@types/react@19.2.13", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-KkiJeU6VbYbUOp5ITMIc7kBfqlYkKA5KhEHVrGMmUUMt7NeaZg65ojdPk+FtNrBAOXNVM5QM72jnADjM+XVRAQ=="], | ||
| "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], | ||
| "bun-types": ["bun-types@1.3.8", "", { "dependencies": { "@types/node": "*" } }, "sha512-fL99nxdOWvV4LqjmC+8Q9kW3M4QTtTR1eePs94v5ctGqU8OeceWrSUaRw3JYb7tU3FkMIAjkueehrHPPPGKi5Q=="], | ||
| "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], | ||
| "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], | ||
@@ -132,2 +142,8 @@ | ||
| "react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="], | ||
| "react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="], | ||
| "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], | ||
| "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], | ||
@@ -134,0 +150,0 @@ |
+15
-12
| import { parseArgs } from 'node:util' | ||
| import type { TunnelInit, TunnelRequest, TunnelResponse } from './types' | ||
| import { fromBase64, openBrowser, toBase64 } from './utils' | ||
| import { fromBase64, openBrowser, page, toBase64 } from './utils' | ||
| function badGatewayHtml(port: string) { | ||
| return page( | ||
| 'Bad Gateway', | ||
| `<h1>Bad Gateway</h1> | ||
| <p>Could not reach <code>localhost:${port}</code>.</p> | ||
| <p>Make sure your local server is running.</p>` | ||
| ) | ||
| } | ||
| async function main({ | ||
@@ -21,4 +30,2 @@ port, | ||
| // Auto-detect ws:// vs wss:// — use secure WebSocket for anything | ||
| // that isn't localhost or an explicit IP | ||
| const isLocal = /^(localhost|127\.|0\.0\.0\.0|\[::1\])/.test(domain || '') | ||
@@ -34,3 +41,2 @@ const wsScheme = isLocal ? 'ws' : 'wss' | ||
| // Initial connection — server tells us our public URL | ||
| if (data.type === 'init') { | ||
@@ -43,3 +49,2 @@ const init = data as TunnelInit | ||
| // Incoming tunnel request — proxy to local server | ||
| if (data.type === 'request') { | ||
@@ -50,7 +55,5 @@ const req = data as TunnelRequest | ||
| try { | ||
| // Decode base64 request body | ||
| const reqBody = | ||
| req.body && req.method !== 'GET' && req.method !== 'HEAD' ? fromBase64(req.body) : null | ||
| // Remove headers that would conflict with the local fetch | ||
| const fwdHeaders = { ...req.headers } | ||
@@ -70,3 +73,2 @@ delete fwdHeaders.host | ||
| // Read response as binary, encode to base64 | ||
| const resBody = await res.arrayBuffer() | ||
@@ -91,5 +93,6 @@ const headers: Record<string, string> = {} | ||
| const html = badGatewayHtml(port || '3000') | ||
| const response: TunnelResponse = { | ||
| body: toBase64(new TextEncoder().encode(`Failed to reach localhost:${port} — ${err}`).buffer), | ||
| headers: { 'content-type': 'text/plain' }, | ||
| body: toBase64(new TextEncoder().encode(html).buffer), | ||
| headers: { 'content-type': 'text/html; charset=utf-8' }, | ||
| id: req.id, | ||
@@ -124,3 +127,3 @@ status: 502, | ||
| options: { | ||
| domain: { default: 'localhost:1234', short: 'd', type: 'string' }, | ||
| domain: { default: 'bunl.sh', short: 'd', type: 'string' }, | ||
| open: { short: 'o', type: 'boolean' }, | ||
@@ -140,3 +143,3 @@ port: { default: '3000', short: 'p', type: 'string' }, | ||
| main({ | ||
| domain: values.domain || 'localhost:1234', | ||
| domain: values.domain || 'bunl.sh', | ||
| open: values.open || false, | ||
@@ -143,0 +146,0 @@ port: values.port || '3000', |
+15
-17
@@ -8,7 +8,2 @@ import { afterAll, beforeAll, describe, expect, test } from 'bun:test' | ||
| /** | ||
| * Make a request through the tunnel using Host-header routing. | ||
| * This avoids needing wildcard DNS (*.localhost) to resolve, | ||
| * which doesn't work in all environments. | ||
| */ | ||
| function tunnelFetch(path: string, init?: RequestInit): Promise<Response> { | ||
@@ -27,3 +22,2 @@ const headers = new Headers(init?.headers as HeadersInit) | ||
| /** Wait until a URL responds (or timeout). */ | ||
| async function waitFor(url: string, timeoutMs = 10_000): Promise<void> { | ||
@@ -42,3 +36,2 @@ const start = Date.now() | ||
| /** Wait until a tunnel subdomain is connected (server returns non-404). */ | ||
| async function waitForTunnel(timeoutMs = 15_000): Promise<void> { | ||
@@ -51,3 +44,3 @@ const start = Date.now() | ||
| } catch { | ||
| // not ready yet | ||
| /* not ready */ | ||
| } | ||
@@ -60,3 +53,2 @@ await Bun.sleep(300) | ||
| beforeAll(async () => { | ||
| // 1. Start the tunnel server | ||
| serverProc = Bun.spawn(['bun', 'run', 'server.ts'], { | ||
@@ -74,4 +66,3 @@ cwd: import.meta.dir, | ||
| // 2. Start the demo webserver | ||
| demoProc = Bun.spawn(['bun', 'run', 'demo.ts'], { | ||
| demoProc = Bun.spawn(['bun', 'run', 'demo.tsx'], { | ||
| cwd: import.meta.dir, | ||
@@ -83,3 +74,2 @@ env: { ...process.env, DEMO_PORT: String(DEMO_PORT) }, | ||
| // Wait for both to be up | ||
| await Promise.all([ | ||
@@ -90,3 +80,2 @@ waitFor(`http://localhost:${SERVER_PORT}/?new`), | ||
| // 3. Start the tunnel client | ||
| clientProc = Bun.spawn( | ||
@@ -112,3 +101,2 @@ [ | ||
| // Wait for the tunnel to be live | ||
| await waitForTunnel() | ||
@@ -129,3 +117,3 @@ }) | ||
| const body = await res.text() | ||
| expect(body).toContain('<h1>bunl tunnel works!</h1>') | ||
| expect(body).toContain('<h1>bunl</h1>') | ||
| }) | ||
@@ -147,3 +135,2 @@ | ||
| const bytes = new Uint8Array(buf) | ||
| // PNG magic bytes: 0x89 0x50 0x4E 0x47 | ||
| expect(bytes[0]).toBe(0x89) | ||
@@ -161,3 +148,2 @@ expect(bytes[1]).toBe(0x50) | ||
| const bytes = new Uint8Array(buf) | ||
| // Our fake font is 256 bytes: [0, 1, 2, ..., 255] | ||
| expect(bytes.length).toBe(256) | ||
@@ -200,3 +186,15 @@ expect(bytes[0]).toBe(0) | ||
| expect(res.status).toBe(404) | ||
| const body = await res.text() | ||
| expect(body).toContain('Not Found') | ||
| }) | ||
| test('returns landing page for root domain', async () => { | ||
| const res = await fetch(`http://localhost:${SERVER_PORT}/`, { | ||
| headers: { host: `localhost:${SERVER_PORT}` } | ||
| }) | ||
| expect(res.status).toBe(200) | ||
| const body = await res.text() | ||
| expect(body).toContain('bunl') | ||
| expect(body).toContain('Expose localhost to the world') | ||
| }) | ||
| }) |
+9
-4
@@ -6,3 +6,5 @@ { | ||
| "dependencies": { | ||
| "@rubriclab/config": "^0.0.24" | ||
| "@rubriclab/config": "^0.0.24", | ||
| "react": "^19.2.4", | ||
| "react-dom": "^19.2.4" | ||
| }, | ||
@@ -12,3 +14,5 @@ "description": "Expose localhost to the world. Bun-native localtunnel.", | ||
| "@rubriclab/package": "^0.0.124", | ||
| "@types/bun": "latest" | ||
| "@types/bun": "latest", | ||
| "@types/react": "^19.2.13", | ||
| "@types/react-dom": "^19.2.3" | ||
| }, | ||
@@ -37,3 +41,3 @@ "homepage": "https://github.com/RubricLab/bunl#readme", | ||
| "client": "bun --watch client.ts", | ||
| "demo": "bun --watch demo.ts", | ||
| "demo": "bun --watch demo.tsx", | ||
| "dev:server": "bun --watch server.ts", | ||
@@ -43,2 +47,3 @@ "format": "bun x biome format --write .", | ||
| "lint:fix": "bun x biome lint . --write --unsafe", | ||
| "prepare": "bun x @rubriclab/package prepare", | ||
| "server": "bun server.ts", | ||
@@ -52,3 +57,3 @@ "test": "bun test", | ||
| "type": "module", | ||
| "version": "0.2.0" | ||
| "version": "0.2.1" | ||
| } |
+15
-67
| # bunl | ||
| ## A Bun WebSocket re-write of LocalTunnel | ||
| Expose localhost to the world. Bun-native WebSocket tunnel. | ||
| ### Usage | ||
| To try it: | ||
| ```bash | ||
| bun x bunl -p 3000 -d dev.rubric.me -s my-name | ||
| bun x bunl -p 3000 | ||
| ``` | ||
| ### Development | ||
| ## Options | ||
| To install dependencies: | ||
| | Flag | Short | Default | Description | | ||
| | --- | --- | --- | --- | | ||
| | `--port` | `-p` | `3000` | Local port to expose | | ||
| | `--domain` | `-d` | `bunl.sh` | Tunnel server | | ||
| | `--subdomain` | `-s` | random | Requested subdomain | | ||
| | `--open` | `-o` | `false` | Open URL in browser | | ||
| ## Development | ||
| ```bash | ||
| bun i | ||
| bun dev:server # tunnel server on :1234 | ||
| bun demo # demo app on :3000 | ||
| bun client # connect demo to server | ||
| bun test:e2e # end-to-end tests | ||
| ``` | ||
| To run the server: | ||
| ```bash | ||
| bun dev:server | ||
| ``` | ||
| (Optional) to run a dummy process on localhost:3000: | ||
| ```bash | ||
| bun demo | ||
| ``` | ||
| To run the client: | ||
| ```bash | ||
| bun client -p 3000 | ||
| ``` | ||
| With full args: | ||
| ```bash | ||
| bun client --port 3000 --domain example.so --subdomain my-subdomain --open | ||
| ``` | ||
| Or in shortform: | ||
| ```bash | ||
| bun client -p 3000 -d example.so -s my-subdomain -o | ||
| ``` | ||
| The options: | ||
| - `port` / `p` the localhost port to expose eg. **3000** | ||
| - `domain` / `d` the hostname of the server Bunl is running on eg. **example.so** | ||
| - `subdomain` / `s` the public URL to request eg. **my-subdomain**.example.so | ||
| - `open` / `o` to auto-open your public URL in the browser | ||
| ### [WIP] Deployment | ||
| To build the client code: | ||
| ```bash | ||
| bun run build | ||
| ``` | ||
| To deploy the server, for example on [Fly](https://fly.io): | ||
| ```bash | ||
| fly launch && fly deploy | ||
| ``` | ||
| Making sure to set `DOMAIN` to your domain: | ||
| ```bash | ||
| fly secrets set DOMAIN=example.so | ||
| ``` | ||
| Open to PRs! |
+38
-26
| import { type ServerWebSocket, serve } from 'bun' | ||
| import type { Client, TunnelInit, TunnelRequest, TunnelResponse } from './types' | ||
| import { fromBase64, toBase64, uid } from './utils' | ||
| import { fromBase64, page, toBase64, uid } from './utils' | ||
@@ -8,10 +8,5 @@ const port = Number(Bun.env.PORT) || 1234 | ||
| const domain = Bun.env.DOMAIN || `localhost:${port}` | ||
| /** The hostname portion of DOMAIN (no port) used for subdomain extraction */ | ||
| const domainHost = domain.replace(/:\d+$/, '') | ||
| /** Connected tunnel clients keyed by subdomain */ | ||
| const clients = new Map<string, ServerWebSocket<Client>>() | ||
| /** Pending HTTP requests waiting for a tunnel response, keyed by request ID */ | ||
| const pending = new Map< | ||
@@ -27,2 +22,24 @@ string, | ||
| const landingHtml = page( | ||
| 'bunl', | ||
| `<h1>bunl</h1> | ||
| <p>Expose localhost to the world.</p> | ||
| <p><code>bun x bunl -p 3000</code></p>` | ||
| ) | ||
| function notFoundHtml(subdomain: string) { | ||
| return page( | ||
| 'Not Found', | ||
| `<h1>Not Found</h1> | ||
| <p>No tunnel is connected for <code>${subdomain}</code>.</p> | ||
| <p>Make sure your client is running.</p>` | ||
| ) | ||
| } | ||
| const timeoutHtml = page( | ||
| 'Gateway Timeout', | ||
| `<h1>Gateway Timeout</h1> | ||
| <p>The tunnel client didn't respond in time.</p>` | ||
| ) | ||
| serve<Client>({ | ||
@@ -32,7 +49,5 @@ fetch: async (req, server) => { | ||
| // Client wants to register a new tunnel | ||
| if (reqUrl.searchParams.has('new')) { | ||
| const requested = reqUrl.searchParams.get('subdomain') | ||
| let id = requested || uid() | ||
| // Avoid collisions — if taken, generate a fresh one | ||
| if (clients.has(id)) id = uid() | ||
@@ -45,13 +60,18 @@ | ||
| // Public HTTP request — route to the right tunnel client. | ||
| // Use the Host header (not reqUrl.hostname) so this works behind | ||
| // reverse proxies like Fly.io where reqUrl.hostname is internal. | ||
| // Strip the DOMAIN suffix to extract the tunnel subdomain, so it | ||
| // works when the server is itself on a subdomain (e.g. bunl.rubric.sh). | ||
| const host = (req.headers.get('host') || reqUrl.hostname).replace(/:\d+$/, '') | ||
| const subdomain = host.endsWith(`.${domainHost}`) ? host.slice(0, -(domainHost.length + 1)) : '' | ||
| const client = subdomain ? clients.get(subdomain) : undefined | ||
| if (!subdomain) { | ||
| return new Response(landingHtml, { | ||
| headers: { 'content-type': 'text/html; charset=utf-8' } | ||
| }) | ||
| } | ||
| const client = clients.get(subdomain) | ||
| if (!client) { | ||
| return new Response(`Tunnel "${subdomain}" not found`, { status: 404 }) | ||
| return new Response(notFoundHtml(subdomain), { | ||
| headers: { 'content-type': 'text/html; charset=utf-8' }, | ||
| status: 404 | ||
| }) | ||
| } | ||
@@ -63,7 +83,5 @@ | ||
| // Read request body as binary and encode to base64 | ||
| const rawBody = await req.arrayBuffer() | ||
| const body = rawBody.byteLength > 0 ? toBase64(rawBody) : '' | ||
| // Flatten request headers | ||
| const headers: Record<string, string> = {} | ||
@@ -83,3 +101,2 @@ req.headers.forEach((v, k) => { | ||
| // Create a promise that will be resolved when the client responds | ||
| const response = await new Promise<TunnelResponse>((resolve, reject) => { | ||
@@ -93,7 +110,6 @@ const timer = setTimeout(() => { | ||
| client.send(JSON.stringify(message)) | ||
| }).catch((err: unknown): TunnelResponse => { | ||
| const message = err instanceof Error ? err.message : String(err) | ||
| }).catch((): TunnelResponse => { | ||
| return { | ||
| body: Buffer.from(message).toString('base64'), | ||
| headers: { 'content-type': 'text/plain' }, | ||
| body: Buffer.from(timeoutHtml).toString('base64'), | ||
| headers: { 'content-type': 'text/html; charset=utf-8' }, | ||
| id, | ||
@@ -106,10 +122,7 @@ status: 504, | ||
| // Decode base64 response body back to binary | ||
| const resBody = response.body ? fromBase64(response.body) : null | ||
| // Build response headers, removing problematic ones | ||
| const resHeaders = { ...response.headers } | ||
| delete resHeaders['content-encoding'] | ||
| delete resHeaders['transfer-encoding'] | ||
| // Fix content-length to match the actual decoded body | ||
| if (resBody) { | ||
@@ -131,3 +144,2 @@ resHeaders['content-length'] = String(resBody.byteLength) | ||
| }, | ||
| message(_ws, raw) { | ||
@@ -134,0 +146,0 @@ const msg = JSON.parse( |
+1
-1
| { | ||
| "exclude": ["node_modules"], | ||
| "extends": "@rubriclab/config/tsconfig", | ||
| "include": ["**/*.ts"] | ||
| "include": ["**/*.ts", "**/*.tsx"] | ||
| } |
+2
-5
| export type Client = { id: string } | ||
| /** Server → Client: incoming HTTP request to proxy */ | ||
| export type TunnelRequest = { | ||
@@ -10,6 +9,5 @@ type: 'request' | ||
| headers: Record<string, string> | ||
| body: string // base64-encoded | ||
| body: string | ||
| } | ||
| /** Client → Server: proxied HTTP response */ | ||
| export type TunnelResponse = { | ||
@@ -21,6 +19,5 @@ type: 'response' | ||
| headers: Record<string, string> | ||
| body: string // base64-encoded | ||
| body: string | ||
| } | ||
| /** Server → Client: initial connection info */ | ||
| export type TunnelInit = { | ||
@@ -27,0 +24,0 @@ type: 'init' |
+34
-4
@@ -95,3 +95,2 @@ const adjectives = [ | ||
| /** Generate a human-readable unique identifier, e.g. "bold-calm-fox" */ | ||
| export function uid(): string { | ||
@@ -101,3 +100,2 @@ return `${pick(adjectives)}-${pick(adjectives)}-${pick(nouns)}` | ||
| /** Open a URL in the default browser using platform-native commands */ | ||
| export function openBrowser(url: string): void { | ||
@@ -112,3 +110,2 @@ const cmds: Record<string, string[]> = { | ||
| /** Encode an ArrayBuffer to base64 */ | ||
| export function toBase64(buf: ArrayBuffer): string { | ||
@@ -118,3 +115,2 @@ return Buffer.from(buf).toString('base64') | ||
| /** Decode a base64 string to a Uint8Array */ | ||
| export function fromBase64(str: string): Uint8Array<ArrayBuffer> { | ||
@@ -124,1 +120,35 @@ const buf = Buffer.from(str, 'base64') | ||
| } | ||
| export function page(title: string, body: string): string { | ||
| return `<!DOCTYPE html> | ||
| <html lang="en"> | ||
| <head> | ||
| <meta charset="utf-8"> | ||
| <meta name="viewport" content="width=device-width, initial-scale=1"> | ||
| <title>${title} — bunl</title> | ||
| <style> | ||
| * { margin: 0; padding: 0; box-sizing: border-box; } | ||
| body { | ||
| font-family: system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; | ||
| background: #fff; | ||
| color: #000; | ||
| min-height: 100vh; | ||
| display: flex; | ||
| align-items: center; | ||
| justify-content: center; | ||
| padding: 2rem; | ||
| } | ||
| main { max-width: 480px; width: 100%; } | ||
| h1 { font-size: 1.5rem; font-weight: 600; letter-spacing: -0.02em; margin-bottom: 1rem; } | ||
| p { color: #666; line-height: 1.6; margin-bottom: 0.75rem; } | ||
| code { background: #f5f5f5; padding: 0.15em 0.4em; border-radius: 3px; font-size: 0.9em; } | ||
| a { color: #000; } | ||
| </style> | ||
| </head> | ||
| <body> | ||
| <main> | ||
| ${body} | ||
| </main> | ||
| </body> | ||
| </html>` | ||
| } |
-85
| /** | ||
| * Demo webserver that serves multiple content types for testing the tunnel. | ||
| * Exercises: HTML, CSS, JSON API, binary images (PNG), and binary fonts (WOFF2-like). | ||
| */ | ||
| import { serve } from 'bun' | ||
| const port = Number(Bun.env.DEMO_PORT) || 3000 | ||
| // 1x1 red PNG pixel (67 bytes) — smallest valid PNG | ||
| const PNG_1PX = Buffer.from( | ||
| 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==', | ||
| 'base64' | ||
| ) | ||
| // Generate a binary blob to simulate a font file | ||
| const FAKE_FONT = (() => { | ||
| const buf = Buffer.alloc(256) | ||
| for (let i = 0; i < 256; i++) buf[i] = i | ||
| return buf | ||
| })() | ||
| const CSS_CONTENT = `body { font-family: sans-serif; background: #111; color: #0f0; padding: 2rem; } | ||
| img { border: 2px solid #0f0; margin: 1rem 0; }` | ||
| const HTML = `<!DOCTYPE html> | ||
| <html> | ||
| <head> | ||
| <meta charset="utf-8"> | ||
| <title>bunl demo</title> | ||
| <link rel="stylesheet" href="/style.css"> | ||
| </head> | ||
| <body> | ||
| <h1>bunl tunnel works!</h1> | ||
| <p>This page was served through the tunnel.</p> | ||
| <img src="/image.png" alt="test pixel" width="100" height="100"> | ||
| <p><a href="/api/health">JSON API</a> · <a href="/font.woff2">Font binary</a></p> | ||
| </body> | ||
| </html>` | ||
| serve({ | ||
| fetch(req) { | ||
| const url = new URL(req.url) | ||
| const { pathname } = url | ||
| console.log(`${req.method} ${pathname}`) | ||
| if (pathname === '/style.css') { | ||
| return new Response(CSS_CONTENT, { | ||
| headers: { 'content-type': 'text/css; charset=utf-8' } | ||
| }) | ||
| } | ||
| if (pathname === '/image.png') { | ||
| return new Response(new Uint8Array(PNG_1PX), { | ||
| headers: { 'content-type': 'image/png' } | ||
| }) | ||
| } | ||
| if (pathname === '/font.woff2') { | ||
| return new Response(new Uint8Array(FAKE_FONT), { | ||
| headers: { 'content-type': 'font/woff2' } | ||
| }) | ||
| } | ||
| if (pathname === '/api/health') { | ||
| return Response.json({ status: 'ok', timestamp: Date.now() }) | ||
| } | ||
| if (pathname === '/echo' && req.method === 'POST') { | ||
| return new Response(req.body, { | ||
| headers: { | ||
| 'content-type': req.headers.get('content-type') || 'application/octet-stream' | ||
| } | ||
| }) | ||
| } | ||
| return new Response(HTML, { | ||
| headers: { 'content-type': 'text/html; charset=utf-8' } | ||
| }) | ||
| }, | ||
| port | ||
| }) | ||
| console.log(`Demo server at http://localhost:${port}`) |
Sorry, the diff of this file is not supported yet
Network access
Supply chain riskThis module accesses the network.
Found 1 instance in 1 package
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
Found 1 instance in 1 package
Network access
Supply chain riskThis module accesses the network.
Found 1 instance in 1 package
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 1 instance in 1 package
46254
5.06%20
5.26%885
14.05%8
-11.11%3
200%4
100%27
-65.82%4
33.33%+ Added
+ Added
+ Added
+ Added
+ Added