From d5d0e6fdbbc5e32ee42117d2dbecc7597b292d20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Guti=C3=A9rrez?= <35082514+alezmad@users.noreply.github.com> Date: Sat, 4 Apr 2026 21:24:17 +0100 Subject: [PATCH] feat(broker): scaffold apps/broker workspace (bun WS runtime, no port yet) - @claudemesh/broker package with bun dev/start scripts - src/index.ts stub: WS server on BROKER_PORT, SIGTERM cleanup - src/env.ts: Zod-validated env (BROKER_PORT, DATABASE_URL, STATUS_TTL_SECONDS, HOOK_FRESH_WINDOW_SECONDS) - src/db.ts: re-exports Drizzle client from @turbostarter/db - src/broker.ts + src/types.ts: placeholders for step 8 port - README documents run commands, env vars, deploy targets - tsconfig extends @turbostarter/tsconfig base - eslint.config.js extends @turbostarter/eslint-config/base Dependencies declared but not installed yet (ws, drizzle-orm, zod, libsodium-wrappers + workspace deps). turbo.json unchanged: the global dev task already has persistent=true + cache=false which is what the broker needs. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/broker/README.md | 46 +++++++++++++++++++++ apps/broker/eslint.config.js | 3 ++ apps/broker/package.json | 33 ++++++++++++++++ apps/broker/src/broker.ts | 12 ++++++ apps/broker/src/db.ts | 10 +++++ apps/broker/src/env.ts | 33 ++++++++++++++++ apps/broker/src/index.ts | 77 ++++++++++++++++++++++++++++++++++++ apps/broker/src/types.ts | 35 ++++++++++++++++ apps/broker/tsconfig.json | 15 +++++++ 9 files changed, 264 insertions(+) create mode 100644 apps/broker/README.md create mode 100644 apps/broker/eslint.config.js create mode 100644 apps/broker/package.json create mode 100644 apps/broker/src/broker.ts create mode 100644 apps/broker/src/db.ts create mode 100644 apps/broker/src/env.ts create mode 100644 apps/broker/src/index.ts create mode 100644 apps/broker/src/types.ts create mode 100644 apps/broker/tsconfig.json diff --git a/apps/broker/README.md b/apps/broker/README.md new file mode 100644 index 0000000..44e8681 --- /dev/null +++ b/apps/broker/README.md @@ -0,0 +1,46 @@ +# @claudemesh/broker + +WebSocket broker for claudemesh — routes E2E-encrypted messages between Claude +Code peer sessions, tracks presence, and stores metadata-only audit logs in +Postgres. + +## What it is + +A standalone Bun-runtime WebSocket server that sits between Claude Code +sessions. Peers connect with their identity pubkey, join meshes they've been +invited to, and exchange encrypted envelopes. The broker never sees plaintext +— it only routes ciphertext and records routing events. + +## Running locally + +```sh +# from the repo root +pnpm --filter=@claudemesh/broker dev # watch mode +pnpm --filter=@claudemesh/broker start # production +``` + +## Required env vars + +| Var | Default | Purpose | +| ---------------------------- | ------- | --------------------------------------------------- | +| `BROKER_PORT` | `7899` | Port the WS server listens on | +| `DATABASE_URL` | — | Postgres connection string (shared with apps/web) | +| `STATUS_TTL_SECONDS` | `60` | Flip stuck-"working" peers to idle after this TTL | +| `HOOK_FRESH_WINDOW_SECONDS` | `30` | How long a hook signal beats JSONL inference | + +## Depends on + +- `@turbostarter/db` — Drizzle/Postgres schema (uses the `mesh` pgSchema) +- `@turbostarter/shared` — cross-package utilities + +## Deployment + +Runs as a separate process (not inside Next.js). Intended deployment targets: +Fly.io, Railway, or Coolify on the surfquant VPS. WebSocket server must be +reachable at `ic.claudemesh.com`. + +## Status + +**Scaffold only.** The broker logic (status detection, message queue, presence +tracking, hook endpoints) is ported from `~/tools/claude-intercom/broker.ts` +in a follow-up step. diff --git a/apps/broker/eslint.config.js b/apps/broker/eslint.config.js new file mode 100644 index 0000000..005be22 --- /dev/null +++ b/apps/broker/eslint.config.js @@ -0,0 +1,3 @@ +import baseConfig from "@turbostarter/eslint-config/base"; + +export default baseConfig; diff --git a/apps/broker/package.json b/apps/broker/package.json new file mode 100644 index 0000000..1616c45 --- /dev/null +++ b/apps/broker/package.json @@ -0,0 +1,33 @@ +{ + "name": "@claudemesh/broker", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "clean": "git clean -xdf .cache .turbo dist node_modules", + "dev": "bun --hot src/index.ts", + "start": "bun src/index.ts", + "format": "prettier --check . --ignore-path ../../.gitignore", + "lint": "eslint", + "typecheck": "tsc --noEmit" + }, + "prettier": "@turbostarter/prettier-config", + "dependencies": { + "@turbostarter/db": "workspace:*", + "@turbostarter/shared": "workspace:*", + "drizzle-orm": "catalog:", + "libsodium-wrappers": "0.7.15", + "ws": "8.19.1", + "zod": "catalog:" + }, + "devDependencies": { + "@turbostarter/eslint-config": "workspace:*", + "@turbostarter/prettier-config": "workspace:*", + "@turbostarter/tsconfig": "workspace:*", + "@types/libsodium-wrappers": "0.7.14", + "@types/ws": "8.5.13", + "eslint": "catalog:", + "prettier": "catalog:", + "typescript": "catalog:" + } +} diff --git a/apps/broker/src/broker.ts b/apps/broker/src/broker.ts new file mode 100644 index 0000000..394177d --- /dev/null +++ b/apps/broker/src/broker.ts @@ -0,0 +1,12 @@ +// TODO: port from ~/tools/claude-intercom/broker.ts in step 8 +// +// That implementation carries the battle-tested pieces we'll migrate: +// - status_source column (hook > manual > jsonl) + writeStatus rules +// - TTL sweeper for stuck-"working" peers +// - Pending hook statuses (first-turn race handler) +// - /hook/set-status endpoint for Claude Code hook scripts +// +// The port swaps SQLite prepared statements for Drizzle queries against +// the `mesh` pgSchema (see packages/db/src/schema/mesh.ts). All logic +// and test patterns are ported verbatim — only the persistence layer +// changes. diff --git a/apps/broker/src/db.ts b/apps/broker/src/db.ts new file mode 100644 index 0000000..afad2ec --- /dev/null +++ b/apps/broker/src/db.ts @@ -0,0 +1,10 @@ +/** + * Postgres client re-export for the broker. + * + * The broker shares a Postgres instance with apps/web, accessed through + * the same Drizzle schema defined in @turbostarter/db. Importing the + * `db` binding from `@turbostarter/db/server` gives us a pre-wired + * client with the `mesh` pgSchema tables already in scope. + */ +export { db } from "@turbostarter/db/server"; +export { schema } from "@turbostarter/db/schema"; diff --git a/apps/broker/src/env.ts b/apps/broker/src/env.ts new file mode 100644 index 0000000..cd4c0d3 --- /dev/null +++ b/apps/broker/src/env.ts @@ -0,0 +1,33 @@ +import { z } from "zod"; + +/** + * Broker environment config. + * + * Validated at startup with Zod. Fails fast with a useful error if any + * required var is missing or malformed. Defaults mirror the values + * proven out in the claude-intercom prototype so local dev works + * without a .env file. + */ +const envSchema = z.object({ + BROKER_PORT: z.coerce.number().int().positive().default(7899), + DATABASE_URL: z.string().min(1, "DATABASE_URL is required"), + STATUS_TTL_SECONDS: z.coerce.number().int().positive().default(60), + HOOK_FRESH_WINDOW_SECONDS: z.coerce.number().int().positive().default(30), + NODE_ENV: z + .enum(["development", "production", "test"]) + .default("development"), +}); + +export type BrokerEnv = z.infer; + +export function loadEnv(): BrokerEnv { + const parsed = envSchema.safeParse(process.env); + if (!parsed.success) { + console.error("[broker] invalid environment:"); + console.error(z.treeifyError(parsed.error)); + process.exit(1); + } + return parsed.data; +} + +export const env = loadEnv(); diff --git a/apps/broker/src/index.ts b/apps/broker/src/index.ts new file mode 100644 index 0000000..98db87a --- /dev/null +++ b/apps/broker/src/index.ts @@ -0,0 +1,77 @@ +#!/usr/bin/env bun +/** + * @claudemesh/broker entry point. + * + * Stands up a WebSocket server, accepts peer connections, and (in step + * 8) routes E2E-encrypted envelopes between peers joined to the same + * mesh. For now this is a scaffold: it boots, logs, accepts connections + * with a stub handler, and shuts down cleanly on SIGTERM/SIGINT. + */ + +import { WebSocketServer, type WebSocket } from "ws"; +import { env } from "./env"; + +const VERSION = "0.1.0"; + +function log(msg: string): void { + console.error(`[broker] ${msg}`); +} + +function handleConnection(ws: WebSocket, remoteAddress: string | undefined): void { + log(`connection from ${remoteAddress ?? "unknown"}`); + + ws.on("message", (data) => { + // Step-8 stub: echo message length. Real handler will parse the + // WSMessage envelope, authenticate the peer by pubkey, and route. + log(`recv ${data.toString().length} bytes`); + }); + + ws.on("close", () => { + log("connection closed"); + }); + + ws.on("error", (err) => { + log(`ws error: ${err.message}`); + }); +} + +function main(): void { + const wss = new WebSocketServer({ + host: "0.0.0.0", + port: env.BROKER_PORT, + }); + + wss.on("connection", (ws, req) => { + handleConnection(ws, req.socket.remoteAddress); + }); + + wss.on("listening", () => { + log(`@claudemesh/broker v${VERSION} listening on :${env.BROKER_PORT}`); + log( + `config: STATUS_TTL=${env.STATUS_TTL_SECONDS}s HOOK_FRESH=${env.HOOK_FRESH_WINDOW_SECONDS}s`, + ); + }); + + wss.on("error", (err) => { + log(`server error: ${err.message}`); + process.exit(1); + }); + + const shutdown = (signal: string): void => { + log(`${signal} received, shutting down`); + wss.close(() => { + log("server closed, bye"); + process.exit(0); + }); + // Hard exit if close hangs past 5s. + setTimeout(() => { + log("forcing exit after 5s"); + process.exit(1); + }, 5000).unref(); + }; + + process.on("SIGTERM", () => shutdown("SIGTERM")); + process.on("SIGINT", () => shutdown("SIGINT")); +} + +main(); diff --git a/apps/broker/src/types.ts b/apps/broker/src/types.ts new file mode 100644 index 0000000..d27d278 --- /dev/null +++ b/apps/broker/src/types.ts @@ -0,0 +1,35 @@ +/** + * Broker protocol types. + * + * Wire format for WebSocket messages between peers and broker. Kept + * minimal here — the concrete schema lands in step 8 when we port the + * claude-intercom logic into this workspace. + */ + +export type Priority = "now" | "next" | "low"; + +export type PeerStatus = "idle" | "working" | "dnd"; + +export type StatusSource = "hook" | "manual" | "jsonl"; + +/** Runtime view of a connected peer. */ +export interface Peer { + id: string; // broker-assigned short id + meshId: string; + pubkey: string; // ed25519 hex + displayName: string; + status: PeerStatus; + statusSource: StatusSource; + statusUpdatedAt: Date; + connectedAt: Date; +} + +/** + * Generic WS message envelope. Concrete variants (hello, send, ack, + * presence, channel_push) are defined in step 8. + */ +export interface WSMessage { + type: string; + payload: T; + id?: string; +} diff --git a/apps/broker/tsconfig.json b/apps/broker/tsconfig.json new file mode 100644 index 0000000..379db76 --- /dev/null +++ b/apps/broker/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "@turbostarter/tsconfig/base.json", + "compilerOptions": { + "lib": ["es2022"], + "module": "esnext", + "moduleResolution": "bundler", + "baseUrl": ".", + "paths": { + "~/*": ["./src/*"] + }, + "types": ["bun-types"] + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"] +}