@volter/tunnel 1.0.1 → 1.1.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/README.md CHANGED
@@ -79,6 +79,23 @@ npm install && node server.mjs
79
79
 
80
80
  Client and relay must share the same `TUNNEL_SECRET`.
81
81
 
82
+ ## Server — scalable relay (Cloudflare Workers + Durable Objects)
83
+
84
+ `server/` (the Fly relay) is a single stateful process and does not scale
85
+ horizontally. For large/global scale there is a drop-in alternative in
86
+ `server-cf/`: a Cloudflare **Worker** that routes by subdomain to **one Durable
87
+ Object per tunnelId**, which holds the client's control socket (hibernatable, so
88
+ idle tunnels are ~free). Same wire protocol and client — the only client-visible
89
+ change is that `createTunnel` appends `?id=<tunnelId>` to the control URL so the
90
+ Worker can pick the right DO (the Fly relay tolerates and ignores it).
91
+
92
+ ```bash
93
+ cd server-cf
94
+ npm run dev # wrangler dev --local (real workerd)
95
+ npm test # vitest: real Worker+DO + createTunnel, no mocks
96
+ npm run deploy # wrangler deploy (needs a CF zone for the wildcard host)
97
+ ```
98
+
82
99
  ## Layout
83
100
 
84
101
  ```
@@ -14,7 +14,11 @@ import https from 'node:https';
14
14
  import net from 'node:net';
15
15
  import WebSocket from 'ws';
16
16
 
17
- /** Force HTTP/1.1 for WebSocket connections — HTTP/2 breaks the Upgrade handshake. */
17
+ /**
18
+ * Force HTTP/1.1 for the (TLS) control WebSocket — HTTP/2 breaks the Upgrade
19
+ * handshake. Only applies to wss:// (an https.Agent rejects a plain ws:// URL),
20
+ * so it's used conditionally; plain ws:// (e.g. a local relay) needs no agent.
21
+ */
18
22
  const http1Agent = new https.Agent({ ALPNProtocols: ['http/1.1'] });
19
23
 
20
24
  /** Minimal logger interface for tunnel client — compatible with pino, console, etc. */
@@ -266,7 +270,14 @@ export function createTunnel({
266
270
  logger,
267
271
  }: TunnelOptions): Promise<TunnelHandle> {
268
272
  const log = logger ?? defaultLogger;
269
- const wsUrl = `${host.replace(/^http/, 'ws')}/ws`;
273
+ // Include the tunnelId in the control URL so a routing relay (e.g. the
274
+ // Cloudflare Workers + Durable Objects relay) can pick the right backend at
275
+ // upgrade time, before the `register` message is sent. The Fly relay ignores
276
+ // the query param and reads tunnelId from `register`, so one client works
277
+ // against both servers.
278
+ const wsUrl =
279
+ `${host.replace(/^http/, 'ws')}/ws` +
280
+ (tunnelId ? `?id=${encodeURIComponent(tunnelId)}` : '');
270
281
 
271
282
  // Resolved loopback address for this port (set before first connection)
272
283
  let localHost = '127.0.0.1';
@@ -285,7 +296,7 @@ export function createTunnel({
285
296
  ): void {
286
297
  if (closed) return;
287
298
 
288
- const ws = new WebSocket(wsUrl, { agent: http1Agent });
299
+ const ws = new WebSocket(wsUrl, wsUrl.startsWith('wss:') ? { agent: http1Agent } : {});
289
300
  let registered = false;
290
301
  // Hoisted so every teardown path (a reconnect via the 'close' handler, or an
291
302
  // explicit handle.close()) can clear it. It used to be declared inside the
@@ -834,8 +845,13 @@ if (import.meta.main) {
834
845
  process.exit(1);
835
846
  }
836
847
 
848
+ // Default to the Cloudflare Workers + Durable Objects relay. The old Fly relay
849
+ // (vgit-tunnels.volterapp.com) returns HTTP 200 instead of 101 on HTTP/2
850
+ // WebSocket upgrades, which breaks browser-based flows (e.g. QA proofs).
837
851
  const host =
838
- flag('host') || process.env.TUNNEL_SERVER_URL || 'https://vgit-tunnels.volterapp.com';
852
+ flag('host') ||
853
+ process.env.TUNNEL_SERVER_URL ||
854
+ 'https://volter-tunnel.aaron-0ed.workers.dev';
839
855
  const secret = process.env.TUNNEL_SECRET;
840
856
  const tunnelId = flag('tunnel-id');
841
857
  const authNotRequired = args.includes('--auth-not-required');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/tunnel",
3
- "version": "1.0.1",
3
+ "version": "1.1.1",
4
4
  "description": "Volter tunnel — WebSocket-based HTTP/WS reverse tunnel: a relay server plus a client connector library and CLI.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -21,6 +21,8 @@
21
21
  ],
22
22
  "scripts": {
23
23
  "typecheck": "tsc --noEmit",
24
+ "test": "bun test ./test",
25
+ "test:cf": "cd server-cf && vitest run",
24
26
  "tunnel": "bun run client/tunnel-client.ts",
25
27
  "server": "node server/server.mjs"
26
28
  },
@@ -36,8 +38,10 @@
36
38
  }
37
39
  },
38
40
  "devDependencies": {
41
+ "@types/jsonwebtoken": "^9.0.0",
39
42
  "@types/node": "^22.0.0",
40
43
  "@types/ws": "^8.5.14",
44
+ "jsonwebtoken": "^9.0.2",
41
45
  "typescript": "^5.0.0"
42
46
  }
43
47
  }
package/server/server.mjs CHANGED
@@ -422,8 +422,10 @@ server.on('upgrade', (req, socket, head) => {
422
422
 
423
423
  const tunnelId = getTunnelIdFromHost(req.headers.host);
424
424
 
425
- // No subdomain + /ws path → control channel for tunnel clients
426
- if (!tunnelId && req.url === '/ws') {
425
+ // No subdomain + /ws path → control channel for tunnel clients.
426
+ // Tolerate a query string (the client appends ?id=<tunnelId> so a routing
427
+ // relay can pick its backend; this server reads tunnelId from `register`).
428
+ if (!tunnelId && req.url && req.url.split('?')[0] === '/ws') {
427
429
  wss.handleUpgrade(req, socket, head, (ws) => {
428
430
  wss.emit('connection', ws, req);
429
431
  });