linkshell-cli 0.2.52 → 0.2.54

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.
@@ -133,6 +133,7 @@ export class BridgeSession {
133
133
  private hookMarker = `lsh-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
134
134
  private screenCapture: ScreenFallback | undefined;
135
135
  private screenShare: ScreenShare | undefined;
136
+ private tunnelSockets = new Map<string, WebSocket>();
136
137
 
137
138
  constructor(options: BridgeSessionOptions) {
138
139
  this.options = options;
@@ -442,11 +443,209 @@ export class BridgeSession {
442
443
  }
443
444
  break;
444
445
  }
446
+ case "tunnel.request": {
447
+ const p = parseTypedPayload("tunnel.request", envelope.payload);
448
+ this.handleTunnelRequest(p);
449
+ break;
450
+ }
451
+ case "tunnel.ws.data": {
452
+ const p = parseTypedPayload("tunnel.ws.data", envelope.payload);
453
+ this.handleTunnelWsData(p);
454
+ break;
455
+ }
456
+ case "tunnel.ws.close": {
457
+ const p = parseTypedPayload("tunnel.ws.close", envelope.payload);
458
+ this.handleTunnelWsClose(p);
459
+ break;
460
+ }
445
461
  default:
446
462
  break;
447
463
  }
448
464
  }
449
465
 
466
+ // ── Tunnel handlers ────────────────────────────────────────────────
467
+
468
+ private handleTunnelRequest(payload: {
469
+ requestId: string;
470
+ method: string;
471
+ url: string;
472
+ headers: Record<string, string>;
473
+ body: string | null;
474
+ port: number;
475
+ }): void {
476
+ const { requestId, method, url: reqUrl, headers, body, port } = payload;
477
+
478
+ // WebSocket upgrade request
479
+ if (headers.upgrade === "websocket") {
480
+ this.handleTunnelWsUpgrade(requestId, port, reqUrl);
481
+ return;
482
+ }
483
+
484
+ const parsedUrl = new URL(reqUrl, `http://127.0.0.1:${port}`);
485
+
486
+ const reqOptions: http.RequestOptions = {
487
+ hostname: "127.0.0.1",
488
+ port,
489
+ path: parsedUrl.pathname + parsedUrl.search,
490
+ method,
491
+ headers: { ...headers, host: `127.0.0.1:${port}` },
492
+ };
493
+
494
+ const proxyReq = http.request(reqOptions, (proxyRes) => {
495
+ // Collect response headers
496
+ const resHeaders: Record<string, string> = {};
497
+ for (const [key, val] of Object.entries(proxyRes.headers)) {
498
+ if (typeof val === "string") resHeaders[key] = val;
499
+ else if (Array.isArray(val)) resHeaders[key] = val.join(", ");
500
+ }
501
+
502
+ let firstChunk = true;
503
+ proxyRes.on("data", (chunk: Buffer) => {
504
+ this.send(
505
+ createEnvelope({
506
+ type: "tunnel.response",
507
+ sessionId: this.sessionId,
508
+ payload: {
509
+ requestId,
510
+ statusCode: proxyRes.statusCode ?? 200,
511
+ headers: firstChunk ? resHeaders : {},
512
+ body: chunk.toString("base64"),
513
+ isFinal: false,
514
+ },
515
+ }),
516
+ );
517
+ firstChunk = false;
518
+ });
519
+
520
+ proxyRes.on("end", () => {
521
+ this.send(
522
+ createEnvelope({
523
+ type: "tunnel.response",
524
+ sessionId: this.sessionId,
525
+ payload: {
526
+ requestId,
527
+ statusCode: proxyRes.statusCode ?? 200,
528
+ headers: firstChunk ? resHeaders : {},
529
+ body: "",
530
+ isFinal: true,
531
+ },
532
+ }),
533
+ );
534
+ });
535
+
536
+ proxyRes.on("error", () => {
537
+ this.sendTunnelError(requestId, 502, "Upstream read error");
538
+ });
539
+ });
540
+
541
+ proxyReq.on("error", () => {
542
+ this.sendTunnelError(requestId, 502, "Connection refused");
543
+ });
544
+
545
+ proxyReq.setTimeout(30_000, () => {
546
+ proxyReq.destroy();
547
+ this.sendTunnelError(requestId, 504, "Upstream timeout");
548
+ });
549
+
550
+ if (body) {
551
+ proxyReq.write(Buffer.from(body, "base64"));
552
+ }
553
+ proxyReq.end();
554
+ }
555
+
556
+ private handleTunnelWsUpgrade(requestId: string, port: number, url: string): void {
557
+ const wsUrl = `ws://127.0.0.1:${port}${url}`;
558
+ const localWs = new WebSocket(wsUrl);
559
+
560
+ localWs.on("open", () => {
561
+ this.tunnelSockets.set(requestId, localWs);
562
+ });
563
+
564
+ localWs.on("message", (data: Buffer | string) => {
565
+ const isBinary = typeof data !== "string";
566
+ const buf = typeof data === "string" ? Buffer.from(data) : data;
567
+ this.send(
568
+ createEnvelope({
569
+ type: "tunnel.ws.data",
570
+ sessionId: this.sessionId,
571
+ payload: {
572
+ requestId,
573
+ data: buf.toString("base64"),
574
+ isBinary,
575
+ },
576
+ }),
577
+ );
578
+ });
579
+
580
+ localWs.on("close", (code, reason) => {
581
+ this.tunnelSockets.delete(requestId);
582
+ this.send(
583
+ createEnvelope({
584
+ type: "tunnel.ws.close",
585
+ sessionId: this.sessionId,
586
+ payload: {
587
+ requestId,
588
+ code,
589
+ reason: reason?.toString() || "",
590
+ },
591
+ }),
592
+ );
593
+ });
594
+
595
+ localWs.on("error", () => {
596
+ this.tunnelSockets.delete(requestId);
597
+ this.send(
598
+ createEnvelope({
599
+ type: "tunnel.ws.close",
600
+ sessionId: this.sessionId,
601
+ payload: {
602
+ requestId,
603
+ code: 1001,
604
+ reason: "Local WebSocket error",
605
+ },
606
+ }),
607
+ );
608
+ });
609
+ }
610
+
611
+ private handleTunnelWsData(payload: {
612
+ requestId: string;
613
+ data: string;
614
+ isBinary: boolean;
615
+ }): void {
616
+ const ws = this.tunnelSockets.get(payload.requestId);
617
+ if (!ws || ws.readyState !== WebSocket.OPEN) return;
618
+ const buf = Buffer.from(payload.data, "base64");
619
+ ws.send(payload.isBinary ? buf : buf.toString("utf8"));
620
+ }
621
+
622
+ private handleTunnelWsClose(payload: {
623
+ requestId: string;
624
+ code?: number;
625
+ reason?: string;
626
+ }): void {
627
+ const ws = this.tunnelSockets.get(payload.requestId);
628
+ if (!ws) return;
629
+ ws.close(payload.code ?? 1000, payload.reason ?? "");
630
+ this.tunnelSockets.delete(payload.requestId);
631
+ }
632
+
633
+ private sendTunnelError(requestId: string, statusCode: number, message: string): void {
634
+ this.send(
635
+ createEnvelope({
636
+ type: "tunnel.response",
637
+ sessionId: this.sessionId,
638
+ payload: {
639
+ requestId,
640
+ statusCode,
641
+ headers: { "content-type": "text/plain" },
642
+ body: Buffer.from(message).toString("base64"),
643
+ isFinal: true,
644
+ },
645
+ }),
646
+ );
647
+ }
648
+
450
649
  private sendTerminalList(): void {
451
650
  const terminals = [...this.terminals.values()].map((t) => ({
452
651
  terminalId: t.id,
@@ -1274,6 +1473,11 @@ export class BridgeSession {
1274
1473
  }
1275
1474
  this.socket?.close();
1276
1475
  this.socket = undefined;
1476
+ // Clean up tunnel WebSockets
1477
+ for (const ws of this.tunnelSockets.values()) {
1478
+ ws.close(1001, "Session stopped");
1479
+ }
1480
+ this.tunnelSockets.clear();
1277
1481
  for (const term of this.terminals.values()) {
1278
1482
  this.cleanupHookServer(term);
1279
1483
  if (term.status === "running") term.pty.kill();