Skip to content

Commit eb89a10

Browse files
committed
support multiple tunnels
1 parent fa1a056 commit eb89a10

File tree

2 files changed

+79
-30
lines changed

2 files changed

+79
-30
lines changed

worker/src/index.ts

Lines changed: 78 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,17 @@ import { toHex, fromHex } from "../../shared/hex";
44

55
const RESPONSE_TIMEOUT_MS = 30_000; // 30 seconds
66

7+
type Stats = {
8+
isConnected: boolean;
9+
requests: number;
10+
};
11+
712
export class MyDurableObject extends DurableObject {
813
// TODO: think of using a WeakMap
914
proxyTo: WebSocket | null = null;
1015
resolve: ((value: Response) => void) | null = null;
1116
reject: ((value: Error) => void) | null = null;
17+
requests: number = 0;
1218
constructor(ctx: DurableObjectState, env: Env) {
1319
super(ctx, env);
1420
}
@@ -48,6 +54,7 @@ export class MyDurableObject extends DurableObject {
4854
});
4955

5056
server.addEventListener("close", (cls: CloseEvent) => {
57+
this.proxyTo = null;
5158
this.reject?.(new Error("server closed"));
5259
console.info("closing connection", cls.code, cls.reason);
5360
server.close(1001, `server closed (${cls.code}: ${cls.reason})`);
@@ -60,6 +67,7 @@ export class MyDurableObject extends DurableObject {
6067
}
6168

6269
async proxy(request: Request): Promise<Response> {
70+
this.requests++;
6371
console.info("proxying request", request.url);
6472
if (!this.proxyTo) {
6573
return new Response("no proxy connection", { status: 502 });
@@ -121,14 +129,39 @@ export class MyDurableObject extends DurableObject {
121129
this.proxyTo = null;
122130
return true;
123131
}
132+
133+
async stats(): Promise<Stats> {
134+
return {
135+
isConnected: !!this.proxyTo,
136+
requests: this.requests,
137+
};
138+
}
124139
}
125140

126-
const DO_NAME = "foo";
141+
function getTunnelId(path: string): string {
142+
const [, , uuid] = path.split("/");
143+
if (!uuid) {
144+
throw new Error("Missing tunnel URL");
145+
}
146+
if (uuid.length !== 36) {
147+
throw new Error("Invalid tunnel URL");
148+
}
149+
return uuid;
150+
}
127151

128152
export default {
129153
async fetch(request, env, ctx): Promise<Response> {
130154
const url = new URL(request.url);
131-
if (url.pathname == "/tunnel") {
155+
if (url.pathname.startsWith("/tunnel/")) {
156+
const tunnelId = getTunnelId(url.pathname);
157+
const doId = env.MY_DURABLE_OBJECT.idFromName(tunnelId);
158+
const stub = env.MY_DURABLE_OBJECT.get(doId);
159+
const stats = await stub.stats();
160+
161+
return new Response(tunnelPage(url.origin, tunnelId, stats), {
162+
headers: { "content-type": "text/html" },
163+
});
164+
} else if (url.pathname.startsWith("/connect/")) {
132165
// Expect to receive a WebSocket Upgrade request.
133166
// If there is one, accept the request and return a WebSocket Response.
134167
const upgradeHeader = request.headers.get("Upgrade");
@@ -138,32 +171,30 @@ export default {
138171
});
139172
}
140173

141-
const id = env.MY_DURABLE_OBJECT.idFromName(DO_NAME);
142-
143-
// Create a stub to open a communication channel with the Durable
144-
// Object instance.
145-
const stub = env.MY_DURABLE_OBJECT.get(id);
146-
174+
const tunnelId = getTunnelId(url.pathname);
175+
const doId = env.MY_DURABLE_OBJECT.idFromName(tunnelId);
176+
const stub = env.MY_DURABLE_OBJECT.get(doId);
147177
return stub.fetch(request);
148178
} else if (url.pathname.startsWith("/proxy/")) {
149-
const id = env.MY_DURABLE_OBJECT.idFromName(DO_NAME);
150-
151-
// Create a stub to open a communication channel with the Durable
152-
// Object instance.
153-
const stub = env.MY_DURABLE_OBJECT.get(id);
179+
const tunnelId = getTunnelId(url.pathname);
180+
const doId = env.MY_DURABLE_OBJECT.idFromName(tunnelId);
181+
const stub = env.MY_DURABLE_OBJECT.get(doId);
154182
return stub.proxy(request);
155-
} else if (url.pathname == "/close") {
156-
const id = env.MY_DURABLE_OBJECT.idFromName(DO_NAME);
157-
const stub = env.MY_DURABLE_OBJECT.get(id);
183+
} else if (url.pathname.startsWith("/close/")) {
184+
const tunnelId = getTunnelId(url.pathname);
185+
const doId = env.MY_DURABLE_OBJECT.idFromName(tunnelId);
186+
const stub = env.MY_DURABLE_OBJECT.get(doId);
158187

159188
return new Response(
160-
(await stub.close()) ? "Closed connection" : "No proxy connection",
189+
(await stub.close())
190+
? "Closed connection"
191+
: "No proxy connection found, all good.",
161192
{
162193
headers: { "cache-control": "no-cache, no-store, max-age=0" },
163194
},
164195
);
165196
} else if (url.pathname == "/") {
166-
return new Response(indexPage(url.origin), {
197+
return new Response(homePage(), {
167198
headers: { "content-type": "text/html" },
168199
});
169200
}
@@ -185,34 +216,52 @@ function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
185216
});
186217
}
187218

188-
function indexPage(origin: string): string {
219+
function homePage(): string {
220+
const uuid = crypto.randomUUID();
189221
return `
190222
<!doctype html>
191223
<html lang="en">
192224
<head>
193225
<meta charset="UTF-8" />
194226
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
195-
<title>Hello, World!</title>
196-
<link
197-
rel="stylesheet"
198-
href="/pico.min.css"
199-
>
227+
<title>Webhooks Proxy Tunnel</title>
228+
<link rel="stylesheet" href="/pico.min.css">
200229
</head>
201230
<body>
202231
<main class="container">
203232
<h1>Webhooks Proxy Tunnel</h1>
204-
<p>Use <a href="https://github.com/peter-leonov/webhooks-proxy-tunnel">Webhooks Proxy Tunnel</a> to proxy HTTP requests made to the public URL to your project local web server.</p>
205-
<p>Public URL: <code>${origin}/proxy/</code></p>
206-
<p>Tunnel URL: <code>${origin}/tunnel</code></p>
233+
<p>Use Webhooks Proxy Tunnel (<a href="https://github.com/peter-leonov/webhooks-proxy-tunnel">GitHub</a>) to proxy HTTP requests made to the public URL to your project local web server.</p>
234+
<p>Here is your very personal tunnel: <a href="/tunnel/${uuid}">${uuid}</a> (refresh the page for a new one).</p>
235+
</body>
236+
</html>`;
237+
}
238+
239+
function tunnelPage(origin: string, tunnelId: string, stats: Stats): string {
240+
return `
241+
<!doctype html>
242+
<html lang="en">
243+
<head>
244+
<meta charset="UTF-8" />
245+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
246+
<title>Webhooks Proxy Tunnel / ${tunnelId}</title>
247+
<link rel="stylesheet" href="/pico.min.css">
248+
</head>
249+
<body>
250+
<main class="container">
251+
<h1>Tunnel ${tunnelId}</h1>
252+
<p>Connected: ${stats.isConnected ? "yes" : "no"}</p>
253+
<p>Requests: ${stats.requests}</p>
254+
<p>Public URL: <code>${origin}/proxy/${tunnelId}</code></p>
255+
<p>Connect URL: <code>${origin}/connect/${tunnelId}</code></p>
207256
<p>
208257
Local server URL: <input type="text" value="http://localhost:3000" id="target-input" />
209258
Client command:
210259
<pre><code>cd webhooks-proxy-tunnel/client
211-
npm start -- ${origin}/tunnel <span id="target-span">http://localhost:3000</span>
260+
npm start -- ${origin}/connect/${tunnelId} <span id="target-span">http://localhost:3000</span>
212261
</code></pre>
213262
Connecting a new client kicks out the currently connected one.
214263
</p>
215-
<p>Force <a href="/close">close</a> the tunnel if the connected client is stuck.</p>
264+
<p>Force <a href="/close/${tunnelId}">close</a> the tunnel if the connected client got stuck.</p>
216265
</main>
217266
<script>
218267
const targetInput = document.getElementById("target-input");

worker/wrangler.jsonc

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
"$schema": "node_modules/wrangler/config-schema.json",
77
"name": "webhooks-proxy-tunnel",
88
"main": "src/index.ts",
9-
"compatibility_date": "2025-04-06",
9+
"compatibility_date": "2025-04-04",
1010
"migrations": [
1111
{
1212
"new_sqlite_classes": ["MyDurableObject"],

0 commit comments

Comments
 (0)