@dainprotocol/tunnel 1.1.26 → 1.1.30
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 +2 -17
- package/dist/client/index.js +209 -268
- package/dist/server/index.d.ts +6 -4
- package/dist/server/index.js +255 -308
- package/package.json +10 -11
package/dist/client/index.js
CHANGED
|
@@ -9,6 +9,17 @@ const http_1 = __importDefault(require("http"));
|
|
|
9
9
|
const events_1 = require("events");
|
|
10
10
|
const crypto_1 = require("crypto");
|
|
11
11
|
const auth_1 = require("@dainprotocol/service-sdk/service/auth");
|
|
12
|
+
const TIMEOUTS = {
|
|
13
|
+
HEARTBEAT: 25000,
|
|
14
|
+
REQUEST: 25000,
|
|
15
|
+
CONNECTION: 10000,
|
|
16
|
+
CHALLENGE: 5000,
|
|
17
|
+
RECONNECT_DELAY: 5000,
|
|
18
|
+
SHUTDOWN_GRACE: 500,
|
|
19
|
+
};
|
|
20
|
+
const RECONNECT = {
|
|
21
|
+
MAX_ATTEMPTS: 5,
|
|
22
|
+
};
|
|
12
23
|
class DainTunnel extends events_1.EventEmitter {
|
|
13
24
|
constructor(serverUrl, apiKey) {
|
|
14
25
|
super();
|
|
@@ -17,63 +28,56 @@ class DainTunnel extends events_1.EventEmitter {
|
|
|
17
28
|
this.tunnelUrl = null;
|
|
18
29
|
this.port = null;
|
|
19
30
|
this.reconnectAttempts = 0;
|
|
20
|
-
this.maxReconnectAttempts = 5;
|
|
21
|
-
this.reconnectDelay = 5000;
|
|
22
31
|
this.webSocketClients = new Map();
|
|
23
32
|
this.sseClients = new Map();
|
|
24
|
-
// Heartbeat interval for client-side liveness
|
|
25
33
|
this.heartbeatInterval = null;
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
// Parse API key to extract agentId and secret
|
|
34
|
+
console.log('[DainTunnel] ========================================');
|
|
35
|
+
console.log('[DainTunnel] FIXED VERSION WITH X-FORWARDED-HOST SUPPORT');
|
|
36
|
+
console.log('[DainTunnel] ========================================');
|
|
30
37
|
const parsed = (0, auth_1.parseAPIKey)(apiKey);
|
|
31
38
|
if (!parsed) {
|
|
32
39
|
throw new Error('Invalid API key format. Expected: sk_agent_{agentId}_{orgId}_{secret}');
|
|
33
40
|
}
|
|
34
41
|
this.apiKey = apiKey;
|
|
35
|
-
this.tunnelId = `${parsed.orgId}_${parsed.agentId}`;
|
|
36
|
-
this.secret = parsed.secret;
|
|
37
|
-
// High-frequency optimization: Create reusable HTTP agent with connection pooling
|
|
38
|
-
// Aligned with server's MAX_CONCURRENT_REQUESTS_PER_TUNNEL = 100
|
|
42
|
+
this.tunnelId = `${parsed.orgId}_${parsed.agentId}`;
|
|
43
|
+
this.secret = parsed.secret;
|
|
39
44
|
this.httpAgent = new http_1.default.Agent({
|
|
40
45
|
keepAlive: true,
|
|
41
|
-
keepAliveMsecs: 30000,
|
|
42
|
-
maxSockets: 100,
|
|
43
|
-
maxFreeSockets: 20,
|
|
46
|
+
keepAliveMsecs: 30000,
|
|
47
|
+
maxSockets: 100,
|
|
48
|
+
maxFreeSockets: 20,
|
|
44
49
|
});
|
|
45
50
|
}
|
|
46
|
-
/**
|
|
47
|
-
* Sign a challenge using HMAC-SHA256
|
|
48
|
-
* @private
|
|
49
|
-
*/
|
|
50
51
|
signChallenge(challenge) {
|
|
51
|
-
return (0, crypto_1.createHmac)('sha256', this.secret)
|
|
52
|
-
|
|
53
|
-
|
|
52
|
+
return (0, crypto_1.createHmac)('sha256', this.secret).update(challenge).digest('hex');
|
|
53
|
+
}
|
|
54
|
+
safeSend(data) {
|
|
55
|
+
var _a;
|
|
56
|
+
try {
|
|
57
|
+
if (((_a = this.ws) === null || _a === void 0 ? void 0 : _a.readyState) === ws_1.default.OPEN) {
|
|
58
|
+
this.ws.send(JSON.stringify(data));
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
catch (_b) {
|
|
63
|
+
// Connection lost during send
|
|
64
|
+
}
|
|
65
|
+
return false;
|
|
54
66
|
}
|
|
55
|
-
/**
|
|
56
|
-
* Start client-side heartbeat to detect connection issues early
|
|
57
|
-
* @private
|
|
58
|
-
*/
|
|
59
67
|
startHeartbeat() {
|
|
60
|
-
this.stopHeartbeat();
|
|
68
|
+
this.stopHeartbeat();
|
|
61
69
|
this.heartbeatInterval = setInterval(() => {
|
|
62
|
-
|
|
70
|
+
var _a;
|
|
71
|
+
if (((_a = this.ws) === null || _a === void 0 ? void 0 : _a.readyState) === ws_1.default.OPEN) {
|
|
63
72
|
try {
|
|
64
73
|
this.ws.ping();
|
|
65
74
|
}
|
|
66
|
-
catch (
|
|
67
|
-
// Ping failed, connection might be dead
|
|
75
|
+
catch (_b) {
|
|
68
76
|
this.emit("error", new Error("Heartbeat ping failed"));
|
|
69
77
|
}
|
|
70
78
|
}
|
|
71
|
-
},
|
|
79
|
+
}, TIMEOUTS.HEARTBEAT);
|
|
72
80
|
}
|
|
73
|
-
/**
|
|
74
|
-
* Stop the client-side heartbeat
|
|
75
|
-
* @private
|
|
76
|
-
*/
|
|
77
81
|
stopHeartbeat() {
|
|
78
82
|
if (this.heartbeatInterval) {
|
|
79
83
|
clearInterval(this.heartbeatInterval);
|
|
@@ -88,25 +92,20 @@ class DainTunnel extends events_1.EventEmitter {
|
|
|
88
92
|
return new Promise((resolve, reject) => {
|
|
89
93
|
let resolved = false;
|
|
90
94
|
let connectionTimeoutId = null;
|
|
91
|
-
const
|
|
95
|
+
const finish = (value, error) => {
|
|
96
|
+
if (resolved)
|
|
97
|
+
return;
|
|
98
|
+
resolved = true;
|
|
92
99
|
if (connectionTimeoutId) {
|
|
93
100
|
clearTimeout(connectionTimeoutId);
|
|
94
101
|
connectionTimeoutId = null;
|
|
95
102
|
}
|
|
96
|
-
|
|
97
|
-
const safeResolve = (value) => {
|
|
98
|
-
if (!resolved) {
|
|
99
|
-
resolved = true;
|
|
100
|
-
cleanup();
|
|
101
|
-
resolve(value);
|
|
102
|
-
}
|
|
103
|
-
};
|
|
104
|
-
const safeReject = (error) => {
|
|
105
|
-
if (!resolved) {
|
|
106
|
-
resolved = true;
|
|
107
|
-
cleanup();
|
|
103
|
+
if (error) {
|
|
108
104
|
reject(error);
|
|
109
105
|
}
|
|
106
|
+
else {
|
|
107
|
+
resolve(value);
|
|
108
|
+
}
|
|
110
109
|
};
|
|
111
110
|
try {
|
|
112
111
|
this.ws = new ws_1.default(this.serverUrl);
|
|
@@ -115,78 +114,74 @@ class DainTunnel extends events_1.EventEmitter {
|
|
|
115
114
|
try {
|
|
116
115
|
const challenge = await this.requestChallenge();
|
|
117
116
|
const signature = this.signChallenge(challenge);
|
|
118
|
-
this.
|
|
117
|
+
this.safeSend({
|
|
119
118
|
type: "start",
|
|
120
119
|
port: this.port,
|
|
121
120
|
challenge,
|
|
122
121
|
signature,
|
|
123
122
|
tunnelId: this.tunnelId,
|
|
124
|
-
apiKey: this.apiKey
|
|
123
|
+
apiKey: this.apiKey
|
|
125
124
|
});
|
|
126
|
-
// Start heartbeat after successful authentication
|
|
127
125
|
this.startHeartbeat();
|
|
128
126
|
this.emit("connected");
|
|
129
127
|
}
|
|
130
128
|
catch (err) {
|
|
131
|
-
|
|
129
|
+
finish(undefined, err);
|
|
132
130
|
}
|
|
133
131
|
});
|
|
134
132
|
this.ws.on("message", (data) => {
|
|
135
133
|
try {
|
|
136
|
-
this.handleMessage(JSON.parse(data),
|
|
134
|
+
this.handleMessage(JSON.parse(data), (url) => finish(url));
|
|
137
135
|
}
|
|
138
136
|
catch (err) {
|
|
139
|
-
|
|
137
|
+
finish(undefined, err);
|
|
140
138
|
}
|
|
141
139
|
});
|
|
142
140
|
this.ws.on("close", () => {
|
|
143
|
-
// Stop heartbeat on disconnect
|
|
144
141
|
this.stopHeartbeat();
|
|
145
|
-
|
|
146
|
-
for (const [, client] of this.sseClients) {
|
|
147
|
-
try {
|
|
148
|
-
client.destroy();
|
|
149
|
-
}
|
|
150
|
-
catch (e) { /* ignore */ }
|
|
151
|
-
}
|
|
152
|
-
this.sseClients.clear();
|
|
153
|
-
// Clean up all WebSocket clients
|
|
154
|
-
for (const [, client] of this.webSocketClients) {
|
|
155
|
-
try {
|
|
156
|
-
if (client.readyState === ws_1.default.OPEN)
|
|
157
|
-
client.close(1001);
|
|
158
|
-
}
|
|
159
|
-
catch (e) { /* ignore */ }
|
|
160
|
-
}
|
|
161
|
-
this.webSocketClients.clear();
|
|
142
|
+
this.cleanupAllClients();
|
|
162
143
|
if (this.tunnelUrl) {
|
|
163
144
|
this.emit("disconnected");
|
|
164
145
|
this.attemptReconnect();
|
|
165
146
|
}
|
|
166
147
|
else {
|
|
167
|
-
|
|
148
|
+
finish(undefined, new Error("Connection closed before tunnel established"));
|
|
168
149
|
}
|
|
169
150
|
});
|
|
170
151
|
this.ws.on("error", (error) => this.emit("error", error));
|
|
171
|
-
// Connection timeout with proper cleanup
|
|
172
152
|
connectionTimeoutId = setTimeout(() => {
|
|
153
|
+
var _a;
|
|
173
154
|
if (!resolved && (!this.ws || this.ws.readyState !== ws_1.default.OPEN)) {
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
try {
|
|
178
|
-
this.ws.terminate();
|
|
179
|
-
}
|
|
180
|
-
catch (e) { /* ignore */ }
|
|
155
|
+
finish(undefined, new Error("Connection timeout"));
|
|
156
|
+
try {
|
|
157
|
+
(_a = this.ws) === null || _a === void 0 ? void 0 : _a.terminate();
|
|
181
158
|
}
|
|
159
|
+
catch (_b) { }
|
|
182
160
|
}
|
|
183
|
-
},
|
|
161
|
+
}, TIMEOUTS.CONNECTION);
|
|
184
162
|
}
|
|
185
163
|
catch (err) {
|
|
186
|
-
|
|
164
|
+
finish(undefined, err);
|
|
187
165
|
}
|
|
188
166
|
});
|
|
189
167
|
}
|
|
168
|
+
cleanupAllClients() {
|
|
169
|
+
for (const client of this.sseClients.values()) {
|
|
170
|
+
try {
|
|
171
|
+
client.destroy();
|
|
172
|
+
}
|
|
173
|
+
catch (_a) { }
|
|
174
|
+
}
|
|
175
|
+
this.sseClients.clear();
|
|
176
|
+
for (const client of this.webSocketClients.values()) {
|
|
177
|
+
try {
|
|
178
|
+
if (client.readyState === ws_1.default.OPEN)
|
|
179
|
+
client.close(1001);
|
|
180
|
+
}
|
|
181
|
+
catch (_b) { }
|
|
182
|
+
}
|
|
183
|
+
this.webSocketClients.clear();
|
|
184
|
+
}
|
|
190
185
|
async requestChallenge() {
|
|
191
186
|
return new Promise((resolve, reject) => {
|
|
192
187
|
if (!this.ws) {
|
|
@@ -195,38 +190,37 @@ class DainTunnel extends events_1.EventEmitter {
|
|
|
195
190
|
}
|
|
196
191
|
let resolved = false;
|
|
197
192
|
let timeoutId = null;
|
|
198
|
-
const
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
193
|
+
const finish = (challenge, error) => {
|
|
194
|
+
var _a;
|
|
195
|
+
if (resolved)
|
|
196
|
+
return;
|
|
197
|
+
resolved = true;
|
|
198
|
+
(_a = this.ws) === null || _a === void 0 ? void 0 : _a.removeListener("message", challengeHandler);
|
|
202
199
|
if (timeoutId) {
|
|
203
200
|
clearTimeout(timeoutId);
|
|
204
201
|
timeoutId = null;
|
|
205
202
|
}
|
|
203
|
+
if (error) {
|
|
204
|
+
reject(error);
|
|
205
|
+
}
|
|
206
|
+
else {
|
|
207
|
+
resolve(challenge);
|
|
208
|
+
}
|
|
206
209
|
};
|
|
207
210
|
const challengeHandler = (message) => {
|
|
208
211
|
try {
|
|
209
212
|
const data = JSON.parse(message);
|
|
210
|
-
if (data.type === "challenge"
|
|
211
|
-
|
|
212
|
-
cleanup();
|
|
213
|
-
resolve(data.challenge);
|
|
213
|
+
if (data.type === "challenge") {
|
|
214
|
+
finish(data.challenge);
|
|
214
215
|
}
|
|
215
216
|
}
|
|
216
|
-
catch (
|
|
217
|
-
// Ignore parse errors for non-challenge messages
|
|
218
|
-
}
|
|
217
|
+
catch (_a) { }
|
|
219
218
|
};
|
|
220
219
|
this.ws.on("message", challengeHandler);
|
|
221
220
|
this.ws.send(JSON.stringify({ type: "challenge_request" }));
|
|
222
|
-
// Add a timeout for the challenge request
|
|
223
221
|
timeoutId = setTimeout(() => {
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
cleanup();
|
|
227
|
-
reject(new Error("Challenge request timeout"));
|
|
228
|
-
}
|
|
229
|
-
}, 5000);
|
|
222
|
+
finish(undefined, new Error("Challenge request timeout"));
|
|
223
|
+
}, TIMEOUTS.CHALLENGE);
|
|
230
224
|
});
|
|
231
225
|
}
|
|
232
226
|
handleMessage(message, resolve) {
|
|
@@ -256,141 +250,138 @@ class DainTunnel extends events_1.EventEmitter {
|
|
|
256
250
|
async handleRequest(request) {
|
|
257
251
|
try {
|
|
258
252
|
const response = await this.forwardRequest(request);
|
|
259
|
-
this.
|
|
253
|
+
this.safeSend(response);
|
|
260
254
|
this.emit("request_handled", { request, response });
|
|
261
255
|
}
|
|
262
256
|
catch (error) {
|
|
263
|
-
// Send error response back to server instead of just emitting an event
|
|
264
|
-
// This prevents 30-second timeouts when the local service is unreachable
|
|
265
257
|
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
|
266
|
-
|
|
258
|
+
this.safeSend({
|
|
267
259
|
type: "response",
|
|
268
260
|
requestId: request.id,
|
|
269
|
-
status: 502,
|
|
261
|
+
status: 502,
|
|
270
262
|
headers: { "content-type": "application/json" },
|
|
271
263
|
body: Buffer.from(JSON.stringify({
|
|
272
264
|
error: "Bad Gateway",
|
|
273
265
|
message: `Failed to forward request to local service: ${errorMessage}`
|
|
274
266
|
})).toString("base64")
|
|
275
|
-
};
|
|
276
|
-
this.sendMessage(errorResponse);
|
|
267
|
+
});
|
|
277
268
|
this.emit("request_error", { request, error });
|
|
278
269
|
}
|
|
279
270
|
}
|
|
280
271
|
handleWebSocketConnection(message) {
|
|
281
|
-
|
|
272
|
+
const sendWsEvent = (event, data) => {
|
|
273
|
+
this.safeSend({ type: 'websocket', id: message.id, event, data });
|
|
274
|
+
};
|
|
282
275
|
try {
|
|
276
|
+
// Preserve original host for JWT audience validation
|
|
277
|
+
const headers = { ...message.headers };
|
|
278
|
+
const originalHost = message.headers.host;
|
|
279
|
+
if (originalHost && !headers['x-forwarded-host']) {
|
|
280
|
+
headers['x-forwarded-host'] = originalHost;
|
|
281
|
+
headers['x-forwarded-proto'] = 'https';
|
|
282
|
+
}
|
|
283
|
+
delete headers.host; // Remove so WebSocket library doesn't send tunnel host to localhost
|
|
283
284
|
const client = new ws_1.default(`ws://localhost:${this.port}${message.path}`, {
|
|
284
|
-
headers
|
|
285
|
+
headers
|
|
285
286
|
});
|
|
286
287
|
this.webSocketClients.set(message.id, client);
|
|
287
288
|
client.on('message', (data) => {
|
|
288
|
-
|
|
289
|
-
if (((_a = this.ws) === null || _a === void 0 ? void 0 : _a.readyState) === ws_1.default.OPEN) {
|
|
290
|
-
this.ws.send(JSON.stringify({ type: 'websocket', id: message.id, event: 'message', data: data.toString('base64') }));
|
|
291
|
-
}
|
|
289
|
+
sendWsEvent('message', data.toString('base64'));
|
|
292
290
|
});
|
|
293
291
|
client.on('close', () => {
|
|
294
|
-
|
|
295
|
-
if (((_a = this.ws) === null || _a === void 0 ? void 0 : _a.readyState) === ws_1.default.OPEN) {
|
|
296
|
-
this.ws.send(JSON.stringify({ type: 'websocket', id: message.id, event: 'close' }));
|
|
297
|
-
}
|
|
292
|
+
sendWsEvent('close');
|
|
298
293
|
this.webSocketClients.delete(message.id);
|
|
299
294
|
});
|
|
300
295
|
client.on('error', (error) => {
|
|
301
|
-
|
|
302
|
-
if (((_a = this.ws) === null || _a === void 0 ? void 0 : _a.readyState) === ws_1.default.OPEN) {
|
|
303
|
-
this.ws.send(JSON.stringify({ type: 'websocket', id: message.id, event: 'error', data: error.message }));
|
|
304
|
-
}
|
|
296
|
+
sendWsEvent('error', error.message);
|
|
305
297
|
this.webSocketClients.delete(message.id);
|
|
306
298
|
});
|
|
307
299
|
this.emit("websocket_connection", { id: message.id, path: message.path });
|
|
308
300
|
}
|
|
309
301
|
catch (error) {
|
|
310
|
-
|
|
311
|
-
this.ws.send(JSON.stringify({ type: 'websocket', id: message.id, event: 'error', data: error.message }));
|
|
312
|
-
}
|
|
302
|
+
sendWsEvent('error', error.message);
|
|
313
303
|
}
|
|
314
304
|
}
|
|
315
305
|
handleWebSocketMessage(message) {
|
|
316
306
|
const client = this.webSocketClients.get(message.id);
|
|
317
307
|
if (!client)
|
|
318
308
|
return;
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
309
|
+
switch (message.event) {
|
|
310
|
+
case 'message':
|
|
311
|
+
if (message.data && client.readyState === ws_1.default.OPEN) {
|
|
312
|
+
client.send(Buffer.from(message.data, 'base64'));
|
|
313
|
+
}
|
|
314
|
+
break;
|
|
315
|
+
case 'close':
|
|
316
|
+
if (client.readyState === ws_1.default.OPEN) {
|
|
317
|
+
client.close();
|
|
318
|
+
}
|
|
319
|
+
this.webSocketClients.delete(message.id);
|
|
320
|
+
break;
|
|
330
321
|
}
|
|
331
322
|
}
|
|
332
323
|
handleSSEConnection(message) {
|
|
333
|
-
|
|
324
|
+
const sendSseEvent = (event, data = '') => {
|
|
325
|
+
this.safeSend({ type: 'sse', id: message.id, event, data });
|
|
326
|
+
};
|
|
334
327
|
try {
|
|
328
|
+
const headers = { ...message.headers };
|
|
329
|
+
// Preserve original host for JWT audience validation
|
|
330
|
+
const originalHost = message.headers.host;
|
|
331
|
+
console.log(`[TunnelClient SSE] Original host from request: ${originalHost}`);
|
|
332
|
+
if (originalHost && !headers['x-forwarded-host']) {
|
|
333
|
+
headers['x-forwarded-host'] = originalHost;
|
|
334
|
+
headers['x-forwarded-proto'] = 'https';
|
|
335
|
+
console.log(`[TunnelClient SSE] Set x-forwarded-host: ${originalHost}`);
|
|
336
|
+
}
|
|
337
|
+
delete headers.host;
|
|
338
|
+
headers['accept-encoding'] = 'identity';
|
|
339
|
+
console.log(`[TunnelClient SSE] Forwarding to localhost:${this.port}${message.path} with x-forwarded-host: ${headers['x-forwarded-host']}`);
|
|
335
340
|
const req = http_1.default.request({
|
|
336
341
|
hostname: 'localhost',
|
|
337
342
|
port: this.port,
|
|
338
343
|
path: message.path,
|
|
339
344
|
method: message.method || 'GET',
|
|
340
|
-
headers
|
|
345
|
+
headers,
|
|
341
346
|
agent: this.httpAgent,
|
|
342
347
|
}, (res) => {
|
|
343
|
-
var _a;
|
|
344
|
-
// Non-200 response - forward error to tunnel server
|
|
345
348
|
if (res.statusCode !== 200) {
|
|
349
|
+
console.log(`[TunnelClient SSE] Got non-200 status: ${res.statusCode}`);
|
|
346
350
|
let errorBody = '';
|
|
347
351
|
res.on('data', (chunk) => { errorBody += chunk.toString(); });
|
|
348
352
|
res.on('end', () => {
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
this.ws.send(JSON.stringify({
|
|
352
|
-
type: 'sse', id: message.id, event: 'error',
|
|
353
|
-
data: `Status ${res.statusCode}: ${errorBody.substring(0, 200)}`
|
|
354
|
-
}));
|
|
355
|
-
}
|
|
353
|
+
console.log(`[TunnelClient SSE] Error body: ${errorBody.substring(0, 500)}`);
|
|
354
|
+
sendSseEvent('error', `Status ${res.statusCode}: ${errorBody.substring(0, 200)}`);
|
|
356
355
|
});
|
|
357
356
|
return;
|
|
358
357
|
}
|
|
359
|
-
// Optimize TCP for streaming
|
|
360
358
|
const socket = res.socket || res.connection;
|
|
361
359
|
if (socket === null || socket === void 0 ? void 0 : socket.setNoDelay)
|
|
362
360
|
socket.setNoDelay(true);
|
|
363
|
-
|
|
364
|
-
if (((_a = this.ws) === null || _a === void 0 ? void 0 : _a.readyState) === ws_1.default.OPEN) {
|
|
365
|
-
this.ws.send(JSON.stringify({ type: 'sse', id: message.id, event: 'connected', data: '' }));
|
|
366
|
-
}
|
|
367
|
-
// Stream SSE events to tunnel server
|
|
361
|
+
sendSseEvent('connected');
|
|
368
362
|
let buffer = '';
|
|
369
|
-
// Helper to process SSE events from buffer
|
|
370
363
|
const processBuffer = () => {
|
|
371
|
-
var _a;
|
|
372
|
-
// FIX: Normalize CRLF to LF for cross-platform SSE compatibility
|
|
373
|
-
// Some SSE implementations use \r\n\r\n instead of \n\n
|
|
374
364
|
buffer = buffer.replace(/\r\n/g, '\n');
|
|
375
365
|
while (buffer.includes('\n\n')) {
|
|
376
366
|
const idx = buffer.indexOf('\n\n');
|
|
377
367
|
const msgData = buffer.substring(0, idx);
|
|
378
368
|
buffer = buffer.substring(idx + 2);
|
|
379
|
-
// Skip empty messages and keepalive comments
|
|
380
369
|
if (!msgData.trim() || msgData.startsWith(':'))
|
|
381
370
|
continue;
|
|
382
|
-
let event = 'message'
|
|
371
|
+
let event = 'message';
|
|
372
|
+
let data = '';
|
|
383
373
|
for (const line of msgData.split('\n')) {
|
|
384
|
-
if (line.startsWith('event:'))
|
|
374
|
+
if (line.startsWith('event:')) {
|
|
385
375
|
event = line.substring(6).trim();
|
|
386
|
-
|
|
376
|
+
}
|
|
377
|
+
else if (line.startsWith('data:')) {
|
|
387
378
|
data += line.substring(5).trim() + '\n';
|
|
379
|
+
}
|
|
388
380
|
}
|
|
389
381
|
if (data.endsWith('\n'))
|
|
390
382
|
data = data.slice(0, -1);
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
this.ws.send(JSON.stringify({ type: 'sse', id: message.id, event, data }));
|
|
383
|
+
if (data) {
|
|
384
|
+
sendSseEvent(event, data);
|
|
394
385
|
}
|
|
395
386
|
}
|
|
396
387
|
};
|
|
@@ -399,34 +390,29 @@ class DainTunnel extends events_1.EventEmitter {
|
|
|
399
390
|
processBuffer();
|
|
400
391
|
});
|
|
401
392
|
res.on('end', () => {
|
|
402
|
-
var _a;
|
|
403
|
-
// Process any remaining data in buffer (might be missing final \n\n)
|
|
404
393
|
if (buffer.trim()) {
|
|
405
|
-
// Add missing newlines to ensure processing
|
|
406
394
|
buffer += '\n\n';
|
|
407
395
|
processBuffer();
|
|
408
396
|
}
|
|
409
|
-
|
|
410
|
-
this.ws.send(JSON.stringify({ type: 'sse', id: message.id, event: 'close', data: '' }));
|
|
411
|
-
}
|
|
397
|
+
sendSseEvent('close');
|
|
412
398
|
});
|
|
413
399
|
});
|
|
400
|
+
req.on('socket', (socket) => {
|
|
401
|
+
socket.setNoDelay(true);
|
|
402
|
+
socket.setTimeout(0);
|
|
403
|
+
});
|
|
414
404
|
req.on('error', (error) => {
|
|
415
|
-
|
|
416
|
-
if (((_a = this.ws) === null || _a === void 0 ? void 0 : _a.readyState) === ws_1.default.OPEN) {
|
|
417
|
-
this.ws.send(JSON.stringify({ type: 'sse', id: message.id, event: 'error', data: error.message }));
|
|
418
|
-
}
|
|
405
|
+
sendSseEvent('error', error.message);
|
|
419
406
|
});
|
|
420
|
-
if (message.body && message.method !== 'GET')
|
|
407
|
+
if (message.body && message.method !== 'GET') {
|
|
421
408
|
req.write(Buffer.from(message.body, 'base64'));
|
|
409
|
+
}
|
|
422
410
|
req.end();
|
|
423
411
|
this.sseClients.set(message.id, req);
|
|
424
412
|
this.emit("sse_connection", { id: message.id, path: message.path });
|
|
425
413
|
}
|
|
426
414
|
catch (error) {
|
|
427
|
-
|
|
428
|
-
this.ws.send(JSON.stringify({ type: 'sse', id: message.id, event: 'error', data: error.message }));
|
|
429
|
-
}
|
|
415
|
+
sendSseEvent('error', error.message);
|
|
430
416
|
}
|
|
431
417
|
}
|
|
432
418
|
handleSSEClose(message) {
|
|
@@ -440,146 +426,101 @@ class DainTunnel extends events_1.EventEmitter {
|
|
|
440
426
|
return new Promise((resolve, reject) => {
|
|
441
427
|
let resolved = false;
|
|
442
428
|
let timeoutId = null;
|
|
443
|
-
const
|
|
429
|
+
const finish = (response, error) => {
|
|
430
|
+
if (resolved)
|
|
431
|
+
return;
|
|
432
|
+
resolved = true;
|
|
444
433
|
if (timeoutId) {
|
|
445
434
|
clearTimeout(timeoutId);
|
|
446
435
|
timeoutId = null;
|
|
447
436
|
}
|
|
448
|
-
|
|
449
|
-
const safeResolve = (response) => {
|
|
450
|
-
if (!resolved) {
|
|
451
|
-
resolved = true;
|
|
452
|
-
cleanup();
|
|
453
|
-
resolve(response);
|
|
454
|
-
}
|
|
455
|
-
};
|
|
456
|
-
const safeReject = (error) => {
|
|
457
|
-
if (!resolved) {
|
|
458
|
-
resolved = true;
|
|
459
|
-
cleanup();
|
|
437
|
+
if (error) {
|
|
460
438
|
reject(error);
|
|
461
439
|
}
|
|
440
|
+
else {
|
|
441
|
+
resolve(response);
|
|
442
|
+
}
|
|
462
443
|
};
|
|
444
|
+
// Preserve original host for JWT audience validation
|
|
445
|
+
const headers = { ...request.headers };
|
|
446
|
+
const originalHost = request.headers.host;
|
|
447
|
+
console.log(`[TunnelClient] Original host from request: ${originalHost}`);
|
|
448
|
+
if (originalHost && !headers['x-forwarded-host']) {
|
|
449
|
+
headers['x-forwarded-host'] = originalHost;
|
|
450
|
+
headers['x-forwarded-proto'] = 'https';
|
|
451
|
+
console.log(`[TunnelClient] Set x-forwarded-host: ${originalHost}`);
|
|
452
|
+
}
|
|
453
|
+
delete headers.host; // Remove so Node.js doesn't send the tunnel host to localhost
|
|
454
|
+
console.log(`[TunnelClient] Forwarding to localhost:${this.port}${request.path} with x-forwarded-host: ${headers['x-forwarded-host']}`);
|
|
463
455
|
const options = {
|
|
464
456
|
hostname: 'localhost',
|
|
465
457
|
port: this.port,
|
|
466
458
|
path: request.path,
|
|
467
459
|
method: request.method,
|
|
468
|
-
headers
|
|
469
|
-
agent: this.httpAgent,
|
|
470
|
-
timeout:
|
|
460
|
+
headers,
|
|
461
|
+
agent: this.httpAgent,
|
|
462
|
+
timeout: TIMEOUTS.REQUEST,
|
|
471
463
|
};
|
|
472
464
|
const req = http_1.default.request(options, (res) => {
|
|
473
|
-
|
|
474
|
-
res.on('data', (chunk) =>
|
|
475
|
-
body = Buffer.concat([body, chunk]);
|
|
476
|
-
});
|
|
465
|
+
const chunks = [];
|
|
466
|
+
res.on('data', (chunk) => chunks.push(chunk));
|
|
477
467
|
res.on('end', () => {
|
|
478
468
|
const headers = { ...res.headers };
|
|
479
469
|
delete headers['transfer-encoding'];
|
|
480
470
|
delete headers['content-length'];
|
|
481
|
-
|
|
471
|
+
finish({
|
|
482
472
|
type: 'response',
|
|
483
473
|
requestId: request.id,
|
|
484
474
|
status: res.statusCode,
|
|
485
475
|
headers,
|
|
486
|
-
body:
|
|
476
|
+
body: Buffer.concat(chunks).toString('base64'),
|
|
487
477
|
});
|
|
488
478
|
});
|
|
489
|
-
res.on('error',
|
|
479
|
+
res.on('error', (err) => finish(undefined, err));
|
|
490
480
|
});
|
|
491
|
-
// Handle request timeout
|
|
492
481
|
req.on('timeout', () => {
|
|
493
482
|
req.destroy();
|
|
494
|
-
|
|
483
|
+
finish(undefined, new Error(`Request timeout after ${TIMEOUTS.REQUEST}ms`));
|
|
495
484
|
});
|
|
496
|
-
req.on('error',
|
|
497
|
-
// Additional safety timeout
|
|
485
|
+
req.on('error', (err) => finish(undefined, err));
|
|
498
486
|
timeoutId = setTimeout(() => {
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
}
|
|
503
|
-
}, this.REQUEST_TIMEOUT);
|
|
487
|
+
req.destroy();
|
|
488
|
+
finish(undefined, new Error(`Request timeout after ${TIMEOUTS.REQUEST}ms`));
|
|
489
|
+
}, TIMEOUTS.REQUEST);
|
|
504
490
|
if (request.body && request.method !== 'GET') {
|
|
505
491
|
req.write(Buffer.from(request.body, 'base64'));
|
|
506
492
|
}
|
|
507
493
|
req.end();
|
|
508
494
|
});
|
|
509
495
|
}
|
|
510
|
-
sendMessage(message) {
|
|
511
|
-
if (this.ws && this.ws.readyState === ws_1.default.OPEN) {
|
|
512
|
-
this.ws.send(JSON.stringify(message));
|
|
513
|
-
}
|
|
514
|
-
}
|
|
515
496
|
attemptReconnect() {
|
|
516
|
-
if (this.reconnectAttempts <
|
|
497
|
+
if (this.reconnectAttempts < RECONNECT.MAX_ATTEMPTS) {
|
|
517
498
|
this.reconnectAttempts++;
|
|
518
499
|
setTimeout(async () => {
|
|
519
500
|
try {
|
|
520
501
|
await this.connect();
|
|
521
502
|
}
|
|
522
|
-
catch (
|
|
523
|
-
},
|
|
503
|
+
catch (_a) { }
|
|
504
|
+
}, TIMEOUTS.RECONNECT_DELAY);
|
|
524
505
|
}
|
|
525
506
|
else {
|
|
526
507
|
this.emit("max_reconnect_attempts");
|
|
527
508
|
}
|
|
528
509
|
}
|
|
529
510
|
async stop() {
|
|
530
|
-
|
|
511
|
+
this.stopHeartbeat();
|
|
512
|
+
this.cleanupAllClients();
|
|
513
|
+
if (this.ws) {
|
|
531
514
|
try {
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
// Close all WebSocket clients
|
|
535
|
-
for (const [id, client] of this.webSocketClients.entries()) {
|
|
536
|
-
try {
|
|
537
|
-
if (client.readyState === ws_1.default.OPEN) {
|
|
538
|
-
client.close();
|
|
539
|
-
}
|
|
540
|
-
}
|
|
541
|
-
catch (error) {
|
|
542
|
-
console.error(`Error closing WebSocket client ${id}:`, error);
|
|
543
|
-
}
|
|
544
|
-
}
|
|
545
|
-
this.webSocketClients.clear();
|
|
546
|
-
// Close all SSE clients
|
|
547
|
-
for (const [id, client] of this.sseClients.entries()) {
|
|
548
|
-
try {
|
|
549
|
-
client.destroy();
|
|
550
|
-
}
|
|
551
|
-
catch (error) {
|
|
552
|
-
console.error(`Error destroying SSE client ${id}:`, error);
|
|
553
|
-
}
|
|
554
|
-
}
|
|
555
|
-
this.sseClients.clear();
|
|
556
|
-
// Close main WebSocket connection
|
|
557
|
-
if (this.ws) {
|
|
558
|
-
try {
|
|
559
|
-
if (this.ws.readyState === ws_1.default.OPEN) {
|
|
560
|
-
this.ws.close();
|
|
561
|
-
}
|
|
562
|
-
}
|
|
563
|
-
catch (error) {
|
|
564
|
-
console.error('Error closing main WebSocket:', error);
|
|
565
|
-
}
|
|
566
|
-
this.ws = null;
|
|
567
|
-
}
|
|
568
|
-
// Reset tunnel URL so reconnect doesn't happen
|
|
569
|
-
this.tunnelUrl = null;
|
|
570
|
-
// Destroy the HTTP agent to close pooled connections
|
|
571
|
-
if (this.httpAgent) {
|
|
572
|
-
this.httpAgent.destroy();
|
|
573
|
-
}
|
|
574
|
-
// Wait for all connections to close properly
|
|
575
|
-
await new Promise(resolve => setTimeout(resolve, 500));
|
|
576
|
-
resolve();
|
|
577
|
-
}
|
|
578
|
-
catch (error) {
|
|
579
|
-
console.error('Error in stop method:', error);
|
|
580
|
-
resolve(); // Resolve anyway to not block cleaning up
|
|
515
|
+
if (this.ws.readyState === ws_1.default.OPEN)
|
|
516
|
+
this.ws.close();
|
|
581
517
|
}
|
|
582
|
-
|
|
518
|
+
catch (_a) { }
|
|
519
|
+
this.ws = null;
|
|
520
|
+
}
|
|
521
|
+
this.tunnelUrl = null;
|
|
522
|
+
this.httpAgent.destroy();
|
|
523
|
+
await new Promise(resolve => setTimeout(resolve, TIMEOUTS.SHUTDOWN_GRACE));
|
|
583
524
|
}
|
|
584
525
|
}
|
|
585
526
|
exports.DainTunnel = DainTunnel;
|