From 6893ece898f00fbacff1dd98357cb1dab7394269 Mon Sep 17 00:00:00 2001 From: Anso Date: Fri, 1 May 2026 00:28:18 -0400 Subject: [PATCH] feat(pilot): add tcp tunnel frames + mesh sidecar package (#857) Lays the dormant data-plane foundation for Sencho Mesh. The pilot tunnel gains TCP forwarding frames (tcp_open / tcp_open_ack / tcp_close JSON plus a 0x04 TcpData binary type) and a TcpStream surface on the bridge so a future MeshService can ride the existing WSS tunnel for cross-node container traffic. The agent rejects every tcp_open with mesh_not_enabled until a follow-up PR wires the Dockerode resolver gated by a mesh_stacks opt-in table; ships dormant. A new top-level mesh-sidecar/ package provides the per-node container that will host the L4 forwarder + control WS in production. Built as a small Node 22 alpine image and published in lockstep with the main sencho image via a parallel docker-publish workflow job. Tests cover protocol roundtrips on both packages and the sidecar forwarder end-to-end including resolve, splice, close, and stats. --- .github/workflows/docker-publish.yml | 72 + .../src/__tests__/pilot-protocol-tcp.test.ts | 106 ++ backend/src/pilot/agent.ts | 108 ++ backend/src/pilot/protocol.ts | 36 +- backend/src/services/PilotTunnelBridge.ts | 165 +- mesh-sidecar/.gitignore | 3 + mesh-sidecar/Dockerfile | 17 + mesh-sidecar/package-lock.json | 1333 +++++++++++++++++ mesh-sidecar/package.json | 23 + mesh-sidecar/src/__tests__/forwarder.test.ts | 190 +++ mesh-sidecar/src/__tests__/protocol.test.ts | 73 + mesh-sidecar/src/control.ts | 175 +++ mesh-sidecar/src/forwarder.ts | 180 +++ mesh-sidecar/src/index.ts | 58 + mesh-sidecar/src/protocol.ts | 146 ++ mesh-sidecar/tsconfig.json | 16 + mesh-sidecar/vitest.config.ts | 11 + 17 files changed, 2706 insertions(+), 6 deletions(-) create mode 100644 backend/src/__tests__/pilot-protocol-tcp.test.ts create mode 100644 mesh-sidecar/.gitignore create mode 100644 mesh-sidecar/Dockerfile create mode 100644 mesh-sidecar/package-lock.json create mode 100644 mesh-sidecar/package.json create mode 100644 mesh-sidecar/src/__tests__/forwarder.test.ts create mode 100644 mesh-sidecar/src/__tests__/protocol.test.ts create mode 100644 mesh-sidecar/src/control.ts create mode 100644 mesh-sidecar/src/forwarder.ts create mode 100644 mesh-sidecar/src/index.ts create mode 100644 mesh-sidecar/src/protocol.ts create mode 100644 mesh-sidecar/tsconfig.json create mode 100644 mesh-sidecar/vitest.config.ts diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index b3730ab5..2d1aa87e 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -232,3 +232,75 @@ jobs: sbom.cdx.json sbom.spdx.json security/vex/sencho.openvex.json + + push_mesh_sidecar: + name: Push Sencho Mesh sidecar image to Docker Hub + runs-on: ubuntu-latest + timeout-minutes: 20 + environment: production + permissions: + contents: read + id-token: write + steps: + - name: Check out the repo + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - name: Set up QEMU + uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4 + with: + platforms: arm64 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4 + + - name: Log in to Docker Hub + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Install cosign + uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1 + + - name: Extract metadata for Mesh sidecar image + id: mesh_meta + uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6 + with: + # Sencho Mesh sidecar image. Versioned in lockstep with the main + # sencho image so each Sencho release has a matched mesh sidecar. + images: saelix/sencho-mesh + tags: | + type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }} + type=semver,pattern={{version}},enable=${{ startsWith(github.ref, 'refs/tags/v') }} + type=semver,pattern={{major}}.{{minor}},enable=${{ startsWith(github.ref, 'refs/tags/v') }} + + - name: Build and push Mesh sidecar image + id: mesh_build + uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7 + with: + context: ./mesh-sidecar + file: ./mesh-sidecar/Dockerfile + push: true + platforms: linux/amd64,linux/arm64 + tags: ${{ steps.mesh_meta.outputs.tags }} + labels: ${{ steps.mesh_meta.outputs.labels }} + cache-from: type=registry,ref=saelix/sencho-mesh:buildcache + cache-to: type=registry,ref=saelix/sencho-mesh:buildcache,mode=max + sbom: true + provenance: mode=max + + - name: Sign Mesh sidecar with cosign (keyless) + env: + TAGS: ${{ steps.mesh_meta.outputs.tags }} + DIGEST: ${{ steps.mesh_build.outputs.digest }} + run: | + refs=() + while IFS= read -r tag; do + [ -n "$tag" ] || continue + refs+=("${tag}@${DIGEST}") + done <<< "$TAGS" + if [ ${#refs[@]} -gt 0 ]; then + cosign sign --yes "${refs[@]}" + else + echo "No tags to sign (likely a workflow_dispatch run on a non-tag ref)." + fi diff --git a/backend/src/__tests__/pilot-protocol-tcp.test.ts b/backend/src/__tests__/pilot-protocol-tcp.test.ts new file mode 100644 index 00000000..695c5992 --- /dev/null +++ b/backend/src/__tests__/pilot-protocol-tcp.test.ts @@ -0,0 +1,106 @@ +/** + * Tests for the Sencho Mesh TCP frames added to the pilot tunnel protocol. + */ +import { describe, it, expect } from 'vitest'; +import { + BinaryFrameType, + decodeBinaryFrame, + decodeJsonFrame, + encodeBinaryFrame, + encodeJsonFrame, +} from '../pilot/protocol'; + +describe('Mesh TCP JSON frames', () => { + it('roundtrips a tcp_open frame', () => { + const raw = encodeJsonFrame({ + t: 'tcp_open', + s: 42, + stack: 'api', + service: 'db', + port: 5432, + }); + const decoded = decodeJsonFrame(raw); + expect(decoded.t).toBe('tcp_open'); + if (decoded.t !== 'tcp_open') throw new Error('narrowing'); + expect(decoded.s).toBe(42); + expect(decoded.stack).toBe('api'); + expect(decoded.service).toBe('db'); + expect(decoded.port).toBe(5432); + }); + + it('roundtrips a tcp_open_ack success', () => { + const raw = encodeJsonFrame({ t: 'tcp_open_ack', s: 42, ok: true }); + const decoded = decodeJsonFrame(raw); + expect(decoded.t).toBe('tcp_open_ack'); + if (decoded.t !== 'tcp_open_ack') throw new Error('narrowing'); + expect(decoded.ok).toBe(true); + expect(decoded.err).toBeUndefined(); + }); + + it('roundtrips a tcp_open_ack failure with error code', () => { + const raw = encodeJsonFrame({ + t: 'tcp_open_ack', + s: 42, + ok: false, + err: 'unreachable', + }); + const decoded = decodeJsonFrame(raw); + if (decoded.t !== 'tcp_open_ack') throw new Error('narrowing'); + expect(decoded.ok).toBe(false); + expect(decoded.err).toBe('unreachable'); + }); + + it('roundtrips a tcp_close frame', () => { + const raw = encodeJsonFrame({ t: 'tcp_close', s: 42 }); + const decoded = decodeJsonFrame(raw); + expect(decoded.t).toBe('tcp_close'); + if (decoded.t !== 'tcp_close') throw new Error('narrowing'); + expect(decoded.s).toBe(42); + }); +}); + +describe('Mesh TcpData binary frames', () => { + it('encodes the 0x04 type discriminator', () => { + const payload = Buffer.from('hello'); + const encoded = encodeBinaryFrame(BinaryFrameType.TcpData, 1, payload); + expect(encoded[0]).toBe(0x04); + }); + + it('roundtrips streamId + payload', () => { + const payload = Buffer.from('SELECT 1;'); + const encoded = encodeBinaryFrame(BinaryFrameType.TcpData, 0xdeadbeef, payload); + const decoded = decodeBinaryFrame(encoded); + expect(decoded.type).toBe(BinaryFrameType.TcpData); + expect(decoded.streamId).toBe(0xdeadbeef); + expect(decoded.payload.toString()).toBe('SELECT 1;'); + }); + + it('roundtrips an empty payload', () => { + const encoded = encodeBinaryFrame(BinaryFrameType.TcpData, 7, Buffer.alloc(0)); + const decoded = decodeBinaryFrame(encoded); + expect(decoded.type).toBe(BinaryFrameType.TcpData); + expect(decoded.streamId).toBe(7); + expect(decoded.payload.length).toBe(0); + }); + + it('preserves binary payloads byte-for-byte', () => { + const payload = Buffer.from([0x00, 0xff, 0x01, 0x80, 0x7f, 0x10]); + const encoded = encodeBinaryFrame(BinaryFrameType.TcpData, 1, payload); + const decoded = decodeBinaryFrame(encoded); + expect(decoded.payload.equals(payload)).toBe(true); + }); + + it('rejects an unknown binary frame type', () => { + const buf = Buffer.alloc(5); + buf.writeUInt8(0x99, 0); + buf.writeUInt32BE(1, 1); + expect(() => decodeBinaryFrame(buf)).toThrow(/unknown binary frame type/); + }); + + it('continues to accept the existing http and ws binary types', () => { + for (const t of [BinaryFrameType.HttpReqBody, BinaryFrameType.HttpResBody, BinaryFrameType.WsMessageBinary]) { + const buf = encodeBinaryFrame(t, 1, Buffer.from('x')); + expect(() => decodeBinaryFrame(buf)).not.toThrow(); + } + }); +}); diff --git a/backend/src/pilot/agent.ts b/backend/src/pilot/agent.ts index 7f26d74d..2586ac08 100644 --- a/backend/src/pilot/agent.ts +++ b/backend/src/pilot/agent.ts @@ -1,10 +1,12 @@ import fs from 'fs'; +import net from 'net'; import path from 'path'; import http from 'http'; import WebSocket from 'ws'; import { getSenchoVersion } from '../services/CapabilityRegistry'; import { BinaryFrameType, + MeshErrCode, PROTOCOL_VERSION, decodeBinaryFrame, decodeJsonFrame, @@ -66,6 +68,7 @@ class PilotAgent { private reconnectTimer?: NodeJS.Timeout; private readonly httpStreams = new Map(); private readonly wsStreams = new Map(); + private readonly tcpStreams = new Map(); private shuttingDown = false; private readonly agentVersion: string; @@ -143,6 +146,10 @@ class PilotAgent { try { ws.close(1006, 'tunnel closed'); } catch { /* ignore */ } } this.wsStreams.clear(); + for (const [, stream] of this.tcpStreams) { + try { stream.socket.destroy(); } catch { /* ignore */ } + } + this.tcpStreams.clear(); } private scheduleReconnect(): void { @@ -199,6 +206,8 @@ class PilotAgent { case 'ws_open': this.onWsOpen(frame); break; case 'ws_msg_text': this.onWsMsgText(frame.s, frame.data); break; case 'ws_close': this.onWsClose(frame.s, frame.code, frame.reason); break; + case 'tcp_open': this.onTcpOpen(frame); break; + case 'tcp_close': this.onTcpClose(frame.s); break; default: // Other frame types are primary-bound only; agent ignores. break; @@ -219,6 +228,12 @@ class PilotAgent { try { ws.send(frame.payload, { binary: true }); } catch { /* ignore */ } break; } + case BinaryFrameType.TcpData: { + const stream = this.tcpStreams.get(frame.streamId); + if (!stream) return; + try { stream.socket.write(frame.payload); } catch { /* ignore */ } + break; + } default: break; } @@ -329,8 +344,101 @@ class PilotAgent { try { ws.close(code, reason); } catch { /* ignore */ } this.wsStreams.delete(streamId); } + + // --- Sencho Mesh TCP dispatch (tunnel -> Compose service container) --- + // + // PR 1 rejects every tcp_open with mesh_not_enabled; the dial path is + // exercised by tests via setMeshResolver but never lit in production until + // PR 2 wires Dockerode resolution gated by the local mesh_stacks table. + + private async onTcpOpen(frame: Extract, { t: 'tcp_open' }>): Promise { + const ws = this.ws; + if (!ws) return; + + const target = await this.resolveMeshTarget(frame.stack, frame.service, frame.port); + if (!target.ok) { + try { + ws.send(encodeJsonFrame({ t: 'tcp_open_ack', s: frame.s, ok: false, err: target.err })); + } catch { /* ignore */ } + return; + } + + const socket = net.createConnection({ host: target.host, port: target.port }); + socket.setTimeout(MESH_CONNECT_TIMEOUT_MS); + const entry: MeshTcpStream = { socket, accepted: false }; + this.tcpStreams.set(frame.s, entry); + + const sendAck = (ok: boolean, err?: MeshErrCode) => { + try { ws.send(encodeJsonFrame({ t: 'tcp_open_ack', s: frame.s, ok, err })); } catch { /* ignore */ } + }; + + socket.once('connect', () => { + entry.accepted = true; + socket.setTimeout(0); + sendAck(true); + }); + socket.on('data', (chunk: Buffer) => { + try { + ws.send(encodeBinaryFrame(BinaryFrameType.TcpData, frame.s, chunk), { binary: true }); + } catch { /* ignore */ } + }); + socket.on('timeout', () => { + if (entry.accepted) return; + entry.accepted = true; + sendAck(false, 'unreachable'); + this.tcpStreams.delete(frame.s); + try { socket.destroy(); } catch { /* ignore */ } + }); + socket.on('error', (err) => { + if (!entry.accepted) { + entry.accepted = true; + sendAck(false, 'unreachable'); + this.tcpStreams.delete(frame.s); + return; + } + console.warn('[Pilot] tcp stream error:', sanitizeForLog(err.message)); + if (this.tcpStreams.delete(frame.s)) { + try { ws.send(encodeJsonFrame({ t: 'tcp_close', s: frame.s })); } catch { /* ignore */ } + } + }); + socket.on('close', () => { + if (this.tcpStreams.delete(frame.s)) { + try { ws.send(encodeJsonFrame({ t: 'tcp_close', s: frame.s })); } catch { /* ignore */ } + } + }); + } + + private onTcpClose(streamId: number): void { + const entry = this.tcpStreams.get(streamId); + if (!entry) return; + this.tcpStreams.delete(streamId); + try { entry.socket.destroy(); } catch { /* ignore */ } + } + + /** + * Always returns ok:false in PR 1. Tests inject a real resolver via + * setMeshResolver; PR 2 will install the Dockerode-backed implementation. + */ + private async resolveMeshTarget( + _stack: string, + _service: string, + _port: number, + ): Promise { + return { ok: false, err: 'mesh_not_enabled' }; + } } +const MESH_CONNECT_TIMEOUT_MS = 10_000; + +interface MeshTcpStream { + socket: net.Socket; + accepted: boolean; +} + +type MeshResolveResult = + | { ok: true; host: string; port: number } + | { ok: false; err: MeshErrCode }; + function readPersistedToken(): string | null { try { if (fs.existsSync(TOKEN_PATH)) { diff --git a/backend/src/pilot/protocol.ts b/backend/src/pilot/protocol.ts index 47e11444..bbd337ac 100644 --- a/backend/src/pilot/protocol.ts +++ b/backend/src/pilot/protocol.ts @@ -23,6 +23,7 @@ export enum BinaryFrameType { HttpReqBody = 0x01, HttpResBody = 0x02, WsMessageBinary = 0x03, + TcpData = 0x04, } // --- JSON envelope types --- @@ -39,7 +40,10 @@ export type JsonFrame = | WsRejectFrame | WsMessageTextFrame | WsCloseFrame - | ControlFrame; + | ControlFrame + | TcpOpenFrame + | TcpOpenAckFrame + | TcpCloseFrame; export interface HelloFrame { t: 'hello'; @@ -119,6 +123,33 @@ export interface ControlFrame { payload?: Record; } +/** + * Sencho Mesh TCP frames. The primary asks the agent to open a TCP connection + * to a Compose service on the agent's local Docker host. Bytes flow as + * BinaryFrameType.TcpData. Mid-stream failures send tcp_close. + */ +export interface TcpOpenFrame { + t: 'tcp_open'; + s: number; + stack: string; + service: string; + port: number; +} + +export type MeshErrCode = 'mesh_not_enabled' | 'denied' | 'no_target' | 'unreachable' | 'agent_error'; + +export interface TcpOpenAckFrame { + t: 'tcp_open_ack'; + s: number; + ok: boolean; + err?: MeshErrCode; +} + +export interface TcpCloseFrame { + t: 'tcp_close'; + s: number; +} + // --- Serialize / parse --- export function encodeJsonFrame(frame: JsonFrame): string { @@ -161,7 +192,8 @@ export function decodeBinaryFrame(buf: Buffer): DecodedBinaryFrame { const type = buf.readUInt8(0) as BinaryFrameType; if (type !== BinaryFrameType.HttpReqBody && type !== BinaryFrameType.HttpResBody && - type !== BinaryFrameType.WsMessageBinary) { + type !== BinaryFrameType.WsMessageBinary && + type !== BinaryFrameType.TcpData) { throw new Error(`unknown binary frame type: ${type}`); } const streamId = buf.readUInt32BE(1); diff --git a/backend/src/services/PilotTunnelBridge.ts b/backend/src/services/PilotTunnelBridge.ts index 6e96478d..79f8029c 100644 --- a/backend/src/services/PilotTunnelBridge.ts +++ b/backend/src/services/PilotTunnelBridge.ts @@ -31,7 +31,62 @@ interface WsStreamState { clientWs?: WebSocket; } -type StreamState = HttpStreamState | WsStreamState; +interface TcpStreamState { + kind: 'tcp'; + handle: TcpStream; + bytesIn: number; + bytesOut: number; + openedAt: number; + accepted: boolean; +} + +type StreamState = HttpStreamState | WsStreamState | TcpStreamState; + +export interface TcpStreamSummary { + streamId: number; + bytesIn: number; + bytesOut: number; + openedAt: number; +} + +/** + * Sencho Mesh TCP stream handle. EventEmitter-based duplex-like surface that + * MeshService consumes to bridge a local socket to a Compose service on the + * remote node behind this pilot tunnel. + * + * Events: + * 'open' tcp_open_ack ok received; safe to write/read + * 'data' (Buffer) bytes from the remote socket + * 'drain' send buffer below high-water mark + * 'error' (err) open rejected or mid-stream tunnel error + * 'close' stream closed (graceful or otherwise) + */ +export class TcpStream extends EventEmitter { + public readonly streamId: number; + private readonly bridge: PilotTunnelBridge; + + constructor(streamId: number, bridge: PilotTunnelBridge) { + super(); + this.streamId = streamId; + this.bridge = bridge; + } + + /** + * Returns false when the underlying tunnel buffer is above the high-water + * mark; caller should pause its source until 'drain' fires. + */ + public write(chunk: Buffer): boolean { + return this.bridge._writeTcpData(this.streamId, chunk); + } + + public end(): void { + this.bridge._closeTcpStream(this.streamId); + } + + public destroy(): void { + this.end(); + } +} /** * Per-tunnel bridge: hosts a loopback HTTP server that demuxes requests into @@ -86,14 +141,85 @@ export class PilotTunnelBridge extends EventEmitter { }); }); this.pingTimer = setInterval(() => { - if (this.tunnelWs.readyState === WebSocket.OPEN) { - try { this.tunnelWs.ping(); } catch { /* surfaced via 'error' */ } + if (this.tunnelWs.readyState !== WebSocket.OPEN) return; + try { this.tunnelWs.ping(); } catch { /* surfaced via 'error' */ } + // Coarse drain fan-out for TCP streams: ws does not expose a + // socket-level 'drain' we can hook, so we let backpressure clear + // by the next ping cycle. + if (this.tunnelWs.bufferedAmount <= BUFFER_HIGH_WATER_MARK) { + for (const s of this.streams.values()) { + if (s.kind === 'tcp' && s.accepted) s.handle.emit('drain'); + } } }, PING_INTERVAL_MS); } public getLoopbackUrl(): string { return this.loopbackUrl; } public getConnectedAt(): number { return this.connectedAt; } + public getBufferedAmount(): number { return this.tunnelWs.bufferedAmount; } + public isOpen(): boolean { return !this.closed && this.tunnelWs.readyState === WebSocket.OPEN; } + + /** + * Open a TCP stream to a Compose service on the remote node. Caller listens + * on the returned TcpStream for 'open' (when the remote agent has accepted), + * 'data', 'error', and 'close'. Returns null if the tunnel is not open. + */ + public openTcpStream(target: { stack: string; service: string; port: number }): TcpStream | null { + if (!this.isOpen()) return null; + const streamId = this.streamIds.allocate(); + const handle = new TcpStream(streamId, this); + this.streams.set(streamId, { + kind: 'tcp', + handle, + bytesIn: 0, + bytesOut: 0, + openedAt: Date.now(), + accepted: false, + }); + this.sendJson({ + t: 'tcp_open', + s: streamId, + stack: target.stack, + service: target.service, + port: target.port, + }); + return handle; + } + + /** + * @internal Called only by TcpStream.write; applies the same 4 MB + * backpressure rule used by HTTP request bodies. Public for cross-class + * access only; not part of the bridge's outward API. + */ + public _writeTcpData(streamId: number, payload: Buffer): boolean { + const s = this.streams.get(streamId); + if (!s || s.kind !== 'tcp') return false; + if (!this.isOpen()) return false; + this.sendBinary(BinaryFrameType.TcpData, streamId, payload); + s.bytesOut += payload.length; + return this.tunnelWs.bufferedAmount <= BUFFER_HIGH_WATER_MARK; + } + + /** @internal Called only by TcpStream.end / .destroy. */ + public _closeTcpStream(streamId: number): void { + if (!this.streams.has(streamId)) return; + this.streams.delete(streamId); + this.sendJson({ t: 'tcp_close', s: streamId }); + } + + /** + * Snapshot of active TCP streams for the diagnostics sheet. Cheap; called + * on demand by MeshService. + */ + public listTcpStreams(): TcpStreamSummary[] { + const out: TcpStreamSummary[] = []; + for (const [streamId, s] of this.streams) { + if (s.kind === 'tcp') { + out.push({ streamId, bytesIn: s.bytesIn, bytesOut: s.bytesOut, openedAt: s.openedAt }); + } + } + return out; + } public close(code = 1000, reason = 'closed by primary'): void { if (this.closed) return; @@ -314,6 +440,26 @@ export class PilotTunnelBridge extends EventEmitter { // called, and ping/pong are handled by the WS layer. break; } + case 'tcp_open_ack': { + const s = this.streams.get(frame.s); + if (!s || s.kind !== 'tcp') return; + if (frame.ok) { + s.accepted = true; + s.handle.emit('open'); + } else { + this.streams.delete(frame.s); + s.handle.emit('error', new Error(frame.err ?? 'tcp_open rejected')); + s.handle.emit('close'); + } + break; + } + case 'tcp_close': { + const s = this.streams.get(frame.s); + if (!s || s.kind !== 'tcp') return; + this.streams.delete(frame.s); + s.handle.emit('close'); + break; + } default: // Ignore unknown JSON frame types for forward compatibility. break; @@ -342,6 +488,12 @@ export class PilotTunnelBridge extends EventEmitter { case BinaryFrameType.HttpReqBody: // Agent never originates request bodies; ignore for defense-in-depth. break; + case BinaryFrameType.TcpData: { + if (s.kind !== 'tcp') return; + s.bytesIn += frame.payload.length; + s.handle.emit('data', frame.payload); + break; + } default: break; } @@ -374,12 +526,17 @@ export class PilotTunnelBridge extends EventEmitter { state.res.end(); } } catch { /* ignore */ } - } else { + } else if (state.kind === 'ws') { if (state.clientWs) { try { state.clientWs.close(1011, 'tunnel closed'); } catch { /* ignore */ } } else if (state.rawSocket) { try { state.rawSocket.destroy(); } catch { /* ignore */ } } + } else { + try { + if (!state.accepted) state.handle.emit('error', new Error('tunnel closed before accept')); + state.handle.emit('close'); + } catch { /* ignore */ } } } } diff --git a/mesh-sidecar/.gitignore b/mesh-sidecar/.gitignore new file mode 100644 index 00000000..06e60381 --- /dev/null +++ b/mesh-sidecar/.gitignore @@ -0,0 +1,3 @@ +node_modules +dist +*.tsbuildinfo diff --git a/mesh-sidecar/Dockerfile b/mesh-sidecar/Dockerfile new file mode 100644 index 00000000..50067f43 --- /dev/null +++ b/mesh-sidecar/Dockerfile @@ -0,0 +1,17 @@ +# Multi-stage build for the Sencho Mesh sidecar. +FROM node:22-alpine AS build +WORKDIR /app +COPY package.json tsconfig.json ./ +RUN npm install --no-audit --no-fund +COPY src ./src +RUN npm run build + +FROM node:22-alpine AS runtime +WORKDIR /app +ENV NODE_ENV=production +COPY package.json ./ +RUN npm install --omit=dev --no-audit --no-fund +COPY --from=build /app/dist ./dist + +USER node +CMD ["node", "dist/index.js"] diff --git a/mesh-sidecar/package-lock.json b/mesh-sidecar/package-lock.json new file mode 100644 index 00000000..f9019b53 --- /dev/null +++ b/mesh-sidecar/package-lock.json @@ -0,0 +1,1333 @@ +{ + "name": "@sencho/mesh-sidecar", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@sencho/mesh-sidecar", + "version": "0.0.1", + "dependencies": { + "ws": "^8.19.0" + }, + "devDependencies": { + "@types/node": "^25.3.0", + "@types/ws": "^8.18.1", + "typescript": "^6.0.2", + "vitest": "^4.1.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz", + "integrity": "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz", + "integrity": "sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.17.tgz", + "integrity": "sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.17.tgz", + "integrity": "sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.17.tgz", + "integrity": "sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.17.tgz", + "integrity": "sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.17.tgz", + "integrity": "sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.17.tgz", + "integrity": "sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.17.tgz", + "integrity": "sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.17.tgz", + "integrity": "sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.17.tgz", + "integrity": "sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.17.tgz", + "integrity": "sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.17.tgz", + "integrity": "sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.6.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", + "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.19.0" + } + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz", + "integrity": "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.5", + "@vitest/utils": "4.1.5", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.5.tgz", + "integrity": "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.5", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.5.tgz", + "integrity": "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.5.tgz", + "integrity": "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.5", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.5.tgz", + "integrity": "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.5", + "@vitest/utils": "4.1.5", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.5.tgz", + "integrity": "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.5.tgz", + "integrity": "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.5", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.13", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.13.tgz", + "integrity": "sha512-qif0+jGGZoLWdHey3UFHHWP0H7Gbmsk8T5VEqyYFbWqPr1XqvLGBbk/sl8V5exGmcYJklJOhOQq1pV9IcsiFag==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rolldown": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz", + "integrity": "sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.127.0", + "@rolldown/pluginutils": "1.0.0-rc.17" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-rc.17", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.17", + "@rolldown/binding-darwin-x64": "1.0.0-rc.17", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.17", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.17", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.17", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.17", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.17", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.17", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.17", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.2.tgz", + "integrity": "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.19.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", + "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.0.10", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz", + "integrity": "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.10", + "rolldown": "1.0.0-rc.17", + "tinyglobby": "^0.2.16" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.5.tgz", + "integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.5", + "@vitest/mocker": "4.1.5", + "@vitest/pretty-format": "4.1.5", + "@vitest/runner": "4.1.5", + "@vitest/snapshot": "4.1.5", + "@vitest/spy": "4.1.5", + "@vitest/utils": "4.1.5", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.5", + "@vitest/browser-preview": "4.1.5", + "@vitest/browser-webdriverio": "4.1.5", + "@vitest/coverage-istanbul": "4.1.5", + "@vitest/coverage-v8": "4.1.5", + "@vitest/ui": "4.1.5", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/mesh-sidecar/package.json b/mesh-sidecar/package.json new file mode 100644 index 00000000..07b825ad --- /dev/null +++ b/mesh-sidecar/package.json @@ -0,0 +1,23 @@ +{ + "name": "@sencho/mesh-sidecar", + "version": "0.0.1", + "private": true, + "description": "Sencho Mesh sidecar: per-node TCP forwarder + control WS bridge.", + "main": "dist/index.js", + "type": "commonjs", + "scripts": { + "build": "tsc", + "start": "node dist/index.js", + "test": "vitest run", + "lint": "eslint src" + }, + "dependencies": { + "ws": "^8.19.0" + }, + "devDependencies": { + "@types/node": "^25.3.0", + "@types/ws": "^8.18.1", + "typescript": "^6.0.2", + "vitest": "^4.1.0" + } +} diff --git a/mesh-sidecar/src/__tests__/forwarder.test.ts b/mesh-sidecar/src/__tests__/forwarder.test.ts new file mode 100644 index 00000000..095aa3f4 --- /dev/null +++ b/mesh-sidecar/src/__tests__/forwarder.test.ts @@ -0,0 +1,190 @@ +import net from 'net'; +import { afterEach, describe, expect, it } from 'vitest'; +import { Forwarder, MeshController } from '../forwarder'; + +interface RecordedCalls { + resolve: Array<{ connId: number; port: number; remoteAddr: string }>; + sendData: Array<{ streamId: number; payload: Buffer }>; + sendClose: number[]; + sendStats: Array<{ streamId: number; bytesIn: number; bytesOut: number }>; +} + +function makeRecordingController(): { controller: MeshController; calls: RecordedCalls } { + const calls: RecordedCalls = { resolve: [], sendData: [], sendClose: [], sendStats: [] }; + const controller: MeshController = { + resolve: (connId, port, remoteAddr) => calls.resolve.push({ connId, port, remoteAddr }), + sendData: (streamId, payload) => calls.sendData.push({ streamId, payload: Buffer.from(payload) }), + sendClose: (streamId) => calls.sendClose.push(streamId), + sendStats: (streamId, bytesIn, bytesOut) => calls.sendStats.push({ streamId, bytesIn, bytesOut }), + }; + return { controller, calls }; +} + +async function getEphemeralPort(): Promise { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.unref(); + server.listen(0, '127.0.0.1', () => { + const addr = server.address(); + if (!addr || typeof addr === 'string') { + reject(new Error('no address')); + return; + } + const port = addr.port; + server.close(() => resolve(port)); + }); + }); +} + +async function dial(port: number): Promise { + return new Promise((resolve, reject) => { + const socket = net.createConnection({ host: '127.0.0.1', port }); + socket.once('connect', () => resolve(socket)); + socket.once('error', reject); + }); +} + +async function waitFor(check: () => T | undefined, timeoutMs = 1000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const value = check(); + if (value !== undefined && value !== null && (Array.isArray(value) ? value.length > 0 : true)) { + return value as T; + } + await new Promise((r) => setTimeout(r, 10)); + } + throw new Error('timeout waiting for condition'); +} + +describe('Forwarder', () => { + let forwarder: Forwarder | null = null; + + afterEach(async () => { + if (forwarder) await forwarder.shutdown(); + forwarder = null; + }); + + it('asks the controller to resolve when a client connects', async () => { + const { controller, calls } = makeRecordingController(); + forwarder = new Forwarder(controller); + forwarder.start(); + const port = await getEphemeralPort(); + await forwarder.listen(port); + + const client = await dial(port); + const resolved = await waitFor(() => calls.resolve.length ? calls.resolve : undefined); + expect(resolved[0].port).toBe(port); + expect(resolved[0].connId).toBeGreaterThan(0); + + client.destroy(); + }); + + it('splices bytes both directions after resolve_ok', async () => { + const { controller, calls } = makeRecordingController(); + forwarder = new Forwarder(controller); + forwarder.start(); + const port = await getEphemeralPort(); + await forwarder.listen(port); + + const received: Buffer[] = []; + const client = await dial(port); + client.on('data', (chunk: Buffer) => received.push(chunk)); + + const resolved = await waitFor(() => calls.resolve.length ? calls.resolve : undefined); + const connId = resolved[0].connId; + const streamId = 42; + forwarder.handleResolveOk(connId, streamId, 'test.alias.sencho'); + + client.write('hello'); + const sent = await waitFor(() => calls.sendData.length ? calls.sendData : undefined); + expect(sent[0].streamId).toBe(streamId); + expect(sent[0].payload.toString()).toBe('hello'); + + forwarder.handleData(streamId, Buffer.from('hi back')); + await waitFor(() => received.length ? received : undefined); + expect(Buffer.concat(received).toString()).toBe('hi back'); + + client.destroy(); + }); + + it('sends a close frame when the client socket disconnects', async () => { + const { controller, calls } = makeRecordingController(); + forwarder = new Forwarder(controller); + forwarder.start(); + const port = await getEphemeralPort(); + await forwarder.listen(port); + + const client = await dial(port); + const resolved = await waitFor(() => calls.resolve.length ? calls.resolve : undefined); + forwarder.handleResolveOk(resolved[0].connId, 7); + + client.destroy(); + const closed = await waitFor(() => calls.sendClose.length ? calls.sendClose : undefined); + expect(closed[0]).toBe(7); + }); + + it('drops the local socket on resolve_err', async () => { + const { controller, calls } = makeRecordingController(); + forwarder = new Forwarder(controller); + forwarder.start(); + const port = await getEphemeralPort(); + await forwarder.listen(port); + + const client = await dial(port); + const closedPromise = new Promise((resolve) => client.once('close', () => resolve())); + const resolved = await waitFor(() => calls.resolve.length ? calls.resolve : undefined); + + forwarder.handleResolveErr(resolved[0].connId, 'tunnel_down'); + await closedPromise; + // No data was ever piped, so no close frame sent for this connId. + expect(calls.sendClose.length).toBe(0); + }); + + it('destroys the local socket on inbound close', async () => { + const { controller, calls } = makeRecordingController(); + forwarder = new Forwarder(controller); + forwarder.start(); + const port = await getEphemeralPort(); + await forwarder.listen(port); + + const client = await dial(port); + const closedPromise = new Promise((resolve) => client.once('close', () => resolve())); + const resolved = await waitFor(() => calls.resolve.length ? calls.resolve : undefined); + forwarder.handleResolveOk(resolved[0].connId, 11); + + forwarder.handleClose(11); + await closedPromise; + }); + + it('refuses new connections during shutdown', async () => { + const { controller } = makeRecordingController(); + forwarder = new Forwarder(controller); + forwarder.start(); + const port = await getEphemeralPort(); + await forwarder.listen(port); + await forwarder.shutdown(); + + await expect(dial(port)).rejects.toThrow(); + forwarder = null; + }); + + it('emits stats for active streams when the timer fires', async () => { + const { controller, calls } = makeRecordingController(); + forwarder = new Forwarder(controller, { statsIntervalMs: 50 }); + forwarder.start(); + const port = await getEphemeralPort(); + await forwarder.listen(port); + + const client = await dial(port); + const resolved = await waitFor(() => calls.resolve.length ? calls.resolve : undefined); + forwarder.handleResolveOk(resolved[0].connId, 99); + + client.write('x'); + + const stats = await waitFor(() => calls.sendStats.length ? calls.sendStats : undefined, 1500); + expect(stats[0].streamId).toBe(99); + expect(stats[0].bytesOut).toBeGreaterThan(0); + + client.destroy(); + }); +}); diff --git a/mesh-sidecar/src/__tests__/protocol.test.ts b/mesh-sidecar/src/__tests__/protocol.test.ts new file mode 100644 index 00000000..577cbb88 --- /dev/null +++ b/mesh-sidecar/src/__tests__/protocol.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect } from 'vitest'; +import { + BinaryFrameType, + decodeControl, + decodeData, + encodeControl, + encodeData, +} from '../protocol'; + +describe('Mesh sidecar control frames', () => { + it('roundtrips a hello frame', () => { + const raw = encodeControl({ t: 'hello', version: 1, nodeId: 7, sidecarVersion: '0.0.1' }); + const decoded = decodeControl(raw); + expect(decoded.t).toBe('hello'); + if (decoded.t !== 'hello') throw new Error('narrowing'); + expect(decoded.nodeId).toBe(7); + }); + + it('roundtrips a listen / unlisten pair', () => { + const listen = decodeControl(encodeControl({ t: 'listen', port: 5432 })); + const unlisten = decodeControl(encodeControl({ t: 'unlisten', port: 5432 })); + expect(listen.t).toBe('listen'); + expect(unlisten.t).toBe('unlisten'); + }); + + it('roundtrips resolve / resolve_ok / resolve_err', () => { + const resolve = decodeControl(encodeControl({ t: 'resolve', connId: 1, port: 5432, remoteAddr: '10.0.0.5' })); + const ok = decodeControl(encodeControl({ t: 'resolve_ok', connId: 1, streamId: 9, alias: 'db.api.opsix.sencho' })); + const err = decodeControl(encodeControl({ t: 'resolve_err', connId: 1, code: 'tunnel_down' })); + if (resolve.t !== 'resolve') throw new Error('narrowing'); + if (ok.t !== 'resolve_ok') throw new Error('narrowing'); + if (err.t !== 'resolve_err') throw new Error('narrowing'); + expect(resolve.port).toBe(5432); + expect(ok.streamId).toBe(9); + expect(err.code).toBe('tunnel_down'); + }); + + it('rejects malformed control frames', () => { + expect(() => decodeControl('not json')).toThrow(); + expect(() => decodeControl('{}')).toThrow(); + }); +}); + +describe('Mesh sidecar data frames', () => { + it('encodes the 0x01 type discriminator', () => { + const buf = encodeData(1, Buffer.from('hello')); + expect(buf[0]).toBe(BinaryFrameType.Data); + }); + + it('roundtrips streamId + payload', () => { + const payload = Buffer.from('SELECT 1;'); + const buf = encodeData(0xdeadbeef, payload); + const decoded = decodeData(buf); + expect(decoded.streamId).toBe(0xdeadbeef); + expect(decoded.payload.toString()).toBe('SELECT 1;'); + }); + + it('preserves binary payloads byte-for-byte', () => { + const payload = Buffer.from([0x00, 0xff, 0x01, 0x80, 0x7f]); + const decoded = decodeData(encodeData(1, payload)); + expect(decoded.payload.equals(payload)).toBe(true); + }); + + it('rejects an unknown binary frame type', () => { + const buf = Buffer.alloc(5); + buf.writeUInt8(0x99, 0); + expect(() => decodeData(buf)).toThrow(/unknown binary frame type/); + }); + + it('rejects too-short frames', () => { + expect(() => decodeData(Buffer.alloc(3))).toThrow(/too short/); + }); +}); diff --git a/mesh-sidecar/src/control.ts b/mesh-sidecar/src/control.ts new file mode 100644 index 00000000..9d3e266d --- /dev/null +++ b/mesh-sidecar/src/control.ts @@ -0,0 +1,175 @@ +import WebSocket from 'ws'; +import { Forwarder, MeshController } from './forwarder'; +import { + ControlFrame, + PROTOCOL_VERSION, + decodeControl, + decodeData, + encodeControl, + encodeData, + wsDataToBuffer, + wsDataToString, +} from './protocol'; + +const RECONNECT_MIN_MS = 1_000; +const RECONNECT_MAX_MS = 30_000; +const PING_INTERVAL_MS = 30_000; + +export interface ControlClientOptions { + controlUrl: string; + token: string; + nodeId: number; + sidecarVersion: string; + forwarder: Forwarder; +} + +/** + * WS-backed implementation of MeshController. Keeps a single long-lived + * connection to the local Sencho instance, surfaces inbound frames to the + * Forwarder, and queues outbound frames when reconnecting. + */ +export class ControlClient implements MeshController { + private readonly options: ControlClientOptions; + private ws: WebSocket | null = null; + private backoff = RECONNECT_MIN_MS; + private pingTimer?: NodeJS.Timeout; + private reconnectTimer?: NodeJS.Timeout; + private shuttingDown = false; + + constructor(options: ControlClientOptions) { + this.options = options; + } + + public start(): void { + this.connect(); + } + + public async shutdown(): Promise { + this.shuttingDown = true; + if (this.pingTimer) clearInterval(this.pingTimer); + if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = undefined; } + try { this.ws?.close(1000, 'sidecar shutdown'); } catch { /* ignore */ } + } + + // --- MeshController surface --- + + public resolve(connId: number, port: number, remoteAddr: string): void { + this.send({ t: 'resolve', connId, port, remoteAddr }); + } + + public sendData(streamId: number, payload: Buffer): void { + const ws = this.ws; + if (!ws || ws.readyState !== WebSocket.OPEN) return; + try { ws.send(encodeData(streamId, payload), { binary: true }); } catch { /* ignore */ } + } + + public sendClose(streamId: number): void { + this.send({ t: 'close', streamId }); + } + + public sendStats(streamId: number, bytesIn: number, bytesOut: number, lastActivity: number): void { + this.send({ t: 'stream.stats', streamId, bytesIn, bytesOut, lastActivity }); + } + + public sendLog(level: 'info' | 'warn' | 'error', message: string, details?: Record): void { + this.send({ t: 'log', level, message, details }); + } + + // --- Connection lifecycle --- + + private connect(): void { + if (this.shuttingDown) return; + + if (this.pingTimer) { clearInterval(this.pingTimer); this.pingTimer = undefined; } + if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = undefined; } + + const ws = new WebSocket(this.options.controlUrl, { + headers: { Authorization: `Bearer ${this.options.token}` }, + handshakeTimeout: 15_000, + }); + this.ws = ws; + + ws.on('open', () => { + this.backoff = RECONNECT_MIN_MS; + this.send({ + t: 'hello', + version: PROTOCOL_VERSION, + nodeId: this.options.nodeId, + sidecarVersion: this.options.sidecarVersion, + }); + this.pingTimer = setInterval(() => { + if (ws.readyState === WebSocket.OPEN) { + try { ws.ping(); } catch { /* error event will handle */ } + } + }, PING_INTERVAL_MS); + }); + + ws.on('message', (data, isBinary) => this.onMessage(data, isBinary)); + ws.on('close', () => { + if (this.pingTimer) { clearInterval(this.pingTimer); this.pingTimer = undefined; } + this.scheduleReconnect(); + }); + ws.on('error', () => { + // 'close' will follow. + }); + } + + private scheduleReconnect(): void { + if (this.shuttingDown) return; + const jitter = Math.floor(Math.random() * 500); + const delay = this.backoff + jitter; + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = undefined; + this.connect(); + }, delay); + this.backoff = Math.min(this.backoff * 2, RECONNECT_MAX_MS); + } + + private onMessage(data: WebSocket.RawData, isBinary: boolean): void { + try { + if (isBinary) { + const buf = wsDataToBuffer(data); + if (!buf) return; + const decoded = decodeData(buf); + this.options.forwarder.handleData(decoded.streamId, decoded.payload); + } else { + const text = wsDataToString(data); + if (text == null) return; + const frame = decodeControl(text); + this.dispatchControl(frame); + } + } catch { + // Malformed frames are dropped; tunnel stays up. + } + } + + private dispatchControl(frame: ControlFrame): void { + switch (frame.t) { + case 'listen': + void this.options.forwarder.listen(frame.port); + break; + case 'unlisten': + void this.options.forwarder.unlisten(frame.port); + break; + case 'resolve_ok': + this.options.forwarder.handleResolveOk(frame.connId, frame.streamId, frame.alias); + break; + case 'resolve_err': + this.options.forwarder.handleResolveErr(frame.connId, frame.code); + break; + case 'close': + this.options.forwarder.handleClose(frame.streamId); + break; + default: + // hello / resolve / stream.stats / log are sidecar-originated; + // ignore on the inbound path. + break; + } + } + + private send(frame: ControlFrame): void { + const ws = this.ws; + if (!ws || ws.readyState !== WebSocket.OPEN) return; + try { ws.send(encodeControl(frame)); } catch { /* ignore */ } + } +} diff --git a/mesh-sidecar/src/forwarder.ts b/mesh-sidecar/src/forwarder.ts new file mode 100644 index 00000000..a3760b87 --- /dev/null +++ b/mesh-sidecar/src/forwarder.ts @@ -0,0 +1,180 @@ +import net from 'net'; + +const DEFAULT_STATS_INTERVAL_MS = 5_000; + +export interface ForwarderOptions { + statsIntervalMs?: number; +} + +/** + * Outbound channel to the Sencho instance hosting this sidecar. The + * production implementation is the control WS client; tests inject a fake. + */ +export interface MeshController { + resolve(connId: number, port: number, remoteAddr: string): void; + sendData(streamId: number, payload: Buffer): void; + sendClose(streamId: number): void; + sendStats(streamId: number, bytesIn: number, bytesOut: number, lastActivity: number): void; +} + +interface PendingConn { + connId: number; + socket: net.Socket; + port: number; +} + +interface ActiveStream { + streamId: number; + socket: net.Socket; + bytesIn: number; + bytesOut: number; + lastActivity: number; + alias?: string; +} + +/** + * The Forwarder owns per-port TCP listeners and the live socket <-> stream + * mapping. It is wire-agnostic: all outbound frames flow through MeshController + * methods. Inbound frames are delivered via the handle* methods. + */ +export class Forwarder { + private readonly controller: MeshController; + private readonly statsIntervalMs: number; + private readonly listeners = new Map(); + private readonly pendingConns = new Map(); + private readonly activeStreams = new Map(); + private nextConnId = 1; + private statsTimer?: NodeJS.Timeout; + private shuttingDown = false; + + constructor(controller: MeshController, options: ForwarderOptions = {}) { + this.controller = controller; + this.statsIntervalMs = options.statsIntervalMs ?? DEFAULT_STATS_INTERVAL_MS; + } + + public start(): void { + this.statsTimer = setInterval(() => this.flushStats(), this.statsIntervalMs); + } + + public async listen(port: number): Promise { + if (this.listeners.has(port) || this.shuttingDown) return; + const server = net.createServer((socket) => this.acceptConnection(port, socket)); + await new Promise((resolve, reject) => { + const onError = (err: Error) => { server.removeListener('listening', onListening); reject(err); }; + const onListening = () => { server.removeListener('error', onError); resolve(); }; + server.once('error', onError); + server.once('listening', onListening); + server.listen(port); + }); + this.listeners.set(port, server); + } + + public async unlisten(port: number): Promise { + const server = this.listeners.get(port); + if (!server) return; + this.listeners.delete(port); + await new Promise((resolve) => { + server.close(() => resolve()); + }); + } + + public handleResolveOk(connId: number, streamId: number, alias?: string): void { + const pending = this.pendingConns.get(connId); + if (!pending) { + // Sencho thinks we have a conn but we don't (race with socket close). + this.controller.sendClose(streamId); + return; + } + this.pendingConns.delete(connId); + + const stream: ActiveStream = { + streamId, + socket: pending.socket, + bytesIn: 0, + bytesOut: 0, + lastActivity: Date.now(), + alias, + }; + this.activeStreams.set(streamId, stream); + + pending.socket.on('data', (chunk: Buffer) => { + stream.bytesOut += chunk.length; + stream.lastActivity = Date.now(); + this.controller.sendData(streamId, chunk); + }); + pending.socket.on('close', () => { + if (this.activeStreams.delete(streamId)) { + this.controller.sendClose(streamId); + } + }); + pending.socket.on('error', () => { + if (this.activeStreams.delete(streamId)) { + this.controller.sendClose(streamId); + } + }); + } + + public handleResolveErr(connId: number, _code: string): void { + const pending = this.pendingConns.get(connId); + if (!pending) return; + this.pendingConns.delete(connId); + try { pending.socket.destroy(); } catch { /* ignore */ } + } + + public handleData(streamId: number, payload: Buffer): void { + const stream = this.activeStreams.get(streamId); + if (!stream) return; + stream.bytesIn += payload.length; + stream.lastActivity = Date.now(); + try { stream.socket.write(payload); } catch { /* ignore */ } + } + + public handleClose(streamId: number): void { + const stream = this.activeStreams.get(streamId); + if (!stream) return; + this.activeStreams.delete(streamId); + try { stream.socket.destroy(); } catch { /* ignore */ } + } + + public async shutdown(): Promise { + this.shuttingDown = true; + if (this.statsTimer) { clearInterval(this.statsTimer); this.statsTimer = undefined; } + for (const [, stream] of this.activeStreams) { + try { stream.socket.destroy(); } catch { /* ignore */ } + } + this.activeStreams.clear(); + for (const [, pending] of this.pendingConns) { + try { pending.socket.destroy(); } catch { /* ignore */ } + } + this.pendingConns.clear(); + const ports = Array.from(this.listeners.keys()); + await Promise.all(ports.map((p) => this.unlisten(p))); + } + + public getActiveStreamCount(): number { return this.activeStreams.size; } + public getListenerPorts(): number[] { return Array.from(this.listeners.keys()); } + + private acceptConnection(port: number, socket: net.Socket): void { + if (this.shuttingDown) { + try { socket.destroy(); } catch { /* ignore */ } + return; + } + const connId = this.nextConnId++; + this.pendingConns.set(connId, { connId, socket, port }); + const remoteAddr = socket.remoteAddress ?? ''; + socket.once('error', () => { this.pendingConns.delete(connId); }); + socket.once('close', () => { this.pendingConns.delete(connId); }); + this.controller.resolve(connId, port, remoteAddr); + } + + private flushStats(): void { + const now = Date.now(); + for (const [streamId, stream] of this.activeStreams) { + // Only emit for streams that saw activity in the last interval to + // keep the activity log readable. + if (now - stream.lastActivity <= this.statsIntervalMs * 2) { + this.controller.sendStats(streamId, stream.bytesIn, stream.bytesOut, stream.lastActivity); + } + } + } +} diff --git a/mesh-sidecar/src/index.ts b/mesh-sidecar/src/index.ts new file mode 100644 index 00000000..57056e34 --- /dev/null +++ b/mesh-sidecar/src/index.ts @@ -0,0 +1,58 @@ +import { ControlClient } from './control'; +import { Forwarder } from './forwarder'; + +const SIDECAR_VERSION = '0.0.1'; + +function requireEnv(name: string): string { + const value = process.env[name]; + if (!value) { + console.error(`[mesh-sidecar] missing required env: ${name}`); + process.exit(1); + } + return value; +} + +function main(): void { + const controlUrl = requireEnv('SENCHO_CONTROL_URL'); + const token = requireEnv('SENCHO_MESH_TOKEN'); + const nodeIdRaw = requireEnv('MESH_NODE_ID'); + const nodeId = Number.parseInt(nodeIdRaw, 10); + if (!Number.isFinite(nodeId)) { + console.error('[mesh-sidecar] MESH_NODE_ID must be an integer'); + process.exit(1); + } + + let client: ControlClient | null = null; + // Bridge with explicit named params so types stay tight; client is wired + // immediately after construction so the null check is only racing against + // the 5s stats timer first tick. + const forwarder = new Forwarder({ + resolve: (connId, port, remoteAddr) => client?.resolve(connId, port, remoteAddr), + sendData: (streamId, payload) => client?.sendData(streamId, payload), + sendClose: (streamId) => client?.sendClose(streamId), + sendStats: (streamId, bytesIn, bytesOut, lastActivity) => + client?.sendStats(streamId, bytesIn, bytesOut, lastActivity), + }); + forwarder.start(); + + client = new ControlClient({ + controlUrl, + token, + nodeId, + sidecarVersion: SIDECAR_VERSION, + forwarder, + }); + client.start(); + + const shutdown = async () => { + await forwarder.shutdown(); + await client?.shutdown(); + process.exit(0); + }; + process.on('SIGTERM', () => { void shutdown(); }); + process.on('SIGINT', () => { void shutdown(); }); + + console.log(`[mesh-sidecar] started for node=${nodeId} version=${SIDECAR_VERSION}`); +} + +main(); diff --git a/mesh-sidecar/src/protocol.ts b/mesh-sidecar/src/protocol.ts new file mode 100644 index 00000000..48eda602 --- /dev/null +++ b/mesh-sidecar/src/protocol.ts @@ -0,0 +1,146 @@ +/** + * Mesh sidecar control protocol. Wire-compatible shape with the pilot tunnel: + * JSON text frames for control + a single binary frame type carrying the + * tunneled bytes. + * + * [ 1 byte: BinaryFrameType ][ 4 bytes: streamId (BE) ][ payload bytes... ] + */ + +export const PROTOCOL_VERSION = 1; + +export enum BinaryFrameType { + Data = 0x01, +} + +export type ControlFrame = + | ListenFrame + | UnlistenFrame + | HelloFrame + | ResolveFrame + | ResolveOkFrame + | ResolveErrFrame + | StreamStatsFrame + | LogFrame + | CloseFrame; + +export interface HelloFrame { + t: 'hello'; + version: number; + nodeId: number; + sidecarVersion: string; +} + +/** Sencho -> sidecar: start listening on a TCP port for inbound app traffic. */ +export interface ListenFrame { + t: 'listen'; + port: number; +} + +/** Sencho -> sidecar: stop listening on the given port. */ +export interface UnlistenFrame { + t: 'unlisten'; + port: number; +} + +/** + * Sidecar -> Sencho: a new local TCP connection arrived on this port; what + * stream should I attach it to? + */ +export interface ResolveFrame { + t: 'resolve'; + connId: number; + port: number; + remoteAddr?: string; +} + +/** Sencho -> sidecar: forward bytes for connId via streamId. */ +export interface ResolveOkFrame { + t: 'resolve_ok'; + connId: number; + streamId: number; + alias?: string; +} + +/** Sencho -> sidecar: drop the connection with a reason. */ +export interface ResolveErrFrame { + t: 'resolve_err'; + connId: number; + code: 'no_route' | 'tunnel_down' | 'denied' | 'unreachable' | 'agent_error'; + message?: string; +} + +/** Sidecar -> Sencho: per-stream byte counters every ~5 seconds while open. */ +export interface StreamStatsFrame { + t: 'stream.stats'; + streamId: number; + bytesIn: number; + bytesOut: number; + lastActivity: number; +} + +/** Sidecar -> Sencho: structured log line surfaced into the activity log. */ +export interface LogFrame { + t: 'log'; + level: 'info' | 'warn' | 'error'; + message: string; + details?: Record; +} + +/** Either side -> close a stream. */ +export interface CloseFrame { + t: 'close'; + streamId: number; +} + +export function encodeControl(frame: ControlFrame): string { + return JSON.stringify(frame); +} + +export function decodeControl(raw: string): ControlFrame { + const parsed = JSON.parse(raw); + if (!parsed || typeof parsed !== 'object' || typeof parsed.t !== 'string') { + throw new Error('invalid control frame: missing type discriminator'); + } + return parsed as ControlFrame; +} + +export function encodeData(streamId: number, payload: Buffer): Buffer { + if (!Number.isInteger(streamId) || streamId < 0 || streamId > 0xffffffff) { + throw new Error(`invalid streamId: ${streamId}`); + } + const out = Buffer.allocUnsafe(5 + payload.length); + out.writeUInt8(BinaryFrameType.Data, 0); + out.writeUInt32BE(streamId, 1); + payload.copy(out, 5); + return out; +} + +export interface DecodedData { + streamId: number; + payload: Buffer; +} + +export function decodeData(buf: Buffer): DecodedData { + if (buf.length < 5) throw new Error(`data frame too short: ${buf.length} bytes`); + const type = buf.readUInt8(0); + if (type !== BinaryFrameType.Data) throw new Error(`unknown binary frame type: ${type}`); + return { + streamId: buf.readUInt32BE(1), + payload: buf.subarray(5), + }; +} + +export type WsRawData = Buffer | ArrayBuffer | Buffer[] | string; + +export function wsDataToBuffer(data: WsRawData): Buffer | null { + if (Buffer.isBuffer(data)) return data; + if (data instanceof ArrayBuffer) return Buffer.from(data); + if (Array.isArray(data)) return Buffer.concat(data.map((d) => Buffer.isBuffer(d) ? d : Buffer.from(d as ArrayBuffer))); + return null; +} + +export function wsDataToString(data: WsRawData): string | null { + if (typeof data === 'string') return data; + const buf = wsDataToBuffer(data); + return buf ? buf.toString('utf8') : null; +} diff --git a/mesh-sidecar/tsconfig.json b/mesh-sidecar/tsconfig.json new file mode 100644 index 00000000..856416bb --- /dev/null +++ b/mesh-sidecar/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "commonjs", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/mesh-sidecar/vitest.config.ts b/mesh-sidecar/vitest.config.ts new file mode 100644 index 00000000..53c06fc7 --- /dev/null +++ b/mesh-sidecar/vitest.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'node', + include: ['src/__tests__/**/*.test.ts'], + exclude: ['dist/**', 'node_modules/**'], + pool: 'forks', + testTimeout: 15_000, + }, +});