diff options
| author | Adam Malczewski <[email protected]> | 2026-06-06 23:18:13 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-06 23:18:13 +0900 |
| commit | 395d7565b46416e9914db099b9a4c226f4c95648 (patch) | |
| tree | 8b3c58bfb0db9bbe0025f6d2029885fa2bee92d0 /packages | |
| parent | 3e95b26ee2928c40db581bed4c138d3fa842b753 (diff) | |
| download | dispatch-395d7565b46416e9914db099b9a4c226f4c95648.tar.gz dispatch-395d7565b46416e9914db099b9a4c226f4c95648.zip | |
refactor(transport-http,host-bin): transport-http owns its Bun.serve (fix log scope)
Make transport-http a full-fidelity extension that runs its own Bun.serve
inside activate(host) — symmetric with transport-ws. The Hono app is now built
with the extension-scoped host, so all HTTP edge logs are correctly attributed
extensionId=transport-http instead of the host-bin __host__ scope (verified
live in the journal).
- transport-http: createTransportHttpExtension() factory; activate builds the
app + Bun.serve, reads host.config httpPort (?? 24203); deactivate stops it.
- host-bin: drops the HTTP Bun.serve + createServer call; config.ts maps
BACKEND_PORT/PORT -> httpPort. host-bin now serves no transport (both
transports self-serve); boot log -> 'Dispatch booted'.
- +5 bun lifecycle tests wired into test:bun.
No contract change (composition wiring). Verified live: HTTP serves on :24203;
journal edge logs now scoped transport-http. typecheck clean, 498 vitest + 89
bun, biome clean.
Diffstat (limited to 'packages')
| -rw-r--r-- | packages/host-bin/src/config.ts | 8 | ||||
| -rw-r--r-- | packages/host-bin/src/main.ts | 15 | ||||
| -rw-r--r-- | packages/transport-http/src/extension.ts | 53 | ||||
| -rw-r--r-- | packages/transport-http/src/index.ts | 2 | ||||
| -rw-r--r-- | packages/transport-http/src/server.bun.test.ts | 213 |
5 files changed, 265 insertions, 26 deletions
diff --git a/packages/host-bin/src/config.ts b/packages/host-bin/src/config.ts index cbf7bce..cf6b0ce 100644 --- a/packages/host-bin/src/config.ts +++ b/packages/host-bin/src/config.ts @@ -20,6 +20,14 @@ export function envToConfigMap( map["provider.openai-compat.model"] = model; } + const httpPort = env.BACKEND_PORT ?? env.PORT; + if (httpPort !== undefined) { + const n = Number(httpPort); + if (Number.isFinite(n) && n > 0) { + map.httpPort = n; + } + } + return map; } diff --git a/packages/host-bin/src/main.ts b/packages/host-bin/src/main.ts index 68cbbdd..b18f105 100644 --- a/packages/host-bin/src/main.ts +++ b/packages/host-bin/src/main.ts @@ -24,7 +24,7 @@ import { createSqliteStorage, extension as storageSqliteExt } from "@dispatch/st import { createLoadedExtensionsExtension } from "@dispatch/surface-loaded-extensions"; import { createSurfaceRegistryExtension } from "@dispatch/surface-registry"; import { extension as toolReadFileExt } from "@dispatch/tool-read-file"; -import { createServer, extension as transportHttpExt } from "@dispatch/transport-http"; +import { createTransportHttpExtension } from "@dispatch/transport-http"; import { createTransportWsExtension } from "@dispatch/transport-ws"; import type { ChildHandle } from "./collector-supervisor.js"; import { createCollectorSupervisor } from "./collector-supervisor.js"; @@ -63,7 +63,7 @@ const CORE_EXTENSIONS: readonly Extension[] = [ credentials: [{ name: "opencode", providerId: "openai-compat" }], }), sessionOrchestratorExt, - transportHttpExt, + createTransportHttpExtension(), // Surface extensions — dependency order: surface-registry first, then consumers. createSurfaceRegistryExtension(), createTransportWsExtension(), @@ -125,13 +125,6 @@ async function boot(): Promise<void> { } } - const hostAPI = host.getHostAPI(); - const app = createServer(hostAPI); - - // Port precedence: BACKEND_PORT (the rewrite's assigned port) → PORT → default. - const port = Number(process.env.BACKEND_PORT) || Number(process.env.PORT) || 24203; - const server = Bun.serve({ fetch: app.fetch, port }); - const shutdown = async () => { logger.info("Shutting down — draining collector"); await supervisor.stop(); @@ -140,8 +133,8 @@ async function boot(): Promise<void> { process.on("SIGINT", shutdown); process.on("SIGTERM", shutdown); - logger.info(`Dispatch listening on http://localhost:${server.port}`); - console.info(`Dispatch listening on http://localhost:${server.port}`); + logger.info("Dispatch booted"); + console.info("Dispatch booted"); } boot().catch((err) => { diff --git a/packages/transport-http/src/extension.ts b/packages/transport-http/src/extension.ts index dda722e..adbf87f 100644 --- a/packages/transport-http/src/extension.ts +++ b/packages/transport-http/src/extension.ts @@ -1,5 +1,4 @@ import type { Extension, HostAPI, Manifest } from "@dispatch/kernel"; -import type { Hono } from "hono"; import { createApp } from "./app.js"; import { conversationStoreHandle, @@ -19,18 +18,44 @@ export const manifest: Manifest = { activation: "eager", }; -export interface CreateServerOptions { - readonly port?: number; -} +export function createTransportHttpExtension(): Extension & { + readonly _testServer: ReturnType<typeof Bun.serve> | undefined; +} { + let server: ReturnType<typeof Bun.serve> | undefined; -export function createServer(host: HostAPI, _opts?: CreateServerOptions): Hono { - const conversationStore = host.getService(conversationStoreHandle); - const orchestrator = host.getService(sessionOrchestratorHandle); - const credentialStore = host.getService(credentialStoreHandle); - return createApp({ conversationStore, orchestrator, credentialStore, logger: host.logger }); -} + return { + get _testServer() { + return server; + }, + manifest, + async activate(host: HostAPI) { + const conversationStore = host.getService(conversationStoreHandle); + const orchestrator = host.getService(sessionOrchestratorHandle); + const credentialStore = host.getService(credentialStoreHandle); + const logger = host.logger; -export const extension: Extension = { - manifest, - activate: (_host: HostAPI) => {}, -}; + const app = createApp({ + conversationStore, + orchestrator, + credentialStore, + logger, + }); + + const port = host.config.get<number>("httpPort") ?? 24203; + + server = Bun.serve({ + port, + fetch: app.fetch, + }); + + logger.info("transport-http: listening", { port }); + }, + + deactivate() { + if (server) { + server.stop(); + server = undefined; + } + }, + }; +} diff --git a/packages/transport-http/src/index.ts b/packages/transport-http/src/index.ts index d92319c..171cf6a 100644 --- a/packages/transport-http/src/index.ts +++ b/packages/transport-http/src/index.ts @@ -1,6 +1,6 @@ export type { CreateServerOptions } from "./app.js"; export { createApp } from "./app.js"; -export { createServer, extension, manifest } from "./extension.js"; +export { createTransportHttpExtension, manifest } from "./extension.js"; export type { ChatCommand, ParseError, ParseResult, SinceSeqResult } from "./logic.js"; export { isParseError, diff --git a/packages/transport-http/src/server.bun.test.ts b/packages/transport-http/src/server.bun.test.ts new file mode 100644 index 0000000..e824d18 --- /dev/null +++ b/packages/transport-http/src/server.bun.test.ts @@ -0,0 +1,213 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import type { ConfigAccess, HostAPI, Logger } from "@dispatch/kernel"; +import { createApp } from "./app.js"; +import { createTransportHttpExtension } from "./index.js"; +import type { ConversationStore, CredentialStore, SessionOrchestrator } from "./seam.js"; + +function fakeLogger(): Logger { + return { + debug() {}, + info() {}, + warn() {}, + error() {}, + child() { + return fakeLogger(); + }, + span() { + return { + id: "fake-span", + log: fakeLogger(), + setAttributes() {}, + addLink() {}, + child() { + return this; + }, + end() {}, + }; + }, + }; +} + +function fakeConversationStore(): ConversationStore { + return { + async append() {}, + async load() { + return []; + }, + async loadSince() { + return []; + }, + }; +} + +function fakeOrchestrator(): SessionOrchestrator { + return { + async handleMessage() {}, + }; +} + +function fakeCredentialStore(): CredentialStore { + return { + resolve() { + return undefined; + }, + async listCatalog() { + return []; + }, + }; +} + +function fakeConfig(overrides: Record<string, unknown> = {}): ConfigAccess { + return { + get<T>(key: string): T | undefined { + return overrides[key] as T | undefined; + }, + getAll() { + return overrides; + }, + }; +} + +const SERVICES = new Map<string, unknown>([ + ["conversation-store/store", fakeConversationStore()], + ["session-orchestrator/orchestrator", fakeOrchestrator()], + ["credential-store/registry", fakeCredentialStore()], +]); + +function createFakeHostAPI(configOverrides: Record<string, unknown> = {}): HostAPI { + return { + defineTool() {}, + defineProvider() {}, + defineAuth() {}, + on() { + return () => {}; + }, + addFilter() { + return () => {}; + }, + provideService() {}, + getService(handle) { + return SERVICES.get(handle.id) as never; + }, + storage() { + return { + get: async () => null, + set: async () => {}, + delete: async () => {}, + has: async () => false, + keys: async () => [], + }; + }, + config: fakeConfig(configOverrides), + secrets: { get: async () => null, set: async () => {}, delete: async () => {} }, + permissions: { check: async () => ({ allowed: true }) }, + events: { emit() {} }, + logger: fakeLogger(), + getProviders() { + return new Map(); + }, + getTools() { + return new Map(); + }, + getAuthProviders() { + return new Map(); + }, + getAuthProvider() { + return undefined; + }, + getExtensions() { + return []; + }, + scheduler: { register() {} }, + }; +} + +// ── Server helper (mirrors extension.ts logic for direct testing) ──────── + +function startServer(port = 0) { + const app = createApp({ + conversationStore: fakeConversationStore(), + orchestrator: fakeOrchestrator(), + credentialStore: fakeCredentialStore(), + }); + return Bun.serve({ port, fetch: app.fetch }); +} + +// ── Tests ──────────────────────────────────────────────────────────────── + +describe("serves HTTP on the configured port", () => { + let server: ReturnType<typeof Bun.serve>; + let port: number; + + afterEach(() => { + server.stop(); + }); + + test("GET /health returns 200", async () => { + server = startServer(); + port = server.port as number; + + const res = await fetch(`http://localhost:${port}/health`); + expect(res.status).toBe(200); + const body = (await res.json()) as { ok: boolean }; + expect(body.ok).toBe(true); + }); + + test("POST /chat returns NDJSON", async () => { + server = startServer(); + port = server.port as number; + + const res = await fetch(`http://localhost:${port}/chat`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ message: "hi", conversationId: "conv1" }), + }); + expect(res.status).toBe(200); + expect(res.headers.get("Content-Type")).toBe("application/x-ndjson"); + }); + + test("GET /models returns 200", async () => { + server = startServer(); + port = server.port as number; + + const res = await fetch(`http://localhost:${port}/models`); + expect(res.status).toBe(200); + const body = (await res.json()) as { models: readonly string[] }; + expect(body.models).toEqual([]); + }); + + test("GET /conversations/:id returns 200", async () => { + server = startServer(); + port = server.port as number; + + const res = await fetch(`http://localhost:${port}/conversations/conv1`); + expect(res.status).toBe(200); + const body = (await res.json()) as { chunks: readonly unknown[]; latestSeq: number }; + expect(body.chunks).toEqual([]); + expect(body.latestSeq).toBe(0); + }); +}); + +describe("extension lifecycle", () => { + test("activate starts server on config port and deactivate stops it", async () => { + const ext = createTransportHttpExtension(); + const host = createFakeHostAPI({ httpPort: 0 }); + await ext.activate(host); + const server = ext._testServer; + expect(server).toBeDefined(); + const port = server?.port as number; + expect(port).toBeGreaterThan(0); + + const res = await fetch(`http://localhost:${port}/health`); + expect(res.status).toBe(200); + + ext.deactivate?.(); + + try { + await fetch(`http://localhost:${port}/health`); + expect(true).toBe(false); + } catch { + // expected — server stopped + } + }); +}); |
