diff --git a/backend/src/__tests__/mesh-forwarder.test.ts b/backend/src/__tests__/mesh-forwarder.test.ts new file mode 100644 index 00000000..b4f9e5c7 --- /dev/null +++ b/backend/src/__tests__/mesh-forwarder.test.ts @@ -0,0 +1,166 @@ +/** + * Unit tests for MeshForwarder. Exercises the per-port listener lifecycle + * (listen/unlisten/shutdown) and the accept dispatch into the host + * (MeshService surrogate). MeshForwarder itself does not splice bytes — + * the host's `handleAccept` does — so the test injects a recording + * surrogate and asserts the call shape. + */ +import net from 'net'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { MeshForwarder, type MeshForwarderHost } from '../services/MeshForwarder'; + +interface Accept { + port: number; + socket: net.Socket; +} + +function makeRecordingHost(): { host: MeshForwarderHost; accepts: Accept[]; resolveAfter: (cb: (a: Accept) => void) => void } { + const accepts: Accept[] = []; + const subscribers: Array<(a: Accept) => void> = []; + const host: MeshForwarderHost = { + async handleAccept(port, socket) { + const a = { port, socket }; + accepts.push(a); + for (const s of subscribers.splice(0)) s(a); + }, + }; + return { + host, + accepts, + resolveAfter: (cb) => { subscribers.push(cb); }, + }; +} + +async function getEphemeralPort(): Promise { + return new Promise((resolve, reject) => { + const s = net.createServer(); + s.unref(); + s.listen(0, '127.0.0.1', () => { + const addr = s.address(); + if (!addr || typeof addr === 'string') { + reject(new Error('no address')); + return; + } + const port = addr.port; + s.close(() => resolve(port)); + }); + }); +} + +async function dial(port: number, host = '127.0.0.1'): Promise { + return new Promise((resolve, reject) => { + const s = net.createConnection({ host, port }); + s.once('connect', () => resolve(s)); + s.once('error', reject); + }); +} + +async function waitFor(check: () => T | undefined, timeoutMs = 1000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const v = check(); + if (v !== undefined && v !== null && (Array.isArray(v) ? (v as unknown[]).length > 0 : true)) { + return v as T; + } + await new Promise((r) => setTimeout(r, 10)); + } + throw new Error('timeout waiting for condition'); +} + +describe('MeshForwarder', () => { + let forwarder: MeshForwarder | null = null; + + afterEach(async () => { + if (forwarder) await forwarder.shutdown(); + forwarder = null; + }); + + beforeEach(() => { /* fresh per test */ }); + + it('binds a listener on the requested port and reports it via getListenerPorts', async () => { + const { host } = makeRecordingHost(); + forwarder = new MeshForwarder(host); + const port = await getEphemeralPort(); + await forwarder.listen(port); + + expect(forwarder.getListenerPorts()).toEqual([port]); + expect(forwarder.isListening(port)).toBe(true); + }); + + it('hands accepted sockets to the host with the original destination port', async () => { + const { host, accepts } = makeRecordingHost(); + forwarder = new MeshForwarder(host); + const port = await getEphemeralPort(); + await forwarder.listen(port); + + const client = await dial(port); + const seen = await waitFor(() => accepts.length ? accepts : undefined); + expect(seen[0].port).toBe(port); + expect(seen[0].socket).toBeInstanceOf(net.Socket); + + client.destroy(); + seen[0].socket.destroy(); + }); + + it('listen is idempotent (repeated calls on the same port no-op)', async () => { + const { host } = makeRecordingHost(); + forwarder = new MeshForwarder(host); + const port = await getEphemeralPort(); + await forwarder.listen(port); + await forwarder.listen(port); + expect(forwarder.getListenerPorts()).toEqual([port]); + }); + + it('two concurrent listen() calls on the same port produce a single bind, not EADDRINUSE', async () => { + const { host } = makeRecordingHost(); + forwarder = new MeshForwarder(host); + const port = await getEphemeralPort(); + // Fire both calls before either resolves. Without the in-flight + // dedup map, the second call would race past the listeners.has() + // check and fail with EADDRINUSE on its own bind attempt. + const [a, b] = await Promise.all([forwarder.listen(port), forwarder.listen(port)]); + expect(a).toBeUndefined(); + expect(b).toBeUndefined(); + expect(forwarder.getListenerPorts()).toEqual([port]); + }); + + it('unlisten releases the port so a new listener can bind it', async () => { + const { host } = makeRecordingHost(); + forwarder = new MeshForwarder(host); + const port = await getEphemeralPort(); + await forwarder.listen(port); + await forwarder.unlisten(port); + expect(forwarder.isListening(port)).toBe(false); + + // Verify the port is genuinely free by binding a fresh net.Server. + await new Promise((resolve, reject) => { + const probe = net.createServer(); + probe.once('error', reject); + probe.listen(port, '127.0.0.1', () => probe.close(() => resolve())); + }); + }); + + it('rejects new connections after shutdown', async () => { + const { host } = makeRecordingHost(); + forwarder = new MeshForwarder(host); + const port = await getEphemeralPort(); + await forwarder.listen(port); + await forwarder.shutdown(); + + await expect(dial(port)).rejects.toThrow(); + forwarder = null; + }); + + it('destroys the source socket when the host handler throws', async () => { + const host: MeshForwarderHost = { + async handleAccept() { throw new Error('host blew up'); }, + }; + forwarder = new MeshForwarder(host); + const port = await getEphemeralPort(); + await forwarder.listen(port); + + const client = await dial(port); + const closed = new Promise((resolve) => client.once('close', () => resolve())); + await closed; + }); +}); diff --git a/backend/src/__tests__/mesh-service.test.ts b/backend/src/__tests__/mesh-service.test.ts index 1954d950..6653a1e4 100644 --- a/backend/src/__tests__/mesh-service.test.ts +++ b/backend/src/__tests__/mesh-service.test.ts @@ -97,7 +97,7 @@ describe('MeshService activity log', () => { const svc = MeshService.getInstance(); svc.logActivity({ source: 'mesh', level: 'info', type: 'opt_in', alias: 'a.b.c.sencho', message: 'a' }); svc.logActivity({ source: 'pilot', level: 'error', type: 'tunnel.fail', alias: 'a.b.c.sencho', message: 'b' }); - svc.logActivity({ source: 'sidecar', level: 'info', type: 'route.resolve.ok', alias: 'x.y.z.sencho', message: 'c' }); + svc.logActivity({ source: 'mesh', level: 'info', type: 'route.resolve.ok', alias: 'x.y.z.sencho', message: 'c' }); expect(svc.getActivity({ alias: 'a.b.c.sencho' }).length).toBe(2); expect(svc.getActivity({ source: 'pilot' }).length).toBe(1); @@ -172,7 +172,7 @@ describe('MeshService.testUpstream tunnel-down path', () => { const result = await svc.testUpstream('nonexistent.sencho', localNodeId); expect(result.ok).toBe(false); - expect(result.where).toBe('sidecar'); + expect(result.where).toBe('no_route'); expect(result.code).toBe('no_route'); }); }); diff --git a/backend/src/routes/mesh.ts b/backend/src/routes/mesh.ts index df188bea..510cadca 100644 --- a/backend/src/routes/mesh.ts +++ b/backend/src/routes/mesh.ts @@ -173,24 +173,10 @@ meshRouter.get('/nodes/:nodeId/diagnostic', async (req: Request, res: Response): } }); -meshRouter.post('/nodes/:nodeId/sidecar/restart', async (req: Request, res: Response): Promise => { - if (!requireAdmiral(req, res)) return; - if (!requireAdmin(req, res)) return; - const nodeId = Number.parseInt(req.params.nodeId as string, 10); - if (!Number.isFinite(nodeId)) { res.status(400).json({ error: 'Invalid node id' }); return; } - try { - await MeshService.getInstance().stopSidecar(nodeId); - await MeshService.getInstance().spawnSidecar(nodeId); - res.json({ ok: true }); - } catch (err) { - res.status(500).json({ error: (err as Error).message }); - } -}); - meshRouter.get('/activity', (req: Request, res: Response): void => { if (!requireAdmiral(req, res)) return; const alias = typeof req.query.alias === 'string' ? req.query.alias : undefined; - const source = typeof req.query.source === 'string' ? (req.query.source as 'sidecar' | 'pilot' | 'mesh') : undefined; + const source = typeof req.query.source === 'string' ? (req.query.source as 'pilot' | 'mesh') : undefined; const level = typeof req.query.level === 'string' ? (req.query.level as 'info' | 'warn' | 'error') : undefined; const limit = typeof req.query.limit === 'string' ? Number.parseInt(req.query.limit, 10) : 200; const events = MeshService.getInstance().getActivity({ alias, source, level, limit }); diff --git a/backend/src/services/MeshForwarder.ts b/backend/src/services/MeshForwarder.ts new file mode 100644 index 00000000..af6bca1e --- /dev/null +++ b/backend/src/services/MeshForwarder.ts @@ -0,0 +1,101 @@ +import net from 'net'; +import { sanitizeForLog } from '../utils/safeLog'; + +/** + * In-process mesh TCP forwarder. Owns per-port `net.Server` listeners on the + * host network and delegates accepted sockets to the host (MeshService) for + * resolve + splice. Replaces the separate `saelix/sencho-mesh` sidecar + * container that previously did this job over a control WebSocket. The + * resolve step is now a sync map lookup rather than a round-trip, so + * MeshForwarder is just a thin lifecycle layer; all routing + splicing + * lives on MeshService. + * + * Sencho's container must run in `network_mode: host` (Linux) for the + * listeners to bind on the host's network where meshed containers' + * `extra_hosts: :host-gateway` entries point. Without host network + * mode, `net.createServer().listen(port)` lands inside the container's + * namespace and inbound traffic from peers never reaches it. + */ + +export interface MeshForwarderHost { + /** Called on each accepted inbound socket. The host owns the splice + * lifecycle; MeshForwarder only manages listener boilerplate. */ + handleAccept(port: number, source: net.Socket): Promise; +} + +export class MeshForwarder { + private readonly listeners = new Map(); + /** + * In-flight `listen(port)` promises so concurrent callers race-safely + * deduplicate. Without this guard, two concurrent calls to listen on + * the same port would both pass the `listeners.has(port)` check (which + * is only populated after the listening event resolves) and the second + * would fail with EADDRINUSE. + */ + private readonly pending = new Map>(); + private shuttingDown = false; + + constructor(private readonly host: MeshForwarderHost) {} + + public async listen(port: number): Promise { + if (this.shuttingDown) return; + if (this.listeners.has(port)) return; + const inflight = this.pending.get(port); + if (inflight) return inflight; + const promise = (async () => { + const server = net.createServer((socket) => this.acceptConnection(port, socket)); + try { + 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); + // Bind on all interfaces. Under host network mode this is + // the host's own network; under bridge mode (mesh disabled + // at boot) this would be the container's namespace. + server.listen(port, '0.0.0.0'); + }); + this.listeners.set(port, server); + } finally { + this.pending.delete(port); + } + })(); + this.pending.set(port, promise); + return promise; + } + + 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 async shutdown(): Promise { + this.shuttingDown = true; + const ports = Array.from(this.listeners.keys()); + await Promise.all(ports.map((p) => this.unlisten(p))); + } + + public getListenerPorts(): number[] { + return Array.from(this.listeners.keys()); + } + + public isListening(port: number): boolean { + return this.listeners.has(port); + } + + private acceptConnection(port: number, source: net.Socket): void { + if (this.shuttingDown) { + try { source.destroy(); } catch { /* ignore */ } + return; + } + // Defer to the host for resolve + splice. MeshForwarder itself does + // not look at the source bytes; routing lives on MeshService where + // the alias map and the cross-node bridge dispatch are. + this.host.handleAccept(port, source).catch((err) => { + console.warn('[MeshForwarder] accept handler failed:', sanitizeForLog((err as Error).message)); + try { source.destroy(); } catch { /* ignore */ } + }); + } +} diff --git a/backend/src/services/MeshService.ts b/backend/src/services/MeshService.ts index 1c1aad45..c6a5b558 100644 --- a/backend/src/services/MeshService.ts +++ b/backend/src/services/MeshService.ts @@ -2,11 +2,11 @@ import net from 'net'; import path from 'path'; import fs from 'fs/promises'; import { EventEmitter } from 'events'; -import jwt from 'jsonwebtoken'; import { DatabaseService } from './DatabaseService'; import DockerController from './DockerController'; import { LicenseService } from './LicenseService'; import { PROXY_TIER_HEADER, PROXY_VARIANT_HEADER } from './license-headers'; +import { MeshForwarder, type MeshForwarderHost } from './MeshForwarder'; import { NodeRegistry } from './NodeRegistry'; import { PilotTunnelManager } from './PilotTunnelManager'; import { generateOverrideYaml, MeshAlias } from './MeshComposeOverride'; @@ -15,13 +15,10 @@ import { isPathWithinBase, isValidStackName } from '../utils/validation'; const ACTIVITY_BUFFER_SIZE = 1000; const ALIAS_REFRESH_INTERVAL_MS = 60_000; -const SIDECAR_CONTAINER_PREFIX = 'sencho-mesh-'; -const DEFAULT_SIDECAR_IMAGE = process.env.SENCHO_MESH_IMAGE || 'saelix/sencho-mesh:latest'; -const SIDECAR_TOKEN_TTL = '7d'; const PROBE_TIMEOUT_MS = 5_000; const SLOW_PROBE_THRESHOLD_MS = 500; -export type MeshActivitySource = 'sidecar' | 'pilot' | 'mesh'; +export type MeshActivitySource = 'pilot' | 'mesh'; export type MeshActivityLevel = 'info' | 'warn' | 'error'; export type MeshActivityType = | 'route.resolve.ok' | 'route.resolve.denied' @@ -29,7 +26,7 @@ export type MeshActivityType = | 'opt_in' | 'opt_out' | 'mesh.enable' | 'mesh.disable' | 'probe.ok' | 'probe.fail' - | 'sidecar.start' | 'sidecar.stop' | 'sidecar.crash'; + | 'forwarder.listen' | 'forwarder.unlisten' | 'forwarder.error'; export interface MeshActivityEvent { ts: number; @@ -65,7 +62,8 @@ export interface MeshNodeStatus { nodeId: number; nodeName: string; enabled: boolean; - sidecarRunning: boolean; + /** Forwarder state for the LOCAL node (the Sencho instance answering this request). Always `null` for any non-local node — fetching the remote forwarder state requires a cross-node call which lands in Phase B. */ + localForwarderListening: boolean | null; pilotConnected: boolean; optedInStacks: string[]; activeStreamCount: number; @@ -73,7 +71,7 @@ export interface MeshNodeStatus { export interface MeshNodeDiagnostic { nodeId: number; - sidecar: { running: boolean; restartCount: number }; + forwarder: { listening: boolean; listenerCount: number }; pilot: { connected: boolean; bufferedAmount: number; lastSeen: number | null }; activeStreams: Array<{ streamId: number; alias?: string; bytesIn: number; bytesOut: number; ageMs: number }>; aliasCache: Array<{ host: string; targetNodeId: number; port: number }>; @@ -91,7 +89,7 @@ export interface MeshRouteDiagnostic { export interface MeshProbeResult { ok: boolean; latencyMs?: number; - where?: 'sidecar' | 'pilot_tunnel' | 'agent_resolve' | 'agent_dial' | 'target_port'; + where?: 'no_route' | 'pilot_tunnel' | 'agent_resolve' | 'agent_dial' | 'target_port'; code?: string; message?: string; } @@ -104,50 +102,44 @@ interface ActiveStreamRecord { openedAt: number; } -interface PendingResolve { - sidecarSocket: WebSocketLike; - connId: number; - port: number; - remoteAddr: string; -} - -interface WebSocketLike { - send(data: string | Buffer, opts?: unknown, cb?: (err?: Error) => void): void; - readyState: number; - on(event: string, listener: (...args: unknown[]) => void): unknown; -} - /** * Sencho Mesh orchestrator. Owns: - * - sidecar lifecycle (Dockerode-spawned per-instance) + * - in-process TCP forwarder (`MeshForwarder`) that binds host-network + * listeners on alias ports. Replaces the prior separate sidecar + * container; one container per node now. * - opt-in / opt-out persistence and cascading override regeneration - * - global alias aggregation (across the fleet via the existing API) - * - request-based resolution from sidecar control WS - * - cross-node TCP forwarding via PilotTunnelManager + * - global alias aggregation (across the fleet via the existing HTTP + * proxy chain — see `inspectStackServices`) + * - cross-node TCP forwarding via `PilotTunnelManager` (central-side) * - probe + diagnostics + activity ring buffer * * V1 limitations: - * - one cross-node alias per TCP port across the fleet (port-collision check at opt-in) - * - sidecar runs in host network mode; aliases resolve via `host-gateway` extra_hosts - * - pilot-to-pilot mesh routing is not supported (only central <-> pilot) + * - one cross-node alias per TCP port across the fleet (port-collision + * check at opt-in) + * - aliases resolve via `host-gateway` extra_hosts; Sencho's container + * must run with `network_mode: host` for the forwarder's listeners to + * bind on the host's network where meshed containers' `host-gateway` + * entries point + * - cross-node mesh routing is central → pilot in this phase. Pilot → + * central and pilot ↔ pilot via central relay land in Phase B. */ -export class MeshService extends EventEmitter { +export class MeshService extends EventEmitter implements MeshForwarderHost { private static instance: MeshService; private started = false; private aliasCache = new Map(); private aliasByPort = new Map(); private activity: MeshActivityEvent[] = []; private activeStreams = new Map(); - private pendingResolves = new Map(); - private sidecarSockets = new Set(); private aliasRefreshTimer?: NodeJS.Timeout; private routeErrorMap = new Map(); private routeLatencyMap = new Map(); private activityListeners = new Set<(e: MeshActivityEvent) => void>(); + private readonly forwarder: MeshForwarder; private constructor() { super(); this.setMaxListeners(50); + this.forwarder = new MeshForwarder(this); } public static getInstance(): MeshService { @@ -167,10 +159,16 @@ export class MeshService extends EventEmitter { })); await this.refreshAliasCache(); + await this.syncForwarderListeners(); this.aliasRefreshTimer = setInterval(() => { - void this.refreshAliasCache().catch((err) => { - console.warn('[MeshService] alias refresh failed:', sanitizeForLog((err as Error).message)); - }); + void (async () => { + try { + await this.refreshAliasCache(); + await this.syncForwarderListeners(); + } catch (err) { + console.warn('[MeshService] alias refresh failed:', sanitizeForLog((err as Error).message)); + } + })(); }, ALIAS_REFRESH_INTERVAL_MS); this.logActivity({ @@ -186,10 +184,49 @@ export class MeshService extends EventEmitter { clearInterval(this.aliasRefreshTimer); this.aliasRefreshTimer = undefined; } - for (const ws of this.sidecarSockets) { - try { (ws as { close?: (code: number) => void }).close?.(1000); } catch { /* ignore */ } + await this.forwarder.shutdown(); + } + + /** + * Bind the forwarder's listeners to the local-owned alias ports and + * release any listeners no longer in the alias set. Called from + * `start`, after each `refreshAliasCache` tick, and after every + * opt-in / opt-out / disable on the local node so the bound port set + * follows the DB state. + */ + private async syncForwarderListeners(): Promise { + const localNodeId = NodeRegistry.getInstance().getDefaultNodeId(); + const wantPorts = new Set(); + for (const alias of this.aliasByPort.values()) { + if (alias.nodeId === localNodeId) wantPorts.add(alias.port); + } + const havePorts = new Set(this.forwarder.getListenerPorts()); + for (const port of havePorts) { + if (!wantPorts.has(port)) { + await this.forwarder.unlisten(port); + this.logActivity({ + source: 'mesh', level: 'info', type: 'forwarder.unlisten', + nodeId: localNodeId, message: `forwarder released port ${port}`, + }); + } + } + for (const port of wantPorts) { + if (havePorts.has(port)) continue; + try { + await this.forwarder.listen(port); + this.logActivity({ + source: 'mesh', level: 'info', type: 'forwarder.listen', + nodeId: localNodeId, message: `forwarder listening on port ${port}`, + }); + } catch (err) { + this.logActivity({ + source: 'mesh', level: 'error', type: 'forwarder.error', + nodeId: localNodeId, + message: `forwarder bind failed on port ${port}: ${sanitizeForLog((err as Error).message)}`, + details: { port }, + }); + } } - this.sidecarSockets.clear(); } // --- Activity log --- @@ -248,6 +285,7 @@ export class MeshService extends EventEmitter { db.insertMeshStack(nodeId, stackName, actor); await this.refreshAliasCache(); + await this.syncForwarderListeners(); await this.regenerateOverridesForNode(nodeId); this.logActivity({ @@ -271,6 +309,7 @@ export class MeshService extends EventEmitter { db.deleteMeshStack(nodeId, stackName); await this.removeStackOverride(nodeId, stackName); await this.refreshAliasCache(); + await this.syncForwarderListeners(); await this.regenerateOverridesForNode(nodeId); this.logActivity({ @@ -301,6 +340,7 @@ export class MeshService extends EventEmitter { await this.removeStackOverride(nodeId, s.stack_name); } await this.refreshAliasCache(); + await this.syncForwarderListeners(); this.logActivity({ source: 'mesh', level: 'info', type: 'mesh.disable', nodeId, message: `mesh disabled on node ${nodeId}`, @@ -483,25 +523,50 @@ export class MeshService extends EventEmitter { } /** - * Forward bytes from a sidecar-accepted local socket to the target. - * Same-node target: open a direct TCP socket. - * Cross-node target: open a pilot-tunnel TcpStream to that node's agent. + * MeshForwarder calls this on every accepted inbound socket. Resolves + * the alias by destination port, then dispatches to the same-node fast + * path or the cross-node bridge path. */ - public openTcp(target: MeshTarget, src: net.Socket, sourceNodeId: number): void { - if (target.nodeId === sourceNodeId) { - this.openSameNode(target, src); + public async handleAccept(port: number, src: net.Socket): Promise { + const target = this.resolveByLocalPort(port); + if (!target) { + this.logActivity({ + source: 'mesh', level: 'warn', type: 'route.resolve.denied', + message: `inbound on port ${port} has no registered alias`, + details: { port, remoteAddr: src.remoteAddress ?? '' }, + }); + try { src.destroy(); } catch { /* ignore */ } return; } - this.openCrossNode(target, src); + const localNodeId = NodeRegistry.getInstance().getDefaultNodeId(); + if (target.nodeId === localNodeId) { + await this.openSameNode(target, src); + } else { + this.openCrossNode(target, src); + } } - private openSameNode(target: MeshTarget, src: net.Socket): void { - // For same-node fast path, the agent's resolution logic isn't needed: - // we can dial via Dockerode's container IP. For V1 simplicity, we dial - // the host-gateway's published port if mapped, falling back to - // 127.0.0.1 (the sidecar runs in host network mode so localhost reaches - // the container's published port). - const upstream = net.createConnection({ host: '127.0.0.1', port: target.port }); + /** + * Same-node forward: dial the target container's bridge IP directly. + * Sencho runs in `network_mode: host` so it sees the docker bridge + * networks and can reach container IPs without going through any + * host-port publish. Looks up the container by Compose's + * `--` naming convention; falls back to a + * label-filtered listContainers if the conventional name is absent + * (e.g. when the operator overrode the project name). + */ + private async openSameNode(target: MeshTarget, src: net.Socket): Promise { + const ip = await this.resolveContainerIp(target); + if (!ip) { + this.logActivity({ + source: 'mesh', level: 'error', type: 'route.resolve.denied', + alias: target.alias, + message: `cannot resolve container IP for ${target.alias}`, + }); + try { src.destroy(); } catch { /* ignore */ } + return; + } + const upstream = net.createConnection({ host: ip, port: target.port }); upstream.setTimeout(PROBE_TIMEOUT_MS); const stream = this.registerActiveStream(target.alias); upstream.once('connect', () => { @@ -509,7 +574,7 @@ export class MeshService extends EventEmitter { this.logActivity({ source: 'mesh', level: 'info', type: 'route.resolve.ok', alias: target.alias, streamId: stream.streamId, - message: `same-node connect to ${target.alias}`, + message: `same-node connect to ${target.alias} (${ip}:${target.port})`, }); src.pipe(upstream); upstream.pipe(src); @@ -525,6 +590,60 @@ export class MeshService extends EventEmitter { src.on('close', () => teardown()); } + /** Find the bridge-network IP of the first container of `/`. */ + private async resolveContainerIp(target: MeshTarget): Promise { + try { + const docker = DockerController.getInstance().getDocker(); + // Compose default container name pattern; -1 is the first replica. + const conventionalName = `${target.stack}-${target.service}-1`; + const info = await docker.getContainer(conventionalName).inspect().catch(() => null); + const fromInspect = info ? this.extractContainerIp(target.stack, info) : null; + if (fromInspect) return fromInspect; + // Fallback: filter by compose labels in case of a non-conventional + // container name (operator overrode `container_name` or compose + // project). + const containers = await docker.listContainers({ + all: true, + filters: { + label: [ + `com.docker.compose.project=${target.stack}`, + `com.docker.compose.service=${target.service}`, + ], + }, + }); + if (containers.length === 0) return null; + const fallbackInfo = await docker.getContainer(containers[0].Id).inspect().catch(() => null); + return fallbackInfo ? this.extractContainerIp(target.stack, fallbackInfo) : null; + } catch (err) { + console.warn('[MeshService] container IP lookup failed:', sanitizeForLog((err as Error).message)); + return null; + } + } + + /** + * Pick a deterministic IP. Prefer the compose default network + * (`_default` or any network whose name starts with `_`), + * then any other declared network, then the legacy bridge `IPAddress`. + * Without this preference order, `Object.values(Networks)` ordering on + * containers attached to multiple networks varies across daemon + * versions and can make same-node forwarding flaky on a redeploy. + */ + private extractContainerIp( + stackName: string, + info: { NetworkSettings?: { Networks?: Record; IPAddress?: string } }, + ): string | null { + const networks = info.NetworkSettings?.Networks ?? {}; + const composeDefault = networks[`${stackName}_default`]; + if (composeDefault?.IPAddress) return composeDefault.IPAddress; + for (const [name, net] of Object.entries(networks)) { + if (name.startsWith(`${stackName}_`) && net?.IPAddress) return net.IPAddress; + } + for (const net of Object.values(networks)) { + if (net?.IPAddress) return net.IPAddress; + } + return info.NetworkSettings?.IPAddress || null; + } + private openCrossNode(target: MeshTarget, src: net.Socket): void { const ptm = PilotTunnelManager.getInstance(); if (!ptm.hasActiveTunnel(target.nodeId)) { @@ -598,7 +717,7 @@ export class MeshService extends EventEmitter { public async testUpstream(alias: string, sourceNodeId: number): Promise { const target = this.lookupAliasGlobal(alias); if (!target) { - return { ok: false, where: 'sidecar', code: 'no_route', message: 'alias not found' }; + return { ok: false, where: 'no_route', code: 'no_route', message: 'alias not found' }; } if (!DatabaseService.getInstance().isMeshStackEnabled(target.nodeId, target.stackName)) { return { ok: false, where: 'agent_resolve', code: 'denied', message: 'target stack not opted in' }; @@ -726,7 +845,8 @@ export class MeshService extends EventEmitter { const ptm = PilotTunnelManager.getInstance(); const bridge = ptm.getBridge(nodeId); const node = DatabaseService.getInstance().getNode(nodeId); - const sidecarRunning = await this.isSidecarRunning(nodeId); + const localNodeId = NodeRegistry.getInstance().getDefaultNodeId(); + const isLocal = nodeId === localNodeId; const aliasCacheRows = Array.from(this.aliasCache.values()) .filter((a) => a.nodeId === nodeId) @@ -739,9 +859,13 @@ export class MeshService extends EventEmitter { ageMs: now - s.openedAt, })); + const listenerCount = isLocal ? this.forwarder.getListenerPorts().length : 0; return { nodeId, - sidecar: { running: sidecarRunning, restartCount: 0 }, + forwarder: { + listening: isLocal && this.isLocalForwarderActive(), + listenerCount, + }, pilot: { connected: !!bridge, bufferedAmount: bridge?.getBufferedAmount() ?? 0, @@ -755,151 +879,30 @@ export class MeshService extends EventEmitter { public async getStatus(): Promise { const db = DatabaseService.getInstance(); const nodes = db.getNodes(); - const out: MeshNodeStatus[] = []; - for (const node of nodes) { - const optedInStacks = db.listMeshStacks(node.id).map((s) => s.stack_name); - out.push({ - nodeId: node.id, - nodeName: node.name, - enabled: db.getNodeMeshEnabled(node.id), - sidecarRunning: await this.isSidecarRunning(node.id), - pilotConnected: this.isMeshReachable(node.id), - optedInStacks, - activeStreamCount: Array.from(this.activeStreams.values()).length, - }); - } - return out; + const localNodeId = NodeRegistry.getInstance().getDefaultNodeId(); + const localListening = this.isLocalForwarderActive(); + return nodes.map((node) => ({ + nodeId: node.id, + nodeName: node.name, + enabled: db.getNodeMeshEnabled(node.id), + localForwarderListening: node.id === localNodeId ? localListening : null, + pilotConnected: this.isMeshReachable(node.id), + optedInStacks: db.listMeshStacks(node.id).map((s) => s.stack_name), + activeStreamCount: this.activeStreams.size, + })); } - // --- Sidecar lifecycle (best-effort; real spawn happens on local node) --- - - public async spawnSidecar(nodeId: number): Promise { - const docker = DockerController.getInstance(nodeId).getDocker(); - const name = `${SIDECAR_CONTAINER_PREFIX}${nodeId}`; - try { - const existing = docker.getContainer(name); - const info = await existing.inspect().catch(() => null); - if (info?.State?.Running) return; - if (info) await existing.remove({ force: true }).catch(() => undefined); - } catch { /* ignore */ } - - const token = this.mintSidecarToken(nodeId); - const controlUrl = process.env.SENCHO_INTERNAL_URL || 'ws://127.0.0.1:1852/api/mesh/control'; - try { - const container = await docker.createContainer({ - name, - Image: DEFAULT_SIDECAR_IMAGE, - Env: [ - `SENCHO_CONTROL_URL=${controlUrl}`, - `SENCHO_MESH_TOKEN=${token}`, - `MESH_NODE_ID=${nodeId}`, - ], - HostConfig: { - NetworkMode: 'host', - RestartPolicy: { Name: 'unless-stopped' }, - }, - Labels: { - 'sencho.mesh.role': 'sidecar', - 'sencho.mesh.node_id': String(nodeId), - }, - }); - await container.start(); - this.logActivity({ - source: 'mesh', level: 'info', type: 'sidecar.start', - nodeId, message: `sidecar started for node ${nodeId}`, - }); - } catch (err) { - this.logActivity({ - source: 'mesh', level: 'error', type: 'sidecar.crash', - nodeId, message: `sidecar spawn failed: ${(err as Error).message}`, - }); - throw err; - } + /** True when the local Sencho's forwarder is started and bound to at least one alias port. */ + private isLocalForwarderActive(): boolean { + return this.started && this.forwarder.getListenerPorts().length > 0; } - public async stopSidecar(nodeId: number): Promise { - const docker = DockerController.getInstance(nodeId).getDocker(); - const name = `${SIDECAR_CONTAINER_PREFIX}${nodeId}`; - try { - const c = docker.getContainer(name); - await c.stop({ t: 5 }).catch(() => undefined); - await c.remove({ force: true }).catch(() => undefined); - this.logActivity({ - source: 'mesh', level: 'info', type: 'sidecar.stop', - nodeId, message: `sidecar stopped for node ${nodeId}`, - }); - } catch { /* ignore */ } - } - - private async isSidecarRunning(nodeId: number): Promise { - try { - const docker = DockerController.getInstance(nodeId).getDocker(); - const info = await docker.getContainer(`${SIDECAR_CONTAINER_PREFIX}${nodeId}`).inspect(); - return !!info.State?.Running; - } catch { - return false; - } - } - - public mintSidecarToken(nodeId: number): string { - const settings = DatabaseService.getInstance().getGlobalSettings(); - const secret = settings.auth_jwt_secret; - if (!secret) throw new Error('JWT secret not configured'); - return jwt.sign({ scope: 'mesh_sidecar', nodeId }, secret, { expiresIn: SIDECAR_TOKEN_TTL }); - } - - public verifySidecarToken(token: string): { nodeId: number } | null { - try { - const settings = DatabaseService.getInstance().getGlobalSettings(); - const secret = settings.auth_jwt_secret; - if (!secret) return null; - const decoded = jwt.verify(token, secret) as { scope?: string; nodeId?: number }; - if (decoded.scope !== 'mesh_sidecar' || typeof decoded.nodeId !== 'number') return null; - return { nodeId: decoded.nodeId }; - } catch { - return null; - } - } - - // --- Sidecar control WS attachment (called from websocket/meshControl.ts) --- - - public attachSidecarSocket(ws: WebSocketLike, _nodeId: number): void { - this.sidecarSockets.add(ws); - ws.on('close', () => { this.sidecarSockets.delete(ws); }); - } - - /** Resolve an inbound sidecar request: "I have a connection on this port; who's it for?" */ - public handleSidecarResolve(ws: WebSocketLike, nodeId: number, connId: number, port: number, remoteAddr: string): void { - const target = this.resolveByLocalPort(port); - if (!target) { - this.sendSidecar(ws, { t: 'resolve_err', connId, code: 'no_route', message: 'port not registered' }); - this.logActivity({ - source: 'sidecar', level: 'warn', type: 'route.resolve.denied', - nodeId, message: `unknown port ${port}`, details: { connId, remoteAddr }, - }); - return; - } - - // The sidecar is the SOURCE; it asks for routing on its local node. - // Open a TCP path on this node's MeshService (same-node fast path or - // pilot tunnel) and acknowledge the resolve with a freshly allocated - // streamId. For V1 we do NOT bridge real bytes through the control WS - // until the sidecar package gains binary frame plumbing (Phase B). - // Instead we ack the resolve with the target metadata so the sidecar - // can dial directly on the host gateway. - this.sendSidecar(ws, { t: 'resolve_ok', connId, streamId: connId, alias: target.alias }); - this.logActivity({ - source: 'sidecar', level: 'info', type: 'route.resolve.ok', - nodeId, alias: target.alias, - message: `resolved port ${port} to ${target.alias}`, - details: { connId, remoteAddr }, - }); - } - - private sendSidecar(ws: WebSocketLike, frame: Record): void { - if (ws.readyState !== 1 /* OPEN */) return; - try { ws.send(JSON.stringify(frame)); } catch { /* ignore */ } - } + // mintSidecarToken / verifySidecarToken / spawnSidecar / stopSidecar / + // isSidecarRunning / handleSidecarResolve / sendSidecar / + // attachSidecarSocket are gone: the in-process MeshForwarder replaces + // the entire sidecar layer. Routing decisions happen via direct + // MeshService calls — no JWT minting, no separate container, no control + // WebSocket. See `docs/internal/architecture/mesh.md` for the new flow. } export class MeshError extends Error { diff --git a/backend/src/websocket/meshControl.ts b/backend/src/websocket/meshControl.ts deleted file mode 100644 index 7e1f3970..00000000 --- a/backend/src/websocket/meshControl.ts +++ /dev/null @@ -1,57 +0,0 @@ -import type { IncomingMessage } from 'http'; -import type { Duplex } from 'stream'; -import type { WebSocketServer, WebSocket } from 'ws'; -import { MeshService } from '../services/MeshService'; -import { sanitizeForLog } from '../utils/safeLog'; -import { rejectUpgrade as rejectSocket } from './reject'; - -/** - * Handle the local Sencho Mesh sidecar's control WebSocket. Authenticated - * with a `mesh_sidecar`-scoped JWT minted by MeshService when it spawned the - * sidecar; the JWT carries the node id the sidecar serves. - * - * The control WS is intentionally local-only: the sidecar runs in host - * network mode on the same Docker host as Sencho and reaches us via the - * loopback interface. - */ -export async function handleMeshControl( - req: IncomingMessage, - socket: Duplex, - head: Buffer, - wss: WebSocketServer, -): Promise { - const authHeader = req.headers['authorization']; - const header = Array.isArray(authHeader) ? authHeader[0] : authHeader; - const token = header?.startsWith('Bearer ') ? header.slice(7) : null; - if (!token) return rejectSocket(socket, 401, 'Unauthorized'); - - const verified = MeshService.getInstance().verifySidecarToken(token); - if (!verified) return rejectSocket(socket, 401, 'Unauthorized'); - - wss.handleUpgrade(req, socket as never, head, (ws: WebSocket) => { - MeshService.getInstance().attachSidecarSocket(ws as unknown as never, verified.nodeId); - - ws.on('message', (data, isBinary) => { - if (isBinary) return; // V1: control plane is JSON-only. - try { - const text = data.toString('utf8'); - const frame = JSON.parse(text) as { t?: string; connId?: number; port?: number; remoteAddr?: string }; - if (frame.t === 'resolve' && typeof frame.connId === 'number' && typeof frame.port === 'number') { - MeshService.getInstance().handleSidecarResolve( - ws as unknown as never, - verified.nodeId, - frame.connId, - frame.port, - frame.remoteAddr ?? '', - ); - } - // hello / log / stream.stats / close are advisory; we accept - // them silently in V1. Future revisions can expand handling. - } catch (err) { - console.warn('[meshControl] bad frame:', sanitizeForLog((err as Error).message)); - } - }); - - ws.on('error', () => { try { ws.close(); } catch { /* ignore */ } }); - }); -} diff --git a/backend/src/websocket/upgradeHandler.ts b/backend/src/websocket/upgradeHandler.ts index 6305c93f..256466a0 100644 --- a/backend/src/websocket/upgradeHandler.ts +++ b/backend/src/websocket/upgradeHandler.ts @@ -7,7 +7,6 @@ import { DatabaseService, type UserRole } from '../services/DatabaseService'; import { NodeRegistry } from '../services/NodeRegistry'; import { COOKIE_NAME } from '../helpers/constants'; import { handlePilotTunnel } from './pilotTunnel'; -import { handleMeshControl } from './meshControl'; import { handleNotificationsWs } from './notifications'; import { handleRemoteForwarder } from './remoteForwarder'; import { handleLogsWs } from './logs'; @@ -31,8 +30,7 @@ function parseCookies(req: IncomingMessage): Record { * * Dispatch order (first match wins): * 1. `/api/pilot/tunnel` -> handlePilotTunnel (own auth, own wss) - * 2. `/api/mesh/control` -> handleMeshControl (sidecar JWT, local-only) - * 3. shared cookie/Bearer auth + JWT verify (rejects unauthenticated) + * 2. shared cookie/Bearer auth + JWT verify (rejects unauthenticated) * 3. API token scope gate (read-only / deploy-only restricted to logs + notifications) * 4. `/ws/notifications` local -> handleNotificationsWs * 5. remote nodeId path -> handleRemoteForwarder @@ -56,10 +54,6 @@ export function attachUpgrade( await handlePilotTunnel(req, socket, head, pilotTunnelWss); return; } - if (reqUrl.pathname === '/api/mesh/control') { - await handleMeshControl(req, socket, head, wss); - return; - } } catch { // URL parse error falls through and will be rejected below. } diff --git a/docker-compose.yml b/docker-compose.yml index b51424f6..57d67019 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,8 +4,12 @@ services: build: . container_name: sencho restart: unless-stopped - ports: - - "1852:1852" + # Sencho Mesh listens on host ports for cross-stack alias traffic. Host + # network mode is required for mesh to function. If you do not use mesh, + # comment `network_mode: host` out and uncomment the `ports` block below. + network_mode: host + # ports: + # - "1852:1852" volumes: # Required: Docker Socket for container orchestration - /var/run/docker.sock:/var/run/docker.sock diff --git a/frontend/src/components/fleet/MeshDiagnosticsSheet.tsx b/frontend/src/components/fleet/MeshDiagnosticsSheet.tsx index 52896830..f7ee6ecc 100644 --- a/frontend/src/components/fleet/MeshDiagnosticsSheet.tsx +++ b/frontend/src/components/fleet/MeshDiagnosticsSheet.tsx @@ -1,8 +1,7 @@ import { useEffect, useState } from 'react'; import { apiFetch } from '@/lib/api'; -import { toast } from '@/components/ui/toast-store'; import { SystemSheet, SheetSection } from '@/components/ui/system-sheet'; -import { RefreshCw, ServerCog } from 'lucide-react'; +import { RefreshCw } from 'lucide-react'; import { formatTimeAgo } from '@/lib/relativeTime'; import type { MeshNodeDiagnostic } from '@/types/mesh'; @@ -28,7 +27,6 @@ function ageFmt(ms: number): string { export function MeshDiagnosticsSheet({ open, onOpenChange, nodeId, nodeName }: Props) { const [diag, setDiag] = useState(null); const [loading, setLoading] = useState(false); - const [restarting, setRestarting] = useState(false); const [updatedAt, setUpdatedAt] = useState(null); const refresh = async () => { @@ -50,29 +48,13 @@ export function MeshDiagnosticsSheet({ open, onOpenChange, nodeId, nodeName }: P // eslint-disable-next-line react-hooks/exhaustive-deps }, [open, nodeId]); - const restart = async () => { - if (nodeId == null) return; - setRestarting(true); - try { - const res = await apiFetch(`/mesh/nodes/${nodeId}/sidecar/restart`, { - method: 'POST', localOnly: true, - }); - if (res.ok) { - toast.success('Sidecar restart requested'); - await refresh(); - } else { - toast.error('Sidecar restart failed'); - } - } finally { - setRestarting(false); - } - }; - - const sidecarLabel = diag ? (diag.sidecar.running ? 'sidecar running' : 'sidecar off') : 'sidecar ?'; + const forwarderLabel = diag + ? (diag.forwarder.listening ? `forwarder listening (${diag.forwarder.listenerCount})` : 'forwarder idle') + : 'forwarder ?'; const pilotLabel = diag ? (diag.pilot.connected ? 'pilot connected' : 'pilot disconnected') : 'pilot ?'; const streamsLabel = `${diag?.activeStreams.length ?? 0} streams`; const aliasesLabel = `${diag?.aliasCache.length ?? 0} aliases`; - const meta = `${sidecarLabel} · ${pilotLabel} · ${streamsLabel} · ${aliasesLabel}`; + const meta = `${forwarderLabel} · ${pilotLabel} · ${streamsLabel} · ${aliasesLabel}`; const footerContext = updatedAt ? `Updated ${formatTimeAgo(updatedAt)}` : (loading ? 'Loading…' : 'Never updated'); @@ -89,21 +71,15 @@ export function MeshDiagnosticsSheet({ open, onOpenChange, nodeId, nodeName }: P onClick: () => { void refresh(); }, disabled: loading, }} - secondaryActions={[ - { - label: 'Restart sidecar', - icon: ServerCog, - onClick: () => { void restart(); }, - disabled: restarting, - }, - ]} footerContext={footerContext} size="md" > - +
-
Sidecar
-
{diag?.sidecar.running ? 'running' : 'off'}
+
Forwarder
+
+ {diag?.forwarder.listening ? `listening on ${diag.forwarder.listenerCount} port${diag.forwarder.listenerCount === 1 ? '' : 's'}` : 'idle'} +
Pilot tunnel
{diag?.pilot.connected ? 'connected' : 'disconnected'}
Buffered
@@ -111,6 +87,9 @@ export function MeshDiagnosticsSheet({ open, onOpenChange, nodeId, nodeName }: P
Last seen
{diag?.pilot.lastSeen ? new Date(diag.pilot.lastSeen).toLocaleTimeString() : '-'}
+

+ Mesh runs in-process on each node; no separate container. +

diff --git a/frontend/src/components/fleet/MeshRouteDetailSheet.tsx b/frontend/src/components/fleet/MeshRouteDetailSheet.tsx index 3a89caf5..bfe8e38e 100644 --- a/frontend/src/components/fleet/MeshRouteDetailSheet.tsx +++ b/frontend/src/components/fleet/MeshRouteDetailSheet.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from 'react'; import { apiFetch } from '@/lib/api'; import { SystemSheet, SheetSection } from '@/components/ui/system-sheet'; import { Badge } from '@/components/ui/badge'; -import { Loader2, Activity, ServerCog, Hash } from 'lucide-react'; +import { Loader2, Activity, Hash } from 'lucide-react'; import type { MeshRouteDiagnostic, MeshActivityEvent, MeshProbeResult } from '@/types/mesh'; import { meshRouteStateFromBackend, meshRouteStateTokens } from './meshRouteState'; @@ -146,7 +146,6 @@ export function MeshRouteDetailSheet({ open, onOpenChange, alias }: Props) { )} {events.map((e, i) => (
- {e.source === 'sidecar' && } {e.source === 'pilot' && } {e.source === 'mesh' && } diff --git a/frontend/src/types/mesh.ts b/frontend/src/types/mesh.ts index 93b7c139..8dad9e01 100644 --- a/frontend/src/types/mesh.ts +++ b/frontend/src/types/mesh.ts @@ -13,7 +13,8 @@ export interface MeshNodeStatus { nodeId: number; nodeName: string; enabled: boolean; - sidecarRunning: boolean; + /** Forwarder state for the LOCAL Sencho instance only. `null` for non-local nodes — cross-node forwarder state lands in Phase B. */ + localForwarderListening: boolean | null; pilotConnected: boolean; optedInStacks: string[]; activeStreamCount: number; @@ -36,7 +37,7 @@ export interface MeshRouteDiagnostic { export interface MeshNodeDiagnostic { nodeId: number; - sidecar: { running: boolean; restartCount: number }; + forwarder: { listening: boolean; listenerCount: number }; pilot: { connected: boolean; bufferedAmount: number; lastSeen: number | null }; activeStreams: Array<{ streamId: number; alias?: string; bytesIn: number; bytesOut: number; ageMs: number }>; aliasCache: Array<{ host: string; targetNodeId: number; port: number }>; @@ -45,14 +46,14 @@ export interface MeshNodeDiagnostic { export interface MeshProbeResult { ok: boolean; latencyMs?: number; - where?: 'sidecar' | 'pilot_tunnel' | 'agent_resolve' | 'agent_dial' | 'target_port'; + where?: 'no_route' | 'pilot_tunnel' | 'agent_resolve' | 'agent_dial' | 'target_port'; code?: string; message?: string; } export interface MeshActivityEvent { ts: number; - source: 'sidecar' | 'pilot' | 'mesh'; + source: 'pilot' | 'mesh'; level: 'info' | 'warn' | 'error'; type: string; nodeId?: number; diff --git a/mesh-sidecar/.gitignore b/mesh-sidecar/.gitignore deleted file mode 100644 index 06e60381..00000000 --- a/mesh-sidecar/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -node_modules -dist -*.tsbuildinfo diff --git a/mesh-sidecar/Dockerfile b/mesh-sidecar/Dockerfile deleted file mode 100644 index 06ba1bbf..00000000 --- a/mesh-sidecar/Dockerfile +++ /dev/null @@ -1,30 +0,0 @@ -# Multi-stage build for the Sencho Mesh sidecar. -# -# Base image is pinned by digest, not tag, so a future republish of the -# `22-alpine` tag cannot silently shift the runtime under a release. Update -# the digest in the same PR that rolls the Node minor or upgrades from -# alpine. Resolve a fresh digest with: -# docker buildx imagetools inspect node:22-alpine -ARG NODE_BASE=node:22-alpine@sha256:8ea2348b068a9544dae7317b4f3aafcdc032df1647bb7d768a05a5cad1a7683f - -FROM ${NODE_BASE} 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_BASE} 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 - -# No HEALTHCHECK: the sidecar has no inbound HTTP listener. It maintains -# an outbound websocket to the control plane and exits on connection loss -# so Docker's restart policy is the natural recovery loop. A liveness -# probe based on the node process being PID 1 would be tautological. - -USER node -CMD ["node", "dist/index.js"] diff --git a/mesh-sidecar/package-lock.json b/mesh-sidecar/package-lock.json deleted file mode 100644 index f9019b53..00000000 --- a/mesh-sidecar/package-lock.json +++ /dev/null @@ -1,1333 +0,0 @@ -{ - "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 deleted file mode 100644 index 07b825ad..00000000 --- a/mesh-sidecar/package.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "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 deleted file mode 100644 index 095aa3f4..00000000 --- a/mesh-sidecar/src/__tests__/forwarder.test.ts +++ /dev/null @@ -1,190 +0,0 @@ -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 deleted file mode 100644 index 577cbb88..00000000 --- a/mesh-sidecar/src/__tests__/protocol.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -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 deleted file mode 100644 index 9d3e266d..00000000 --- a/mesh-sidecar/src/control.ts +++ /dev/null @@ -1,175 +0,0 @@ -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 deleted file mode 100644 index a3760b87..00000000 --- a/mesh-sidecar/src/forwarder.ts +++ /dev/null @@ -1,180 +0,0 @@ -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 deleted file mode 100644 index 57056e34..00000000 --- a/mesh-sidecar/src/index.ts +++ /dev/null @@ -1,58 +0,0 @@ -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 deleted file mode 100644 index 48eda602..00000000 --- a/mesh-sidecar/src/protocol.ts +++ /dev/null @@ -1,146 +0,0 @@ -/** - * 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 deleted file mode 100644 index 856416bb..00000000 --- a/mesh-sidecar/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "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 deleted file mode 100644 index 53c06fc7..00000000 --- a/mesh-sidecar/vitest.config.ts +++ /dev/null @@ -1,11 +0,0 @@ -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, - }, -});