@dainprotocol/tunnel 1.0.5 → 1.1.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.
@@ -7,14 +7,26 @@ declare class DainTunnel extends EventEmitter {
7
7
  private reconnectAttempts;
8
8
  private maxReconnectAttempts;
9
9
  private reconnectDelay;
10
- private auth;
10
+ private apiKey;
11
11
  private tunnelId;
12
+ private secret;
13
+ private webSocketClients;
14
+ private sseClients;
12
15
  constructor(serverUrl: string, apiKey: string);
16
+ /**
17
+ * Sign a challenge using HMAC-SHA256
18
+ * @private
19
+ */
20
+ private signChallenge;
13
21
  start(port: number): Promise<string>;
14
22
  private connect;
15
23
  private requestChallenge;
16
24
  private handleMessage;
17
25
  private handleRequest;
26
+ private handleWebSocketConnection;
27
+ private handleWebSocketMessage;
28
+ private handleSSEConnection;
29
+ private handleSSEClose;
18
30
  private forwardRequest;
19
31
  private sendMessage;
20
32
  private attemptReconnect;
@@ -7,8 +7,8 @@ exports.DainTunnel = void 0;
7
7
  const ws_1 = __importDefault(require("ws"));
8
8
  const http_1 = __importDefault(require("http"));
9
9
  const events_1 = require("events");
10
- const bs58_1 = __importDefault(require("bs58"));
11
- const client_1 = require("@dainprotocol/service-sdk/client");
10
+ const crypto_1 = require("crypto");
11
+ const auth_1 = require("@dainprotocol/service-sdk/service/auth");
12
12
  class DainTunnel extends events_1.EventEmitter {
13
13
  constructor(serverUrl, apiKey) {
14
14
  super();
@@ -19,8 +19,25 @@ class DainTunnel extends events_1.EventEmitter {
19
19
  this.reconnectAttempts = 0;
20
20
  this.maxReconnectAttempts = 5;
21
21
  this.reconnectDelay = 5000;
22
- this.auth = new client_1.DainClientAuth({ apiKey });
23
- this.tunnelId = bs58_1.default.encode(this.auth.getPublicKey());
22
+ this.webSocketClients = new Map();
23
+ this.sseClients = new Map();
24
+ // Parse API key to extract agentId and secret
25
+ const parsed = (0, auth_1.parseAPIKey)(apiKey);
26
+ if (!parsed) {
27
+ throw new Error('Invalid API key format. Expected: sk_agent_{agentId}_{orgId}_{secret}');
28
+ }
29
+ this.apiKey = apiKey;
30
+ this.tunnelId = parsed.agentId; // agentId is the tunnel identifier
31
+ this.secret = parsed.secret; // secret for HMAC signatures
32
+ }
33
+ /**
34
+ * Sign a challenge using HMAC-SHA256
35
+ * @private
36
+ */
37
+ signChallenge(challenge) {
38
+ return (0, crypto_1.createHmac)('sha256', this.secret)
39
+ .update(challenge)
40
+ .digest('hex');
24
41
  }
25
42
  async start(port) {
26
43
  this.port = port;
@@ -28,39 +45,68 @@ class DainTunnel extends events_1.EventEmitter {
28
45
  }
29
46
  async connect() {
30
47
  return new Promise((resolve, reject) => {
31
- this.ws = new ws_1.default(this.serverUrl);
32
- this.ws.on("open", async () => {
33
- this.reconnectAttempts = 0;
34
- const challenge = await this.requestChallenge();
35
- const signature = this.auth.signMessage(challenge);
36
- this.sendMessage({
37
- type: "start",
38
- port: this.port,
39
- challenge,
40
- signature,
41
- tunnelId: this.tunnelId
48
+ try {
49
+ console.log(`Connecting to WebSocket server: ${this.serverUrl}`);
50
+ this.ws = new ws_1.default(this.serverUrl);
51
+ this.ws.on("open", async () => {
52
+ console.log('WebSocket connection opened');
53
+ this.reconnectAttempts = 0;
54
+ try {
55
+ const challenge = await this.requestChallenge();
56
+ const signature = this.signChallenge(challenge);
57
+ this.sendMessage({
58
+ type: "start",
59
+ port: this.port,
60
+ challenge,
61
+ signature,
62
+ tunnelId: this.tunnelId,
63
+ apiKey: this.apiKey // Send API key for server validation
64
+ });
65
+ this.emit("connected");
66
+ }
67
+ catch (err) {
68
+ console.error('Error during challenge-response:', err);
69
+ reject(err);
70
+ }
42
71
  });
43
- this.emit("connected");
44
- });
45
- this.ws.on("message", (data) => {
46
- const message = JSON.parse(data);
47
- this.handleMessage(message, resolve);
48
- });
49
- this.ws.on("close", () => {
50
- this.emit("disconnected");
51
- this.attemptReconnect();
52
- });
53
- this.ws.on("error", (error) => {
54
- this.emit("error", error);
55
- reject(error);
56
- });
57
- // Add a timeout to reject the promise if connection takes too long
58
- setTimeout(() => {
59
- var _a;
60
- if (((_a = this.ws) === null || _a === void 0 ? void 0 : _a.readyState) !== ws_1.default.OPEN) {
61
- reject(new Error("Connection timeout"));
62
- }
63
- }, 5000);
72
+ this.ws.on("message", (data) => {
73
+ try {
74
+ const message = JSON.parse(data);
75
+ this.handleMessage(message, resolve);
76
+ }
77
+ catch (err) {
78
+ console.error('Error handling message:', err);
79
+ reject(err);
80
+ }
81
+ });
82
+ this.ws.on("close", () => {
83
+ console.log('WebSocket connection closed');
84
+ if (this.tunnelUrl) {
85
+ this.emit("disconnected");
86
+ this.attemptReconnect();
87
+ }
88
+ else {
89
+ // If we haven't received a tunnelUrl yet, just reject
90
+ reject(new Error("Connection closed before tunnel was established"));
91
+ }
92
+ });
93
+ this.ws.on("error", (error) => {
94
+ // Prevent unhandled error events
95
+ // Don't reject here, the close handler will be called after an error
96
+ this.emit("error", error);
97
+ });
98
+ // Add a timeout to reject the promise if connection takes too long
99
+ setTimeout(() => {
100
+ if (!this.ws || this.ws.readyState !== ws_1.default.OPEN) {
101
+ const timeoutError = new Error("Connection timeout");
102
+ reject(timeoutError);
103
+ }
104
+ }, 10000);
105
+ }
106
+ catch (err) {
107
+ console.error('Error creating WebSocket:', err);
108
+ reject(err);
109
+ }
64
110
  });
65
111
  }
66
112
  async requestChallenge() {
@@ -99,6 +145,18 @@ class DainTunnel extends events_1.EventEmitter {
99
145
  case "request":
100
146
  this.handleRequest(message);
101
147
  break;
148
+ case "websocket_connection":
149
+ this.handleWebSocketConnection(message);
150
+ break;
151
+ case "sse_connection":
152
+ this.handleSSEConnection(message);
153
+ break;
154
+ case "sse_close":
155
+ this.handleSSEClose(message);
156
+ break;
157
+ case "websocket":
158
+ this.handleWebSocketMessage(message);
159
+ break;
102
160
  }
103
161
  }
104
162
  async handleRequest(request) {
@@ -111,6 +169,191 @@ class DainTunnel extends events_1.EventEmitter {
111
169
  this.emit("request_error", { request, error });
112
170
  }
113
171
  }
172
+ handleWebSocketConnection(message) {
173
+ try {
174
+ console.log(`Creating local WebSocket connection to: ws://localhost:${this.port}${message.path}`);
175
+ // Create a WebSocket connection to the local server
176
+ const client = new ws_1.default(`ws://localhost:${this.port}${message.path}`, {
177
+ headers: message.headers
178
+ });
179
+ // Store the client
180
+ this.webSocketClients.set(message.id, client);
181
+ // Handle connection open
182
+ client.on('open', () => {
183
+ console.log(`Local WebSocket connection opened: ${message.id}`);
184
+ });
185
+ // Handle messages from the local server
186
+ client.on('message', (data) => {
187
+ if (this.ws && this.ws.readyState === ws_1.default.OPEN) {
188
+ this.ws.send(JSON.stringify({
189
+ type: 'websocket',
190
+ id: message.id,
191
+ event: 'message',
192
+ data: data.toString('base64')
193
+ }));
194
+ }
195
+ });
196
+ // Handle close
197
+ client.on('close', () => {
198
+ console.log(`Local WebSocket connection closed: ${message.id}`);
199
+ if (this.ws && this.ws.readyState === ws_1.default.OPEN) {
200
+ this.ws.send(JSON.stringify({
201
+ type: 'websocket',
202
+ id: message.id,
203
+ event: 'close'
204
+ }));
205
+ }
206
+ this.webSocketClients.delete(message.id);
207
+ });
208
+ // Handle errors
209
+ client.on('error', (error) => {
210
+ console.error(`Local WebSocket connection error: ${message.id}`, error.message);
211
+ if (this.ws && this.ws.readyState === ws_1.default.OPEN) {
212
+ this.ws.send(JSON.stringify({
213
+ type: 'websocket',
214
+ id: message.id,
215
+ event: 'error',
216
+ data: error.message
217
+ }));
218
+ }
219
+ this.webSocketClients.delete(message.id);
220
+ });
221
+ this.emit("websocket_connection", { id: message.id, path: message.path });
222
+ }
223
+ catch (error) {
224
+ console.error('Error establishing WebSocket connection:', error);
225
+ if (this.ws && this.ws.readyState === ws_1.default.OPEN) {
226
+ this.ws.send(JSON.stringify({
227
+ type: 'websocket',
228
+ id: message.id,
229
+ event: 'error',
230
+ data: error.message
231
+ }));
232
+ }
233
+ }
234
+ }
235
+ handleWebSocketMessage(message) {
236
+ const client = this.webSocketClients.get(message.id);
237
+ if (!client)
238
+ return;
239
+ if (message.event === 'message' && message.data) {
240
+ const data = Buffer.from(message.data, 'base64');
241
+ if (client.readyState === ws_1.default.OPEN) {
242
+ client.send(data);
243
+ }
244
+ }
245
+ else if (message.event === 'close') {
246
+ if (client.readyState === ws_1.default.OPEN) {
247
+ client.close();
248
+ }
249
+ this.webSocketClients.delete(message.id);
250
+ }
251
+ }
252
+ handleSSEConnection(message) {
253
+ try {
254
+ // Create an EventSource-like stream to the local server
255
+ // Since Node.js doesn't have a built-in EventSource, we'll use HTTP
256
+ const options = {
257
+ hostname: 'localhost',
258
+ port: this.port,
259
+ path: message.path,
260
+ method: 'GET',
261
+ headers: message.headers,
262
+ };
263
+ const req = http_1.default.request(options, (res) => {
264
+ if (res.statusCode !== 200) {
265
+ if (this.ws && this.ws.readyState === ws_1.default.OPEN) {
266
+ this.ws.send(JSON.stringify({
267
+ type: 'sse',
268
+ id: message.id,
269
+ event: 'error',
270
+ data: `Server responded with status code ${res.statusCode}`
271
+ }));
272
+ }
273
+ return;
274
+ }
275
+ // Process SSE stream
276
+ let buffer = '';
277
+ res.on('data', (chunk) => {
278
+ buffer += chunk.toString();
279
+ // Process complete SSE messages
280
+ while (buffer.includes('\n\n')) {
281
+ const messageEndIndex = buffer.indexOf('\n\n');
282
+ const messageData = buffer.substring(0, messageEndIndex);
283
+ buffer = buffer.substring(messageEndIndex + 2);
284
+ // Parse the SSE message
285
+ const lines = messageData.split('\n');
286
+ let event = 'message';
287
+ let data = '';
288
+ for (const line of lines) {
289
+ if (line.startsWith('event:')) {
290
+ event = line.substring(6).trim();
291
+ }
292
+ else if (line.startsWith('data:')) {
293
+ data += line.substring(5).trim() + '\n';
294
+ }
295
+ }
296
+ // Remove trailing newline
297
+ if (data.endsWith('\n')) {
298
+ data = data.substring(0, data.length - 1);
299
+ }
300
+ // Forward to server
301
+ if (this.ws && this.ws.readyState === ws_1.default.OPEN) {
302
+ this.ws.send(JSON.stringify({
303
+ type: 'sse',
304
+ id: message.id,
305
+ event,
306
+ data
307
+ }));
308
+ }
309
+ }
310
+ });
311
+ res.on('end', () => {
312
+ if (this.ws && this.ws.readyState === ws_1.default.OPEN) {
313
+ this.ws.send(JSON.stringify({
314
+ type: 'sse',
315
+ id: message.id,
316
+ event: 'close',
317
+ data: 'Connection closed'
318
+ }));
319
+ }
320
+ });
321
+ });
322
+ req.on('error', (error) => {
323
+ if (this.ws && this.ws.readyState === ws_1.default.OPEN) {
324
+ this.ws.send(JSON.stringify({
325
+ type: 'sse',
326
+ id: message.id,
327
+ event: 'error',
328
+ data: error.message
329
+ }));
330
+ }
331
+ });
332
+ req.end();
333
+ // Store a reference to abort the connection later if needed
334
+ this.sseClients.set(message.id, req);
335
+ this.emit("sse_connection", { id: message.id, path: message.path });
336
+ }
337
+ catch (error) {
338
+ console.error('Error establishing SSE connection:', error);
339
+ if (this.ws && this.ws.readyState === ws_1.default.OPEN) {
340
+ this.ws.send(JSON.stringify({
341
+ type: 'sse',
342
+ id: message.id,
343
+ event: 'error',
344
+ data: error.message
345
+ }));
346
+ }
347
+ }
348
+ }
349
+ handleSSEClose(message) {
350
+ const client = this.sseClients.get(message.id);
351
+ if (client) {
352
+ // Abort the request if it's still active
353
+ client.destroy();
354
+ this.sseClients.delete(message.id);
355
+ }
356
+ }
114
357
  forwardRequest(request) {
115
358
  return new Promise((resolve, reject) => {
116
359
  const options = {
@@ -153,19 +396,73 @@ class DainTunnel extends events_1.EventEmitter {
153
396
  attemptReconnect() {
154
397
  if (this.reconnectAttempts < this.maxReconnectAttempts) {
155
398
  this.reconnectAttempts++;
156
- setTimeout(() => this.connect(), this.reconnectDelay);
399
+ console.log(`Attempting to reconnect (${this.reconnectAttempts}/${this.maxReconnectAttempts})`);
400
+ setTimeout(async () => {
401
+ try {
402
+ await this.connect();
403
+ // If the connect succeeds but we don't have a tunnelUrl yet, we're still initializing
404
+ if (!this.tunnelUrl) {
405
+ console.log('Reconnected but tunnelUrl not set. Waiting for tunnel URL...');
406
+ }
407
+ else {
408
+ console.log(`Reconnected successfully. Tunnel URL: ${this.tunnelUrl}`);
409
+ }
410
+ }
411
+ catch (error) {
412
+ console.error(`Reconnection attempt ${this.reconnectAttempts} failed:`, error);
413
+ }
414
+ }, this.reconnectDelay);
157
415
  }
158
416
  else {
417
+ console.log('Maximum reconnection attempts reached');
159
418
  this.emit("max_reconnect_attempts");
160
419
  }
161
420
  }
162
421
  async stop() {
163
- return new Promise((resolve) => {
164
- if (this.ws) {
165
- this.ws.close();
166
- this.ws = null;
422
+ return new Promise(async (resolve) => {
423
+ try {
424
+ // Close all WebSocket clients
425
+ for (const [id, client] of this.webSocketClients.entries()) {
426
+ try {
427
+ if (client.readyState === ws_1.default.OPEN) {
428
+ client.close();
429
+ }
430
+ }
431
+ catch (error) {
432
+ console.error(`Error closing WebSocket client ${id}:`, error);
433
+ }
434
+ }
435
+ this.webSocketClients.clear();
436
+ // Close all SSE clients
437
+ for (const [id, client] of this.sseClients.entries()) {
438
+ try {
439
+ client.destroy();
440
+ }
441
+ catch (error) {
442
+ console.error(`Error destroying SSE client ${id}:`, error);
443
+ }
444
+ }
445
+ this.sseClients.clear();
446
+ // Close main WebSocket connection
447
+ if (this.ws) {
448
+ try {
449
+ if (this.ws.readyState === ws_1.default.OPEN) {
450
+ this.ws.close();
451
+ }
452
+ }
453
+ catch (error) {
454
+ console.error('Error closing main WebSocket:', error);
455
+ }
456
+ this.ws = null;
457
+ }
458
+ // Wait for all connections to close properly
459
+ await new Promise(resolve => setTimeout(resolve, 500));
460
+ resolve();
461
+ }
462
+ catch (error) {
463
+ console.error('Error in stop method:', error);
464
+ resolve(); // Resolve anyway to not block cleaning up
167
465
  }
168
- resolve();
169
466
  });
170
467
  }
171
468
  }
@@ -7,13 +7,20 @@ declare class DainTunnelServer {
7
7
  private tunnels;
8
8
  private pendingRequests;
9
9
  private challenges;
10
+ private sseConnections;
11
+ private wsConnections;
10
12
  constructor(hostname: string, port: number);
11
13
  private setupExpressRoutes;
12
14
  private setupWebSocketServer;
15
+ private handleTunnelClientConnection;
16
+ private handleProxiedWebSocketConnection;
13
17
  private handleChallengeRequest;
14
18
  private handleStartMessage;
15
19
  private handleResponseMessage;
16
- private handleHttpRequest;
20
+ private handleSSEMessage;
21
+ private handleWebSocketMessage;
22
+ private handleRequest;
23
+ private handleSSERequest;
17
24
  private removeTunnel;
18
25
  start(): Promise<void>;
19
26
  stop(): Promise<void>;
@@ -9,8 +9,9 @@ const ws_1 = __importDefault(require("ws"));
9
9
  const uuid_1 = require("uuid");
10
10
  const body_parser_1 = __importDefault(require("body-parser"));
11
11
  const cors_1 = __importDefault(require("cors"));
12
- const client_1 = require("@dainprotocol/service-sdk/client");
13
- const bs58_1 = __importDefault(require("bs58"));
12
+ const auth_1 = require("@dainprotocol/service-sdk/service/auth");
13
+ const crypto_1 = require("crypto");
14
+ const url_1 = require("url");
14
15
  class DainTunnelServer {
15
16
  constructor(hostname, port) {
16
17
  this.hostname = hostname;
@@ -18,11 +19,20 @@ class DainTunnelServer {
18
19
  this.tunnels = new Map();
19
20
  this.pendingRequests = new Map();
20
21
  this.challenges = new Map();
22
+ this.sseConnections = new Map();
23
+ this.wsConnections = new Map();
21
24
  this.app = (0, express_1.default)();
22
25
  this.server = http_1.default.createServer(this.app);
23
- this.wss = new ws_1.default.Server({ server: this.server });
26
+ this.wss = new ws_1.default.Server({
27
+ server: this.server,
28
+ path: undefined // Allow connections on any path
29
+ });
24
30
  // Use cors middleware
25
- this.app.use((0, cors_1.default)());
31
+ this.app.use((0, cors_1.default)({
32
+ origin: '*',
33
+ methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
34
+ credentials: true
35
+ }));
26
36
  // Update CORS middleware
27
37
  this.app.use((req, res, next) => {
28
38
  res.header("Access-Control-Allow-Origin", "*");
@@ -39,37 +49,132 @@ class DainTunnelServer {
39
49
  this.setupWebSocketServer();
40
50
  }
41
51
  setupExpressRoutes() {
42
- this.app.use("/:tunnelId", this.handleHttpRequest.bind(this));
52
+ // Generic route handler for all tunnel requests
53
+ this.app.use("/:tunnelId", this.handleRequest.bind(this));
43
54
  }
44
55
  setupWebSocketServer() {
45
- this.wss.on("connection", (ws) => {
56
+ // Handle WebSocket connections from tunnel clients
57
+ this.wss.on("connection", (ws, req) => {
58
+ var _a;
46
59
  console.log("New WebSocket connection");
47
- ws.on("message", (message) => {
48
- try {
49
- const data = JSON.parse(message);
50
- console.log(`Received WebSocket message: ${data.type}`);
51
- if (data.type === "challenge_request") {
52
- this.handleChallengeRequest(ws);
53
- }
54
- else if (data.type === "start") {
55
- this.handleStartMessage(ws, data);
56
- }
57
- else if (data.type === "response") {
58
- this.handleResponseMessage(data);
59
- }
60
+ try {
61
+ // Check if this is a tunnel client connection or a user connection to be proxied
62
+ const url = (0, url_1.parse)(req.url || '', true);
63
+ const pathParts = ((_a = url.pathname) === null || _a === void 0 ? void 0 : _a.split('/')) || [];
64
+ console.log(`WebSocket connection path: ${url.pathname}`);
65
+ // Client tunnel connection has no tunnelId in the path (or is at root)
66
+ if (!url.pathname || url.pathname === '/' || pathParts.length <= 1 || !pathParts[1]) {
67
+ console.log("Handling as tunnel client connection");
68
+ this.handleTunnelClientConnection(ws, req);
60
69
  }
61
- catch (error) {
62
- console.error("Error processing message:", error);
63
- ws.close(1008, "Invalid message");
70
+ else {
71
+ // This is a WebSocket connection to be proxied through the tunnel
72
+ console.log(`Handling as proxied WebSocket connection for path: ${url.pathname}`);
73
+ this.handleProxiedWebSocketConnection(ws, req);
64
74
  }
65
- });
66
- ws.on("close", () => {
67
- console.log("WebSocket connection closed");
68
- this.removeTunnel(ws);
69
- });
70
- ws.on("error", (error) => {
71
- console.error("WebSocket error:", error);
72
- });
75
+ }
76
+ catch (error) {
77
+ console.error("Error in WebSocket connection setup:", error);
78
+ ws.close(1011, "Internal server error");
79
+ }
80
+ });
81
+ }
82
+ // Handle WebSocket connections from tunnel clients
83
+ handleTunnelClientConnection(ws, req) {
84
+ ws.on("message", (message) => {
85
+ try {
86
+ const data = JSON.parse(message);
87
+ console.log(`Received WebSocket message: ${data.type}`);
88
+ if (data.type === "challenge_request") {
89
+ this.handleChallengeRequest(ws);
90
+ }
91
+ else if (data.type === "start") {
92
+ this.handleStartMessage(ws, data);
93
+ }
94
+ else if (data.type === "response") {
95
+ this.handleResponseMessage(data);
96
+ }
97
+ else if (data.type === "sse") {
98
+ this.handleSSEMessage(data);
99
+ }
100
+ else if (data.type === "websocket") {
101
+ this.handleWebSocketMessage(data);
102
+ }
103
+ }
104
+ catch (error) {
105
+ console.error("Error processing message:", error);
106
+ ws.close(1008, "Invalid message");
107
+ }
108
+ });
109
+ ws.on("close", () => {
110
+ console.log("WebSocket connection closed");
111
+ this.removeTunnel(ws);
112
+ });
113
+ ws.on("error", (error) => {
114
+ console.error("WebSocket error:", error);
115
+ });
116
+ }
117
+ // Handle incoming WebSocket connections to be proxied through the tunnel
118
+ handleProxiedWebSocketConnection(ws, req) {
119
+ var _a;
120
+ const url = (0, url_1.parse)(req.url || '', true);
121
+ const pathParts = ((_a = url.pathname) === null || _a === void 0 ? void 0 : _a.split('/')) || [];
122
+ if (pathParts.length < 2) {
123
+ ws.close(1008, "Invalid tunnel ID");
124
+ return;
125
+ }
126
+ const tunnelId = pathParts[1];
127
+ const remainingPath = '/' + pathParts.slice(2).join('/');
128
+ const tunnel = this.tunnels.get(tunnelId);
129
+ console.log(`Handling WebSocket connection for tunnel: ${tunnelId}, path: ${remainingPath}`);
130
+ console.log(`Available tunnels: ${Array.from(this.tunnels.keys()).join(', ')}`);
131
+ if (!tunnel) {
132
+ console.log(`Tunnel not found for WebSocket connection: ${tunnelId}`);
133
+ ws.close(1008, "Tunnel not found");
134
+ return;
135
+ }
136
+ // Create a WebSocket connection ID
137
+ const wsConnectionId = (0, uuid_1.v4)();
138
+ // Store this connection
139
+ this.wsConnections.set(wsConnectionId, {
140
+ clientSocket: ws,
141
+ id: wsConnectionId,
142
+ path: remainingPath,
143
+ headers: req.headers
144
+ });
145
+ // Notify tunnel client about the new WebSocket connection
146
+ tunnel.ws.send(JSON.stringify({
147
+ type: "websocket_connection",
148
+ id: wsConnectionId,
149
+ path: remainingPath,
150
+ headers: req.headers
151
+ }));
152
+ // Handle messages from the client
153
+ ws.on("message", (data) => {
154
+ tunnel.ws.send(JSON.stringify({
155
+ type: "websocket",
156
+ id: wsConnectionId,
157
+ event: "message",
158
+ data: data.toString('base64')
159
+ }));
160
+ });
161
+ // Handle client disconnection
162
+ ws.on("close", () => {
163
+ tunnel.ws.send(JSON.stringify({
164
+ type: "websocket",
165
+ id: wsConnectionId,
166
+ event: "close"
167
+ }));
168
+ this.wsConnections.delete(wsConnectionId);
169
+ });
170
+ // Handle errors
171
+ ws.on("error", (error) => {
172
+ tunnel.ws.send(JSON.stringify({
173
+ type: "websocket",
174
+ id: wsConnectionId,
175
+ event: "error",
176
+ data: error.message
177
+ }));
73
178
  });
74
179
  }
75
180
  handleChallengeRequest(ws) {
@@ -79,41 +184,96 @@ class DainTunnelServer {
79
184
  ws.send(JSON.stringify({ type: "challenge", challenge }));
80
185
  }
81
186
  handleStartMessage(ws, data) {
82
- const { challenge, signature, tunnelId } = data;
187
+ const { challenge, signature, tunnelId, apiKey } = data;
83
188
  const challengeObj = this.challenges.get(challenge);
84
189
  if (!challengeObj || challengeObj.ws !== ws) {
85
190
  ws.close(1008, "Invalid challenge");
86
191
  return;
87
192
  }
88
193
  this.challenges.delete(challenge);
89
- const publicKey = bs58_1.default.decode(tunnelId);
90
- if (!client_1.DainClientAuth.verifyMessage(challenge, signature, publicKey)) {
91
- ws.close(1008, "Invalid signature");
92
- return;
93
- }
94
- this.tunnels.set(tunnelId, { id: tunnelId, ws });
95
- console.log(`Tunnel added: ${tunnelId}`);
96
- console.log(`Current tunnels: ${Array.from(this.tunnels.keys()).join(', ')}`);
97
- // Add a periodic check to ensure the tunnel is still in the map
98
- const intervalId = setInterval(() => {
194
+ try {
195
+ // Parse API key to get the secret for HMAC validation
196
+ if (!apiKey) {
197
+ ws.close(1008, "API key required");
198
+ return;
199
+ }
200
+ const parsed = (0, auth_1.parseAPIKey)(apiKey);
201
+ if (!parsed) {
202
+ ws.close(1008, "Invalid API key format");
203
+ return;
204
+ }
205
+ // Validate HMAC signature
206
+ const expectedSignature = (0, crypto_1.createHmac)('sha256', parsed.secret)
207
+ .update(challenge)
208
+ .digest('hex');
209
+ if (expectedSignature !== signature) {
210
+ ws.close(1008, "Invalid signature");
211
+ return;
212
+ }
213
+ // Verify that tunnelId matches the agentId from the API key
214
+ if (tunnelId !== parsed.agentId) {
215
+ ws.close(1008, "Tunnel ID does not match API key");
216
+ return;
217
+ }
218
+ // If tunnel already exists, remove old one
99
219
  if (this.tunnels.has(tunnelId)) {
100
- console.log(`Tunnel ${tunnelId} still active`);
220
+ console.log(`Tunnel ${tunnelId} already exists, replacing it`);
221
+ const oldTunnel = this.tunnels.get(tunnelId);
222
+ if (oldTunnel && oldTunnel.ws !== ws) {
223
+ oldTunnel.ws.close(1000, "Replaced by new connection");
224
+ }
101
225
  }
102
- else {
103
- console.log(`Tunnel ${tunnelId} not found in periodic check`);
226
+ this.tunnels.set(tunnelId, { id: tunnelId, ws });
227
+ console.log(`Tunnel added: ${tunnelId}`);
228
+ console.log(`Current tunnels: ${Array.from(this.tunnels.keys()).join(', ')}`);
229
+ // Save tunnel ID on the WebSocket object for easy lookup on close
230
+ ws.tunnelId = tunnelId;
231
+ // Add a periodic check to ensure the tunnel is still in the map
232
+ const intervalId = setInterval(() => {
233
+ if (this.tunnels.has(tunnelId)) {
234
+ const tunnel = this.tunnels.get(tunnelId);
235
+ if (tunnel && tunnel.ws === ws && ws.readyState === ws_1.default.OPEN) {
236
+ console.log(`Tunnel ${tunnelId} still active`);
237
+ // Send a ping to keep the connection alive
238
+ try {
239
+ ws.ping();
240
+ }
241
+ catch (error) {
242
+ console.error(`Error sending ping to tunnel ${tunnelId}:`, error);
243
+ clearInterval(intervalId);
244
+ }
245
+ }
246
+ else {
247
+ console.log(`Tunnel ${tunnelId} exists but WebSocket state is invalid, cleaning up`);
248
+ clearInterval(intervalId);
249
+ if (tunnel && tunnel.ws === ws) {
250
+ this.tunnels.delete(tunnelId);
251
+ }
252
+ }
253
+ }
254
+ else {
255
+ console.log(`Tunnel ${tunnelId} not found in periodic check`);
256
+ clearInterval(intervalId);
257
+ }
258
+ }, 5000); // Check every 5 seconds
259
+ // Store the interval ID on the WebSocket object so we can clean it up
260
+ ws.keepAliveInterval = intervalId;
261
+ ws.on("close", () => {
262
+ console.log(`WebSocket for tunnel ${tunnelId} closed, clearing interval`);
104
263
  clearInterval(intervalId);
264
+ });
265
+ let tunnelUrl = `${this.hostname}`;
266
+ if (process.env.SKIP_PORT !== "true") {
267
+ tunnelUrl += `:${this.port}`;
105
268
  }
106
- }, 5000); // Check every 5 seconds
107
- ws.on("close", () => {
108
- clearInterval(intervalId);
109
- });
110
- let tunnelUrl = `${this.hostname}`;
111
- if (process.env.SKIP_PORT !== "true") {
112
- tunnelUrl += `:${this.port}`;
269
+ tunnelUrl += `/${tunnelId}`;
270
+ ws.send(JSON.stringify({ type: "tunnelUrl", url: tunnelUrl }));
271
+ console.log(`New tunnel created: ${tunnelUrl}`);
272
+ }
273
+ catch (error) {
274
+ console.error(`Error in handleStartMessage for tunnel ${tunnelId}:`, error);
275
+ ws.close(1011, "Internal server error");
113
276
  }
114
- tunnelUrl += `/${tunnelId}`;
115
- ws.send(JSON.stringify({ type: "tunnelUrl", url: tunnelUrl }));
116
- console.log(`New tunnel created: ${tunnelUrl}`);
117
277
  }
118
278
  handleResponseMessage(data) {
119
279
  const pendingRequest = this.pendingRequests.get(data.requestId);
@@ -133,8 +293,61 @@ class DainTunnelServer {
133
293
  this.pendingRequests.delete(data.requestId);
134
294
  }
135
295
  }
136
- async handleHttpRequest(req, res) {
296
+ handleSSEMessage(data) {
297
+ const connection = this.sseConnections.get(data.id);
298
+ if (!connection) {
299
+ console.log(`SSE connection not found: ${data.id}`);
300
+ return;
301
+ }
302
+ const { res } = connection;
303
+ if (data.event) {
304
+ res.write(`event: ${data.event}\n`);
305
+ }
306
+ // Split data by newlines and send each line
307
+ const dataLines = data.data.split('\n');
308
+ for (const line of dataLines) {
309
+ res.write(`data: ${line}\n`);
310
+ }
311
+ res.write('\n');
312
+ // Check if this is a "close" event
313
+ if (data.event === 'close') {
314
+ res.end();
315
+ this.sseConnections.delete(data.id);
316
+ }
317
+ }
318
+ handleWebSocketMessage(data) {
319
+ const connection = this.wsConnections.get(data.id);
320
+ if (!connection) {
321
+ console.log(`WebSocket connection not found: ${data.id}`);
322
+ return;
323
+ }
324
+ const { clientSocket } = connection;
325
+ if (data.event === 'message' && data.data) {
326
+ const messageData = Buffer.from(data.data, 'base64');
327
+ if (clientSocket.readyState === ws_1.default.OPEN) {
328
+ clientSocket.send(messageData);
329
+ }
330
+ }
331
+ else if (data.event === 'close') {
332
+ if (clientSocket.readyState === ws_1.default.OPEN) {
333
+ clientSocket.close();
334
+ }
335
+ this.wsConnections.delete(data.id);
336
+ }
337
+ else if (data.event === 'error' && data.data) {
338
+ if (clientSocket.readyState === ws_1.default.OPEN) {
339
+ clientSocket.close(1011, data.data);
340
+ }
341
+ this.wsConnections.delete(data.id);
342
+ }
343
+ }
344
+ async handleRequest(req, res) {
137
345
  const tunnelId = req.params.tunnelId;
346
+ // Check for upgraded connections (WebSockets) - these are handled by the WebSocket server
347
+ if (req.headers.upgrade && req.headers.upgrade.toLowerCase() === 'websocket') {
348
+ // This is handled by the WebSocket server now
349
+ return;
350
+ }
138
351
  let tunnel;
139
352
  let retries = 3;
140
353
  while (retries > 0 && !tunnel) {
@@ -150,6 +363,11 @@ class DainTunnelServer {
150
363
  console.log(`Tunnel not found after retries: ${tunnelId}`);
151
364
  return res.status(404).send("Tunnel not found");
152
365
  }
366
+ // Check for SSE request
367
+ if (req.headers.accept && req.headers.accept.includes('text/event-stream')) {
368
+ return this.handleSSERequest(req, res, tunnelId, tunnel);
369
+ }
370
+ // Handle regular HTTP request
153
371
  const requestId = (0, uuid_1.v4)();
154
372
  const startTime = Date.now();
155
373
  this.pendingRequests.set(requestId, { res, startTime });
@@ -166,28 +384,109 @@ class DainTunnelServer {
166
384
  tunnel.ws.send(JSON.stringify(requestMessage));
167
385
  console.log(`Request forwarded: ${requestId}, Method: ${req.method}, Path: ${req.url}`);
168
386
  }
387
+ handleSSERequest(req, res, tunnelId, tunnel) {
388
+ // Setup SSE connection
389
+ const sseId = (0, uuid_1.v4)();
390
+ res.writeHead(200, {
391
+ 'Content-Type': 'text/event-stream',
392
+ 'Cache-Control': 'no-cache',
393
+ 'Connection': 'keep-alive'
394
+ });
395
+ // Send initial connection event
396
+ res.write('\n');
397
+ // Store the SSE connection
398
+ this.sseConnections.set(sseId, {
399
+ req,
400
+ res,
401
+ id: sseId,
402
+ tunnelId
403
+ });
404
+ // Notify the tunnel client about the new SSE connection
405
+ tunnel.ws.send(JSON.stringify({
406
+ type: "sse_connection",
407
+ id: sseId,
408
+ path: req.url,
409
+ headers: req.headers
410
+ }));
411
+ // Handle client disconnect
412
+ req.on('close', () => {
413
+ tunnel.ws.send(JSON.stringify({
414
+ type: "sse_close",
415
+ id: sseId
416
+ }));
417
+ this.sseConnections.delete(sseId);
418
+ });
419
+ }
169
420
  removeTunnel(ws) {
170
- let removedTunnelId;
171
- for (const [id, tunnel] of this.tunnels.entries()) {
172
- if (tunnel.ws === ws) {
173
- this.tunnels.delete(id);
174
- removedTunnelId = id;
175
- console.log(`Tunnel removed: ${id}`);
176
- break;
421
+ try {
422
+ // Clear any interval timer
423
+ if (ws.keepAliveInterval) {
424
+ clearInterval(ws.keepAliveInterval);
177
425
  }
178
- }
179
- if (removedTunnelId) {
180
- console.log(`Tunnel ${removedTunnelId} removed. Current tunnels: ${Array.from(this.tunnels.keys()).join(', ')}`);
181
- }
182
- else {
183
- console.log(`No tunnel found to remove for the closed WebSocket connection`);
184
- }
185
- // Also remove any pending challenges for this WebSocket
186
- for (const [challenge, challengeObj] of this.challenges.entries()) {
187
- if (challengeObj.ws === ws) {
188
- this.challenges.delete(challenge);
189
- break;
426
+ // If we saved tunnelId on the WebSocket object, use it for faster lookup
427
+ const tunnelId = ws.tunnelId;
428
+ let removedTunnelId = tunnelId;
429
+ if (tunnelId && this.tunnels.has(tunnelId)) {
430
+ const tunnel = this.tunnels.get(tunnelId);
431
+ if (tunnel && tunnel.ws === ws) {
432
+ this.tunnels.delete(tunnelId);
433
+ console.log(`Tunnel removed using stored ID: ${tunnelId}`);
434
+ }
435
+ }
436
+ else {
437
+ // Fall back to iterating through all tunnels
438
+ removedTunnelId = undefined;
439
+ for (const [id, tunnel] of this.tunnels.entries()) {
440
+ if (tunnel.ws === ws) {
441
+ this.tunnels.delete(id);
442
+ removedTunnelId = id;
443
+ console.log(`Tunnel removed: ${id}`);
444
+ break;
445
+ }
446
+ }
447
+ }
448
+ if (removedTunnelId) {
449
+ console.log(`Tunnel ${removedTunnelId} removed. Current tunnels: ${Array.from(this.tunnels.keys()).join(', ')}`);
450
+ // Close all SSE connections associated with this tunnel
451
+ for (const [sseId, sseConnection] of this.sseConnections.entries()) {
452
+ if (sseConnection.tunnelId === removedTunnelId) {
453
+ try {
454
+ sseConnection.res.end();
455
+ }
456
+ catch (error) {
457
+ console.error(`Error closing SSE connection ${sseId}:`, error);
458
+ }
459
+ this.sseConnections.delete(sseId);
460
+ }
461
+ }
462
+ // Close all WebSocket connections associated with this tunnel
463
+ for (const [wsId, wsConnection] of this.wsConnections.entries()) {
464
+ const wsPath = wsConnection.path;
465
+ if (wsPath.startsWith(`/${removedTunnelId}/`)) {
466
+ try {
467
+ wsConnection.clientSocket.close(1001, "Tunnel closed");
468
+ }
469
+ catch (error) {
470
+ console.error(`Error closing WebSocket connection ${wsId}:`, error);
471
+ }
472
+ this.wsConnections.delete(wsId);
473
+ }
474
+ }
475
+ }
476
+ else {
477
+ console.log(`No tunnel found to remove for the closed WebSocket connection`);
190
478
  }
479
+ // Also remove any pending challenges for this WebSocket
480
+ for (const [challenge, challengeObj] of this.challenges.entries()) {
481
+ if (challengeObj.ws === ws) {
482
+ this.challenges.delete(challenge);
483
+ console.log(`Challenge removed for closed WebSocket`);
484
+ break;
485
+ }
486
+ }
487
+ }
488
+ catch (error) {
489
+ console.error("Error in removeTunnel:", error);
191
490
  }
192
491
  }
193
492
  async start() {
@@ -200,11 +499,39 @@ class DainTunnelServer {
200
499
  }
201
500
  async stop() {
202
501
  return new Promise((resolve) => {
203
- this.wss.close(() => {
204
- this.server.close(() => {
205
- resolve();
502
+ try {
503
+ // Close all SSE connections
504
+ for (const [sseId, sseConnection] of this.sseConnections.entries()) {
505
+ try {
506
+ sseConnection.res.end();
507
+ }
508
+ catch (error) {
509
+ console.error(`Error closing SSE connection ${sseId}:`, error);
510
+ }
511
+ this.sseConnections.delete(sseId);
512
+ }
513
+ // Close all WebSocket connections
514
+ for (const [wsId, wsConnection] of this.wsConnections.entries()) {
515
+ try {
516
+ wsConnection.clientSocket.close(1001, "Server shutting down");
517
+ }
518
+ catch (error) {
519
+ console.error(`Error closing WebSocket connection ${wsId}:`, error);
520
+ }
521
+ this.wsConnections.delete(wsId);
522
+ }
523
+ // Close the WebSocket server
524
+ this.wss.close(() => {
525
+ // Close the HTTP server
526
+ this.server.close(() => {
527
+ resolve();
528
+ });
206
529
  });
207
- });
530
+ }
531
+ catch (error) {
532
+ console.error('Error during server shutdown:', error);
533
+ resolve(); // Resolve anyway to prevent hanging
534
+ }
208
535
  });
209
536
  }
210
537
  }
@@ -13,6 +13,7 @@ const server = new index_1.default(hostname, port);
13
13
  server.start()
14
14
  .then(() => {
15
15
  console.log(`DainTunnel Server started on http://${hostname}:${port}`);
16
+ console.log(`Supports HTTP, WebSockets, and Server-Sent Events (SSE)`);
16
17
  })
17
18
  .catch((error) => {
18
19
  console.error('Failed to start DainTunnel Server:', error);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dainprotocol/tunnel",
3
- "version": "1.0.5",
3
+ "version": "1.1.0",
4
4
  "description": "",
5
5
  "main": "dist/index.js",
6
6
  "private": false,
@@ -19,9 +19,10 @@
19
19
  "author": "Ryan",
20
20
  "license": "ISC",
21
21
  "dependencies": {
22
- "@dainprotocol/service-sdk": "^1.0.11",
22
+ "@dainprotocol/service-sdk": "^1.3.0",
23
23
  "@types/body-parser": "^1.19.5",
24
24
  "@types/cors": "^2.8.17",
25
+ "@types/eventsource": "^3.0.0",
25
26
  "@types/express": "^4.17.21",
26
27
  "@types/node": "^22.5.4",
27
28
  "@types/uuid": "^10.0.0",
@@ -30,6 +31,7 @@
30
31
  "bs58": "^6.0.0",
31
32
  "cors": "^2.8.5",
32
33
  "dotenv": "^16.4.5",
34
+ "eventsource": "^3.0.6",
33
35
  "express": "^4.19.2",
34
36
  "fetch-mock": "^11.1.3",
35
37
  "ts-node": "^10.9.2",
@@ -43,13 +45,12 @@
43
45
  "ts-jest": "^29.1.5",
44
46
  "typescript": "^5.0.0"
45
47
  },
46
-
47
48
  "files": [
48
49
  "dist",
49
50
  "README.md"
50
51
  ],
51
52
  "engines": {
52
- "node": "20.7.0"
53
+ "node": ">=20.7.0"
53
54
  },
54
55
  "exports": {
55
56
  ".": "./dist/index.js",