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) <noreply@anthropic.com>
This commit is contained in:
46
apps/broker/README.md
Normal file
46
apps/broker/README.md
Normal file
@@ -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.
|
||||
3
apps/broker/eslint.config.js
Normal file
3
apps/broker/eslint.config.js
Normal file
@@ -0,0 +1,3 @@
|
||||
import baseConfig from "@turbostarter/eslint-config/base";
|
||||
|
||||
export default baseConfig;
|
||||
33
apps/broker/package.json
Normal file
33
apps/broker/package.json
Normal file
@@ -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:"
|
||||
}
|
||||
}
|
||||
12
apps/broker/src/broker.ts
Normal file
12
apps/broker/src/broker.ts
Normal file
@@ -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.
|
||||
10
apps/broker/src/db.ts
Normal file
10
apps/broker/src/db.ts
Normal file
@@ -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";
|
||||
33
apps/broker/src/env.ts
Normal file
33
apps/broker/src/env.ts
Normal file
@@ -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<typeof envSchema>;
|
||||
|
||||
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();
|
||||
77
apps/broker/src/index.ts
Normal file
77
apps/broker/src/index.ts
Normal file
@@ -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();
|
||||
35
apps/broker/src/types.ts
Normal file
35
apps/broker/src/types.ts
Normal file
@@ -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<T = unknown> {
|
||||
type: string;
|
||||
payload: T;
|
||||
id?: string;
|
||||
}
|
||||
15
apps/broker/tsconfig.json
Normal file
15
apps/broker/tsconfig.json
Normal file
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user