@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.
- package/dist/index.mjs +1087 -0
- package/package.json +42 -0
- package/src/auth-bridge.test.ts +118 -0
- package/src/auth-bridge.ts +73 -0
- package/src/brand-badge-inject.test.ts +94 -0
- package/src/build.ts +430 -0
- package/src/codegen.ts +134 -0
- package/src/error-sink.ts +66 -0
- package/src/feedback-inject.test.ts +79 -0
- package/src/index.ts +967 -0
- package/src/prerender.ts +106 -0
- package/src/pwa.test.ts +138 -0
- package/src/pwa.ts +132 -0
- package/src/scanner-billing.ts +127 -0
- package/src/scanner-triggers.ts +15 -0
- package/src/scanner-workflows.test.ts +69 -0
- package/src/scanner-workflows.ts +125 -0
- package/src/scanner.ts +146 -0
|
@@ -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
|
+
}
|