@symbo.ls/tunnel 3.14.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.
Files changed (26) hide show
  1. package/.wrangler/cache/cf.json +1 -0
  2. package/.wrangler/state/v3/cache/miniflare-CacheObject/metadata.sqlite +0 -0
  3. package/.wrangler/state/v3/cache/miniflare-CacheObject/metadata.sqlite-shm +0 -0
  4. package/.wrangler/state/v3/cache/miniflare-CacheObject/metadata.sqlite-wal +0 -0
  5. package/.wrangler/state/v3/do/symbols-tunnel-TunnelRoom/3a9e8170dcfddd4d80b5cf81fbbfb44e39e1b52012822f06d44419a77bf5c28b.sqlite +0 -0
  6. package/.wrangler/state/v3/do/symbols-tunnel-TunnelRoom/3a9e8170dcfddd4d80b5cf81fbbfb44e39e1b52012822f06d44419a77bf5c28b.sqlite-shm +0 -0
  7. package/.wrangler/state/v3/do/symbols-tunnel-TunnelRoom/3a9e8170dcfddd4d80b5cf81fbbfb44e39e1b52012822f06d44419a77bf5c28b.sqlite-wal +0 -0
  8. package/.wrangler/state/v3/do/symbols-tunnel-TunnelRoom/bf5eea075153d5d7877e172ef111f3068ecb77c4ecbc4f7c28915912eb3cd0da.sqlite +0 -0
  9. package/.wrangler/state/v3/do/symbols-tunnel-TunnelRoom/bf5eea075153d5d7877e172ef111f3068ecb77c4ecbc4f7c28915912eb3cd0da.sqlite-shm +0 -0
  10. package/.wrangler/state/v3/do/symbols-tunnel-TunnelRoom/bf5eea075153d5d7877e172ef111f3068ecb77c4ecbc4f7c28915912eb3cd0da.sqlite-wal +0 -0
  11. package/.wrangler/state/v3/do/symbols-tunnel-TunnelRoom/metadata.sqlite +0 -0
  12. package/.wrangler/state/v3/do/symbols-tunnel-TunnelRoom/metadata.sqlite-shm +0 -0
  13. package/.wrangler/state/v3/do/symbols-tunnel-TunnelRoom/metadata.sqlite-wal +0 -0
  14. package/README.md +46 -0
  15. package/bin/client.js +18 -0
  16. package/bin/server.js +8 -0
  17. package/dist-worker/README.md +1 -0
  18. package/dist-worker/index.js +222 -0
  19. package/dist-worker/index.js.map +8 -0
  20. package/package.json +25 -0
  21. package/src/client.js +152 -0
  22. package/src/protocol.js +24 -0
  23. package/src/server.js +177 -0
  24. package/worker/index.js +110 -0
  25. package/worker/tunnel-room.js +151 -0
  26. package/wrangler.toml +49 -0
@@ -0,0 +1 @@
1
+ {"httpProtocol":"HTTP/1.1","clientAcceptEncoding":"gzip, deflate, br","requestPriority":"","edgeRequestKeepAliveStatus":1,"requestHeaderNames":{},"clientTcpRtt":7,"clientQuicRtt":0,"colo":"TBS","asn":16010,"asOrganization":"Caucasus Online LLC","country":"GE","isEUCountry":false,"city":"Tbilisi","continent":"AS","region":"Tbilisi","regionCode":"TB","timezone":"Asia/Tbilisi","longitude":"44.83412","latitude":"41.69143","tlsVersion":"TLSv1.3","tlsCipher":"AEAD-AES256-GCM-SHA384","tlsClientRandom":"f1ZBvvH8HtvDoYpg3t6OzwIx8Ks/1zG9HNR394Dfg8c=","tlsClientCiphersSha1":"kXrN3VEKDdzz2cPKTQaKzpxVTxQ=","tlsClientExtensionsSha1":"1eY97BUYYO8vDaTfHQywB1pcNdM=","tlsClientExtensionsSha1Le":"u4wtEMFQBY18l3BzHAvORm+KGRw=","tlsExportedAuthenticator":{"clientHandshake":"9b50ff71340861fedd458ff983032727ef9d477d0c55e523f70ed4a49decc68333024cd0eaf72d0c6ea8dbc04b46aa04","serverHandshake":"f09a1c6c05a4a967ebf1fd6dbbe8b153749e808b0ac8bdea102be1900a5a6b162bd49a2040977bd0fee0d46e9acc315a","clientFinished":"e34c7dcc4aabb8eb057841cc90d5ae810804177894505fbc7858ed21956e29fbb3760113c6b673726c9e9a1de9576216","serverFinished":"77097e6fa8669d551ba1770b0a4a9caf700910f090751e5d1cb2692466221dfb6b7a4062eda8f271725d90c84794dd71"},"tlsClientHelloLength":"1605","tlsClientAuth":{"certPresented":"0","certVerified":"NONE","certRevoked":"0","certIssuerDN":"","certSubjectDN":"","certIssuerDNRFC2253":"","certSubjectDNRFC2253":"","certIssuerDNLegacy":"","certSubjectDNLegacy":"","certSerial":"","certIssuerSerial":"","certSKI":"","certIssuerSKI":"","certFingerprintSHA1":"","certFingerprintSHA256":"","certNotBefore":"","certNotAfter":"","certRFC9440":"","certRFC9440TooLarge":false,"certChainRFC9440":"","certChainRFC9440TooLarge":false},"verifiedBotCategory":"","edgeL4":{"deliveryRate":755939},"botManagement":{"corporateProxy":false,"verifiedBot":false,"jsDetection":{"passed":false},"staticResource":false,"detectionIds":{},"score":99}}
package/README.md ADDED
@@ -0,0 +1,46 @@
1
+ # @symbo.ls/tunnel
2
+
3
+ Expose a local HTTP server to the internet through `tunnel.symbo.ls`.
4
+
5
+ ## Client (CLI)
6
+
7
+ ```bash
8
+ smbls tunnel 1234 # → https://<random>.tunnel.symbo.ls
9
+ smbls tunnel 1234 --subdomain myapp # → https://myapp.tunnel.symbo.ls
10
+ smbls tunnel 1234 --host tunnel.symbo.ls --token $TUNNEL_AUTH_TOKEN
11
+ smbls tunnel 1234 --host localhost:3000 --insecure
12
+ ```
13
+
14
+ Or standalone:
15
+
16
+ ```bash
17
+ npx smbls-tunnel 1234 --subdomain myapp
18
+ ```
19
+
20
+ ## Server (hosted at tunnel.symbo.ls)
21
+
22
+ ```bash
23
+ PORT=3000 TUNNEL_ROOT_DOMAIN=tunnel.symbo.ls node bin/server.js
24
+ ```
25
+
26
+ Optional: set `TUNNEL_AUTH_TOKEN` to require a token from clients.
27
+
28
+ ### DNS / reverse proxy
29
+
30
+ Point `*.tunnel.symbo.ls` and `tunnel.symbo.ls` at the box running the server.
31
+ Terminate TLS at your reverse proxy (nginx / caddy) and forward both HTTP and
32
+ WebSocket upgrades to the tunnel server's `PORT`.
33
+
34
+ ## Protocol
35
+
36
+ Clients open `wss://tunnel.symbo.ls/_tunnel?subdomain=<name>`. Each HTTP request
37
+ to `<sub>.tunnel.symbo.ls` is framed as JSON messages (`request` /
38
+ `request_body` / `request_end`) and replies stream back as `response` /
39
+ `response_body` / `response_end`. Bodies are base64.
40
+
41
+ ## Programmatic
42
+
43
+ ```js
44
+ import { createTunnelClient } from '@symbo.ls/tunnel/client'
45
+ createTunnelClient({ localPort: 1234, subdomain: 'myapp' })
46
+ ```
package/bin/client.js ADDED
@@ -0,0 +1,18 @@
1
+ #!/usr/bin/env node
2
+ import { createTunnelClient } from '../src/client.js'
3
+
4
+ const args = process.argv.slice(2)
5
+ const opts = { localPort: null }
6
+ for (let i = 0; i < args.length; i++) {
7
+ const a = args[i]
8
+ if (a === '--host') opts.host = args[++i]
9
+ else if (a === '--subdomain' || a === '-s') opts.subdomain = args[++i]
10
+ else if (a === '--token') opts.token = args[++i]
11
+ else if (a === '--insecure') { opts.secure = false; opts.port = 80 }
12
+ else if (/^\d+$/.test(a)) opts.localPort = parseInt(a, 10)
13
+ }
14
+ if (!opts.localPort) {
15
+ console.error('Usage: smbls-tunnel <localPort> [--subdomain name] [--host tunnel.symbo.ls] [--token X] [--insecure]')
16
+ process.exit(1)
17
+ }
18
+ createTunnelClient(opts)
package/bin/server.js ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env node
2
+ import { createTunnelServer } from '../src/server.js'
3
+
4
+ const port = parseInt(process.env.PORT || '3000', 10)
5
+ const { httpServer, rootDomain } = createTunnelServer()
6
+ httpServer.listen(port, () => {
7
+ console.log(`tunnel server listening on :${port} (root: ${rootDomain})`)
8
+ })
@@ -0,0 +1 @@
1
+ This folder contains the built output assets for the worker "symbols-tunnel" generated at 2026-04-19T12:26:26.957Z.
@@ -0,0 +1,222 @@
1
+ var __defProp = Object.defineProperty;
2
+ var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
3
+
4
+ // src/protocol.js
5
+ var MSG = {
6
+ REGISTER: "register",
7
+ REGISTERED: "registered",
8
+ ERROR: "error",
9
+ REQUEST: "request",
10
+ REQUEST_BODY: "request_body",
11
+ REQUEST_END: "request_end",
12
+ RESPONSE: "response",
13
+ RESPONSE_BODY: "response_body",
14
+ RESPONSE_END: "response_end",
15
+ PING: "ping",
16
+ PONG: "pong"
17
+ };
18
+ var randomId = /* @__PURE__ */ __name(() => Math.random().toString(36).slice(2) + Date.now().toString(36), "randomId");
19
+ var randomSubdomain = /* @__PURE__ */ __name(() => {
20
+ const adj = ["calm", "bold", "brisk", "quick", "bright", "soft", "warm", "cool", "keen", "lush"];
21
+ const noun = ["fox", "owl", "wolf", "hawk", "lark", "wren", "seal", "deer", "moth", "cub"];
22
+ const a = adj[Math.floor(Math.random() * adj.length)];
23
+ const n = noun[Math.floor(Math.random() * noun.length)];
24
+ const s = Math.random().toString(36).slice(2, 6);
25
+ return `${a}-${n}-${s}`;
26
+ }, "randomSubdomain");
27
+
28
+ // worker/tunnel-room.js
29
+ var TunnelRoom = class {
30
+ static {
31
+ __name(this, "TunnelRoom");
32
+ }
33
+ constructor(state, env) {
34
+ this.state = state;
35
+ this.env = env;
36
+ this.client = null;
37
+ this.subdomain = null;
38
+ this.root = "tunnel.symbo.ls";
39
+ this.pending = /* @__PURE__ */ new Map();
40
+ }
41
+ async fetch(request) {
42
+ const url = new URL(request.url);
43
+ if (url.pathname === "/_register") {
44
+ return this.register(request, url);
45
+ }
46
+ if (!this.client || this.client.readyState !== 1) {
47
+ return new Response(`No tunnel registered for ${this.subdomain || "this subdomain"}.`, { status: 502 });
48
+ }
49
+ return this.proxyRequest(request);
50
+ }
51
+ async register(request, url) {
52
+ if (request.headers.get("upgrade") !== "websocket") {
53
+ return new Response("expected websocket", { status: 426 });
54
+ }
55
+ this.subdomain = url.searchParams.get("subdomain");
56
+ this.root = url.searchParams.get("root") || this.root;
57
+ if (this.client) {
58
+ try {
59
+ this.client.close(1e3, "replaced");
60
+ } catch {
61
+ }
62
+ }
63
+ const pair = new WebSocketPair();
64
+ const [clientSide, serverSide] = [pair[0], pair[1]];
65
+ serverSide.accept();
66
+ this.client = serverSide;
67
+ serverSide.addEventListener("message", (event) => this.onClientMessage(event));
68
+ const onClose = /* @__PURE__ */ __name(() => {
69
+ this.client = null;
70
+ this.failAllPending("tunnel closed");
71
+ }, "onClose");
72
+ serverSide.addEventListener("close", onClose);
73
+ serverSide.addEventListener("error", onClose);
74
+ serverSide.send(JSON.stringify({
75
+ type: MSG.REGISTERED,
76
+ subdomain: this.subdomain,
77
+ publicUrl: `https://${this.subdomain}.${this.root}`
78
+ }));
79
+ return new Response(null, { status: 101, webSocket: clientSide });
80
+ }
81
+ failAllPending(reason) {
82
+ for (const [, p] of this.pending) {
83
+ if (!p.headSent) {
84
+ p.resolveHead(new Response(reason, { status: 502 }));
85
+ } else {
86
+ try {
87
+ p.controller.error(new Error(reason));
88
+ } catch {
89
+ }
90
+ }
91
+ }
92
+ this.pending.clear();
93
+ }
94
+ onClientMessage(event) {
95
+ let msg;
96
+ try {
97
+ msg = JSON.parse(typeof event.data === "string" ? event.data : new TextDecoder().decode(event.data));
98
+ } catch {
99
+ return;
100
+ }
101
+ const entry = msg.id && this.pending.get(msg.id);
102
+ if (!entry) return;
103
+ if (msg.type === MSG.RESPONSE) {
104
+ const stream = new ReadableStream({
105
+ start: /* @__PURE__ */ __name((controller) => {
106
+ entry.controller = controller;
107
+ }, "start")
108
+ });
109
+ entry.headSent = true;
110
+ entry.resolveHead(new Response(stream, {
111
+ status: msg.status || 200,
112
+ headers: sanitizeHeaders(msg.headers || {})
113
+ }));
114
+ } else if (msg.type === MSG.RESPONSE_BODY) {
115
+ if (msg.chunk && entry.controller) {
116
+ entry.controller.enqueue(base64ToBytes(msg.chunk));
117
+ }
118
+ } else if (msg.type === MSG.RESPONSE_END) {
119
+ try {
120
+ entry.controller && entry.controller.close();
121
+ } catch {
122
+ }
123
+ this.pending.delete(msg.id);
124
+ }
125
+ }
126
+ async proxyRequest(request) {
127
+ const id = randomId();
128
+ const url = new URL(request.url);
129
+ const headers = {};
130
+ for (const [k, v] of request.headers) headers[k] = v;
131
+ const bodyBuf = ["GET", "HEAD"].includes(request.method) ? null : new Uint8Array(await request.arrayBuffer());
132
+ const entry = { controller: null, headSent: false };
133
+ const headPromise = new Promise((resolve) => {
134
+ entry.resolveHead = resolve;
135
+ });
136
+ this.pending.set(id, entry);
137
+ this.client.send(JSON.stringify({
138
+ type: MSG.REQUEST,
139
+ id,
140
+ method: request.method,
141
+ url: url.pathname + url.search,
142
+ headers
143
+ }));
144
+ if (bodyBuf && bodyBuf.byteLength) {
145
+ this.client.send(JSON.stringify({ type: MSG.REQUEST_BODY, id, chunk: bytesToBase64(bodyBuf) }));
146
+ }
147
+ this.client.send(JSON.stringify({ type: MSG.REQUEST_END, id }));
148
+ const timeout = new Promise((resolve) => setTimeout(() => {
149
+ if (this.pending.has(id)) {
150
+ this.pending.delete(id);
151
+ resolve(new Response("Tunnel request timed out", { status: 504 }));
152
+ }
153
+ }, 3e4));
154
+ return Promise.race([headPromise, timeout]);
155
+ }
156
+ };
157
+ var HOP_BY_HOP = /* @__PURE__ */ new Set(["connection", "keep-alive", "transfer-encoding", "upgrade", "proxy-authenticate", "proxy-authorization", "te", "trailer", "content-length"]);
158
+ var sanitizeHeaders = /* @__PURE__ */ __name((h) => {
159
+ const out = {};
160
+ for (const [k, v] of Object.entries(h)) {
161
+ if (!HOP_BY_HOP.has(k.toLowerCase())) out[k] = v;
162
+ }
163
+ return out;
164
+ }, "sanitizeHeaders");
165
+ var bytesToBase64 = /* @__PURE__ */ __name((bytes) => {
166
+ let s = "";
167
+ for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]);
168
+ return btoa(s);
169
+ }, "bytesToBase64");
170
+ var base64ToBytes = /* @__PURE__ */ __name((b64) => {
171
+ const s = atob(b64);
172
+ const out = new Uint8Array(s.length);
173
+ for (let i = 0; i < s.length; i++) out[i] = s.charCodeAt(i);
174
+ return out;
175
+ }, "base64ToBytes");
176
+
177
+ // worker/index.js
178
+ var extractSubdomain = /* @__PURE__ */ __name((host, root) => {
179
+ if (!host) return null;
180
+ host = host.split(":")[0].toLowerCase();
181
+ if (host === root) return null;
182
+ if (!host.endsWith("." + root)) return null;
183
+ return host.slice(0, -("." + root).length);
184
+ }, "extractSubdomain");
185
+ var index_default = {
186
+ async fetch(request, env) {
187
+ const url = new URL(request.url);
188
+ const root = env.TUNNEL_ROOT_DOMAIN || "tunnel.symbo.ls";
189
+ const host = request.headers.get("host") || "";
190
+ if (url.pathname === "/_tunnel" && request.headers.get("upgrade") === "websocket") {
191
+ if (env.TUNNEL_AUTH_TOKEN) {
192
+ const token = url.searchParams.get("token") || request.headers.get("x-tunnel-token");
193
+ if (token !== env.TUNNEL_AUTH_TOKEN) return new Response("unauthorized", { status: 401 });
194
+ }
195
+ const requested = url.searchParams.get("subdomain");
196
+ const sub2 = requested && /^[a-z0-9-]{2,40}$/.test(requested) ? requested : randomSubdomain();
197
+ const id2 = env.TUNNEL_ROOM.idFromName(sub2);
198
+ const stub2 = env.TUNNEL_ROOM.get(id2);
199
+ const rewritten = new Request(`https://internal/_register?subdomain=${sub2}&root=${encodeURIComponent(root)}`, request);
200
+ return stub2.fetch(rewritten);
201
+ }
202
+ const sub = extractSubdomain(host, root);
203
+ if (!sub) {
204
+ if (url.pathname === "/health") {
205
+ return new Response(JSON.stringify({ ok: true }), { headers: { "content-type": "application/json" } });
206
+ }
207
+ return new Response(
208
+ `tunnel.symbo.ls \u2014 run \`smbls tunnel <port>\` to expose a local server.
209
+ `,
210
+ { headers: { "content-type": "text/plain" } }
211
+ );
212
+ }
213
+ const id = env.TUNNEL_ROOM.idFromName(sub);
214
+ const stub = env.TUNNEL_ROOM.get(id);
215
+ return stub.fetch(request);
216
+ }
217
+ };
218
+ export {
219
+ TunnelRoom,
220
+ index_default as default
221
+ };
222
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,8 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../src/protocol.js", "../worker/tunnel-room.js", "../worker/index.js"],
4
+ "sourceRoot": "dist-worker",
5
+ "sourcesContent": ["export const MSG = {\n REGISTER: 'register',\n REGISTERED: 'registered',\n ERROR: 'error',\n REQUEST: 'request',\n REQUEST_BODY: 'request_body',\n REQUEST_END: 'request_end',\n RESPONSE: 'response',\n RESPONSE_BODY: 'response_body',\n RESPONSE_END: 'response_end',\n PING: 'ping',\n PONG: 'pong'\n}\n\nexport const randomId = () => Math.random().toString(36).slice(2) + Date.now().toString(36)\n\nexport const randomSubdomain = () => {\n const adj = ['calm', 'bold', 'brisk', 'quick', 'bright', 'soft', 'warm', 'cool', 'keen', 'lush']\n const noun = ['fox', 'owl', 'wolf', 'hawk', 'lark', 'wren', 'seal', 'deer', 'moth', 'cub']\n const a = adj[Math.floor(Math.random() * adj.length)]\n const n = noun[Math.floor(Math.random() * noun.length)]\n const s = Math.random().toString(36).slice(2, 6)\n return `${a}-${n}-${s}`\n}\n", "import { MSG, randomId } from '../src/protocol.js'\n\n// One Durable Object per subdomain. Holds the client WebSocket connection\n// and bridges incoming HTTP requests over JSON frames (see ../src/protocol.js).\n\nexport class TunnelRoom {\n constructor (state, env) {\n this.state = state\n this.env = env\n this.client = null // WebSocket accepted from the CLI client\n this.subdomain = null\n this.root = 'tunnel.symbo.ls'\n this.pending = new Map() // reqId -> { controller, resolveHead, rejectHead, headSent }\n }\n\n async fetch (request) {\n const url = new URL(request.url)\n\n if (url.pathname === '/_register') {\n return this.register(request, url)\n }\n\n if (!this.client || this.client.readyState !== 1 /* OPEN */) {\n return new Response(`No tunnel registered for ${this.subdomain || 'this subdomain'}.`, { status: 502 })\n }\n\n return this.proxyRequest(request)\n }\n\n async register (request, url) {\n if (request.headers.get('upgrade') !== 'websocket') {\n return new Response('expected websocket', { status: 426 })\n }\n this.subdomain = url.searchParams.get('subdomain')\n this.root = url.searchParams.get('root') || this.root\n\n // If a previous client is still attached, close it \u2014 a new client wins.\n if (this.client) {\n try { this.client.close(1000, 'replaced') } catch {}\n }\n\n const pair = new WebSocketPair()\n const [clientSide, serverSide] = [pair[0], pair[1]]\n serverSide.accept()\n this.client = serverSide\n\n serverSide.addEventListener('message', (event) => this.onClientMessage(event))\n const onClose = () => { this.client = null; this.failAllPending('tunnel closed') }\n serverSide.addEventListener('close', onClose)\n serverSide.addEventListener('error', onClose)\n\n serverSide.send(JSON.stringify({\n type: MSG.REGISTERED,\n subdomain: this.subdomain,\n publicUrl: `https://${this.subdomain}.${this.root}`\n }))\n\n return new Response(null, { status: 101, webSocket: clientSide })\n }\n\n failAllPending (reason) {\n for (const [, p] of this.pending) {\n if (!p.headSent) {\n p.resolveHead(new Response(reason, { status: 502 }))\n } else {\n try { p.controller.error(new Error(reason)) } catch {}\n }\n }\n this.pending.clear()\n }\n\n onClientMessage (event) {\n let msg\n try { msg = JSON.parse(typeof event.data === 'string' ? event.data : new TextDecoder().decode(event.data)) } catch { return }\n const entry = msg.id && this.pending.get(msg.id)\n if (!entry) return\n\n if (msg.type === MSG.RESPONSE) {\n const stream = new ReadableStream({\n start: (controller) => { entry.controller = controller }\n })\n entry.headSent = true\n entry.resolveHead(new Response(stream, {\n status: msg.status || 200,\n headers: sanitizeHeaders(msg.headers || {})\n }))\n } else if (msg.type === MSG.RESPONSE_BODY) {\n if (msg.chunk && entry.controller) {\n entry.controller.enqueue(base64ToBytes(msg.chunk))\n }\n } else if (msg.type === MSG.RESPONSE_END) {\n try { entry.controller && entry.controller.close() } catch {}\n this.pending.delete(msg.id)\n }\n }\n\n async proxyRequest (request) {\n const id = randomId()\n const url = new URL(request.url)\n const headers = {}\n for (const [k, v] of request.headers) headers[k] = v\n\n const bodyBuf = ['GET', 'HEAD'].includes(request.method) ? null : new Uint8Array(await request.arrayBuffer())\n\n const entry = { controller: null, headSent: false }\n const headPromise = new Promise((resolve) => { entry.resolveHead = resolve })\n this.pending.set(id, entry)\n\n this.client.send(JSON.stringify({\n type: MSG.REQUEST,\n id,\n method: request.method,\n url: url.pathname + url.search,\n headers\n }))\n if (bodyBuf && bodyBuf.byteLength) {\n this.client.send(JSON.stringify({ type: MSG.REQUEST_BODY, id, chunk: bytesToBase64(bodyBuf) }))\n }\n this.client.send(JSON.stringify({ type: MSG.REQUEST_END, id }))\n\n const timeout = new Promise((resolve) => setTimeout(() => {\n if (this.pending.has(id)) {\n this.pending.delete(id)\n resolve(new Response('Tunnel request timed out', { status: 504 }))\n }\n }, 30000))\n\n return Promise.race([headPromise, timeout])\n }\n}\n\nconst HOP_BY_HOP = new Set(['connection', 'keep-alive', 'transfer-encoding', 'upgrade', 'proxy-authenticate', 'proxy-authorization', 'te', 'trailer', 'content-length'])\nconst sanitizeHeaders = (h) => {\n const out = {}\n for (const [k, v] of Object.entries(h)) {\n if (!HOP_BY_HOP.has(k.toLowerCase())) out[k] = v\n }\n return out\n}\n\nconst bytesToBase64 = (bytes) => {\n let s = ''\n for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i])\n return btoa(s)\n}\nconst base64ToBytes = (b64) => {\n const s = atob(b64)\n const out = new Uint8Array(s.length)\n for (let i = 0; i < s.length; i++) out[i] = s.charCodeAt(i)\n return out\n}\n", "// Cloudflare Worker entry for tunnel.symbo.ls.\n// Routes:\n// - `tunnel.symbo.ls/_tunnel?subdomain=X` (WS upgrade) \u2192 TunnelRoom DO named X\n// - `<sub>.tunnel.symbo.ls/*` \u2192 TunnelRoom DO named <sub>\n// - `tunnel.symbo.ls/health` \u2192 JSON ok\n// - `tunnel.symbo.ls/*` \u2192 landing\n\nimport { randomSubdomain } from '../src/protocol.js'\nexport { TunnelRoom } from './tunnel-room.js'\n\nconst extractSubdomain = (host, root) => {\n if (!host) return null\n host = host.split(':')[0].toLowerCase()\n if (host === root) return null\n if (!host.endsWith('.' + root)) return null\n return host.slice(0, -('.' + root).length)\n}\n\nexport default {\n async fetch (request, env) {\n const url = new URL(request.url)\n const root = env.TUNNEL_ROOT_DOMAIN || 'tunnel.symbo.ls'\n const host = request.headers.get('host') || ''\n\n // Client registration WS upgrade.\n if (url.pathname === '/_tunnel' && request.headers.get('upgrade') === 'websocket') {\n if (env.TUNNEL_AUTH_TOKEN) {\n const token = url.searchParams.get('token') || request.headers.get('x-tunnel-token')\n if (token !== env.TUNNEL_AUTH_TOKEN) return new Response('unauthorized', { status: 401 })\n }\n const requested = url.searchParams.get('subdomain')\n const sub = requested && /^[a-z0-9-]{2,40}$/.test(requested) ? requested : randomSubdomain()\n const id = env.TUNNEL_ROOM.idFromName(sub)\n const stub = env.TUNNEL_ROOM.get(id)\n const rewritten = new Request(`https://internal/_register?subdomain=${sub}&root=${encodeURIComponent(root)}`, request)\n return stub.fetch(rewritten)\n }\n\n const sub = extractSubdomain(host, root)\n if (!sub) {\n if (url.pathname === '/health') {\n return new Response(JSON.stringify({ ok: true }), { headers: { 'content-type': 'application/json' } })\n }\n return new Response(\n `tunnel.symbo.ls \u2014 run \\`smbls tunnel <port>\\` to expose a local server.\\n`,\n { headers: { 'content-type': 'text/plain' } }\n )\n }\n\n const id = env.TUNNEL_ROOM.idFromName(sub)\n const stub = env.TUNNEL_ROOM.get(id)\n return stub.fetch(request)\n }\n}\n"],
6
+ "mappings": "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",
7
+ "names": ["sub", "id", "stub"]
8
+ }
package/package.json ADDED
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "@symbo.ls/tunnel",
3
+ "version": "3.14.0",
4
+ "description": "Expose localhost through tunnel.symbo.ls",
5
+ "type": "module",
6
+ "main": "src/client.js",
7
+ "exports": {
8
+ ".": "./src/client.js",
9
+ "./server": "./src/server.js",
10
+ "./client": "./src/client.js"
11
+ },
12
+ "bin": {
13
+ "smbls-tunnel-server": "bin/server.js",
14
+ "smbls-tunnel": "bin/client.js"
15
+ },
16
+ "scripts": {
17
+ "start": "node bin/server.js"
18
+ },
19
+ "dependencies": {
20
+ "ws": "^8.18.0"
21
+ },
22
+ "author": "Symbols",
23
+ "license": "CC-BY-NC-4.0",
24
+ "publishConfig": { "access": "public" }
25
+ }
package/src/client.js ADDED
@@ -0,0 +1,152 @@
1
+ import WebSocket from 'ws'
2
+ import http from 'node:http'
3
+ import https from 'node:https'
4
+ import { MSG } from './protocol.js'
5
+
6
+ // Connect to a tunnel server and forward requests to a local HTTP server.
7
+ // Returns an EventEmitter-like handle with .close() and `onRegistered` callback.
8
+
9
+ export const createTunnelClient = (options = {}) => {
10
+ const {
11
+ host = process.env.TUNNEL_HOST || 'tunnel.symbo.ls',
12
+ port = 443,
13
+ secure = port === 443,
14
+ localPort,
15
+ localHost = '127.0.0.1',
16
+ subdomain = null,
17
+ token = process.env.TUNNEL_AUTH_TOKEN || null,
18
+ reconnect = true,
19
+ reconnectDelayMs = 2000,
20
+ onRegistered = () => {},
21
+ onError = (err) => console.error('[tunnel] error:', err.message || err),
22
+ onClose = () => {},
23
+ onRequest = () => {},
24
+ onResponse = () => {},
25
+ log = (...a) => console.log('[tunnel]', ...a)
26
+ } = options
27
+
28
+ if (!localPort) throw new Error('createTunnelClient: localPort is required')
29
+
30
+ let ws = null
31
+ let closed = false
32
+ const activeRequests = new Map() // reqId -> { req, chunks }
33
+
34
+ const buildUrl = () => {
35
+ const scheme = secure ? 'wss' : 'ws'
36
+ const params = new URLSearchParams()
37
+ if (subdomain) params.set('subdomain', subdomain)
38
+ const qs = params.toString()
39
+ return `${scheme}://${host}${port && port !== 80 && port !== 443 ? ':' + port : ''}/_tunnel${qs ? '?' + qs : ''}`
40
+ }
41
+
42
+ const send = (msg) => {
43
+ if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(msg))
44
+ }
45
+
46
+ const startLocalRequest = (msg) => {
47
+ const startedAt = Date.now()
48
+ onRequest({ id: msg.id, method: msg.method, url: msg.url, headers: msg.headers })
49
+ let bytes = 0
50
+ let status = 0
51
+ const localReq = http.request({
52
+ host: localHost,
53
+ port: localPort,
54
+ method: msg.method,
55
+ path: msg.url,
56
+ headers: { ...msg.headers, host: `${localHost}:${localPort}` }
57
+ }, (localRes) => {
58
+ status = localRes.statusCode
59
+ send({ type: MSG.RESPONSE, id: msg.id, status, headers: localRes.headers })
60
+ localRes.on('data', (chunk) => {
61
+ bytes += chunk.length
62
+ send({ type: MSG.RESPONSE_BODY, id: msg.id, chunk: chunk.toString('base64') })
63
+ })
64
+ localRes.on('end', () => {
65
+ send({ type: MSG.RESPONSE_END, id: msg.id })
66
+ activeRequests.delete(msg.id)
67
+ onResponse({ id: msg.id, method: msg.method, url: msg.url, status, bytes, durationMs: Date.now() - startedAt })
68
+ })
69
+ })
70
+ localReq.on('error', (err) => {
71
+ send({ type: MSG.RESPONSE, id: msg.id, status: 502, headers: { 'content-type': 'text/plain' } })
72
+ send({ type: MSG.RESPONSE_BODY, id: msg.id, chunk: Buffer.from(`Local server error: ${err.message}`).toString('base64') })
73
+ send({ type: MSG.RESPONSE_END, id: msg.id })
74
+ activeRequests.delete(msg.id)
75
+ onResponse({ id: msg.id, method: msg.method, url: msg.url, status: 502, bytes: 0, durationMs: Date.now() - startedAt, error: err.message })
76
+ })
77
+ activeRequests.set(msg.id, { localReq })
78
+ }
79
+
80
+ const connect = () => {
81
+ const url = buildUrl()
82
+ log(`connecting to ${url}`)
83
+ ws = new WebSocket(url, {
84
+ headers: token ? { 'x-tunnel-token': token } : {}
85
+ })
86
+
87
+ let pingTimer = null
88
+ ws.on('open', () => {
89
+ log('connected')
90
+ pingTimer = setInterval(() => {
91
+ if (ws.readyState === WebSocket.OPEN) send({ type: MSG.PING, id: 'keepalive' })
92
+ }, 25000)
93
+ })
94
+
95
+ ws.on('message', (raw) => {
96
+ let msg
97
+ try { msg = JSON.parse(raw.toString()) } catch { return }
98
+
99
+ if (msg.type === MSG.REGISTERED) {
100
+ log(`registered → ${msg.publicUrl}`)
101
+ onRegistered(msg)
102
+ return
103
+ }
104
+ if (msg.type === MSG.ERROR) {
105
+ onError(new Error(msg.message || 'tunnel error'))
106
+ return
107
+ }
108
+ if (msg.type === MSG.REQUEST) {
109
+ startLocalRequest(msg)
110
+ return
111
+ }
112
+ const entry = activeRequests.get(msg.id)
113
+ if (!entry) return
114
+ if (msg.type === MSG.REQUEST_BODY) {
115
+ if (msg.chunk) entry.localReq.write(Buffer.from(msg.chunk, 'base64'))
116
+ } else if (msg.type === MSG.REQUEST_END) {
117
+ entry.localReq.end()
118
+ }
119
+ })
120
+
121
+ ws.on('close', () => {
122
+ if (pingTimer) { clearInterval(pingTimer); pingTimer = null }
123
+ onClose()
124
+ if (closed || !reconnect) return
125
+ setTimeout(connect, reconnectDelayMs)
126
+ })
127
+
128
+ // Surface the HTTP error body when the WS upgrade is rejected (4xx/5xx).
129
+ // Without this, the user only sees "Unexpected server response: 403" with
130
+ // no indication of what to fix (e.g. missing <owner>-- prefix).
131
+ ws.on('unexpected-response', async (req, res) => {
132
+ let body = ''
133
+ try {
134
+ for await (const chunk of res) body += chunk.toString('utf8')
135
+ } catch { /* ignore read errors — we already have the status */ }
136
+ onError(new Error(`HTTP ${res.statusCode}: ${body.slice(0, 480) || '(empty body)'}`))
137
+ if (pingTimer) { clearInterval(pingTimer); pingTimer = null }
138
+ if (!closed && reconnect) setTimeout(connect, reconnectDelayMs)
139
+ })
140
+
141
+ ws.on('error', (err) => onError(err))
142
+ }
143
+
144
+ connect()
145
+
146
+ return {
147
+ close: () => {
148
+ closed = true
149
+ if (ws) ws.close()
150
+ }
151
+ }
152
+ }
@@ -0,0 +1,24 @@
1
+ export const MSG = {
2
+ REGISTER: 'register',
3
+ REGISTERED: 'registered',
4
+ ERROR: 'error',
5
+ REQUEST: 'request',
6
+ REQUEST_BODY: 'request_body',
7
+ REQUEST_END: 'request_end',
8
+ RESPONSE: 'response',
9
+ RESPONSE_BODY: 'response_body',
10
+ RESPONSE_END: 'response_end',
11
+ PING: 'ping',
12
+ PONG: 'pong'
13
+ }
14
+
15
+ export const randomId = () => Math.random().toString(36).slice(2) + Date.now().toString(36)
16
+
17
+ export const randomSubdomain = () => {
18
+ const adj = ['calm', 'bold', 'brisk', 'quick', 'bright', 'soft', 'warm', 'cool', 'keen', 'lush']
19
+ const noun = ['fox', 'owl', 'wolf', 'hawk', 'lark', 'wren', 'seal', 'deer', 'moth', 'cub']
20
+ const a = adj[Math.floor(Math.random() * adj.length)]
21
+ const n = noun[Math.floor(Math.random() * noun.length)]
22
+ const s = Math.random().toString(36).slice(2, 6)
23
+ return `${a}-${n}-${s}`
24
+ }
package/src/server.js ADDED
@@ -0,0 +1,177 @@
1
+ import http from 'node:http'
2
+ import { WebSocketServer } from 'ws'
3
+ import { URL } from 'node:url'
4
+ import { MSG, randomId, randomSubdomain } from './protocol.js'
5
+
6
+ // Server hosted at tunnel.symbo.ls.
7
+ // - HTTP requests to `<sub>.tunnel.symbo.ls` are proxied over a WebSocket
8
+ // connection held open by the CLI client that owns that subdomain.
9
+ // - Clients register by opening `wss://tunnel.symbo.ls/_tunnel?subdomain=<sub>`.
10
+
11
+ export const createTunnelServer = (options = {}) => {
12
+ const {
13
+ rootDomain = process.env.TUNNEL_ROOT_DOMAIN || 'tunnel.symbo.ls',
14
+ authToken = process.env.TUNNEL_AUTH_TOKEN || null,
15
+ requestTimeoutMs = 30000
16
+ } = options
17
+
18
+ const tunnels = new Map() // subdomain -> { ws, pending: Map<reqId, { res, bodyChunks }> }
19
+
20
+ const send = (ws, msg) => {
21
+ if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(msg))
22
+ }
23
+
24
+ const extractSubdomain = (hostHeader) => {
25
+ if (!hostHeader) return null
26
+ const host = hostHeader.split(':')[0].toLowerCase()
27
+ if (host === rootDomain) return null
28
+ if (!host.endsWith('.' + rootDomain)) return null
29
+ return host.slice(0, -('.' + rootDomain).length)
30
+ }
31
+
32
+ const httpServer = http.createServer((req, res) => {
33
+ const sub = extractSubdomain(req.headers.host)
34
+
35
+ if (!sub) {
36
+ if (req.url === '/health') {
37
+ res.writeHead(200, { 'content-type': 'application/json' })
38
+ res.end(JSON.stringify({ ok: true, tunnels: tunnels.size }))
39
+ return
40
+ }
41
+ res.writeHead(200, { 'content-type': 'text/plain' })
42
+ res.end(`tunnel.symbo.ls — point a CLI client at this host to expose localhost.\nActive tunnels: ${tunnels.size}\n`)
43
+ return
44
+ }
45
+
46
+ const tunnel = tunnels.get(sub)
47
+ if (!tunnel) {
48
+ res.writeHead(502, { 'content-type': 'text/plain' })
49
+ res.end(`No tunnel registered for ${sub}.${rootDomain}`)
50
+ return
51
+ }
52
+
53
+ const reqId = randomId()
54
+ const pending = { res, timeout: null }
55
+ tunnel.pending.set(reqId, pending)
56
+
57
+ pending.timeout = setTimeout(() => {
58
+ if (!tunnel.pending.has(reqId)) return
59
+ tunnel.pending.delete(reqId)
60
+ if (!res.headersSent) {
61
+ res.writeHead(504, { 'content-type': 'text/plain' })
62
+ res.end('Tunnel request timed out')
63
+ } else {
64
+ res.end()
65
+ }
66
+ }, requestTimeoutMs)
67
+
68
+ send(tunnel.ws, {
69
+ type: MSG.REQUEST,
70
+ id: reqId,
71
+ method: req.method,
72
+ url: req.url,
73
+ headers: req.headers
74
+ })
75
+
76
+ req.on('data', (chunk) => {
77
+ send(tunnel.ws, { type: MSG.REQUEST_BODY, id: reqId, chunk: chunk.toString('base64') })
78
+ })
79
+ req.on('end', () => send(tunnel.ws, { type: MSG.REQUEST_END, id: reqId }))
80
+ req.on('error', () => {
81
+ tunnel.pending.delete(reqId)
82
+ clearTimeout(pending.timeout)
83
+ })
84
+
85
+ res.on('close', () => {
86
+ tunnel.pending.delete(reqId)
87
+ clearTimeout(pending.timeout)
88
+ })
89
+ })
90
+
91
+ const wss = new WebSocketServer({ noServer: true })
92
+
93
+ httpServer.on('upgrade', (req, socket, head) => {
94
+ const url = new URL(req.url, `http://${req.headers.host}`)
95
+ if (url.pathname !== '/_tunnel') {
96
+ // Proxy websocket upgrades to subdomain owners.
97
+ const sub = extractSubdomain(req.headers.host)
98
+ const tunnel = sub && tunnels.get(sub)
99
+ if (!tunnel) {
100
+ socket.write('HTTP/1.1 502 Bad Gateway\r\n\r\n')
101
+ socket.destroy()
102
+ return
103
+ }
104
+ // Minimal: websocket-through-tunnel not implemented; reject for now.
105
+ socket.write('HTTP/1.1 501 Not Implemented\r\n\r\n')
106
+ socket.destroy()
107
+ return
108
+ }
109
+
110
+ if (authToken) {
111
+ const token = url.searchParams.get('token') || req.headers['x-tunnel-token']
112
+ if (token !== authToken) {
113
+ socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n')
114
+ socket.destroy()
115
+ return
116
+ }
117
+ }
118
+
119
+ wss.handleUpgrade(req, socket, head, (ws) => {
120
+ const requested = url.searchParams.get('subdomain')
121
+ let sub = requested && /^[a-z0-9-]{2,40}$/.test(requested) ? requested : randomSubdomain()
122
+ if (tunnels.has(sub)) sub = `${sub}-${Math.random().toString(36).slice(2, 5)}`
123
+
124
+ const tunnel = { ws, pending: new Map() }
125
+ tunnels.set(sub, tunnel)
126
+
127
+ send(ws, {
128
+ type: MSG.REGISTERED,
129
+ subdomain: sub,
130
+ publicUrl: `https://${sub}.${rootDomain}`
131
+ })
132
+
133
+ ws.on('message', (raw) => {
134
+ let msg
135
+ try { msg = JSON.parse(raw.toString()) } catch { return }
136
+ const pending = msg.id && tunnel.pending.get(msg.id)
137
+ if (!pending) return
138
+ const { res } = pending
139
+
140
+ if (msg.type === MSG.RESPONSE) {
141
+ if (!res.headersSent) res.writeHead(msg.status || 200, msg.headers || {})
142
+ } else if (msg.type === MSG.RESPONSE_BODY) {
143
+ if (msg.chunk) res.write(Buffer.from(msg.chunk, 'base64'))
144
+ } else if (msg.type === MSG.RESPONSE_END) {
145
+ clearTimeout(pending.timeout)
146
+ tunnel.pending.delete(msg.id)
147
+ res.end()
148
+ }
149
+ })
150
+
151
+ const cleanup = () => {
152
+ tunnels.delete(sub)
153
+ for (const [, p] of tunnel.pending) {
154
+ clearTimeout(p.timeout)
155
+ if (!p.res.headersSent) {
156
+ p.res.writeHead(502, { 'content-type': 'text/plain' })
157
+ p.res.end('Tunnel closed')
158
+ } else p.res.end()
159
+ }
160
+ tunnel.pending.clear()
161
+ }
162
+ ws.on('close', cleanup)
163
+ ws.on('error', cleanup)
164
+ })
165
+ })
166
+
167
+ return { httpServer, tunnels, rootDomain }
168
+ }
169
+
170
+ // Allow `node src/server.js` for local testing.
171
+ if (import.meta.url === `file://${process.argv[1]}`) {
172
+ const port = parseInt(process.env.PORT || '3000', 10)
173
+ const { httpServer, rootDomain } = createTunnelServer()
174
+ httpServer.listen(port, () => {
175
+ console.log(`tunnel server listening on :${port} (root: ${rootDomain})`)
176
+ })
177
+ }
@@ -0,0 +1,110 @@
1
+ // Cloudflare Worker entry for tunnel.symbo.ls.
2
+ // Routes:
3
+ // - `tunnel.symbo.ls/_tunnel?subdomain=X` (WS upgrade) → TunnelRoom DO named X
4
+ // - `<sub>.tunnel.symbo.ls/*` → TunnelRoom DO named <sub>
5
+ // - `tunnel.symbo.ls/health` → JSON ok
6
+ // - `tunnel.symbo.ls/*` → landing
7
+
8
+ import { randomSubdomain } from '../src/protocol.js'
9
+ export { TunnelRoom } from './tunnel-room.js'
10
+
11
+ // Validate a Symbols auth token via the Symbols API's /core/auth/me endpoint.
12
+ // Returns an array of lowercase owner slugs the user may claim, or null if
13
+ // the token is invalid. Results are cached in a Map (per-isolate) for 60s to
14
+ // avoid a round-trip per reconnect.
15
+ const authCache = new Map()
16
+ const AUTH_TTL_MS = 60_000
17
+
18
+ const authenticate = async (token, env) => {
19
+ const cached = authCache.get(token)
20
+ if (cached && cached.expires > Date.now()) return cached.owners
21
+
22
+ const apiBase = (env.SYMBOLS_API_URL || 'https://api.symbols.app').replace(/\/+$/, '')
23
+ const res = await fetch(`${apiBase}/core/auth/me`, {
24
+ headers: { Authorization: `Bearer ${token}` },
25
+ cf: { cacheTtl: 0 }
26
+ }).catch(() => null)
27
+ if (!res || !res.ok) return null
28
+
29
+ const data = await res.json().catch(() => null)
30
+ const user = data?.data?.user || data?.user || data?.data || data
31
+ if (!user) return null
32
+
33
+ const owners = new Set()
34
+ const add = (v) => { if (typeof v === 'string' && v.trim()) owners.add(v.toLowerCase()) }
35
+ add(user.username); add(user.handle); add(user.slug); add(user.ownerSlug); add(user.owner)
36
+ for (const o of user.owners || []) add(typeof o === 'string' ? o : (o?.slug || o?.key || o?.username))
37
+ for (const t of user.teams || []) add(typeof t === 'string' ? t : (t?.slug || t?.key))
38
+
39
+ if (!owners.size) return null
40
+ const list = [...owners]
41
+ authCache.set(token, { owners: list, expires: Date.now() + AUTH_TTL_MS })
42
+ return list
43
+ }
44
+
45
+ const extractSubdomain = (host, root) => {
46
+ if (!host) return null
47
+ host = host.split(':')[0].toLowerCase()
48
+ if (host === root) return null
49
+ if (!host.endsWith('.' + root)) return null
50
+ return host.slice(0, -('.' + root).length)
51
+ }
52
+
53
+ export default {
54
+ async fetch (request, env) {
55
+ const url = new URL(request.url)
56
+ const root = env.TUNNEL_ROOT_DOMAIN || 'tunnel.symbo.ls'
57
+ // In production, CF routes requests by Host header. For local `wrangler dev`
58
+ // Miniflare normalizes Host to `localhost`, so allow an `x-forwarded-host`
59
+ // override to test subdomain routing.
60
+ // In production, CF routes requests by real Host header. `x-forwarded-host`
61
+ // is honored for reverse-proxy / local-dev scenarios (Miniflare rewrites Host).
62
+ const host = (request.headers.get('x-forwarded-host') || request.headers.get('host') || url.hostname || '').toLowerCase()
63
+
64
+ // Client registration WS upgrade — authenticate and enforce owner scoping.
65
+ if (url.pathname === '/_tunnel' && request.headers.get('upgrade') === 'websocket') {
66
+ const token = url.searchParams.get('token') || request.headers.get('x-tunnel-token')
67
+ if (!token) return new Response('authentication required', { status: 401 })
68
+
69
+ const allowedOwners = await authenticate(token, env)
70
+ if (!allowedOwners) return new Response('invalid or expired token', { status: 401 })
71
+
72
+ const requested = (url.searchParams.get('subdomain') || '').toLowerCase()
73
+ const requestedOwner = requested.includes('--') ? requested.split('--', 1)[0] : null
74
+
75
+ let sub
76
+ if (requested) {
77
+ if (!/^[a-z0-9-]{2,63}$/.test(requested)) return new Response('invalid subdomain', { status: 400 })
78
+ if (!requestedOwner || !allowedOwners.includes(requestedOwner)) {
79
+ return new Response(
80
+ `forbidden: subdomain must start with one of your owner scopes (${allowedOwners.join(', ')}) followed by '--'`,
81
+ { status: 403 }
82
+ )
83
+ }
84
+ sub = requested
85
+ } else {
86
+ sub = `${allowedOwners[0]}--${randomSubdomain()}`
87
+ }
88
+
89
+ const id = env.TUNNEL_ROOM.idFromName(sub)
90
+ const stub = env.TUNNEL_ROOM.get(id)
91
+ const rewritten = new Request(`https://internal/_register?subdomain=${sub}&root=${encodeURIComponent(root)}`, request)
92
+ return stub.fetch(rewritten)
93
+ }
94
+
95
+ const sub = extractSubdomain(host, root)
96
+ if (!sub) {
97
+ if (url.pathname === '/health') {
98
+ return new Response(JSON.stringify({ ok: true }), { headers: { 'content-type': 'application/json' } })
99
+ }
100
+ return new Response(
101
+ `tunnel.symbo.ls — run \`smbls tunnel <port>\` to expose a local server.\n`,
102
+ { headers: { 'content-type': 'text/plain' } }
103
+ )
104
+ }
105
+
106
+ const id = env.TUNNEL_ROOM.idFromName(sub)
107
+ const stub = env.TUNNEL_ROOM.get(id)
108
+ return stub.fetch(request)
109
+ }
110
+ }
@@ -0,0 +1,151 @@
1
+ import { MSG, randomId } from '../src/protocol.js'
2
+
3
+ // One Durable Object per subdomain. Holds the client WebSocket connection
4
+ // and bridges incoming HTTP requests over JSON frames (see ../src/protocol.js).
5
+ //
6
+ // Uses the Hibernation API (`state.acceptWebSocket`) so the DO can be evicted
7
+ // while the CLI client's WS stays open. `this.pending` lives only while the
8
+ // DO is hot; if it wakes for a request and finds no client (`getWebSockets()`
9
+ // empty), we return 502.
10
+
11
+ export class TunnelRoom {
12
+ constructor (state, env) {
13
+ this.state = state
14
+ this.env = env
15
+ this.pending = new Map() // reqId -> { controller, resolveHead, headSent }
16
+ }
17
+
18
+ async fetch (request) {
19
+ const url = new URL(request.url)
20
+ if (url.pathname === '/_register') return this.register(request, url)
21
+
22
+ const [ws] = this.state.getWebSockets('client')
23
+ if (!ws) {
24
+ const sub = (await this.state.storage.get('subdomain')) || 'this subdomain'
25
+ return new Response(`No tunnel registered for ${sub}.`, { status: 502 })
26
+ }
27
+ return this.proxyRequest(request, ws)
28
+ }
29
+
30
+ async register (request, url) {
31
+ if (request.headers.get('upgrade') !== 'websocket') {
32
+ return new Response('expected websocket', { status: 426 })
33
+ }
34
+ const subdomain = url.searchParams.get('subdomain')
35
+ const root = url.searchParams.get('root') || 'tunnel.symbo.ls'
36
+ await this.state.storage.put('subdomain', subdomain)
37
+ await this.state.storage.put('root', root)
38
+
39
+ // Close any previously attached client — a new registration wins.
40
+ for (const existing of this.state.getWebSockets('client')) {
41
+ try { existing.close(1000, 'replaced') } catch {}
42
+ }
43
+
44
+ const pair = new WebSocketPair()
45
+ const [clientSide, serverSide] = [pair[0], pair[1]]
46
+ this.state.acceptWebSocket(serverSide, ['client'])
47
+
48
+ serverSide.send(JSON.stringify({
49
+ type: MSG.REGISTERED,
50
+ subdomain,
51
+ publicUrl: `https://${subdomain}.${root}`
52
+ }))
53
+
54
+ return new Response(null, { status: 101, webSocket: clientSide })
55
+ }
56
+
57
+ // Hibernation handlers — called by the runtime even after the DO was evicted.
58
+ async webSocketMessage (ws, data) {
59
+ let msg
60
+ try { msg = JSON.parse(typeof data === 'string' ? data : new TextDecoder().decode(data)) } catch { return }
61
+ if (msg.type === MSG.PING) {
62
+ ws.send(JSON.stringify({ type: MSG.PONG, id: msg.id }))
63
+ return
64
+ }
65
+ const entry = msg.id && this.pending.get(msg.id)
66
+ if (!entry) return
67
+ if (msg.type === MSG.RESPONSE) {
68
+ const stream = new ReadableStream({ start: (c) => { entry.controller = c } })
69
+ entry.headSent = true
70
+ entry.resolveHead(new Response(stream, {
71
+ status: msg.status || 200,
72
+ headers: sanitizeHeaders(msg.headers || {})
73
+ }))
74
+ } else if (msg.type === MSG.RESPONSE_BODY) {
75
+ if (msg.chunk && entry.controller) entry.controller.enqueue(base64ToBytes(msg.chunk))
76
+ } else if (msg.type === MSG.RESPONSE_END) {
77
+ try { entry.controller && entry.controller.close() } catch {}
78
+ this.pending.delete(msg.id)
79
+ }
80
+ }
81
+
82
+ async webSocketClose (_ws, _code, _reason, _clean) {
83
+ this.failAllPending('tunnel closed')
84
+ }
85
+
86
+ async webSocketError (_ws, _err) {
87
+ this.failAllPending('tunnel error')
88
+ }
89
+
90
+ failAllPending (reason) {
91
+ for (const [, p] of this.pending) {
92
+ if (!p.headSent) p.resolveHead(new Response(reason, { status: 502 }))
93
+ else { try { p.controller.error(new Error(reason)) } catch {} }
94
+ }
95
+ this.pending.clear()
96
+ }
97
+
98
+ async proxyRequest (request, ws) {
99
+ const id = randomId()
100
+ const url = new URL(request.url)
101
+ const headers = {}
102
+ for (const [k, v] of request.headers) headers[k] = v
103
+
104
+ const bodyBuf = ['GET', 'HEAD'].includes(request.method) ? null : new Uint8Array(await request.arrayBuffer())
105
+
106
+ const entry = { controller: null, headSent: false }
107
+ const headPromise = new Promise((resolve) => { entry.resolveHead = resolve })
108
+ this.pending.set(id, entry)
109
+
110
+ ws.send(JSON.stringify({
111
+ type: MSG.REQUEST, id,
112
+ method: request.method,
113
+ url: url.pathname + url.search,
114
+ headers
115
+ }))
116
+ if (bodyBuf && bodyBuf.byteLength) {
117
+ ws.send(JSON.stringify({ type: MSG.REQUEST_BODY, id, chunk: bytesToBase64(bodyBuf) }))
118
+ }
119
+ ws.send(JSON.stringify({ type: MSG.REQUEST_END, id }))
120
+
121
+ const timeout = new Promise((resolve) => setTimeout(() => {
122
+ if (this.pending.has(id)) {
123
+ this.pending.delete(id)
124
+ resolve(new Response('Tunnel request timed out', { status: 504 }))
125
+ }
126
+ }, 30000))
127
+
128
+ return Promise.race([headPromise, timeout])
129
+ }
130
+ }
131
+
132
+ const HOP_BY_HOP = new Set(['connection', 'keep-alive', 'transfer-encoding', 'upgrade', 'proxy-authenticate', 'proxy-authorization', 'te', 'trailer', 'content-length'])
133
+ const sanitizeHeaders = (h) => {
134
+ const out = {}
135
+ for (const [k, v] of Object.entries(h)) {
136
+ if (!HOP_BY_HOP.has(k.toLowerCase())) out[k] = v
137
+ }
138
+ return out
139
+ }
140
+
141
+ const bytesToBase64 = (bytes) => {
142
+ let s = ''
143
+ for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i])
144
+ return btoa(s)
145
+ }
146
+ const base64ToBytes = (b64) => {
147
+ const s = atob(b64)
148
+ const out = new Uint8Array(s.length)
149
+ for (let i = 0; i < s.length; i++) out[i] = s.charCodeAt(i)
150
+ return out
151
+ }
package/wrangler.toml ADDED
@@ -0,0 +1,49 @@
1
+ name = "symbols-tunnel"
2
+ main = "worker/index.js"
3
+ compatibility_date = "2025-01-01"
4
+ account_id = "98091ed46ea209bb648b108950bd78e7"
5
+
6
+ [vars]
7
+ TUNNEL_ROOT_DOMAIN = "tunnel.symbo.ls"
8
+ # Tokens issued by next.api.symbols.app verify here.
9
+ # For a main-channel deployment override this per-env (see [env.main] below).
10
+ SYMBOLS_API_URL = "https://next.api.symbols.app"
11
+
12
+ # Main-channel deployment: wrangler deploy --env main
13
+ [env.main.vars]
14
+ TUNNEL_ROOT_DOMAIN = "tunnel.symbo.ls"
15
+ SYMBOLS_API_URL = "https://api.symbols.app"
16
+
17
+ # Next-channel deployment (default above): wrangler deploy
18
+ [env.next.vars]
19
+ TUNNEL_ROOT_DOMAIN = "tunnel.symbo.ls"
20
+ SYMBOLS_API_URL = "https://next.api.symbols.app"
21
+
22
+ [[durable_objects.bindings]]
23
+ name = "TUNNEL_ROOM"
24
+ class_name = "TunnelRoom"
25
+
26
+ [[migrations]]
27
+ tag = "v1"
28
+ new_sqlite_classes = ["TunnelRoom"]
29
+
30
+ # Attach once the DNS + zone are in place:
31
+ # wrangler deploy
32
+ # Routes require *.tunnel.symbo.ls and tunnel.symbo.ls to be proxied through
33
+ # the Cloudflare zone for symbo.ls.
34
+ [[routes]]
35
+ pattern = "tunnel.symbo.ls"
36
+ custom_domain = true
37
+
38
+ # Explicit apex path route — more specific than `*.symbo.ls/*` (mermaid) so
39
+ # tunnel paths like `/_tunnel`, `/health` are not swallowed by other workers.
40
+ [[routes]]
41
+ pattern = "tunnel.symbo.ls/*"
42
+ zone_name = "symbo.ls"
43
+
44
+ # Wildcard subdomains require a wildcard DNS record (`*.tunnel` → proxied)
45
+ # in the Cloudflare zone. Once present, this route activates for every
46
+ # `<sub>.tunnel.symbo.ls`.
47
+ [[routes]]
48
+ pattern = "*.tunnel.symbo.ls/*"
49
+ zone_name = "symbo.ls"