@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.
@@ -0,0 +1,78 @@
1
+ // Trigger scanner (moved from vite-plugin) + cron driver selection.
2
+
3
+ import { describe, test, expect, afterEach } from "bun:test"
4
+ import fs from "node:fs"
5
+ import os from "node:os"
6
+ import path from "node:path"
7
+ import { extractTriggers, scanTriggers, writeTriggersManifest, TriggerScanError } from "../trigger-scan.ts"
8
+ import { setCronDriver } from "../triggers.ts"
9
+
10
+ const tmpRoots: string[] = []
11
+ function makeRoot(files: Record<string, string>): string {
12
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "vibes-trigger-scan-"))
13
+ tmpRoots.push(root)
14
+ fs.mkdirSync(path.join(root, "functions"), { recursive: true })
15
+ for (const [name, source] of Object.entries(files)) {
16
+ fs.writeFileSync(path.join(root, "functions", name), source, "utf-8")
17
+ }
18
+ return root
19
+ }
20
+
21
+ afterEach(() => {
22
+ for (const root of tmpRoots.splice(0)) {
23
+ fs.rmSync(root, { recursive: true, force: true })
24
+ }
25
+ // Reset to default so driver state doesn't leak across tests.
26
+ setCronDriver("orchestrator")
27
+ })
28
+
29
+ describe("extractTriggers", () => {
30
+ test("detects exported cron() and on() with literal first args", () => {
31
+ const src = [
32
+ `export const purge = cron("0 3 * * *", async () => {})`,
33
+ `export const welcome = on('user.signup', async (_ctx, p) => {})`,
34
+ ].join("\n")
35
+ const out = extractTriggers(src, "/x/cleanup.ts", "cleanup")
36
+ expect(out).toEqual([
37
+ expect.objectContaining({ handler: "cleanup.purge", kind: "cron", key: "0 3 * * *" }),
38
+ expect.objectContaining({ handler: "cleanup.welcome", kind: "on", key: "user.signup" }),
39
+ ])
40
+ })
41
+
42
+ test("throws loudly on a non-literal schedule", () => {
43
+ expect(() =>
44
+ extractTriggers(`export const p = cron(SCHEDULE, async () => {})`, "/x/a.ts", "a"),
45
+ ).toThrow(TriggerScanError)
46
+ })
47
+ })
48
+
49
+ describe("writeTriggersManifest", () => {
50
+ test("scans functions/ and writes .vibes/triggers.json", async () => {
51
+ const root = makeRoot({
52
+ "jobs.ts": `import { cron } from "@omg-dev/server"\nexport const tick = cron("*/10 * * * *", async () => {})\n`,
53
+ })
54
+ const written = await writeTriggersManifest(root)
55
+ expect(written.length).toBe(1)
56
+ const onDisk = JSON.parse(fs.readFileSync(path.join(root, ".vibes", "triggers.json"), "utf-8"))
57
+ expect(onDisk[0]).toMatchObject({ handler: "jobs.tick", kind: "cron", key: "*/10 * * * *" })
58
+ // Matches what scanTriggers returns directly.
59
+ expect(await scanTriggers(root)).toEqual(written)
60
+ })
61
+
62
+ test("no functions/ dir → empty manifest, no throw", async () => {
63
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "vibes-trigger-scan-"))
64
+ tmpRoots.push(root)
65
+ expect(await writeTriggersManifest(root)).toEqual([])
66
+ })
67
+ })
68
+
69
+ describe("setCronDriver", () => {
70
+ test("accepts the two known drivers", () => {
71
+ setCronDriver("in-process")
72
+ setCronDriver("orchestrator")
73
+ })
74
+
75
+ test("fails loud on anything else", () => {
76
+ expect(() => setCronDriver("inprocess" as never)).toThrow(/unknown cron driver/)
77
+ })
78
+ })
@@ -0,0 +1,173 @@
1
+ // Trigger scanner — detects `cron("…", fn)` and `on("…", fn)` call sites in
2
+ // functions/*.ts and emits a registry consumed by @omg-dev/server's in-process
3
+ // scheduler (dev) and the orchestrator's cron+event queue (prod).
4
+ //
5
+ // Lives in @omg-dev/server (moved from @omg-dev/vite-plugin) so non-vite hosts —
6
+ // the control-plane container, self-host single-container bundles — can scan
7
+ // + write .vibes/triggers.json at boot; the vite-plugin re-imports it from
8
+ // here for the build-time path. Pure node:fs/path, no vite dependency.
9
+ //
10
+ // Convention: a trigger MUST be declared as a top-level export:
11
+ //
12
+ // export const purge = cron("0 3 * * *", async (ctx) => { ... })
13
+ // export const welcome = on("user.signup", async (ctx, payload) => { ... })
14
+ //
15
+ // The first argument must be a *string literal* — dynamic schedules / topics
16
+ // can't be persisted ahead of time. We fail the build loudly when we detect
17
+ // a non-literal first arg so the user can't silently lose a trigger.
18
+ //
19
+ // Scanner shape mirrors scanner.ts (regex + walk). For correctness we use a
20
+ // stricter export-binding regex that also captures the call expression.
21
+
22
+ import fs from "node:fs"
23
+ import path from "node:path"
24
+
25
+ export type TriggerKind = "cron" | "on" | "storage:upload" | "storage:delete"
26
+
27
+ export interface ScannedTrigger {
28
+ /** Dispatch identifier: "<file-basename>.<exportName>" e.g. "cleanup.purge". */
29
+ handler: string
30
+ kind: TriggerKind
31
+ /** Cron expression (kind=cron) or topic string (kind=on). */
32
+ key: string
33
+ /** Absolute file path of the function module (for dev import). */
34
+ module: string
35
+ /** Exported binding name inside the module. */
36
+ exportName: string
37
+ }
38
+
39
+ /** scanTriggers walks `<root>/functions/` and returns every detected trigger. */
40
+ export async function scanTriggers(root: string): Promise<ScannedTrigger[]> {
41
+ const fnDir = path.join(root, "functions")
42
+ if (!fs.existsSync(fnDir)) return []
43
+
44
+ const out: ScannedTrigger[] = []
45
+ walk(fnDir, out)
46
+ // Also recurse into functions/api/ — same convention as scanner.ts.
47
+ const apiDir = path.join(fnDir, "api")
48
+ if (fs.existsSync(apiDir) && fs.statSync(apiDir).isDirectory()) {
49
+ walk(apiDir, out)
50
+ }
51
+ return out
52
+ }
53
+
54
+ function walk(dir: string, out: ScannedTrigger[]): void {
55
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
56
+ if (entry.isDirectory()) continue
57
+ if (!entry.name.endsWith(".ts") || entry.name.endsWith(".d.ts")) continue
58
+ const filePath = path.join(dir, entry.name)
59
+ const source = fs.readFileSync(filePath, "utf-8")
60
+ const basename = path.basename(entry.name, ".ts")
61
+ out.push(...extractTriggers(source, filePath, basename))
62
+ }
63
+ }
64
+
65
+ // ── Regex extraction ─────────────────────────────────────────────────────────
66
+ //
67
+ // Match either:
68
+ // export const NAME = cron("SCHEDULE", ...)
69
+ // export const NAME = on("TOPIC", ...)
70
+ // export const NAME: SomeType = cron(...) ← type annotation tolerated
71
+ //
72
+ // We rely on the cron/on call appearing on the same line as the export
73
+ // declaration. Multi-line literal-string-then-call is rare; if we miss it,
74
+ // the orchestrator simply doesn't fire the trigger — the build still
75
+ // succeeds (no silent data corruption). For clearer detection of mis-declared
76
+ // triggers we also scan for the call-site without `export const` prefix and
77
+ // emit a structured error.
78
+
79
+ const EXPORT_TRIGGER_RE = /^export\s+const\s+(\w+)\s*(?::\s*[^=]+)?\s*=\s*(cron|on)\s*\(\s*(.+?)\s*,/gm
80
+
81
+ // `storage.onUpload(handler)` / `storage.onDelete(handler)` have no first-arg
82
+ // literal — the "key" is implicit (file uploads vs deletes for this app). We
83
+ // emit them with an empty key string; the dispatcher treats matching by kind
84
+ // rather than (kind, key).
85
+ const EXPORT_STORAGE_HOOK_RE = /^export\s+const\s+(\w+)\s*(?::\s*[^=]+)?\s*=\s*storage\.(onUpload|onDelete)\s*\(/gm
86
+
87
+ // Detect a `cron(…` or `on(…` call that isn't bound to a top-level export.
88
+ // Used to surface helpful errors ("declare as `export const`...").
89
+ const ORPHAN_TRIGGER_RE = /(?<!export\s+const\s+\w+\s*=\s*)\b(cron|on)\s*\(/g
90
+
91
+ export class TriggerScanError extends Error {
92
+ constructor(public file: string, public detail: string) {
93
+ super(`[vibes:triggers] ${file}: ${detail}`)
94
+ this.name = "TriggerScanError"
95
+ }
96
+ }
97
+
98
+ export function extractTriggers(source: string, filePath: string, basename: string): ScannedTrigger[] {
99
+ const out: ScannedTrigger[] = []
100
+ // Strip line + block comments so trigger-shaped strings inside `// cron(...)`
101
+ // comments don't get picked up. Simple regex pass — good enough for the
102
+ // patterns we expect.
103
+ const stripped = source
104
+ .replace(/\/\/.*$/gm, "")
105
+ .replace(/\/\*[\s\S]*?\*\//g, "")
106
+
107
+ let m: RegExpExecArray | null
108
+ EXPORT_TRIGGER_RE.lastIndex = 0
109
+ while ((m = EXPORT_TRIGGER_RE.exec(stripped)) !== null) {
110
+ const [, exportName, kind, firstArg] = m
111
+ const lit = parseStringLiteral(firstArg!)
112
+ if (lit === null) {
113
+ throw new TriggerScanError(
114
+ filePath,
115
+ `${kind}() first arg must be a string literal — got: ${firstArg!.slice(0, 60)}`,
116
+ )
117
+ }
118
+ out.push({
119
+ handler: `${basename}.${exportName!}`,
120
+ kind: kind as TriggerKind,
121
+ key: lit,
122
+ module: filePath,
123
+ exportName: exportName!,
124
+ })
125
+ }
126
+ // storage.onUpload(...) / storage.onDelete(...) — no string literal key.
127
+ EXPORT_STORAGE_HOOK_RE.lastIndex = 0
128
+ while ((m = EXPORT_STORAGE_HOOK_RE.exec(stripped)) !== null) {
129
+ const [, exportName, hookName] = m
130
+ out.push({
131
+ handler: `${basename}.${exportName!}`,
132
+ kind: hookName === "onUpload" ? "storage:upload" : "storage:delete",
133
+ key: "", // not used for storage hooks; dispatcher matches by kind
134
+ module: filePath,
135
+ exportName: exportName!,
136
+ })
137
+ }
138
+ return out
139
+ }
140
+
141
+ /**
142
+ * Parse a single-line first-arg into a string literal. Returns null when the
143
+ * input isn't a plain "..." or '...' literal (e.g. it's a template literal, a
144
+ * variable reference, or a runtime expression).
145
+ */
146
+ function parseStringLiteral(raw: string): string | null {
147
+ const trimmed = raw.trim()
148
+ if (trimmed.length < 2) return null
149
+ const first = trimmed[0]
150
+ if ((first === '"' || first === "'") && trimmed.endsWith(first)) {
151
+ // Disallow embedded interpolation just in case.
152
+ const inner = trimmed.slice(1, -1)
153
+ if (inner.includes("${")) return null
154
+ // Unescape \" and \\ — we don't need full JSON unescaping here.
155
+ return inner.replace(/\\(.)/g, "$1")
156
+ }
157
+ return null
158
+ }
159
+
160
+ /**
161
+ * Scan `<root>/functions/` and write the result to `<root>/.vibes/triggers.json`
162
+ * — the file `createVibesServer` loads at boot when no bundled `triggers`
163
+ * array is passed. For hosts without a vite build (control-plane container,
164
+ * self-host bundles): call this before `createVibesServer` so cron()/on()
165
+ * declarations in functions/*.ts actually register.
166
+ */
167
+ export async function writeTriggersManifest(root: string): Promise<ScannedTrigger[]> {
168
+ const triggers = await scanTriggers(root)
169
+ const vibesDir = path.join(root, ".vibes")
170
+ fs.mkdirSync(vibesDir, { recursive: true })
171
+ fs.writeFileSync(path.join(vibesDir, "triggers.json"), JSON.stringify(triggers, null, 2), "utf-8")
172
+ return triggers
173
+ }