@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/index.ts ADDED
@@ -0,0 +1,702 @@
1
+ import path from "node:path"
2
+ import fs from "node:fs"
3
+ import type { Schema } from "@omg-dev/schema"
4
+ import { createAuthMiddleware, type AuthResult } from "@omg-dev/auth"
5
+ import { ctxStore, ctx } from "./ctx.ts"
6
+ import { openDb, dbProxy, setDbInstance, type VibesDb } from "./db.ts"
7
+ import { migrate, registerScopes } from "./migrator.ts"
8
+ import { loadRoutes, handleRequest, clearModuleCache, type Route, type RouteMap } from "./dispatcher.ts"
9
+ import { buildAutoCrudRoutes } from "./auto-crud.ts"
10
+ import { setSubscriptionSchema } from "./subscriptions.ts"
11
+ import {
12
+ loadTriggersFromFile,
13
+ registerTriggers,
14
+ setCronDriver,
15
+ dispatchHandler,
16
+ dispatchStorageEvent,
17
+ devInspectTriggers,
18
+ devInspectDeliveries,
19
+ devInspectEvents,
20
+ } from "./triggers.ts"
21
+ import {
22
+ loadWorkflowsFromFile,
23
+ registerWorkflows,
24
+ buildWorkflowEndpoint,
25
+ handleWorkflowRequest,
26
+ devInspectWorkflowRuns,
27
+ type WorkflowEntry,
28
+ } from "./workflows.ts"
29
+ import { storage } from "./storage.ts"
30
+ import {
31
+ ensureNotificationIndexes,
32
+ notificationSystemCollectionNames,
33
+ notificationSystemSchema,
34
+ notificationsConfigHandler,
35
+ notificationsCreateHandler,
36
+ notificationsReadHandler,
37
+ notificationServiceWorkerHandler,
38
+ notificationsSubscribeHandler,
39
+ notificationsListHandler,
40
+ notificationsUnreadCountHandler,
41
+ notificationsUnsubscribeHandler,
42
+ notify,
43
+ withNotificationSystemSchema,
44
+ type NotifyInput,
45
+ } from "./notifications.ts"
46
+
47
+ export { ctx } from "./ctx.ts"
48
+ export { dbProxy as db } from "./db.ts"
49
+ export { VibesAuthRequiredError } from "./db.ts"
50
+ export { VibesHttpError } from "./http-error.ts"
51
+ export type { VibesDb, GetAllOpts } from "./db.ts"
52
+ export type { VibesCtx } from "./ctx.ts"
53
+ export type { Route } from "./dispatcher.ts"
54
+ export { addClient, removeClient, invalidate, clientCount } from "./broker.ts"
55
+ export {
56
+ addSubClient,
57
+ removeSubClient,
58
+ handleSubMessage,
59
+ notifyCollectionChange,
60
+ notifyRowChange,
61
+ subClientCount,
62
+ subscriptionCount,
63
+ setSubscriptionSchema,
64
+ setSubscriptionRingCap,
65
+ setMaxSubsPerClient,
66
+ type SubClient,
67
+ type SubClientMessage,
68
+ type SubServerMessage,
69
+ } from "./subscriptions.ts"
70
+ export {
71
+ validatePredicate,
72
+ compileToSql,
73
+ compileToJs,
74
+ PredicateValidationError,
75
+ type Predicate,
76
+ type Literal,
77
+ type CompiledSql,
78
+ } from "./predicate.ts"
79
+ export { createAuthMiddleware } from "@omg-dev/auth"
80
+ export { migrate, registerScopes } from "./migrator.ts"
81
+ export { buildAutoCrudRoutes } from "./auto-crud.ts"
82
+ // Schema-aware row decoders (parse JSON array columns, coerce booleans). The
83
+ // auto-CRUD read handlers run every returned row through these; hand-written
84
+ // db.raw() read paths must do the same or array columns leak to the client as
85
+ // raw JSON strings (see control-plane projects.get / sessionMarkers crash).
86
+ export { decodeRow, decodeRows } from "./codec.ts"
87
+ export {
88
+ cron,
89
+ on,
90
+ emit,
91
+ schedule,
92
+ cancel,
93
+ registerTriggers,
94
+ loadTriggersFromFile,
95
+ dispatchHandler,
96
+ listTriggers,
97
+ clearTriggers,
98
+ setCronDriver,
99
+ devInspectTriggers,
100
+ devInspectDeliveries,
101
+ devInspectEvents,
102
+ type CronHandler,
103
+ type EventHandler,
104
+ type CronDriver,
105
+ } from "./triggers.ts"
106
+ // Build/boot-time trigger scanning — shared by vite-plugin (build) and
107
+ // non-vite hosts that write .vibes/triggers.json themselves at boot.
108
+ export {
109
+ scanTriggers,
110
+ extractTriggers,
111
+ writeTriggersManifest,
112
+ TriggerScanError,
113
+ type ScannedTrigger,
114
+ type TriggerKind,
115
+ } from "./trigger-scan.ts"
116
+ export {
117
+ workflow,
118
+ startWorkflow,
119
+ registerWorkflows,
120
+ loadWorkflowsFromFile,
121
+ listWorkflows,
122
+ clearWorkflows,
123
+ devInspectWorkflowRuns,
124
+ workflowServiceName,
125
+ type StepContext,
126
+ type WorkflowFn,
127
+ type WorkflowEntry,
128
+ type StartWorkflowOptions,
129
+ } from "./workflows.ts"
130
+ export {
131
+ storage,
132
+ _verifyDevStorageToken,
133
+ _devStorageWrite,
134
+ _devStorageRead,
135
+ type StorageScope,
136
+ type UploadUrlOptions,
137
+ type DownloadUrlOptions,
138
+ type ListOptions,
139
+ type PresignedUploadResult,
140
+ type PresignedDownloadResult,
141
+ type StorageItem,
142
+ type StorageEvent,
143
+ type StorageEventHandler,
144
+ } from "./storage.ts"
145
+ export {
146
+ billing,
147
+ usd,
148
+ type Billing,
149
+ type EnsureCustomerResult,
150
+ type CheckResult,
151
+ type TrackResult,
152
+ type GrantResult,
153
+ type BalanceResult,
154
+ type GrantOptions,
155
+ type CheckoutOptions,
156
+ type CheckoutResult,
157
+ } from "./billing.ts"
158
+ export {
159
+ notify,
160
+ notificationSystemSchema,
161
+ type NotifyInput,
162
+ } from "./notifications.ts"
163
+
164
+ // ── Types ─────────────────────────────────────────────────────────────────────
165
+
166
+ export interface VibesServerOptions {
167
+ root: string
168
+ db: string
169
+ /**
170
+ * Auth posture. A mode string ("vibes" verifies a Bearer JWT via JWKS;
171
+ * "local" is anonymous), OR a custom middleware function for hosts that need
172
+ * bespoke verification (e.g. the control-plane accepts a second token family
173
+ * on a scoped route surface). The function receives the request and returns
174
+ * the resolved identity, or null when anonymous — same contract as the
175
+ * mode-string middleware that createAuthMiddleware produces.
176
+ */
177
+ auth?: "vibes" | "local" | ((req: Request) => Promise<AuthResult | null>)
178
+ routes?: Route[]
179
+ /**
180
+ * Pre-bundled trigger entries from .vibes/triggers.generated.ts (prod).
181
+ * When provided, the trigger registry skips reading .vibes/triggers.json
182
+ * and dynamic-importing handler modules — module paths in the JSON are
183
+ * build-time absolutes that don't resolve at runtime in a bundled
184
+ * deploy. Dev mode (no `triggers` passed) still uses the JSON-based
185
+ * loader since vite-plugin can dynamic-import via the dev server.
186
+ */
187
+ triggers?: BundledTrigger[]
188
+ /**
189
+ * Which machinery fires cron() handlers. Default "orchestrator": the
190
+ * platform's ticker + dispatcher POST /_vibes/dispatch into this server —
191
+ * correct for orchestrator-managed deploys, but a standalone container
192
+ * (control-plane, self-host bundle) never receives those dispatches and
193
+ * its crons silently never fire. Set "in-process" there: the same
194
+ * setInterval scheduler dev mode uses, armed at trigger registration.
195
+ * Only affects cron — emit()/schedule() routing still follows VIBES_MODE.
196
+ * Env equivalent: VIBES_CRON_DRIVER.
197
+ */
198
+ cron?: "orchestrator" | "in-process"
199
+ /**
200
+ * Pre-bundled workflow entries from .vibes/workflows.generated.ts (prod).
201
+ * Same dev/prod split as `triggers`: when omitted, the registry reads
202
+ * .vibes/workflows.json and dynamic-imports handler modules.
203
+ */
204
+ workflows?: WorkflowEntry[]
205
+ staticDir?: string
206
+ schema?: Schema
207
+ /**
208
+ * When false, skip DDL migrations at startup — but still register user-scoped
209
+ * tables so row-level filtering by _owner keeps working. Used by the
210
+ * bundled production server: migrations ran at build time against the
211
+ * seeded .vibes/data.db and we don't want to re-diff sqlite_master on every
212
+ * cold start. Defaults to true.
213
+ */
214
+ migrate?: boolean
215
+ /**
216
+ * When false, do NOT register the generic auto-CRUD REST routes
217
+ * (`GET/POST/PUT/DELETE /api/<collection>` + `/:id`). The schema is still
218
+ * used for migrations, scope registration, and the live-query subscription
219
+ * layer — only the generic REST surface is withheld. Defaults to true (user
220
+ * apps rely on it via useCollection). The control-plane sets this false: its
221
+ * tables aren't `scope:"user"`, so an open auto-CRUD route would serve every
222
+ * row to anyone; all dashboard reads/writes go through scoped custom
223
+ * functions instead.
224
+ */
225
+ autoCrud?: boolean
226
+ }
227
+
228
+ // Shape produced by vite-plugin's triggers.generated.ts. Mirrors the
229
+ // internal TriggerEntry shape in triggers.ts but is the public type the
230
+ // generated file imports.
231
+ export interface BundledTrigger {
232
+ handler: string
233
+ kind: "cron" | "on"
234
+ key: string
235
+ module: string
236
+ exportName: string
237
+ mod: Record<string, unknown>
238
+ }
239
+
240
+ export interface VibesServerInstance {
241
+ apiHandler: (req: Request) => Promise<Response>
242
+ fetch: (req: Request) => Promise<Response>
243
+ /**
244
+ * Re-run DDL migrations and rebuild the auto-CRUD route table. Pass
245
+ * `newSchema` to swap the active schema (the dev plugin's HMR handler
246
+ * calls this when `schema.ts` changes); omit to re-migrate the current
247
+ * one. Lazily opens the DB the first time collections appear, so a
248
+ * server that booted with empty collections can transition to having
249
+ * collections without a restart — this is what makes the
250
+ * "template starts empty → agent writes schema.ts" flow work in dev.
251
+ */
252
+ migrate: (newSchema?: Schema) => void
253
+ reloadFunctions: () => void
254
+ /**
255
+ * Re-read .vibes/triggers.json and re-register cron + on handlers. Called
256
+ * by the vite-plugin's HMR handler when a functions/*.ts file changes.
257
+ */
258
+ reloadTriggers: () => Promise<void>
259
+ /** Same as reloadTriggers, for .vibes/workflows.json (dev HMR). */
260
+ reloadWorkflows: () => Promise<void>
261
+ close: () => void
262
+ }
263
+
264
+ // ── Built-in storage presign route handler ───────────────────────────────────
265
+ //
266
+ // Exposed at POST /api/_storage/presign so client-side <VibesUpload /> +
267
+ // useUpload() don't have to wire any boilerplate in the user app. The handler
268
+ // reads ctx.userId (set by the auth middleware) and refuses requests without
269
+ // a verified user unless scope:"app" is explicitly requested.
270
+
271
+ interface PresignRequestBody {
272
+ action?: "put" | "get"
273
+ key?: string
274
+ contentType?: string
275
+ scope?: "user" | "app"
276
+ }
277
+
278
+ async function storagePresignHandler(req: Request): Promise<Response> {
279
+ let body: PresignRequestBody
280
+ try {
281
+ body = (await req.json()) as PresignRequestBody
282
+ } catch {
283
+ return Response.json({ error: "invalid JSON body" }, { status: 400 })
284
+ }
285
+ const action = body.action ?? "put"
286
+ if (action !== "put" && action !== "get") {
287
+ return Response.json({ error: `unknown action: ${action}` }, { status: 400 })
288
+ }
289
+ if (!body.key || typeof body.key !== "string") {
290
+ return Response.json({ error: "key required" }, { status: 400 })
291
+ }
292
+ const scope = body.scope ?? "user"
293
+ if (scope !== "user" && scope !== "app") {
294
+ return Response.json({ error: `unknown scope: ${scope}` }, { status: 400 })
295
+ }
296
+ if (scope === "user" && !ctx.userId) {
297
+ return Response.json(
298
+ { error: "auth required for scope:user — sign in or pass scope:'app'." },
299
+ { status: 401 },
300
+ )
301
+ }
302
+ try {
303
+ if (action === "put") {
304
+ const out = await storage.uploadUrl({
305
+ key: body.key,
306
+ contentType: body.contentType,
307
+ scope,
308
+ })
309
+ return Response.json(out)
310
+ }
311
+ const out = await storage.downloadUrl(body.key, { scope })
312
+ return Response.json(out)
313
+ } catch (err) {
314
+ const msg = err instanceof Error ? err.message : String(err)
315
+ return Response.json({ error: msg }, { status: 500 })
316
+ }
317
+ }
318
+
319
+ // ── Built-in storage notify route ───────────────────────────────────────────
320
+ //
321
+ // POST /api/_storage/notify is fired by the SDK (useUpload, VibesUpload) after
322
+ // a successful PUT/DELETE round-trip. Body identifies the file + action; this
323
+ // handler fans out to every `storage.onUpload` / `onDelete` registered via
324
+ // functions/*.ts. Runs in-process — no orchestrator round-trip.
325
+ //
326
+ // Auth: same posture as presign. ctx.userId is read to attribute the event;
327
+ // the handler is free to refuse if scope:"user" with no authed user. We
328
+ // don't reject here — handlers may legitimately fire under scope:"app".
329
+
330
+ interface StorageNotifyBody {
331
+ action?: "upload" | "delete"
332
+ key?: string
333
+ size?: number
334
+ contentType?: string
335
+ scope?: "user" | "app"
336
+ }
337
+
338
+ async function storageNotifyHandler(req: Request): Promise<Response> {
339
+ let body: StorageNotifyBody
340
+ try {
341
+ body = (await req.json()) as StorageNotifyBody
342
+ } catch {
343
+ return Response.json({ error: "invalid JSON body" }, { status: 400 })
344
+ }
345
+ const action = body.action ?? "upload"
346
+ if (action !== "upload" && action !== "delete") {
347
+ return Response.json({ error: `unknown action: ${action}` }, { status: 400 })
348
+ }
349
+ if (!body.key || typeof body.key !== "string") {
350
+ return Response.json({ error: "key required" }, { status: 400 })
351
+ }
352
+ const scope = body.scope ?? "user"
353
+ const kind = action === "upload" ? "storage:upload" : "storage:delete"
354
+ try {
355
+ const fired = await dispatchStorageEvent(kind, {
356
+ key: body.key,
357
+ size: body.size,
358
+ contentType: body.contentType,
359
+ userId: ctx.userId ?? null,
360
+ scope,
361
+ })
362
+ return Response.json({ ok: true, handlersFired: fired })
363
+ } catch (err) {
364
+ const msg = err instanceof Error ? err.message : String(err)
365
+ return Response.json({ error: msg }, { status: 500 })
366
+ }
367
+ }
368
+
369
+ // ── createVibesServer ─────────────────────────────────────────────────────────
370
+
371
+ export async function createVibesServer(opts: VibesServerOptions): Promise<VibesServerInstance> {
372
+ const { root, db: dbPath } = opts
373
+ let currentSchema: Schema | undefined = withNotificationSystemSchema(opts.schema)
374
+
375
+ // Track function-file routes separately from auto-CRUD so the route table
376
+ // can be rebuilt when collections are added or removed via HMR — without
377
+ // dropping user-authored handlers in the process.
378
+ //
379
+ // Source of explicit routes:
380
+ // - `opts.routes` (prod-bundled deploys): preloaded with `mod` refs;
381
+ // never changes after boot.
382
+ // - `.vibes/routes.json` on disk (dev): regenerated by the vite plugin
383
+ // whenever a `functions/**.ts` file is added/edited/removed. The
384
+ // dev path re-reads on every `reloadFunctions()` call so newly
385
+ // scaffolded routes (e.g. pi creates `functions/api/chat.ts`
386
+ // after the server is already running) actually become reachable
387
+ // without restarting the dev server.
388
+ function loadExplicitRoutes(): RouteMap {
389
+ if (opts.routes) return loadRoutes(opts.routes)
390
+ const routesJsonPath = path.join(root, ".vibes", "routes.json")
391
+ if (!fs.existsSync(routesJsonPath)) return []
392
+ const raw = JSON.parse(fs.readFileSync(routesJsonPath, "utf-8")) as Route[]
393
+ return loadRoutes(raw)
394
+ }
395
+
396
+ let explicitRoutes: RouteMap = loadExplicitRoutes()
397
+ let routes: RouteMap = []
398
+
399
+ // Touching dbProxy at runtime throws when no DB has been opened —
400
+ // intentional; a no-collection deploy shouldn't have code paths that
401
+ // reach it. We open lazily so the empty → non-empty transition works.
402
+ let dbInstance: VibesDb | null = null
403
+ function ensureDb(): VibesDb {
404
+ if (dbInstance) return dbInstance
405
+ const resolvedDbPath = path.isAbsolute(dbPath) ? dbPath : path.join(root, dbPath)
406
+ dbInstance = openDb(resolvedDbPath)
407
+ setDbInstance(dbInstance)
408
+ return dbInstance
409
+ }
410
+
411
+ function hasCollections(s: Schema | undefined): s is Schema {
412
+ return !!s?.collections && Object.keys(s.collections).length > 0
413
+ }
414
+
415
+ // Built-in storage routes. Same precedence rule as auto-CRUD: a
416
+ // user-authored functions/_storage.ts route with the same (method, path)
417
+ // overrides this default.
418
+ const storageBuiltinRoutes: Route[] = [
419
+ {
420
+ method: "POST",
421
+ path: "/api/_storage/presign",
422
+ module: "@omg-dev/server/storage-presign",
423
+ handler: "inline",
424
+ inlineHandler: storagePresignHandler,
425
+ },
426
+ {
427
+ method: "POST",
428
+ path: "/api/_storage/notify",
429
+ module: "@omg-dev/server/storage-notify",
430
+ handler: "inline",
431
+ inlineHandler: storageNotifyHandler,
432
+ },
433
+ ]
434
+ const notificationBuiltinRoutes: Route[] = [
435
+ {
436
+ method: "GET",
437
+ path: "/api/_notifications/config",
438
+ module: "@omg-dev/server/notifications-config",
439
+ handler: "inline",
440
+ inlineHandler: notificationsConfigHandler,
441
+ },
442
+ {
443
+ method: "GET",
444
+ path: "/api/_notifications/list",
445
+ module: "@omg-dev/server/notifications-list",
446
+ handler: "inline",
447
+ inlineHandler: notificationsListHandler,
448
+ },
449
+ {
450
+ method: "GET",
451
+ path: "/api/_notifications/unread-count",
452
+ module: "@omg-dev/server/notifications-unread-count",
453
+ handler: "inline",
454
+ inlineHandler: notificationsUnreadCountHandler,
455
+ },
456
+ {
457
+ method: "POST",
458
+ path: "/api/_notifications/subscribe",
459
+ module: "@omg-dev/server/notifications-subscribe",
460
+ handler: "inline",
461
+ inlineHandler: notificationsSubscribeHandler,
462
+ },
463
+ {
464
+ method: "POST",
465
+ path: "/api/_notifications/unsubscribe",
466
+ module: "@omg-dev/server/notifications-unsubscribe",
467
+ handler: "inline",
468
+ inlineHandler: notificationsUnsubscribeHandler,
469
+ },
470
+ {
471
+ method: "POST",
472
+ path: "/api/_notifications/read",
473
+ module: "@omg-dev/server/notifications-read",
474
+ handler: "inline",
475
+ inlineHandler: notificationsReadHandler,
476
+ },
477
+ {
478
+ method: "POST",
479
+ path: "/api/_notifications/create",
480
+ module: "@omg-dev/server/notifications-create",
481
+ handler: "inline",
482
+ inlineHandler: notificationsCreateHandler,
483
+ },
484
+ ]
485
+
486
+ // Function-file routes take precedence — when a user-authored handler
487
+ // exists at the same (method, path), the auto-route is skipped so custom
488
+ // logic always wins.
489
+ function rebuildRoutes(): void {
490
+ const explicit = new Set(explicitRoutes.map(r => `${r.method} ${r.path}`))
491
+ const builtinKeys = new Set([
492
+ ...storageBuiltinRoutes.map(r => `${r.method} ${r.path}`),
493
+ ...notificationBuiltinRoutes.map(r => `${r.method} ${r.path}`),
494
+ ])
495
+ const builtinStorage = storageBuiltinRoutes.filter(
496
+ r => !explicit.has(`${r.method} ${r.path}`),
497
+ )
498
+ const builtinNotifications = notificationBuiltinRoutes.filter(
499
+ r => !explicit.has(`${r.method} ${r.path}`),
500
+ )
501
+ if (hasCollections(currentSchema) && opts.autoCrud !== false) {
502
+ const auto = buildAutoCrudRoutes(currentSchema).filter(
503
+ r => {
504
+ if (explicit.has(`${r.method} ${r.path}`) || builtinKeys.has(`${r.method} ${r.path}`)) return false
505
+ const collection = r.path.split("/")[2]
506
+ return !notificationSystemCollectionNames.has(collection)
507
+ },
508
+ )
509
+ routes = [...auto, ...builtinStorage, ...builtinNotifications, ...explicitRoutes]
510
+ } else {
511
+ routes = [...builtinStorage, ...builtinNotifications, ...explicitRoutes]
512
+ }
513
+ }
514
+
515
+ if (hasCollections(currentSchema)) {
516
+ const db = ensureDb()
517
+ // Scope registration must happen even when migrations are skipped —
518
+ // otherwise user-scoped tables silently stop filtering by _owner.
519
+ registerScopes(currentSchema)
520
+ if (opts.migrate !== false) {
521
+ migrate(db.raw(), currentSchema)
522
+ ensureNotificationIndexes(db)
523
+ }
524
+ }
525
+ // Subscriptions need the schema to whitelist collection names + decode
526
+ // rows. Set unconditionally (including with no-collection schemas) so the
527
+ // module stays in a defined state.
528
+ setSubscriptionSchema(currentSchema ?? null)
529
+ rebuildRoutes()
530
+
531
+ // Triggers — prefer the build-time-bundled list (prod path: handler refs
532
+ // are preloaded via `mod`). Fall back to the on-disk JSON (dev path:
533
+ // dynamic import resolves against the running source tree).
534
+ if (opts.cron) setCronDriver(opts.cron)
535
+ const triggerEntries = opts.triggers && opts.triggers.length > 0
536
+ ? opts.triggers
537
+ : await loadTriggersFromFile(root)
538
+ if (triggerEntries.length > 0) {
539
+ await registerTriggers(triggerEntries)
540
+ }
541
+
542
+ // Workflows — same bundled-vs-disk split as triggers. In prod this also
543
+ // builds the Restate endpoint handler (mounted under /_vibes/workflow/*);
544
+ // it throws on a mis-configured deploy (workflows declared but no
545
+ // VIBES_APP_SLUG) rather than booting a service that can't be invoked.
546
+ const workflowEntries = opts.workflows && opts.workflows.length > 0
547
+ ? opts.workflows
548
+ : await loadWorkflowsFromFile(root)
549
+ if (workflowEntries.length > 0) {
550
+ await registerWorkflows(workflowEntries)
551
+ await buildWorkflowEndpoint()
552
+ }
553
+
554
+ // Auth middleware — verifies Bearer JWT via JWKS in "vibes" mode, no-op in
555
+ // "local". Built once and reused for every request so JWKS keys are cached.
556
+ const authMW = typeof opts.auth === "function"
557
+ ? opts.auth
558
+ : opts.auth
559
+ ? createAuthMiddleware(opts.auth)
560
+ : null
561
+
562
+ // Static file serving
563
+ const staticDir = opts.staticDir ? path.resolve(root, opts.staticDir) : null
564
+
565
+ async function serveStatic(req: Request): Promise<Response | null> {
566
+ if (!staticDir) return null
567
+ const url = new URL(req.url)
568
+ let filePath = path.join(staticDir, url.pathname)
569
+ if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
570
+ return new Response(Bun.file(filePath))
571
+ }
572
+ // SPA fallback
573
+ const indexPath = path.join(staticDir, "index.html")
574
+ if (fs.existsSync(indexPath)) {
575
+ return new Response(Bun.file(indexPath))
576
+ }
577
+ return null
578
+ }
579
+
580
+ const instance: VibesServerInstance = {
581
+ async apiHandler(req: Request): Promise<Response> {
582
+ let authResult: Awaited<ReturnType<NonNullable<typeof authMW>>> = null
583
+ if (authMW) {
584
+ try {
585
+ authResult = await authMW(req)
586
+ } catch {
587
+ authResult = null
588
+ }
589
+ }
590
+ return ctxStore.run(
591
+ {
592
+ userId: authResult?.userId ?? null,
593
+ userEmail: authResult?.userEmail,
594
+ userName: authResult?.userName,
595
+ appId: authResult?.appId,
596
+ },
597
+ () => handleRequest(req, routes),
598
+ )
599
+ },
600
+
601
+ async fetch(req: Request): Promise<Response> {
602
+ const url = new URL(req.url)
603
+ // Cron + event queue plumbing. /_vibes/dispatch is the orchestrator
604
+ // → user app entry point; /_vibes/inspect/* surfaces the in-process
605
+ // trigger/event state for the dashboard's dev-mode Inspect tabs.
606
+ if (url.pathname === "/_vibes/dispatch" && req.method === "POST") {
607
+ try {
608
+ const body = await req.json()
609
+ return await dispatchHandler(body as { handler: string; payload?: string | null })
610
+ } catch (err) {
611
+ return Response.json(
612
+ { error: `dispatch: ${(err as Error).message}` },
613
+ { status: 400 },
614
+ )
615
+ }
616
+ }
617
+ if (url.pathname === "/_vibes/inspect/triggers" && req.method === "GET") {
618
+ return Response.json(devInspectTriggers())
619
+ }
620
+ if (url.pathname === "/_vibes/inspect/deliveries" && req.method === "GET") {
621
+ return Response.json(devInspectDeliveries())
622
+ }
623
+ if (url.pathname === "/_vibes/inspect/events" && req.method === "GET") {
624
+ return Response.json(devInspectEvents())
625
+ }
626
+ if (url.pathname === "/_vibes/inspect/workflows" && req.method === "GET") {
627
+ return Response.json(devInspectWorkflowRuns())
628
+ }
629
+ // Durable workflow endpoint (prod only): the Restate server drives
630
+ // registered workflow handlers through here. Signed requests only —
631
+ // the handler itself verifies request identity. Registered URIs are
632
+ // versioned (/_vibes/workflow/v<N>), and the SDK suffix-matches the
633
+ // path, so we route on the prefix.
634
+ if (url.pathname.startsWith("/_vibes/workflow")) {
635
+ const r = handleWorkflowRequest(req)
636
+ if (r) return r
637
+ return Response.json({ error: "no workflow endpoint mounted" }, { status: 404 })
638
+ }
639
+ if (url.pathname === "/__vibes_push/sw.js" && req.method === "GET") {
640
+ return notificationServiceWorkerHandler()
641
+ }
642
+ // Route /api/* to API handler
643
+ if (url.pathname.startsWith("/api/")) {
644
+ return instance.apiHandler(req)
645
+ }
646
+ // Try static files
647
+ const staticResponse = await serveStatic(req)
648
+ if (staticResponse) return staticResponse
649
+
650
+ return Response.json({ error: "Not found" }, { status: 404 })
651
+ },
652
+
653
+ migrate(newSchema?: Schema): void {
654
+ currentSchema = withNotificationSystemSchema(newSchema ?? currentSchema)
655
+ // Keep subscriptions and routes in sync with the new schema even when
656
+ // the user landed on an empty collection set (e.g. agent reset). The
657
+ // route table just becomes auto-CRUD-less; subs reject all collection
658
+ // names. Without this, the dev HMR path could leave subs pointing at
659
+ // the previous schema.
660
+ setSubscriptionSchema(currentSchema ?? null)
661
+ if (!hasCollections(currentSchema)) {
662
+ rebuildRoutes()
663
+ return
664
+ }
665
+ const db = ensureDb()
666
+ registerScopes(currentSchema)
667
+ migrate(db.raw(), currentSchema)
668
+ ensureNotificationIndexes(db)
669
+ rebuildRoutes()
670
+ },
671
+
672
+ reloadFunctions(): void {
673
+ // Re-read routes from disk so new function files become reachable
674
+ // without a dev-server restart. Prod path (opts.routes set) is a
675
+ // no-op since routes are baked at build time.
676
+ if (!opts.routes) {
677
+ explicitRoutes = loadExplicitRoutes()
678
+ rebuildRoutes()
679
+ }
680
+ clearModuleCache()
681
+ console.log(`[vibes] Function modules reloaded (${explicitRoutes.length} explicit routes).`)
682
+ },
683
+
684
+ async reloadTriggers(): Promise<void> {
685
+ const entries = await loadTriggersFromFile(root)
686
+ await registerTriggers(entries)
687
+ console.log(`[vibes] Triggers reloaded (${entries.length} registered).`)
688
+ },
689
+
690
+ async reloadWorkflows(): Promise<void> {
691
+ const entries = await loadWorkflowsFromFile(root)
692
+ await registerWorkflows(entries)
693
+ console.log(`[vibes] Workflows reloaded (${entries.length} registered).`)
694
+ },
695
+
696
+ close(): void {
697
+ dbInstance?.close()
698
+ },
699
+ }
700
+
701
+ return instance
702
+ }