@dainprotocol/tunnel 1.1.35 → 2.0.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/dist/client/index.d.ts +1 -6
- package/dist/client/index.js +120 -184
- package/dist/server/index.d.ts +9 -5
- package/dist/server/index.js +517 -371
- package/dist/server/start.js +4 -13
- package/package.json +9 -24
package/dist/server/index.js
CHANGED
|
@@ -1,20 +1,12 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
};
|
|
5
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
-
const express_1 = __importDefault(require("express"));
|
|
7
|
-
const http_1 = __importDefault(require("http"));
|
|
8
|
-
const ws_1 = __importDefault(require("ws"));
|
|
9
|
-
const body_parser_1 = __importDefault(require("body-parser"));
|
|
10
|
-
const auth_1 = require("@dainprotocol/service-sdk/service/auth");
|
|
11
|
-
const crypto_1 = require("crypto");
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { parseAPIKey } from "@dainprotocol/service-sdk/service/auth";
|
|
3
|
+
import { createHmac, timingSafeEqual } from "crypto";
|
|
12
4
|
const TIMEOUTS = {
|
|
13
5
|
CHALLENGE_TTL: 30000,
|
|
14
6
|
PING_INTERVAL: 30000,
|
|
15
7
|
REQUEST_TIMEOUT: 30000,
|
|
16
8
|
SSE_KEEPALIVE: 5000,
|
|
17
|
-
TUNNEL_RETRY_DELAY: 20,
|
|
9
|
+
TUNNEL_RETRY_DELAY: 20,
|
|
18
10
|
SERVER_KEEPALIVE: 65000,
|
|
19
11
|
SERVER_HEADERS: 66000,
|
|
20
12
|
};
|
|
@@ -23,17 +15,16 @@ const LIMITS = {
|
|
|
23
15
|
MAX_MISSED_PONGS: 2,
|
|
24
16
|
MAX_PAYLOAD_BYTES: 100 * 1024 * 1024,
|
|
25
17
|
BACKPRESSURE_THRESHOLD: 1024 * 1024,
|
|
18
|
+
MAX_PENDING_WS_EVENTS_PER_CONNECTION: 256,
|
|
26
19
|
SERVER_MAX_HEADERS: 100,
|
|
27
20
|
WS_BACKLOG: 100,
|
|
28
|
-
TUNNEL_RETRY_COUNT: 2,
|
|
21
|
+
TUNNEL_RETRY_COUNT: 2,
|
|
29
22
|
};
|
|
30
|
-
// Fast ID generator - no crypto needed for internal IDs
|
|
31
23
|
let idCounter = 0;
|
|
32
24
|
const ID_PREFIX = Date.now().toString(36);
|
|
33
25
|
function fastId() {
|
|
34
26
|
return `${ID_PREFIX}-${(++idCounter).toString(36)}`;
|
|
35
27
|
}
|
|
36
|
-
// Fast path extraction - avoids URL parsing overhead
|
|
37
28
|
function extractPathParts(url) {
|
|
38
29
|
if (!url)
|
|
39
30
|
return [];
|
|
@@ -41,31 +32,45 @@ function extractPathParts(url) {
|
|
|
41
32
|
const path = qIdx >= 0 ? url.slice(0, qIdx) : url;
|
|
42
33
|
return path.split('/');
|
|
43
34
|
}
|
|
44
|
-
function rawDataToString(message) {
|
|
45
|
-
if (typeof message === "string")
|
|
46
|
-
return message;
|
|
47
|
-
if (Buffer.isBuffer(message))
|
|
48
|
-
return message.toString("utf8");
|
|
49
|
-
if (Array.isArray(message))
|
|
50
|
-
return Buffer.concat(message).toString("utf8");
|
|
51
|
-
return Buffer.from(message).toString("utf8");
|
|
52
|
-
}
|
|
53
|
-
function rawDataToBase64(data) {
|
|
54
|
-
if (typeof data === "string")
|
|
55
|
-
return Buffer.from(data, "utf8").toString("base64");
|
|
56
|
-
if (Buffer.isBuffer(data))
|
|
57
|
-
return data.toString("base64");
|
|
58
|
-
if (Array.isArray(data))
|
|
59
|
-
return Buffer.concat(data).toString("base64");
|
|
60
|
-
return Buffer.from(data).toString("base64");
|
|
61
|
-
}
|
|
62
35
|
function normalizeHostToBaseUrl(hostname) {
|
|
63
36
|
return /^https?:\/\//i.test(hostname) ? hostname : `http://${hostname}`;
|
|
64
37
|
}
|
|
38
|
+
function parseAllowedCorsOrigins(raw) {
|
|
39
|
+
if (!raw)
|
|
40
|
+
return null;
|
|
41
|
+
const origins = raw
|
|
42
|
+
.split(",")
|
|
43
|
+
.map((origin) => origin.trim().toLowerCase())
|
|
44
|
+
.filter(Boolean);
|
|
45
|
+
return origins.length > 0 ? new Set(origins) : null;
|
|
46
|
+
}
|
|
47
|
+
function isCorsOriginAllowed(origin, allowedOrigins) {
|
|
48
|
+
if (!allowedOrigins)
|
|
49
|
+
return true;
|
|
50
|
+
if (allowedOrigins.has("*"))
|
|
51
|
+
return true;
|
|
52
|
+
return allowedOrigins.has(origin.trim().toLowerCase());
|
|
53
|
+
}
|
|
54
|
+
const ALLOWED_CORS_ORIGINS = parseAllowedCorsOrigins(process.env.TUNNEL_ALLOWED_ORIGINS || process.env.CORS_ALLOWED_ORIGINS);
|
|
65
55
|
class DainTunnelServer {
|
|
56
|
+
buildCorsHeaders(origin) {
|
|
57
|
+
const headers = {
|
|
58
|
+
"access-control-allow-methods": "GET, POST, PUT, DELETE, PATCH, OPTIONS",
|
|
59
|
+
"access-control-allow-headers": "X-DAIN-SIGNATURE, X-DAIN-SMART-ACCOUNT-PDA, X-DAIN-AGENT-ID, X-DAIN-ORG-ID, X-DAIN-ADDRESS, X-DAIN-TIMESTAMP, X-DAIN-WEBHOOK-URL, X-Service, X-Service-Token, Content-Type, Authorization, Accept, Origin, X-Requested-With",
|
|
60
|
+
};
|
|
61
|
+
if (origin && isCorsOriginAllowed(origin, this.allowedCorsOrigins)) {
|
|
62
|
+
headers["access-control-allow-origin"] = origin;
|
|
63
|
+
headers["access-control-allow-credentials"] = "true";
|
|
64
|
+
headers["vary"] = "Origin";
|
|
65
|
+
}
|
|
66
|
+
else if (!origin) {
|
|
67
|
+
headers["access-control-allow-origin"] = "*";
|
|
68
|
+
}
|
|
69
|
+
return headers;
|
|
70
|
+
}
|
|
66
71
|
safeSend(ws, data) {
|
|
67
72
|
try {
|
|
68
|
-
if (ws.readyState ===
|
|
73
|
+
if (ws.readyState === 1) { // WebSocket.OPEN
|
|
69
74
|
ws.send(JSON.stringify(data));
|
|
70
75
|
return true;
|
|
71
76
|
}
|
|
@@ -103,55 +108,43 @@ class DainTunnelServer {
|
|
|
103
108
|
constructor(hostname, port) {
|
|
104
109
|
this.hostname = hostname;
|
|
105
110
|
this.port = port;
|
|
111
|
+
this.server = null;
|
|
112
|
+
this.allowedCorsOrigins = ALLOWED_CORS_ORIGINS;
|
|
106
113
|
this.tunnels = new Map();
|
|
107
114
|
this.pendingRequests = new Map();
|
|
108
115
|
this.challenges = new Map();
|
|
109
116
|
this.sseConnections = new Map();
|
|
110
117
|
this.wsConnections = new Map();
|
|
118
|
+
this.pendingProxiedWSEvents = new Map();
|
|
111
119
|
this.tunnelRequestCount = new Map();
|
|
112
|
-
this.app =
|
|
113
|
-
this.
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
this.
|
|
118
|
-
|
|
119
|
-
path: undefined,
|
|
120
|
-
backlog: LIMITS.WS_BACKLOG,
|
|
121
|
-
perMessageDeflate: false,
|
|
122
|
-
maxPayload: LIMITS.MAX_PAYLOAD_BYTES,
|
|
123
|
-
});
|
|
124
|
-
this.app.use((req, res, next) => {
|
|
125
|
-
const origin = req.headers.origin;
|
|
120
|
+
this.app = new Hono();
|
|
121
|
+
this.setupRoutes();
|
|
122
|
+
}
|
|
123
|
+
setupRoutes() {
|
|
124
|
+
// CORS middleware
|
|
125
|
+
this.app.use("*", async (c, next) => {
|
|
126
|
+
const origin = c.req.header("origin");
|
|
126
127
|
if (origin) {
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
128
|
+
if (!isCorsOriginAllowed(origin, this.allowedCorsOrigins)) {
|
|
129
|
+
return c.json({ error: "Origin not allowed" }, 403);
|
|
130
|
+
}
|
|
131
|
+
c.header("Access-Control-Allow-Origin", origin);
|
|
132
|
+
c.header("Vary", "Origin");
|
|
133
|
+
c.header("Access-Control-Allow-Credentials", "true");
|
|
130
134
|
}
|
|
131
135
|
else {
|
|
132
|
-
|
|
136
|
+
c.header("Access-Control-Allow-Origin", "*");
|
|
133
137
|
}
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
if (req.method === "OPTIONS")
|
|
137
|
-
return
|
|
138
|
-
next();
|
|
139
|
-
});
|
|
140
|
-
this.app.use((req, res, next) => {
|
|
141
|
-
if (req.method === 'GET' || req.method === 'HEAD' || req.method === 'OPTIONS') {
|
|
142
|
-
return next();
|
|
138
|
+
c.header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, PATCH, OPTIONS");
|
|
139
|
+
c.header("Access-Control-Allow-Headers", "X-DAIN-SIGNATURE, X-DAIN-SMART-ACCOUNT-PDA, X-DAIN-AGENT-ID, X-DAIN-ORG-ID, X-DAIN-ADDRESS, X-DAIN-TIMESTAMP, X-DAIN-WEBHOOK-URL, X-Service, X-Service-Token, Content-Type, Authorization, Accept, Origin, X-Requested-With");
|
|
140
|
+
if (c.req.method === "OPTIONS") {
|
|
141
|
+
return c.body(null, 204);
|
|
143
142
|
}
|
|
144
|
-
|
|
143
|
+
await next();
|
|
145
144
|
});
|
|
146
|
-
|
|
147
|
-
this.
|
|
148
|
-
|
|
149
|
-
setupExpressRoutes() {
|
|
150
|
-
this.app.get("/", (req, res, next) => {
|
|
151
|
-
var _a;
|
|
152
|
-
if (((_a = req.headers.upgrade) === null || _a === void 0 ? void 0 : _a.toLowerCase()) === 'websocket')
|
|
153
|
-
return next();
|
|
154
|
-
res.status(200).json({
|
|
145
|
+
// Health/status at root
|
|
146
|
+
this.app.get("/", (c) => {
|
|
147
|
+
return c.json({
|
|
155
148
|
status: "healthy",
|
|
156
149
|
tunnels: this.tunnels.size,
|
|
157
150
|
pendingRequests: this.pendingRequests.size,
|
|
@@ -160,102 +153,87 @@ class DainTunnelServer {
|
|
|
160
153
|
uptime: process.uptime()
|
|
161
154
|
});
|
|
162
155
|
});
|
|
163
|
-
this.app.get("/health", (
|
|
164
|
-
|
|
156
|
+
this.app.get("/health", (c) => {
|
|
157
|
+
return c.json({
|
|
165
158
|
status: "ok",
|
|
166
159
|
tunnels: this.tunnels.size,
|
|
167
160
|
pendingRequests: this.pendingRequests.size,
|
|
168
161
|
uptime: process.uptime()
|
|
169
162
|
});
|
|
170
163
|
});
|
|
171
|
-
|
|
164
|
+
// Tunnel request handler - all methods
|
|
165
|
+
this.app.all("/:tunnelId/*", (c) => this.handleRequest(c));
|
|
166
|
+
this.app.all("/:tunnelId", (c) => this.handleRequest(c));
|
|
172
167
|
}
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
168
|
+
// --- WebSocket handlers for Bun.serve ---
|
|
169
|
+
handleWsOpen(ws) {
|
|
170
|
+
// Nothing needed on open for tunnel clients; proxied WS connections
|
|
171
|
+
// are set up via the data attached during upgrade
|
|
172
|
+
}
|
|
173
|
+
handleWsMessage(ws, message) {
|
|
174
|
+
const data = ws.data;
|
|
175
|
+
if (data.isProxiedWebSocket) {
|
|
176
|
+
// This is a proxied WebSocket client connection
|
|
177
|
+
this.handleProxiedWebSocketClientMessage(ws, message);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
// This is a tunnel client connection
|
|
181
|
+
try {
|
|
182
|
+
const msgStr = typeof message === "string" ? message : Buffer.from(message).toString("utf8");
|
|
183
|
+
const parsed = JSON.parse(msgStr);
|
|
184
|
+
switch (parsed.type) {
|
|
185
|
+
case "challenge_request":
|
|
186
|
+
this.handleChallengeRequest(ws);
|
|
187
|
+
break;
|
|
188
|
+
case "start":
|
|
189
|
+
this.handleStartMessage(ws, parsed);
|
|
190
|
+
break;
|
|
191
|
+
case "response":
|
|
192
|
+
this.handleResponseMessage(parsed);
|
|
193
|
+
break;
|
|
194
|
+
case "sse":
|
|
195
|
+
this.handleSSEMessage(parsed);
|
|
196
|
+
break;
|
|
197
|
+
case "websocket":
|
|
198
|
+
this.handleWebSocketMessage(parsed);
|
|
199
|
+
break;
|
|
188
200
|
}
|
|
189
|
-
}
|
|
201
|
+
}
|
|
202
|
+
catch (error) {
|
|
203
|
+
console.error("[Tunnel] Message error:", error);
|
|
204
|
+
ws.close(1008, "Invalid message");
|
|
205
|
+
}
|
|
190
206
|
}
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
case "start":
|
|
200
|
-
this.handleStartMessage(ws, data);
|
|
201
|
-
break;
|
|
202
|
-
case "response":
|
|
203
|
-
this.handleResponseMessage(data);
|
|
204
|
-
break;
|
|
205
|
-
case "sse":
|
|
206
|
-
this.handleSSEMessage(data);
|
|
207
|
-
break;
|
|
208
|
-
case "websocket":
|
|
209
|
-
this.handleWebSocketMessage(data);
|
|
210
|
-
break;
|
|
207
|
+
handleWsClose(ws, code, reason) {
|
|
208
|
+
const data = ws.data;
|
|
209
|
+
if (data.isProxiedWebSocket) {
|
|
210
|
+
// Proxied WS client disconnected
|
|
211
|
+
if (data.proxiedWsId && data.proxiedTunnelId) {
|
|
212
|
+
const tunnel = this.tunnels.get(data.proxiedTunnelId);
|
|
213
|
+
if (tunnel) {
|
|
214
|
+
this.safeSend(tunnel.ws, { type: "websocket", id: data.proxiedWsId, event: "close" });
|
|
211
215
|
}
|
|
216
|
+
this.wsConnections.delete(data.proxiedWsId);
|
|
212
217
|
}
|
|
213
|
-
catch (error) {
|
|
214
|
-
console.error("[Tunnel] Message error:", error);
|
|
215
|
-
ws.close(1008, "Invalid message");
|
|
216
|
-
}
|
|
217
|
-
});
|
|
218
|
-
ws.on("close", () => this.removeTunnel(ws));
|
|
219
|
-
ws.on("error", (error) => console.error("[Tunnel] WS error:", error));
|
|
220
|
-
}
|
|
221
|
-
handleProxiedWebSocketConnection(ws, req) {
|
|
222
|
-
const pathParts = extractPathParts(req.url);
|
|
223
|
-
if (pathParts.length < 2) {
|
|
224
|
-
ws.close(1008, "Invalid tunnel ID");
|
|
225
218
|
return;
|
|
226
219
|
}
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
220
|
+
// Tunnel client disconnected
|
|
221
|
+
this.removeTunnel(ws);
|
|
222
|
+
}
|
|
223
|
+
// --- Proxied WebSocket handling ---
|
|
224
|
+
handleProxiedWebSocketClientMessage(ws, message) {
|
|
225
|
+
const { proxiedWsId, proxiedTunnelId } = ws.data;
|
|
226
|
+
if (!proxiedWsId || !proxiedTunnelId)
|
|
232
227
|
return;
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
headers: forwardedHeaders,
|
|
241
|
-
tunnelId
|
|
242
|
-
});
|
|
243
|
-
this.safeSend(tunnel.ws, {
|
|
244
|
-
type: "websocket_connection",
|
|
245
|
-
id: wsConnectionId,
|
|
246
|
-
path: remainingPath,
|
|
247
|
-
headers: forwardedHeaders
|
|
248
|
-
});
|
|
249
|
-
const sendToTunnel = (event, data) => {
|
|
250
|
-
const currentTunnel = this.tunnels.get(tunnelId);
|
|
251
|
-
if (currentTunnel) {
|
|
252
|
-
this.safeSend(currentTunnel.ws, { type: "websocket", id: wsConnectionId, event, data });
|
|
253
|
-
}
|
|
254
|
-
};
|
|
255
|
-
ws.on("message", (data) => sendToTunnel("message", rawDataToBase64(data)));
|
|
256
|
-
ws.on("close", () => { sendToTunnel("close"); this.wsConnections.delete(wsConnectionId); });
|
|
257
|
-
ws.on("error", (error) => sendToTunnel("error", error.message));
|
|
228
|
+
const tunnel = this.tunnels.get(proxiedTunnelId);
|
|
229
|
+
if (!tunnel)
|
|
230
|
+
return;
|
|
231
|
+
const base64Data = typeof message === "string"
|
|
232
|
+
? Buffer.from(message, "utf8").toString("base64")
|
|
233
|
+
: Buffer.from(message).toString("base64");
|
|
234
|
+
this.safeSend(tunnel.ws, { type: "websocket", id: proxiedWsId, event: "message", data: base64Data });
|
|
258
235
|
}
|
|
236
|
+
// --- Tunnel client message handlers ---
|
|
259
237
|
handleChallengeRequest(ws) {
|
|
260
238
|
const challenge = fastId();
|
|
261
239
|
this.challenges.set(challenge, { ws, challenge, timestamp: Date.now() });
|
|
@@ -275,16 +253,16 @@ class DainTunnelServer {
|
|
|
275
253
|
ws.close(1008, "API key required");
|
|
276
254
|
return;
|
|
277
255
|
}
|
|
278
|
-
const parsed =
|
|
256
|
+
const parsed = parseAPIKey(apiKey);
|
|
279
257
|
if (!parsed) {
|
|
280
258
|
ws.close(1008, "Invalid API key format");
|
|
281
259
|
return;
|
|
282
260
|
}
|
|
283
|
-
const expectedSignature =
|
|
261
|
+
const expectedSignature = createHmac('sha256', parsed.secret).update(challenge).digest('hex');
|
|
284
262
|
const expectedSigBuffer = Buffer.from(expectedSignature, 'hex');
|
|
285
263
|
const receivedSigBuffer = Buffer.from(signature, 'hex');
|
|
286
264
|
if (expectedSigBuffer.length !== receivedSigBuffer.length ||
|
|
287
|
-
!
|
|
265
|
+
!timingSafeEqual(expectedSigBuffer, receivedSigBuffer)) {
|
|
288
266
|
ws.close(1008, "Invalid signature");
|
|
289
267
|
return;
|
|
290
268
|
}
|
|
@@ -298,19 +276,20 @@ class DainTunnelServer {
|
|
|
298
276
|
existingTunnel.ws.close(1000, "Replaced by new connection");
|
|
299
277
|
}
|
|
300
278
|
this.tunnels.set(tunnelId, { id: tunnelId, ws });
|
|
301
|
-
ws.tunnelId = tunnelId;
|
|
279
|
+
ws.data.tunnelId = tunnelId;
|
|
302
280
|
let isAlive = true;
|
|
303
281
|
let missedPongs = 0;
|
|
304
|
-
|
|
282
|
+
// Bun ServerWebSocket doesn't have pong events or ping() method the same way.
|
|
283
|
+
// Use a simple send-based keepalive check instead.
|
|
305
284
|
const intervalId = setInterval(() => {
|
|
306
285
|
if (!this.tunnels.has(tunnelId)) {
|
|
307
286
|
clearInterval(intervalId);
|
|
308
287
|
return;
|
|
309
288
|
}
|
|
310
289
|
const tunnel = this.tunnels.get(tunnelId);
|
|
311
|
-
if (!tunnel || tunnel.ws !== ws || ws.readyState !==
|
|
290
|
+
if (!tunnel || tunnel.ws !== ws || ws.readyState !== 1) {
|
|
312
291
|
clearInterval(intervalId);
|
|
313
|
-
if (
|
|
292
|
+
if (tunnel?.ws === ws)
|
|
314
293
|
this.tunnels.delete(tunnelId);
|
|
315
294
|
return;
|
|
316
295
|
}
|
|
@@ -319,24 +298,29 @@ class DainTunnelServer {
|
|
|
319
298
|
if (missedPongs >= LIMITS.MAX_MISSED_PONGS) {
|
|
320
299
|
console.log(`[Tunnel] ${tunnelId} failed liveness check (${missedPongs} missed pongs), terminating`);
|
|
321
300
|
clearInterval(intervalId);
|
|
322
|
-
ws.
|
|
301
|
+
ws.close(1001, "Liveness check failed");
|
|
323
302
|
return;
|
|
324
303
|
}
|
|
325
304
|
}
|
|
326
305
|
isAlive = false;
|
|
327
306
|
try {
|
|
307
|
+
// Bun supports ws.ping() on ServerWebSocket
|
|
328
308
|
ws.ping();
|
|
329
309
|
}
|
|
330
|
-
catch
|
|
310
|
+
catch {
|
|
331
311
|
clearInterval(intervalId);
|
|
332
|
-
ws.
|
|
312
|
+
ws.close(1001, "Ping failed");
|
|
333
313
|
}
|
|
334
314
|
}, TIMEOUTS.PING_INTERVAL);
|
|
335
|
-
ws.keepAliveInterval = intervalId;
|
|
336
|
-
|
|
315
|
+
ws.data.keepAliveInterval = intervalId;
|
|
316
|
+
// Bun's pong is received via the websocket handler pong callback
|
|
317
|
+
// We track isAlive through the pong handler set up in Bun.serve websocket config
|
|
337
318
|
const tunnelUrl = this.buildTunnelUrl(tunnelId);
|
|
338
319
|
ws.send(JSON.stringify({ type: "tunnelUrl", url: tunnelUrl }));
|
|
339
320
|
console.log(`[Tunnel] Created: ${tunnelUrl}`);
|
|
321
|
+
// Store isAlive/missedPongs on ws.data for the pong handler
|
|
322
|
+
ws.data._isAlive = true;
|
|
323
|
+
ws.data._missedPongsReset = () => { isAlive = true; missedPongs = 0; };
|
|
340
324
|
}
|
|
341
325
|
catch (error) {
|
|
342
326
|
console.error(`Error in handleStartMessage for tunnel ${tunnelId}:`, error);
|
|
@@ -347,66 +331,91 @@ class DainTunnelServer {
|
|
|
347
331
|
const pendingRequest = this.pendingRequests.get(data.requestId);
|
|
348
332
|
if (!pendingRequest)
|
|
349
333
|
return;
|
|
350
|
-
const {
|
|
334
|
+
const { resolve, tunnelId, timeoutId, origin } = pendingRequest;
|
|
351
335
|
this.pendingRequests.delete(data.requestId);
|
|
352
336
|
if (timeoutId)
|
|
353
337
|
clearTimeout(timeoutId);
|
|
354
338
|
this.decrementRequestCount(tunnelId);
|
|
355
|
-
// Modify headers in place instead of spreading
|
|
356
339
|
const headers = data.headers;
|
|
357
340
|
delete headers["transfer-encoding"];
|
|
358
341
|
delete headers["content-length"];
|
|
342
|
+
// Inject CORS headers — Hono middleware doesn't apply to Promise-resolved responses
|
|
343
|
+
Object.assign(headers, this.buildCorsHeaders(origin));
|
|
359
344
|
const bodyBuffer = Buffer.from(data.body, "base64");
|
|
360
|
-
|
|
345
|
+
headers["content-length"] = bodyBuffer.length.toString();
|
|
346
|
+
resolve(new Response(bodyBuffer, {
|
|
347
|
+
status: data.status,
|
|
348
|
+
headers,
|
|
349
|
+
}));
|
|
361
350
|
}
|
|
362
351
|
handleSSEMessage(data) {
|
|
363
352
|
const connection = this.sseConnections.get(data.id);
|
|
364
|
-
if (!connection)
|
|
353
|
+
if (!connection || connection.closed)
|
|
365
354
|
return;
|
|
366
|
-
const { res, tunnelId } = connection;
|
|
367
355
|
if (data.event === 'connected')
|
|
368
356
|
return;
|
|
369
357
|
if (data.event === 'close') {
|
|
370
358
|
try {
|
|
371
|
-
|
|
359
|
+
connection.controller.close();
|
|
372
360
|
}
|
|
373
|
-
catch
|
|
374
|
-
|
|
361
|
+
catch { }
|
|
362
|
+
connection.closed = true;
|
|
363
|
+
this.cleanupSSEConnection(data.id, connection.tunnelId);
|
|
375
364
|
return;
|
|
376
365
|
}
|
|
377
366
|
if (data.event === 'error') {
|
|
378
367
|
try {
|
|
379
|
-
|
|
380
|
-
|
|
368
|
+
connection.controller.enqueue(new TextEncoder().encode(`event: error\ndata: ${data.data}\n\n`));
|
|
369
|
+
connection.controller.close();
|
|
381
370
|
}
|
|
382
|
-
catch
|
|
383
|
-
|
|
371
|
+
catch { }
|
|
372
|
+
connection.closed = true;
|
|
373
|
+
this.cleanupSSEConnection(data.id, connection.tunnelId);
|
|
384
374
|
return;
|
|
385
375
|
}
|
|
386
376
|
try {
|
|
387
|
-
// Batch write SSE event in single call
|
|
388
377
|
const lines = data.data.split('\n');
|
|
389
378
|
const message = (data.event ? `event: ${data.event}\n` : '') +
|
|
390
|
-
lines.map(line => `data: ${line}`).join('\n') + '\n\n';
|
|
391
|
-
|
|
379
|
+
lines.map((line) => `data: ${line}`).join('\n') + '\n\n';
|
|
380
|
+
connection.controller.enqueue(new TextEncoder().encode(message));
|
|
392
381
|
}
|
|
393
|
-
catch
|
|
394
|
-
this.cleanupSSEConnection(data.id, tunnelId);
|
|
382
|
+
catch {
|
|
383
|
+
this.cleanupSSEConnection(data.id, connection.tunnelId);
|
|
395
384
|
}
|
|
396
385
|
}
|
|
397
386
|
cleanupSSEConnection(id, tunnelId) {
|
|
398
387
|
const connection = this.sseConnections.get(id);
|
|
399
|
-
if (connection
|
|
388
|
+
if (connection?.keepAliveInterval)
|
|
400
389
|
clearInterval(connection.keepAliveInterval);
|
|
401
390
|
this.decrementRequestCount(tunnelId);
|
|
402
391
|
this.sseConnections.delete(id);
|
|
403
392
|
}
|
|
404
393
|
handleWebSocketMessage(data) {
|
|
405
394
|
const connection = this.wsConnections.get(data.id);
|
|
406
|
-
if (!connection)
|
|
395
|
+
if (!connection) {
|
|
396
|
+
const pendingEvents = this.pendingProxiedWSEvents.get(data.id);
|
|
397
|
+
if (!pendingEvents)
|
|
398
|
+
return;
|
|
399
|
+
if (data.event === "message" &&
|
|
400
|
+
pendingEvents.length >= LIMITS.MAX_PENDING_WS_EVENTS_PER_CONNECTION) {
|
|
401
|
+
pendingEvents.push({
|
|
402
|
+
type: "websocket",
|
|
403
|
+
id: data.id,
|
|
404
|
+
event: "error",
|
|
405
|
+
data: "WebSocket downstream overloaded",
|
|
406
|
+
});
|
|
407
|
+
pendingEvents.push({
|
|
408
|
+
type: "websocket",
|
|
409
|
+
id: data.id,
|
|
410
|
+
event: "close",
|
|
411
|
+
});
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
pendingEvents.push(data);
|
|
407
415
|
return;
|
|
416
|
+
}
|
|
408
417
|
const { clientSocket } = connection;
|
|
409
|
-
const isOpen = clientSocket.readyState ===
|
|
418
|
+
const isOpen = clientSocket.readyState === 1;
|
|
410
419
|
switch (data.event) {
|
|
411
420
|
case 'message':
|
|
412
421
|
if (data.data && isOpen)
|
|
@@ -424,17 +433,24 @@ class DainTunnelServer {
|
|
|
424
433
|
break;
|
|
425
434
|
}
|
|
426
435
|
}
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
436
|
+
flushPendingWebSocketEvents(id) {
|
|
437
|
+
const pendingEvents = this.pendingProxiedWSEvents.get(id);
|
|
438
|
+
if (!pendingEvents || pendingEvents.length === 0) {
|
|
439
|
+
this.pendingProxiedWSEvents.delete(id);
|
|
431
440
|
return;
|
|
441
|
+
}
|
|
442
|
+
this.pendingProxiedWSEvents.delete(id);
|
|
443
|
+
for (const event of pendingEvents) {
|
|
444
|
+
this.handleWebSocketMessage(event);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
async handleRequest(c) {
|
|
448
|
+
const tunnelId = c.req.param("tunnelId");
|
|
432
449
|
if (!tunnelId || !tunnelId.includes("_")) {
|
|
433
|
-
|
|
450
|
+
return c.json({
|
|
434
451
|
error: "Not Found",
|
|
435
452
|
message: `Tunnel "${tunnelId}" does not exist.`,
|
|
436
|
-
});
|
|
437
|
-
return;
|
|
453
|
+
}, 404);
|
|
438
454
|
}
|
|
439
455
|
let tunnel;
|
|
440
456
|
let retries = LIMITS.TUNNEL_RETRY_COUNT;
|
|
@@ -446,142 +462,180 @@ class DainTunnelServer {
|
|
|
446
462
|
}
|
|
447
463
|
}
|
|
448
464
|
if (!tunnel) {
|
|
449
|
-
|
|
465
|
+
return c.json({
|
|
450
466
|
error: "Bad Gateway",
|
|
451
467
|
message: `Tunnel "${tunnelId}" not connected. The service may be offline or reconnecting.`,
|
|
452
468
|
availableTunnels: this.tunnels.size
|
|
453
|
-
});
|
|
454
|
-
return;
|
|
455
|
-
}
|
|
456
|
-
if (tunnel.ws.bufferedAmount > LIMITS.BACKPRESSURE_THRESHOLD) {
|
|
457
|
-
res.status(503).json({ error: "Service Unavailable", message: "Tunnel high load" });
|
|
458
|
-
return;
|
|
469
|
+
}, 502);
|
|
459
470
|
}
|
|
460
471
|
const currentCount = this.tunnelRequestCount.get(tunnelId) || 0;
|
|
461
472
|
if (currentCount >= LIMITS.MAX_CONCURRENT_REQUESTS_PER_TUNNEL) {
|
|
462
|
-
|
|
463
|
-
return;
|
|
473
|
+
return c.json({ error: "Service Unavailable", message: "Too many concurrent requests" }, 503);
|
|
464
474
|
}
|
|
465
475
|
this.tunnelRequestCount.set(tunnelId, currentCount + 1);
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
return;
|
|
476
|
+
// Check for SSE
|
|
477
|
+
if (c.req.header("accept")?.includes('text/event-stream')) {
|
|
478
|
+
return this.handleSSERequest(c, tunnelId, tunnel);
|
|
469
479
|
}
|
|
480
|
+
// Regular HTTP request
|
|
470
481
|
const requestId = fastId();
|
|
471
|
-
|
|
472
|
-
const
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
this.decrementRequestCount(tunnelId);
|
|
476
|
-
this.pendingRequests.delete(requestId);
|
|
477
|
-
if (!res.headersSent) {
|
|
478
|
-
res.status(504).json({ error: "Gateway Timeout", message: "Request timed out" });
|
|
479
|
-
}
|
|
480
|
-
}
|
|
481
|
-
}, TIMEOUTS.REQUEST_TIMEOUT);
|
|
482
|
-
this.pendingRequests.set(requestId, { res, startTime, tunnelId, timeoutId });
|
|
483
|
-
const hasBody = req.method !== "GET" && req.method !== "HEAD" &&
|
|
484
|
-
req.body && Buffer.isBuffer(req.body) && req.body.length > 0;
|
|
485
|
-
const sent = this.safeSend(tunnel.ws, {
|
|
486
|
-
type: "request",
|
|
487
|
-
id: requestId,
|
|
488
|
-
method: req.method,
|
|
489
|
-
path: req.url,
|
|
490
|
-
headers: this.buildForwardedHeaders(req.headers, tunnelId),
|
|
491
|
-
body: hasBody ? req.body.toString("base64") : undefined,
|
|
482
|
+
// Extract headers as a plain object
|
|
483
|
+
const reqHeaders = {};
|
|
484
|
+
c.req.raw.headers.forEach((value, key) => {
|
|
485
|
+
reqHeaders[key] = value;
|
|
492
486
|
});
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
487
|
+
const forwardedHeaders = this.buildForwardedHeaders(reqHeaders, tunnelId);
|
|
488
|
+
// Get the path relative to the tunnel ID (everything after /:tunnelId)
|
|
489
|
+
const fullUrl = new URL(c.req.url);
|
|
490
|
+
const tunnelPrefix = `/${tunnelId}`;
|
|
491
|
+
let path = fullUrl.pathname.slice(tunnelPrefix.length) || '/';
|
|
492
|
+
if (fullUrl.search)
|
|
493
|
+
path += fullUrl.search;
|
|
494
|
+
// Read body for non-GET/HEAD methods
|
|
495
|
+
let bodyBase64;
|
|
496
|
+
const method = c.req.method;
|
|
497
|
+
if (method !== "GET" && method !== "HEAD") {
|
|
498
|
+
try {
|
|
499
|
+
const bodyBuffer = await c.req.arrayBuffer();
|
|
500
|
+
if (bodyBuffer.byteLength > 0) {
|
|
501
|
+
bodyBase64 = Buffer.from(bodyBuffer).toString("base64");
|
|
502
|
+
}
|
|
499
503
|
}
|
|
504
|
+
catch { }
|
|
500
505
|
}
|
|
506
|
+
const origin = c.req.header("origin");
|
|
507
|
+
const corsHeaders = this.buildCorsHeaders(origin);
|
|
508
|
+
return new Promise((resolve) => {
|
|
509
|
+
const timeoutId = setTimeout(() => {
|
|
510
|
+
const pendingRequest = this.pendingRequests.get(requestId);
|
|
511
|
+
if (pendingRequest) {
|
|
512
|
+
this.decrementRequestCount(tunnelId);
|
|
513
|
+
this.pendingRequests.delete(requestId);
|
|
514
|
+
resolve(new Response(JSON.stringify({ error: "Gateway Timeout", message: "Request timed out" }), {
|
|
515
|
+
status: 504,
|
|
516
|
+
headers: { "content-type": "application/json", ...corsHeaders },
|
|
517
|
+
}));
|
|
518
|
+
}
|
|
519
|
+
}, TIMEOUTS.REQUEST_TIMEOUT);
|
|
520
|
+
this.pendingRequests.set(requestId, { resolve, startTime: Date.now(), tunnelId, timeoutId, origin });
|
|
521
|
+
const sent = this.safeSend(tunnel.ws, {
|
|
522
|
+
type: "request",
|
|
523
|
+
id: requestId,
|
|
524
|
+
method,
|
|
525
|
+
path,
|
|
526
|
+
headers: forwardedHeaders,
|
|
527
|
+
body: bodyBase64,
|
|
528
|
+
});
|
|
529
|
+
if (!sent) {
|
|
530
|
+
clearTimeout(timeoutId);
|
|
531
|
+
this.pendingRequests.delete(requestId);
|
|
532
|
+
this.decrementRequestCount(tunnelId);
|
|
533
|
+
resolve(new Response(JSON.stringify({ error: "Bad Gateway", message: "Tunnel connection lost" }), {
|
|
534
|
+
status: 502,
|
|
535
|
+
headers: { "content-type": "application/json", ...corsHeaders },
|
|
536
|
+
}));
|
|
537
|
+
}
|
|
538
|
+
});
|
|
501
539
|
}
|
|
502
|
-
handleSSERequest(
|
|
503
|
-
var _a, _b;
|
|
540
|
+
async handleSSERequest(c, tunnelId, tunnel) {
|
|
504
541
|
const sseId = fastId();
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
542
|
+
const requestUrl = new URL(c.req.url);
|
|
543
|
+
let path = requestUrl.pathname.slice(`/${tunnelId}`.length) || "/";
|
|
544
|
+
if (requestUrl.search)
|
|
545
|
+
path += requestUrl.search;
|
|
546
|
+
// Extract headers
|
|
547
|
+
const reqHeaders = {};
|
|
548
|
+
c.req.raw.headers.forEach((value, key) => {
|
|
549
|
+
reqHeaders[key] = value;
|
|
550
|
+
});
|
|
551
|
+
const forwardedHeaders = this.buildForwardedHeaders(reqHeaders, tunnelId);
|
|
552
|
+
let bodyBase64;
|
|
553
|
+
if (c.req.method !== "GET" && c.req.method !== "HEAD") {
|
|
554
|
+
try {
|
|
555
|
+
const bodyBuffer = await c.req.arrayBuffer();
|
|
556
|
+
if (bodyBuffer.byteLength > 0) {
|
|
557
|
+
bodyBase64 = Buffer.from(bodyBuffer).toString("base64");
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
catch {
|
|
561
|
+
// Best-effort body forwarding for streaming requests
|
|
562
|
+
}
|
|
508
563
|
}
|
|
509
|
-
const origin = req.
|
|
510
|
-
const
|
|
564
|
+
const origin = c.req.header("origin");
|
|
565
|
+
const responseHeaders = {
|
|
511
566
|
'Content-Type': 'text/event-stream',
|
|
512
567
|
'Cache-Control': 'no-cache, no-transform',
|
|
513
568
|
'Connection': 'keep-alive',
|
|
514
569
|
'X-Accel-Buffering': 'no',
|
|
515
570
|
'X-Content-Type-Options': 'nosniff',
|
|
516
571
|
'Content-Encoding': 'identity',
|
|
517
|
-
'Transfer-Encoding': 'chunked',
|
|
518
572
|
};
|
|
519
|
-
if (
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
573
|
+
if (origin) {
|
|
574
|
+
responseHeaders['Access-Control-Allow-Origin'] = origin;
|
|
575
|
+
responseHeaders['Access-Control-Allow-Credentials'] = 'true';
|
|
576
|
+
responseHeaders['Vary'] = 'Origin';
|
|
523
577
|
}
|
|
524
578
|
else {
|
|
525
|
-
|
|
526
|
-
}
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
579
|
+
responseHeaders['Access-Control-Allow-Origin'] = '*';
|
|
580
|
+
}
|
|
581
|
+
const encoder = new TextEncoder();
|
|
582
|
+
let sseController;
|
|
583
|
+
const stream = new ReadableStream({
|
|
584
|
+
start: (controller) => {
|
|
585
|
+
sseController = controller;
|
|
586
|
+
// Send initial keepalive
|
|
587
|
+
controller.enqueue(encoder.encode(':keepalive\n\n'));
|
|
588
|
+
const keepAliveInterval = setInterval(() => {
|
|
589
|
+
const conn = this.sseConnections.get(sseId);
|
|
590
|
+
if (!conn || conn.closed) {
|
|
591
|
+
clearInterval(keepAliveInterval);
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
try {
|
|
595
|
+
controller.enqueue(encoder.encode(':keepalive\n\n'));
|
|
596
|
+
}
|
|
597
|
+
catch {
|
|
598
|
+
clearInterval(keepAliveInterval);
|
|
599
|
+
}
|
|
600
|
+
}, TIMEOUTS.SSE_KEEPALIVE);
|
|
601
|
+
this.sseConnections.set(sseId, {
|
|
602
|
+
controller,
|
|
603
|
+
id: sseId,
|
|
604
|
+
tunnelId,
|
|
605
|
+
keepAliveInterval,
|
|
606
|
+
closed: false,
|
|
607
|
+
});
|
|
608
|
+
const sent = this.safeSend(tunnel.ws, {
|
|
609
|
+
type: "sse_connection",
|
|
610
|
+
id: sseId,
|
|
611
|
+
path,
|
|
612
|
+
method: c.req.method,
|
|
613
|
+
headers: forwardedHeaders,
|
|
614
|
+
body: bodyBase64,
|
|
615
|
+
});
|
|
616
|
+
if (!sent) {
|
|
617
|
+
this.cleanupSSEConnection(sseId, tunnelId);
|
|
618
|
+
try {
|
|
619
|
+
controller.close();
|
|
620
|
+
}
|
|
621
|
+
catch { }
|
|
540
622
|
}
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
}
|
|
547
|
-
}, TIMEOUTS.SSE_KEEPALIVE);
|
|
548
|
-
this.sseConnections.set(sseId, { req, res, id: sseId, tunnelId, keepAliveInterval });
|
|
549
|
-
const hasBody = req.method !== "GET" && req.body &&
|
|
550
|
-
Buffer.isBuffer(req.body) && req.body.length > 0;
|
|
551
|
-
const sent = this.safeSend(tunnel.ws, {
|
|
552
|
-
type: "sse_connection",
|
|
553
|
-
id: sseId,
|
|
554
|
-
path: req.url,
|
|
555
|
-
method: req.method,
|
|
556
|
-
headers: this.buildForwardedHeaders(req.headers, tunnelId),
|
|
557
|
-
body: hasBody ? req.body.toString("base64") : undefined
|
|
623
|
+
},
|
|
624
|
+
cancel: () => {
|
|
625
|
+
// Client disconnected
|
|
626
|
+
this.safeSend(tunnel.ws, { type: "sse_close", id: sseId });
|
|
627
|
+
this.cleanupSSEConnection(sseId, tunnelId);
|
|
628
|
+
},
|
|
558
629
|
});
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
return;
|
|
563
|
-
}
|
|
564
|
-
let cleanedUp = false;
|
|
565
|
-
const doCleanup = () => {
|
|
566
|
-
if (cleanedUp)
|
|
567
|
-
return;
|
|
568
|
-
cleanedUp = true;
|
|
569
|
-
this.safeSend(tunnel.ws, { type: "sse_close", id: sseId });
|
|
570
|
-
this.cleanupSSEConnection(sseId, tunnelId);
|
|
571
|
-
};
|
|
572
|
-
req.on('close', () => {
|
|
573
|
-
var _a;
|
|
574
|
-
if (((_a = req.socket) === null || _a === void 0 ? void 0 : _a.destroyed) || res.writableEnded)
|
|
575
|
-
doCleanup();
|
|
630
|
+
return new Response(stream, {
|
|
631
|
+
status: 200,
|
|
632
|
+
headers: responseHeaders,
|
|
576
633
|
});
|
|
577
|
-
(_b = req.socket) === null || _b === void 0 ? void 0 : _b.on('close', doCleanup);
|
|
578
|
-
req.on('error', doCleanup);
|
|
579
|
-
res.on('error', doCleanup);
|
|
580
634
|
}
|
|
581
635
|
removeTunnel(ws) {
|
|
582
636
|
try {
|
|
583
|
-
if (ws.keepAliveInterval)
|
|
584
|
-
clearInterval(ws.keepAliveInterval);
|
|
637
|
+
if (ws.data.keepAliveInterval)
|
|
638
|
+
clearInterval(ws.data.keepAliveInterval);
|
|
585
639
|
const removedTunnelId = this.findAndRemoveTunnel(ws);
|
|
586
640
|
if (removedTunnelId) {
|
|
587
641
|
this.tunnelRequestCount.delete(removedTunnelId);
|
|
@@ -596,10 +650,10 @@ class DainTunnelServer {
|
|
|
596
650
|
}
|
|
597
651
|
}
|
|
598
652
|
findAndRemoveTunnel(ws) {
|
|
599
|
-
const tunnelId = ws.tunnelId;
|
|
653
|
+
const tunnelId = ws.data.tunnelId;
|
|
600
654
|
if (tunnelId && this.tunnels.has(tunnelId)) {
|
|
601
655
|
const tunnel = this.tunnels.get(tunnelId);
|
|
602
|
-
if (
|
|
656
|
+
if (tunnel?.ws === ws) {
|
|
603
657
|
this.tunnels.delete(tunnelId);
|
|
604
658
|
return tunnelId;
|
|
605
659
|
}
|
|
@@ -615,14 +669,15 @@ class DainTunnelServer {
|
|
|
615
669
|
cleanupPendingRequests(tunnelId) {
|
|
616
670
|
for (const [requestId, pending] of this.pendingRequests.entries()) {
|
|
617
671
|
if (pending.tunnelId === tunnelId) {
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
672
|
+
if (pending.timeoutId)
|
|
673
|
+
clearTimeout(pending.timeoutId);
|
|
674
|
+
pending.resolve(new Response(JSON.stringify({ error: "Bad Gateway", message: "Tunnel closed" }), {
|
|
675
|
+
status: 502,
|
|
676
|
+
headers: {
|
|
677
|
+
"content-type": "application/json",
|
|
678
|
+
...this.buildCorsHeaders(pending.origin),
|
|
679
|
+
},
|
|
680
|
+
}));
|
|
626
681
|
this.pendingRequests.delete(requestId);
|
|
627
682
|
}
|
|
628
683
|
}
|
|
@@ -633,9 +688,10 @@ class DainTunnelServer {
|
|
|
633
688
|
if (conn.keepAliveInterval)
|
|
634
689
|
clearInterval(conn.keepAliveInterval);
|
|
635
690
|
try {
|
|
636
|
-
conn.
|
|
691
|
+
conn.controller.close();
|
|
637
692
|
}
|
|
638
|
-
catch
|
|
693
|
+
catch { }
|
|
694
|
+
conn.closed = true;
|
|
639
695
|
this.sseConnections.delete(sseId);
|
|
640
696
|
}
|
|
641
697
|
}
|
|
@@ -646,7 +702,7 @@ class DainTunnelServer {
|
|
|
646
702
|
try {
|
|
647
703
|
conn.clientSocket.close(1001, "Tunnel closed");
|
|
648
704
|
}
|
|
649
|
-
catch
|
|
705
|
+
catch { }
|
|
650
706
|
this.wsConnections.delete(wsId);
|
|
651
707
|
}
|
|
652
708
|
}
|
|
@@ -659,78 +715,168 @@ class DainTunnelServer {
|
|
|
659
715
|
}
|
|
660
716
|
}
|
|
661
717
|
async start() {
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
718
|
+
const self = this;
|
|
719
|
+
this.server = Bun.serve({
|
|
720
|
+
port: this.port,
|
|
721
|
+
hostname: "0.0.0.0",
|
|
722
|
+
fetch(req, server) {
|
|
723
|
+
const url = new URL(req.url);
|
|
724
|
+
// Check for WebSocket upgrade
|
|
725
|
+
if (req.headers.get('upgrade')?.toLowerCase() === 'websocket') {
|
|
726
|
+
const pathParts = url.pathname.split('/').filter(Boolean);
|
|
727
|
+
if (pathParts.length === 0) {
|
|
728
|
+
// Root WebSocket = tunnel client connection
|
|
729
|
+
const success = server.upgrade(req, {
|
|
730
|
+
data: {
|
|
731
|
+
isProxiedWebSocket: false,
|
|
732
|
+
tunnelId: undefined,
|
|
733
|
+
keepAliveInterval: undefined,
|
|
734
|
+
},
|
|
735
|
+
});
|
|
736
|
+
if (success)
|
|
737
|
+
return undefined;
|
|
738
|
+
return new Response("WebSocket upgrade failed", { status: 400 });
|
|
739
|
+
}
|
|
740
|
+
// Proxied WebSocket = /:tunnelId/...
|
|
741
|
+
const tunnelId = pathParts[0];
|
|
742
|
+
const remainingPath = '/' + pathParts.slice(1).join('/');
|
|
743
|
+
const tunnel = self.tunnels.get(tunnelId);
|
|
744
|
+
if (!tunnel) {
|
|
745
|
+
return new Response("Tunnel not found", { status: 404 });
|
|
677
746
|
}
|
|
678
|
-
|
|
679
|
-
|
|
747
|
+
const wsConnectionId = fastId();
|
|
748
|
+
const reqHeaders = {};
|
|
749
|
+
req.headers.forEach((value, key) => {
|
|
750
|
+
reqHeaders[key] = value;
|
|
751
|
+
});
|
|
752
|
+
const forwardedHeaders = self.buildForwardedHeaders(reqHeaders, tunnelId);
|
|
753
|
+
self._pendingProxiedWs = self._pendingProxiedWs || new Map();
|
|
754
|
+
self._pendingProxiedWs.set(wsConnectionId, {
|
|
755
|
+
tunnelId,
|
|
756
|
+
path: remainingPath,
|
|
757
|
+
headers: forwardedHeaders,
|
|
758
|
+
});
|
|
759
|
+
self.pendingProxiedWSEvents.set(wsConnectionId, []);
|
|
760
|
+
const success = server.upgrade(req, {
|
|
761
|
+
data: {
|
|
762
|
+
isProxiedWebSocket: true,
|
|
763
|
+
proxiedWsId: wsConnectionId,
|
|
764
|
+
proxiedTunnelId: tunnelId,
|
|
765
|
+
},
|
|
766
|
+
});
|
|
767
|
+
if (success) {
|
|
768
|
+
self.safeSend(tunnel.ws, {
|
|
769
|
+
type: "websocket_connection",
|
|
770
|
+
id: wsConnectionId,
|
|
771
|
+
path: remainingPath,
|
|
772
|
+
headers: forwardedHeaders,
|
|
773
|
+
});
|
|
774
|
+
return undefined;
|
|
680
775
|
}
|
|
776
|
+
self._pendingProxiedWs?.delete(wsConnectionId);
|
|
777
|
+
self.pendingProxiedWSEvents.delete(wsConnectionId);
|
|
778
|
+
return new Response("WebSocket upgrade failed", { status: 400 });
|
|
681
779
|
}
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
if (
|
|
688
|
-
|
|
689
|
-
|
|
780
|
+
// Regular HTTP request - pass to Hono
|
|
781
|
+
return self.app.fetch(req);
|
|
782
|
+
},
|
|
783
|
+
websocket: {
|
|
784
|
+
open(ws) {
|
|
785
|
+
if (ws.data.isProxiedWebSocket && ws.data.proxiedWsId) {
|
|
786
|
+
const pendingMap = self._pendingProxiedWs;
|
|
787
|
+
const pending = pendingMap?.get(ws.data.proxiedWsId);
|
|
788
|
+
if (pending) {
|
|
789
|
+
self.wsConnections.set(ws.data.proxiedWsId, {
|
|
790
|
+
clientSocket: ws,
|
|
791
|
+
id: ws.data.proxiedWsId,
|
|
792
|
+
path: pending.path,
|
|
793
|
+
headers: pending.headers,
|
|
794
|
+
tunnelId: pending.tunnelId,
|
|
795
|
+
});
|
|
796
|
+
pendingMap.delete(ws.data.proxiedWsId);
|
|
797
|
+
self.flushPendingWebSocketEvents(ws.data.proxiedWsId);
|
|
690
798
|
}
|
|
691
|
-
catch (_a) { }
|
|
692
799
|
}
|
|
693
|
-
|
|
800
|
+
self.handleWsOpen(ws);
|
|
801
|
+
},
|
|
802
|
+
message(ws, message) {
|
|
803
|
+
self.handleWsMessage(ws, message);
|
|
804
|
+
},
|
|
805
|
+
close(ws, code, reason) {
|
|
806
|
+
self.handleWsClose(ws, code, reason);
|
|
807
|
+
},
|
|
808
|
+
pong(ws) {
|
|
809
|
+
// Reset liveness tracking when pong received
|
|
810
|
+
const resetFn = ws.data?._missedPongsReset;
|
|
811
|
+
if (resetFn)
|
|
812
|
+
resetFn();
|
|
813
|
+
},
|
|
814
|
+
perMessageDeflate: false,
|
|
815
|
+
maxPayloadLength: LIMITS.MAX_PAYLOAD_BYTES,
|
|
816
|
+
backpressureLimit: LIMITS.BACKPRESSURE_THRESHOLD,
|
|
817
|
+
},
|
|
818
|
+
});
|
|
819
|
+
console.log(`DainTunnel Server is running on ${this.hostname}:${this.port}`);
|
|
820
|
+
}
|
|
821
|
+
async stop() {
|
|
822
|
+
try {
|
|
823
|
+
for (const tunnel of this.tunnels.values()) {
|
|
824
|
+
if (tunnel.ws.data.keepAliveInterval)
|
|
825
|
+
clearInterval(tunnel.ws.data.keepAliveInterval);
|
|
826
|
+
try {
|
|
827
|
+
tunnel.ws.close(1001, "Server shutting down");
|
|
694
828
|
}
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
if (conn.keepAliveInterval)
|
|
698
|
-
clearInterval(conn.keepAliveInterval);
|
|
699
|
-
try {
|
|
700
|
-
conn.res.end();
|
|
701
|
-
}
|
|
702
|
-
catch (error) {
|
|
703
|
-
console.error(`Error closing SSE ${sseId}:`, error);
|
|
704
|
-
}
|
|
705
|
-
this.sseConnections.delete(sseId);
|
|
829
|
+
catch (error) {
|
|
830
|
+
console.error(`Error closing tunnel ${tunnel.id}:`, error);
|
|
706
831
|
}
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
832
|
+
}
|
|
833
|
+
this.tunnels.clear();
|
|
834
|
+
this.tunnelRequestCount.clear();
|
|
835
|
+
for (const [requestId, pending] of this.pendingRequests.entries()) {
|
|
836
|
+
if (pending.timeoutId)
|
|
837
|
+
clearTimeout(pending.timeoutId);
|
|
838
|
+
pending.resolve(new Response(JSON.stringify({
|
|
839
|
+
error: "Service Unavailable",
|
|
840
|
+
message: "Server shutting down",
|
|
841
|
+
}), {
|
|
842
|
+
status: 503,
|
|
843
|
+
headers: {
|
|
844
|
+
"content-type": "application/json",
|
|
845
|
+
...this.buildCorsHeaders(pending.origin),
|
|
846
|
+
},
|
|
847
|
+
}));
|
|
848
|
+
this.pendingRequests.delete(requestId);
|
|
849
|
+
}
|
|
850
|
+
this.challenges.clear();
|
|
851
|
+
for (const [sseId, conn] of this.sseConnections.entries()) {
|
|
852
|
+
if (conn.keepAliveInterval)
|
|
853
|
+
clearInterval(conn.keepAliveInterval);
|
|
854
|
+
try {
|
|
855
|
+
conn.controller.close();
|
|
715
856
|
}
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
socket.terminate();
|
|
719
|
-
}
|
|
720
|
-
catch (_b) { }
|
|
857
|
+
catch (error) {
|
|
858
|
+
console.error(`Error closing SSE ${sseId}:`, error);
|
|
721
859
|
}
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
(_b = (_a = this.server).closeIdleConnections) === null || _b === void 0 ? void 0 : _b.call(_a);
|
|
725
|
-
(_d = (_c = this.server).closeAllConnections) === null || _d === void 0 ? void 0 : _d.call(_c);
|
|
726
|
-
this.server.close(() => resolve());
|
|
727
|
-
});
|
|
860
|
+
conn.closed = true;
|
|
861
|
+
this.sseConnections.delete(sseId);
|
|
728
862
|
}
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
863
|
+
for (const [wsId, conn] of this.wsConnections.entries()) {
|
|
864
|
+
try {
|
|
865
|
+
conn.clientSocket.close(1001, "Server shutting down");
|
|
866
|
+
}
|
|
867
|
+
catch (error) {
|
|
868
|
+
console.error(`Error closing WS ${wsId}:`, error);
|
|
869
|
+
}
|
|
870
|
+
this.wsConnections.delete(wsId);
|
|
732
871
|
}
|
|
733
|
-
|
|
872
|
+
if (this.server) {
|
|
873
|
+
this.server.stop();
|
|
874
|
+
this.server = null;
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
catch (error) {
|
|
878
|
+
console.error('Error during server shutdown:', error);
|
|
879
|
+
}
|
|
734
880
|
}
|
|
735
881
|
}
|
|
736
|
-
|
|
882
|
+
export default DainTunnelServer;
|