@omg-dev/vite-plugin 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,125 @@
1
+ // Workflow scanner — detects `workflow("…", fn)` call sites in functions/*.ts
2
+ // and emits a registry consumed by @omg-dev/server (dev engine + prod Restate
3
+ // endpoint) and the orchestrator (deployment registration at publish).
4
+ //
5
+ // Convention mirrors scanner-triggers.ts: a workflow MUST be a top-level
6
+ // export with a string-literal first arg:
7
+ //
8
+ // export const onboarding = workflow("onboarding", async (step, payload) => { ... })
9
+ //
10
+ // The name becomes the engine-side handler name. Restate rejects handler
11
+ // names outside ^([a-zA-Z]|_[a-zA-Z0-9])[a-zA-Z0-9_]*$ at discovery time
12
+ // (verified against 1.6.2 — dashes are NOT allowed), so we enforce
13
+ // [A-Za-z][A-Za-z0-9_]* here and fail the build loudly — a publish-time
14
+ // registration failure would strand every startWorkflow() call site.
15
+
16
+ import fs from "node:fs"
17
+ import path from "node:path"
18
+
19
+ export interface ScannedWorkflow {
20
+ /** Workflow name — the literal first arg of workflow(). */
21
+ name: string
22
+ /** Dispatch identifier: "<file-basename>.<exportName>" (parity with triggers). */
23
+ handler: string
24
+ /** Absolute file path of the function module (for dev import). */
25
+ module: string
26
+ /** Exported binding name inside the module. */
27
+ exportName: string
28
+ }
29
+
30
+ const EXPORT_WORKFLOW_RE = /^export\s+const\s+(\w+)\s*(?::\s*[^=]+)?\s*=\s*workflow\s*\(\s*(.+?)\s*,/gm
31
+
32
+ const NAME_RE = /^[A-Za-z][A-Za-z0-9_]*$/
33
+
34
+ export class WorkflowScanError extends Error {
35
+ constructor(public file: string, public detail: string) {
36
+ super(`[vibes:workflows] ${file}: ${detail}`)
37
+ this.name = "WorkflowScanError"
38
+ }
39
+ }
40
+
41
+ /** scanWorkflows walks `<root>/functions/` and returns every declared workflow. */
42
+ export async function scanWorkflows(root: string): Promise<ScannedWorkflow[]> {
43
+ const fnDir = path.join(root, "functions")
44
+ if (!fs.existsSync(fnDir)) return []
45
+
46
+ const out: ScannedWorkflow[] = []
47
+ walk(fnDir, out)
48
+ const apiDir = path.join(fnDir, "api")
49
+ if (fs.existsSync(apiDir) && fs.statSync(apiDir).isDirectory()) {
50
+ walk(apiDir, out)
51
+ }
52
+
53
+ // Names key engine handlers + startWorkflow() lookups — duplicates would
54
+ // shadow each other. Fail loud at build time.
55
+ const seen = new Map<string, string>()
56
+ for (const w of out) {
57
+ const prev = seen.get(w.name)
58
+ if (prev) {
59
+ throw new WorkflowScanError(
60
+ w.module,
61
+ `duplicate workflow name "${w.name}" (also declared by ${prev})`,
62
+ )
63
+ }
64
+ seen.set(w.name, w.handler)
65
+ }
66
+ return out
67
+ }
68
+
69
+ function walk(dir: string, out: ScannedWorkflow[]): void {
70
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
71
+ if (entry.isDirectory()) continue
72
+ if (!entry.name.endsWith(".ts") || entry.name.endsWith(".d.ts")) continue
73
+ const filePath = path.join(dir, entry.name)
74
+ const source = fs.readFileSync(filePath, "utf-8")
75
+ const basename = path.basename(entry.name, ".ts")
76
+ out.push(...extractWorkflows(source, filePath, basename))
77
+ }
78
+ }
79
+
80
+ export function extractWorkflows(source: string, filePath: string, basename: string): ScannedWorkflow[] {
81
+ const out: ScannedWorkflow[] = []
82
+ // Strip comments so commented-out declarations aren't picked up (same
83
+ // simple pass as scanner-triggers.ts).
84
+ const stripped = source
85
+ .replace(/\/\/.*$/gm, "")
86
+ .replace(/\/\*[\s\S]*?\*\//g, "")
87
+
88
+ let m: RegExpExecArray | null
89
+ EXPORT_WORKFLOW_RE.lastIndex = 0
90
+ while ((m = EXPORT_WORKFLOW_RE.exec(stripped)) !== null) {
91
+ const [, exportName, firstArg] = m
92
+ const lit = parseStringLiteral(firstArg!)
93
+ if (lit === null) {
94
+ throw new WorkflowScanError(
95
+ filePath,
96
+ `workflow() first arg must be a string literal — got: ${firstArg!.slice(0, 60)}`,
97
+ )
98
+ }
99
+ if (!NAME_RE.test(lit)) {
100
+ throw new WorkflowScanError(
101
+ filePath,
102
+ `workflow name "${lit}" is invalid — use [A-Za-z][A-Za-z0-9_]* (no dashes; it becomes the engine handler name)`,
103
+ )
104
+ }
105
+ out.push({
106
+ name: lit,
107
+ handler: `${basename}.${exportName!}`,
108
+ module: filePath,
109
+ exportName: exportName!,
110
+ })
111
+ }
112
+ return out
113
+ }
114
+
115
+ function parseStringLiteral(raw: string): string | null {
116
+ const trimmed = raw.trim()
117
+ if (trimmed.length < 2) return null
118
+ const first = trimmed[0]
119
+ if ((first === '"' || first === "'") && trimmed.endsWith(first)) {
120
+ const inner = trimmed.slice(1, -1)
121
+ if (inner.includes("${")) return null
122
+ return inner.replace(/\\(.)/g, "$1")
123
+ }
124
+ return null
125
+ }
package/src/scanner.ts ADDED
@@ -0,0 +1,146 @@
1
+ import fs from "node:fs"
2
+ import path from "node:path"
3
+
4
+ export interface Route {
5
+ method: string
6
+ path: string
7
+ module: string
8
+ handler: string
9
+ /**
10
+ * "crud" — handler is invoked with the conventional (id?, body?) args.
11
+ * handler name maps to an HTTP method via list/get/create/update/remove.
12
+ * "method" — handler is invoked with (req: Request, params). Used when the
13
+ * exported name is an HTTP method (GET/POST/PUT/PATCH/DELETE)
14
+ * and the handler should receive the raw Request — e.g. for
15
+ * streaming responses, custom headers, or non-JSON bodies.
16
+ */
17
+ style?: "crud" | "method"
18
+ }
19
+
20
+ const HTTP_METHODS = new Set(["GET", "POST", "PUT", "PATCH", "DELETE"])
21
+
22
+ function handlerToRoute(routePath: string, handlerName: string, modulePath: string): Route {
23
+ // Method-named exports — receive the raw Request, route by HTTP method on
24
+ // the resource path itself (no /:name suffix). This is what unlocks
25
+ // streaming handlers that previously had to live in a separate Bun.serve.
26
+ if (HTTP_METHODS.has(handlerName)) {
27
+ return {
28
+ method: handlerName,
29
+ path: routePath,
30
+ module: modulePath,
31
+ handler: handlerName,
32
+ style: "method",
33
+ }
34
+ }
35
+ // CRUD convention.
36
+ switch (handlerName) {
37
+ case "list": return { method: "GET", path: routePath, module: modulePath, handler: handlerName, style: "crud" }
38
+ case "get": return { method: "GET", path: `${routePath}/:id`, module: modulePath, handler: handlerName, style: "crud" }
39
+ case "create": return { method: "POST", path: routePath, module: modulePath, handler: handlerName, style: "crud" }
40
+ case "update": return { method: "PATCH", path: `${routePath}/:id`, module: modulePath, handler: handlerName, style: "crud" }
41
+ case "remove": return { method: "DELETE", path: `${routePath}/:id`, module: modulePath, handler: handlerName, style: "crud" }
42
+ default: return { method: "POST", path: `${routePath}/${handlerName}`, module: modulePath, handler: handlerName, style: "crud" }
43
+ }
44
+ }
45
+
46
+ // ── scanFunctions ─────────────────────────────────────────────────────────────
47
+
48
+ /**
49
+ * Scans the `functions/` directory in `dir` and returns a list of routes.
50
+ *
51
+ * Layout:
52
+ * functions/<name>.ts → routes mounted at /api/<name>
53
+ * functions/api/<name>.ts → routes mounted at /api/<name> (the `api/`
54
+ * subdir is treated as a no-op prefix so the
55
+ * path doesn't double up to /api/api/<name>;
56
+ * lets people group "raw HTTP" handlers
57
+ * alongside CRUD without an ugly URL).
58
+ *
59
+ * Uses regex to extract exported function names — no AST parser needed.
60
+ */
61
+ export async function scanFunctions(dir: string): Promise<Route[]> {
62
+ const functionsDir = path.join(dir, "functions")
63
+ if (!fs.existsSync(functionsDir)) return []
64
+
65
+ const routes: Route[] = []
66
+
67
+ // Top-level: functions/<name>.ts
68
+ walkDir(functionsDir, "/api", routes, /* skipDirName */ "api")
69
+
70
+ // Nested: functions/api/<name>.ts — treated as if it were at the root of
71
+ // functions/ so paths stay /api/<name> rather than /api/api/<name>.
72
+ const apiDir = path.join(functionsDir, "api")
73
+ if (fs.existsSync(apiDir) && fs.statSync(apiDir).isDirectory()) {
74
+ walkDir(apiDir, "/api", routes, /* skipDirName */ null)
75
+ }
76
+
77
+ return routes
78
+ }
79
+
80
+ function walkDir(
81
+ fullDir: string,
82
+ routePrefix: string,
83
+ routes: Route[],
84
+ skipDirName: string | null,
85
+ ): void {
86
+ const entries = fs.readdirSync(fullDir, { withFileTypes: true })
87
+ for (const entry of entries) {
88
+ if (entry.isDirectory()) continue // nested dirs handled separately
89
+ if (skipDirName && entry.name === skipDirName) continue
90
+ if (!entry.name.endsWith(".ts") || entry.name.endsWith(".d.ts")) continue
91
+
92
+ const filePath = path.join(fullDir, entry.name)
93
+ const resource = path.basename(entry.name, ".ts")
94
+ const source = fs.readFileSync(filePath, "utf-8")
95
+ const handlerNames = extractExports(source)
96
+
97
+ for (const handlerName of handlerNames) {
98
+ routes.push(handlerToRoute(`${routePrefix}/${resource}`, handlerName, filePath))
99
+ }
100
+ }
101
+ }
102
+
103
+ // ── extractExports ────────────────────────────────────────────────────────────
104
+
105
+ /**
106
+ * Extracts exported function/const names from TypeScript source using regex.
107
+ */
108
+ export function extractExports(source: string): string[] {
109
+ const names: string[] = []
110
+ const seen = new Set<string>()
111
+
112
+ // Match: export async function name / export function name
113
+ const fnRegex = /^export\s+(?:async\s+)?function\s+(\w+)/gm
114
+ let match: RegExpExecArray | null
115
+ while ((match = fnRegex.exec(source)) !== null) {
116
+ const name = match[1]
117
+ if (!seen.has(name)) {
118
+ seen.add(name)
119
+ names.push(name)
120
+ }
121
+ }
122
+
123
+ // Match: export const name = / export const name: Type =
124
+ const constRegex = /^export\s+const\s+(\w+)\s*(?::\s*[^=]+)?\s*=/gm
125
+ while ((match = constRegex.exec(source)) !== null) {
126
+ const name = match[1]
127
+ if (!seen.has(name)) {
128
+ seen.add(name)
129
+ names.push(name)
130
+ }
131
+ }
132
+
133
+ // Match: export { name, name2 }
134
+ const namedExportRegex = /^export\s*\{([^}]+)\}/gm
135
+ while ((match = namedExportRegex.exec(source)) !== null) {
136
+ const exports = match[1].split(",").map(s => s.trim().split(/\s+as\s+/).pop()!.trim())
137
+ for (const name of exports) {
138
+ if (name && !seen.has(name)) {
139
+ seen.add(name)
140
+ names.push(name)
141
+ }
142
+ }
143
+ }
144
+
145
+ return names
146
+ }