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.
This commit is contained in:
Anso
2026-05-01 00:28:18 -04:00
committed by GitHub
parent b8437e8780
commit 6893ece898
17 changed files with 2706 additions and 6 deletions
@@ -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<number> {
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<net.Socket> {
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<T>(check: () => T | undefined, timeoutMs = 1000): Promise<T> {
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<void>((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<void>((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();
});
});
@@ -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/);
});
});
+175
View File
@@ -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<void> {
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<string, unknown>): 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 */ }
}
}
+180
View File
@@ -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<number, net.Server>();
private readonly pendingConns = new Map<number, PendingConn>();
private readonly activeStreams = new Map<number, ActiveStream>();
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<void> {
if (this.listeners.has(port) || this.shuttingDown) return;
const server = net.createServer((socket) => this.acceptConnection(port, socket));
await new Promise<void>((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<void> {
const server = this.listeners.get(port);
if (!server) return;
this.listeners.delete(port);
await new Promise<void>((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<void> {
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);
}
}
}
}
+58
View File
@@ -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();
+146
View File
@@ -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<string, unknown>;
}
/** 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;
}