@versdotsh/reef 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/.github/workflows/test.yml +47 -0
  2. package/README.md +257 -0
  3. package/bun.lock +587 -0
  4. package/examples/services/board/board.test.ts +215 -0
  5. package/examples/services/board/index.ts +155 -0
  6. package/examples/services/board/routes.ts +335 -0
  7. package/examples/services/board/store.ts +329 -0
  8. package/examples/services/board/tools.ts +214 -0
  9. package/examples/services/commits/commits.test.ts +74 -0
  10. package/examples/services/commits/index.ts +14 -0
  11. package/examples/services/commits/routes.ts +43 -0
  12. package/examples/services/commits/store.ts +114 -0
  13. package/examples/services/feed/behaviors.ts +23 -0
  14. package/examples/services/feed/feed.test.ts +101 -0
  15. package/examples/services/feed/index.ts +117 -0
  16. package/examples/services/feed/routes.ts +224 -0
  17. package/examples/services/feed/store.ts +194 -0
  18. package/examples/services/feed/tools.ts +83 -0
  19. package/examples/services/journal/index.ts +15 -0
  20. package/examples/services/journal/journal.test.ts +57 -0
  21. package/examples/services/journal/routes.ts +45 -0
  22. package/examples/services/journal/store.ts +119 -0
  23. package/examples/services/journal/tools.ts +32 -0
  24. package/examples/services/log/index.ts +15 -0
  25. package/examples/services/log/log.test.ts +70 -0
  26. package/examples/services/log/routes.ts +44 -0
  27. package/examples/services/log/store.ts +105 -0
  28. package/examples/services/log/tools.ts +57 -0
  29. package/examples/services/registry/behaviors.ts +128 -0
  30. package/examples/services/registry/index.ts +37 -0
  31. package/examples/services/registry/registry.test.ts +135 -0
  32. package/examples/services/registry/routes.ts +76 -0
  33. package/examples/services/registry/store.ts +224 -0
  34. package/examples/services/registry/tools.ts +116 -0
  35. package/examples/services/reports/index.ts +14 -0
  36. package/examples/services/reports/reports.test.ts +75 -0
  37. package/examples/services/reports/routes.ts +42 -0
  38. package/examples/services/reports/store.ts +110 -0
  39. package/examples/services/ui/auth.ts +61 -0
  40. package/examples/services/ui/index.ts +16 -0
  41. package/examples/services/ui/routes.ts +160 -0
  42. package/examples/services/ui/static/app.js +369 -0
  43. package/examples/services/ui/static/index.html +42 -0
  44. package/examples/services/ui/static/style.css +157 -0
  45. package/examples/services/usage/behaviors.ts +166 -0
  46. package/examples/services/usage/index.ts +19 -0
  47. package/examples/services/usage/routes.ts +53 -0
  48. package/examples/services/usage/store.ts +341 -0
  49. package/examples/services/usage/tools.ts +75 -0
  50. package/examples/services/usage/usage.test.ts +91 -0
  51. package/package.json +29 -0
  52. package/services/agent/index.ts +465 -0
  53. package/services/board/index.ts +155 -0
  54. package/services/board/routes.ts +335 -0
  55. package/services/board/store.ts +329 -0
  56. package/services/board/tools.ts +214 -0
  57. package/services/docs/index.ts +391 -0
  58. package/services/feed/behaviors.ts +23 -0
  59. package/services/feed/index.ts +117 -0
  60. package/services/feed/routes.ts +224 -0
  61. package/services/feed/store.ts +194 -0
  62. package/services/feed/tools.ts +83 -0
  63. package/services/installer/index.ts +574 -0
  64. package/services/services/index.ts +165 -0
  65. package/services/ui/auth.ts +61 -0
  66. package/services/ui/index.ts +16 -0
  67. package/services/ui/routes.ts +160 -0
  68. package/services/ui/static/app.js +369 -0
  69. package/services/ui/static/index.html +42 -0
  70. package/services/ui/static/style.css +157 -0
  71. package/skills/create-service/SKILL.md +698 -0
  72. package/src/core/auth.ts +28 -0
  73. package/src/core/client.ts +99 -0
  74. package/src/core/discover.ts +152 -0
  75. package/src/core/events.ts +44 -0
  76. package/src/core/extension.ts +66 -0
  77. package/src/core/server.ts +262 -0
  78. package/src/core/testing.ts +155 -0
  79. package/src/core/types.ts +194 -0
  80. package/src/extension.ts +16 -0
  81. package/src/main.ts +11 -0
  82. package/tests/server.test.ts +1338 -0
  83. package/tsconfig.json +29 -0
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Bearer token auth middleware for Hono.
3
+ *
4
+ * If VERS_AUTH_TOKEN is set, requires Authorization: Bearer <token>.
5
+ * If not set, all requests pass through (dev mode).
6
+ */
7
+
8
+ import type { MiddlewareHandler } from "hono";
9
+
10
+ export function bearerAuth(): MiddlewareHandler {
11
+ return async (c, next) => {
12
+ const token = process.env.VERS_AUTH_TOKEN;
13
+
14
+ if (!token) return next();
15
+
16
+ const authHeader = c.req.header("Authorization");
17
+ if (!authHeader) {
18
+ return c.json({ error: "Unauthorized — missing Authorization header" }, 401);
19
+ }
20
+
21
+ const match = authHeader.match(/^Bearer\s+(.+)$/i);
22
+ if (!match || match[1] !== token) {
23
+ return c.json({ error: "Unauthorized — invalid token" }, 401);
24
+ }
25
+
26
+ return next();
27
+ };
28
+ }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * FleetClient — shared HTTP client + identity for all service modules.
3
+ *
4
+ * Injected into every service's registerTools/registerBehaviors so they
5
+ * don't manage HTTP or identity themselves.
6
+ */
7
+
8
+ import type { FleetClient, ToolResult } from "./types.js";
9
+
10
+ export function createFleetClient(): FleetClient {
11
+ const agentName = process.env.VERS_AGENT_NAME || `agent-${process.pid}`;
12
+ const vmId = process.env.VERS_VM_ID || undefined;
13
+ const agentRole = process.env.VERS_AGENT_ROLE || "worker";
14
+
15
+ function getBaseUrl(): string | null {
16
+ return process.env.VERS_INFRA_URL || null;
17
+ }
18
+
19
+ async function api<T = unknown>(
20
+ method: string,
21
+ path: string,
22
+ body?: unknown,
23
+ ): Promise<T> {
24
+ const base = getBaseUrl();
25
+ if (!base) throw new Error("VERS_INFRA_URL not set");
26
+
27
+ const headers: Record<string, string> = {
28
+ "Content-Type": "application/json",
29
+ };
30
+
31
+ const token = process.env.VERS_AUTH_TOKEN;
32
+ if (token) {
33
+ headers["Authorization"] = `Bearer ${token}`;
34
+ }
35
+
36
+ const res = await fetch(`${base}${path}`, {
37
+ method,
38
+ headers,
39
+ body: body !== undefined ? JSON.stringify(body) : undefined,
40
+ });
41
+
42
+ const text = await res.text();
43
+ let data: unknown;
44
+ try {
45
+ data = JSON.parse(text);
46
+ } catch {
47
+ data = text;
48
+ }
49
+
50
+ if (!res.ok) {
51
+ const msg =
52
+ typeof data === "object" &&
53
+ data !== null &&
54
+ "error" in (data as Record<string, unknown>)
55
+ ? (data as { error: string }).error
56
+ : text;
57
+ throw new Error(`${method} ${path} (${res.status}): ${msg}`);
58
+ }
59
+
60
+ return data as T;
61
+ }
62
+
63
+ function ok(text: string, details?: Record<string, unknown>): ToolResult {
64
+ return {
65
+ content: [{ type: "text", text }],
66
+ details: details ?? {},
67
+ };
68
+ }
69
+
70
+ function err(text: string): ToolResult {
71
+ return {
72
+ content: [{ type: "text", text: `Error: ${text}` }],
73
+ isError: true,
74
+ };
75
+ }
76
+
77
+ function noUrl(): ToolResult {
78
+ return {
79
+ content: [
80
+ {
81
+ type: "text",
82
+ text: "Error: VERS_INFRA_URL environment variable is not set.\n\nSet it to the base URL of your reef instance, e.g.:\n export VERS_INFRA_URL=http://localhost:3000",
83
+ },
84
+ ],
85
+ isError: true,
86
+ };
87
+ }
88
+
89
+ return {
90
+ api,
91
+ getBaseUrl,
92
+ agentName,
93
+ vmId,
94
+ agentRole,
95
+ ok,
96
+ err,
97
+ noUrl,
98
+ };
99
+ }
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Service module discovery — scan a directory, dynamic import, topo-sort.
3
+ *
4
+ * Follows the pi-mono pattern: each service is a self-contained directory
5
+ * with an index.ts that default-exports a ServiceModule. Drop a folder in,
6
+ * it gets picked up. Delete one, it's gone. No import wiring.
7
+ *
8
+ * Discovery rules:
9
+ * services/foo/index.ts → import default → ServiceModule
10
+ *
11
+ * Modules are topologically sorted by `dependencies` before being returned,
12
+ * so init() hooks can safely reference stores from upstream modules.
13
+ */
14
+
15
+ import { readdirSync, existsSync } from "node:fs";
16
+ import { join, resolve } from "node:path";
17
+ import type { ServiceModule } from "./types.js";
18
+
19
+ // =============================================================================
20
+ // Initial discovery
21
+ // =============================================================================
22
+
23
+ /**
24
+ * Discover and load all service modules from a directory.
25
+ *
26
+ * @param servicesDir - Path to the services directory (e.g. "./services")
27
+ * @returns Topologically sorted array of ServiceModules
28
+ */
29
+ export async function discoverServiceModules(
30
+ servicesDir: string,
31
+ ): Promise<ServiceModule[]> {
32
+ const resolved = resolve(servicesDir);
33
+
34
+ if (!existsSync(resolved)) {
35
+ throw new Error(`Services directory not found: ${resolved}`);
36
+ }
37
+
38
+ const entries = readdirSync(resolved, { withFileTypes: true });
39
+ const modules: ServiceModule[] = [];
40
+ const errors: Array<{ dir: string; error: string }> = [];
41
+
42
+ for (const entry of entries) {
43
+ if (!entry.isDirectory()) continue;
44
+
45
+ const indexPath = join(resolved, entry.name, "index.ts");
46
+ if (!existsSync(indexPath)) continue;
47
+
48
+ try {
49
+ const mod = await import(indexPath);
50
+ const serviceModule: ServiceModule = mod.default;
51
+
52
+ if (!serviceModule?.name) {
53
+ errors.push({
54
+ dir: entry.name,
55
+ error: "No default export or missing 'name' property",
56
+ });
57
+ continue;
58
+ }
59
+
60
+ modules.push(serviceModule);
61
+ } catch (err) {
62
+ errors.push({
63
+ dir: entry.name,
64
+ error: err instanceof Error ? err.message : String(err),
65
+ });
66
+ }
67
+ }
68
+
69
+ if (errors.length > 0) {
70
+ for (const { dir, error } of errors) {
71
+ console.error(` [discover] Failed to load services/${dir}: ${error}`);
72
+ }
73
+ }
74
+
75
+ return topoSort(modules);
76
+ }
77
+
78
+ /**
79
+ * Filter modules that have client-side code (tools, behaviors, or widgets).
80
+ * Used by the extension loader to skip server-only modules.
81
+ */
82
+ export function filterClientModules(modules: ServiceModule[]): ServiceModule[] {
83
+ return modules.filter(
84
+ (m) => m.registerTools || m.registerBehaviors || m.widget,
85
+ );
86
+ }
87
+
88
+ // =============================================================================
89
+ // Single-module loading (used by reload and install endpoints)
90
+ // =============================================================================
91
+
92
+ /**
93
+ * Load a single service module from a directory.
94
+ * Uses cache-busting so re-imports pick up changes.
95
+ */
96
+ export async function loadServiceModule(
97
+ dirPath: string,
98
+ ): Promise<ServiceModule> {
99
+ const indexPath = join(dirPath, "index.ts");
100
+
101
+ if (!existsSync(indexPath)) {
102
+ throw new Error(`No index.ts found in ${dirPath}`);
103
+ }
104
+
105
+ const mod = await import(`${indexPath}?t=${Date.now()}`);
106
+ const serviceModule: ServiceModule = mod.default;
107
+
108
+ if (!serviceModule?.name) {
109
+ throw new Error(`No valid default export in ${indexPath}`);
110
+ }
111
+
112
+ return serviceModule;
113
+ }
114
+
115
+ // =============================================================================
116
+ // Topological sort by dependencies
117
+ // =============================================================================
118
+
119
+ function topoSort(modules: ServiceModule[]): ServiceModule[] {
120
+ const byName = new Map<string, ServiceModule>();
121
+ for (const m of modules) byName.set(m.name, m);
122
+
123
+ const visited = new Set<string>();
124
+ const sorted: ServiceModule[] = [];
125
+
126
+ function visit(name: string, stack: Set<string>) {
127
+ if (visited.has(name)) return;
128
+ if (stack.has(name)) {
129
+ throw new Error(
130
+ `Circular dependency detected: ${[...stack, name].join(" → ")}`,
131
+ );
132
+ }
133
+
134
+ const mod = byName.get(name);
135
+ if (!mod) return;
136
+
137
+ stack.add(name);
138
+ for (const dep of mod.dependencies ?? []) {
139
+ visit(dep, stack);
140
+ }
141
+ stack.delete(name);
142
+
143
+ visited.add(name);
144
+ sorted.push(mod);
145
+ }
146
+
147
+ for (const mod of modules) {
148
+ visit(mod.name, new Set());
149
+ }
150
+
151
+ return sorted;
152
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Server-side event bus — lets service modules communicate without
3
+ * importing each other. Same pattern as pi.events on the client side.
4
+ *
5
+ * Typed loosely on purpose: modules define their own event shapes,
6
+ * and subscribers cast as needed. This keeps the bus decoupled from
7
+ * any specific module's types.
8
+ */
9
+
10
+ type Handler = (data: any) => void | Promise<void>;
11
+
12
+ export class ServiceEventBus {
13
+ private handlers = new Map<string, Set<Handler>>();
14
+
15
+ /** Subscribe to an event. Returns an unsubscribe function. */
16
+ on(event: string, handler: Handler): () => void {
17
+ if (!this.handlers.has(event)) {
18
+ this.handlers.set(event, new Set());
19
+ }
20
+ this.handlers.get(event)!.add(handler);
21
+ return () => {
22
+ this.handlers.get(event)?.delete(handler);
23
+ };
24
+ }
25
+
26
+ /** Emit an event. Handlers run in order, errors are caught and logged. */
27
+ async emit(event: string, data?: unknown): Promise<void> {
28
+ const handlers = this.handlers.get(event);
29
+ if (!handlers) return;
30
+
31
+ for (const handler of handlers) {
32
+ try {
33
+ await handler(data);
34
+ } catch (err) {
35
+ console.error(`[events] Handler error for "${event}":`, err);
36
+ }
37
+ }
38
+ }
39
+
40
+ /** Fire and forget — emit without awaiting handlers. */
41
+ fire(event: string, data?: unknown): void {
42
+ this.emit(event, data).catch(() => {});
43
+ }
44
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Extension — composes all service modules into a single pi extension.
3
+ *
4
+ * Each module registers its own tools and behaviors. The extension loader
5
+ * creates the shared FleetClient and wires up the composite status widget.
6
+ */
7
+
8
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
9
+ import type { ServiceModule, WidgetContribution } from "./types.js";
10
+ import { createFleetClient } from "./client.js";
11
+
12
+ export function createExtension(modules: ServiceModule[]) {
13
+ return function (pi: ExtensionAPI) {
14
+ const client = createFleetClient();
15
+
16
+ // Let each module register its tools and behaviors
17
+ for (const mod of modules) {
18
+ mod.registerTools?.(pi, client);
19
+ mod.registerBehaviors?.(pi, client);
20
+ }
21
+
22
+ // Composite widget from all contributing modules
23
+ const widgetContributions: Array<{ name: string; widget: WidgetContribution }> =
24
+ modules
25
+ .filter((m) => m.widget)
26
+ .map((m) => ({ name: m.name, widget: m.widget! }));
27
+
28
+ let widgetTimer: ReturnType<typeof setInterval> | null = null;
29
+
30
+ async function updateWidget(ctx: {
31
+ ui: { setWidget: (id: string, lines: string[]) => void };
32
+ }) {
33
+ if (!client.getBaseUrl()) return;
34
+
35
+ const allLines: string[] = [];
36
+ const base = client.getBaseUrl();
37
+ allLines.push(`--- Fleet Services --- ${base}/ui`);
38
+
39
+ for (const { widget } of widgetContributions) {
40
+ try {
41
+ const lines = await widget.getLines(client);
42
+ allLines.push(...lines);
43
+ } catch {
44
+ // Best effort — skip failing widgets
45
+ }
46
+ }
47
+
48
+ if (allLines.length > 1) {
49
+ ctx.ui.setWidget("reef", allLines);
50
+ }
51
+ }
52
+
53
+ pi.on("session_start", async (_event, ctx) => {
54
+ if (!client.getBaseUrl()) return;
55
+ updateWidget(ctx);
56
+ widgetTimer = setInterval(() => updateWidget(ctx), 30_000);
57
+ });
58
+
59
+ pi.on("session_shutdown", async () => {
60
+ if (widgetTimer) {
61
+ clearInterval(widgetTimer);
62
+ widgetTimer = null;
63
+ }
64
+ });
65
+ };
66
+ }
@@ -0,0 +1,262 @@
1
+ /**
2
+ * Server — minimal dispatch infrastructure.
3
+ *
4
+ * The server's only job is:
5
+ * 1. Discover and load service modules at startup
6
+ * 2. Dispatch requests to the right module
7
+ * 3. Health check
8
+ * 4. Graceful shutdown
9
+ *
10
+ * Everything else — including managing modules at runtime — is handled
11
+ * by service modules themselves:
12
+ * /services — reload and unload modules
13
+ * /installer — install from git or local paths
14
+ * /docs — auto-generated API documentation
15
+ */
16
+
17
+ import { join, resolve } from "node:path";
18
+ import { existsSync } from "node:fs";
19
+ import { Hono } from "hono";
20
+ import { bearerAuth } from "./auth.js";
21
+ import { discoverServiceModules, loadServiceModule } from "./discover.js";
22
+ import { ServiceEventBus } from "./events.js";
23
+ import type { ServiceModule, ServiceContext } from "./types.js";
24
+
25
+ export const DEFAULT_SERVICES_DIR = "./services";
26
+
27
+ export interface ServerOptions {
28
+ modules?: ServiceModule[];
29
+ servicesDir?: string;
30
+ port?: number;
31
+ }
32
+
33
+ export async function createServer(options: ServerOptions) {
34
+ const servicesDir = options.servicesDir ?? process.env.SERVICES_DIR ?? DEFAULT_SERVICES_DIR;
35
+ const resolvedServicesDir = resolve(servicesDir);
36
+ const initialModules = options.modules ?? await discoverServiceModules(servicesDir);
37
+ const app = new Hono();
38
+ const events = new ServiceEventBus();
39
+
40
+ // Catch any unhandled errors from service route handlers
41
+ app.onError((err, c) => {
42
+ const msg = err instanceof Error ? err.message : String(err);
43
+ console.error(` [dispatch] error: ${msg}`);
44
+ return c.json({ error: "internal service error" }, 500);
45
+ });
46
+
47
+ // ==========================================================================
48
+ // Live module registry
49
+ // ==========================================================================
50
+
51
+ const liveModules = new Map<string, ServiceModule>();
52
+ const stores = new Map<string, unknown>();
53
+ const dirForModule = new Map<string, string>();
54
+
55
+ function registerModule(mod: ServiceModule, dirName?: string): void {
56
+ liveModules.set(mod.name, mod);
57
+ if (mod.store) stores.set(mod.name, mod.store);
58
+ if (dirName) dirForModule.set(mod.name, dirName);
59
+ }
60
+
61
+ async function unregisterModule(name: string): Promise<void> {
62
+ const mod = liveModules.get(name);
63
+ if (!mod) return;
64
+
65
+ if (mod.store?.flush) mod.store.flush();
66
+ if (mod.store?.close) await mod.store.close();
67
+
68
+ liveModules.delete(name);
69
+ stores.delete(name);
70
+ dirForModule.delete(name);
71
+ }
72
+
73
+ async function loadFromDir(
74
+ dirName: string,
75
+ ): Promise<{ name: string; action: "added" | "updated" }> {
76
+ const dirPath = join(resolvedServicesDir, dirName);
77
+ const serviceModule = await loadServiceModule(dirPath);
78
+
79
+ for (const dep of serviceModule.dependencies ?? []) {
80
+ if (!liveModules.has(dep)) {
81
+ throw new Error(`Missing dependency "${dep}"`);
82
+ }
83
+ }
84
+
85
+ const existed = liveModules.has(serviceModule.name);
86
+
87
+ if (existed) {
88
+ const old = liveModules.get(serviceModule.name)!;
89
+ if (old.store?.flush) old.store.flush();
90
+ if (old.store?.close) await old.store.close();
91
+ }
92
+
93
+ registerModule(serviceModule, dirName);
94
+
95
+ try {
96
+ serviceModule.init?.(ctx);
97
+ } catch (err) {
98
+ // Roll back — don't leave a half-initialized module in the registry
99
+ liveModules.delete(serviceModule.name);
100
+ stores.delete(serviceModule.name);
101
+ dirForModule.delete(serviceModule.name);
102
+ const msg = err instanceof Error ? err.message : String(err);
103
+ throw new Error(`Module "${serviceModule.name}" init() failed: ${msg}`);
104
+ }
105
+
106
+ return { name: serviceModule.name, action: existed ? "updated" : "added" };
107
+ }
108
+
109
+ // ==========================================================================
110
+ // Service context — passed to all modules
111
+ // ==========================================================================
112
+
113
+ const ctx: ServiceContext = {
114
+ events,
115
+ servicesDir: resolvedServicesDir,
116
+
117
+ getStore<T = unknown>(name: string): T | undefined {
118
+ return stores.get(name) as T | undefined;
119
+ },
120
+
121
+ getModules(): ServiceModule[] {
122
+ return Array.from(liveModules.values());
123
+ },
124
+
125
+ getModule(name: string): ServiceModule | undefined {
126
+ return liveModules.get(name);
127
+ },
128
+
129
+ loadModule(dirName: string) {
130
+ return loadFromDir(dirName);
131
+ },
132
+
133
+ async unloadModule(name: string) {
134
+ await unregisterModule(name);
135
+ },
136
+ };
137
+
138
+ // ==========================================================================
139
+ // Routes — health check + dynamic dispatch
140
+ // ==========================================================================
141
+
142
+ const auth = bearerAuth();
143
+
144
+ app.get("/health", (c) =>
145
+ c.json({
146
+ status: "ok",
147
+ uptime: process.uptime(),
148
+ services: Array.from(liveModules.values())
149
+ .filter((m) => m.routes)
150
+ .map((m) => m.name),
151
+ }),
152
+ );
153
+
154
+ // Dynamic dispatch: look up module by name on each request
155
+ async function dispatch(c: any) {
156
+ const serviceName = c.req.param("service");
157
+
158
+ if (serviceName === "health") return c.notFound();
159
+
160
+ const mod = liveModules.get(serviceName);
161
+ if (!mod?.routes || mod.mountAtRoot) {
162
+ return c.json({ error: "not found" }, 404);
163
+ }
164
+
165
+ if (mod.requiresAuth !== false) {
166
+ const authResponse = await auth(c, async () => {});
167
+ if (authResponse instanceof Response) return authResponse;
168
+ if (c.res.status === 401) return c.res;
169
+ }
170
+
171
+ const url = new URL(c.req.url);
172
+ const prefix = `/${serviceName}`;
173
+ url.pathname = url.pathname.slice(prefix.length) || "/";
174
+ const rewritten = new Request(url.toString(), c.req.raw);
175
+
176
+ const response = await mod.routes.fetch(rewritten);
177
+
178
+ // If the sub-Hono returned a 500 (e.g. unhandled throw in route handler),
179
+ // normalize it to a JSON error response
180
+ if (response.status >= 500) {
181
+ return c.json({ error: "internal service error" }, response.status as any);
182
+ }
183
+
184
+ return response;
185
+ }
186
+
187
+ // Root-mounted modules (UI, webhooks) — registered before catch-all
188
+ for (const mod of initialModules) {
189
+ if (mod.mountAtRoot && mod.routes) {
190
+ app.route("/", mod.routes);
191
+ }
192
+ }
193
+
194
+ app.all("/:service{[^/]+}", dispatch);
195
+ app.all("/:service{[^/]+}/*", dispatch);
196
+
197
+ // ==========================================================================
198
+ // Initialize all modules
199
+ // ==========================================================================
200
+
201
+ for (const mod of initialModules) {
202
+ registerModule(mod, mod.name);
203
+ }
204
+
205
+ for (const mod of initialModules) {
206
+ try {
207
+ mod.init?.(ctx);
208
+ } catch (err) {
209
+ const msg = err instanceof Error ? err.message : String(err);
210
+ console.error(` [init] /${mod.name} failed — skipping: ${msg}`);
211
+ liveModules.delete(mod.name);
212
+ stores.delete(mod.name);
213
+ dirForModule.delete(mod.name);
214
+ }
215
+ }
216
+
217
+ return { app, liveModules, events, ctx };
218
+ }
219
+
220
+ /**
221
+ * Start the server and wire up graceful shutdown.
222
+ */
223
+ export async function startServer(options: ServerOptions = {}) {
224
+ const { app, liveModules } = await createServer(options);
225
+ const port = options.port ?? parseInt(process.env.PORT || "3000", 10);
226
+
227
+ if (!process.env.VERS_AUTH_TOKEN) {
228
+ console.warn(
229
+ " VERS_AUTH_TOKEN is not set — all endpoints are unauthenticated.",
230
+ );
231
+ }
232
+
233
+ console.log(" services:");
234
+ for (const mod of liveModules.values()) {
235
+ if (mod.routes) {
236
+ console.log(` /${mod.name} — ${mod.description || mod.name}`);
237
+ }
238
+ }
239
+
240
+ const server = Bun.serve({
241
+ fetch: app.fetch,
242
+ port,
243
+ hostname: "::",
244
+ });
245
+
246
+ console.log(`\n reef running on :${port}\n`);
247
+
248
+ async function shutdown() {
249
+ console.log("\n shutting down...");
250
+ for (const mod of liveModules.values()) {
251
+ if (mod.store?.flush) mod.store.flush();
252
+ if (mod.store?.close) await mod.store.close();
253
+ }
254
+ server.stop();
255
+ process.exit(0);
256
+ }
257
+
258
+ process.on("SIGTERM", shutdown);
259
+ process.on("SIGINT", shutdown);
260
+
261
+ return { app, server, liveModules };
262
+ }