@dainprotocol/tunnel 2.0.0 → 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.
@@ -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,6 +15,7 @@ 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,
@@ -52,6 +53,21 @@ function isCorsOriginAllowed(origin, allowedOrigins) {
52
53
  }
53
54
  const ALLOWED_CORS_ORIGINS = parseAllowedCorsOrigins(process.env.TUNNEL_ALLOWED_ORIGINS || process.env.CORS_ALLOWED_ORIGINS);
54
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
+ }
55
71
  safeSend(ws, data) {
56
72
  try {
57
73
  if (ws.readyState === 1) { // WebSocket.OPEN
@@ -99,6 +115,7 @@ class DainTunnelServer {
99
115
  this.challenges = new Map();
100
116
  this.sseConnections = new Map();
101
117
  this.wsConnections = new Map();
118
+ this.pendingProxiedWSEvents = new Map();
102
119
  this.tunnelRequestCount = new Map();
103
120
  this.app = new Hono();
104
121
  this.setupRoutes();
@@ -314,7 +331,7 @@ class DainTunnelServer {
314
331
  const pendingRequest = this.pendingRequests.get(data.requestId);
315
332
  if (!pendingRequest)
316
333
  return;
317
- const { resolve, tunnelId, timeoutId } = pendingRequest;
334
+ const { resolve, tunnelId, timeoutId, origin } = pendingRequest;
318
335
  this.pendingRequests.delete(data.requestId);
319
336
  if (timeoutId)
320
337
  clearTimeout(timeoutId);
@@ -322,6 +339,8 @@ class DainTunnelServer {
322
339
  const headers = data.headers;
323
340
  delete headers["transfer-encoding"];
324
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));
325
344
  const bodyBuffer = Buffer.from(data.body, "base64");
326
345
  headers["content-length"] = bodyBuffer.length.toString();
327
346
  resolve(new Response(bodyBuffer, {
@@ -373,8 +392,28 @@ class DainTunnelServer {
373
392
  }
374
393
  handleWebSocketMessage(data) {
375
394
  const connection = this.wsConnections.get(data.id);
376
- 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);
377
415
  return;
416
+ }
378
417
  const { clientSocket } = connection;
379
418
  const isOpen = clientSocket.readyState === 1;
380
419
  switch (data.event) {
@@ -394,6 +433,17 @@ class DainTunnelServer {
394
433
  break;
395
434
  }
396
435
  }
436
+ flushPendingWebSocketEvents(id) {
437
+ const pendingEvents = this.pendingProxiedWSEvents.get(id);
438
+ if (!pendingEvents || pendingEvents.length === 0) {
439
+ this.pendingProxiedWSEvents.delete(id);
440
+ return;
441
+ }
442
+ this.pendingProxiedWSEvents.delete(id);
443
+ for (const event of pendingEvents) {
444
+ this.handleWebSocketMessage(event);
445
+ }
446
+ }
397
447
  async handleRequest(c) {
398
448
  const tunnelId = c.req.param("tunnelId");
399
449
  if (!tunnelId || !tunnelId.includes("_")) {
@@ -453,6 +503,8 @@ class DainTunnelServer {
453
503
  }
454
504
  catch { }
455
505
  }
506
+ const origin = c.req.header("origin");
507
+ const corsHeaders = this.buildCorsHeaders(origin);
456
508
  return new Promise((resolve) => {
457
509
  const timeoutId = setTimeout(() => {
458
510
  const pendingRequest = this.pendingRequests.get(requestId);
@@ -461,11 +513,11 @@ class DainTunnelServer {
461
513
  this.pendingRequests.delete(requestId);
462
514
  resolve(new Response(JSON.stringify({ error: "Gateway Timeout", message: "Request timed out" }), {
463
515
  status: 504,
464
- headers: { "content-type": "application/json" },
516
+ headers: { "content-type": "application/json", ...corsHeaders },
465
517
  }));
466
518
  }
467
519
  }, TIMEOUTS.REQUEST_TIMEOUT);
468
- this.pendingRequests.set(requestId, { resolve, startTime: Date.now(), tunnelId, timeoutId });
520
+ this.pendingRequests.set(requestId, { resolve, startTime: Date.now(), tunnelId, timeoutId, origin });
469
521
  const sent = this.safeSend(tunnel.ws, {
470
522
  type: "request",
471
523
  id: requestId,
@@ -480,19 +532,35 @@ class DainTunnelServer {
480
532
  this.decrementRequestCount(tunnelId);
481
533
  resolve(new Response(JSON.stringify({ error: "Bad Gateway", message: "Tunnel connection lost" }), {
482
534
  status: 502,
483
- headers: { "content-type": "application/json" },
535
+ headers: { "content-type": "application/json", ...corsHeaders },
484
536
  }));
485
537
  }
486
538
  });
487
539
  }
488
- handleSSERequest(c, tunnelId, tunnel) {
540
+ async handleSSERequest(c, tunnelId, tunnel) {
489
541
  const sseId = fastId();
542
+ const requestUrl = new URL(c.req.url);
543
+ let path = requestUrl.pathname.slice(`/${tunnelId}`.length) || "/";
544
+ if (requestUrl.search)
545
+ path += requestUrl.search;
490
546
  // Extract headers
491
547
  const reqHeaders = {};
492
548
  c.req.raw.headers.forEach((value, key) => {
493
549
  reqHeaders[key] = value;
494
550
  });
495
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
+ }
563
+ }
496
564
  const origin = c.req.header("origin");
497
565
  const responseHeaders = {
498
566
  'Content-Type': 'text/event-stream',
@@ -540,9 +608,10 @@ class DainTunnelServer {
540
608
  const sent = this.safeSend(tunnel.ws, {
541
609
  type: "sse_connection",
542
610
  id: sseId,
543
- path: new URL(c.req.url).pathname.slice(`/${tunnelId}`.length) || '/',
611
+ path,
544
612
  method: c.req.method,
545
613
  headers: forwardedHeaders,
614
+ body: bodyBase64,
546
615
  });
547
616
  if (!sent) {
548
617
  this.cleanupSSEConnection(sseId, tunnelId);
@@ -604,7 +673,10 @@ class DainTunnelServer {
604
673
  clearTimeout(pending.timeoutId);
605
674
  pending.resolve(new Response(JSON.stringify({ error: "Bad Gateway", message: "Tunnel closed" }), {
606
675
  status: 502,
607
- headers: { "content-type": "application/json" },
676
+ headers: {
677
+ "content-type": "application/json",
678
+ ...this.buildCorsHeaders(pending.origin),
679
+ },
608
680
  }));
609
681
  this.pendingRequests.delete(requestId);
610
682
  }
@@ -678,6 +750,13 @@ class DainTunnelServer {
678
750
  reqHeaders[key] = value;
679
751
  });
680
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, []);
681
760
  const success = server.upgrade(req, {
682
761
  data: {
683
762
  isProxiedWebSocket: true,
@@ -686,13 +765,6 @@ class DainTunnelServer {
686
765
  },
687
766
  });
688
767
  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
768
  self.safeSend(tunnel.ws, {
697
769
  type: "websocket_connection",
698
770
  id: wsConnectionId,
@@ -701,6 +773,8 @@ class DainTunnelServer {
701
773
  });
702
774
  return undefined;
703
775
  }
776
+ self._pendingProxiedWs?.delete(wsConnectionId);
777
+ self.pendingProxiedWSEvents.delete(wsConnectionId);
704
778
  return new Response("WebSocket upgrade failed", { status: 400 });
705
779
  }
706
780
  // Regular HTTP request - pass to Hono
@@ -720,6 +794,7 @@ class DainTunnelServer {
720
794
  tunnelId: pending.tunnelId,
721
795
  });
722
796
  pendingMap.delete(ws.data.proxiedWsId);
797
+ self.flushPendingWebSocketEvents(ws.data.proxiedWsId);
723
798
  }
724
799
  }
725
800
  self.handleWsOpen(ws);
@@ -760,9 +835,15 @@ class DainTunnelServer {
760
835
  for (const [requestId, pending] of this.pendingRequests.entries()) {
761
836
  if (pending.timeoutId)
762
837
  clearTimeout(pending.timeoutId);
763
- pending.resolve(new Response(JSON.stringify({ error: "Service Unavailable", message: "Server shutting down" }), {
838
+ pending.resolve(new Response(JSON.stringify({
839
+ error: "Service Unavailable",
840
+ message: "Server shutting down",
841
+ }), {
764
842
  status: 503,
765
- headers: { "content-type": "application/json" },
843
+ headers: {
844
+ "content-type": "application/json",
845
+ ...this.buildCorsHeaders(pending.origin),
846
+ },
766
847
  }));
767
848
  this.pendingRequests.delete(requestId);
768
849
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dainprotocol/tunnel",
3
- "version": "2.0.0",
3
+ "version": "2.0.1",
4
4
  "description": "",
5
5
  "main": "dist/index.js",
6
6
  "private": false,
@@ -9,6 +9,7 @@
9
9
  },
10
10
  "scripts": {
11
11
  "build": "tsc",
12
+ "build:production": "tsc && bun build dist/server/start.js --outdir dist-prod --target bun --minify --sourcemap=external",
12
13
  "build:types": "tsc --emitDeclarationOnly",
13
14
  "test": "bun test",
14
15
  "prepublishOnly": "npm run build && npm run build:types",
@@ -19,7 +20,7 @@
19
20
  "author": "Ryan",
20
21
  "license": "ISC",
21
22
  "dependencies": {
22
- "@dainprotocol/service-sdk": "2.0.88",
23
+ "@dainprotocol/service-sdk": "2.0.91",
23
24
  "@types/node": "^22.5.4",
24
25
  "@types/ws": "^8.5.12",
25
26
  "hono": "^4.7.0",