summaryrefslogtreecommitdiffhomepage
path: root/packages/host-bin
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-05 00:04:58 +0900
committerAdam Malczewski <[email protected]>2026-06-05 00:04:58 +0900
commitbf52b114711f148df0446911ce2b0b3783c4d5ab (patch)
tree527a00c1f5406f55ef00481f8704c0e44cd88e56 /packages/host-bin
parente56b591d56ea3f3007c612da2f0fe2004d696f45 (diff)
downloaddispatch-bf52b114711f148df0446911ce2b0b3783c4d5ab.tar.gz
dispatch-bf52b114711f148df0446911ce2b0b3783c4d5ab.zip
feat(host-bin): composition root — boot, discover+activate extensions, Bun.serve; full-fidelity wiring (178 tests)
Diffstat (limited to 'packages/host-bin')
-rw-r--r--packages/host-bin/package.json15
-rw-r--r--packages/host-bin/src/config.test.ts77
-rw-r--r--packages/host-bin/src/config.ts35
-rw-r--r--packages/host-bin/src/main.ts138
-rw-r--r--packages/host-bin/tsconfig.json14
5 files changed, 279 insertions, 0 deletions
diff --git a/packages/host-bin/package.json b/packages/host-bin/package.json
new file mode 100644
index 0000000..59ea899
--- /dev/null
+++ b/packages/host-bin/package.json
@@ -0,0 +1,15 @@
+{
+ "name": "@dispatch/host-bin",
+ "version": "0.0.0",
+ "type": "module",
+ "private": true,
+ "dependencies": {
+ "@dispatch/kernel": "workspace:*",
+ "@dispatch/storage-sqlite": "workspace:*",
+ "@dispatch/conversation-store": "workspace:*",
+ "@dispatch/auth-apikey": "workspace:*",
+ "@dispatch/provider-openai-compat": "workspace:*",
+ "@dispatch/session-orchestrator": "workspace:*",
+ "@dispatch/transport-http": "workspace:*"
+ }
+}
diff --git a/packages/host-bin/src/config.test.ts b/packages/host-bin/src/config.test.ts
new file mode 100644
index 0000000..a5cfde1
--- /dev/null
+++ b/packages/host-bin/src/config.test.ts
@@ -0,0 +1,77 @@
+import { describe, expect, it } from "vitest";
+import { configMapToAccess, envToConfigMap } from "./config.js";
+
+describe("envToConfigMap", () => {
+ it("maps DISPATCH_API_KEY to provider.openai-compat.apiKey", () => {
+ const result = envToConfigMap({ DISPATCH_API_KEY: "sk-test-123" });
+ expect(result["provider.openai-compat.apiKey"]).toBe("sk-test-123");
+ });
+
+ it("maps DISPATCH_BASE_URL to provider.openai-compat.baseURL", () => {
+ const result = envToConfigMap({ DISPATCH_BASE_URL: "https://custom.api/v1" });
+ expect(result["provider.openai-compat.baseURL"]).toBe("https://custom.api/v1");
+ });
+
+ it("maps DISPATCH_MODEL to provider.openai-compat.model", () => {
+ const result = envToConfigMap({ DISPATCH_MODEL: "gpt-4" });
+ expect(result["provider.openai-compat.model"]).toBe("gpt-4");
+ });
+
+ it("maps all three env vars together", () => {
+ const result = envToConfigMap({
+ DISPATCH_API_KEY: "key",
+ DISPATCH_BASE_URL: "https://api.example.com",
+ DISPATCH_MODEL: "my-model",
+ });
+ expect(result).toEqual({
+ "provider.openai-compat.apiKey": "key",
+ "provider.openai-compat.baseURL": "https://api.example.com",
+ "provider.openai-compat.model": "my-model",
+ });
+ });
+
+ it("returns empty map when no relevant env vars are set", () => {
+ const result = envToConfigMap({ HOME: "/home/user", PATH: "/usr/bin" });
+ expect(result).toEqual({});
+ });
+
+ it("skips undefined env vars", () => {
+ const result = envToConfigMap({ DISPATCH_API_KEY: undefined });
+ expect(result).toEqual({});
+ });
+
+ it("includes only set vars when some are missing", () => {
+ const result = envToConfigMap({
+ DISPATCH_API_KEY: "key",
+ DISPATCH_MODEL: undefined,
+ });
+ expect(result).toEqual({
+ "provider.openai-compat.apiKey": "key",
+ });
+ expect(result["provider.openai-compat.baseURL"]).toBeUndefined();
+ expect(result["provider.openai-compat.model"]).toBeUndefined();
+ });
+});
+
+describe("configMapToAccess", () => {
+ it("returns value for existing key", () => {
+ const access = configMapToAccess({ "provider.openai-compat.apiKey": "sk-123" });
+ expect(access.get("provider.openai-compat.apiKey")).toBe("sk-123");
+ });
+
+ it("returns undefined for missing key", () => {
+ const access = configMapToAccess({});
+ expect(access.get("nonexistent")).toBeUndefined();
+ });
+
+ it("returns typed value", () => {
+ const access = configMapToAccess({ "some.number": 42 });
+ expect(access.get<number>("some.number")).toBe(42);
+ });
+
+ it("getAll returns the full map", () => {
+ const map = { a: 1, b: "two" };
+ const access = configMapToAccess(map);
+ expect(access.getAll()).toEqual(map);
+ });
+});
diff --git a/packages/host-bin/src/config.ts b/packages/host-bin/src/config.ts
new file mode 100644
index 0000000..cbf7bce
--- /dev/null
+++ b/packages/host-bin/src/config.ts
@@ -0,0 +1,35 @@
+import type { ConfigAccess } from "@dispatch/kernel";
+
+export function envToConfigMap(
+ env: Readonly<Record<string, string | undefined>>,
+): Record<string, unknown> {
+ const map: Record<string, unknown> = {};
+
+ const apiKey = env.DISPATCH_API_KEY;
+ if (apiKey !== undefined) {
+ map["provider.openai-compat.apiKey"] = apiKey;
+ }
+
+ const baseURL = env.DISPATCH_BASE_URL;
+ if (baseURL !== undefined) {
+ map["provider.openai-compat.baseURL"] = baseURL;
+ }
+
+ const model = env.DISPATCH_MODEL;
+ if (model !== undefined) {
+ map["provider.openai-compat.model"] = model;
+ }
+
+ return map;
+}
+
+export function configMapToAccess(map: Readonly<Record<string, unknown>>): ConfigAccess {
+ return {
+ get<T = unknown>(key: string): T | undefined {
+ return map[key] as T | undefined;
+ },
+ getAll(): Readonly<Record<string, unknown>> {
+ return map;
+ },
+ };
+}
diff --git a/packages/host-bin/src/main.ts b/packages/host-bin/src/main.ts
new file mode 100644
index 0000000..8a7dd57
--- /dev/null
+++ b/packages/host-bin/src/main.ts
@@ -0,0 +1,138 @@
+import { mkdirSync } from "node:fs";
+import { dirname } from "node:path";
+import { extension as authApikeyExt } from "@dispatch/auth-apikey";
+import { extension as conversationStoreExt } from "@dispatch/conversation-store";
+import {
+ type ConfigAccess,
+ createBus,
+ createHost,
+ type EventsEmitter,
+ type Extension,
+ type HostAPI,
+ type HostDeps,
+ type Logger,
+ type PermissionGate,
+ type ScheduledJob,
+ type SecretsAccess,
+ type StorageNamespace,
+} from "@dispatch/kernel";
+import { extension as providerOpenaiCompatExt } from "@dispatch/provider-openai-compat";
+import { extension as sessionOrchestratorExt } from "@dispatch/session-orchestrator";
+import { createSqliteStorage, extension as storageSqliteExt } from "@dispatch/storage-sqlite";
+import { createServer, extension as transportHttpExt } from "@dispatch/transport-http";
+import { configMapToAccess, envToConfigMap } from "./config.js";
+
+function createConsoleLogger(): Logger {
+ return {
+ debug: (message: string, ...args: unknown[]) => console.debug(`[debug] ${message}`, ...args),
+ info: (message: string, ...args: unknown[]) => console.info(`[info] ${message}`, ...args),
+ warn: (message: string, ...args: unknown[]) => console.warn(`[warn] ${message}`, ...args),
+ error: (message: string, ...args: unknown[]) => console.error(`[error] ${message}`, ...args),
+ };
+}
+
+function createEmptySecrets(): SecretsAccess {
+ return {
+ get: async () => null,
+ set: async () => {},
+ delete: async () => {},
+ };
+}
+
+function createAllowAllPermissions(): PermissionGate {
+ return {
+ check: async () => ({ allowed: true }),
+ };
+}
+
+function createNoopScheduler(): { readonly register: (job: ScheduledJob) => void } {
+ return { register: () => {} };
+}
+
+function createNoopEvents(): EventsEmitter {
+ return { emit: () => {} };
+}
+
+function buildPostActivationHostAPI(
+ host: {
+ getProviders: () => ReadonlyMap<string, unknown>;
+ getTools: () => ReadonlyMap<string, unknown>;
+ },
+ deps: HostDeps,
+): HostAPI {
+ const notAvailable = () => {
+ throw new Error("Registration not available after activation");
+ };
+ return {
+ defineTool: notAvailable,
+ defineProvider: notAvailable,
+ defineAuth: notAvailable,
+ on: (hook, handler) => deps.bus.on(hook, handler),
+ addFilter: (hook, fn) => deps.bus.addFilter(hook, fn),
+ provideService: (handle, impl) => deps.bus.provideService(handle, impl),
+ getService: (handle) => deps.bus.getService(handle),
+ storage: (namespace: string) => deps.storageFactory(namespace),
+ config: deps.config,
+ secrets: deps.secrets,
+ permissions: deps.permissions,
+ events: deps.events,
+ logger: deps.logger,
+ getProviders: () => host.getProviders() as ReturnType<HostAPI["getProviders"]>,
+ getTools: () => host.getTools() as ReturnType<HostAPI["getTools"]>,
+ scheduler: { register: (job: ScheduledJob) => deps.scheduler.register(job) },
+ };
+}
+
+const CORE_EXTENSIONS: readonly Extension[] = [
+ storageSqliteExt,
+ conversationStoreExt,
+ authApikeyExt,
+ providerOpenaiCompatExt,
+ sessionOrchestratorExt,
+ transportHttpExt,
+];
+
+async function boot(): Promise<void> {
+ const logger = createConsoleLogger();
+
+ const dbPath = process.env.DISPATCH_DB ?? "./.dispatch-data/dispatch.db";
+ mkdirSync(dirname(dbPath), { recursive: true });
+ const sqliteBackend = createSqliteStorage({ path: dbPath });
+ const storageFactory = (namespace: string): StorageNamespace => sqliteBackend.storage(namespace);
+
+ const configMap = envToConfigMap(process.env as Readonly<Record<string, string | undefined>>);
+ const config: ConfigAccess = configMapToAccess(configMap);
+
+ const deps: HostDeps = {
+ logger,
+ config,
+ storageFactory,
+ secrets: createEmptySecrets(),
+ permissions: createAllowAllPermissions(),
+ scheduler: createNoopScheduler(),
+ bus: createBus(logger),
+ events: createNoopEvents(),
+ };
+
+ const host = createHost(CORE_EXTENSIONS, deps);
+ await host.activate();
+
+ const disabled = host.getDisabled();
+ if (disabled.length > 0) {
+ for (const d of disabled) {
+ logger.warn(`Extension "${d.manifest.id}" disabled: ${d.reason}`);
+ }
+ }
+
+ const hostAPI = buildPostActivationHostAPI(host, deps);
+ const app = createServer(hostAPI);
+
+ const port = Number(process.env.PORT) || 3000;
+ const server = Bun.serve({ fetch: app.fetch, port });
+ logger.info(`Dispatch listening on http://localhost:${server.port}`);
+}
+
+boot().catch((err) => {
+ console.error("Fatal boot error:", err);
+ process.exit(1);
+});
diff --git a/packages/host-bin/tsconfig.json b/packages/host-bin/tsconfig.json
new file mode 100644
index 0000000..a811c47
--- /dev/null
+++ b/packages/host-bin/tsconfig.json
@@ -0,0 +1,14 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true },
+ "include": ["src/**/*.ts"],
+ "references": [
+ { "path": "../kernel" },
+ { "path": "../storage-sqlite" },
+ { "path": "../conversation-store" },
+ { "path": "../auth-apikey" },
+ { "path": "../provider-openai-compat" },
+ { "path": "../session-orchestrator" },
+ { "path": "../transport-http" }
+ ]
+}