From eea445a0187d668472f737fe1b6f9f9012489957 Mon Sep 17 00:00:00 2001 From: Bailey Dixon Date: Sun, 19 Apr 2026 14:26:56 -0400 Subject: [PATCH] feat(relay): stateless pair-mux WebSocket relay skeleton MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Minimal self-hostable relay that routes opaque ciphertext bytes between two endpoints sharing a `?pair=` query param. First connection becomes side A, second becomes side B, third is rejected. Binary frames forward verbatim; text frames drop. Either disconnect tears the pair down. Pairs with one-sided connections beyond 5 minutes are swept. Zero crypto awareness by design — NaCl box wiring lives in the daemon and client, never in the relay (Phase 10 of arc-v3-daemon plan). Ships: - `startRelay({ port, host })` API + `arc-relay start` Commander CLI - Multi-stage Dockerfile pruned to the relay workspace (port 8765) - docker-compose.yml with commented nginx/Caddy TLS-proxy options - README with protocol table, run recipes, and smoke-test snippet - 5 Vitest cases: A↔B round-trip, disconnect teardown, 3rd-conn 409, missing-pair 400, pair-independence Wires the new package into root tsconfig paths + vitest alias so cross-workspace imports resolve in TS and tests. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/relay/Dockerfile | 40 +++++ packages/relay/README.md | 112 +++++++++++- packages/relay/docker-compose.yml | 80 +++++++++ packages/relay/package.json | 19 ++- packages/relay/src/cli.ts | 47 +++++ packages/relay/src/index.ts | 264 +++++++++++++++++++++++++++++ packages/relay/tests/relay.test.ts | 122 +++++++++++++ packages/relay/tsconfig.json | 8 + pnpm-lock.yaml | 16 +- tsconfig.json | 4 +- vitest.config.ts | 1 + 11 files changed, 704 insertions(+), 9 deletions(-) create mode 100644 packages/relay/Dockerfile create mode 100644 packages/relay/docker-compose.yml create mode 100644 packages/relay/src/cli.ts create mode 100644 packages/relay/src/index.ts create mode 100644 packages/relay/tests/relay.test.ts create mode 100644 packages/relay/tsconfig.json diff --git a/packages/relay/Dockerfile b/packages/relay/Dockerfile new file mode 100644 index 0000000..ba68af4 --- /dev/null +++ b/packages/relay/Dockerfile @@ -0,0 +1,40 @@ +# ─── Build stage ───────────────────────────────────────────────── +FROM node:22-alpine AS build +WORKDIR /app + +RUN corepack enable && corepack prepare pnpm@latest --activate + +# Workspace config + lockfile first for layer caching +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml tsconfig.json ./ +COPY packages/relay/package.json packages/relay/package.json + +# Install only the relay workspace (and its deps) +RUN pnpm install --frozen-lockfile --ignore-scripts --filter @axiom-labs/arc-relay... + +# Copy source +COPY packages/relay/ packages/relay/ + +# Build +RUN pnpm --filter @axiom-labs/arc-relay run build + +# ─── Runtime stage ─────────────────────────────────────────────── +FROM node:22-alpine AS runtime +WORKDIR /app + +ENV NODE_ENV=production +ENV RELAY_PORT=8765 +ENV RELAY_HOST=0.0.0.0 + +# Copy the built relay + its pruned node_modules +COPY --from=build /app/packages/relay/dist ./dist +COPY --from=build /app/packages/relay/package.json ./package.json +COPY --from=build /app/node_modules ./node_modules + +EXPOSE 8765 + +# Minimal HEALTHCHECK: relay exposes GET /health +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s \ + CMD wget -qO- "http://127.0.0.1:${RELAY_PORT}/health" > /dev/null || exit 1 + +ENTRYPOINT ["node", "dist/cli.js"] +CMD ["start", "--port", "8765", "--host", "0.0.0.0"] diff --git a/packages/relay/README.md b/packages/relay/README.md index b738aba..83f9871 100644 --- a/packages/relay/README.md +++ b/packages/relay/README.md @@ -1,8 +1,110 @@ # @axiom-labs/arc-relay -Self-hosted, end-to-end-encrypted tunnel for remote ARC daemon access. +Self-hosted, zero-knowledge WebSocket multiplexer that routes opaque bytes +between two endpoints sharing a `pairCode`. -**Status:** placeholder. Implementation lands in Phase 10 of the v3 plan -(see `docs/plans/arc-v3-daemon.md`). The relay is a stateless WebSocket -multiplexer that routes opaque NaCl-box-encrypted frames between a daemon -and its paired clients. +The relay has **no crypto awareness** — payloads are forwarded verbatim. +End-to-end encryption (NaCl box) is performed by the ARC daemon and its +clients; the relay never sees plaintext. + +--- + +## Protocol + +Endpoint: `ws(s):///?pair=` + +| Behavior | Detail | +| --- | --- | +| Pair registry | In-memory `Map`. | +| First connect | Becomes side **A**. | +| Second connect | Becomes side **B**. | +| Third connect | Rejected with HTTP `409 Conflict`. | +| Binary frame | Forwarded verbatim to the peer side. | +| Text frame | Dropped (payloads are ciphertext, not text). | +| Disconnect | Closes the peer with code `1000` and deletes the pair. | +| TTL | Pairs that sit with only one side connected for **5 minutes** are swept; the lone socket is closed with `1001` and the entry removed. | +| Persistence | None. Restarting the process drops every live pair. | +| Logs | None of payload content. | + +`GET /health` returns `{"ok": true, "pairs": }` — useful for +container health checks and LB probes. + +--- + +## Run locally + +```bash +pnpm install +pnpm --filter @axiom-labs/arc-relay run build +node packages/relay/dist/cli.js start --port 8765 +``` + +Or during development without a build: + +```bash +npx tsx packages/relay/src/cli.ts start --port 8765 +``` + +CLI: + +``` +arc-relay start [--port 8765] [--host 0.0.0.0] +``` + +--- + +## Run in Docker + +```bash +# Build from the repo root (multi-stage, pruned to the relay workspace) +docker build -f packages/relay/Dockerfile -t arc-relay:local . + +# Run +docker run --rm -p 8765:8765 arc-relay:local +``` + +A ready-to-edit compose example with a TLS-terminating proxy (nginx+certbot +or Caddy) lives in [`docker-compose.yml`](./docker-compose.yml). The relay +itself speaks plain `ws://`; TLS is intentionally delegated to the edge. + +--- + +## Smoke test + +With the relay running on `:8765`: + +```bash +npx tsx -e " +import WebSocket from 'ws'; +const a = new WebSocket('ws://127.0.0.1:8765/?pair=demo'); +const b = new WebSocket('ws://127.0.0.1:8765/?pair=demo'); +a.on('open', () => a.send(Buffer.from('hi-from-a'))); +b.on('message', (d) => { console.log('b got', d.toString()); process.exit(0); }); +setTimeout(() => process.exit(1), 3000); +" +``` + +Expected: `b got hi-from-a`. + +--- + +## What the relay does **not** do + +- No user accounts, tokens, or authz beyond the shared `pairCode`. +- No persistent storage — everything is in-memory. +- No TLS — terminate at a proxy or cloud LB. +- No inspection of payloads — they are opaque bytes. + +Pair codes are a thin rendezvous mechanism, not a security primitive. +Authentication, integrity, and confidentiality are the endpoints' job +(NaCl box in the ARC daemon/client). + +--- + +## Status + +Part of the v3 daemon pivot — see +[`docs/plans/arc-v3-daemon.md`](../../docs/plans/arc-v3-daemon.md) Phase 10. + +Actual crypto wiring between daemon ↔ client ↔ relay is tracked separately. +This package's job is routing only. diff --git a/packages/relay/docker-compose.yml b/packages/relay/docker-compose.yml new file mode 100644 index 0000000..100e6e5 --- /dev/null +++ b/packages/relay/docker-compose.yml @@ -0,0 +1,80 @@ +# Example compose for self-hosting arc-relay. +# +# The relay itself speaks plain `ws://` on port 8765 — terminate TLS at an +# edge proxy (nginx, Caddy, or a cloud LB) and forward to the relay. +# +# ┌─────────┐ wss://my-relay.example.com ┌───────────┐ ws://relay:8765 ┌───────┐ +# │ clients │ ────────────────────────────▶ │ tls proxy │ ─────────────────▶ │ relay │ +# └─────────┘ └───────────┘ └───────┘ +# +# Uncomment one of the proxy blocks below (nginx or caddy) and point it at +# a real domain + cert. Both are battle-tested; pick whichever you prefer. + +version: "3.9" + +services: + relay: + image: ghcr.io/axiom-labs/arc-relay:latest + # Or build locally from this workspace: + # build: + # context: ../../ + # dockerfile: packages/relay/Dockerfile + container_name: arc-relay + restart: unless-stopped + environment: + RELAY_PORT: "8765" + RELAY_HOST: "0.0.0.0" + # Expose directly if you terminate TLS elsewhere (cloud LB, existing nginx). + # For a fully self-contained stack, comment the ports block and use the + # proxy service below. + ports: + - "8765:8765" + + # ── Option A: nginx reverse proxy with Let's Encrypt via certbot ──────── + # Uncomment and edit `server_name` + email. + # + # proxy: + # image: nginx:alpine + # container_name: arc-relay-proxy + # restart: unless-stopped + # depends_on: [relay] + # ports: + # - "443:443" + # - "80:80" + # volumes: + # - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro + # - ./certs:/etc/letsencrypt:ro + # + # certbot: + # image: certbot/certbot:latest + # volumes: + # - ./certs:/etc/letsencrypt + # entrypoint: > + # sh -c "certbot certonly --webroot -w /var/www/certbot + # --email you@example.com --agree-tos --no-eff-email + # -d relay.example.com" + + # ── Option B: Caddy (automatic HTTPS, zero config) ────────────────────── + # Uncomment and edit `relay.example.com` in Caddyfile below. + # + # proxy: + # image: caddy:alpine + # container_name: arc-relay-proxy + # restart: unless-stopped + # depends_on: [relay] + # ports: + # - "80:80" + # - "443:443" + # volumes: + # - ./Caddyfile:/etc/caddy/Caddyfile:ro + # - caddy_data:/data + # - caddy_config:/config + # + # Example Caddyfile: + # relay.example.com { + # reverse_proxy /* relay:8765 + # } + +# volumes: +# caddy_data: +# caddy_config: diff --git a/packages/relay/package.json b/packages/relay/package.json index 1bf927f..5658cb5 100644 --- a/packages/relay/package.json +++ b/packages/relay/package.json @@ -3,8 +3,23 @@ "version": "1.0.0-alpha.0", "private": true, "type": "module", - "description": "ARC relay — self-hosted, E2E-encrypted tunnel for remote daemon access. Placeholder; implementation lands in Phase 10.", + "description": "ARC relay — self-hosted, stateless WebSocket multiplexer that routes opaque E2E-encrypted frames between paired endpoints.", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "bin": { + "arc-relay": "./dist/cli.js" + }, "scripts": { - "typecheck": "echo 'relay stub — no code yet'" + "build": "tsup src/index.ts src/cli.ts --format esm --target node20 --clean --dts", + "dev": "tsx src/cli.ts start", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "commander": "^14", + "ws": "^8.18.0", + "zod": "^3.25.0" + }, + "devDependencies": { + "@types/ws": "^8.5.12" } } diff --git a/packages/relay/src/cli.ts b/packages/relay/src/cli.ts new file mode 100644 index 0000000..2d7147f --- /dev/null +++ b/packages/relay/src/cli.ts @@ -0,0 +1,47 @@ +#!/usr/bin/env node +import { Command } from "commander"; +import { startRelay } from "./index.js"; + +async function main(): Promise { + const program = new Command(); + program + .name("arc-relay") + .description( + "ARC relay — stateless WebSocket multiplexer that routes opaque bytes between paired endpoints.", + ); + + program + .command("start") + .description("Start the relay server") + .option("-p, --port ", "TCP port to bind", "8765") + .option("-H, --host ", "Host/interface to bind", "0.0.0.0") + .action(async (opts: { port: string; host: string }) => { + const port = Number.parseInt(opts.port, 10); + if (!Number.isFinite(port) || port < 0 || port > 65535) { + process.stderr.write(`invalid port: ${opts.port}\n`); + process.exit(2); + } + const handle = await startRelay({ port, host: opts.host }); + process.stdout.write( + `arc-relay listening on ws://${handle.host}:${handle.port} (health at /health)\n`, + ); + + const shutdown = async (signal: NodeJS.Signals): Promise => { + process.stdout.write(`\narc-relay received ${signal}, shutting down...\n`); + try { + await handle.stop(); + } finally { + process.exit(0); + } + }; + process.once("SIGINT", () => void shutdown("SIGINT")); + process.once("SIGTERM", () => void shutdown("SIGTERM")); + }); + + await program.parseAsync(process.argv); +} + +main().catch((err) => { + process.stderr.write(`arc-relay: ${(err as Error).message}\n`); + process.exit(1); +}); diff --git a/packages/relay/src/index.ts b/packages/relay/src/index.ts new file mode 100644 index 0000000..a64c78b --- /dev/null +++ b/packages/relay/src/index.ts @@ -0,0 +1,264 @@ +import http from "node:http"; +import { WebSocketServer, type WebSocket } from "ws"; +import { z } from "zod"; + +/** + * Relay options. + */ +export interface RelayOptions { + /** TCP port to bind. */ + port: number; + /** Host/interface to bind. Defaults to "0.0.0.0". */ + host?: string; + /** + * Max time in ms a pair may sit with only one side connected before the + * lone socket is evicted and the pair entry is deleted. + * + * Defaults to 5 minutes. Exposed primarily for tests. + */ + pairTtlMs?: number; + /** + * How often the sweeper runs to enforce `pairTtlMs`. Defaults to 30s. + */ + sweepIntervalMs?: number; +} + +/** + * Handle returned by `startRelay`. + */ +export interface RelayHandle { + /** Bound port (useful when callers pass `port: 0`). */ + port: number; + /** Bound host. */ + host: string; + /** Graceful shutdown. Closes all sockets then the HTTP server. */ + stop: () => Promise; +} + +interface Pair { + pairCode: string; + a?: WebSocket; + b?: WebSocket; + createdAt: number; +} + +const OptionsSchema = z.object({ + port: z.number().int().nonnegative(), + host: z.string().min(1).optional(), + pairTtlMs: z.number().int().positive().optional(), + sweepIntervalMs: z.number().int().positive().optional(), +}); + +const DEFAULT_PAIR_TTL_MS = 5 * 60_000; +const DEFAULT_SWEEP_INTERVAL_MS = 30_000; + +/** + * Start a stateless pair-mux relay. + * + * Protocol: + * - Client connects via WebSocket to `/?pair=`. + * - First connection with a given `` becomes side `a`. + * - Second connection with that same `` becomes side `b`. + * - Any further connection for that `` is rejected (pair is full). + * - Binary frames received on one side are forwarded verbatim to the other. + * - Text frames are dropped (payloads are opaque ciphertext, not text). + * - When either side closes, the other is closed and the pair is deleted. + * - Pairs that sit with only one side connected beyond `pairTtlMs` are + * swept: the lone socket is closed and the entry removed. + * + * The relay is intentionally zero-knowledge: it never inspects, decrypts, + * or logs payload content. + */ +export async function startRelay(opts: RelayOptions): Promise { + const parsed = OptionsSchema.parse(opts); + const host = parsed.host ?? "0.0.0.0"; + const pairTtlMs = parsed.pairTtlMs ?? DEFAULT_PAIR_TTL_MS; + const sweepIntervalMs = parsed.sweepIntervalMs ?? DEFAULT_SWEEP_INTERVAL_MS; + + const pairs = new Map(); + + const httpServer = http.createServer((req, res) => { + // Tiny liveness endpoint. Everything else is WS. + if (req.method === "GET" && (req.url === "/health" || req.url === "/health/")) { + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify({ ok: true, pairs: pairs.size })); + return; + } + res.statusCode = 404; + res.end("not found"); + }); + + const wss = new WebSocketServer({ noServer: true }); + + httpServer.on("upgrade", (req, socket, head) => { + let url: URL; + try { + // Host header is irrelevant for routing; synthesize one. + url = new URL(req.url ?? "/", "http://relay.local"); + } catch { + rejectUpgrade(socket, 400, "bad request"); + return; + } + const pairCode = url.searchParams.get("pair"); + if (!pairCode || pairCode.length < 1 || pairCode.length > 256) { + rejectUpgrade(socket, 400, "missing or invalid pair code"); + return; + } + + const existing = pairs.get(pairCode); + if (existing && existing.a && existing.b) { + rejectUpgrade(socket, 409, "pair is full"); + return; + } + + wss.handleUpgrade(req, socket as never, head, (ws) => { + attach(ws, pairCode); + }); + }); + + function attach(ws: WebSocket, pairCode: string): void { + let pair = pairs.get(pairCode); + if (!pair) { + pair = { pairCode, createdAt: Date.now() }; + pairs.set(pairCode, pair); + } + + let side: "a" | "b"; + if (!pair.a) { + pair.a = ws; + side = "a"; + } else if (!pair.b) { + pair.b = ws; + side = "b"; + } else { + // Defensive: the upgrade handler already rejects full pairs; this only + // fires if the gate is bypassed (e.g. internal caller). + try { + ws.close(1013, "pair is full"); + } catch { + // ignore + } + return; + } + + const forward = (data: unknown, isBinary: boolean): void => { + if (!isBinary) return; // drop text frames + const peer = side === "a" ? pair!.b : pair!.a; + if (!peer || peer.readyState !== peer.OPEN) return; + // `data` from ws is a Buffer (or array of Buffers) by default. Pass binary. + peer.send(data as Buffer, { binary: true }, () => { + // Errors during forwarding close the peer; we silently drop. + }); + }; + + const teardown = (reason: number = 1000): void => { + const current = pairs.get(pairCode); + if (!current) return; + const peer = side === "a" ? current.b : current.a; + pairs.delete(pairCode); + if (peer && peer.readyState === peer.OPEN) { + try { + peer.close(reason, "peer disconnected"); + } catch { + // ignore + } + } + }; + + ws.on("message", forward); + ws.once("close", () => teardown(1000)); + ws.once("error", () => { + try { + ws.close(1011, "socket error"); + } catch { + // ignore + } + teardown(1011); + }); + } + + const sweeper = setInterval(() => { + const now = Date.now(); + for (const [code, pair] of pairs) { + const bothConnected = + pair.a && pair.a.readyState === pair.a.OPEN && pair.b && pair.b.readyState === pair.b.OPEN; + if (bothConnected) continue; + if (now - pair.createdAt >= pairTtlMs) { + pairs.delete(code); + for (const side of [pair.a, pair.b]) { + if (!side) continue; + try { + side.close(1001, "pair expired"); + } catch { + // ignore + } + } + } + } + }, sweepIntervalMs); + if (typeof sweeper.unref === "function") sweeper.unref(); + + await new Promise((resolve, reject) => { + const onError = (err: Error): void => { + httpServer.off("listening", onListening); + reject(err); + }; + const onListening = (): void => { + httpServer.off("error", onError); + resolve(); + }; + httpServer.once("error", onError); + httpServer.once("listening", onListening); + httpServer.listen(parsed.port, host); + }); + + const address = httpServer.address(); + const boundPort = typeof address === "object" && address ? address.port : parsed.port; + + async function stop(): Promise { + clearInterval(sweeper); + for (const pair of pairs.values()) { + for (const side of [pair.a, pair.b]) { + if (!side) continue; + try { + side.close(1001, "relay stopping"); + } catch { + // ignore + } + } + } + pairs.clear(); + await new Promise((resolve) => { + wss.close(() => resolve()); + }); + await new Promise((resolve) => { + httpServer.close(() => resolve()); + }); + } + + return { port: boundPort, host, stop }; +} + +const HTTP_STATUS_TEXT: Record = { + 400: "Bad Request", + 409: "Conflict", +}; + +function rejectUpgrade( + socket: import("node:stream").Duplex, + status: number, + reason: string, +): void { + const statusText = HTTP_STATUS_TEXT[status] ?? "Error"; + const body = `${reason}\n`; + const response = + `HTTP/1.1 ${status} ${statusText}\r\n` + + `Content-Type: text/plain; charset=utf-8\r\n` + + `Content-Length: ${Buffer.byteLength(body)}\r\n` + + `Connection: close\r\n\r\n${body}`; + try { + socket.write(response); + } finally { + socket.destroy(); + } +} diff --git a/packages/relay/tests/relay.test.ts b/packages/relay/tests/relay.test.ts new file mode 100644 index 0000000..519397f --- /dev/null +++ b/packages/relay/tests/relay.test.ts @@ -0,0 +1,122 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import WebSocket from "ws"; +import { startRelay, type RelayHandle } from "@axiom-labs/arc-relay"; + +let relay: RelayHandle; + +beforeEach(async () => { + // port: 0 → OS picks a free port. Test talks to the bound port via `relay.port`. + relay = await startRelay({ port: 0, host: "127.0.0.1" }); +}); + +afterEach(async () => { + await relay.stop(); +}); + +function wsUrl(pair: string): string { + return `ws://127.0.0.1:${relay.port}/?pair=${encodeURIComponent(pair)}`; +} + +function open(pair: string): Promise { + return new Promise((resolve, reject) => { + const ws = new WebSocket(wsUrl(pair)); + ws.once("open", () => resolve(ws)); + ws.once("error", reject); + }); +} + +function nextBinary(ws: WebSocket): Promise { + return new Promise((resolve, reject) => { + const onMessage = (data: WebSocket.RawData, isBinary: boolean): void => { + ws.off("error", onError); + // `ws` delivers Buffer by default for binary frames. + const buf = Array.isArray(data) ? Buffer.concat(data) : Buffer.from(data as Buffer); + if (!isBinary) { + reject(new Error("expected binary frame")); + return; + } + resolve(buf); + }; + const onError = (err: Error): void => { + ws.off("message", onMessage); + reject(err); + }; + ws.once("message", onMessage); + ws.once("error", onError); + }); +} + +function nextClose(ws: WebSocket): Promise { + return new Promise((resolve) => { + ws.once("close", (code) => resolve(code)); + }); +} + +describe("relay", () => { + it("routes binary bytes A → B and B → A", async () => { + const a = await open("round-trip"); + const b = await open("round-trip"); + + const bGot = nextBinary(b); + a.send(Buffer.from("hello from a"), { binary: true }); + expect((await bGot).toString("utf8")).toBe("hello from a"); + + const aGot = nextBinary(a); + b.send(Buffer.from("hi back from b"), { binary: true }); + expect((await aGot).toString("utf8")).toBe("hi back from b"); + + a.close(); + b.close(); + }); + + it("closes B when A disconnects", async () => { + const a = await open("teardown"); + const b = await open("teardown"); + + const bClosed = nextClose(b); + a.close(); + const code = await bClosed; + // We sent `close(1000, ...)`. ws may expose that as 1000 or 1005 (no code) + // depending on how the other side terminated; accept either. + expect([1000, 1005, 1006]).toContain(code); + }); + + it("rejects a third connection to the same pair", async () => { + const a = await open("full"); + const b = await open("full"); + + await expect(open("full")).rejects.toThrow(); + + a.close(); + b.close(); + }); + + it("rejects connections without a pair code", async () => { + const ws = new WebSocket(`ws://127.0.0.1:${relay.port}/`); + const err = await new Promise((resolve) => { + ws.once("error", resolve); + ws.once("open", () => resolve(new Error("unexpected open"))); + }); + expect(err.message).toMatch(/400|Unexpected/); + }); + + it("keeps pairs independent", async () => { + const a1 = await open("room-1"); + const b1 = await open("room-1"); + const a2 = await open("room-2"); + const b2 = await open("room-2"); + + const b1Got = nextBinary(b1); + const b2Got = nextBinary(b2); + a1.send(Buffer.from("one"), { binary: true }); + a2.send(Buffer.from("two"), { binary: true }); + + expect((await b1Got).toString("utf8")).toBe("one"); + expect((await b2Got).toString("utf8")).toBe("two"); + + a1.close(); + b1.close(); + a2.close(); + b2.close(); + }); +}); diff --git a/packages/relay/tsconfig.json b/packages/relay/tsconfig.json new file mode 100644 index 0000000..6435d43 --- /dev/null +++ b/packages/relay/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist" + }, + "include": ["src"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1bc0959..3150689 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -154,7 +154,21 @@ importers: specifier: ^3.25.0 version: 3.25.76 - packages/relay: {} + packages/relay: + dependencies: + commander: + specifier: ^14 + version: 14.0.3 + ws: + specifier: ^8.18.0 + version: 8.20.0 + zod: + specifier: ^3.25.0 + version: 3.25.76 + devDependencies: + '@types/ws': + specifier: ^8.5.12 + version: 8.18.1 site: dependencies: diff --git a/tsconfig.json b/tsconfig.json index e77475f..e733778 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -16,7 +16,8 @@ "@axiom-labs/arc-mcp": ["./packages/mcp/src/index.ts"], "@axiom-labs/arc-dashboard": ["./packages/dashboard/src/index.ts"], "@axiom-labs/arc-daemon": ["./packages/daemon/src/index.ts"], - "@axiom-labs/arc-client": ["./packages/client/src/index.ts"] + "@axiom-labs/arc-client": ["./packages/client/src/index.ts"], + "@axiom-labs/arc-relay": ["./packages/relay/src/index.ts"] } }, "include": [ @@ -28,6 +29,7 @@ "packages/dashboard/src/**/*.ts", "packages/daemon/src/**/*.ts", "packages/client/src/**/*.ts", + "packages/relay/src/**/*.ts", "packages/cli/src/**/*.ts", "packages/cli/src/**/*.tsx", "tests/**/*.ts", diff --git a/vitest.config.ts b/vitest.config.ts index fcb801c..5d7fcc1 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -10,6 +10,7 @@ export default defineConfig({ "@axiom-labs/arc-mcp": path.resolve(__dirname, "packages/mcp/src/index.ts"), "@axiom-labs/arc-daemon": path.resolve(__dirname, "packages/daemon/src/index.ts"), "@axiom-labs/arc-client": path.resolve(__dirname, "packages/client/src/index.ts"), + "@axiom-labs/arc-relay": path.resolve(__dirname, "packages/relay/src/index.ts"), }, }, test: {