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:
Alejandro Gutiérrez
2026-04-04 21:24:17 +01:00
parent d3163a5bff
commit d5d0e6fdbb
9 changed files with 264 additions and 0 deletions

12
apps/broker/src/broker.ts Normal file
View 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
View 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
View 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
View 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
View 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;
}