@omg-dev/server 0.4.24

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.
package/src/broker.ts ADDED
@@ -0,0 +1,85 @@
1
+ // ── Realtime broker ──────────────────────────────────────────────────────────
2
+ // Broadcasts invalidation signals to all connected SSE clients.
3
+ // No data is sent — clients re-fetch from the auth-checked API.
4
+ //
5
+ // Internals: a module-level `Topic` from @omg-dev/stream/core handles fan-out.
6
+ // Each BrokerClient is wrapped in a TopicWriter adapter that decodes the SSE
7
+ // frame (`id: <n>\ndata: <json>\n\n`) back to the raw JSON string the legacy
8
+ // client.send() API expects. Throwing from write() causes Topic to drop the
9
+ // writer, which is how we preserve the old "skip disconnected / drop broken"
10
+ // semantics.
11
+
12
+ import { Topic, type TopicWriter } from "@omg-dev/stream/core"
13
+
14
+ export interface InvalidationEvent {
15
+ type: "invalidate"
16
+ collection: string
17
+ }
18
+
19
+ interface BrokerClient {
20
+ send: (data: string) => void
21
+ readyState: number
22
+ }
23
+
24
+ const topic = new Topic()
25
+ const adapters = new Map<BrokerClient, TopicWriter>()
26
+
27
+ const decoder = new TextDecoder()
28
+
29
+ /** Extract the JSON payload from an SSE frame `id: N\ndata: <json>\n\n`. */
30
+ function extractData(chunk: Uint8Array): string {
31
+ const text = decoder.decode(chunk)
32
+ const dataLines: string[] = []
33
+ for (const line of text.split("\n")) {
34
+ if (line.startsWith("data: ")) dataLines.push(line.slice(6))
35
+ }
36
+ return dataLines.join("\n")
37
+ }
38
+
39
+ function makeAdapter(client: BrokerClient): TopicWriter {
40
+ const adapter: TopicWriter = {
41
+ async write(chunk: Uint8Array): Promise<void> {
42
+ // Both failure modes (closed socket, throwing send) must drop the
43
+ // writer SYNCHRONOUSLY so callers observing clientCount() right after
44
+ // invalidate() see the post-drop count. Topic's own catch handler
45
+ // would drop on the microtask, which is too late for legacy callers
46
+ // (and for the security tests that codify the contract).
47
+ if (client.readyState !== 1) {
48
+ adapters.delete(client)
49
+ topic.detach(adapter)
50
+ throw new Error("client not open")
51
+ }
52
+ try {
53
+ client.send(extractData(chunk))
54
+ } catch (err) {
55
+ adapters.delete(client)
56
+ topic.detach(adapter)
57
+ throw err
58
+ }
59
+ },
60
+ }
61
+ return adapter
62
+ }
63
+
64
+ export function addClient(client: BrokerClient) {
65
+ const adapter = makeAdapter(client)
66
+ adapters.set(client, adapter)
67
+ // attach() is async but, with no sinceId, runs entirely synchronously to
68
+ // the subs.add() — so the adapter is live before this returns.
69
+ void topic.attach(adapter)
70
+ }
71
+
72
+ export function removeClient(client: BrokerClient) {
73
+ const adapter = adapters.get(client)
74
+ if (!adapter) return
75
+ adapters.delete(client)
76
+ topic.detach(adapter)
77
+ }
78
+
79
+ export function invalidate(collection: string) {
80
+ topic.publish({ type: "invalidate", collection })
81
+ }
82
+
83
+ export function clientCount(): number {
84
+ return topic.size()
85
+ }
package/src/codec.ts ADDED
@@ -0,0 +1,41 @@
1
+ // Row codec — converts between SQLite storage shape and JS/JSON wire shape.
2
+ //
3
+ // SQLite stores booleans as INTEGER 0/1 and JSON-shaped fields (array/enum/ref)
4
+ // as TEXT. Anywhere a row leaves the DB and heads to a client (REST handlers,
5
+ // subscription snapshots, future delta payloads) it goes through decodeRow
6
+ // first so the JSON shape is consistent. Anywhere a value comes from a client
7
+ // and heads to a `?`-parameter, it goes through encodeValue.
8
+ //
9
+ // Lives in its own file because both auto-crud and subscriptions need it.
10
+
11
+ import type { FieldType } from "@omg-dev/schema"
12
+
13
+ export function encodeValue(type: FieldType, v: unknown): unknown {
14
+ if (v === null || v === undefined) return v
15
+ if (type === "boolean") return v ? 1 : 0
16
+ if (typeof type === "object" && "array" in type) return JSON.stringify(v)
17
+ return v
18
+ }
19
+
20
+ export function decodeRow(
21
+ row: Record<string, unknown>,
22
+ fields: Record<string, { type: FieldType }>,
23
+ ): Record<string, unknown> {
24
+ const out: Record<string, unknown> = { ...row }
25
+ for (const [name, def] of Object.entries(fields)) {
26
+ if (out[name] === null || out[name] === undefined) continue
27
+ if (def.type === "boolean") {
28
+ out[name] = !!out[name]
29
+ } else if (typeof def.type === "object" && "array" in def.type) {
30
+ try { out[name] = JSON.parse(out[name] as string) } catch { /* leave as-is */ }
31
+ }
32
+ }
33
+ return out
34
+ }
35
+
36
+ export function decodeRows(
37
+ rows: Record<string, unknown>[],
38
+ fields: Record<string, { type: FieldType }>,
39
+ ): Record<string, unknown>[] {
40
+ return rows.map(r => decodeRow(r, fields))
41
+ }
package/src/ctx.ts ADDED
@@ -0,0 +1,33 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks"
2
+
3
+ export interface VibesCtx {
4
+ /** Authed user id, or null when the request is anonymous. */
5
+ userId: string | null
6
+ userEmail?: string
7
+ userName?: string
8
+ appId?: string
9
+ /**
10
+ * True when the current ctx was created by the trigger dispatcher (cron
11
+ * tick or event delivery), not from an inbound HTTP request. Lets handlers
12
+ * branch on "is this a system run" if needed; defaults to undefined for
13
+ * normal request ctxs.
14
+ */
15
+ system?: boolean
16
+ }
17
+
18
+ export const ctxStore = new AsyncLocalStorage<VibesCtx>()
19
+
20
+ export const ctx = {
21
+ get userId() {
22
+ return ctxStore.getStore()?.userId ?? null
23
+ },
24
+ get userEmail() {
25
+ return ctxStore.getStore()?.userEmail
26
+ },
27
+ get userName() {
28
+ return ctxStore.getStore()?.userName
29
+ },
30
+ get appId() {
31
+ return ctxStore.getStore()?.appId
32
+ },
33
+ }
package/src/db.ts ADDED
@@ -0,0 +1,258 @@
1
+ import { Database } from "bun:sqlite"
2
+ import { ctxStore } from "./ctx.ts"
3
+ import { invalidate } from "./broker.ts"
4
+ import { notifyRowChange } from "./subscriptions.ts"
5
+
6
+ // ── Types ─────────────────────────────────────────────────────────────────────
7
+
8
+ export interface GetAllOpts {
9
+ limit?: number
10
+ offset?: number
11
+ orderBy?: string
12
+ orderDir?: "ASC" | "DESC"
13
+ }
14
+
15
+ export interface VibesDb {
16
+ getAll(table: string, opts?: GetAllOpts): Promise<Record<string, unknown>[]>
17
+ get(table: string, id: string): Promise<Record<string, unknown> | null>
18
+ insert(table: string, data: Record<string, unknown>): Promise<Record<string, unknown>>
19
+ update(table: string, id: string, data: Record<string, unknown>): Promise<Record<string, unknown> | null>
20
+ delete(table: string, id: string): Promise<boolean>
21
+ raw(): Database
22
+ close(): void
23
+ }
24
+
25
+ // ── Scoped table names ────────────────────────────────────────────────────────
26
+
27
+ // Tables that have _owner column — tracked after migration
28
+ const scopedTables = new Set<string>()
29
+
30
+ export function markScoped(table: string) {
31
+ scopedTables.add(table)
32
+ }
33
+
34
+ // System-managed columns — never accept these from caller-supplied data.
35
+ const RESERVED_COLUMNS = new Set(["id", "_owner", "created_at", "updated_at"])
36
+
37
+ const IDENTIFIER_RE = /^[a-zA-Z_][a-zA-Z0-9_]*$/
38
+
39
+ function stripReserved(data: Record<string, unknown>): Record<string, unknown> {
40
+ const out: Record<string, unknown> = {}
41
+ for (const [k, v] of Object.entries(data)) {
42
+ if (!RESERVED_COLUMNS.has(k)) out[k] = v
43
+ }
44
+ return out
45
+ }
46
+
47
+ /**
48
+ * Thrown when a row-level operation on a user-scoped table is attempted with
49
+ * no authenticated user in context. Caught by the apiHandler dispatcher and
50
+ * turned into a 401 — letting the call through (the prior behaviour) silently
51
+ * dropped the `_owner = ?` predicate and exposed every user's rows.
52
+ */
53
+ export class VibesAuthRequiredError extends Error {
54
+ readonly table: string
55
+ constructor(table: string) {
56
+ super(`[vibes:auth] scoped table "${table}" requires an authenticated user`)
57
+ this.name = "VibesAuthRequiredError"
58
+ this.table = table
59
+ }
60
+ }
61
+
62
+
63
+
64
+ // ── openDb ────────────────────────────────────────────────────────────────────
65
+
66
+ export function openDb(path: string): VibesDb {
67
+ const bun = new Database(path, { create: true })
68
+ // Enable WAL mode for better concurrency
69
+ bun.exec("PRAGMA journal_mode=WAL;")
70
+
71
+ function now(): string {
72
+ return new Date().toISOString()
73
+ }
74
+
75
+ function getOwner(): string | null {
76
+ return ctxStore.getStore()?.userId ?? null
77
+ }
78
+
79
+ function isScoped(table: string): boolean {
80
+ return scopedTables.has(table)
81
+ }
82
+
83
+ const db: VibesDb = {
84
+ async getAll(table: string, opts: GetAllOpts = {}): Promise<Record<string, unknown>[]> {
85
+ const conditions: string[] = []
86
+ const params: unknown[] = []
87
+
88
+ if (isScoped(table)) {
89
+ const owner = getOwner()
90
+ if (!owner) throw new VibesAuthRequiredError(table)
91
+ conditions.push("_owner = ?")
92
+ params.push(owner)
93
+ }
94
+
95
+ const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""
96
+
97
+ let orderClause = "ORDER BY created_at DESC"
98
+ if (opts.orderBy) {
99
+ if (!IDENTIFIER_RE.test(opts.orderBy)) {
100
+ throw new Error(`[vibes] getAll: invalid orderBy column: ${opts.orderBy}`)
101
+ }
102
+ const dir = opts.orderDir === "DESC" ? "DESC" : "ASC"
103
+ orderClause = `ORDER BY ${opts.orderBy} ${dir}`
104
+ }
105
+
106
+ const limit =
107
+ Number.isInteger(opts.limit) && (opts.limit as number) >= 0
108
+ ? `LIMIT ${opts.limit}`
109
+ : ""
110
+ const offset =
111
+ Number.isInteger(opts.offset) && (opts.offset as number) >= 0
112
+ ? `OFFSET ${opts.offset}`
113
+ : ""
114
+
115
+ const sql = `SELECT * FROM ${table} ${where} ${orderClause} ${limit} ${offset}`.trim()
116
+ const stmt = bun.prepare(sql)
117
+ return stmt.all(...params) as Record<string, unknown>[]
118
+ },
119
+
120
+ async get(table: string, id: string): Promise<Record<string, unknown> | null> {
121
+ const conditions = ["id = ?"]
122
+ const params: unknown[] = [id]
123
+
124
+ if (isScoped(table)) {
125
+ const owner = getOwner()
126
+ if (!owner) throw new VibesAuthRequiredError(table)
127
+ conditions.push("_owner = ?")
128
+ params.push(owner)
129
+ }
130
+
131
+ const sql = `SELECT * FROM ${table} WHERE ${conditions.join(" AND ")} LIMIT 1`
132
+ const stmt = bun.prepare(sql)
133
+ const row = stmt.get(...params) as Record<string, unknown> | null
134
+ return row ?? null
135
+ },
136
+
137
+ async insert(table: string, data: Record<string, unknown>): Promise<Record<string, unknown>> {
138
+ const id = crypto.randomUUID()
139
+ const ts = now()
140
+ const record: Record<string, unknown> = {
141
+ ...stripReserved(data),
142
+ id,
143
+ created_at: ts,
144
+ updated_at: ts,
145
+ }
146
+
147
+ if (isScoped(table)) {
148
+ const owner = getOwner()
149
+ if (!owner) throw new VibesAuthRequiredError(table)
150
+ record._owner = owner
151
+ }
152
+
153
+ const columns = Object.keys(record)
154
+ const placeholders = columns.map(() => "?").join(", ")
155
+ const values = Object.values(record)
156
+
157
+ const sql = `INSERT INTO ${table} (${columns.join(", ")}) VALUES (${placeholders})`
158
+ bun.prepare(sql).run(...values)
159
+
160
+ invalidate(table)
161
+ // Push a live delta to WS subscribers (useCollection). Without this, a
162
+ // server-side insert only triggers the legacy SSE re-fetch — modern
163
+ // WS-first clients sit on a stale list until manual refresh. Mirrors
164
+ // auto-crud.ts so custom functions and auto-CRUD notify identically.
165
+ void notifyRowChange(table, "insert", null, record)
166
+ return record
167
+ },
168
+
169
+ async update(table: string, id: string, data: Record<string, unknown>): Promise<Record<string, unknown> | null> {
170
+ // First check it exists (and is owned if scoped). db.get throws
171
+ // VibesAuthRequiredError when scoped+!owner, which is the desired behavior.
172
+ const existing = await db.get(table, id)
173
+ if (!existing) return null
174
+
175
+ const updates = { ...stripReserved(data), updated_at: now() }
176
+ const setClauses = Object.keys(updates).map(k => `${k} = ?`).join(", ")
177
+ const values = [...Object.values(updates), id]
178
+
179
+ // Belt-and-braces: db.get already filtered by _owner for scoped tables,
180
+ // but include the same predicate in the UPDATE WHERE so the mutation
181
+ // can never write a row the caller doesn't own — even in the face of
182
+ // future refactors that bypass the get() precondition.
183
+ let sql = `UPDATE ${table} SET ${setClauses} WHERE id = ?`
184
+ if (isScoped(table)) {
185
+ const owner = getOwner()
186
+ if (!owner) throw new VibesAuthRequiredError(table)
187
+ sql += " AND _owner = ?"
188
+ values.push(owner)
189
+ }
190
+ bun.prepare(sql).run(...values)
191
+
192
+ const updated = { ...existing, ...updates }
193
+ invalidate(table)
194
+ void notifyRowChange(table, "update", existing, updated)
195
+ return updated
196
+ },
197
+
198
+ async delete(table: string, id: string): Promise<boolean> {
199
+ // Fetch first so the live delete delta can carry the removed row, and so
200
+ // the owner precondition matches update(). db.get throws
201
+ // VibesAuthRequiredError for scoped+!owner; returns null if missing/unowned.
202
+ const existing = await db.get(table, id)
203
+ if (!existing) return false
204
+
205
+ const conditions = ["id = ?"]
206
+ const params: unknown[] = [id]
207
+
208
+ if (isScoped(table)) {
209
+ const owner = getOwner()
210
+ if (!owner) throw new VibesAuthRequiredError(table)
211
+ conditions.push("_owner = ?")
212
+ params.push(owner)
213
+ }
214
+
215
+ const sql = `DELETE FROM ${table} WHERE ${conditions.join(" AND ")}`
216
+ const result = bun.prepare(sql).run(...params)
217
+ const deleted = (result.changes ?? 0) > 0
218
+ if (deleted) {
219
+ invalidate(table)
220
+ void notifyRowChange(table, "delete", existing, null)
221
+ }
222
+ return deleted
223
+ },
224
+
225
+ raw(): Database {
226
+ return bun
227
+ },
228
+
229
+ close(): void {
230
+ bun.close()
231
+ },
232
+ }
233
+
234
+ return db
235
+ }
236
+
237
+ // ── Singleton db export (set by createVibesServer) ────────────────────────────
238
+
239
+ let _dbInstance: VibesDb | null = null
240
+
241
+ export const dbProxy = new Proxy({} as VibesDb, {
242
+ get(_target, prop: string) {
243
+ if (!_dbInstance) {
244
+ throw new Error(
245
+ `[vibes] db not initialized. Call createVibesServer() before using db.`
246
+ )
247
+ }
248
+ return (_dbInstance as Record<string, unknown>)[prop]
249
+ },
250
+ })
251
+
252
+ export function setDbInstance(instance: VibesDb) {
253
+ _dbInstance = instance
254
+ }
255
+
256
+ export function getDbInstance(): VibesDb | null {
257
+ return _dbInstance
258
+ }
@@ -0,0 +1,257 @@
1
+
2
+ import { VibesAuthRequiredError } from "./db.ts"
3
+ import { VibesHttpError } from "./http-error.ts"
4
+
5
+ // ── Error → response mapping ─────────────────────────────────────────────────
6
+ //
7
+ // Auth-required errors (raised by db.ts when a scoped table is touched without
8
+ // an authenticated ctx.userId) MUST become a 401, not a 500. The whole point
9
+ // of the throw is to refuse the request — letting it propagate as "Internal
10
+ // server error" would still deny the client but mislabel the cause and put
11
+ // the error in the wrong place in dashboards.
12
+ //
13
+ // VibesHttpError carries an explicit status: an expected refusal (ownership
14
+ // check, upstream 4xx) keeps its status and logs a one-liner — NOT a
15
+ // stack-bearing crash entry — so dashboards distinguish "client was told no"
16
+ // from "handler blew up". 5xx VibesHttpErrors keep the crash-level log.
17
+ //
18
+ // The refusal line deliberately avoids the "Handler error in" wording: log
19
+ // triage greps for that phrase to find crashes, and a correctly-denied
20
+ // request must not show up in those sweeps.
21
+ function errorResponse(err: unknown, where: string): Response {
22
+ if (err instanceof VibesAuthRequiredError) {
23
+ return Response.json({ error: err.message }, { status: 401 })
24
+ }
25
+ if (err instanceof VibesHttpError && err.status < 500) {
26
+ console.warn(`[vibes:dispatcher] refused ${where}: ${err.message} (${err.status})`)
27
+ return Response.json({ error: err.message }, { status: err.status })
28
+ }
29
+ console.error(`[vibes:dispatcher] Handler error in ${where}:`, err)
30
+ const message = err instanceof Error ? err.message : "Internal server error"
31
+ const status = err instanceof VibesHttpError ? err.status : 500
32
+ return Response.json({ error: message }, { status })
33
+ }
34
+
35
+ // ── Types ─────────────────────────────────────────────────────────────────────
36
+
37
+ export interface Route {
38
+ method: string
39
+ path: string // e.g. "/api/entries" or "/api/entries/:id"
40
+ module: string // module path to import (dev) — ignored when `mod` is set
41
+ handler: string // exported function name
42
+ /**
43
+ * Calling convention for the handler.
44
+ * "crud" — default; handler is invoked with conventional (id?, body?)
45
+ * args derived from the route convention (list/get/create/...).
46
+ * "method" — handler is invoked with (req: Request, params). Used when
47
+ * the exported name is an HTTP verb (GET/POST/...) so the
48
+ * handler can stream, set custom headers, etc.
49
+ * Defaults to "crud" if undefined for backwards compatibility.
50
+ */
51
+ style?: "crud" | "method"
52
+ /**
53
+ * Preloaded module for production bundles. When present, the dispatcher
54
+ * reads `mod[handler]` directly instead of dynamic-importing `module`.
55
+ * Set by .vibes/routes.generated.ts emitted by `vibes-build`.
56
+ */
57
+ mod?: Record<string, unknown>
58
+ /**
59
+ * In-process handler for routes that don't live in a function file —
60
+ * used by auto-mounted CRUD routes (see ./auto-crud.ts). When set, the
61
+ * dispatcher invokes this directly with the raw Request and skips the
62
+ * convention-based arg-marshalling path. Auth still runs.
63
+ */
64
+ inlineHandler?: (req: Request, params: Record<string, string>) => Promise<Response>
65
+ }
66
+
67
+ export type RouteMap = Route[]
68
+
69
+ // ── Convention mapping ────────────────────────────────────────────────────────
70
+ // CRUD shape (handler is invoked with (id?, body?)):
71
+ // list → GET /api/:resource
72
+ // get → GET /api/:resource/:id
73
+ // create → POST /api/:resource
74
+ // update → PATCH /api/:resource/:id
75
+ // remove → DELETE /api/:resource/:id
76
+ // custom → POST /api/:resource/:name
77
+ //
78
+ // Method shape (handler receives raw Request):
79
+ // GET / POST / PUT / PATCH / DELETE → <METHOD> /api/:resource
80
+
81
+ const HTTP_METHODS = new Set(["GET", "POST", "PUT", "PATCH", "DELETE"])
82
+
83
+ export function handlerToRoute(resource: string, handlerName: string, modulePath: string): Route {
84
+ if (HTTP_METHODS.has(handlerName)) {
85
+ return { method: handlerName, path: `/api/${resource}`, module: modulePath, handler: handlerName, style: "method" }
86
+ }
87
+ switch (handlerName) {
88
+ case "list":
89
+ return { method: "GET", path: `/api/${resource}`, module: modulePath, handler: handlerName, style: "crud" }
90
+ case "get":
91
+ return { method: "GET", path: `/api/${resource}/:id`, module: modulePath, handler: handlerName, style: "crud" }
92
+ case "create":
93
+ return { method: "POST", path: `/api/${resource}`, module: modulePath, handler: handlerName, style: "crud" }
94
+ case "update":
95
+ return { method: "PATCH", path: `/api/${resource}/:id`, module: modulePath, handler: handlerName, style: "crud" }
96
+ case "remove":
97
+ return { method: "DELETE", path: `/api/${resource}/:id`, module: modulePath, handler: handlerName, style: "crud" }
98
+ default:
99
+ return { method: "POST", path: `/api/${resource}/${handlerName}`, module: modulePath, handler: handlerName, style: "crud" }
100
+ }
101
+ }
102
+
103
+ // ── loadRoutes ────────────────────────────────────────────────────────────────
104
+
105
+ export function loadRoutes(routesJson: Route[]): RouteMap {
106
+ return routesJson
107
+ }
108
+
109
+ // ── Path matching ─────────────────────────────────────────────────────────────
110
+
111
+ function matchPath(pattern: string, path: string): Record<string, string> | null {
112
+ const patternParts = pattern.split("/")
113
+ const pathParts = path.split("/")
114
+
115
+ if (patternParts.length !== pathParts.length) return null
116
+
117
+ const params: Record<string, string> = {}
118
+ for (let i = 0; i < patternParts.length; i++) {
119
+ const pp = patternParts[i]
120
+ const vp = pathParts[i]
121
+ if (pp.startsWith(":")) {
122
+ params[pp.slice(1)] = decodeURIComponent(vp)
123
+ } else if (pp !== vp) {
124
+ return null
125
+ }
126
+ }
127
+ return params
128
+ }
129
+
130
+ // ── Module cache ──────────────────────────────────────────────────────────────
131
+
132
+ const moduleCache = new Map<string, Record<string, unknown>>()
133
+
134
+ async function loadModule(modulePath: string): Promise<Record<string, unknown>> {
135
+ if (moduleCache.has(modulePath)) {
136
+ return moduleCache.get(modulePath)!
137
+ }
138
+ const mod = (await import(modulePath)) as Record<string, unknown>
139
+ moduleCache.set(modulePath, mod)
140
+ return mod
141
+ }
142
+
143
+ export function clearModuleCache() {
144
+ moduleCache.clear()
145
+ }
146
+
147
+ // ── handleRequest ─────────────────────────────────────────────────────────────
148
+ //
149
+ // Auth is no longer attached here — apiHandler (createVibesServer) is the
150
+ // single boundary that runs the auth middleware and seeds ctxStore. handlers
151
+ // running through dispatcher inherit that store via AsyncLocalStorage.
152
+
153
+ export async function handleRequest(
154
+ req: Request,
155
+ routes: RouteMap
156
+ ): Promise<Response> {
157
+ const url = new URL(req.url)
158
+ const pathname = url.pathname
159
+
160
+ // Find matching route
161
+ let matchedRoute: Route | null = null
162
+ let pathParams: Record<string, string> = {}
163
+
164
+ for (const route of routes) {
165
+ if (route.method !== req.method) continue
166
+ const params = matchPath(route.path, pathname)
167
+ if (params !== null) {
168
+ matchedRoute = route
169
+ pathParams = params
170
+ break
171
+ }
172
+ }
173
+
174
+ if (!matchedRoute) {
175
+ return Response.json({ error: "Not found" }, { status: 404 })
176
+ }
177
+
178
+ // Inline handler short-circuit (auto-mounted CRUD).
179
+ if (matchedRoute.inlineHandler) {
180
+ try {
181
+ return await matchedRoute.inlineHandler(req, pathParams)
182
+ } catch (err) {
183
+ return errorResponse(err, `inline route ${matchedRoute.path}`)
184
+ }
185
+ }
186
+
187
+ // Load module and find handler. Bundled routes ship a preloaded `mod` so
188
+ // the dispatcher never touches disk — this is what lets runtime sandboxes
189
+ // serve without node_modules.
190
+ const handlerName = matchedRoute.handler
191
+ let mod: Record<string, unknown>
192
+ if (matchedRoute.mod) {
193
+ mod = matchedRoute.mod
194
+ } else {
195
+ try {
196
+ mod = await loadModule(matchedRoute.module)
197
+ } catch (err) {
198
+ console.error(`[vibes:dispatcher] Failed to load module ${matchedRoute.module}:`, err)
199
+ return Response.json({ error: "Internal server error" }, { status: 500 })
200
+ }
201
+ }
202
+
203
+ const handler = mod[handlerName]
204
+ if (typeof handler !== "function") {
205
+ return Response.json({ error: `Handler "${handlerName}" not found in module` }, { status: 500 })
206
+ }
207
+
208
+ // Method-style handlers receive the raw Request — body parsing, header
209
+ // inspection, and streaming are all the handler's responsibility.
210
+ if (matchedRoute.style === "method") {
211
+ try {
212
+ const result = await (handler as (req: Request, params: Record<string, string>) => Promise<unknown>)(
213
+ req,
214
+ pathParams,
215
+ )
216
+ return result instanceof Response ? result : Response.json(result)
217
+ } catch (err) {
218
+ return errorResponse(err, handlerName)
219
+ }
220
+ }
221
+
222
+ // ── CRUD-style handlers ──────────────────────────────────────────────
223
+ // Parse body
224
+ let body: unknown = undefined
225
+ if (["POST", "PUT", "PATCH"].includes(req.method)) {
226
+ const contentType = req.headers.get("content-type") ?? ""
227
+ if (contentType.includes("application/json")) {
228
+ try {
229
+ body = await req.json()
230
+ } catch {
231
+ return Response.json({ error: "Invalid JSON body" }, { status: 400 })
232
+ }
233
+ }
234
+ }
235
+
236
+ // Build handler args based on route convention
237
+ const args: unknown[] = []
238
+
239
+ if (pathParams.id) {
240
+ args.push(pathParams.id)
241
+ }
242
+
243
+ if (body !== undefined) {
244
+ args.push(body)
245
+ }
246
+
247
+ // Run handler — ctxStore is already seeded by apiHandler.
248
+ try {
249
+ const result = await (handler as (...a: unknown[]) => Promise<unknown>)(...args)
250
+ // Passthrough Response objects so handlers can stream (e.g.
251
+ // streamText(...).toTextStreamResponse()) or set custom headers
252
+ // without the dispatcher mangling the body via Response.json.
253
+ return result instanceof Response ? result : Response.json(result)
254
+ } catch (err) {
255
+ return errorResponse(err, handlerName)
256
+ }
257
+ }