connectbase-client 0.2.0 → 0.3.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.
Files changed (3) hide show
  1. package/README.md +38 -1
  2. package/dist/cli.js +376 -0
  3. package/package.json +3 -2
package/README.md CHANGED
@@ -52,7 +52,7 @@ const state = await gameClient.createRoom({
52
52
  - **WebRTC**: Real-time audio/video communication
53
53
  - **Payments**: Subscription and one-time payment support
54
54
  - **AI Streaming**: Real-time AI text generation via WebSocket (Gemini)
55
- - **CLI**: Command-line tool for deploying web storage
55
+ - **CLI**: Command-line tool for deploying web storage and tunneling local services
56
56
 
57
57
  ## CLI
58
58
 
@@ -81,6 +81,7 @@ The `init` command will:
81
81
  |---------|-------------|
82
82
  | `init` | Interactive project setup (creates config, adds deploy script) |
83
83
  | `deploy <dir>` | Deploy files to web storage |
84
+ | `tunnel <port>` | Expose a local service to the internet via WebSocket tunnel |
84
85
 
85
86
  ### Manual Usage
86
87
 
@@ -97,9 +98,45 @@ npx connectbase-client deploy ./dist -s <storage-id> -k <api-key>
97
98
  | `--storage <id>` | `-s` | Storage ID |
98
99
  | `--api-key <key>` | `-k` | API Key |
99
100
  | `--base-url <url>` | `-u` | Custom server URL |
101
+ | `--timeout <sec>` | `-t` | Tunnel request timeout in seconds (tunnel only) |
102
+ | `--max-body <MB>` | | Tunnel max body size in MB (tunnel only) |
100
103
  | `--help` | `-h` | Show help |
101
104
  | `--version` | `-v` | Show version |
102
105
 
106
+ ### Tunnel
107
+
108
+ Expose a local server to the internet through a secure WebSocket tunnel. Useful for sharing local MCP servers, development servers, or any HTTP service.
109
+
110
+ ```bash
111
+ # Expose local port 8084 to the internet
112
+ npx connectbase-client tunnel 8084 -k <api-key>
113
+
114
+ # With environment variable
115
+ export CONNECTBASE_API_KEY=your-api-key
116
+ npx connectbase-client tunnel 8084
117
+
118
+ # For GPU servers or long-running tasks (e.g., image generation)
119
+ npx connectbase-client tunnel 7860 --timeout 300 --max-body 50
120
+ ```
121
+
122
+ The tunnel creates a public URL like `https://tunnel.connectbase.world/<tunnel-id>/` that proxies all HTTP requests to your local service.
123
+
124
+ **Plan-based limits:** Timeout and body size are clamped to your plan's maximum:
125
+
126
+ | Plan | Max Timeout | Max Body |
127
+ |------|-------------|----------|
128
+ | Free | 60s | 10MB |
129
+ | Starter | 120s | 25MB |
130
+ | Pro | 300s | 50MB |
131
+ | Business | 600s | 100MB |
132
+
133
+ Features:
134
+ - Per-tunnel timeout and body size configuration
135
+ - Automatic reconnection with exponential backoff
136
+ - Request/response logging in terminal
137
+ - Graceful shutdown with Ctrl+C
138
+ - No external dependencies (uses Node.js built-in modules)
139
+
103
140
  ### Configuration File
104
141
 
105
142
  The `init` command creates `.connectbaserc` automatically. You can also create it manually:
package/dist/cli.js CHANGED
@@ -26,6 +26,7 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
26
26
  // src/cli.ts
27
27
  var fs = __toESM(require("fs"));
28
28
  var path = __toESM(require("path"));
29
+ var crypto = __toESM(require("crypto"));
29
30
  var https = __toESM(require("https"));
30
31
  var http = __toESM(require("http"));
31
32
  var readline = __toESM(require("readline"));
@@ -431,6 +432,347 @@ function addDeployScript(deployDir) {
431
432
  warn("package.json \uC218\uC815\uC5D0 \uC2E4\uD328\uD588\uC2B5\uB2C8\uB2E4");
432
433
  }
433
434
  }
435
+ function createWsTextFrame(payload) {
436
+ const data = Buffer.from(payload, "utf-8");
437
+ const len = data.length;
438
+ const maskKey = crypto.randomBytes(4);
439
+ let header;
440
+ if (len < 126) {
441
+ header = Buffer.alloc(2);
442
+ header[0] = 129;
443
+ header[1] = 128 | len;
444
+ } else if (len < 65536) {
445
+ header = Buffer.alloc(4);
446
+ header[0] = 129;
447
+ header[1] = 128 | 126;
448
+ header.writeUInt16BE(len, 2);
449
+ } else {
450
+ header = Buffer.alloc(10);
451
+ header[0] = 129;
452
+ header[1] = 128 | 127;
453
+ header.writeBigUInt64BE(BigInt(len), 2);
454
+ }
455
+ const masked = Buffer.alloc(data.length);
456
+ for (let i = 0; i < data.length; i++) {
457
+ masked[i] = data[i] ^ maskKey[i % 4];
458
+ }
459
+ return Buffer.concat([header, maskKey, masked]);
460
+ }
461
+ function createWsCloseFrame(code) {
462
+ const maskKey = crypto.randomBytes(4);
463
+ const payload = Buffer.alloc(2);
464
+ payload.writeUInt16BE(code, 0);
465
+ const masked = Buffer.alloc(2);
466
+ masked[0] = payload[0] ^ maskKey[0];
467
+ masked[1] = payload[1] ^ maskKey[1];
468
+ const header = Buffer.alloc(2);
469
+ header[0] = 136;
470
+ header[1] = 128 | 2;
471
+ return Buffer.concat([header, maskKey, masked]);
472
+ }
473
+ function createWsPongFrame() {
474
+ const maskKey = crypto.randomBytes(4);
475
+ const header = Buffer.alloc(2);
476
+ header[0] = 138;
477
+ header[1] = 128 | 0;
478
+ return Buffer.concat([header, maskKey]);
479
+ }
480
+ var WsFrameParser = class {
481
+ constructor(handlers) {
482
+ this.buffer = Buffer.alloc(0);
483
+ this.onMessage = handlers.onMessage;
484
+ this.onClose = handlers.onClose;
485
+ this.onPing = handlers.onPing;
486
+ }
487
+ feed(chunk) {
488
+ this.buffer = Buffer.concat([this.buffer, chunk]);
489
+ this.parse();
490
+ }
491
+ parse() {
492
+ while (this.buffer.length >= 2) {
493
+ const firstByte = this.buffer[0];
494
+ const secondByte = this.buffer[1];
495
+ const opcode = firstByte & 15;
496
+ const isMasked = (secondByte & 128) !== 0;
497
+ let payloadLen = secondByte & 127;
498
+ let offset = 2;
499
+ if (payloadLen === 126) {
500
+ if (this.buffer.length < 4) return;
501
+ payloadLen = this.buffer.readUInt16BE(2);
502
+ offset = 4;
503
+ } else if (payloadLen === 127) {
504
+ if (this.buffer.length < 10) return;
505
+ payloadLen = Number(this.buffer.readBigUInt64BE(2));
506
+ offset = 10;
507
+ }
508
+ let maskKey = null;
509
+ if (isMasked) {
510
+ if (this.buffer.length < offset + 4) return;
511
+ maskKey = this.buffer.subarray(offset, offset + 4);
512
+ offset += 4;
513
+ }
514
+ if (this.buffer.length < offset + payloadLen) return;
515
+ let payload = this.buffer.subarray(offset, offset + payloadLen);
516
+ if (maskKey) {
517
+ const unmasked = Buffer.alloc(payloadLen);
518
+ for (let i = 0; i < payloadLen; i++) {
519
+ unmasked[i] = payload[i] ^ maskKey[i % 4];
520
+ }
521
+ payload = unmasked;
522
+ }
523
+ this.buffer = this.buffer.subarray(offset + payloadLen);
524
+ switch (opcode) {
525
+ case 1:
526
+ this.onMessage(payload.toString("utf-8"));
527
+ break;
528
+ case 8:
529
+ this.onClose();
530
+ break;
531
+ case 9:
532
+ this.onPing();
533
+ break;
534
+ case 10:
535
+ break;
536
+ }
537
+ }
538
+ }
539
+ };
540
+ function getTunnelServerUrl(baseUrl) {
541
+ if (baseUrl.includes("api.connectbase.world")) {
542
+ return baseUrl.replace("api.connectbase.world", "tunnel.connectbase.world");
543
+ }
544
+ if (baseUrl.includes("localhost:8080")) {
545
+ return baseUrl.replace("localhost:8080", "localhost:8090");
546
+ }
547
+ return baseUrl.replace(/:\d+/, ":8090");
548
+ }
549
+ async function startTunnel(port, config, tunnelOpts) {
550
+ if (!config.apiKey) {
551
+ error("API Key\uAC00 \uD544\uC694\uD569\uB2C8\uB2E4. -k \uC635\uC158 \uB610\uB294 CONNECTBASE_API_KEY \uD658\uACBD\uBCC0\uC218\uB97C \uC124\uC815\uD558\uC138\uC694");
552
+ process.exit(1);
553
+ }
554
+ const tunnelServerUrl = getTunnelServerUrl(config.baseUrl);
555
+ const parsedUrl = new URL(tunnelServerUrl);
556
+ const isHttps = parsedUrl.protocol === "https:";
557
+ let wsPath = `/v1/tunnel/connect?api_key=${encodeURIComponent(config.apiKey)}`;
558
+ if (tunnelOpts?.timeout) {
559
+ wsPath += `&timeout=${tunnelOpts.timeout}`;
560
+ }
561
+ if (tunnelOpts?.maxBody) {
562
+ wsPath += `&max_body=${tunnelOpts.maxBody}`;
563
+ }
564
+ let reconnectAttempts = 0;
565
+ const maxReconnectAttempts = 10;
566
+ let shouldReconnect = true;
567
+ let socket = null;
568
+ const cleanup = () => {
569
+ shouldReconnect = false;
570
+ if (socket) {
571
+ try {
572
+ socket.write(createWsCloseFrame(1e3));
573
+ } catch {
574
+ }
575
+ setTimeout(() => {
576
+ socket?.destroy();
577
+ process.exit(0);
578
+ }, 500);
579
+ } else {
580
+ process.exit(0);
581
+ }
582
+ };
583
+ process.on("SIGINT", cleanup);
584
+ process.on("SIGTERM", cleanup);
585
+ log(`
586
+ ${colors.cyan}ConnectBase Tunnel${colors.reset}`);
587
+ log(`${colors.dim}\uB85C\uCEEC \uD3EC\uD2B8 ${port}\uB97C \uC778\uD130\uB137\uC5D0 \uB178\uCD9C\uD569\uB2C8\uB2E4${colors.reset}
588
+ `);
589
+ function connect() {
590
+ const lib = isHttps ? https : http;
591
+ const wsKey = crypto.randomBytes(16).toString("base64");
592
+ const reqOptions = {
593
+ hostname: parsedUrl.hostname,
594
+ port: parsedUrl.port || (isHttps ? 443 : 80),
595
+ path: wsPath,
596
+ method: "GET",
597
+ family: 4,
598
+ autoSelectFamily: false,
599
+ headers: {
600
+ "Upgrade": "websocket",
601
+ "Connection": "Upgrade",
602
+ "Sec-WebSocket-Key": wsKey,
603
+ "Sec-WebSocket-Version": "13"
604
+ }
605
+ };
606
+ const req = lib.request(reqOptions);
607
+ req.on("upgrade", (_res, sock, _head) => {
608
+ socket = sock;
609
+ reconnectAttempts = 0;
610
+ const parser = new WsFrameParser({
611
+ onMessage: (data) => {
612
+ try {
613
+ const msg = JSON.parse(data);
614
+ handleMessage(msg, sock, port);
615
+ } catch (e) {
616
+ warn(`\uBA54\uC2DC\uC9C0 \uD30C\uC2F1 \uC2E4\uD328: ${e}`);
617
+ }
618
+ },
619
+ onClose: () => {
620
+ info("\uC11C\uBC84\uAC00 \uC5F0\uACB0\uC744 \uC885\uB8CC\uD588\uC2B5\uB2C8\uB2E4");
621
+ sock.destroy();
622
+ },
623
+ onPing: () => {
624
+ sock.write(createWsPongFrame());
625
+ }
626
+ });
627
+ sock.on("data", (chunk) => parser.feed(chunk));
628
+ sock.on("close", () => {
629
+ socket = null;
630
+ if (shouldReconnect) {
631
+ scheduleReconnect();
632
+ }
633
+ });
634
+ sock.on("error", (err) => {
635
+ warn(`\uC5F0\uACB0 \uC5D0\uB7EC: ${err.message}`);
636
+ socket = null;
637
+ if (shouldReconnect) {
638
+ scheduleReconnect();
639
+ }
640
+ });
641
+ });
642
+ req.on("error", (err) => {
643
+ if (reconnectAttempts === 0) {
644
+ error(`\uD130\uB110 \uC11C\uBC84 \uC5F0\uACB0 \uC2E4\uD328: ${err.message}`);
645
+ }
646
+ if (shouldReconnect) {
647
+ scheduleReconnect();
648
+ }
649
+ });
650
+ req.on("response", (res) => {
651
+ let body = "";
652
+ res.on("data", (chunk) => body += chunk.toString());
653
+ res.on("end", () => {
654
+ if (res.statusCode === 401) {
655
+ error("\uC778\uC99D \uC2E4\uD328: API Key\uAC00 \uC62C\uBC14\uB974\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4");
656
+ shouldReconnect = false;
657
+ process.exit(1);
658
+ } else if (res.statusCode === 402) {
659
+ error("\uD560\uB2F9\uB7C9 \uCD08\uACFC: \uD50C\uB79C\uC744 \uC5C5\uADF8\uB808\uC774\uB4DC\uD558\uC138\uC694");
660
+ shouldReconnect = false;
661
+ process.exit(1);
662
+ } else {
663
+ error(`\uD130\uB110 \uC11C\uBC84 \uC751\uB2F5 \uC624\uB958 (${res.statusCode}): ${body}`);
664
+ if (shouldReconnect) {
665
+ scheduleReconnect();
666
+ }
667
+ }
668
+ });
669
+ });
670
+ req.end();
671
+ }
672
+ function scheduleReconnect() {
673
+ reconnectAttempts++;
674
+ if (reconnectAttempts > maxReconnectAttempts) {
675
+ error("\uCD5C\uB300 \uC7AC\uC5F0\uACB0 \uC2DC\uB3C4 \uD69F\uC218 \uCD08\uACFC");
676
+ process.exit(1);
677
+ }
678
+ const delay = Math.min(2e3 * Math.pow(2, reconnectAttempts - 1), 3e4);
679
+ info(`${(delay / 1e3).toFixed(0)}\uCD08 \uD6C4 \uC7AC\uC5F0\uACB0 \uC2DC\uB3C4... (${reconnectAttempts}/${maxReconnectAttempts})`);
680
+ setTimeout(connect, delay);
681
+ }
682
+ function handleMessage(msg, sock, localPort) {
683
+ switch (msg.type) {
684
+ case "tunnel_ready":
685
+ success(`\uD130\uB110 \uD65C\uC131\uD654!`);
686
+ log(`${colors.green}\u2192${colors.reset} URL: ${colors.cyan}${msg.url}${colors.reset}`);
687
+ log(`${colors.green}\u2192${colors.reset} \uB85C\uCEEC: ${colors.cyan}http://localhost:${localPort}${colors.reset}`);
688
+ if (msg.timeout || msg.max_body) {
689
+ log(`${colors.green}\u2192${colors.reset} \uC124\uC815: timeout=${colors.cyan}${msg.timeout}s${colors.reset}, max-body=${colors.cyan}${msg.max_body}MB${colors.reset}`);
690
+ }
691
+ log(`
692
+ ${colors.dim}Ctrl+C\uB85C \uC885\uB8CC${colors.reset}
693
+ `);
694
+ break;
695
+ case "http_request":
696
+ forwardRequest(msg, sock, localPort);
697
+ break;
698
+ case "tunnel_error":
699
+ error(`\uD130\uB110 \uC5D0\uB7EC: ${msg.message}`);
700
+ break;
701
+ case "ping":
702
+ sock.write(createWsTextFrame(JSON.stringify({ type: "pong" })));
703
+ break;
704
+ }
705
+ }
706
+ function forwardRequest(msg, sock, localPort) {
707
+ const requestId = msg.request_id;
708
+ const method = msg.method;
709
+ const reqPath = msg.path;
710
+ const query = msg.query || "";
711
+ const headers = msg.headers || {};
712
+ const bodyBase64 = msg.body;
713
+ const fullPath = query ? `${reqPath}?${query}` : reqPath;
714
+ const localHeaders = {};
715
+ for (const [key, value] of Object.entries(headers)) {
716
+ if (key.toLowerCase() !== "host") {
717
+ localHeaders[key] = value;
718
+ }
719
+ }
720
+ localHeaders["host"] = `localhost:${localPort}`;
721
+ const reqOptions = {
722
+ hostname: "127.0.0.1",
723
+ port: localPort,
724
+ path: fullPath,
725
+ method,
726
+ headers: localHeaders
727
+ };
728
+ const localReq = http.request(reqOptions, (res) => {
729
+ const chunks = [];
730
+ res.on("data", (chunk) => chunks.push(chunk));
731
+ res.on("end", () => {
732
+ const body = Buffer.concat(chunks);
733
+ const responseHeaders = {};
734
+ for (const [key, value] of Object.entries(res.headers)) {
735
+ if (value) responseHeaders[key] = Array.isArray(value) ? value.join(", ") : value;
736
+ }
737
+ const response = {
738
+ type: "http_response",
739
+ request_id: requestId,
740
+ status: res.statusCode || 200,
741
+ headers: responseHeaders,
742
+ body: body.length > 0 ? body.toString("base64") : ""
743
+ };
744
+ try {
745
+ sock.write(createWsTextFrame(JSON.stringify(response)));
746
+ const methodColor = method === "GET" ? colors.green : method === "POST" ? colors.blue : colors.yellow;
747
+ log(`${colors.dim}${(/* @__PURE__ */ new Date()).toLocaleTimeString()}${colors.reset} ${methodColor}${method}${colors.reset} ${reqPath} \u2192 ${res.statusCode}`);
748
+ } catch {
749
+ warn(`\uC751\uB2F5 \uC804\uC1A1 \uC2E4\uD328: ${requestId}`);
750
+ }
751
+ });
752
+ });
753
+ localReq.on("error", (err) => {
754
+ const response = {
755
+ type: "http_response",
756
+ request_id: requestId,
757
+ status: 502,
758
+ headers: { "content-type": "application/json" },
759
+ body: Buffer.from(JSON.stringify({ error: `Local server error: ${err.message}` })).toString("base64")
760
+ };
761
+ try {
762
+ sock.write(createWsTextFrame(JSON.stringify(response)));
763
+ } catch {
764
+ }
765
+ warn(`\uB85C\uCEEC \uC11C\uBC84 \uC5F0\uACB0 \uC2E4\uD328 (${method} ${reqPath}): ${err.message}`);
766
+ });
767
+ if (bodyBase64) {
768
+ localReq.write(Buffer.from(bodyBase64, "base64"));
769
+ }
770
+ localReq.end();
771
+ }
772
+ connect();
773
+ await new Promise(() => {
774
+ });
775
+ }
434
776
  function showHelp() {
435
777
  log(`
436
778
  ${colors.cyan}connectbase-client${colors.reset} - Connect Base SDK & CLI
@@ -441,11 +783,14 @@ ${colors.yellow}\uC0AC\uC6A9\uBC95:${colors.reset}
441
783
  ${colors.yellow}\uBA85\uB839\uC5B4:${colors.reset}
442
784
  init \uD504\uB85C\uC81D\uD2B8 \uCD08\uAE30\uD654 (\uC124\uC815 \uD30C\uC77C \uC0DD\uC131)
443
785
  deploy <directory> \uC6F9 \uC2A4\uD1A0\uB9AC\uC9C0\uC5D0 \uD30C\uC77C \uBC30\uD3EC
786
+ tunnel <port> \uB85C\uCEEC \uC11C\uBE44\uC2A4\uB97C \uC778\uD130\uB137\uC5D0 \uB178\uCD9C
444
787
 
445
788
  ${colors.yellow}\uC635\uC158:${colors.reset}
446
789
  -s, --storage <id> \uC2A4\uD1A0\uB9AC\uC9C0 ID
447
790
  -k, --api-key <key> API Key
448
791
  -u, --base-url <url> \uC11C\uBC84 URL (\uAE30\uBCF8: ${DEFAULT_BASE_URL})
792
+ -t, --timeout <sec> \uD130\uB110 \uC694\uCCAD \uD0C0\uC784\uC544\uC6C3 (\uCD08, tunnel \uC804\uC6A9)
793
+ --max-body <MB> \uD130\uB110 \uCD5C\uB300 \uBC14\uB514 \uD06C\uAE30 (MB, tunnel \uC804\uC6A9)
449
794
  -h, --help \uB3C4\uC6C0\uB9D0 \uD45C\uC2DC
450
795
  -v, --version \uBC84\uC804 \uD45C\uC2DC
451
796
 
@@ -456,6 +801,12 @@ ${colors.yellow}\uBE60\uB978 \uC2DC\uC791:${colors.reset}
456
801
  ${colors.dim}# 2. \uBC30\uD3EC${colors.reset}
457
802
  npm run deploy
458
803
 
804
+ ${colors.dim}# 3. \uD130\uB110 (\uAE30\uBCF8)${colors.reset}
805
+ npx connectbase-client tunnel 3000
806
+
807
+ ${colors.dim}# 4. \uD130\uB110 (GPU \uC11C\uBC84 \uB4F1 \uAE34 \uC751\uB2F5 \uC2DC)${colors.reset}
808
+ npx connectbase-client tunnel 7860 --timeout 300 --max-body 50
809
+
459
810
  ${colors.yellow}\uD658\uACBD\uBCC0\uC218:${colors.reset}
460
811
  CONNECTBASE_API_KEY API Key
461
812
  CONNECTBASE_STORAGE_ID \uC2A4\uD1A0\uB9AC\uC9C0 ID
@@ -483,6 +834,10 @@ function parseArgs(args) {
483
834
  result.options.apiKey = args[++i];
484
835
  } else if (arg === "-u" || arg === "--base-url") {
485
836
  result.options.baseUrl = args[++i];
837
+ } else if (arg === "-t" || arg === "--timeout") {
838
+ result.options.timeout = args[++i];
839
+ } else if (arg === "--max-body") {
840
+ result.options.maxBody = args[++i];
486
841
  } else if (arg === "-h" || arg === "--help") {
487
842
  result.options.help = "true";
488
843
  } else if (arg === "-v" || arg === "--version") {
@@ -527,6 +882,27 @@ async function main() {
527
882
  process.exit(1);
528
883
  }
529
884
  await deploy(directory, config);
885
+ } else if (parsed.command === "tunnel") {
886
+ const portStr = parsed.args[0];
887
+ if (!portStr) {
888
+ error("\uD3EC\uD2B8 \uBC88\uD638\uAC00 \uD544\uC694\uD569\uB2C8\uB2E4. \uC608: npx connectbase-client tunnel 8084");
889
+ process.exit(1);
890
+ }
891
+ const port = parseInt(portStr, 10);
892
+ if (isNaN(port) || port < 1 || port > 65535) {
893
+ error("\uC720\uD6A8\uD55C \uD3EC\uD2B8 \uBC88\uD638\uB97C \uC785\uB825\uD558\uC138\uC694 (1-65535)");
894
+ process.exit(1);
895
+ }
896
+ const tunnelOpts = {};
897
+ if (parsed.options.timeout) {
898
+ const t = parseInt(parsed.options.timeout, 10);
899
+ if (!isNaN(t) && t > 0) tunnelOpts.timeout = t;
900
+ }
901
+ if (parsed.options.maxBody) {
902
+ const m = parseInt(parsed.options.maxBody, 10);
903
+ if (!isNaN(m) && m > 0) tunnelOpts.maxBody = m;
904
+ }
905
+ await startTunnel(port, config, tunnelOpts);
530
906
  } else {
531
907
  error(`\uC54C \uC218 \uC5C6\uB294 \uBA85\uB839\uC5B4: ${parsed.command}`);
532
908
  showHelp();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "connectbase-client",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "description": "Connect Base JavaScript/TypeScript SDK for browser and Node.js",
5
5
  "repository": {
6
6
  "type": "git",
@@ -45,7 +45,8 @@
45
45
  "deploy",
46
46
  "ai",
47
47
  "streaming",
48
- "realtime"
48
+ "realtime",
49
+ "tunnel"
49
50
  ],
50
51
  "author": "",
51
52
  "license": "MIT",