@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/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
|
+
}
|