From 47698237280bf74d31b0b6b6a92d8df13a387153 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Wed, 15 Apr 2026 19:10:44 -0400 Subject: [PATCH 1/8] refactor(session-broker-core): extract runtime-agnostic broker core --- bun.lock | 18 +- package.json | 6 +- packages/session-broker-core/README.md | 435 ++++++++++++++++++ packages/session-broker-core/package.json | 22 + .../src}/brokerState.test.ts | 0 .../session-broker-core/src}/brokerState.ts | 2 +- .../src}/brokerWire.test.ts | 0 .../session-broker-core/src}/brokerWire.ts | 0 packages/session-broker-core/src/index.ts | 5 + .../session-broker-core/src}/selectors.ts | 0 .../src}/sessionTerminalMetadata.test.ts | 0 .../src}/sessionTerminalMetadata.ts | 0 .../session-broker-core/src}/types.ts | 0 src/hunk-session/brokerAdapter.ts | 2 +- src/hunk-session/cli.ts | 4 +- src/hunk-session/sessionRegistration.ts | 6 +- src/hunk-session/types.ts | 2 +- src/hunk-session/wire.test.ts | 2 +- src/hunk-session/wire.ts | 2 +- src/session-broker/brokerClient.ts | 2 +- src/session-broker/brokerServer.test.ts | 2 +- src/session/commands.ts | 2 +- src/ui/AppHost.interactions.test.tsx | 2 +- test/helpers/session-daemon-fixtures.ts | 2 +- tsconfig.json | 4 + 25 files changed, 502 insertions(+), 18 deletions(-) create mode 100644 packages/session-broker-core/README.md create mode 100644 packages/session-broker-core/package.json rename {src/session-broker => packages/session-broker-core/src}/brokerState.test.ts (100%) rename {src/session-broker => packages/session-broker-core/src}/brokerState.ts (99%) rename {src/session-broker => packages/session-broker-core/src}/brokerWire.test.ts (100%) rename {src/session-broker => packages/session-broker-core/src}/brokerWire.ts (100%) create mode 100644 packages/session-broker-core/src/index.ts rename {src/session-broker => packages/session-broker-core/src}/selectors.ts (100%) rename {src/session-broker => packages/session-broker-core/src}/sessionTerminalMetadata.test.ts (100%) rename {src/session-broker => packages/session-broker-core/src}/sessionTerminalMetadata.ts (100%) rename {src/session-broker => packages/session-broker-core/src}/types.ts (100%) diff --git a/bun.lock b/bun.lock index 164d83e3..da0641b7 100644 --- a/bun.lock +++ b/bun.lock @@ -5,25 +5,35 @@ "": { "name": "hunk", "dependencies": { - "@opentui/core": "^0.1.88", - "@opentui/react": "^0.1.88", "@pierre/diffs": "^1.1.0", "bun": "^1.3.10", "commander": "^14.0.3", "diff": "^8.0.3", - "react": "^19.2.4", "zod": "^4.3.6", }, "devDependencies": { + "@hunk/session-broker-core": "workspace:*", + "@opentui/core": "^0.1.88", + "@opentui/react": "^0.1.88", "@types/bun": "latest", "@types/react": "^19.2.14", "lint-staged": "^16.4.0", "oxfmt": "^0.41.0", "oxlint": "^1.56.0", + "react": "^19.2.4", "simple-git-hooks": "^2.13.1", "tuistory": "^0.0.16", "typescript": "^5.9.3", }, + "peerDependencies": { + "@opentui/core": "^0.1.88", + "@opentui/react": "^0.1.88", + "react": "^19.2.4", + }, + }, + "packages/session-broker-core": { + "name": "@hunk/session-broker-core", + "version": "0.0.0", }, }, "packages": { @@ -31,6 +41,8 @@ "@hono/node-server": ["@hono/node-server@1.19.11", "", { "peerDependencies": { "hono": "^4" } }, "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g=="], + "@hunk/session-broker-core": ["@hunk/session-broker-core@workspace:packages/session-broker-core"], + "@jimp/core": ["@jimp/core@1.6.0", "", { "dependencies": { "@jimp/file-ops": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "await-to-js": "^3.0.0", "exif-parser": "^0.1.12", "file-type": "^16.0.0", "mime": "3" } }, "sha512-EQQlKU3s9QfdJqiSrZWNTxBs3rKXgO2W+GxNXDtwchF3a4IqxDheFX1ti+Env9hdJXDiYLp2jTRjlxhPthsk8w=="], "@jimp/diff": ["@jimp/diff@1.6.0", "", { "dependencies": { "@jimp/plugin-resize": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "pixelmatch": "^5.3.0" } }, "sha512-+yUAQ5gvRC5D1WHYxjBHZI7JBRusGGSLf8AmPRPCenTzh4PA+wZ1xv2+cYqQwTfQHU5tXYOhA0xDytfHUf1Zyw=="], diff --git a/package.json b/package.json index 6aed2c56..a7e49ccd 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,9 @@ "bin": { "hunk": "./bin/hunk.cjs" }, + "workspaces": [ + "packages/*" + ], "files": [ "bin", "dist/npm", @@ -55,7 +58,7 @@ "lint": "oxlint . --deny-warnings", "lint:fix": "oxlint . --fix", "prepare": "simple-git-hooks", - "test": "\"${npm_execpath:-bun}\" test ./src ./scripts ./test/cli ./test/session", + "test": "\"${npm_execpath:-bun}\" test ./src ./packages ./scripts ./test/cli ./test/session", "test:integration": "\"${npm_execpath:-bun}\" test ./test/pty", "test:tty-smoke": "HUNK_RUN_TTY_SMOKE=1 \"${npm_execpath:-bun}\" test ./test/smoke", "check:pack": "bun run ./scripts/check-pack.ts", @@ -76,6 +79,7 @@ "zod": "^4.3.6" }, "devDependencies": { + "@hunk/session-broker-core": "workspace:*", "@opentui/core": "^0.1.88", "@opentui/react": "^0.1.88", "@types/bun": "latest", diff --git a/packages/session-broker-core/README.md b/packages/session-broker-core/README.md new file mode 100644 index 00000000..5709cec5 --- /dev/null +++ b/packages/session-broker-core/README.md @@ -0,0 +1,435 @@ +# @hunk/session-broker-core + +Runtime-agnostic primitives for brokering live app sessions over any transport. + +This package is the clean boundary between Hunk's app-specific session features and the generic mechanics needed to: + +- register live sessions +- keep snapshots up to date +- select a target session +- dispatch commands to one session +- resolve async command results + +It works in both Node and Bun because the core package does **not** depend on `Bun.*`, HTTP, WebSocket, process launching, or Hunk's review model. + +## What this package includes + +- shared session envelope types +- registration and snapshot wire parsing helpers +- an in-memory `SessionBrokerState` +- selector helpers for `sessionId`, `sessionPath`, and `repoRoot` +- generic terminal metadata capture + +## What this package does not include + +Deliberately **out of scope**: + +- HTTP or WebSocket servers +- client reconnect / heartbeat timers +- daemon launch and restart policy +- CLI formatting and command parsing +- capability negotiation +- app-specific registration info, snapshot state, review projections, or commands + +Those pieces stay in the host app. In Hunk, they live under: + +- [`../../src/session-broker/`](../../src/session-broker) +- [`../../src/hunk-session/`](../../src/hunk-session) +- [`../../src/session/`](../../src/session) + +## Package boundary + +The intended split is: + +- **`@hunk/session-broker-core`** owns generic broker state and types +- **your app** owns payload schemas, transport wiring, commands, and projections + +The most important seam is `SessionBrokerViewAdapter`: the core never interprets your `info` or `state` payloads directly. Your app teaches it how to: + +- parse registrations +- parse snapshots +- build listed-session views +- build selected-session context +- build review/export views +- list comment-like annotations + +That keeps the package reusable without forcing Hunk's model on other consumers. + +## Install + +This is currently an internal workspace package in the Hunk repo. + +```json +{ + "devDependencies": { + "@hunk/session-broker-core": "workspace:*" + } +} +``` + +## Quick start + +A typical integration has four steps: + +1. define your app's session `info`, `state`, command, and result types +2. implement a `SessionBrokerViewAdapter` +3. create a `SessionBrokerState` +4. wire your transport so incoming messages call `registerSession`, `updateSnapshot`, `markSessionSeen`, and `handleCommandResult` + +## Core concepts + +### Registration + +A registration identifies one live session and carries app-owned metadata. + +```ts +import type { SessionRegistration } from "@hunk/session-broker-core"; + +interface MySessionInfo { + title: string; + files: string[]; +} + +type MyRegistration = SessionRegistration; +``` + +### Snapshot + +A snapshot is the current live state for one registered session. + +```ts +import type { SessionSnapshot } from "@hunk/session-broker-core"; + +interface MySessionState { + selectedIndex: number; + noteCount: number; +} + +type MySnapshot = SessionSnapshot; +``` + +### Server message + +Commands sent from the broker to a live session are app-defined. + +```ts +import type { SessionServerMessage } from "@hunk/session-broker-core"; + +type MyServerMessage = + | SessionServerMessage<"annotate", { filePath: string; summary: string }> + | SessionServerMessage<"reload_view", { ref: string }>; +``` + +## Minimal adapter example + +`SessionBrokerState` needs an adapter so the core can stay generic. + +```ts +import { + SessionBrokerState, + brokerWireParsers, + parseSessionRegistrationEnvelope, + parseSessionSnapshotEnvelope, + type SessionBrokerViewAdapter, + type SessionBrokerListedSession, + type SessionRegistration, + type SessionSnapshot, +} from "@hunk/session-broker-core"; + +interface MySessionInfo { + title: string; + files: string[]; +} + +interface MySessionState { + selectedIndex: number; + noteCount: number; +} + +type MyRegistration = SessionRegistration; +type MySnapshot = SessionSnapshot; + +interface MyListedSession extends SessionBrokerListedSession { + fileCount: number; + snapshot: MySnapshot; +} + +interface MySelectedContext { + sessionId: string; + selectedIndex: number; +} + +interface MySessionReview { + sessionId: string; + title: string; + fileCount: number; +} + +interface MyCommentSummary { + id: string; +} + +function parseInfo(value: unknown): MySessionInfo | null { + const record = brokerWireParsers.asRecord(value); + if (!record || !Array.isArray(record.files)) { + return null; + } + + const title = brokerWireParsers.parseRequiredString(record.title); + const files = record.files.filter((entry): entry is string => typeof entry === "string"); + if (title === null || files.length !== record.files.length) { + return null; + } + + return { title, files }; +} + +function parseState(value: unknown): MySessionState | null { + const record = brokerWireParsers.asRecord(value); + if (!record) { + return null; + } + + const selectedIndex = brokerWireParsers.parseNonNegativeInt(record.selectedIndex); + const noteCount = brokerWireParsers.parseNonNegativeInt(record.noteCount); + if (selectedIndex === null || noteCount === null) { + return null; + } + + return { selectedIndex, noteCount }; +} + +const adapter: SessionBrokerViewAdapter< + MySessionInfo, + MySessionState, + MyListedSession, + MySelectedContext, + MySessionReview, + MyCommentSummary +> = { + parseRegistration: (value) => parseSessionRegistrationEnvelope(value, parseInfo), + parseSnapshot: (value) => parseSessionSnapshotEnvelope(value, parseState), + buildListedSession: (entry) => ({ + sessionId: entry.registration.sessionId, + cwd: entry.registration.cwd, + repoRoot: entry.registration.repoRoot, + title: entry.registration.info.title, + fileCount: entry.registration.info.files.length, + snapshot: entry.snapshot, + }), + buildSelectedContext: (session) => ({ + sessionId: session.sessionId, + selectedIndex: session.snapshot.state.selectedIndex, + }), + buildSessionReview: (entry) => ({ + sessionId: entry.registration.sessionId, + title: entry.registration.info.title, + fileCount: entry.registration.info.files.length, + }), + listComments: () => [], +}; + +const broker = new SessionBrokerState(adapter); +``` + +## Wiring a transport + +The core package does not care whether you use WebSocket, TCP, IPC, or tests with in-memory sockets. It only expects a socket-like object with: + +```ts +{ send(data: string): unknown } +``` + +A typical server-side message loop looks like this: + +```ts +const socket = { + send(data: string) { + realTransport.send(data); + }, +}; + +function handleIncomingMessage(message: any) { + switch (message.type) { + case "register": + broker.registerSession(socket, message.registration, message.snapshot); + break; + case "snapshot": + broker.updateSnapshot(message.sessionId, message.snapshot); + break; + case "heartbeat": + broker.markSessionSeen(message.sessionId); + break; + case "command-result": + broker.handleCommandResult(message); + break; + } +} + +function handleDisconnect() { + broker.unregisterSocket(socket); +} +``` + +## Dispatching commands + +Once sessions are registered, you can target one session and wait for its async result. + +```ts +const result = await broker.dispatchCommand<{ kind: "reloaded"; ref: string }, "reload_view">({ + selector: { sessionId: "session-1" }, + command: "reload_view", + input: { ref: "HEAD" }, + timeoutMessage: "Timed out waiting for the session to reload.", +}); +``` + +Selectors support: + +- `sessionId` +- `sessionPath` — matched against `registration.cwd` +- `repoRoot` + +Useful helpers: + +- `matchesSessionSelector()` +- `normalizeSessionSelector()` +- `describeSessionSelector()` +- `resolveSessionTarget()` + +## Session lifecycle on the app side + +A live session typically sends these messages: + +```ts +import { + SESSION_BROKER_REGISTRATION_VERSION, + type SessionClientMessage, +} from "@hunk/session-broker-core"; + +const registration = { + registrationVersion: SESSION_BROKER_REGISTRATION_VERSION, + sessionId: "session-1", + pid: process.pid, + cwd: process.cwd(), + launchedAt: new Date().toISOString(), + info: { + title: "repo working tree", + files: ["src/example.ts"], + }, +}; + +const snapshot = { + updatedAt: new Date().toISOString(), + state: { + selectedIndex: 0, + noteCount: 0, + }, +}; + +const registerMessage: SessionClientMessage = { + type: "register", + registration, + snapshot, +}; +``` + +Then later: + +- send `type: "snapshot"` when the view changes +- send `type: "heartbeat"` to keep the session fresh +- send `type: "command-result"` after handling a broker command + +## Registration and snapshot parsing + +The core package provides envelope parsers, but your app owns schema validation for `info` and `state`. + +Use: + +- `parseSessionRegistrationEnvelope()` +- `parseSessionSnapshotEnvelope()` +- `brokerWireParsers` + +That split is intentional: the broker validates the shared outer envelope, while your app validates the inner payloads. + +## Terminal metadata + +If your app wants to attach terminal identity to a registration, use `resolveSessionTerminalMetadata()`. + +```ts +import { resolveSessionTerminalMetadata } from "@hunk/session-broker-core"; + +const terminal = resolveSessionTerminalMetadata({ + env: process.env, + tty: "/dev/ttys003", +}); +``` + +It captures generic metadata for: + +- tty paths +- tmux panes +- iTerm2 session ids +- `TERM_SESSION_ID`-style terminal session ids + +The shape is generic on purpose so apps do not need terminal-specific top-level fields. + +## API overview + +### Types + +- `SessionTargetInput` +- `SessionTerminalLocation` +- `SessionTerminalMetadata` +- `SessionRegistration` +- `SessionSnapshot` +- `SessionClientMessage` +- `SessionServerMessage` +- `SessionBrokerEntry` +- `SessionBrokerListedSession` +- `SessionBrokerViewAdapter<...>` + +### Functions and constants + +- `SESSION_BROKER_REGISTRATION_VERSION` +- `parseSessionRegistrationEnvelope()` +- `parseSessionSnapshotEnvelope()` +- `brokerWireParsers` +- `matchesSessionSelector()` +- `normalizeSessionSelector()` +- `describeSessionSelector()` +- `resolveSessionTarget()` +- `resolveSessionTerminalMetadata()` + +### State container + +- `SessionBrokerState` + - `listSessions()` + - `getSession()` + - `getSessionReview()` + - `getSelectedContext()` + - `listComments()` + - `registerSession()` + - `updateSnapshot()` + - `markSessionSeen()` + - `unregisterSocket()` + - `pruneStaleSessions()` + - `dispatchCommand()` + - `handleCommandResult()` + - `shutdown()` + +## How Hunk uses this package + +Hunk keeps the generic pieces here and layers app-specific behavior on top: + +- the core broker state and shared envelopes live in this package +- Hunk-specific wire parsing lives in [`../../src/hunk-session/wire.ts`](../../src/hunk-session/wire.ts) +- Hunk-specific projections live in [`../../src/hunk-session/brokerAdapter.ts`](../../src/hunk-session/brokerAdapter.ts) +- Hunk's websocket client and daemon runtime stay in [`../../src/session-broker/`](../../src/session-broker) +- Hunk's HTTP API and session CLI stay in [`../../src/session/`](../../src/session) + +That split is the intended architecture: this package is the reusable core, while Hunk owns the policy and product behavior around it. + +## License + +MIT diff --git a/packages/session-broker-core/package.json b/packages/session-broker-core/package.json new file mode 100644 index 00000000..b86888c3 --- /dev/null +++ b/packages/session-broker-core/package.json @@ -0,0 +1,22 @@ +{ + "name": "@hunk/session-broker-core", + "version": "0.0.0", + "private": true, + "description": "Runtime-agnostic session broker core primitives for Node and Bun apps.", + "license": "MIT", + "files": [ + "src" + ], + "type": "module", + "sideEffects": false, + "exports": { + ".": { + "types": "./src/index.ts", + "import": "./src/index.ts" + } + }, + "engines": { + "bun": ">=1.0.0", + "node": ">=18" + } +} diff --git a/src/session-broker/brokerState.test.ts b/packages/session-broker-core/src/brokerState.test.ts similarity index 100% rename from src/session-broker/brokerState.test.ts rename to packages/session-broker-core/src/brokerState.test.ts diff --git a/src/session-broker/brokerState.ts b/packages/session-broker-core/src/brokerState.ts similarity index 99% rename from src/session-broker/brokerState.ts rename to packages/session-broker-core/src/brokerState.ts index f5bdeb47..4aef046f 100644 --- a/src/session-broker/brokerState.ts +++ b/packages/session-broker-core/src/brokerState.ts @@ -11,7 +11,7 @@ interface PendingCommand { sessionId: string; resolve: (result: Result) => void; reject: (error: Error) => void; - timeout: Timer; + timeout: ReturnType; } interface DaemonSessionSocket { diff --git a/src/session-broker/brokerWire.test.ts b/packages/session-broker-core/src/brokerWire.test.ts similarity index 100% rename from src/session-broker/brokerWire.test.ts rename to packages/session-broker-core/src/brokerWire.test.ts diff --git a/src/session-broker/brokerWire.ts b/packages/session-broker-core/src/brokerWire.ts similarity index 100% rename from src/session-broker/brokerWire.ts rename to packages/session-broker-core/src/brokerWire.ts diff --git a/packages/session-broker-core/src/index.ts b/packages/session-broker-core/src/index.ts new file mode 100644 index 00000000..46c5960a --- /dev/null +++ b/packages/session-broker-core/src/index.ts @@ -0,0 +1,5 @@ +export * from "./types"; +export * from "./brokerWire"; +export * from "./brokerState"; +export * from "./selectors"; +export * from "./sessionTerminalMetadata"; diff --git a/src/session-broker/selectors.ts b/packages/session-broker-core/src/selectors.ts similarity index 100% rename from src/session-broker/selectors.ts rename to packages/session-broker-core/src/selectors.ts diff --git a/src/session-broker/sessionTerminalMetadata.test.ts b/packages/session-broker-core/src/sessionTerminalMetadata.test.ts similarity index 100% rename from src/session-broker/sessionTerminalMetadata.test.ts rename to packages/session-broker-core/src/sessionTerminalMetadata.test.ts diff --git a/src/session-broker/sessionTerminalMetadata.ts b/packages/session-broker-core/src/sessionTerminalMetadata.ts similarity index 100% rename from src/session-broker/sessionTerminalMetadata.ts rename to packages/session-broker-core/src/sessionTerminalMetadata.ts diff --git a/src/session-broker/types.ts b/packages/session-broker-core/src/types.ts similarity index 100% rename from src/session-broker/types.ts rename to packages/session-broker-core/src/types.ts diff --git a/src/hunk-session/brokerAdapter.ts b/src/hunk-session/brokerAdapter.ts index c39ab9d1..7459c632 100644 --- a/src/hunk-session/brokerAdapter.ts +++ b/src/hunk-session/brokerAdapter.ts @@ -15,7 +15,7 @@ import type { SessionReview, } from "./types"; import { parseSessionRegistration, parseSessionSnapshot } from "./wire"; -import { SessionBrokerState, type SessionBrokerViewAdapter } from "../session-broker/brokerState"; +import { SessionBrokerState, type SessionBrokerViewAdapter } from "@hunk/session-broker-core"; const hunkSessionBrokerView: SessionBrokerViewAdapter< HunkSessionInfo, diff --git a/src/hunk-session/cli.ts b/src/hunk-session/cli.ts index 6eee079c..b647b7a0 100644 --- a/src/hunk-session/cli.ts +++ b/src/hunk-session/cli.ts @@ -1,5 +1,5 @@ import { resolveSessionBrokerConfig } from "../session-broker/brokerConfig"; -import type { SessionTerminalLocation, SessionTerminalMetadata } from "../session-broker/types"; +import type { SessionTerminalLocation, SessionTerminalMetadata } from "@hunk/session-broker-core"; import { readHunkSessionDaemonCapabilities } from "../session/capabilities"; import { HUNK_SESSION_API_PATH, @@ -29,7 +29,7 @@ import type { SessionReviewCommandInput, SessionSelectorInput, } from "../core/types"; -import { describeSessionSelector } from "../session-broker/selectors"; +import { describeSessionSelector } from "@hunk/session-broker-core"; export interface HunkSessionCliClient { getCapabilities(): Promise; diff --git a/src/hunk-session/sessionRegistration.ts b/src/hunk-session/sessionRegistration.ts index ef4b9560..15dc111a 100644 --- a/src/hunk-session/sessionRegistration.ts +++ b/src/hunk-session/sessionRegistration.ts @@ -3,8 +3,10 @@ import { spawnSync } from "node:child_process"; import { formatHunkHeader } from "../core/hunkHeader"; import { hunkLineRange } from "../core/liveComments"; import type { AppBootstrap } from "../core/types"; -import { SESSION_BROKER_REGISTRATION_VERSION } from "../session-broker/brokerWire"; -import { resolveSessionTerminalMetadata } from "../session-broker/sessionTerminalMetadata"; +import { + SESSION_BROKER_REGISTRATION_VERSION, + resolveSessionTerminalMetadata, +} from "@hunk/session-broker-core"; import type { HunkSessionRegistration, HunkSessionSnapshot, SessionReviewFile } from "./types"; /** Resolve the TTY device path for the current process, if available. */ diff --git a/src/hunk-session/types.ts b/src/hunk-session/types.ts index 5c4c6a6a..8d3ae1e7 100644 --- a/src/hunk-session/types.ts +++ b/src/hunk-session/types.ts @@ -7,7 +7,7 @@ import type { SessionSnapshot, SessionTargetInput, SessionTerminalMetadata, -} from "../session-broker/types"; +} from "@hunk/session-broker-core"; export type DiffSide = "old" | "new"; diff --git a/src/hunk-session/wire.test.ts b/src/hunk-session/wire.test.ts index 62a229c4..bf73ec57 100644 --- a/src/hunk-session/wire.test.ts +++ b/src/hunk-session/wire.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { SESSION_BROKER_REGISTRATION_VERSION } from "../session-broker/brokerWire"; +import { SESSION_BROKER_REGISTRATION_VERSION } from "@hunk/session-broker-core"; import { parseSessionRegistration, parseSessionSnapshot } from "./wire"; function createValidComment(overrides: Record = {}) { diff --git a/src/hunk-session/wire.ts b/src/hunk-session/wire.ts index de80fee0..c3622e99 100644 --- a/src/hunk-session/wire.ts +++ b/src/hunk-session/wire.ts @@ -3,7 +3,7 @@ import { brokerWireParsers, parseSessionRegistrationEnvelope, parseSessionSnapshotEnvelope, -} from "../session-broker/brokerWire"; +} from "@hunk/session-broker-core"; import type { HunkSessionRegistration, HunkSessionSnapshot } from "./types"; import type { HunkSessionInfo, diff --git a/src/session-broker/brokerClient.ts b/src/session-broker/brokerClient.ts index 60a86eea..59108e6e 100644 --- a/src/session-broker/brokerClient.ts +++ b/src/session-broker/brokerClient.ts @@ -3,7 +3,7 @@ import type { SessionRegistration, SessionServerMessage, SessionSnapshot, -} from "./types"; +} from "@hunk/session-broker-core"; import { SESSION_BROKER_SOCKET_PATH, resolveSessionBrokerConfig, diff --git a/src/session-broker/brokerServer.test.ts b/src/session-broker/brokerServer.test.ts index 950e9778..41e3521e 100644 --- a/src/session-broker/brokerServer.test.ts +++ b/src/session-broker/brokerServer.test.ts @@ -4,7 +4,7 @@ import { createTestSessionRegistration, createTestSessionSnapshot, } from "../../test/helpers/session-daemon-fixtures"; -import { SessionBrokerState } from "./brokerState"; +import { SessionBrokerState } from "@hunk/session-broker-core"; import { serveSessionBrokerDaemon } from "./brokerServer"; const originalHost = process.env.HUNK_MCP_HOST; diff --git a/src/session/commands.ts b/src/session/commands.ts index ad6e0386..b76d0c94 100644 --- a/src/session/commands.ts +++ b/src/session/commands.ts @@ -11,7 +11,7 @@ import { waitForSessionBrokerShutdown, } from "../session-broker/brokerLauncher"; import { resolveSessionBrokerConfig } from "../session-broker/brokerConfig"; -import { matchesSessionSelector, normalizeSessionSelector } from "../session-broker/selectors"; +import { matchesSessionSelector, normalizeSessionSelector } from "@hunk/session-broker-core"; import { createHttpHunkSessionCliClient, formatClearCommentsOutput, diff --git a/src/ui/AppHost.interactions.test.tsx b/src/ui/AppHost.interactions.test.tsx index 0eb3e5d3..a4268684 100644 --- a/src/ui/AppHost.interactions.test.tsx +++ b/src/ui/AppHost.interactions.test.tsx @@ -4,7 +4,7 @@ import { join } from "node:path"; import { describe, expect, mock, test } from "bun:test"; import { testRender } from "@opentui/react/test-utils"; import { act } from "react"; -import { SESSION_BROKER_REGISTRATION_VERSION } from "../session-broker/brokerWire"; +import { SESSION_BROKER_REGISTRATION_VERSION } from "@hunk/session-broker-core"; import type { HunkSessionBrokerClient, HunkSessionRegistration, diff --git a/test/helpers/session-daemon-fixtures.ts b/test/helpers/session-daemon-fixtures.ts index 8654113d..065a30a5 100644 --- a/test/helpers/session-daemon-fixtures.ts +++ b/test/helpers/session-daemon-fixtures.ts @@ -1,4 +1,4 @@ -import { SESSION_BROKER_REGISTRATION_VERSION } from "../../src/session-broker/brokerWire"; +import { SESSION_BROKER_REGISTRATION_VERSION } from "@hunk/session-broker-core"; import type { HunkSessionRegistration, HunkSessionSnapshot, diff --git a/tsconfig.json b/tsconfig.json index bb975c1f..93926f33 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,6 +13,9 @@ "noEmit": true, "types": ["bun", "react"], "baseUrl": ".", + "paths": { + "@hunk/session-broker-core": ["packages/session-broker-core/src/index.ts"] + }, "strict": true, "skipLibCheck": true, "noFallthroughCasesInSwitch": true, @@ -25,6 +28,7 @@ "src/**/*.ts", "src/**/*.tsx", "scripts/**/*.ts", + "packages/**/*.ts", "test/**/*.ts", "test/**/*.tsx", "benchmarks/**/*.ts" From 57b756c3744a7eb96828c06b2d416b02a263fc04 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Wed, 15 Apr 2026 19:20:08 -0400 Subject: [PATCH 2/8] feat(session-broker): add runtime-neutral broker daemon package --- bun.lock | 10 + package.json | 1 + packages/session-broker/package.json | 25 ++ packages/session-broker/src/broker.test.ts | 168 ++++++++ packages/session-broker/src/broker.ts | 178 +++++++++ .../session-broker/src/connection.test.ts | 184 +++++++++ packages/session-broker/src/connection.ts | 278 +++++++++++++ packages/session-broker/src/daemon.test.ts | 231 +++++++++++ packages/session-broker/src/daemon.ts | 367 ++++++++++++++++++ packages/session-broker/src/index.ts | 5 + packages/session-broker/src/types.ts | 89 +++++ tsconfig.json | 1 + 12 files changed, 1537 insertions(+) create mode 100644 packages/session-broker/package.json create mode 100644 packages/session-broker/src/broker.test.ts create mode 100644 packages/session-broker/src/broker.ts create mode 100644 packages/session-broker/src/connection.test.ts create mode 100644 packages/session-broker/src/connection.ts create mode 100644 packages/session-broker/src/daemon.test.ts create mode 100644 packages/session-broker/src/daemon.ts create mode 100644 packages/session-broker/src/index.ts create mode 100644 packages/session-broker/src/types.ts diff --git a/bun.lock b/bun.lock index da0641b7..881ccf43 100644 --- a/bun.lock +++ b/bun.lock @@ -12,6 +12,7 @@ "zod": "^4.3.6", }, "devDependencies": { + "@hunk/session-broker": "workspace:*", "@hunk/session-broker-core": "workspace:*", "@opentui/core": "^0.1.88", "@opentui/react": "^0.1.88", @@ -31,6 +32,13 @@ "react": "^19.2.4", }, }, + "packages/session-broker": { + "name": "@hunk/session-broker", + "version": "0.0.0", + "dependencies": { + "@hunk/session-broker-core": "workspace:*", + }, + }, "packages/session-broker-core": { "name": "@hunk/session-broker-core", "version": "0.0.0", @@ -41,6 +49,8 @@ "@hono/node-server": ["@hono/node-server@1.19.11", "", { "peerDependencies": { "hono": "^4" } }, "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g=="], + "@hunk/session-broker": ["@hunk/session-broker@workspace:packages/session-broker"], + "@hunk/session-broker-core": ["@hunk/session-broker-core@workspace:packages/session-broker-core"], "@jimp/core": ["@jimp/core@1.6.0", "", { "dependencies": { "@jimp/file-ops": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "await-to-js": "^3.0.0", "exif-parser": "^0.1.12", "file-type": "^16.0.0", "mime": "3" } }, "sha512-EQQlKU3s9QfdJqiSrZWNTxBs3rKXgO2W+GxNXDtwchF3a4IqxDheFX1ti+Env9hdJXDiYLp2jTRjlxhPthsk8w=="], diff --git a/package.json b/package.json index a7e49ccd..6b086b83 100644 --- a/package.json +++ b/package.json @@ -79,6 +79,7 @@ "zod": "^4.3.6" }, "devDependencies": { + "@hunk/session-broker": "workspace:*", "@hunk/session-broker-core": "workspace:*", "@opentui/core": "^0.1.88", "@opentui/react": "^0.1.88", diff --git a/packages/session-broker/package.json b/packages/session-broker/package.json new file mode 100644 index 00000000..0a957332 --- /dev/null +++ b/packages/session-broker/package.json @@ -0,0 +1,25 @@ +{ + "name": "@hunk/session-broker", + "version": "0.0.0", + "private": true, + "description": "Runtime-neutral session broker daemon and connection helpers built on top of @hunk/session-broker-core.", + "license": "MIT", + "files": [ + "src" + ], + "type": "module", + "sideEffects": false, + "exports": { + ".": { + "types": "./src/index.ts", + "import": "./src/index.ts" + } + }, + "dependencies": { + "@hunk/session-broker-core": "workspace:*" + }, + "engines": { + "bun": ">=1.0.0", + "node": ">=18" + } +} diff --git a/packages/session-broker/src/broker.test.ts b/packages/session-broker/src/broker.test.ts new file mode 100644 index 00000000..424ae90c --- /dev/null +++ b/packages/session-broker/src/broker.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, test } from "bun:test"; +import { + SESSION_BROKER_REGISTRATION_VERSION, + brokerWireParsers, + parseSessionRegistrationEnvelope, + parseSessionSnapshotEnvelope, + type SessionRegistration, + type SessionServerMessage, + type SessionSnapshot, +} from "@hunk/session-broker-core"; +import { SessionBroker } from "./broker"; + +interface TestSessionInfo { + title: string; + files: string[]; +} + +interface TestSessionState { + selectedIndex: number; + noteCount: number; +} + +type TestRegistration = SessionRegistration; +type TestSnapshot = SessionSnapshot; + +type TestServerMessage = + | SessionServerMessage<"annotate", { filePath: string; summary: string }> + | SessionServerMessage<"reload_view", { ref: string }>; + +function parseInfo(value: unknown): TestSessionInfo | null { + const record = brokerWireParsers.asRecord(value); + if (!record || !Array.isArray(record.files)) { + return null; + } + + const title = brokerWireParsers.parseRequiredString(record.title); + const files = record.files.filter((entry): entry is string => typeof entry === "string"); + if (title === null || files.length !== record.files.length) { + return null; + } + + return { title, files }; +} + +function parseState(value: unknown): TestSessionState | null { + const record = brokerWireParsers.asRecord(value); + if (!record) { + return null; + } + + const selectedIndex = brokerWireParsers.parseNonNegativeInt(record.selectedIndex); + const noteCount = brokerWireParsers.parseNonNegativeInt(record.noteCount); + if (selectedIndex === null || noteCount === null) { + return null; + } + + return { selectedIndex, noteCount }; +} + +function createBroker() { + return new SessionBroker({ + parseRegistration: (value) => parseSessionRegistrationEnvelope(value, parseInfo), + parseSnapshot: (value) => parseSessionSnapshotEnvelope(value, parseState), + }); +} + +function createRegistration( + overrides: Partial & { info?: Partial } = {}, +): TestRegistration { + return { + registrationVersion: SESSION_BROKER_REGISTRATION_VERSION, + sessionId: "session-1", + pid: 123, + cwd: "/repo", + repoRoot: "/repo", + launchedAt: "2026-04-15T00:00:00.000Z", + ...overrides, + info: { + title: "repo working tree", + files: ["src/example.ts"], + ...overrides.info, + }, + }; +} + +function createSnapshot( + overrides: Partial & { updatedAt?: string } = {}, +): TestSnapshot { + const { updatedAt = "2026-04-15T00:00:00.000Z", ...stateOverrides } = overrides; + + return { + updatedAt, + state: { + selectedIndex: 0, + noteCount: 0, + ...stateOverrides, + }, + }; +} + +describe("session broker wrapper", () => { + test("stores raw registrations and snapshots without a custom projection adapter", () => { + const broker = createBroker(); + const connection = { send() {} }; + + expect(broker.registerSession(connection, createRegistration(), createSnapshot())).toBe(true); + + expect(broker.listSessions()).toEqual([ + { + sessionId: "session-1", + cwd: "/repo", + repoRoot: "/repo", + title: "repo working tree", + connectedAt: expect.any(String), + lastSeenAt: expect.any(String), + registration: createRegistration(), + snapshot: createSnapshot(), + }, + ]); + }); + + test("rejects incompatible registrations using the shared envelope parser", () => { + const broker = createBroker(); + const connection = { send() {} }; + + expect( + broker.registerSession( + connection, + { + ...createRegistration(), + registrationVersion: 0, + }, + createSnapshot(), + ), + ).toBe(false); + expect(broker.listSessions()).toEqual([]); + }); + + test("dispatches one raw command and resolves the async result", async () => { + const broker = createBroker(); + const sent: string[] = []; + const connection = { + send(data: string) { + sent.push(data); + }, + }; + + broker.registerSession(connection, createRegistration(), createSnapshot()); + + const pending = broker.dispatchCommand<{ ok: true }, "annotate">({ + selector: { sessionId: "session-1" }, + command: "annotate", + input: { filePath: "src/example.ts", summary: "Review note" }, + timeoutMessage: "Timed out waiting for annotate.", + }); + + const outgoing = JSON.parse(sent[0]!) as { requestId: string; command: string }; + expect(outgoing.command).toBe("annotate"); + + broker.handleCommandResult({ + requestId: outgoing.requestId, + ok: true, + result: { ok: true }, + }); + + await expect(pending).resolves.toEqual({ ok: true }); + }); +}); diff --git a/packages/session-broker/src/broker.ts b/packages/session-broker/src/broker.ts new file mode 100644 index 00000000..1735e37e --- /dev/null +++ b/packages/session-broker/src/broker.ts @@ -0,0 +1,178 @@ +import { + SessionBrokerState, + type SessionBrokerEntry, + type SessionRegistration, + type SessionServerMessage, + type SessionSnapshot, + type SessionTargetInput, + type SessionTargetSelector, + type UpdateSnapshotResult, +} from "@hunk/session-broker-core"; + +/** Minimal socket shape the broker needs in order to target one live session. */ +export interface SessionBrokerPeer { + send(data: string): unknown; + close?(code?: number, reason?: string): unknown; +} + +/** One raw live session record with the original registration and snapshot payloads intact. */ +export interface SessionBrokerRecord { + sessionId: string; + cwd: string; + repoRoot?: string; + title: string; + connectedAt: string; + lastSeenAt: string; + registration: SessionRegistration; + snapshot: SessionSnapshot; +} + +export interface SessionBrokerOptions { + parseRegistration: (value: unknown) => SessionRegistration | null; + parseSnapshot: (value: unknown) => SessionSnapshot | null; + describeSession?: ( + registration: SessionRegistration, + snapshot: SessionSnapshot, + ) => string; +} + +function defaultSessionTitle(registration: SessionRegistration) { + const info = registration.info; + if (info && typeof info === "object") { + const title = (info as { title?: unknown }).title; + if (typeof title === "string" && title.length > 0) { + return title; + } + } + + return registration.sessionId; +} + +/** + * Wrap the lower-level broker core in one raw-session API so apps do not need to define a large + * projection adapter just to store registrations, snapshots, and command routing state. + */ +export class SessionBroker< + Info = unknown, + State = unknown, + ServerMessage extends SessionServerMessage = SessionServerMessage, + CommandResult = unknown, +> { + private readonly state: SessionBrokerState< + Info, + State, + ServerMessage, + CommandResult, + SessionBrokerRecord, + SessionBrokerRecord, + SessionBrokerRecord, + never + >; + + private readonly describeSession: NonNullable< + SessionBrokerOptions["describeSession"] + >; + + constructor(options: SessionBrokerOptions) { + this.describeSession = + options.describeSession ?? ((registration, _snapshot) => defaultSessionTitle(registration)); + + this.state = new SessionBrokerState({ + parseRegistration: options.parseRegistration, + parseSnapshot: options.parseSnapshot, + buildListedSession: (entry) => this.buildRecord(entry), + buildSelectedContext: (session) => session, + buildSessionReview: (entry) => this.buildRecord(entry), + listComments: () => [], + }); + } + + listSessions() { + return this.state.listSessions(); + } + + getSession(selector: SessionTargetSelector) { + return this.state.getSession(selector); + } + + getSessionCount() { + return this.state.getSessionCount(); + } + + getPendingCommandCount() { + return this.state.getPendingCommandCount(); + } + + registerSession( + connection: SessionBrokerPeer, + registrationInput: unknown, + snapshotInput: unknown, + ) { + return this.state.registerSession(connection, registrationInput, snapshotInput); + } + + updateSnapshot(sessionId: string, snapshotInput: unknown): UpdateSnapshotResult { + return this.state.updateSnapshot(sessionId, snapshotInput); + } + + markSessionSeen(sessionId: string) { + this.state.markSessionSeen(sessionId); + } + + unregisterConnection(connection: SessionBrokerPeer) { + this.state.unregisterSocket(connection); + } + + pruneStaleSessions({ ttlMs, now }: { ttlMs: number; now?: number }) { + return this.state.pruneStaleSessions({ ttlMs, now }); + } + + dispatchCommand({ + selector, + command, + input, + timeoutMessage, + timeoutMs, + }: { + selector: SessionTargetInput; + command: CommandName; + input: Extract["input"]; + timeoutMessage: string; + timeoutMs?: number; + }) { + return this.state.dispatchCommand({ + selector, + command, + input, + timeoutMessage, + timeoutMs, + }); + } + + handleCommandResult(message: { + requestId: string; + ok: boolean; + result?: CommandResult; + error?: string; + }) { + this.state.handleCommandResult(message); + } + + shutdown(error = new Error("The session broker shut down.")) { + this.state.shutdown(error); + } + + /** Build one raw record from the core entry plus a host-defined title/label. */ + private buildRecord(entry: SessionBrokerEntry): SessionBrokerRecord { + return { + sessionId: entry.registration.sessionId, + cwd: entry.registration.cwd, + repoRoot: entry.registration.repoRoot, + title: this.describeSession(entry.registration, entry.snapshot), + connectedAt: entry.connectedAt, + lastSeenAt: entry.lastSeenAt, + registration: entry.registration, + snapshot: entry.snapshot, + }; + } +} diff --git a/packages/session-broker/src/connection.test.ts b/packages/session-broker/src/connection.test.ts new file mode 100644 index 00000000..3d6f3443 --- /dev/null +++ b/packages/session-broker/src/connection.test.ts @@ -0,0 +1,184 @@ +import { describe, expect, test } from "bun:test"; +import type { + SessionRegistration, + SessionServerMessage, + SessionSnapshot, +} from "@hunk/session-broker-core"; +import { SESSION_BROKER_REGISTRATION_VERSION } from "@hunk/session-broker-core"; +import { createSessionBrokerConnection } from "./connection"; +import type { SessionBrokerSocketLike } from "./types"; + +interface TestSessionInfo { + title: string; +} + +interface TestSessionState { + selectedIndex: number; +} + +type TestServerMessage = SessionServerMessage<"annotate", { summary: string }>; + +class TestSocket implements SessionBrokerSocketLike { + readyState = 0; + sent: string[] = []; + onopen: (() => void) | null = null; + onmessage: ((event: { data: unknown }) => void) | null = null; + onclose: ((event: { code: number; reason: string }) => void) | null = null; + onerror: (() => void) | null = null; + + send(data: string) { + this.sent.push(data); + } + + close() { + this.emitClose(); + } + + emitOpen() { + this.readyState = 1; + this.onopen?.(); + } + + emitMessage(data: unknown) { + this.onmessage?.({ data }); + } + + emitClose(code = 1000, reason = "") { + this.readyState = 3; + this.onclose?.({ code, reason }); + } +} + +function createRegistration(): SessionRegistration { + return { + registrationVersion: SESSION_BROKER_REGISTRATION_VERSION, + sessionId: "session-1", + pid: 123, + cwd: "/repo", + launchedAt: "2026-04-15T00:00:00.000Z", + info: { title: "repo working tree" }, + }; +} + +function createSnapshot(): SessionSnapshot { + return { + updatedAt: "2026-04-15T00:00:00.000Z", + state: { selectedIndex: 0 }, + }; +} + +describe("session broker connection", () => { + test("registers on open and sends later snapshot updates", () => { + const sockets: TestSocket[] = []; + const connection = createSessionBrokerConnection< + TestSessionInfo, + TestSessionState, + TestSocket, + TestServerMessage, + { ok: true } + >({ + url: "ws://broker.test/session", + createSocket: () => { + const socket = new TestSocket(); + sockets.push(socket); + return socket; + }, + registration: createRegistration(), + snapshot: createSnapshot(), + }); + + connection.start(); + sockets[0]?.emitOpen(); + + const registerMessage = JSON.parse(sockets[0]!.sent[0]!) as { type: string }; + expect(registerMessage.type).toBe("register"); + + connection.updateSnapshot({ + updatedAt: "2026-04-15T00:00:01.000Z", + state: { selectedIndex: 1 }, + }); + + const snapshotMessage = JSON.parse(sockets[0]!.sent[1]!) as { type: string; snapshot: unknown }; + expect(snapshotMessage.type).toBe("snapshot"); + expect(snapshotMessage.snapshot).toEqual({ + updatedAt: "2026-04-15T00:00:01.000Z", + state: { selectedIndex: 1 }, + }); + }); + + test("queues broker commands until the app bridge is ready", async () => { + const socket = new TestSocket(); + const connection = createSessionBrokerConnection< + TestSessionInfo, + TestSessionState, + TestSocket, + TestServerMessage, + { ok: true } + >({ + url: "ws://broker.test/session", + createSocket: () => socket, + registration: createRegistration(), + snapshot: createSnapshot(), + }); + + connection.start(); + socket.emitOpen(); + socket.emitMessage( + JSON.stringify({ + type: "command", + requestId: "request-1", + command: "annotate", + input: { summary: "Review note" }, + }), + ); + + connection.setBridge({ + dispatchCommand: async () => ({ ok: true }), + }); + + await Bun.sleep(0); + const resultMessage = JSON.parse(socket.sent[socket.sent.length - 1]!) as { + type: string; + ok: boolean; + }; + expect(resultMessage).toMatchObject({ type: "command-result", ok: true }); + }); + + test("reconnects after socket close unless a close directive disables it", async () => { + const sockets: TestSocket[] = []; + const warnings: string[] = []; + const connection = createSessionBrokerConnection< + TestSessionInfo, + TestSessionState, + TestSocket, + TestServerMessage, + { ok: true } + >({ + url: "ws://broker.test/session", + createSocket: () => { + const socket = new TestSocket(); + sockets.push(socket); + return socket; + }, + registration: createRegistration(), + snapshot: createSnapshot(), + reconnectDelayMs: 5, + resolveClose: (event) => + event.reason === "stop" + ? { reconnect: false, warning: "Stopped reconnecting." } + : { reconnect: true }, + onWarning: (message) => warnings.push(message), + }); + + connection.start(); + sockets[0]?.emitOpen(); + sockets[0]?.emitClose(1008, "retry"); + await Bun.sleep(15); + expect(sockets).toHaveLength(2); + + sockets[1]?.emitClose(1008, "stop"); + await Bun.sleep(15); + expect(warnings).toEqual(["Stopped reconnecting."]); + expect(sockets).toHaveLength(2); + }); +}); diff --git a/packages/session-broker/src/connection.ts b/packages/session-broker/src/connection.ts new file mode 100644 index 00000000..8ab32f2f --- /dev/null +++ b/packages/session-broker/src/connection.ts @@ -0,0 +1,278 @@ +import type { + SessionClientMessage, + SessionRegistration, + SessionServerMessage, + SessionSnapshot, +} from "@hunk/session-broker-core"; +import type { + SessionBrokerConnectionCloseDirective, + SessionBrokerSocketCloseEvent, + SessionBrokerSocketLike, +} from "./types"; + +const DEFAULT_RECONNECT_DELAY_MS = 3_000; +const DEFAULT_HEARTBEAT_INTERVAL_MS = 10_000; +const DEFAULT_SOCKET_OPEN_STATE = 1; + +export interface SessionBrokerConnectionBridge< + ServerMessage extends SessionServerMessage = SessionServerMessage, + Result = unknown, +> { + dispatchCommand: (message: ServerMessage) => Promise; +} + +export interface SessionBrokerConnectionOptions< + Info = unknown, + State = unknown, + Socket extends SessionBrokerSocketLike = SessionBrokerSocketLike, + ServerMessage extends SessionServerMessage = SessionServerMessage, + Result = unknown, +> { + url: string; + createSocket: (url: string) => Socket; + registration: SessionRegistration; + snapshot: SessionSnapshot; + bridge?: SessionBrokerConnectionBridge | null; + heartbeatIntervalMs?: number; + reconnectDelayMs?: number; + openState?: number; + resolveClose?: (event: SessionBrokerSocketCloseEvent) => SessionBrokerConnectionCloseDirective; + onWarning?: (message: string) => void; +} + +/** + * Keep one live app session connected to a broker websocket while staying agnostic about which + * runtime or websocket implementation created the underlying socket. + */ +export class SessionBrokerConnection< + Info = unknown, + State = unknown, + Socket extends SessionBrokerSocketLike = SessionBrokerSocketLike, + ServerMessage extends SessionServerMessage = SessionServerMessage, + Result = unknown, +> { + private socket: Socket | null = null; + private bridge: SessionBrokerConnectionBridge | null; + private queuedMessages: ServerMessage[] = []; + private reconnectTimer: ReturnType | null = null; + private heartbeatTimer: ReturnType | null = null; + private stopped = false; + private registration: SessionRegistration; + private snapshot: SessionSnapshot; + + constructor( + private readonly options: SessionBrokerConnectionOptions< + Info, + State, + Socket, + ServerMessage, + Result + >, + ) { + this.bridge = options.bridge ?? null; + this.registration = options.registration; + this.snapshot = options.snapshot; + } + + start() { + if (this.stopped || this.socket) { + return; + } + + this.connect(); + } + + stop() { + this.stopped = true; + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + + this.stopHeartbeat(); + this.socket?.close(); + this.socket = null; + } + + getRegistration() { + return this.registration; + } + + setBridge(bridge: SessionBrokerConnectionBridge | null) { + this.bridge = bridge; + void this.flushQueuedMessages(); + } + + replaceSession(registration: SessionRegistration, snapshot: SessionSnapshot) { + this.registration = registration; + this.snapshot = snapshot; + this.send({ + type: "register", + registration, + snapshot, + }); + } + + updateSnapshot(snapshot: SessionSnapshot) { + this.snapshot = snapshot; + this.send({ + type: "snapshot", + sessionId: this.registration.sessionId, + snapshot, + }); + } + + private connect() { + if (this.stopped || this.socket) { + return; + } + + const socket = this.options.createSocket(this.options.url); + this.socket = socket; + + socket.onopen = () => { + this.startHeartbeat(); + this.send({ + type: "register", + registration: this.registration, + snapshot: this.snapshot, + }); + void this.flushQueuedMessages(); + }; + + socket.onmessage = (event) => { + if (typeof event.data !== "string") { + return; + } + + let parsed: ServerMessage; + try { + parsed = JSON.parse(event.data) as ServerMessage; + } catch { + return; + } + + void this.handleServerMessage(parsed); + }; + + socket.onclose = (event) => { + if (this.socket === socket) { + this.socket = null; + } + + this.stopHeartbeat(); + if (this.stopped) { + return; + } + + const directive = this.options.resolveClose?.(event) ?? { reconnect: true }; + if (directive.warning) { + this.options.onWarning?.(directive.warning); + } + + if (directive.reconnect !== false) { + this.scheduleReconnect(); + } + }; + + socket.onerror = () => { + socket.close(); + }; + } + + private scheduleReconnect(delayMs = this.options.reconnectDelayMs ?? DEFAULT_RECONNECT_DELAY_MS) { + if (this.reconnectTimer || this.stopped) { + return; + } + + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = null; + this.connect(); + }, delayMs); + + this.reconnectTimer.unref?.(); + } + + private startHeartbeat() { + if (this.heartbeatTimer) { + return; + } + + this.heartbeatTimer = setInterval(() => { + this.send({ + type: "heartbeat", + sessionId: this.registration.sessionId, + }); + }, this.options.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS); + + this.heartbeatTimer.unref?.(); + } + + private stopHeartbeat() { + if (!this.heartbeatTimer) { + return; + } + + clearInterval(this.heartbeatTimer); + this.heartbeatTimer = null; + } + + private send(message: SessionClientMessage) { + if ( + !this.socket || + this.socket.readyState !== (this.options.openState ?? DEFAULT_SOCKET_OPEN_STATE) + ) { + return; + } + + this.socket.send(JSON.stringify(message)); + } + + private async handleServerMessage(message: ServerMessage) { + if (!this.bridge) { + this.queuedMessages.push(message); + return; + } + + try { + const result = await this.bridge.dispatchCommand(message); + this.send({ + type: "command-result", + requestId: message.requestId, + ok: true, + result, + }); + } catch (error) { + this.send({ + type: "command-result", + requestId: message.requestId, + ok: false, + error: error instanceof Error ? error.message : "Unknown broker connection error.", + }); + } + } + + private async flushQueuedMessages() { + if (!this.bridge || this.queuedMessages.length === 0) { + return; + } + + const queued = [...this.queuedMessages]; + this.queuedMessages = []; + + for (const message of queued) { + await this.handleServerMessage(message); + } + } +} + +/** Create one runtime-neutral session connection around a browser-like websocket factory. */ +export function createSessionBrokerConnection< + Info = unknown, + State = unknown, + Socket extends SessionBrokerSocketLike = SessionBrokerSocketLike, + ServerMessage extends SessionServerMessage = SessionServerMessage, + Result = unknown, +>(options: SessionBrokerConnectionOptions) { + return new SessionBrokerConnection(options); +} diff --git a/packages/session-broker/src/daemon.test.ts b/packages/session-broker/src/daemon.test.ts new file mode 100644 index 00000000..056118b4 --- /dev/null +++ b/packages/session-broker/src/daemon.test.ts @@ -0,0 +1,231 @@ +import { describe, expect, test } from "bun:test"; +import { + SESSION_BROKER_REGISTRATION_VERSION, + brokerWireParsers, + parseSessionRegistrationEnvelope, + parseSessionSnapshotEnvelope, + type SessionRegistration, + type SessionServerMessage, + type SessionSnapshot, +} from "@hunk/session-broker-core"; +import { SessionBroker } from "./broker"; +import { createSessionBrokerDaemon } from "./daemon"; + +interface TestSessionInfo { + title: string; +} + +interface TestSessionState { + selectedIndex: number; +} + +type TestRegistration = SessionRegistration; +type TestSnapshot = SessionSnapshot; +type TestServerMessage = SessionServerMessage<"annotate", { summary: string }>; + +function parseInfo(value: unknown): TestSessionInfo | null { + const record = brokerWireParsers.asRecord(value); + if (!record) { + return null; + } + + const title = brokerWireParsers.parseRequiredString(record.title); + return title === null ? null : { title }; +} + +function parseState(value: unknown): TestSessionState | null { + const record = brokerWireParsers.asRecord(value); + if (!record) { + return null; + } + + const selectedIndex = brokerWireParsers.parseNonNegativeInt(record.selectedIndex); + return selectedIndex === null ? null : { selectedIndex }; +} + +function createBroker() { + return new SessionBroker({ + parseRegistration: (value) => parseSessionRegistrationEnvelope(value, parseInfo), + parseSnapshot: (value) => parseSessionSnapshotEnvelope(value, parseState), + }); +} + +function createRegistration(overrides: Partial = {}): TestRegistration { + return { + registrationVersion: SESSION_BROKER_REGISTRATION_VERSION, + sessionId: "session-1", + pid: 123, + cwd: "/repo", + repoRoot: "/repo", + launchedAt: "2026-04-15T00:00:00.000Z", + info: { title: "repo working tree" }, + ...overrides, + }; +} + +function createSnapshot( + overrides: Partial & { updatedAt?: string } = {}, +): TestSnapshot { + const { updatedAt = "2026-04-15T00:00:00.000Z", ...stateOverrides } = overrides; + + return { + updatedAt, + state: { + selectedIndex: 0, + ...stateOverrides, + }, + }; +} + +function createConnection() { + const sent: string[] = []; + let closed: { code?: number; reason?: string } | null = null; + + return { + sent, + get closed() { + return closed; + }, + connection: { + send(data: string) { + sent.push(data); + }, + close(code?: number, reason?: string) { + closed = { code, reason }; + }, + }, + }; +} + +describe("session broker daemon", () => { + test("serves health and raw list/get requests", async () => { + const daemon = createSessionBrokerDaemon({ + broker: createBroker(), + capabilities: { version: 1, name: "test-broker" }, + }); + const { connection } = createConnection(); + daemon.handleConnectionMessage( + connection, + JSON.stringify({ + type: "register", + registration: createRegistration(), + snapshot: createSnapshot(), + }), + ); + + await expect( + daemon.handleRequest(new Request("http://broker.test/health")), + ).resolves.toBeInstanceOf(Response); + await expect( + daemon.handleRequest(new Request("http://broker.test/broker/capabilities")), + ).resolves.toBeInstanceOf(Response); + + const listResponse = await daemon.handleRequest( + new Request("http://broker.test/broker", { + method: "POST", + body: JSON.stringify({ action: "list" }), + }), + ); + expect(listResponse).toBeInstanceOf(Response); + await expect(listResponse?.json()).resolves.toMatchObject({ + sessions: [{ sessionId: "session-1", title: "repo working tree" }], + }); + + const getResponse = await daemon.handleRequest( + new Request("http://broker.test/broker", { + method: "POST", + body: JSON.stringify({ action: "get", selector: { sessionId: "session-1" } }), + }), + ); + await expect(getResponse?.json()).resolves.toMatchObject({ + session: { + registration: { sessionId: "session-1" }, + snapshot: { state: { selectedIndex: 0 } }, + }, + }); + + daemon.shutdown(); + }); + + test("dispatches one raw command through the broker API", async () => { + const daemon = createSessionBrokerDaemon({ + broker: createBroker(), + capabilities: { version: 1 }, + }); + const session = createConnection(); + const { connection, sent } = session; + daemon.handleConnectionMessage( + connection, + JSON.stringify({ + type: "register", + registration: createRegistration(), + snapshot: createSnapshot(), + }), + ); + + const pendingResponse = daemon.handleRequest( + new Request("http://broker.test/broker", { + method: "POST", + body: JSON.stringify({ + action: "dispatch", + selector: { sessionId: "session-1" }, + command: "annotate", + input: { summary: "Review note" }, + }), + }), + ); + + await Bun.sleep(0); + const outgoing = JSON.parse(sent[sent.length - 1]!) as { requestId: string; command: string }; + expect(outgoing.command).toBe("annotate"); + + daemon.handleConnectionMessage( + connection, + JSON.stringify({ + type: "command-result", + requestId: outgoing.requestId, + ok: true, + result: { applied: true }, + }), + ); + + const response = await pendingResponse; + await expect(response?.json()).resolves.toEqual({ result: { applied: true } }); + daemon.shutdown(); + }); + + test("closes incompatible snapshot updates with a specific reason", () => { + const daemon = createSessionBrokerDaemon({ + broker: createBroker(), + capabilities: { version: 1 }, + }); + const session = createConnection(); + const { connection } = session; + + daemon.handleConnectionMessage( + connection, + JSON.stringify({ + type: "snapshot", + sessionId: "missing-session", + snapshot: createSnapshot(), + }), + ); + + expect(session.closed).toEqual({ + code: 1008, + reason: "Session not registered with broker.", + }); + daemon.shutdown(); + }); + + test("requests shutdown after the idle timeout when no sessions remain", async () => { + const daemon = createSessionBrokerDaemon({ + broker: createBroker(), + idleTimeoutMs: 20, + staleSessionSweepIntervalMs: 10, + capabilities: { version: 1 }, + }); + + await expect(daemon.stopped).resolves.toBeUndefined(); + }); +}); diff --git a/packages/session-broker/src/daemon.ts b/packages/session-broker/src/daemon.ts new file mode 100644 index 00000000..25783fd8 --- /dev/null +++ b/packages/session-broker/src/daemon.ts @@ -0,0 +1,367 @@ +import type { SessionServerMessage } from "@hunk/session-broker-core"; +import type { SessionBroker, SessionBrokerPeer, SessionBrokerRecord } from "./broker"; +import { + DEFAULT_SESSION_BROKER_API_PATH, + DEFAULT_SESSION_BROKER_CAPABILITIES_PATH, + DEFAULT_SESSION_BROKER_HEALTH_PATH, + DEFAULT_SESSION_BROKER_SOCKET_PATH, + type SessionBrokerCapabilities, + type SessionBrokerDaemonRequest, + type SessionBrokerDaemonResponse, + type SessionBrokerHealth, + type SessionBrokerHttpPaths, +} from "./types"; + +const DEFAULT_STALE_SESSION_TTL_MS = 45_000; +const DEFAULT_STALE_SESSION_SWEEP_INTERVAL_MS = 15_000; +const DEFAULT_IDLE_TIMEOUT_MS = 60_000; +const INCOMPATIBLE_PAYLOAD_CLOSE_CODE = 1008; + +export interface SessionBrokerDaemonOptions< + Info = unknown, + State = unknown, + ServerMessage extends SessionServerMessage = SessionServerMessage, + CommandResult = unknown, +> { + broker: SessionBroker; + capabilities?: SessionBrokerCapabilities; + paths?: Partial; + idleTimeoutMs?: number; + staleSessionTtlMs?: number; + staleSessionSweepIntervalMs?: number; +} + +function jsonError(message: string, status = 400) { + return Response.json({ error: message }, { status }); +} + +function parseSocketEnvelope(message: string) { + let parsed: unknown; + try { + parsed = JSON.parse(message); + } catch { + return null; + } + + if (!parsed || typeof parsed !== "object") { + return null; + } + + const type = (parsed as { type?: unknown }).type; + return typeof type === "string" + ? (parsed as object as { type: string } & Record) + : null; +} + +async function parseJsonRequest( + request: Request, +) { + try { + return (await request.json()) as SessionBrokerDaemonRequest; + } catch { + throw new Error("Expected one JSON request body."); + } +} + +function defaultTimeoutMessage(command: string) { + return `Timed out waiting for the session to handle ${command}.`; +} + +/** + * Runtime-neutral daemon engine that owns broker lifecycle, health, stale pruning, and raw HTTP + * plus websocket message handling without choosing Bun, Node, or any other server implementation. + */ +export class SessionBrokerDaemon< + Info = unknown, + State = unknown, + ServerMessage extends SessionServerMessage = SessionServerMessage, + CommandResult = unknown, +> { + readonly paths: SessionBrokerHttpPaths; + readonly stopped: Promise; + + private readonly startedAt = Date.now(); + private readonly capabilities: SessionBrokerCapabilities; + private readonly idleTimeoutMs: number; + private readonly staleSessionTtlMs: number; + private readonly staleSessionSweepIntervalMs: number; + private lastActivityAt = this.startedAt; + private sweepTimer: ReturnType | null = null; + private idleTimer: ReturnType | null = null; + private shuttingDown = false; + private resolveStopped: (() => void) | null = null; + + constructor( + private readonly broker: SessionBroker, + options: Omit< + SessionBrokerDaemonOptions, + "broker" + > = {}, + ) { + this.paths = { + health: options.paths?.health ?? DEFAULT_SESSION_BROKER_HEALTH_PATH, + api: options.paths?.api ?? DEFAULT_SESSION_BROKER_API_PATH, + capabilities: options.paths?.capabilities ?? DEFAULT_SESSION_BROKER_CAPABILITIES_PATH, + socket: options.paths?.socket ?? DEFAULT_SESSION_BROKER_SOCKET_PATH, + }; + this.capabilities = options.capabilities ?? { version: 1 }; + this.idleTimeoutMs = options.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS; + this.staleSessionTtlMs = options.staleSessionTtlMs ?? DEFAULT_STALE_SESSION_TTL_MS; + this.staleSessionSweepIntervalMs = + options.staleSessionSweepIntervalMs ?? DEFAULT_STALE_SESSION_SWEEP_INTERVAL_MS; + this.stopped = new Promise((resolve) => { + this.resolveStopped = resolve; + }); + + this.startLifecycle(); + } + + listSessions() { + return this.broker.listSessions(); + } + + getSession( + selector: Parameters["getSession"]>[0], + ) { + return this.broker.getSession(selector); + } + + getHealth(): SessionBrokerHealth { + return { + ok: true, + pid: process.pid, + sessions: this.broker.getSessionCount(), + pendingCommands: this.broker.getPendingCommandCount(), + startedAt: new Date(this.startedAt).toISOString(), + uptimeMs: Date.now() - this.startedAt, + staleSessionTtlMs: this.staleSessionTtlMs, + paths: this.paths, + }; + } + + matchesSocketPath(pathname: string) { + return pathname === this.paths.socket; + } + + async handleRequest(request: Request) { + const url = new URL(request.url); + + if (url.pathname === this.paths.health) { + const removed = this.broker.pruneStaleSessions({ ttlMs: this.staleSessionTtlMs }); + if (removed > 0) { + this.noteActivity(); + } + + return Response.json(this.getHealth()); + } + + if (url.pathname === this.paths.capabilities) { + this.noteActivity(); + return Response.json(this.capabilities); + } + + if (url.pathname === this.paths.api) { + this.noteActivity(); + return this.handleApiRequest(request); + } + + return null; + } + + handleConnectionMessage(connection: SessionBrokerPeer, message: string) { + const parsed = parseSocketEnvelope(message); + if (!parsed) { + return; + } + + switch (parsed.type) { + case "register": { + if (!this.broker.registerSession(connection, parsed.registration, parsed.snapshot)) { + connection.close?.(INCOMPATIBLE_PAYLOAD_CLOSE_CODE, "Incompatible session registration."); + return; + } + + this.noteActivity(); + break; + } + case "snapshot": { + if (typeof parsed.sessionId !== "string") { + return; + } + + const updateResult = this.broker.updateSnapshot(parsed.sessionId, parsed.snapshot); + if (updateResult === "not-found") { + connection.close?.( + INCOMPATIBLE_PAYLOAD_CLOSE_CODE, + "Session not registered with broker.", + ); + return; + } + + if (updateResult === "invalid") { + connection.close?.(INCOMPATIBLE_PAYLOAD_CLOSE_CODE, "Incompatible session snapshot."); + return; + } + + this.noteActivity(); + break; + } + case "heartbeat": { + if (typeof parsed.sessionId !== "string") { + return; + } + + this.broker.markSessionSeen(parsed.sessionId); + this.noteActivity(); + break; + } + case "command-result": { + if (typeof parsed.requestId !== "string" || typeof parsed.ok !== "boolean") { + return; + } + + this.broker.handleCommandResult({ + requestId: parsed.requestId, + ok: parsed.ok, + result: parsed.result as CommandResult | undefined, + error: typeof parsed.error === "string" ? parsed.error : undefined, + }); + this.noteActivity(); + break; + } + } + } + + handleConnectionClose(connection: SessionBrokerPeer) { + this.broker.unregisterConnection(connection); + this.noteActivity(); + } + + shutdown(error = new Error("The session broker daemon shut down.")) { + if (this.shuttingDown) { + return; + } + + this.shuttingDown = true; + if (this.sweepTimer) { + clearInterval(this.sweepTimer); + this.sweepTimer = null; + } + + if (this.idleTimer) { + clearTimeout(this.idleTimer); + this.idleTimer = null; + } + + this.broker.shutdown(error); + this.resolveStopped?.(); + this.resolveStopped = null; + } + + private startLifecycle() { + this.sweepTimer = setInterval(() => { + const removed = this.broker.pruneStaleSessions({ ttlMs: this.staleSessionTtlMs }); + if (removed > 0) { + this.noteActivity(); + } + }, this.staleSessionSweepIntervalMs); + + this.sweepTimer.unref?.(); + this.refreshIdleTimer(); + } + + private hasActiveWork() { + return this.broker.getSessionCount() > 0 || this.broker.getPendingCommandCount() > 0; + } + + private noteActivity() { + this.lastActivityAt = Date.now(); + this.refreshIdleTimer(); + } + + private refreshIdleTimer() { + if (this.idleTimer) { + clearTimeout(this.idleTimer); + this.idleTimer = null; + } + + if (this.shuttingDown || this.idleTimeoutMs <= 0 || this.hasActiveWork()) { + return; + } + + const idleForMs = Date.now() - this.lastActivityAt; + const remainingMs = Math.max(0, this.idleTimeoutMs - idleForMs); + + this.idleTimer = setTimeout(() => { + this.idleTimer = null; + + if (this.shuttingDown || this.hasActiveWork()) { + return; + } + + if (Date.now() - this.lastActivityAt < this.idleTimeoutMs) { + this.refreshIdleTimer(); + return; + } + + this.shutdown(); + }, remainingMs); + + this.idleTimer.unref?.(); + } + + private async handleApiRequest(request: Request) { + if (request.method !== "POST") { + return jsonError("Broker API requests must use POST.", 405); + } + + try { + const input = await parseJsonRequest(request); + let response: SessionBrokerDaemonResponse; + + switch (input.action) { + case "list": + response = { sessions: this.broker.listSessions() }; + break; + case "get": + response = { session: this.broker.getSession(input.selector) }; + break; + case "dispatch": + response = { + result: await this.broker.dispatchCommand({ + selector: input.selector, + command: input.command, + input: input.input as Extract< + ServerMessage, + { command: ServerMessage["command"] } + >["input"], + timeoutMessage: input.timeoutMessage ?? defaultTimeoutMessage(input.command), + timeoutMs: input.timeoutMs, + }), + }; + break; + default: + throw new Error("Unknown broker API action."); + } + + return Response.json(response); + } catch (error) { + return jsonError(error instanceof Error ? error.message : "Unknown broker API error."); + } + } +} + +/** Create one runtime-neutral broker daemon engine around an existing session broker. */ +export function createSessionBrokerDaemon< + Info = unknown, + State = unknown, + ServerMessage extends SessionServerMessage = SessionServerMessage, + CommandResult = unknown, +>(options: SessionBrokerDaemonOptions) { + return new SessionBrokerDaemon(options.broker, options); +} + +export type SessionBrokerSession = SessionBrokerRecord< + Info, + State +>; diff --git a/packages/session-broker/src/index.ts b/packages/session-broker/src/index.ts new file mode 100644 index 00000000..43301349 --- /dev/null +++ b/packages/session-broker/src/index.ts @@ -0,0 +1,5 @@ +export * from "@hunk/session-broker-core"; +export * from "./types"; +export * from "./broker"; +export * from "./daemon"; +export * from "./connection"; diff --git a/packages/session-broker/src/types.ts b/packages/session-broker/src/types.ts new file mode 100644 index 00000000..62ac128c --- /dev/null +++ b/packages/session-broker/src/types.ts @@ -0,0 +1,89 @@ +import type { SessionTargetInput } from "@hunk/session-broker-core"; +import type { SessionBrokerRecord } from "./broker"; + +export const DEFAULT_SESSION_BROKER_HEALTH_PATH = "/health"; +export const DEFAULT_SESSION_BROKER_API_PATH = "/broker"; +export const DEFAULT_SESSION_BROKER_CAPABILITIES_PATH = `${DEFAULT_SESSION_BROKER_API_PATH}/capabilities`; +export const DEFAULT_SESSION_BROKER_SOCKET_PATH = "/session"; + +/** Describe one runtime-neutral broker capability payload. */ +export interface SessionBrokerCapabilities { + version: number; + name?: string; + features?: string[]; + [key: string]: unknown; +} + +export interface SessionBrokerHttpPaths { + health: string; + api: string; + capabilities: string; + socket: string; +} + +export type SessionBrokerDaemonRequest< + CommandName extends string = string, + CommandInput = unknown, +> = + | { + action: "list"; + } + | { + action: "get"; + selector: SessionTargetInput; + } + | { + action: "dispatch"; + selector: SessionTargetInput; + command: CommandName; + input: CommandInput; + timeoutMs?: number; + timeoutMessage?: string; + }; + +export type SessionBrokerDaemonResponse = + | { + sessions: SessionBrokerRecord[]; + } + | { + session: SessionBrokerRecord; + } + | { + result: CommandResult; + }; + +export interface SessionBrokerHealth { + ok: boolean; + pid: number; + sessions: number; + pendingCommands: number; + startedAt: string; + uptimeMs: number; + staleSessionTtlMs: number; + paths: SessionBrokerHttpPaths; +} + +export interface SessionBrokerSocketCloseEvent { + code: number; + reason: string; +} + +export interface SessionBrokerSocketMessageEvent { + data: unknown; +} + +/** Minimal browser-like websocket client shape used by the runtime-neutral connection helper. */ +export interface SessionBrokerSocketLike { + readonly readyState: number; + send(data: string): void; + close(): void; + onopen: (() => void) | null; + onmessage: ((event: SessionBrokerSocketMessageEvent) => void) | null; + onclose: ((event: SessionBrokerSocketCloseEvent) => void) | null; + onerror: (() => void) | null; +} + +export interface SessionBrokerConnectionCloseDirective { + reconnect?: boolean; + warning?: string; +} diff --git a/tsconfig.json b/tsconfig.json index 93926f33..b050b2ea 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -14,6 +14,7 @@ "types": ["bun", "react"], "baseUrl": ".", "paths": { + "@hunk/session-broker": ["packages/session-broker/src/index.ts"], "@hunk/session-broker-core": ["packages/session-broker-core/src/index.ts"] }, "strict": true, From 5f8539e7bbaa7ae047e596034435f5cc0f56ee7d Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Wed, 15 Apr 2026 19:28:52 -0400 Subject: [PATCH 3/8] feat(session-broker-bun): serve the generic broker through Bun --- bun.lock | 10 + package.json | 1 + packages/session-broker-bun/package.json | 25 ++ packages/session-broker-bun/src/index.ts | 1 + packages/session-broker-bun/src/serve.test.ts | 236 +++++++++++++ packages/session-broker-bun/src/serve.ts | 126 +++++++ packages/session-broker/src/broker.ts | 56 +++- packages/session-broker/src/daemon.ts | 34 +- packages/session-broker/src/types.ts | 7 +- src/session-broker/brokerServer.ts | 313 +++++------------- tsconfig.json | 1 + 11 files changed, 553 insertions(+), 257 deletions(-) create mode 100644 packages/session-broker-bun/package.json create mode 100644 packages/session-broker-bun/src/index.ts create mode 100644 packages/session-broker-bun/src/serve.test.ts create mode 100644 packages/session-broker-bun/src/serve.ts diff --git a/bun.lock b/bun.lock index 881ccf43..62a34d1f 100644 --- a/bun.lock +++ b/bun.lock @@ -13,6 +13,7 @@ }, "devDependencies": { "@hunk/session-broker": "workspace:*", + "@hunk/session-broker-bun": "workspace:*", "@hunk/session-broker-core": "workspace:*", "@opentui/core": "^0.1.88", "@opentui/react": "^0.1.88", @@ -39,6 +40,13 @@ "@hunk/session-broker-core": "workspace:*", }, }, + "packages/session-broker-bun": { + "name": "@hunk/session-broker-bun", + "version": "0.0.0", + "dependencies": { + "@hunk/session-broker": "workspace:*", + }, + }, "packages/session-broker-core": { "name": "@hunk/session-broker-core", "version": "0.0.0", @@ -51,6 +59,8 @@ "@hunk/session-broker": ["@hunk/session-broker@workspace:packages/session-broker"], + "@hunk/session-broker-bun": ["@hunk/session-broker-bun@workspace:packages/session-broker-bun"], + "@hunk/session-broker-core": ["@hunk/session-broker-core@workspace:packages/session-broker-core"], "@jimp/core": ["@jimp/core@1.6.0", "", { "dependencies": { "@jimp/file-ops": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "await-to-js": "^3.0.0", "exif-parser": "^0.1.12", "file-type": "^16.0.0", "mime": "3" } }, "sha512-EQQlKU3s9QfdJqiSrZWNTxBs3rKXgO2W+GxNXDtwchF3a4IqxDheFX1ti+Env9hdJXDiYLp2jTRjlxhPthsk8w=="], diff --git a/package.json b/package.json index 6b086b83..dc14480d 100644 --- a/package.json +++ b/package.json @@ -80,6 +80,7 @@ }, "devDependencies": { "@hunk/session-broker": "workspace:*", + "@hunk/session-broker-bun": "workspace:*", "@hunk/session-broker-core": "workspace:*", "@opentui/core": "^0.1.88", "@opentui/react": "^0.1.88", diff --git a/packages/session-broker-bun/package.json b/packages/session-broker-bun/package.json new file mode 100644 index 00000000..597fc3ce --- /dev/null +++ b/packages/session-broker-bun/package.json @@ -0,0 +1,25 @@ +{ + "name": "@hunk/session-broker-bun", + "version": "0.0.0", + "private": true, + "description": "Bun HTTP and websocket adapter for @hunk/session-broker.", + "license": "MIT", + "files": [ + "src" + ], + "type": "module", + "sideEffects": false, + "exports": { + ".": { + "types": "./src/index.ts", + "import": "./src/index.ts" + } + }, + "dependencies": { + "@hunk/session-broker": "workspace:*" + }, + "engines": { + "bun": ">=1.0.0", + "node": ">=18" + } +} diff --git a/packages/session-broker-bun/src/index.ts b/packages/session-broker-bun/src/index.ts new file mode 100644 index 00000000..7e31de59 --- /dev/null +++ b/packages/session-broker-bun/src/index.ts @@ -0,0 +1 @@ +export * from "./serve"; diff --git a/packages/session-broker-bun/src/serve.test.ts b/packages/session-broker-bun/src/serve.test.ts new file mode 100644 index 00000000..12128a2f --- /dev/null +++ b/packages/session-broker-bun/src/serve.test.ts @@ -0,0 +1,236 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { createServer } from "node:net"; +import { + SESSION_BROKER_REGISTRATION_VERSION, + brokerWireParsers, + parseSessionRegistrationEnvelope, + parseSessionSnapshotEnvelope, + type SessionRegistration, + type SessionSnapshot, +} from "@hunk/session-broker-core"; +import { SessionBroker, createSessionBrokerDaemon } from "@hunk/session-broker"; +import { serveSessionBrokerDaemon } from "./serve"; + +interface TestSessionInfo { + title: string; +} + +interface TestSessionState { + selectedIndex: number; +} + +function parseInfo(value: unknown): TestSessionInfo | null { + const record = brokerWireParsers.asRecord(value); + if (!record) { + return null; + } + + const title = brokerWireParsers.parseRequiredString(record.title); + return title === null ? null : { title }; +} + +function parseState(value: unknown): TestSessionState | null { + const record = brokerWireParsers.asRecord(value); + if (!record) { + return null; + } + + const selectedIndex = brokerWireParsers.parseNonNegativeInt(record.selectedIndex); + return selectedIndex === null ? null : { selectedIndex }; +} + +function createRegistration(overrides: Partial> = {}) { + return { + registrationVersion: SESSION_BROKER_REGISTRATION_VERSION, + sessionId: "session-1", + pid: process.pid, + cwd: "/repo", + repoRoot: "/repo", + launchedAt: "2026-04-15T00:00:00.000Z", + info: { title: "repo working tree" }, + ...overrides, + } satisfies SessionRegistration; +} + +function createSnapshot( + overrides: Partial["state"]> & { updatedAt?: string } = {}, +) { + const { updatedAt = "2026-04-15T00:00:00.000Z", ...stateOverrides } = overrides; + return { + updatedAt, + state: { + selectedIndex: 0, + ...stateOverrides, + }, + } satisfies SessionSnapshot; +} + +async function reserveLoopbackPort() { + const listener = createServer(() => undefined); + await new Promise((resolve, reject) => { + listener.once("error", reject); + listener.listen(0, "127.0.0.1", () => resolve()); + }); + + const address = listener.address(); + const port = typeof address === "object" && address ? address.port : 0; + await new Promise((resolve) => listener.close(() => resolve())); + return port; +} + +async function waitUntil( + label: string, + fn: () => Promise | T | null, + timeoutMs = 1_500, + intervalMs = 20, +) { + const deadline = Date.now() + timeoutMs; + + for (;;) { + const value = await fn(); + if (value !== null) { + return value; + } + + if (Date.now() >= deadline) { + throw new Error(`Timed out waiting for ${label}.`); + } + + await Bun.sleep(intervalMs); + } +} + +async function readHealth(port: number) { + try { + const response = await fetch(`http://127.0.0.1:${port}/health`); + if (!response.ok) { + return null; + } + + return await response.json(); + } catch { + return null; + } +} + +async function waitForSessionCount(port: number, count: number) { + await waitUntil("session registration", async () => { + const response = await fetch(`http://127.0.0.1:${port}/broker`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ action: "list" }), + }); + if (!response.ok) { + return null; + } + + const payload = (await response.json()) as { sessions: { sessionId: string }[] }; + return payload.sessions.length === count ? payload : null; + }); +} + +afterEach(() => { + // No per-test env state to restore yet. +}); + +describe("session broker bun adapter", () => { + test("serves the generic daemon API and websocket path through Bun", async () => { + const broker = new SessionBroker({ + parseRegistration: (value) => parseSessionRegistrationEnvelope(value, parseInfo), + parseSnapshot: (value) => parseSessionSnapshotEnvelope(value, parseState), + }); + const daemon = createSessionBrokerDaemon({ broker, capabilities: { version: 1 } }); + const port = await reserveLoopbackPort(); + const server = serveSessionBrokerDaemon({ + daemon, + hostname: "127.0.0.1", + port, + }); + + try { + await expect(readHealth(port)).resolves.toMatchObject({ ok: true, sessions: 0 }); + + const socket = new WebSocket(`ws://127.0.0.1:${port}/session`); + await new Promise((resolve, reject) => { + const timeout = setTimeout( + () => reject(new Error("Timed out waiting for websocket open.")), + 500, + ); + timeout.unref?.(); + socket.addEventListener( + "open", + () => { + clearTimeout(timeout); + resolve(); + }, + { once: true }, + ); + socket.addEventListener( + "error", + () => { + clearTimeout(timeout); + reject(new Error("Websocket failed to open.")); + }, + { once: true }, + ); + }); + + socket.send( + JSON.stringify({ + type: "register", + registration: createRegistration(), + snapshot: createSnapshot(), + }), + ); + + await waitForSessionCount(port, 1); + const response = await fetch(`http://127.0.0.1:${port}/broker`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ action: "get", selector: { sessionId: "session-1" } }), + }); + expect(response.status).toBe(200); + await expect(response.json()).resolves.toMatchObject({ + session: { + registration: { sessionId: "session-1" }, + snapshot: { state: { selectedIndex: 0 } }, + }, + }); + + socket.close(); + } finally { + server.stop(true); + await server.stopped; + } + }); + + test("lets custom request handlers override generic routes", async () => { + const broker = new SessionBroker({ + parseRegistration: (value) => parseSessionRegistrationEnvelope(value, parseInfo), + parseSnapshot: (value) => parseSessionSnapshotEnvelope(value, parseState), + }); + const daemon = createSessionBrokerDaemon({ broker, capabilities: { version: 1 } }); + const port = await reserveLoopbackPort(); + const server = serveSessionBrokerDaemon({ + daemon, + hostname: "127.0.0.1", + port, + handleRequest: async (request) => { + const url = new URL(request.url); + if (url.pathname === "/health") { + return Response.json({ ok: true, overridden: true }); + } + + return undefined; + }, + }); + + try { + const response = await fetch(`http://127.0.0.1:${port}/health`); + await expect(response.json()).resolves.toEqual({ ok: true, overridden: true }); + } finally { + server.stop(true); + await server.stopped; + } + }); +}); diff --git a/packages/session-broker-bun/src/serve.ts b/packages/session-broker-bun/src/serve.ts new file mode 100644 index 00000000..1fa45b2b --- /dev/null +++ b/packages/session-broker-bun/src/serve.ts @@ -0,0 +1,126 @@ +import type { SessionServerMessage } from "@hunk/session-broker-core"; +import type { SessionBrokerDaemon } from "@hunk/session-broker"; + +export interface ServeSessionBrokerDaemonOptions< + SessionView = unknown, + ServerMessage extends SessionServerMessage = SessionServerMessage, + CommandResult = unknown, +> { + daemon: SessionBrokerDaemon; + hostname: string; + port: number; + handleRequest?: ( + request: Request, + server: ReturnType>, + ) => Response | Promise | undefined; + notFound?: (request: Request) => Response | Promise; + formatServeError?: (error: unknown, address: { hostname: string; port: number }) => Error; +} + +export type RunningSessionBrokerDaemon = ReturnType> & { + stopped: Promise; +}; + +function defaultNotFound() { + return new Response("Not found.", { status: 404 }); +} + +function defaultServeError(error: unknown, address: { hostname: string; port: number }) { + const message = error instanceof Error ? error.message : String(error); + return new Error( + `Failed to start the session broker server on ${address.hostname}:${address.port}: ${message}`, + ); +} + +/** Serve one runtime-neutral broker daemon through Bun's HTTP and websocket runtime. */ +export function serveSessionBrokerDaemon< + SessionView = unknown, + ServerMessage extends SessionServerMessage = SessionServerMessage, + CommandResult = unknown, +>( + options: ServeSessionBrokerDaemonOptions, +): RunningSessionBrokerDaemon { + let resolved = false; + let resolveStopped: (() => void) | null = null; + const stopped = new Promise((resolve) => { + resolveStopped = resolve; + }); + const finish = () => { + if (resolved) { + return; + } + + resolved = true; + resolveStopped?.(); + resolveStopped = null; + }; + + let server: ReturnType>; + try { + server = Bun.serve<{}>({ + hostname: options.hostname, + port: options.port, + fetch: async (request, bunServer) => { + const customResponse = await options.handleRequest?.(request, bunServer); + if (customResponse !== undefined) { + return customResponse; + } + + const daemonResponse = await options.daemon.handleRequest(request); + if (daemonResponse) { + return daemonResponse; + } + + const url = new URL(request.url); + if (options.daemon.matchesSocketPath(url.pathname)) { + if (bunServer.upgrade(request, { data: {} })) { + return undefined; + } + + return new Response("Expected websocket upgrade.", { status: 426 }); + } + + return (await options.notFound?.(request)) ?? defaultNotFound(); + }, + websocket: { + message: (socket, message) => { + if (typeof message !== "string") { + return; + } + + options.daemon.handleConnectionMessage(socket, message); + }, + close: (socket) => { + options.daemon.handleConnectionClose(socket); + }, + }, + }); + } catch (error) { + throw (options.formatServeError ?? defaultServeError)(error, { + hostname: options.hostname, + port: options.port, + }); + } + + const originalStop = server.stop.bind(server); + const stop: typeof server.stop = (closeActiveConnections) => { + options.daemon.shutdown(); + const result = originalStop(closeActiveConnections); + finish(); + return result; + }; + + Object.defineProperty(server, "stop", { + configurable: true, + enumerable: true, + writable: true, + value: stop, + }); + + void options.daemon.stopped.then(() => { + originalStop(true); + finish(); + }); + + return Object.assign(server, { stopped }) as RunningSessionBrokerDaemon; +} diff --git a/packages/session-broker/src/broker.ts b/packages/session-broker/src/broker.ts index 1735e37e..37c4201f 100644 --- a/packages/session-broker/src/broker.ts +++ b/packages/session-broker/src/broker.ts @@ -36,6 +36,41 @@ export interface SessionBrokerOptions { ) => string; } +/** Shared controller surface consumed by runtime-neutral daemon adapters. */ +export interface SessionBrokerController< + SessionView = unknown, + ServerMessage extends SessionServerMessage = SessionServerMessage, + CommandResult = unknown, +> { + listSessions(): SessionView[]; + getSession(selector: SessionTargetSelector): SessionView; + getSessionCount(): number; + getPendingCommandCount(): number; + registerSession( + connection: SessionBrokerPeer, + registrationInput: unknown, + snapshotInput: unknown, + ): boolean; + updateSnapshot(sessionId: string, snapshotInput: unknown): UpdateSnapshotResult; + markSessionSeen(sessionId: string): void; + unregisterConnection(connection: SessionBrokerPeer): void; + pruneStaleSessions(options: { ttlMs: number; now?: number }): number; + dispatchCommand(options: { + selector: SessionTargetInput; + command: ServerMessage["command"]; + input: unknown; + timeoutMessage: string; + timeoutMs?: number; + }): Promise; + handleCommandResult(message: { + requestId: string; + ok: boolean; + result?: CommandResult; + error?: string; + }): void; + shutdown(error?: Error): void; +} + function defaultSessionTitle(registration: SessionRegistration) { const info = registration.info; if (info && typeof info === "object") { @@ -57,6 +92,10 @@ export class SessionBroker< State = unknown, ServerMessage extends SessionServerMessage = SessionServerMessage, CommandResult = unknown, +> implements SessionBrokerController< + SessionBrokerRecord, + ServerMessage, + CommandResult > { private readonly state: SessionBrokerState< Info, @@ -139,11 +178,24 @@ export class SessionBroker< input: Extract["input"]; timeoutMessage: string; timeoutMs?: number; + }): Promise; + dispatchCommand({ + selector, + command, + input, + timeoutMessage, + timeoutMs, + }: { + selector: SessionTargetInput; + command: ServerMessage["command"]; + input: unknown; + timeoutMessage: string; + timeoutMs?: number; }) { - return this.state.dispatchCommand({ + return this.state.dispatchCommand({ selector, command, - input, + input: input as Extract["input"], timeoutMessage, timeoutMs, }); diff --git a/packages/session-broker/src/daemon.ts b/packages/session-broker/src/daemon.ts index 25783fd8..00294e99 100644 --- a/packages/session-broker/src/daemon.ts +++ b/packages/session-broker/src/daemon.ts @@ -1,5 +1,5 @@ -import type { SessionServerMessage } from "@hunk/session-broker-core"; -import type { SessionBroker, SessionBrokerPeer, SessionBrokerRecord } from "./broker"; +import type { SessionServerMessage, SessionTargetSelector } from "@hunk/session-broker-core"; +import type { SessionBrokerController, SessionBrokerPeer } from "./broker"; import { DEFAULT_SESSION_BROKER_API_PATH, DEFAULT_SESSION_BROKER_CAPABILITIES_PATH, @@ -18,12 +18,11 @@ const DEFAULT_IDLE_TIMEOUT_MS = 60_000; const INCOMPATIBLE_PAYLOAD_CLOSE_CODE = 1008; export interface SessionBrokerDaemonOptions< - Info = unknown, - State = unknown, + SessionView = unknown, ServerMessage extends SessionServerMessage = SessionServerMessage, CommandResult = unknown, > { - broker: SessionBroker; + broker: SessionBrokerController; capabilities?: SessionBrokerCapabilities; paths?: Partial; idleTimeoutMs?: number; @@ -72,8 +71,7 @@ function defaultTimeoutMessage(command: string) { * plus websocket message handling without choosing Bun, Node, or any other server implementation. */ export class SessionBrokerDaemon< - Info = unknown, - State = unknown, + SessionView = unknown, ServerMessage extends SessionServerMessage = SessionServerMessage, CommandResult = unknown, > { @@ -92,9 +90,9 @@ export class SessionBrokerDaemon< private resolveStopped: (() => void) | null = null; constructor( - private readonly broker: SessionBroker, + private readonly broker: SessionBrokerController, options: Omit< - SessionBrokerDaemonOptions, + SessionBrokerDaemonOptions, "broker" > = {}, ) { @@ -120,9 +118,7 @@ export class SessionBrokerDaemon< return this.broker.listSessions(); } - getSession( - selector: Parameters["getSession"]>[0], - ) { + getSession(selector: SessionTargetSelector) { return this.broker.getSession(selector); } @@ -317,7 +313,7 @@ export class SessionBrokerDaemon< try { const input = await parseJsonRequest(request); - let response: SessionBrokerDaemonResponse; + let response: SessionBrokerDaemonResponse; switch (input.action) { case "list": @@ -328,7 +324,7 @@ export class SessionBrokerDaemon< break; case "dispatch": response = { - result: await this.broker.dispatchCommand({ + result: await this.broker.dispatchCommand({ selector: input.selector, command: input.command, input: input.input as Extract< @@ -353,15 +349,11 @@ export class SessionBrokerDaemon< /** Create one runtime-neutral broker daemon engine around an existing session broker. */ export function createSessionBrokerDaemon< - Info = unknown, - State = unknown, + SessionView = unknown, ServerMessage extends SessionServerMessage = SessionServerMessage, CommandResult = unknown, ->(options: SessionBrokerDaemonOptions) { +>(options: SessionBrokerDaemonOptions) { return new SessionBrokerDaemon(options.broker, options); } -export type SessionBrokerSession = SessionBrokerRecord< - Info, - State ->; +export type SessionBrokerSession = SessionView; diff --git a/packages/session-broker/src/types.ts b/packages/session-broker/src/types.ts index 62ac128c..2f420b19 100644 --- a/packages/session-broker/src/types.ts +++ b/packages/session-broker/src/types.ts @@ -1,5 +1,4 @@ import type { SessionTargetInput } from "@hunk/session-broker-core"; -import type { SessionBrokerRecord } from "./broker"; export const DEFAULT_SESSION_BROKER_HEALTH_PATH = "/health"; export const DEFAULT_SESSION_BROKER_API_PATH = "/broker"; @@ -41,12 +40,12 @@ export type SessionBrokerDaemonRequest< timeoutMessage?: string; }; -export type SessionBrokerDaemonResponse = +export type SessionBrokerDaemonResponse = | { - sessions: SessionBrokerRecord[]; + sessions: SessionView[]; } | { - session: SessionBrokerRecord; + session: SessionView; } | { result: CommandResult; diff --git a/src/session-broker/brokerServer.ts b/src/session-broker/brokerServer.ts index f86be9df..c2d100cd 100644 --- a/src/session-broker/brokerServer.ts +++ b/src/session-broker/brokerServer.ts @@ -1,3 +1,8 @@ +import { createSessionBrokerDaemon, type SessionBrokerController } from "@hunk/session-broker"; +import { + serveSessionBrokerDaemon as serveSessionBrokerDaemonWithBun, + type RunningSessionBrokerDaemon as RunningBunSessionBrokerDaemon, +} from "@hunk/session-broker-bun"; import { LEGACY_MCP_PATH, SESSION_BROKER_SOCKET_PATH, @@ -12,6 +17,7 @@ import type { AppliedCommentResult, ClearedCommentsResult, HunkSessionCommandResult, + HunkSessionServerMessage, NavigatedSelectionResult, ReloadedSessionResult, RemovedCommentResult, @@ -51,9 +57,7 @@ export interface ServeSessionBrokerDaemonOptions { staleSessionSweepIntervalMs?: number; } -export type RunningSessionBrokerDaemon = ReturnType> & { - stopped: Promise; -}; +export type RunningSessionBrokerDaemon = RunningBunSessionBrokerDaemon; function formatDaemonServeError(error: unknown, host: string, port: number) { const message = error instanceof Error ? error.message : String(error); @@ -84,25 +88,6 @@ function jsonError(message: string, status = 400) { return Response.json({ error: message }, { status }); } -/** Return one object-shaped websocket message envelope when the client sent JSON. */ -function parseSocketEnvelope(message: string) { - let parsed: unknown; - try { - parsed = JSON.parse(message); - } catch { - return null; - } - - if (!parsed || typeof parsed !== "object") { - return null; - } - - const type = (parsed as { type?: unknown }).type; - return typeof type === "string" - ? (parsed as object as { type: string } & Record) - : null; -} - async function parseJsonRequest(request: Request) { try { return (await request.json()) as SessionDaemonRequest; @@ -259,6 +244,31 @@ async function handleSessionApiRequest(state: HunkSessionBrokerState, request: R } } +type ListedHunkSession = ReturnType[number]; + +function createHunkBrokerController( + state: HunkSessionBrokerState, +): SessionBrokerController { + return { + listSessions: () => state.listSessions(), + getSession: (selector) => state.getSession(selector), + getSessionCount: () => state.getSessionCount(), + getPendingCommandCount: () => state.getPendingCommandCount(), + registerSession: (connection, registrationInput, snapshotInput) => + state.registerSession(connection, registrationInput, snapshotInput), + updateSnapshot: (sessionId, snapshotInput) => state.updateSnapshot(sessionId, snapshotInput), + markSessionSeen: (sessionId) => state.markSessionSeen(sessionId), + unregisterConnection: (connection) => state.unregisterSocket(connection), + pruneStaleSessions: (options) => state.pruneStaleSessions(options), + dispatchCommand: (options) => + state.dispatchCommand( + options as Parameters[0], + ), + handleCommandResult: (message) => state.handleCommandResult(message), + shutdown: (error) => state.shutdown(error), + }; +} + /** Serve the local session broker daemon and websocket broker transport. */ export function serveSessionBrokerDaemon( options: ServeSessionBrokerDaemonOptions = {}, @@ -269,231 +279,74 @@ export function serveSessionBrokerDaemon( const staleSessionSweepIntervalMs = options.staleSessionSweepIntervalMs ?? DEFAULT_STALE_SESSION_SWEEP_INTERVAL_MS; const state = createHunkSessionBrokerState(); - const startedAt = Date.now(); - let resolveStopped: (() => void) | null = null; - const stopped = new Promise((resolve) => { - resolveStopped = resolve; + const daemon = createSessionBrokerDaemon({ + broker: createHunkBrokerController(state), + capabilities: { + version: HUNK_SESSION_DAEMON_VERSION, + name: "hunk-session-broker", + actions: SUPPORTED_SESSION_ACTIONS, + }, + idleTimeoutMs, + staleSessionTtlMs, + staleSessionSweepIntervalMs, + paths: { + socket: SESSION_BROKER_SOCKET_PATH, + }, }); - let lastActivityAt = startedAt; - let shuttingDown = false; - let sweepTimer: Timer | null = null; - let idleTimer: Timer | null = null; - let server: ReturnType> | null = null; - - const hasActiveWork = () => state.getSessionCount() > 0 || state.getPendingCommandCount() > 0; - - const clearIdleShutdownTimer = () => { - if (!idleTimer) { - return; - } - - clearTimeout(idleTimer); - idleTimer = null; - }; - - const shutdown = () => { - if (shuttingDown) { - return; - } - - shuttingDown = true; - if (sweepTimer) { - clearInterval(sweepTimer); - sweepTimer = null; - } - - clearIdleShutdownTimer(); - process.off("SIGINT", shutdown); - process.off("SIGTERM", shutdown); - state.shutdown(); - server?.stop(true); - resolveStopped?.(); - resolveStopped = null; - }; - - const refreshIdleShutdownTimer = () => { - clearIdleShutdownTimer(); - - if (shuttingDown || idleTimeoutMs <= 0 || hasActiveWork()) { - return; - } - - const idleForMs = Date.now() - lastActivityAt; - const remainingMs = Math.max(0, idleTimeoutMs - idleForMs); + const server = serveSessionBrokerDaemonWithBun({ + daemon, + hostname: config.host, + port: config.port, + formatServeError: (error, _address) => formatDaemonServeError(error, config.host, config.port), + handleRequest: async (request) => { + const url = new URL(request.url); + + if (url.pathname === "/health") { + return Response.json({ + ...daemon.getHealth(), + sessionApi: `${config.httpOrigin}${HUNK_SESSION_API_PATH}`, + sessionCapabilities: `${config.httpOrigin}${HUNK_SESSION_CAPABILITIES_PATH}`, + sessionSocket: `${config.wsOrigin}${SESSION_BROKER_SOCKET_PATH}`, + }); + } - idleTimer = setTimeout(() => { - idleTimer = null; + if (url.pathname === HUNK_SESSION_CAPABILITIES_PATH) { + return Response.json(sessionCapabilities()); + } - if (shuttingDown || hasActiveWork()) { - return; + if (url.pathname === HUNK_SESSION_API_PATH) { + return handleSessionApiRequest(state, request); } - if (Date.now() - lastActivityAt < idleTimeoutMs) { - refreshIdleShutdownTimer(); - return; + if (url.pathname === LEGACY_MCP_PATH) { + return jsonError( + "This app no longer exposes agent-facing MCP tools. Use the session CLI instead.", + 410, + ); } - shutdown(); - }, remainingMs); - idleTimer.unref?.(); - }; + return undefined; + }, + }); - const noteActivity = () => { - lastActivityAt = Date.now(); - refreshIdleShutdownTimer(); + const shutdown = () => { + process.off("SIGINT", shutdown); + process.off("SIGTERM", shutdown); + server.stop(true); }; - sweepTimer = setInterval(() => { - const removed = state.pruneStaleSessions({ ttlMs: staleSessionTtlMs }); - if (removed > 0) { - noteActivity(); - } - }, staleSessionSweepIntervalMs); - sweepTimer.unref?.(); - - try { - server = Bun.serve<{}>({ - hostname: config.host, - port: config.port, - fetch: async (request, bunServer) => { - const url = new URL(request.url); - - if (url.pathname === "/health") { - const removed = state.pruneStaleSessions({ ttlMs: staleSessionTtlMs }); - if (removed > 0) { - noteActivity(); - } - - return Response.json({ - ok: true, - pid: process.pid, - startedAt: new Date(startedAt).toISOString(), - uptimeMs: Date.now() - startedAt, - sessionApi: `${config.httpOrigin}${HUNK_SESSION_API_PATH}`, - sessionCapabilities: `${config.httpOrigin}${HUNK_SESSION_CAPABILITIES_PATH}`, - sessionSocket: `${config.wsOrigin}${SESSION_BROKER_SOCKET_PATH}`, - sessions: state.getSessionCount(), - pendingCommands: state.getPendingCommandCount(), - staleSessionTtlMs, - }); - } - - if (url.pathname === HUNK_SESSION_CAPABILITIES_PATH) { - noteActivity(); - return Response.json(sessionCapabilities()); - } - - if (url.pathname === HUNK_SESSION_API_PATH) { - noteActivity(); - return handleSessionApiRequest(state, request); - } - - if (url.pathname === LEGACY_MCP_PATH) { - return jsonError( - "This app no longer exposes agent-facing MCP tools. Use the session CLI instead.", - 410, - ); - } - - if (url.pathname === SESSION_BROKER_SOCKET_PATH) { - if (bunServer.upgrade(request, { data: {} })) { - return undefined; - } - - return new Response("Expected websocket upgrade.", { status: 426 }); - } - - return new Response("Not found.", { status: 404 }); - }, - websocket: { - message: (socket, message) => { - if (typeof message !== "string") { - return; - } - - const parsed = parseSocketEnvelope(message); - if (!parsed) { - return; - } - - switch (parsed.type) { - case "register": - if (!state.registerSession(socket, parsed.registration, parsed.snapshot)) { - // Close incompatible clients so old sessions cannot poison the fresh daemon after - // an upgrade. The session CLI will then surface a reconnect timeout instead of a - // broken listing or command crash. - socket.close(1008, "Incompatible session registration."); - return; - } - - noteActivity(); - break; - case "snapshot": - if (typeof parsed.sessionId !== "string") { - return; - } - - const updateResult = state.updateSnapshot(parsed.sessionId, parsed.snapshot); - if (updateResult === "not-found") { - socket.close(1008, "Session not registered with broker."); - return; - } - - if (updateResult === "invalid") { - socket.close(1008, "Incompatible session snapshot."); - return; - } - - noteActivity(); - break; - case "heartbeat": - if (typeof parsed.sessionId !== "string") { - return; - } - - state.markSessionSeen(parsed.sessionId); - noteActivity(); - break; - case "command-result": - if (typeof parsed.requestId !== "string" || typeof parsed.ok !== "boolean") { - return; - } - - state.handleCommandResult({ - requestId: parsed.requestId, - ok: parsed.ok, - result: parsed.result as HunkSessionCommandResult | undefined, - error: typeof parsed.error === "string" ? parsed.error : undefined, - }); - noteActivity(); - break; - } - }, - close: (socket) => { - state.unregisterSocket(socket); - noteActivity(); - }, - }, - }); - } catch (error) { - if (sweepTimer) { - clearInterval(sweepTimer); - sweepTimer = null; - } - - clearIdleShutdownTimer(); - throw formatDaemonServeError(error, config.host, config.port); - } - process.once("SIGINT", shutdown); process.once("SIGTERM", shutdown); - refreshIdleShutdownTimer(); + void server.stopped.finally(() => { + process.off("SIGINT", shutdown); + process.off("SIGTERM", shutdown); + }); console.log(`Session broker API listening on ${config.httpOrigin}${HUNK_SESSION_API_PATH}`); console.log( `Session broker websocket listening on ${config.wsOrigin}${SESSION_BROKER_SOCKET_PATH}`, ); - return Object.assign(server, { stopped }) as RunningSessionBrokerDaemon; + return server; } diff --git a/tsconfig.json b/tsconfig.json index b050b2ea..85f6f2da 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -15,6 +15,7 @@ "baseUrl": ".", "paths": { "@hunk/session-broker": ["packages/session-broker/src/index.ts"], + "@hunk/session-broker-bun": ["packages/session-broker-bun/src/index.ts"], "@hunk/session-broker-core": ["packages/session-broker-core/src/index.ts"] }, "strict": true, From e37cdacd0fb3df180f4e3161ced37685482de695 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Wed, 15 Apr 2026 19:32:01 -0400 Subject: [PATCH 4/8] refactor(session-broker): reuse shared connection lifecycle --- src/session-broker/brokerClient.ts | 186 +++++++---------------------- 1 file changed, 44 insertions(+), 142 deletions(-) diff --git a/src/session-broker/brokerClient.ts b/src/session-broker/brokerClient.ts index 59108e6e..3e960781 100644 --- a/src/session-broker/brokerClient.ts +++ b/src/session-broker/brokerClient.ts @@ -1,5 +1,10 @@ +import { + createSessionBrokerConnection, + type SessionBrokerConnection as GenericSessionBrokerConnection, + type SessionBrokerConnectionBridge, + type SessionBrokerSocketLike, +} from "@hunk/session-broker"; import type { - SessionClientMessage, SessionRegistration, SessionServerMessage, SessionSnapshot, @@ -27,12 +32,10 @@ const INCOMPATIBLE_SESSION_CLOSE_REASON_PREFIX = "Incompatible session "; const INCOMPATIBLE_SESSION_CLOSE_MESSAGE = "This window is too old for the refreshed session broker daemon. Restart the window to reconnect."; -interface SessionAppBridge< +type SessionAppBridge< ServerMessage extends SessionServerMessage = SessionServerMessage, Result = unknown, -> { - dispatchCommand: (message: ServerMessage) => Promise; -} +> = SessionBrokerConnectionBridge; /** Keep one running app session registered with the local session broker daemon. */ export class SessionBrokerClient< @@ -41,11 +44,15 @@ export class SessionBrokerClient< ServerMessage extends SessionServerMessage = SessionServerMessage, Result = unknown, > { - private websocket: WebSocket | null = null; + private connection: GenericSessionBrokerConnection< + Info, + State, + SessionBrokerSocketLike, + ServerMessage, + Result + > | null = null; private bridge: SessionAppBridge | null = null; - private queuedMessages: ServerMessage[] = []; - private reconnectTimer: Timer | null = null; - private heartbeatTimer: Timer | null = null; + private reconnectTimer: ReturnType | null = null; private stopped = false; private startupPromise: Promise | null = null; private lastConnectionWarning: string | null = null; @@ -85,9 +92,8 @@ export class SessionBrokerClient< this.reconnectTimer = null; } - this.stopHeartbeat(); - this.websocket?.close(); - this.websocket = null; + this.connection?.stop(); + this.connection = null; } getRegistration() { @@ -97,11 +103,7 @@ export class SessionBrokerClient< replaceSession(registration: SessionRegistration, snapshot: SessionSnapshot) { this.registration = registration; this.snapshot = snapshot; - this.send({ - type: "register", - registration, - snapshot, - }); + this.connection?.replaceSession(registration, snapshot); } private resolveConfig() { @@ -177,73 +179,41 @@ export class SessionBrokerClient< setBridge(bridge: SessionAppBridge | null) { this.bridge = bridge; - void this.flushQueuedMessages(); + this.connection?.setBridge(bridge); } updateSnapshot(snapshot: SessionSnapshot) { this.snapshot = snapshot; - this.send({ - type: "snapshot", - sessionId: this.registration.sessionId, - snapshot, - }); + this.connection?.updateSnapshot(snapshot); } private connect(config: ResolvedSessionBrokerConfig) { - if (this.stopped || this.websocket) { + if (this.stopped || this.connection) { return; } - const websocket = new WebSocket(`${config.wsOrigin}${SESSION_BROKER_SOCKET_PATH}`); - this.websocket = websocket; - - websocket.onopen = () => { - this.lastConnectionWarning = null; - this.startHeartbeat(); - this.send({ - type: "register", - registration: this.registration, - snapshot: this.snapshot, - }); - void this.flushQueuedMessages(); - }; - - websocket.onmessage = (event) => { - if (typeof event.data !== "string") { - return; - } - - let parsed: ServerMessage; - try { - parsed = JSON.parse(event.data) as ServerMessage; - } catch { - return; - } - - void this.handleServerMessage(parsed); - }; - - websocket.onclose = (event) => { - if (this.websocket === websocket) { - this.websocket = null; - } - - this.stopHeartbeat(); - if (this.stopped) { - return; - } - - if (this.isIncompatibleSessionClose(event)) { - this.warnUnavailable(INCOMPATIBLE_SESSION_CLOSE_MESSAGE); - return; - } - - this.scheduleReconnect(); - }; + this.connection = createSessionBrokerConnection< + Info, + State, + SessionBrokerSocketLike, + ServerMessage, + Result + >({ + url: `${config.wsOrigin}${SESSION_BROKER_SOCKET_PATH}`, + createSocket: (url) => new WebSocket(url) as unknown as SessionBrokerSocketLike, + registration: this.registration, + snapshot: this.snapshot, + bridge: this.bridge, + heartbeatIntervalMs: HEARTBEAT_INTERVAL_MS, + reconnectDelayMs: RECONNECT_DELAY_MS, + resolveClose: (event) => + this.isIncompatibleSessionClose(event) + ? { reconnect: false, warning: INCOMPATIBLE_SESSION_CLOSE_MESSAGE } + : { reconnect: true }, + onWarning: (message) => this.warnUnavailable(message), + }); - websocket.onerror = () => { - websocket.close(); - }; + this.connection.start(); } private scheduleReconnect(delayMs = RECONNECT_DELAY_MS) { @@ -258,76 +228,8 @@ export class SessionBrokerClient< this.reconnectTimer.unref?.(); } - private startHeartbeat() { - if (this.heartbeatTimer) { - return; - } - - this.heartbeatTimer = setInterval(() => { - this.send({ - type: "heartbeat", - sessionId: this.registration.sessionId, - }); - }, HEARTBEAT_INTERVAL_MS); - this.heartbeatTimer.unref?.(); - } - - private stopHeartbeat() { - if (!this.heartbeatTimer) { - return; - } - - clearInterval(this.heartbeatTimer); - this.heartbeatTimer = null; - } - - private send(message: SessionClientMessage) { - if (!this.websocket || this.websocket.readyState !== WebSocket.OPEN) { - return; - } - - this.websocket.send(JSON.stringify(message)); - } - - private async handleServerMessage(message: ServerMessage) { - if (!this.bridge) { - this.queuedMessages.push(message); - return; - } - - try { - const result = await this.bridge.dispatchCommand(message); - this.send({ - type: "command-result", - requestId: message.requestId, - ok: true, - result, - }); - } catch (error) { - this.send({ - type: "command-result", - requestId: message.requestId, - ok: false, - error: error instanceof Error ? error.message : "Unknown session error.", - }); - } - } - - private async flushQueuedMessages() { - if (!this.bridge || this.queuedMessages.length === 0) { - return; - } - - const queued = [...this.queuedMessages]; - this.queuedMessages = []; - - for (const message of queued) { - await this.handleServerMessage(message); - } - } - /** Return whether the daemon explicitly rejected this session as incompatible after an upgrade. */ - private isIncompatibleSessionClose(event: CloseEvent) { + private isIncompatibleSessionClose(event: { code: number; reason: string }) { return ( event.code === INCOMPATIBLE_SESSION_CLOSE_CODE && event.reason.startsWith(INCOMPATIBLE_SESSION_CLOSE_REASON_PREFIX) From 994092dd319dee07a5aa34be4fda59bc8bbea6c7 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Wed, 15 Apr 2026 19:36:22 -0400 Subject: [PATCH 5/8] feat(session-broker-node): add Node runtime adapter --- bun.lock | 14 ++ package.json | 2 + packages/session-broker-node/package.json | 26 +++ packages/session-broker-node/src/index.ts | 1 + .../session-broker-node/src/serve.test.ts | 186 +++++++++++++++ packages/session-broker-node/src/serve.ts | 211 ++++++++++++++++++ tsconfig.json | 3 +- 7 files changed, 442 insertions(+), 1 deletion(-) create mode 100644 packages/session-broker-node/package.json create mode 100644 packages/session-broker-node/src/index.ts create mode 100644 packages/session-broker-node/src/serve.test.ts create mode 100644 packages/session-broker-node/src/serve.ts diff --git a/bun.lock b/bun.lock index 62a34d1f..6e00d26e 100644 --- a/bun.lock +++ b/bun.lock @@ -15,10 +15,12 @@ "@hunk/session-broker": "workspace:*", "@hunk/session-broker-bun": "workspace:*", "@hunk/session-broker-core": "workspace:*", + "@hunk/session-broker-node": "workspace:*", "@opentui/core": "^0.1.88", "@opentui/react": "^0.1.88", "@types/bun": "latest", "@types/react": "^19.2.14", + "@types/ws": "^8.18.1", "lint-staged": "^16.4.0", "oxfmt": "^0.41.0", "oxlint": "^1.56.0", @@ -51,6 +53,14 @@ "name": "@hunk/session-broker-core", "version": "0.0.0", }, + "packages/session-broker-node": { + "name": "@hunk/session-broker-node", + "version": "0.0.0", + "dependencies": { + "@hunk/session-broker": "workspace:*", + "ws": "^8.18.3", + }, + }, }, "packages": { "@dimforge/rapier2d-simd-compat": ["@dimforge/rapier2d-simd-compat@0.17.3", "", {}, "sha512-bijvwWz6NHsNj5e5i1vtd3dU2pDhthSaTUZSh14DUGGKJfw8eMnlWZsxwHBxB/a3AXVNDjL9abuHw1k9FGR+jg=="], @@ -63,6 +73,8 @@ "@hunk/session-broker-core": ["@hunk/session-broker-core@workspace:packages/session-broker-core"], + "@hunk/session-broker-node": ["@hunk/session-broker-node@workspace:packages/session-broker-node"], + "@jimp/core": ["@jimp/core@1.6.0", "", { "dependencies": { "@jimp/file-ops": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "await-to-js": "^3.0.0", "exif-parser": "^0.1.12", "file-type": "^16.0.0", "mime": "3" } }, "sha512-EQQlKU3s9QfdJqiSrZWNTxBs3rKXgO2W+GxNXDtwchF3a4IqxDheFX1ti+Env9hdJXDiYLp2jTRjlxhPthsk8w=="], "@jimp/diff": ["@jimp/diff@1.6.0", "", { "dependencies": { "@jimp/plugin-resize": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "pixelmatch": "^5.3.0" } }, "sha512-+yUAQ5gvRC5D1WHYxjBHZI7JBRusGGSLf8AmPRPCenTzh4PA+wZ1xv2+cYqQwTfQHU5tXYOhA0xDytfHUf1Zyw=="], @@ -289,6 +301,8 @@ "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], + "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], + "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="], "@webgpu/types": ["@webgpu/types@0.1.69", "", {}, "sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ=="], diff --git a/package.json b/package.json index dc14480d..e45f3748 100644 --- a/package.json +++ b/package.json @@ -82,10 +82,12 @@ "@hunk/session-broker": "workspace:*", "@hunk/session-broker-bun": "workspace:*", "@hunk/session-broker-core": "workspace:*", + "@hunk/session-broker-node": "workspace:*", "@opentui/core": "^0.1.88", "@opentui/react": "^0.1.88", "@types/bun": "latest", "@types/react": "^19.2.14", + "@types/ws": "^8.18.1", "lint-staged": "^16.4.0", "oxfmt": "^0.41.0", "oxlint": "^1.56.0", diff --git a/packages/session-broker-node/package.json b/packages/session-broker-node/package.json new file mode 100644 index 00000000..e8bd9b1f --- /dev/null +++ b/packages/session-broker-node/package.json @@ -0,0 +1,26 @@ +{ + "name": "@hunk/session-broker-node", + "version": "0.0.0", + "private": true, + "description": "Node HTTP and websocket adapter for @hunk/session-broker.", + "license": "MIT", + "files": [ + "src" + ], + "type": "module", + "sideEffects": false, + "exports": { + ".": { + "types": "./src/index.ts", + "import": "./src/index.ts" + } + }, + "dependencies": { + "@hunk/session-broker": "workspace:*", + "ws": "^8.18.3" + }, + "engines": { + "bun": ">=1.0.0", + "node": ">=18" + } +} diff --git a/packages/session-broker-node/src/index.ts b/packages/session-broker-node/src/index.ts new file mode 100644 index 00000000..7e31de59 --- /dev/null +++ b/packages/session-broker-node/src/index.ts @@ -0,0 +1 @@ +export * from "./serve"; diff --git a/packages/session-broker-node/src/serve.test.ts b/packages/session-broker-node/src/serve.test.ts new file mode 100644 index 00000000..5a46abf8 --- /dev/null +++ b/packages/session-broker-node/src/serve.test.ts @@ -0,0 +1,186 @@ +import { describe, expect, test } from "bun:test"; +import { createServer } from "node:net"; +import { + SESSION_BROKER_REGISTRATION_VERSION, + brokerWireParsers, + parseSessionRegistrationEnvelope, + parseSessionSnapshotEnvelope, + type SessionRegistration, + type SessionSnapshot, +} from "@hunk/session-broker-core"; +import { SessionBroker, createSessionBrokerDaemon } from "@hunk/session-broker"; +import { serveSessionBrokerDaemon } from "./serve"; + +interface TestSessionInfo { + title: string; +} + +interface TestSessionState { + selectedIndex: number; +} + +function parseInfo(value: unknown): TestSessionInfo | null { + const record = brokerWireParsers.asRecord(value); + if (!record) { + return null; + } + + const title = brokerWireParsers.parseRequiredString(record.title); + return title === null ? null : { title }; +} + +function parseState(value: unknown): TestSessionState | null { + const record = brokerWireParsers.asRecord(value); + if (!record) { + return null; + } + + const selectedIndex = brokerWireParsers.parseNonNegativeInt(record.selectedIndex); + return selectedIndex === null ? null : { selectedIndex }; +} + +function createRegistration(overrides: Partial> = {}) { + return { + registrationVersion: SESSION_BROKER_REGISTRATION_VERSION, + sessionId: "session-1", + pid: process.pid, + cwd: "/repo", + repoRoot: "/repo", + launchedAt: "2026-04-15T00:00:00.000Z", + info: { title: "repo working tree" }, + ...overrides, + } satisfies SessionRegistration; +} + +function createSnapshot( + overrides: Partial["state"]> & { updatedAt?: string } = {}, +) { + const { updatedAt = "2026-04-15T00:00:00.000Z", ...stateOverrides } = overrides; + return { + updatedAt, + state: { + selectedIndex: 0, + ...stateOverrides, + }, + } satisfies SessionSnapshot; +} + +async function reserveLoopbackPort() { + const listener = createServer(() => undefined); + await new Promise((resolve, reject) => { + listener.once("error", reject); + listener.listen(0, "127.0.0.1", () => resolve()); + }); + + const address = listener.address(); + const port = typeof address === "object" && address ? address.port : 0; + await new Promise((resolve) => listener.close(() => resolve())); + return port; +} + +async function waitUntil( + label: string, + fn: () => Promise | T | null, + timeoutMs = 1_500, + intervalMs = 20, +) { + const deadline = Date.now() + timeoutMs; + + for (;;) { + const value = await fn(); + if (value !== null) { + return value; + } + + if (Date.now() >= deadline) { + throw new Error(`Timed out waiting for ${label}.`); + } + + await Bun.sleep(intervalMs); + } +} + +describe("session broker node adapter", () => { + test("serves the generic daemon API and websocket path through Node", async () => { + const broker = new SessionBroker({ + parseRegistration: (value) => parseSessionRegistrationEnvelope(value, parseInfo), + parseSnapshot: (value) => parseSessionSnapshotEnvelope(value, parseState), + }); + const daemon = createSessionBrokerDaemon({ broker, capabilities: { version: 1 } }); + const port = await reserveLoopbackPort(); + const server = await serveSessionBrokerDaemon({ + daemon, + hostname: "127.0.0.1", + port, + }); + + try { + const health = await fetch(`http://127.0.0.1:${port}/health`); + await expect(health.json()).resolves.toMatchObject({ ok: true, sessions: 0 }); + + const socket = new WebSocket(`ws://127.0.0.1:${port}/session`); + await new Promise((resolve, reject) => { + const timeout = setTimeout( + () => reject(new Error("Timed out waiting for websocket open.")), + 500, + ); + timeout.unref?.(); + socket.addEventListener( + "open", + () => { + clearTimeout(timeout); + resolve(); + }, + { once: true }, + ); + socket.addEventListener( + "error", + () => { + clearTimeout(timeout); + reject(new Error("Websocket failed to open.")); + }, + { once: true }, + ); + }); + + socket.send( + JSON.stringify({ + type: "register", + registration: createRegistration(), + snapshot: createSnapshot(), + }), + ); + + await waitUntil("session registration", async () => { + const response = await fetch(`http://127.0.0.1:${port}/broker`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ action: "list" }), + }); + if (!response.ok) { + return null; + } + + const payload = (await response.json()) as { sessions: { sessionId: string }[] }; + return payload.sessions.length === 1 ? payload : null; + }); + + const response = await fetch(`http://127.0.0.1:${port}/broker`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ action: "get", selector: { sessionId: "session-1" } }), + }); + await expect(response.json()).resolves.toMatchObject({ + session: { + registration: { sessionId: "session-1" }, + snapshot: { state: { selectedIndex: 0 } }, + }, + }); + + socket.close(); + } finally { + await server.stop(); + await server.stopped; + } + }); +}); diff --git a/packages/session-broker-node/src/serve.ts b/packages/session-broker-node/src/serve.ts new file mode 100644 index 00000000..e0616ce7 --- /dev/null +++ b/packages/session-broker-node/src/serve.ts @@ -0,0 +1,211 @@ +import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; +import { Readable } from "node:stream"; +import type { AddressInfo } from "node:net"; +import type { SessionServerMessage } from "@hunk/session-broker-core"; +import type { SessionBrokerDaemon, SessionBrokerPeer } from "@hunk/session-broker"; +import { WebSocketServer, type WebSocket } from "ws"; + +export interface ServeSessionBrokerDaemonOptions< + SessionView = unknown, + ServerMessage extends SessionServerMessage = SessionServerMessage, + CommandResult = unknown, +> { + daemon: SessionBrokerDaemon; + hostname: string; + port: number; + handleRequest?: ( + request: Request, + server: ReturnType, + ) => Response | Promise | undefined; + notFound?: (request: Request) => Response | Promise; + formatServeError?: (error: unknown, address: { hostname: string; port: number }) => Error; +} + +export interface RunningSessionBrokerDaemon { + server: ReturnType; + stopped: Promise; + stop(): Promise; + address(): AddressInfo | string | null; +} + +function defaultNotFound() { + return new Response("Not found.", { status: 404 }); +} + +function defaultServeError(error: unknown, address: { hostname: string; port: number }) { + const message = error instanceof Error ? error.message : String(error); + return new Error( + `Failed to start the session broker server on ${address.hostname}:${address.port}: ${message}`, + ); +} + +function toNodeConnection(socket: WebSocket): SessionBrokerPeer { + return { + send(data: string) { + socket.send(data); + }, + close(code?: number, reason?: string) { + socket.close(code, reason); + }, + }; +} + +async function toRequest(request: IncomingMessage, hostname: string, port: number) { + const protocol = "encrypted" in request.socket && request.socket.encrypted ? "https" : "http"; + const url = `${protocol}://${hostname}:${port}${request.url ?? "/"}`; + const body = + request.method === "GET" || request.method === "HEAD" + ? undefined + : (Readable.toWeb(request) as unknown as BodyInit); + + return new Request(url, { + method: request.method, + headers: request.headers as HeadersInit, + body, + duplex: body ? "half" : undefined, + } as RequestInit & { duplex?: "half" }); +} + +async function writeResponse(nodeResponse: ServerResponse, response: Response) { + nodeResponse.statusCode = response.status; + nodeResponse.statusMessage = response.statusText; + + response.headers.forEach((value, key) => { + nodeResponse.setHeader(key, value); + }); + + if (!response.body) { + nodeResponse.end(); + return; + } + + const body = Buffer.from(await response.arrayBuffer()); + nodeResponse.end(body); +} + +/** Serve one runtime-neutral broker daemon through Node HTTP and ws. */ +export async function serveSessionBrokerDaemon< + SessionView = unknown, + ServerMessage extends SessionServerMessage = SessionServerMessage, + CommandResult = unknown, +>( + options: ServeSessionBrokerDaemonOptions, +): Promise { + const server = createServer(async (incoming, outgoing) => { + const request = await toRequest(incoming, options.hostname, options.port); + const customResponse = await options.handleRequest?.(request, server); + if (customResponse !== undefined) { + await writeResponse(outgoing, customResponse); + return; + } + + const daemonResponse = await options.daemon.handleRequest(request); + if (daemonResponse) { + await writeResponse(outgoing, daemonResponse); + return; + } + + await writeResponse(outgoing, (await options.notFound?.(request)) ?? defaultNotFound()); + }); + const webSocketServer = new WebSocketServer({ noServer: true }); + const peerBySocket = new WeakMap(); + let resolved = false; + let resolveStopped: (() => void) | null = null; + const stopped = new Promise((resolve) => { + resolveStopped = resolve; + }); + const finish = () => { + if (resolved) { + return; + } + + resolved = true; + resolveStopped?.(); + resolveStopped = null; + }; + + webSocketServer.on("connection", (socket: WebSocket) => { + const peer = toNodeConnection(socket); + peerBySocket.set(socket, peer); + socket.on("message", (message: string | Buffer | ArrayBuffer | Buffer[]) => { + const text = + typeof message === "string" + ? message + : Array.isArray(message) + ? Buffer.concat(message).toString() + : message instanceof ArrayBuffer + ? Buffer.from(new Uint8Array(message)).toString() + : Buffer.from(message).toString(); + options.daemon.handleConnectionMessage(peer, text); + }); + socket.on("close", (code: number, reason: Buffer) => { + options.daemon.handleConnectionClose(peerBySocket.get(socket) ?? peer); + void code; + void reason; + }); + }); + + server.on("upgrade", (request, socket, head) => { + const pathname = new URL(`http://${options.hostname}:${options.port}${request.url ?? "/"}`) + .pathname; + if (!options.daemon.matchesSocketPath(pathname)) { + socket.destroy(); + return; + } + + webSocketServer.handleUpgrade(request, socket, head, (webSocket: WebSocket) => { + webSocketServer.emit("connection", webSocket, request); + }); + }); + + await new Promise((resolve, reject) => { + const onError = (error: Error) => { + server.off("listening", onListening); + reject( + (options.formatServeError ?? defaultServeError)(error, { + hostname: options.hostname, + port: options.port, + }), + ); + }; + const onListening = () => { + server.off("error", onError); + resolve(); + }; + + server.once("error", onError); + server.once("listening", onListening); + server.listen(options.port, options.hostname); + }); + + const stop = async () => { + options.daemon.shutdown(); + await new Promise((resolve) => webSocketServer.close(() => resolve())); + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error); + return; + } + + resolve(); + }); + }); + finish(); + }; + + void options.daemon.stopped.then(async () => { + try { + await stop(); + } catch { + finish(); + } + }); + + return { + server, + stopped, + stop, + address: () => server.address(), + }; +} diff --git a/tsconfig.json b/tsconfig.json index 85f6f2da..178489d5 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -16,7 +16,8 @@ "paths": { "@hunk/session-broker": ["packages/session-broker/src/index.ts"], "@hunk/session-broker-bun": ["packages/session-broker-bun/src/index.ts"], - "@hunk/session-broker-core": ["packages/session-broker-core/src/index.ts"] + "@hunk/session-broker-core": ["packages/session-broker-core/src/index.ts"], + "@hunk/session-broker-node": ["packages/session-broker-node/src/index.ts"] }, "strict": true, "skipLibCheck": true, From fec20633681829da6236f35f1baa284e777619df Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Wed, 15 Apr 2026 19:37:42 -0400 Subject: [PATCH 6/8] docs(session-broker): document package roles and usage --- packages/session-broker-bun/README.md | 62 ++++ packages/session-broker-core/README.md | 424 ++----------------------- packages/session-broker-node/README.md | 46 +++ packages/session-broker/README.md | 198 ++++++++++++ 4 files changed, 330 insertions(+), 400 deletions(-) create mode 100644 packages/session-broker-bun/README.md create mode 100644 packages/session-broker-node/README.md create mode 100644 packages/session-broker/README.md diff --git a/packages/session-broker-bun/README.md b/packages/session-broker-bun/README.md new file mode 100644 index 00000000..9775c3d8 --- /dev/null +++ b/packages/session-broker-bun/README.md @@ -0,0 +1,62 @@ +# @hunk/session-broker-bun + +Bun HTTP and websocket adapter for `@hunk/session-broker`. + +Use this package when you want to serve a runtime-neutral `SessionBrokerDaemon` through `Bun.serve(...)`. + +## What it does + +- binds a broker daemon to a Bun HTTP server +- upgrades websocket requests on the daemon socket path +- forwards websocket messages and close events into the daemon +- exposes a `stopped` promise compatible with Hunk's daemon lifecycle +- lets callers override or add custom HTTP routes before the generic broker routes + +## Usage + +```ts +import { SessionBroker, createSessionBrokerDaemon } from "@hunk/session-broker"; +import { serveSessionBrokerDaemon } from "@hunk/session-broker-bun"; + +const broker = new SessionBroker({ + parseRegistration, + parseSnapshot, +}); + +const daemon = createSessionBrokerDaemon({ + broker, + capabilities: { version: 1, name: "example-broker" }, +}); + +const server = serveSessionBrokerDaemon({ + daemon, + hostname: "127.0.0.1", + port: 47657, +}); +``` + +## Custom routes + +You can override or extend request handling with `handleRequest`. + +```ts +const server = serveSessionBrokerDaemon({ + daemon, + hostname: "127.0.0.1", + port: 47657, + handleRequest: async (request) => { + const url = new URL(request.url); + if (url.pathname === "/health") { + return Response.json({ ok: true, overridden: true }); + } + + return undefined; + }, +}); +``` + +Return `undefined` to fall through to the generic broker routes. + +## License + +MIT diff --git a/packages/session-broker-core/README.md b/packages/session-broker-core/README.md index 5709cec5..eca31360 100644 --- a/packages/session-broker-core/README.md +++ b/packages/session-broker-core/README.md @@ -1,434 +1,58 @@ # @hunk/session-broker-core -Runtime-agnostic primitives for brokering live app sessions over any transport. +Low-level shared primitives for the session broker packages. -This package is the clean boundary between Hunk's app-specific session features and the generic mechanics needed to: +This package is an **internal foundation layer**, not the main entrypoint you should build against in most cases. -- register live sessions -- keep snapshots up to date -- select a target session -- dispatch commands to one session -- resolve async command results +## Use this package when -It works in both Node and Bun because the core package does **not** depend on `Bun.*`, HTTP, WebSocket, process launching, or Hunk's review model. +- you are working on broker internals +- you need the shared envelope types and parsers directly +- you are implementing a higher-level broker package on top of it + +## Prefer these packages for normal use + +- `@hunk/session-broker` — main runtime-neutral broker API +- `@hunk/session-broker-bun` — Bun runtime adapter +- `@hunk/session-broker-node` — Node runtime adapter ## What this package includes - shared session envelope types - registration and snapshot wire parsing helpers -- an in-memory `SessionBrokerState` +- low-level in-memory `SessionBrokerState` - selector helpers for `sessionId`, `sessionPath`, and `repoRoot` - generic terminal metadata capture ## What this package does not include -Deliberately **out of scope**: - -- HTTP or WebSocket servers -- client reconnect / heartbeat timers -- daemon launch and restart policy -- CLI formatting and command parsing -- capability negotiation -- app-specific registration info, snapshot state, review projections, or commands - -Those pieces stay in the host app. In Hunk, they live under: +- daemon behavior +- session-side websocket lifecycle helpers +- Bun or Node listener setup +- app-specific command semantics or projections -- [`../../src/session-broker/`](../../src/session-broker) -- [`../../src/hunk-session/`](../../src/hunk-session) -- [`../../src/session/`](../../src/session) +Those higher-level concerns live in the packages above. ## Package boundary The intended split is: -- **`@hunk/session-broker-core`** owns generic broker state and types -- **your app** owns payload schemas, transport wiring, commands, and projections - -The most important seam is `SessionBrokerViewAdapter`: the core never interprets your `info` or `state` payloads directly. Your app teaches it how to: - -- parse registrations -- parse snapshots -- build listed-session views -- build selected-session context -- build review/export views -- list comment-like annotations - -That keeps the package reusable without forcing Hunk's model on other consumers. - -## Install - -This is currently an internal workspace package in the Hunk repo. - -```json -{ - "devDependencies": { - "@hunk/session-broker-core": "workspace:*" - } -} -``` - -## Quick start - -A typical integration has four steps: - -1. define your app's session `info`, `state`, command, and result types -2. implement a `SessionBrokerViewAdapter` -3. create a `SessionBrokerState` -4. wire your transport so incoming messages call `registerSession`, `updateSnapshot`, `markSessionSeen`, and `handleCommandResult` +- **`@hunk/session-broker-core`** — low-level primitives +- **`@hunk/session-broker`** — main broker API +- **runtime adapters** — Bun and Node listener bindings -## Core concepts - -### Registration - -A registration identifies one live session and carries app-owned metadata. - -```ts -import type { SessionRegistration } from "@hunk/session-broker-core"; - -interface MySessionInfo { - title: string; - files: string[]; -} - -type MyRegistration = SessionRegistration; -``` - -### Snapshot - -A snapshot is the current live state for one registered session. - -```ts -import type { SessionSnapshot } from "@hunk/session-broker-core"; - -interface MySessionState { - selectedIndex: number; - noteCount: number; -} - -type MySnapshot = SessionSnapshot; -``` - -### Server message - -Commands sent from the broker to a live session are app-defined. - -```ts -import type { SessionServerMessage } from "@hunk/session-broker-core"; - -type MyServerMessage = - | SessionServerMessage<"annotate", { filePath: string; summary: string }> - | SessionServerMessage<"reload_view", { ref: string }>; -``` - -## Minimal adapter example - -`SessionBrokerState` needs an adapter so the core can stay generic. +## Quick example ```ts import { - SessionBrokerState, brokerWireParsers, parseSessionRegistrationEnvelope, parseSessionSnapshotEnvelope, - type SessionBrokerViewAdapter, - type SessionBrokerListedSession, - type SessionRegistration, - type SessionSnapshot, -} from "@hunk/session-broker-core"; - -interface MySessionInfo { - title: string; - files: string[]; -} - -interface MySessionState { - selectedIndex: number; - noteCount: number; -} - -type MyRegistration = SessionRegistration; -type MySnapshot = SessionSnapshot; - -interface MyListedSession extends SessionBrokerListedSession { - fileCount: number; - snapshot: MySnapshot; -} - -interface MySelectedContext { - sessionId: string; - selectedIndex: number; -} - -interface MySessionReview { - sessionId: string; - title: string; - fileCount: number; -} - -interface MyCommentSummary { - id: string; -} - -function parseInfo(value: unknown): MySessionInfo | null { - const record = brokerWireParsers.asRecord(value); - if (!record || !Array.isArray(record.files)) { - return null; - } - - const title = brokerWireParsers.parseRequiredString(record.title); - const files = record.files.filter((entry): entry is string => typeof entry === "string"); - if (title === null || files.length !== record.files.length) { - return null; - } - - return { title, files }; -} - -function parseState(value: unknown): MySessionState | null { - const record = brokerWireParsers.asRecord(value); - if (!record) { - return null; - } - - const selectedIndex = brokerWireParsers.parseNonNegativeInt(record.selectedIndex); - const noteCount = brokerWireParsers.parseNonNegativeInt(record.noteCount); - if (selectedIndex === null || noteCount === null) { - return null; - } - - return { selectedIndex, noteCount }; -} - -const adapter: SessionBrokerViewAdapter< - MySessionInfo, - MySessionState, - MyListedSession, - MySelectedContext, - MySessionReview, - MyCommentSummary -> = { - parseRegistration: (value) => parseSessionRegistrationEnvelope(value, parseInfo), - parseSnapshot: (value) => parseSessionSnapshotEnvelope(value, parseState), - buildListedSession: (entry) => ({ - sessionId: entry.registration.sessionId, - cwd: entry.registration.cwd, - repoRoot: entry.registration.repoRoot, - title: entry.registration.info.title, - fileCount: entry.registration.info.files.length, - snapshot: entry.snapshot, - }), - buildSelectedContext: (session) => ({ - sessionId: session.sessionId, - selectedIndex: session.snapshot.state.selectedIndex, - }), - buildSessionReview: (entry) => ({ - sessionId: entry.registration.sessionId, - title: entry.registration.info.title, - fileCount: entry.registration.info.files.length, - }), - listComments: () => [], -}; - -const broker = new SessionBrokerState(adapter); -``` - -## Wiring a transport - -The core package does not care whether you use WebSocket, TCP, IPC, or tests with in-memory sockets. It only expects a socket-like object with: - -```ts -{ send(data: string): unknown } -``` - -A typical server-side message loop looks like this: - -```ts -const socket = { - send(data: string) { - realTransport.send(data); - }, -}; - -function handleIncomingMessage(message: any) { - switch (message.type) { - case "register": - broker.registerSession(socket, message.registration, message.snapshot); - break; - case "snapshot": - broker.updateSnapshot(message.sessionId, message.snapshot); - break; - case "heartbeat": - broker.markSessionSeen(message.sessionId); - break; - case "command-result": - broker.handleCommandResult(message); - break; - } -} - -function handleDisconnect() { - broker.unregisterSocket(socket); -} -``` - -## Dispatching commands - -Once sessions are registered, you can target one session and wait for its async result. - -```ts -const result = await broker.dispatchCommand<{ kind: "reloaded"; ref: string }, "reload_view">({ - selector: { sessionId: "session-1" }, - command: "reload_view", - input: { ref: "HEAD" }, - timeoutMessage: "Timed out waiting for the session to reload.", -}); -``` - -Selectors support: - -- `sessionId` -- `sessionPath` — matched against `registration.cwd` -- `repoRoot` - -Useful helpers: - -- `matchesSessionSelector()` -- `normalizeSessionSelector()` -- `describeSessionSelector()` -- `resolveSessionTarget()` - -## Session lifecycle on the app side - -A live session typically sends these messages: - -```ts -import { - SESSION_BROKER_REGISTRATION_VERSION, - type SessionClientMessage, + SessionBrokerState, } from "@hunk/session-broker-core"; - -const registration = { - registrationVersion: SESSION_BROKER_REGISTRATION_VERSION, - sessionId: "session-1", - pid: process.pid, - cwd: process.cwd(), - launchedAt: new Date().toISOString(), - info: { - title: "repo working tree", - files: ["src/example.ts"], - }, -}; - -const snapshot = { - updatedAt: new Date().toISOString(), - state: { - selectedIndex: 0, - noteCount: 0, - }, -}; - -const registerMessage: SessionClientMessage = { - type: "register", - registration, - snapshot, -}; -``` - -Then later: - -- send `type: "snapshot"` when the view changes -- send `type: "heartbeat"` to keep the session fresh -- send `type: "command-result"` after handling a broker command - -## Registration and snapshot parsing - -The core package provides envelope parsers, but your app owns schema validation for `info` and `state`. - -Use: - -- `parseSessionRegistrationEnvelope()` -- `parseSessionSnapshotEnvelope()` -- `brokerWireParsers` - -That split is intentional: the broker validates the shared outer envelope, while your app validates the inner payloads. - -## Terminal metadata - -If your app wants to attach terminal identity to a registration, use `resolveSessionTerminalMetadata()`. - -```ts -import { resolveSessionTerminalMetadata } from "@hunk/session-broker-core"; - -const terminal = resolveSessionTerminalMetadata({ - env: process.env, - tty: "/dev/ttys003", -}); ``` -It captures generic metadata for: - -- tty paths -- tmux panes -- iTerm2 session ids -- `TERM_SESSION_ID`-style terminal session ids - -The shape is generic on purpose so apps do not need terminal-specific top-level fields. - -## API overview - -### Types - -- `SessionTargetInput` -- `SessionTerminalLocation` -- `SessionTerminalMetadata` -- `SessionRegistration` -- `SessionSnapshot` -- `SessionClientMessage` -- `SessionServerMessage` -- `SessionBrokerEntry` -- `SessionBrokerListedSession` -- `SessionBrokerViewAdapter<...>` - -### Functions and constants - -- `SESSION_BROKER_REGISTRATION_VERSION` -- `parseSessionRegistrationEnvelope()` -- `parseSessionSnapshotEnvelope()` -- `brokerWireParsers` -- `matchesSessionSelector()` -- `normalizeSessionSelector()` -- `describeSessionSelector()` -- `resolveSessionTarget()` -- `resolveSessionTerminalMetadata()` - -### State container - -- `SessionBrokerState` - - `listSessions()` - - `getSession()` - - `getSessionReview()` - - `getSelectedContext()` - - `listComments()` - - `registerSession()` - - `updateSnapshot()` - - `markSessionSeen()` - - `unregisterSocket()` - - `pruneStaleSessions()` - - `dispatchCommand()` - - `handleCommandResult()` - - `shutdown()` - -## How Hunk uses this package - -Hunk keeps the generic pieces here and layers app-specific behavior on top: - -- the core broker state and shared envelopes live in this package -- Hunk-specific wire parsing lives in [`../../src/hunk-session/wire.ts`](../../src/hunk-session/wire.ts) -- Hunk-specific projections live in [`../../src/hunk-session/brokerAdapter.ts`](../../src/hunk-session/brokerAdapter.ts) -- Hunk's websocket client and daemon runtime stay in [`../../src/session-broker/`](../../src/session-broker) -- Hunk's HTTP API and session CLI stay in [`../../src/session/`](../../src/session) - -That split is the intended architecture: this package is the reusable core, while Hunk owns the policy and product behavior around it. +If you find yourself reaching for this package directly in app code, double-check whether `@hunk/session-broker` would be the better fit. ## License diff --git a/packages/session-broker-node/README.md b/packages/session-broker-node/README.md new file mode 100644 index 00000000..04e363fa --- /dev/null +++ b/packages/session-broker-node/README.md @@ -0,0 +1,46 @@ +# @hunk/session-broker-node + +Node HTTP and websocket adapter for `@hunk/session-broker`. + +Use this package when you want to prove or use the broker daemon under Node instead of Bun. + +## What it does + +- serves a runtime-neutral `SessionBrokerDaemon` through Node HTTP +- upgrades websocket requests with `ws` +- forwards websocket messages and close events into the daemon +- exposes async startup and shutdown helpers +- keeps the runtime-specific listener code out of `@hunk/session-broker` + +## Usage + +```ts +import { SessionBroker, createSessionBrokerDaemon } from "@hunk/session-broker"; +import { serveSessionBrokerDaemon } from "@hunk/session-broker-node"; + +const broker = new SessionBroker({ + parseRegistration, + parseSnapshot, +}); + +const daemon = createSessionBrokerDaemon({ + broker, + capabilities: { version: 1, name: "example-broker" }, +}); + +const server = await serveSessionBrokerDaemon({ + daemon, + hostname: "127.0.0.1", + port: 47657, +}); +``` + +## Why this package exists + +This package validates that the shared broker API is genuinely runtime-neutral. + +If the Node adapter needs an abstraction the shared package does not provide, the fix should happen in `@hunk/session-broker`, not as Node-only glue. + +## License + +MIT diff --git a/packages/session-broker/README.md b/packages/session-broker/README.md new file mode 100644 index 00000000..57ef1758 --- /dev/null +++ b/packages/session-broker/README.md @@ -0,0 +1,198 @@ +# @hunk/session-broker + +Runtime-neutral session broker daemon and connection helpers. + +This is the **main broker package** in the workspace. It owns the reusable broker behavior without committing to Bun or Node server APIs. + +Use this package when you want to: + +- track live sessions +- register and update session snapshots +- route commands to one live session +- expose broker health and raw list/get/dispatch APIs +- manage session-side websocket connection state + +## Package roles + +This workspace is split into layers: + +- `@hunk/session-broker-core` — low-level shared primitives and envelope parsing +- `@hunk/session-broker` — **main runtime-neutral broker API** +- `@hunk/session-broker-bun` — Bun HTTP/websocket adapter +- `@hunk/session-broker-node` — Node HTTP/websocket adapter + +If you are choosing one package to build against, start here. + +## What this package owns + +- `SessionBroker` raw session registry +- `SessionBrokerDaemon` runtime-neutral daemon engine +- `SessionBrokerConnection` runtime-neutral session-side websocket helper +- raw broker HTTP request types +- health and capabilities handling +- stale-session pruning and idle shutdown + +## What this package does not own + +- Bun `Bun.serve(...)` +- Node `http` / `ws` listener setup +- app-specific command semantics +- app-specific projections like Hunk review exports, comments, or selected hunks +- daemon process launch policy + +## Quick start + +### 1. Create a broker + +```ts +import { + SessionBroker, + brokerWireParsers, + parseSessionRegistrationEnvelope, + parseSessionSnapshotEnvelope, +} from "@hunk/session-broker"; + +interface SessionInfo { + title: string; +} + +interface SessionState { + selectedIndex: number; +} + +function parseInfo(value: unknown): SessionInfo | null { + const record = brokerWireParsers.asRecord(value); + if (!record) { + return null; + } + + const title = brokerWireParsers.parseRequiredString(record.title); + return title === null ? null : { title }; +} + +function parseState(value: unknown): SessionState | null { + const record = brokerWireParsers.asRecord(value); + if (!record) { + return null; + } + + const selectedIndex = brokerWireParsers.parseNonNegativeInt(record.selectedIndex); + return selectedIndex === null ? null : { selectedIndex }; +} + +const broker = new SessionBroker({ + parseRegistration: (value) => parseSessionRegistrationEnvelope(value, parseInfo), + parseSnapshot: (value) => parseSessionSnapshotEnvelope(value, parseState), +}); +``` + +### 2. Create a daemon engine + +```ts +import { createSessionBrokerDaemon } from "@hunk/session-broker"; + +const daemon = createSessionBrokerDaemon({ + broker, + capabilities: { + version: 1, + name: "example-broker", + }, +}); +``` + +At this point the daemon can: + +- handle health requests +- handle capabilities requests +- handle raw `list` / `get` / `dispatch` broker API requests +- process websocket register/snapshot/heartbeat/result messages +- prune stale sessions and request idle shutdown + +### 3. Serve it through a runtime adapter + +#### Bun + +```ts +import { serveSessionBrokerDaemon } from "@hunk/session-broker-bun"; + +const server = serveSessionBrokerDaemon({ + daemon, + hostname: "127.0.0.1", + port: 47657, +}); +``` + +#### Node + +```ts +import { serveSessionBrokerDaemon } from "@hunk/session-broker-node"; + +const server = await serveSessionBrokerDaemon({ + daemon, + hostname: "127.0.0.1", + port: 47657, +}); +``` + +## Session-side connection helper + +Use `SessionBrokerConnection` when an app window or live process needs to stay registered with the broker. + +```ts +import { createSessionBrokerConnection } from "@hunk/session-broker"; + +const connection = createSessionBrokerConnection({ + url: "ws://127.0.0.1:47657/session", + createSocket: (url) => new WebSocket(url), + registration, + snapshot, + bridge: { + dispatchCommand: async (message) => { + return handleCommand(message); + }, + }, +}); + +connection.start(); +``` + +The helper owns: + +- initial `register` +- later `snapshot` updates +- heartbeats +- `command-result` replies +- queued broker commands until the bridge is ready +- reconnect scheduling + +## Raw broker API + +The daemon's runtime-neutral HTTP API is intentionally small: + +- `GET /health` +- `GET /broker/capabilities` +- `POST /broker` + +Request body shapes: + +```ts +{ action: "list" } +{ action: "get", selector: { sessionId: "..." } } +{ action: "dispatch", selector: { sessionId: "..." }, command: "...", input: {...} } +``` + +Responses return raw session records or command results. + +## Hunk-specific layering + +Hunk uses this package for the generic broker lifecycle, then layers product-specific behavior on top: + +- Hunk-specific daemon routes stay in `src/session-broker/brokerServer.ts` +- Hunk-specific CLI commands stay in `src/session/` +- Hunk-specific review projections stay in `src/hunk-session/` + +That split is intentional: this package owns generic broker behavior, while Hunk owns what the session data means. + +## License + +MIT From c829e65f4e6bb76b8f4ba3853d919aa1a903796b Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Wed, 15 Apr 2026 21:59:39 -0400 Subject: [PATCH 7/8] fix(ci): skip simple-git-hooks install in workflows --- .github/workflows/benchmarks.yml | 3 +++ .github/workflows/ci.yml | 3 +++ .github/workflows/pr-ci.yml | 3 +++ .github/workflows/release-prebuilt-npm.yml | 3 +++ 4 files changed, 12 insertions(+) diff --git a/.github/workflows/benchmarks.yml b/.github/workflows/benchmarks.yml index 1cffe420..04af681d 100644 --- a/.github/workflows/benchmarks.yml +++ b/.github/workflows/benchmarks.yml @@ -11,6 +11,9 @@ on: - "LICENSE" workflow_dispatch: +env: + SKIP_INSTALL_SIMPLE_GIT_HOOKS: "1" + concurrency: group: benchmarks-${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5bc8d1fe..667775c4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,6 +10,9 @@ on: - "assets/**" - "LICENSE" +env: + SKIP_INSTALL_SIMPLE_GIT_HOOKS: "1" + concurrency: group: main-ci-${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true diff --git a/.github/workflows/pr-ci.yml b/.github/workflows/pr-ci.yml index 47c6fad7..25c13f63 100644 --- a/.github/workflows/pr-ci.yml +++ b/.github/workflows/pr-ci.yml @@ -8,6 +8,9 @@ on: - "assets/**" - "LICENSE" +env: + SKIP_INSTALL_SIMPLE_GIT_HOOKS: "1" + concurrency: group: pr-ci-${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true diff --git a/.github/workflows/release-prebuilt-npm.yml b/.github/workflows/release-prebuilt-npm.yml index dc422558..d7cc2a95 100644 --- a/.github/workflows/release-prebuilt-npm.yml +++ b/.github/workflows/release-prebuilt-npm.yml @@ -17,6 +17,9 @@ on: tags: - "v*" +env: + SKIP_INSTALL_SIMPLE_GIT_HOOKS: "1" + concurrency: group: release-prebuilt-${{ github.ref }} cancel-in-progress: false From f73a782d1df109f06fb3cdc2e6182b4223355a09 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Wed, 15 Apr 2026 22:58:22 -0400 Subject: [PATCH 8/8] docs(session-broker): explain lifecycle and transport invariants --- packages/session-broker-bun/src/serve.ts | 9 +++++++++ packages/session-broker-core/src/brokerState.ts | 7 +++++++ packages/session-broker-node/src/serve.ts | 8 ++++++++ packages/session-broker/src/connection.ts | 10 ++++++++++ packages/session-broker/src/daemon.ts | 15 +++++++++++++++ src/session-broker/brokerServer.ts | 10 ++++++++++ 6 files changed, 59 insertions(+) diff --git a/packages/session-broker-bun/src/serve.ts b/packages/session-broker-bun/src/serve.ts index 1fa45b2b..40a850dd 100644 --- a/packages/session-broker-bun/src/serve.ts +++ b/packages/session-broker-bun/src/serve.ts @@ -62,6 +62,8 @@ export function serveSessionBrokerDaemon< port: options.port, fetch: async (request, bunServer) => { const customResponse = await options.handleRequest?.(request, bunServer); + // Let host apps extend or override routes first; the generic daemon only handles the + // broker's shared HTTP surface plus the websocket upgrade path. if (customResponse !== undefined) { return customResponse; } @@ -77,6 +79,9 @@ export function serveSessionBrokerDaemon< return undefined; } + // Bun signals failed upgrades by returning false from upgrade rather than by throwing, + // so surface that as one explicit HTTP response here. + return new Response("Expected websocket upgrade.", { status: 426 }); } @@ -104,6 +109,8 @@ export function serveSessionBrokerDaemon< const originalStop = server.stop.bind(server); const stop: typeof server.stop = (closeActiveConnections) => { + // Wrap Bun's stop so callers do not need to remember that the daemon and transport have to be + // torn down together. options.daemon.shutdown(); const result = originalStop(closeActiveConnections); finish(); @@ -118,6 +125,8 @@ export function serveSessionBrokerDaemon< }); void options.daemon.stopped.then(() => { + // Idle shutdown and manual stop share one completion promise, but the Bun server only needs + // the original transport stop here because the daemon has already transitioned to stopped. originalStop(true); finish(); }); diff --git a/packages/session-broker-core/src/brokerState.ts b/packages/session-broker-core/src/brokerState.ts index 4aef046f..6577292b 100644 --- a/packages/session-broker-core/src/brokerState.ts +++ b/packages/session-broker-core/src/brokerState.ts @@ -220,6 +220,8 @@ export class SessionBrokerState< const existing = this.sessions.get(registration.sessionId); if (existing && existing.socket !== socket) { this.sessionIdsBySocket.delete(existing.socket); + // A reconnect on a new socket supersedes the old transport immediately. Reject in-flight + // commands so callers do not wait on a connection that can never answer. this.rejectPendingCommandsForSession( registration.sessionId, new Error("Session reconnected before the command completed."), @@ -321,6 +323,9 @@ export class SessionBrokerState< reject(new Error(timeoutMessage)); }, timeoutMs); + // Record the pending request before sending so synchronous transport failures and later close + // events can both resolve the same command bookkeeping path. + this.pendingCommands.set(requestId, { sessionId: session.sessionId, resolve: (result) => resolve(result as ResultType), @@ -403,6 +408,8 @@ export class SessionBrokerState< private removeSession(sessionId: string, error: Error) { const entry = this.sessions.get(sessionId); + // Centralize all session removal here so socket maps, session maps, and pending command + // rejection stay in sync across disconnects, stale pruning, and incompatible reconnects. if (!entry) { return; } diff --git a/packages/session-broker-node/src/serve.ts b/packages/session-broker-node/src/serve.ts index e0616ce7..82f63b72 100644 --- a/packages/session-broker-node/src/serve.ts +++ b/packages/session-broker-node/src/serve.ts @@ -50,6 +50,7 @@ function toNodeConnection(socket: WebSocket): SessionBrokerPeer { }; } +/** Adapt one Node request into the WHATWG Request shape consumed by the runtime-neutral daemon. */ async function toRequest(request: IncomingMessage, hostname: string, port: number) { const protocol = "encrypted" in request.socket && request.socket.encrypted ? "https" : "http"; const url = `${protocol}://${hostname}:${port}${request.url ?? "/"}`; @@ -108,6 +109,8 @@ export async function serveSessionBrokerDaemon< await writeResponse(outgoing, (await options.notFound?.(request)) ?? defaultNotFound()); }); const webSocketServer = new WebSocketServer({ noServer: true }); + // Reuse one stable peer wrapper per websocket so close events unregister the same logical + // connection object that registration and message handling used earlier. const peerBySocket = new WeakMap(); let resolved = false; let resolveStopped: (() => void) | null = null; @@ -140,6 +143,8 @@ export async function serveSessionBrokerDaemon< }); socket.on("close", (code: number, reason: Buffer) => { options.daemon.handleConnectionClose(peerBySocket.get(socket) ?? peer); + // The runtime-neutral daemon only cares that the transport closed; Node-specific close data + // stays ignored here instead of leaking into the shared broker API. void code; void reason; }); @@ -179,6 +184,7 @@ export async function serveSessionBrokerDaemon< }); const stop = async () => { + // Shut down the daemon first so pending broker commands reject before the transport disappears. options.daemon.shutdown(); await new Promise((resolve) => webSocketServer.close(() => resolve())); await new Promise((resolve, reject) => { @@ -196,6 +202,8 @@ export async function serveSessionBrokerDaemon< void options.daemon.stopped.then(async () => { try { + // Reuse the same stop path when the daemon requests shutdown for idleness so manual and + // automatic teardown keep identical ordering. await stop(); } catch { finish(); diff --git a/packages/session-broker/src/connection.ts b/packages/session-broker/src/connection.ts index 8ab32f2f..7fffe841 100644 --- a/packages/session-broker/src/connection.ts +++ b/packages/session-broker/src/connection.ts @@ -106,6 +106,8 @@ export class SessionBrokerConnection< replaceSession(registration: SessionRegistration, snapshot: SessionSnapshot) { this.registration = registration; this.snapshot = snapshot; + // Re-register instead of sending only a snapshot because selectors like cwd, repoRoot, and the + // session id itself live in the registration envelope. this.send({ type: "register", registration, @@ -132,6 +134,8 @@ export class SessionBrokerConnection< socket.onopen = () => { this.startHeartbeat(); + // Always register again on a fresh socket so the broker can replace any stale connection for + // the same session id before later snapshots or commands arrive. this.send({ type: "register", registration: this.registration, @@ -176,6 +180,8 @@ export class SessionBrokerConnection< }; socket.onerror = () => { + // Normalize raw socket errors through onclose so reconnect and warning policy stays in one + // place instead of splitting behavior across runtime-specific error events. socket.close(); }; } @@ -230,6 +236,8 @@ export class SessionBrokerConnection< private async handleServerMessage(message: ServerMessage) { if (!this.bridge) { + // Sessions may connect before the host app has finished wiring its command bridge. Queue + // broker commands so startup races do not drop user-triggered actions. this.queuedMessages.push(message); return; } @@ -257,6 +265,8 @@ export class SessionBrokerConnection< return; } + // Snapshot the queue up front so commands dispatched while we replay are handled in a later + // pass and the original broker ordering stays intact. const queued = [...this.queuedMessages]; this.queuedMessages = []; diff --git a/packages/session-broker/src/daemon.ts b/packages/session-broker/src/daemon.ts index 00294e99..340abbb5 100644 --- a/packages/session-broker/src/daemon.ts +++ b/packages/session-broker/src/daemon.ts @@ -34,6 +34,7 @@ function jsonError(message: string, status = 400) { return Response.json({ error: message }, { status }); } +/** Parse one websocket envelope without committing the daemon to any runtime socket type. */ function parseSocketEnvelope(message: string) { let parsed: unknown; try { @@ -52,6 +53,7 @@ function parseSocketEnvelope(message: string) { : null; } +/** Decode one raw broker API request body and surface a friendly transport-level error. */ async function parseJsonRequest( request: Request, ) { @@ -62,6 +64,7 @@ async function parseJsonRequest 0) { this.noteActivity(); @@ -173,6 +178,8 @@ export class SessionBrokerDaemon< switch (parsed.type) { case "register": { if (!this.broker.registerSession(connection, parsed.registration, parsed.snapshot)) { + // Close immediately when the registration payload is incompatible so the session does not + // stay connected under stale assumptions after an upgrade. connection.close?.(INCOMPATIBLE_PAYLOAD_CLOSE_CODE, "Incompatible session registration."); return; } @@ -185,6 +192,8 @@ export class SessionBrokerDaemon< return; } + // Snapshot updates are only valid after registration. Closing missing or invalid sessions + // keeps the broker state single-sourced instead of guessing how to recover. const updateResult = this.broker.updateSnapshot(parsed.sessionId, parsed.snapshot); if (updateResult === "not-found") { connection.close?.( @@ -281,6 +290,8 @@ export class SessionBrokerDaemon< this.idleTimer = null; } + // Only arm idle shutdown when the daemon is truly quiescent. Any live session or in-flight + // command keeps the process alive, even if no new HTTP requests arrive. if (this.shuttingDown || this.idleTimeoutMs <= 0 || this.hasActiveWork()) { return; } @@ -295,6 +306,8 @@ export class SessionBrokerDaemon< return; } + // Re-check the wall clock when the timer fires because work may have happened after the + // timer was scheduled but before it got a chance to run. if (Date.now() - this.lastActivityAt < this.idleTimeoutMs) { this.refreshIdleTimer(); return; @@ -324,6 +337,8 @@ export class SessionBrokerDaemon< break; case "dispatch": response = { + // The HTTP API stays generic JSON, while the broker keeps ownership of target + // resolution, timeout handling, and websocket command delivery. result: await this.broker.dispatchCommand({ selector: input.selector, command: input.command, diff --git a/src/session-broker/brokerServer.ts b/src/session-broker/brokerServer.ts index c2d100cd..fa3a600e 100644 --- a/src/session-broker/brokerServer.ts +++ b/src/session-broker/brokerServer.ts @@ -246,6 +246,10 @@ async function handleSessionApiRequest(state: HunkSessionBrokerState, request: R type ListedHunkSession = ReturnType[number]; +/** + * Adapt Hunk's richer broker state into the minimal shared controller surface expected by the + * generic daemon package. Hunk-only review/context helpers stay above this boundary. + */ function createHunkBrokerController( state: HunkSessionBrokerState, ): SessionBrokerController { @@ -303,6 +307,8 @@ export function serveSessionBrokerDaemon( const url = new URL(request.url); if (url.pathname === "/health") { + // Extend the generic health payload with the Hunk-specific companion endpoints that older + // CLI clients and debugging workflows still expect to discover from one place. return Response.json({ ...daemon.getHealth(), sessionApi: `${config.httpOrigin}${HUNK_SESSION_API_PATH}`, @@ -315,11 +321,15 @@ export function serveSessionBrokerDaemon( return Response.json(sessionCapabilities()); } + // Keep the richer Hunk session API here rather than in the shared package so commands like + // review, reload, and comment flows stay app-specific. if (url.pathname === HUNK_SESSION_API_PATH) { return handleSessionApiRequest(state, request); } if (url.pathname === LEGACY_MCP_PATH) { + // Preserve an explicit tombstone for the removed MCP route so stale automation gets a clear + // upgrade message instead of a generic 404. return jsonError( "This app no longer exposes agent-facing MCP tools. Use the session CLI instead.", 410,