@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/dist/index.mjs +3278 -0
- package/dist/trigger-scan.mjs +95 -0
- package/package.json +40 -0
- package/src/auto-crud.ts +244 -0
- package/src/billing.ts +297 -0
- package/src/broker.ts +85 -0
- package/src/codec.ts +41 -0
- package/src/ctx.ts +33 -0
- package/src/db.ts +258 -0
- package/src/dispatcher.ts +257 -0
- package/src/http-error.ts +20 -0
- package/src/index.ts +702 -0
- package/src/migrator.ts +167 -0
- package/src/notifications.ts +628 -0
- package/src/predicate.ts +440 -0
- package/src/storage.ts +384 -0
- package/src/subscriptions.ts +654 -0
- package/src/test/auto-crud.test.ts +385 -0
- package/src/test/dispatcher.test.ts +271 -0
- package/src/test/migrator.test.ts +166 -0
- package/src/test/notifications.test.ts +96 -0
- package/src/test/predicate.test.ts +267 -0
- package/src/test/schema-swap.test.ts +252 -0
- package/src/test/security.test.ts +323 -0
- package/src/test/subscriptions.test.ts +878 -0
- package/src/test/trigger-scan.test.ts +78 -0
- package/src/trigger-scan.ts +173 -0
- package/src/triggers.ts +837 -0
- package/src/web-push.d.ts +18 -0
- package/src/workflows.test.ts +127 -0
- package/src/workflows.ts +438 -0
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
|
+
}
|