@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 +2 -2
- package/CHANGELOG.md +3 -0
- package/README.md +15 -67
- package/build/client.js +43 -4
- package/bun.lock +16 -0
- package/client.ts +15 -12
- package/demo.tsx +127 -0
- package/e2e.test.ts +15 -17
- package/package.json +9 -4
- package/server.ts +38 -26
- package/tsconfig.json +1 -1
- package/types.ts +2 -5
- package/utils.ts +34 -4
- package/demo.ts +0 -85
package/.env.example
CHANGED
package/CHANGELOG.md
ADDED
package/README.md
CHANGED
|
@@ -1,78 +1,26 @@
|
|
|
1
1
|
# bunl
|
|
2
2
|
|
|
3
|
-
|
|
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
|
|
6
|
+
bun x bunl -p 3000
|
|
31
7
|
```
|
|
32
8
|
|
|
33
|
-
|
|
9
|
+
## Options
|
|
34
10
|
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
18
|
+
## Development
|
|
40
19
|
|
|
41
20
|
```bash
|
|
42
|
-
bun
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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(
|
|
85
|
-
headers: { "content-type": "text/
|
|
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: "
|
|
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 || "
|
|
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(
|
|
88
|
-
headers: { 'content-type': 'text/
|
|
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: '
|
|
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 || '
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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(
|
|
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((
|
|
88
|
-
const message = err instanceof Error ? err.message : String(err)
|
|
104
|
+
}).catch((): TunnelResponse => {
|
|
89
105
|
return {
|
|
90
|
-
body: Buffer.from(
|
|
91
|
-
headers: { 'content-type': 'text/
|
|
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
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
|
|
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
|
|
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}`)
|