summaryrefslogtreecommitdiffhomepage
path: root/packages
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-06 23:18:13 +0900
committerAdam Malczewski <[email protected]>2026-06-06 23:18:13 +0900
commit395d7565b46416e9914db099b9a4c226f4c95648 (patch)
tree8b3c58bfb0db9bbe0025f6d2029885fa2bee92d0 /packages
parent3e95b26ee2928c40db581bed4c138d3fa842b753 (diff)
downloaddispatch-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.ts8
-rw-r--r--packages/host-bin/src/main.ts15
-rw-r--r--packages/transport-http/src/extension.ts53
-rw-r--r--packages/transport-http/src/index.ts2
-rw-r--r--packages/transport-http/src/server.bun.test.ts213
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
+ }
+ });
+});