@dainprotocol/tunnel 2.0.0 → 2.0.3

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.
@@ -18,6 +18,11 @@ const RECONNECT = {
18
18
  const LIMITS = {
19
19
  MAX_PENDING_WS_MESSAGES_PER_CONNECTION: 256,
20
20
  };
21
+ function emitClientError(emitter, error) {
22
+ if (emitter.listenerCount("error") > 0) {
23
+ emitter.emit("error", error);
24
+ }
25
+ }
21
26
  function rawDataToString(message) {
22
27
  if (typeof message === "string")
23
28
  return message;
@@ -81,7 +86,7 @@ class DainTunnel extends EventEmitter {
81
86
  this.ws.ping();
82
87
  }
83
88
  catch {
84
- this.emit("error", new Error("Heartbeat ping failed"));
89
+ emitClientError(this, new Error("Heartbeat ping failed"));
85
90
  }
86
91
  }
87
92
  }, TIMEOUTS.HEARTBEAT);
@@ -160,7 +165,7 @@ class DainTunnel extends EventEmitter {
160
165
  finish(undefined, new Error("Connection closed before tunnel established"));
161
166
  }
162
167
  });
163
- this.ws.on("error", (error) => this.emit("error", error));
168
+ this.ws.on("error", (error) => emitClientError(this, error));
164
169
  connectionTimeoutId = setTimeout(() => {
165
170
  if (!resolved && (!this.ws || this.ws.readyState !== WebSocket.OPEN)) {
166
171
  finish(undefined, new Error("Connection timeout"));
@@ -552,8 +557,12 @@ class DainTunnel extends EventEmitter {
552
557
  }
553
558
  if (this.ws) {
554
559
  try {
555
- if (this.ws.readyState === WebSocket.OPEN)
556
- this.ws.close();
560
+ if (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING) {
561
+ this.ws.close(1000, "Client stopping");
562
+ }
563
+ else if (this.ws.readyState !== WebSocket.CLOSED) {
564
+ this.ws.terminate();
565
+ }
557
566
  }
558
567
  catch { }
559
568
  this.ws = null;
@@ -9,7 +9,9 @@ declare class DainTunnelServer {
9
9
  private challenges;
10
10
  private sseConnections;
11
11
  private wsConnections;
12
+ private pendingProxiedWSEvents;
12
13
  private tunnelRequestCount;
14
+ private buildCorsHeaders;
13
15
  private safeSend;
14
16
  private decrementRequestCount;
15
17
  private buildForwardedHeaders;
@@ -26,6 +28,7 @@ declare class DainTunnelServer {
26
28
  private handleSSEMessage;
27
29
  private cleanupSSEConnection;
28
30
  private handleWebSocketMessage;
31
+ private flushPendingWebSocketEvents;
29
32
  private handleRequest;
30
33
  private handleSSERequest;
31
34
  private removeTunnel;
@@ -15,10 +15,20 @@ const LIMITS = {
15
15
  MAX_MISSED_PONGS: 2,
16
16
  MAX_PAYLOAD_BYTES: 100 * 1024 * 1024,
17
17
  BACKPRESSURE_THRESHOLD: 1024 * 1024,
18
+ MAX_PENDING_WS_EVENTS_PER_CONNECTION: 256,
18
19
  SERVER_MAX_HEADERS: 100,
19
20
  WS_BACKLOG: 100,
20
21
  TUNNEL_RETRY_COUNT: 2,
21
22
  };
23
+ const DEBUG_TUNNEL = process.env.DAIN_TUNNEL_DEBUG === "1";
24
+ function debugLog(...args) {
25
+ if (DEBUG_TUNNEL)
26
+ console.log(...args);
27
+ }
28
+ function debugError(...args) {
29
+ if (DEBUG_TUNNEL)
30
+ console.error(...args);
31
+ }
22
32
  let idCounter = 0;
23
33
  const ID_PREFIX = Date.now().toString(36);
24
34
  function fastId() {
@@ -52,6 +62,21 @@ function isCorsOriginAllowed(origin, allowedOrigins) {
52
62
  }
53
63
  const ALLOWED_CORS_ORIGINS = parseAllowedCorsOrigins(process.env.TUNNEL_ALLOWED_ORIGINS || process.env.CORS_ALLOWED_ORIGINS);
54
64
  class DainTunnelServer {
65
+ buildCorsHeaders(origin) {
66
+ const headers = {
67
+ "access-control-allow-methods": "GET, POST, PUT, DELETE, PATCH, OPTIONS",
68
+ "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",
69
+ };
70
+ if (origin && isCorsOriginAllowed(origin, this.allowedCorsOrigins)) {
71
+ headers["access-control-allow-origin"] = origin;
72
+ headers["access-control-allow-credentials"] = "true";
73
+ headers["vary"] = "Origin";
74
+ }
75
+ else if (!origin) {
76
+ headers["access-control-allow-origin"] = "*";
77
+ }
78
+ return headers;
79
+ }
55
80
  safeSend(ws, data) {
56
81
  try {
57
82
  if (ws.readyState === 1) { // WebSocket.OPEN
@@ -60,7 +85,7 @@ class DainTunnelServer {
60
85
  }
61
86
  }
62
87
  catch (error) {
63
- console.error("[Tunnel] SafeSend error:", error);
88
+ debugError("[Tunnel] SafeSend error:", error);
64
89
  }
65
90
  return false;
66
91
  }
@@ -99,6 +124,7 @@ class DainTunnelServer {
99
124
  this.challenges = new Map();
100
125
  this.sseConnections = new Map();
101
126
  this.wsConnections = new Map();
127
+ this.pendingProxiedWSEvents = new Map();
102
128
  this.tunnelRequestCount = new Map();
103
129
  this.app = new Hono();
104
130
  this.setupRoutes();
@@ -183,7 +209,7 @@ class DainTunnelServer {
183
209
  }
184
210
  }
185
211
  catch (error) {
186
- console.error("[Tunnel] Message error:", error);
212
+ debugError("[Tunnel] Message error:", error);
187
213
  ws.close(1008, "Invalid message");
188
214
  }
189
215
  }
@@ -279,7 +305,7 @@ class DainTunnelServer {
279
305
  if (!isAlive) {
280
306
  missedPongs++;
281
307
  if (missedPongs >= LIMITS.MAX_MISSED_PONGS) {
282
- console.log(`[Tunnel] ${tunnelId} failed liveness check (${missedPongs} missed pongs), terminating`);
308
+ debugLog(`[Tunnel] ${tunnelId} failed liveness check (${missedPongs} missed pongs), terminating`);
283
309
  clearInterval(intervalId);
284
310
  ws.close(1001, "Liveness check failed");
285
311
  return;
@@ -300,13 +326,13 @@ class DainTunnelServer {
300
326
  // We track isAlive through the pong handler set up in Bun.serve websocket config
301
327
  const tunnelUrl = this.buildTunnelUrl(tunnelId);
302
328
  ws.send(JSON.stringify({ type: "tunnelUrl", url: tunnelUrl }));
303
- console.log(`[Tunnel] Created: ${tunnelUrl}`);
329
+ debugLog(`[Tunnel] Created: ${tunnelUrl}`);
304
330
  // Store isAlive/missedPongs on ws.data for the pong handler
305
331
  ws.data._isAlive = true;
306
332
  ws.data._missedPongsReset = () => { isAlive = true; missedPongs = 0; };
307
333
  }
308
334
  catch (error) {
309
- console.error(`Error in handleStartMessage for tunnel ${tunnelId}:`, error);
335
+ debugError(`Error in handleStartMessage for tunnel ${tunnelId}:`, error);
310
336
  ws.close(1011, "Internal server error");
311
337
  }
312
338
  }
@@ -314,7 +340,7 @@ class DainTunnelServer {
314
340
  const pendingRequest = this.pendingRequests.get(data.requestId);
315
341
  if (!pendingRequest)
316
342
  return;
317
- const { resolve, tunnelId, timeoutId } = pendingRequest;
343
+ const { resolve, tunnelId, timeoutId, origin } = pendingRequest;
318
344
  this.pendingRequests.delete(data.requestId);
319
345
  if (timeoutId)
320
346
  clearTimeout(timeoutId);
@@ -322,6 +348,8 @@ class DainTunnelServer {
322
348
  const headers = data.headers;
323
349
  delete headers["transfer-encoding"];
324
350
  delete headers["content-length"];
351
+ // Inject CORS headers — Hono middleware doesn't apply to Promise-resolved responses
352
+ Object.assign(headers, this.buildCorsHeaders(origin));
325
353
  const bodyBuffer = Buffer.from(data.body, "base64");
326
354
  headers["content-length"] = bodyBuffer.length.toString();
327
355
  resolve(new Response(bodyBuffer, {
@@ -373,8 +401,28 @@ class DainTunnelServer {
373
401
  }
374
402
  handleWebSocketMessage(data) {
375
403
  const connection = this.wsConnections.get(data.id);
376
- if (!connection)
404
+ if (!connection) {
405
+ const pendingEvents = this.pendingProxiedWSEvents.get(data.id);
406
+ if (!pendingEvents)
407
+ return;
408
+ if (data.event === "message" &&
409
+ pendingEvents.length >= LIMITS.MAX_PENDING_WS_EVENTS_PER_CONNECTION) {
410
+ pendingEvents.push({
411
+ type: "websocket",
412
+ id: data.id,
413
+ event: "error",
414
+ data: "WebSocket downstream overloaded",
415
+ });
416
+ pendingEvents.push({
417
+ type: "websocket",
418
+ id: data.id,
419
+ event: "close",
420
+ });
421
+ return;
422
+ }
423
+ pendingEvents.push(data);
377
424
  return;
425
+ }
378
426
  const { clientSocket } = connection;
379
427
  const isOpen = clientSocket.readyState === 1;
380
428
  switch (data.event) {
@@ -394,6 +442,17 @@ class DainTunnelServer {
394
442
  break;
395
443
  }
396
444
  }
445
+ flushPendingWebSocketEvents(id) {
446
+ const pendingEvents = this.pendingProxiedWSEvents.get(id);
447
+ if (!pendingEvents || pendingEvents.length === 0) {
448
+ this.pendingProxiedWSEvents.delete(id);
449
+ return;
450
+ }
451
+ this.pendingProxiedWSEvents.delete(id);
452
+ for (const event of pendingEvents) {
453
+ this.handleWebSocketMessage(event);
454
+ }
455
+ }
397
456
  async handleRequest(c) {
398
457
  const tunnelId = c.req.param("tunnelId");
399
458
  if (!tunnelId || !tunnelId.includes("_")) {
@@ -453,6 +512,8 @@ class DainTunnelServer {
453
512
  }
454
513
  catch { }
455
514
  }
515
+ const origin = c.req.header("origin");
516
+ const corsHeaders = this.buildCorsHeaders(origin);
456
517
  return new Promise((resolve) => {
457
518
  const timeoutId = setTimeout(() => {
458
519
  const pendingRequest = this.pendingRequests.get(requestId);
@@ -461,11 +522,11 @@ class DainTunnelServer {
461
522
  this.pendingRequests.delete(requestId);
462
523
  resolve(new Response(JSON.stringify({ error: "Gateway Timeout", message: "Request timed out" }), {
463
524
  status: 504,
464
- headers: { "content-type": "application/json" },
525
+ headers: { "content-type": "application/json", ...corsHeaders },
465
526
  }));
466
527
  }
467
528
  }, TIMEOUTS.REQUEST_TIMEOUT);
468
- this.pendingRequests.set(requestId, { resolve, startTime: Date.now(), tunnelId, timeoutId });
529
+ this.pendingRequests.set(requestId, { resolve, startTime: Date.now(), tunnelId, timeoutId, origin });
469
530
  const sent = this.safeSend(tunnel.ws, {
470
531
  type: "request",
471
532
  id: requestId,
@@ -480,19 +541,35 @@ class DainTunnelServer {
480
541
  this.decrementRequestCount(tunnelId);
481
542
  resolve(new Response(JSON.stringify({ error: "Bad Gateway", message: "Tunnel connection lost" }), {
482
543
  status: 502,
483
- headers: { "content-type": "application/json" },
544
+ headers: { "content-type": "application/json", ...corsHeaders },
484
545
  }));
485
546
  }
486
547
  });
487
548
  }
488
- handleSSERequest(c, tunnelId, tunnel) {
549
+ async handleSSERequest(c, tunnelId, tunnel) {
489
550
  const sseId = fastId();
551
+ const requestUrl = new URL(c.req.url);
552
+ let path = requestUrl.pathname.slice(`/${tunnelId}`.length) || "/";
553
+ if (requestUrl.search)
554
+ path += requestUrl.search;
490
555
  // Extract headers
491
556
  const reqHeaders = {};
492
557
  c.req.raw.headers.forEach((value, key) => {
493
558
  reqHeaders[key] = value;
494
559
  });
495
560
  const forwardedHeaders = this.buildForwardedHeaders(reqHeaders, tunnelId);
561
+ let bodyBase64;
562
+ if (c.req.method !== "GET" && c.req.method !== "HEAD") {
563
+ try {
564
+ const bodyBuffer = await c.req.arrayBuffer();
565
+ if (bodyBuffer.byteLength > 0) {
566
+ bodyBase64 = Buffer.from(bodyBuffer).toString("base64");
567
+ }
568
+ }
569
+ catch {
570
+ // Best-effort body forwarding for streaming requests
571
+ }
572
+ }
496
573
  const origin = c.req.header("origin");
497
574
  const responseHeaders = {
498
575
  'Content-Type': 'text/event-stream',
@@ -540,9 +617,10 @@ class DainTunnelServer {
540
617
  const sent = this.safeSend(tunnel.ws, {
541
618
  type: "sse_connection",
542
619
  id: sseId,
543
- path: new URL(c.req.url).pathname.slice(`/${tunnelId}`.length) || '/',
620
+ path,
544
621
  method: c.req.method,
545
622
  headers: forwardedHeaders,
623
+ body: bodyBase64,
546
624
  });
547
625
  if (!sent) {
548
626
  this.cleanupSSEConnection(sseId, tunnelId);
@@ -577,7 +655,7 @@ class DainTunnelServer {
577
655
  this.cleanupChallengesForSocket(ws);
578
656
  }
579
657
  catch (error) {
580
- console.error("[Tunnel] Remove error:", error);
658
+ debugError("[Tunnel] Remove error:", error);
581
659
  }
582
660
  }
583
661
  findAndRemoveTunnel(ws) {
@@ -604,7 +682,10 @@ class DainTunnelServer {
604
682
  clearTimeout(pending.timeoutId);
605
683
  pending.resolve(new Response(JSON.stringify({ error: "Bad Gateway", message: "Tunnel closed" }), {
606
684
  status: 502,
607
- headers: { "content-type": "application/json" },
685
+ headers: {
686
+ "content-type": "application/json",
687
+ ...this.buildCorsHeaders(pending.origin),
688
+ },
608
689
  }));
609
690
  this.pendingRequests.delete(requestId);
610
691
  }
@@ -678,6 +759,13 @@ class DainTunnelServer {
678
759
  reqHeaders[key] = value;
679
760
  });
680
761
  const forwardedHeaders = self.buildForwardedHeaders(reqHeaders, tunnelId);
762
+ self._pendingProxiedWs = self._pendingProxiedWs || new Map();
763
+ self._pendingProxiedWs.set(wsConnectionId, {
764
+ tunnelId,
765
+ path: remainingPath,
766
+ headers: forwardedHeaders,
767
+ });
768
+ self.pendingProxiedWSEvents.set(wsConnectionId, []);
681
769
  const success = server.upgrade(req, {
682
770
  data: {
683
771
  isProxiedWebSocket: true,
@@ -686,13 +774,6 @@ class DainTunnelServer {
686
774
  },
687
775
  });
688
776
  if (success) {
689
- // Store setup info so open handler can register the connection
690
- self._pendingProxiedWs = self._pendingProxiedWs || new Map();
691
- self._pendingProxiedWs.set(wsConnectionId, {
692
- tunnelId,
693
- path: remainingPath,
694
- headers: forwardedHeaders,
695
- });
696
777
  self.safeSend(tunnel.ws, {
697
778
  type: "websocket_connection",
698
779
  id: wsConnectionId,
@@ -701,6 +782,8 @@ class DainTunnelServer {
701
782
  });
702
783
  return undefined;
703
784
  }
785
+ self._pendingProxiedWs?.delete(wsConnectionId);
786
+ self.pendingProxiedWSEvents.delete(wsConnectionId);
704
787
  return new Response("WebSocket upgrade failed", { status: 400 });
705
788
  }
706
789
  // Regular HTTP request - pass to Hono
@@ -720,6 +803,7 @@ class DainTunnelServer {
720
803
  tunnelId: pending.tunnelId,
721
804
  });
722
805
  pendingMap.delete(ws.data.proxiedWsId);
806
+ self.flushPendingWebSocketEvents(ws.data.proxiedWsId);
723
807
  }
724
808
  }
725
809
  self.handleWsOpen(ws);
@@ -741,7 +825,7 @@ class DainTunnelServer {
741
825
  backpressureLimit: LIMITS.BACKPRESSURE_THRESHOLD,
742
826
  },
743
827
  });
744
- console.log(`DainTunnel Server is running on ${this.hostname}:${this.port}`);
828
+ debugLog(`DainTunnel Server is running on ${this.hostname}:${this.port}`);
745
829
  }
746
830
  async stop() {
747
831
  try {
@@ -752,7 +836,7 @@ class DainTunnelServer {
752
836
  tunnel.ws.close(1001, "Server shutting down");
753
837
  }
754
838
  catch (error) {
755
- console.error(`Error closing tunnel ${tunnel.id}:`, error);
839
+ debugError(`Error closing tunnel ${tunnel.id}:`, error);
756
840
  }
757
841
  }
758
842
  this.tunnels.clear();
@@ -760,9 +844,15 @@ class DainTunnelServer {
760
844
  for (const [requestId, pending] of this.pendingRequests.entries()) {
761
845
  if (pending.timeoutId)
762
846
  clearTimeout(pending.timeoutId);
763
- pending.resolve(new Response(JSON.stringify({ error: "Service Unavailable", message: "Server shutting down" }), {
847
+ pending.resolve(new Response(JSON.stringify({
848
+ error: "Service Unavailable",
849
+ message: "Server shutting down",
850
+ }), {
764
851
  status: 503,
765
- headers: { "content-type": "application/json" },
852
+ headers: {
853
+ "content-type": "application/json",
854
+ ...this.buildCorsHeaders(pending.origin),
855
+ },
766
856
  }));
767
857
  this.pendingRequests.delete(requestId);
768
858
  }
@@ -774,7 +864,7 @@ class DainTunnelServer {
774
864
  conn.controller.close();
775
865
  }
776
866
  catch (error) {
777
- console.error(`Error closing SSE ${sseId}:`, error);
867
+ debugError(`Error closing SSE ${sseId}:`, error);
778
868
  }
779
869
  conn.closed = true;
780
870
  this.sseConnections.delete(sseId);
@@ -784,7 +874,7 @@ class DainTunnelServer {
784
874
  conn.clientSocket.close(1001, "Server shutting down");
785
875
  }
786
876
  catch (error) {
787
- console.error(`Error closing WS ${wsId}:`, error);
877
+ debugError(`Error closing WS ${wsId}:`, error);
788
878
  }
789
879
  this.wsConnections.delete(wsId);
790
880
  }
@@ -794,7 +884,7 @@ class DainTunnelServer {
794
884
  }
795
885
  }
796
886
  catch (error) {
797
- console.error('Error during server shutdown:', error);
887
+ debugError('Error during server shutdown:', error);
798
888
  }
799
889
  }
800
890
  }
package/package.json CHANGED
@@ -1,17 +1,22 @@
1
1
  {
2
2
  "name": "@dainprotocol/tunnel",
3
- "version": "2.0.0",
3
+ "version": "2.0.3",
4
4
  "description": "",
5
5
  "main": "dist/index.js",
6
6
  "private": false,
7
+ "packageManager": "bun@1.3.13",
7
8
  "publishConfig": {
8
9
  "access": "public"
9
10
  },
10
11
  "scripts": {
11
- "build": "tsc",
12
- "build:types": "tsc --emitDeclarationOnly",
13
- "test": "bun test",
14
- "prepublishOnly": "npm run build && npm run build:types",
12
+ "build": "bunx --bun tsc",
13
+ "build:production": "bun run build && bun build dist/server/start.js --outdir dist-prod --target bun --minify --sourcemap=external",
14
+ "build:types": "bunx --bun tsc --emitDeclarationOnly",
15
+ "test": "bun test --parallel=4",
16
+ "test:serial": "bun test --isolate",
17
+ "test:watch": "bun test --watch",
18
+ "test:coverage": "bun test --coverage --isolate",
19
+ "prepublishOnly": "bun run build && bun run build:types",
15
20
  "start": "bun dist/server/start.js",
16
21
  "start-server": "bun src/server/start.ts"
17
22
  },
@@ -19,22 +24,23 @@
19
24
  "author": "Ryan",
20
25
  "license": "ISC",
21
26
  "dependencies": {
22
- "@dainprotocol/service-sdk": "2.0.88",
23
- "@types/node": "^22.5.4",
24
- "@types/ws": "^8.5.12",
25
- "hono": "^4.7.0",
26
- "ws": "^8.18.0"
27
+ "@dainprotocol/service-sdk": "^2.0.95",
28
+ "hono": "^4.12.14",
29
+ "ws": "^8.20.0"
27
30
  },
28
31
  "devDependencies": {
29
- "@types/bun": "^1.2.0",
30
- "typescript": "^5.0.0"
32
+ "@types/bun": "^1.3.12",
33
+ "@types/node": "^25.6.0",
34
+ "@types/ws": "^8.18.1",
35
+ "eventsource": "^4.1.0",
36
+ "typescript": "^6.0.3"
31
37
  },
32
38
  "files": [
33
39
  "dist",
34
40
  "README.md"
35
41
  ],
36
42
  "engines": {
37
- "bun": ">=1.1.0"
43
+ "bun": ">=1.3.13"
38
44
  },
39
45
  "exports": {
40
46
  ".": "./dist/index.js",