@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
package/src/prerender.ts
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// Build-time prerender (SSG) for user apps.
|
|
2
|
+
//
|
|
3
|
+
// Renders the app's root view (<App/> from src/App.tsx) to static HTML at build
|
|
4
|
+
// time and bakes it into dist/index.html, so crawlers + first paint see real
|
|
5
|
+
// content instead of an empty SPA shell. The client (src/main.tsx, createRoot)
|
|
6
|
+
// mounts the full app over whatever HTML is in #root — so behavior is identical
|
|
7
|
+
// whether or not the prerender ran; it only changes the initial bytes.
|
|
8
|
+
//
|
|
9
|
+
// FAIL-SOFT IS THE CONTRACT. This runs against arbitrary agent-generated code,
|
|
10
|
+
// so anything can go wrong (a module-scope `window` reference, a render-time
|
|
11
|
+
// throw, a missing App). Every failure path leaves dist/index.html exactly as
|
|
12
|
+
// the client build produced it and returns normally — a prerender failure can
|
|
13
|
+
// never fail a deploy. The worst case is "no prerender", never a broken page.
|
|
14
|
+
import fs from "node:fs"
|
|
15
|
+
import path from "node:path"
|
|
16
|
+
|
|
17
|
+
const ROOT_MARKER = '<div id="root"></div>'
|
|
18
|
+
|
|
19
|
+
export type PrerenderResult =
|
|
20
|
+
| { status: "baked"; bytes: number }
|
|
21
|
+
| { status: "skipped"; reason: string }
|
|
22
|
+
|
|
23
|
+
// Renders src/App.tsx to HTML and bakes it into dist/index.html. Never throws.
|
|
24
|
+
export async function prerenderApp(opts: {
|
|
25
|
+
root: string
|
|
26
|
+
distDir: string
|
|
27
|
+
vibesDir: string
|
|
28
|
+
log?: (msg: string) => void
|
|
29
|
+
}): Promise<PrerenderResult> {
|
|
30
|
+
const { root, distDir, vibesDir } = opts
|
|
31
|
+
const log = opts.log ?? (() => {})
|
|
32
|
+
const htmlPath = path.join(distDir, "index.html")
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
const appPath = path.join(root, "src", "App.tsx")
|
|
36
|
+
if (!fs.existsSync(appPath)) {
|
|
37
|
+
return done(log, { status: "skipped", reason: "src/App.tsx not found" })
|
|
38
|
+
}
|
|
39
|
+
if (!fs.existsSync(htmlPath)) {
|
|
40
|
+
return done(log, { status: "skipped", reason: "dist/index.html not found" })
|
|
41
|
+
}
|
|
42
|
+
const shell = fs.readFileSync(htmlPath, "utf-8")
|
|
43
|
+
if (!shell.includes(ROOT_MARKER)) {
|
|
44
|
+
return done(log, { status: "skipped", reason: "#root marker not found (custom index.html?)" })
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Build-generated SSR entry — lives in .vibes/ so we never add a file to the
|
|
48
|
+
// user's src tree, and works for apps that predate this feature. Imports the
|
|
49
|
+
// conventional app root (default export of src/App.tsx) and renders it in the
|
|
50
|
+
// same StrictMode wrapper the client uses (src/main.tsx), so the baked markup
|
|
51
|
+
// matches the client's first render.
|
|
52
|
+
const entryPath = path.join(vibesDir, "prerender.entry.tsx")
|
|
53
|
+
fs.writeFileSync(
|
|
54
|
+
entryPath,
|
|
55
|
+
[
|
|
56
|
+
"// AUTO-GENERATED by @omg-dev/vite-plugin. Do not edit.",
|
|
57
|
+
'import { StrictMode } from "react"',
|
|
58
|
+
'import { renderToString } from "react-dom/server"',
|
|
59
|
+
'import App from "../src/App.tsx"',
|
|
60
|
+
"export function render() {",
|
|
61
|
+
" return renderToString(<StrictMode><App /></StrictMode>)",
|
|
62
|
+
"}",
|
|
63
|
+
"",
|
|
64
|
+
].join("\n"),
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
// Use the app's own Vite config (react plugin, @/ alias, @omg-dev/vite-plugin)
|
|
68
|
+
// so SSR module resolution matches the client build. middlewareMode = no
|
|
69
|
+
// port binding; the vibes plugin's WS upgrade is guarded on httpServer (null
|
|
70
|
+
// here), so nothing tries to listen.
|
|
71
|
+
const { createServer } = await import("vite")
|
|
72
|
+
const vite = await createServer({
|
|
73
|
+
root,
|
|
74
|
+
appType: "custom",
|
|
75
|
+
server: { middlewareMode: true, hmr: false },
|
|
76
|
+
logLevel: "error",
|
|
77
|
+
})
|
|
78
|
+
try {
|
|
79
|
+
const mod = (await vite.ssrLoadModule("/.vibes/prerender.entry.tsx")) as {
|
|
80
|
+
render: () => string
|
|
81
|
+
}
|
|
82
|
+
const html = mod.render()
|
|
83
|
+
if (!html || !html.trim()) {
|
|
84
|
+
return done(log, { status: "skipped", reason: "empty render output" })
|
|
85
|
+
}
|
|
86
|
+
const out = shell.replace(ROOT_MARKER, `<div id="root">${html}</div>`)
|
|
87
|
+
fs.writeFileSync(htmlPath, out)
|
|
88
|
+
return done(log, { status: "baked", bytes: html.length })
|
|
89
|
+
} finally {
|
|
90
|
+
await vite.close()
|
|
91
|
+
fs.rmSync(entryPath, { force: true })
|
|
92
|
+
}
|
|
93
|
+
} catch (err) {
|
|
94
|
+
// Fail-soft: keep the un-prerendered shell, report why.
|
|
95
|
+
return done(log, { status: "skipped", reason: String(err).split("\n")[0] })
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function done(log: (m: string) => void, r: PrerenderResult): PrerenderResult {
|
|
100
|
+
if (r.status === "baked") {
|
|
101
|
+
log(`prerender: ✓ baked app root into dist/index.html (+${r.bytes}B static HTML)`)
|
|
102
|
+
} else {
|
|
103
|
+
log(`prerender: SPA shell kept (no prerender) — ${r.reason}`)
|
|
104
|
+
}
|
|
105
|
+
return r
|
|
106
|
+
}
|
package/src/pwa.test.ts
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test"
|
|
2
|
+
import {
|
|
3
|
+
APPLE_TOUCH_ICON,
|
|
4
|
+
MANIFEST_FILE,
|
|
5
|
+
PWA_ICONS,
|
|
6
|
+
buildWebManifest,
|
|
7
|
+
injectPwaTags,
|
|
8
|
+
isAppEntry,
|
|
9
|
+
resolvePwaConfig,
|
|
10
|
+
} from "./pwa.ts"
|
|
11
|
+
|
|
12
|
+
const HTML = `<!doctype html>
|
|
13
|
+
<html lang="en">
|
|
14
|
+
<head>
|
|
15
|
+
<meta charset="UTF-8" />
|
|
16
|
+
<title>Tide Tracker</title>
|
|
17
|
+
</head>
|
|
18
|
+
<body><div id="root"></div></body>
|
|
19
|
+
</html>`
|
|
20
|
+
|
|
21
|
+
describe("resolvePwaConfig", () => {
|
|
22
|
+
test("name from <title>, 12-char short_name, default colors", () => {
|
|
23
|
+
const cfg = resolvePwaConfig(undefined, HTML)
|
|
24
|
+
expect(cfg.name).toBe("Tide Tracker")
|
|
25
|
+
expect(cfg.shortName).toBe("Tide Tracker") // exactly 12 chars
|
|
26
|
+
expect(cfg.themeColor).toBe("#ffffff")
|
|
27
|
+
expect(cfg.backgroundColor).toBe("#ffffff")
|
|
28
|
+
expect(cfg.autoPrompt).toBe(true)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
test("long titles truncate short_name without trailing space", () => {
|
|
32
|
+
const cfg = resolvePwaConfig(undefined, "<title>My Wonderful Recipe Box</title>")
|
|
33
|
+
expect(cfg.shortName).toBe("My Wonderful")
|
|
34
|
+
expect(cfg.shortName.length).toBeLessThanOrEqual(12)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test("explicit options win over parsed html", () => {
|
|
38
|
+
const cfg = resolvePwaConfig(
|
|
39
|
+
{ name: "Custom", themeColor: "#112233", autoPrompt: false },
|
|
40
|
+
HTML,
|
|
41
|
+
)
|
|
42
|
+
expect(cfg.name).toBe("Custom")
|
|
43
|
+
expect(cfg.shortName).toBe("Custom")
|
|
44
|
+
expect(cfg.themeColor).toBe("#112233")
|
|
45
|
+
expect(cfg.backgroundColor).toBe("#112233")
|
|
46
|
+
expect(cfg.autoPrompt).toBe(false)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test("picks up an existing theme-color meta", () => {
|
|
50
|
+
const cfg = resolvePwaConfig(
|
|
51
|
+
undefined,
|
|
52
|
+
`<title>X</title><meta name="theme-color" content="#15110D">`,
|
|
53
|
+
)
|
|
54
|
+
expect(cfg.themeColor).toBe("#15110D")
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test("empty title falls back to App", () => {
|
|
58
|
+
const cfg = resolvePwaConfig(undefined, "<title></title>")
|
|
59
|
+
expect(cfg.name).toBe("App")
|
|
60
|
+
})
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
describe("buildWebManifest", () => {
|
|
64
|
+
test("emits only icons that exist", () => {
|
|
65
|
+
const cfg = resolvePwaConfig(undefined, HTML)
|
|
66
|
+
const manifest = JSON.parse(buildWebManifest(cfg, ["icons/pwa-192x192.png"]))
|
|
67
|
+
expect(manifest.display).toBe("standalone")
|
|
68
|
+
expect(manifest.start_url).toBe("/")
|
|
69
|
+
expect(manifest.icons).toEqual([
|
|
70
|
+
{ src: "/icons/pwa-192x192.png", sizes: "192x192", type: "image/png" },
|
|
71
|
+
])
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
test("full icon set includes maskable purpose", () => {
|
|
75
|
+
const cfg = resolvePwaConfig(undefined, HTML)
|
|
76
|
+
const all = PWA_ICONS.map((i) => i.file)
|
|
77
|
+
const manifest = JSON.parse(buildWebManifest(cfg, all))
|
|
78
|
+
expect(manifest.icons).toHaveLength(3)
|
|
79
|
+
expect(manifest.icons[2].purpose).toBe("maskable")
|
|
80
|
+
})
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
describe("injectPwaTags", () => {
|
|
84
|
+
const cfg = resolvePwaConfig(undefined, HTML)
|
|
85
|
+
|
|
86
|
+
test("injects manifest link, capable metas, apple-touch icon, theme-color", () => {
|
|
87
|
+
const out = injectPwaTags(HTML, cfg, { hasAppleIcon: true })
|
|
88
|
+
expect(out).toContain(`<link rel="manifest" href="/${MANIFEST_FILE}">`)
|
|
89
|
+
expect(out).toContain(`<meta name="mobile-web-app-capable" content="yes">`)
|
|
90
|
+
expect(out).toContain(`<meta name="apple-mobile-web-app-capable" content="yes">`)
|
|
91
|
+
expect(out).toContain(`<meta name="apple-mobile-web-app-title" content="Tide Tracker">`)
|
|
92
|
+
expect(out).toContain(`<link rel="apple-touch-icon" href="/${APPLE_TOUCH_ICON}">`)
|
|
93
|
+
expect(out).toContain(`<meta name="theme-color" content="#ffffff">`)
|
|
94
|
+
// injected inside <head>
|
|
95
|
+
expect(out.indexOf("</head>")).toBeGreaterThan(out.indexOf("rel=\"manifest\""))
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
test("no apple-touch link when the icon file is missing", () => {
|
|
99
|
+
const out = injectPwaTags(HTML, cfg, { hasAppleIcon: false })
|
|
100
|
+
expect(out).not.toContain("apple-touch-icon")
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
test("leaves apps with their own manifest link untouched", () => {
|
|
104
|
+
const html = `<head><link rel="manifest" href="/mine.webmanifest"><title>X</title></head>`
|
|
105
|
+
expect(injectPwaTags(html, cfg, { hasAppleIcon: true })).toBe(html)
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
test("respects a user manifest href", () => {
|
|
109
|
+
const out = injectPwaTags(HTML, cfg, { hasAppleIcon: false, manifestHref: "/manifest.json" })
|
|
110
|
+
expect(out).toContain(`<link rel="manifest" href="/manifest.json">`)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
test("does not duplicate an existing theme-color meta", () => {
|
|
114
|
+
const html = `<head><meta name="theme-color" content="#000"><title>X</title></head>`
|
|
115
|
+
const out = injectPwaTags(html, cfg, { hasAppleIcon: false })
|
|
116
|
+
expect(out.match(/theme-color/g)).toHaveLength(1)
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
test("escapes quotes in app names", () => {
|
|
120
|
+
const evil = resolvePwaConfig(undefined, `<title>A"B</title>`)
|
|
121
|
+
const out = injectPwaTags(HTML, evil, { hasAppleIcon: false })
|
|
122
|
+
expect(out).toContain(`content="A"B"`)
|
|
123
|
+
})
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
describe("isAppEntry", () => {
|
|
127
|
+
test("matches src/main.tsx under root", () => {
|
|
128
|
+
expect(isAppEntry("/app/src/main.tsx", "/app")).toBe(true)
|
|
129
|
+
expect(isAppEntry("/app/src/main.ts?v=123", "/app")).toBe(true)
|
|
130
|
+
expect(isAppEntry("/app/src/main.jsx", "/app")).toBe(true)
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
test("rejects other files, node_modules, and foreign roots", () => {
|
|
134
|
+
expect(isAppEntry("/app/src/App.tsx", "/app")).toBe(false)
|
|
135
|
+
expect(isAppEntry("/app/node_modules/x/src/main.tsx", "/app")).toBe(false)
|
|
136
|
+
expect(isAppEntry("/elsewhere/src/main.tsx", "/app")).toBe(false)
|
|
137
|
+
})
|
|
138
|
+
})
|
package/src/pwa.ts
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// PWA installability for published apps (default ON; vibes({ pwa: false })
|
|
2
|
+
// to opt out). At build time this:
|
|
3
|
+
// 1. emits /manifest.webmanifest (name from <title>, standalone display)
|
|
4
|
+
// 2. injects manifest/apple-touch/meta tags into index.html
|
|
5
|
+
// 3. appends `import "@omg-dev/pwa/auto"` to the app entry — the soft
|
|
6
|
+
// install prompt that mounts outside the app's React tree
|
|
7
|
+
//
|
|
8
|
+
// Dev (`command === "serve"`) is untouched: the preview always runs inside
|
|
9
|
+
// the dashboard iframe where installing makes no sense.
|
|
10
|
+
//
|
|
11
|
+
// Icons are NOT generated here — the template ships default PNGs under
|
|
12
|
+
// public/icons/ and the build agent is instructed to replace them. Manifest
|
|
13
|
+
// entries are emitted only for icon files that actually exist, so an app
|
|
14
|
+
// missing them still gets a valid (if degraded) manifest instead of 404ing
|
|
15
|
+
// icon links.
|
|
16
|
+
|
|
17
|
+
export interface PwaOptions {
|
|
18
|
+
/** Manifest `name` (default: the app's <title>). */
|
|
19
|
+
name?: string
|
|
20
|
+
/** Manifest `short_name` (default: `name` truncated to 12 chars). */
|
|
21
|
+
shortName?: string
|
|
22
|
+
/** Manifest + <meta> theme color (default: existing theme-color meta, else "#ffffff"). */
|
|
23
|
+
themeColor?: string
|
|
24
|
+
/** Manifest background_color (default: themeColor). */
|
|
25
|
+
backgroundColor?: string
|
|
26
|
+
/** Inject the @omg-dev/pwa/auto soft install prompt (default: true). */
|
|
27
|
+
autoPrompt?: boolean
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ResolvedPwaConfig {
|
|
31
|
+
name: string
|
|
32
|
+
shortName: string
|
|
33
|
+
themeColor: string
|
|
34
|
+
backgroundColor: string
|
|
35
|
+
autoPrompt: boolean
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Icon set contract shared with templates/react-ts/public/icons/. */
|
|
39
|
+
export const PWA_ICONS = [
|
|
40
|
+
{ file: "icons/pwa-192x192.png", sizes: "192x192", purpose: "any" },
|
|
41
|
+
{ file: "icons/pwa-512x512.png", sizes: "512x512", purpose: "any" },
|
|
42
|
+
{ file: "icons/pwa-512x512-maskable.png", sizes: "512x512", purpose: "maskable" },
|
|
43
|
+
] as const
|
|
44
|
+
|
|
45
|
+
export const APPLE_TOUCH_ICON = "icons/apple-touch-icon.png"
|
|
46
|
+
export const MANIFEST_FILE = "manifest.webmanifest"
|
|
47
|
+
|
|
48
|
+
export function resolvePwaConfig(opts: PwaOptions | undefined, indexHtml: string): ResolvedPwaConfig {
|
|
49
|
+
const title = /<title[^>]*>([^<]*)<\/title>/i.exec(indexHtml)?.[1]?.trim()
|
|
50
|
+
const metaTheme = /<meta[^>]+name=["']theme-color["'][^>]*content=["']([^"']+)["']/i.exec(indexHtml)?.[1]
|
|
51
|
+
?? /<meta[^>]+content=["']([^"']+)["'][^>]*name=["']theme-color["']/i.exec(indexHtml)?.[1]
|
|
52
|
+
const name = opts?.name ?? (title || "App")
|
|
53
|
+
const themeColor = opts?.themeColor ?? metaTheme ?? "#ffffff"
|
|
54
|
+
return {
|
|
55
|
+
name,
|
|
56
|
+
shortName: opts?.shortName ?? (name.length > 12 ? name.slice(0, 12).trimEnd() : name),
|
|
57
|
+
themeColor,
|
|
58
|
+
backgroundColor: opts?.backgroundColor ?? themeColor,
|
|
59
|
+
autoPrompt: opts?.autoPrompt !== false,
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function buildWebManifest(cfg: ResolvedPwaConfig, availableIconFiles: string[]): string {
|
|
64
|
+
const available = new Set(availableIconFiles)
|
|
65
|
+
const icons = PWA_ICONS.filter((i) => available.has(i.file)).map((i) => ({
|
|
66
|
+
src: `/${i.file}`,
|
|
67
|
+
sizes: i.sizes,
|
|
68
|
+
type: "image/png",
|
|
69
|
+
...(i.purpose === "maskable" ? { purpose: "maskable" } : {}),
|
|
70
|
+
}))
|
|
71
|
+
return JSON.stringify(
|
|
72
|
+
{
|
|
73
|
+
name: cfg.name,
|
|
74
|
+
short_name: cfg.shortName,
|
|
75
|
+
start_url: "/",
|
|
76
|
+
scope: "/",
|
|
77
|
+
display: "standalone",
|
|
78
|
+
background_color: cfg.backgroundColor,
|
|
79
|
+
theme_color: cfg.themeColor,
|
|
80
|
+
icons,
|
|
81
|
+
},
|
|
82
|
+
null,
|
|
83
|
+
2,
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function escapeAttr(value: string): string {
|
|
88
|
+
return value
|
|
89
|
+
.replace(/&/g, "&")
|
|
90
|
+
.replace(/"/g, """)
|
|
91
|
+
.replace(/</g, "<")
|
|
92
|
+
.replace(/>/g, ">")
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Inject PWA tags into built index.html. Idempotent: an app that already
|
|
96
|
+
* declares a manifest link (hand-rolled PWA) is left entirely alone. */
|
|
97
|
+
export function injectPwaTags(
|
|
98
|
+
html: string,
|
|
99
|
+
cfg: ResolvedPwaConfig,
|
|
100
|
+
opts: { hasAppleIcon: boolean; manifestHref?: string },
|
|
101
|
+
): string {
|
|
102
|
+
if (/rel=["']manifest["']/i.test(html)) return html
|
|
103
|
+
const tags: string[] = [
|
|
104
|
+
`<link rel="manifest" href="${opts.manifestHref ?? `/${MANIFEST_FILE}`}">`,
|
|
105
|
+
`<meta name="mobile-web-app-capable" content="yes">`,
|
|
106
|
+
]
|
|
107
|
+
if (!/name=["']apple-mobile-web-app-capable["']/i.test(html)) {
|
|
108
|
+
tags.push(`<meta name="apple-mobile-web-app-capable" content="yes">`)
|
|
109
|
+
}
|
|
110
|
+
if (!/name=["']apple-mobile-web-app-title["']/i.test(html)) {
|
|
111
|
+
tags.push(`<meta name="apple-mobile-web-app-title" content="${escapeAttr(cfg.shortName)}">`)
|
|
112
|
+
}
|
|
113
|
+
if (opts.hasAppleIcon && !/rel=["']apple-touch-icon["']/i.test(html)) {
|
|
114
|
+
tags.push(`<link rel="apple-touch-icon" href="/${APPLE_TOUCH_ICON}">`)
|
|
115
|
+
}
|
|
116
|
+
if (!/name=["']theme-color["']/i.test(html)) {
|
|
117
|
+
tags.push(`<meta name="theme-color" content="${escapeAttr(cfg.themeColor)}">`)
|
|
118
|
+
}
|
|
119
|
+
const block = `\n ${tags.join("\n ")}`
|
|
120
|
+
return /<\/head>/i.test(html)
|
|
121
|
+
? html.replace(/<\/head>/i, `${block}\n </head>`)
|
|
122
|
+
: block + html
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** True when `id` is the app's client entry (src/main.*) — the injection
|
|
126
|
+
* point for the auto install prompt import. */
|
|
127
|
+
export function isAppEntry(id: string, root: string): boolean {
|
|
128
|
+
const clean = id.split("?")[0]
|
|
129
|
+
if (!clean.startsWith(root)) return false
|
|
130
|
+
if (clean.includes("node_modules")) return false
|
|
131
|
+
return /[\\/]src[\\/]main\.(tsx|ts|jsx|js)$/.test(clean)
|
|
132
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import fs from "node:fs"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
|
|
4
|
+
// ── scanBilling ────────────────────────────────────────────────────────────
|
|
5
|
+
//
|
|
6
|
+
// Discovers the app's @omg-dev/billing declaration the same way scanFunctions
|
|
7
|
+
// discovers routes: walk the source tree, find the module that calls
|
|
8
|
+
// `defineBilling(...)`, import it, and serialize the resulting Billing object
|
|
9
|
+
// to the canonical catalog JSON the Go enforcer reads (deploys.catalog_json).
|
|
10
|
+
//
|
|
11
|
+
// Convention is intentionally loose — the config can live anywhere (the
|
|
12
|
+
// vibes-pricing skill writes src/billing.ts, but root-level pricing.ts etc.
|
|
13
|
+
// are equally valid) — so we SCAN rather than hard-code a path. We only look
|
|
14
|
+
// at .ts files: a .tsx is almost certainly a component and importing it at
|
|
15
|
+
// build time could trigger DOM/React side effects, whereas a billing config
|
|
16
|
+
// is pure data.
|
|
17
|
+
|
|
18
|
+
const SKIP_DIRS = new Set([
|
|
19
|
+
"node_modules",
|
|
20
|
+
".vibes",
|
|
21
|
+
"dist",
|
|
22
|
+
".git",
|
|
23
|
+
"coverage",
|
|
24
|
+
".next",
|
|
25
|
+
"functions", // route handlers, not billing config
|
|
26
|
+
])
|
|
27
|
+
|
|
28
|
+
// A defineBilling() result (see @omg-dev/billing Billing interface). Duck-typed —
|
|
29
|
+
// instance identity across module copies doesn't matter, serializeCatalog only
|
|
30
|
+
// reads plain properties.
|
|
31
|
+
function isBillingObject(v: unknown): boolean {
|
|
32
|
+
if (!v || typeof v !== "object") return false
|
|
33
|
+
const o = v as Record<string, unknown>
|
|
34
|
+
const provider = o.provider as Record<string, unknown> | undefined
|
|
35
|
+
return (
|
|
36
|
+
!!provider &&
|
|
37
|
+
typeof provider.name === "string" &&
|
|
38
|
+
Array.isArray(o.plans) &&
|
|
39
|
+
Array.isArray(o.features) &&
|
|
40
|
+
!!o.credit
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Recursively collect .ts files whose source calls defineBilling(...). */
|
|
45
|
+
function findBillingModules(dir: string, out: string[]): void {
|
|
46
|
+
let entries: fs.Dirent[]
|
|
47
|
+
try {
|
|
48
|
+
entries = fs.readdirSync(dir, { withFileTypes: true })
|
|
49
|
+
} catch {
|
|
50
|
+
return
|
|
51
|
+
}
|
|
52
|
+
for (const entry of entries) {
|
|
53
|
+
if (entry.name.startsWith(".") && entry.name !== ".") continue
|
|
54
|
+
const full = path.join(dir, entry.name)
|
|
55
|
+
if (entry.isDirectory()) {
|
|
56
|
+
if (SKIP_DIRS.has(entry.name)) continue
|
|
57
|
+
findBillingModules(full, out)
|
|
58
|
+
continue
|
|
59
|
+
}
|
|
60
|
+
if (!entry.name.endsWith(".ts") || entry.name.endsWith(".d.ts")) continue
|
|
61
|
+
let source: string
|
|
62
|
+
try {
|
|
63
|
+
source = fs.readFileSync(full, "utf-8")
|
|
64
|
+
} catch {
|
|
65
|
+
continue
|
|
66
|
+
}
|
|
67
|
+
// Cheap pre-filter — only import modules that actually declare billing.
|
|
68
|
+
if (/\bdefineBilling\s*\(/.test(source)) out.push(full)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface FoundBilling {
|
|
73
|
+
module: string
|
|
74
|
+
exportName: string
|
|
75
|
+
/** The serialized canonical catalog JSON (deploys.catalog_json contract). */
|
|
76
|
+
catalogJson: string
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Locate + serialize the app's billing catalog. Returns null when the app
|
|
81
|
+
* declares no billing. Throws (fail-loud, like defineBilling itself) when the
|
|
82
|
+
* declaration is invalid or ambiguous — a broken catalog must fail the BUILD,
|
|
83
|
+
* never ship a deploy the enforcer then chokes on at runtime.
|
|
84
|
+
*/
|
|
85
|
+
export async function scanBilling(root: string): Promise<FoundBilling | null> {
|
|
86
|
+
const candidates: string[] = []
|
|
87
|
+
findBillingModules(root, candidates)
|
|
88
|
+
if (candidates.length === 0) return null
|
|
89
|
+
|
|
90
|
+
// Import each candidate and collect every export that is a Billing object.
|
|
91
|
+
// Importing the module also runs defineBilling's own validation, so an
|
|
92
|
+
// invalid config throws here and fails the build.
|
|
93
|
+
const found: { module: string; exportName: string; billing: unknown }[] = []
|
|
94
|
+
for (const mod of candidates) {
|
|
95
|
+
const ns = (await import(mod)) as Record<string, unknown>
|
|
96
|
+
for (const [exportName, value] of Object.entries(ns)) {
|
|
97
|
+
if (isBillingObject(value)) found.push({ module: mod, exportName, billing: value })
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (found.length === 0) return null
|
|
102
|
+
|
|
103
|
+
// Resolve ambiguity deterministically: prefer the conventional `billing`
|
|
104
|
+
// named export, then a default export, otherwise require exactly one.
|
|
105
|
+
let chosen = found.length === 1 ? found[0] : undefined
|
|
106
|
+
if (!chosen) chosen = found.find((f) => f.exportName === "billing")
|
|
107
|
+
if (!chosen) chosen = found.find((f) => f.exportName === "default")
|
|
108
|
+
if (!chosen) {
|
|
109
|
+
const list = found.map((f) => `${path.relative(root, f.module)}#${f.exportName}`).join(", ")
|
|
110
|
+
throw new Error(
|
|
111
|
+
`@omg-dev/vite-plugin: multiple billing declarations found ([${list}]); ` +
|
|
112
|
+
`export exactly one as \`billing\` so the catalog is unambiguous`,
|
|
113
|
+
)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// serializeCatalog lives in @omg-dev/billing/catalog; resolve it from the app's
|
|
117
|
+
// deps (the app necessarily depends on @omg-dev/billing to have called
|
|
118
|
+
// defineBilling). Done lazily so apps with no billing never need the dep.
|
|
119
|
+
const { serializeCatalog } = (await import("@omg-dev/billing/catalog")) as {
|
|
120
|
+
serializeCatalog: (b: unknown) => string
|
|
121
|
+
}
|
|
122
|
+
return {
|
|
123
|
+
module: chosen.module,
|
|
124
|
+
exportName: chosen.exportName,
|
|
125
|
+
catalogJson: serializeCatalog(chosen.billing),
|
|
126
|
+
}
|
|
127
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Trigger scanner — moved to @omg-dev/server (src/trigger-scan.ts) so non-vite
|
|
2
|
+
// hosts (control-plane container, self-host bundles) can scan + write
|
|
3
|
+
// .vibes/triggers.json at boot. This module stays as a re-export so the
|
|
4
|
+
// plugin's internal imports (codegen.ts, build.ts) keep working unchanged.
|
|
5
|
+
//
|
|
6
|
+
// Import the standalone subpath, NOT the main bundle: @omg-dev/server's index
|
|
7
|
+
// top-level-imports bun:sqlite and cannot load under a Node-run vite build.
|
|
8
|
+
// trigger-scan.mjs is pure node:fs/path.
|
|
9
|
+
export {
|
|
10
|
+
scanTriggers,
|
|
11
|
+
extractTriggers,
|
|
12
|
+
TriggerScanError,
|
|
13
|
+
type ScannedTrigger,
|
|
14
|
+
type TriggerKind,
|
|
15
|
+
} from "@omg-dev/server/trigger-scan"
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
import { extractWorkflows, WorkflowScanError } from "./scanner-workflows.ts"
|
|
3
|
+
|
|
4
|
+
describe("extractWorkflows", () => {
|
|
5
|
+
it("extracts a basic workflow declaration", () => {
|
|
6
|
+
const src = `
|
|
7
|
+
import { workflow } from "@omg-dev/server"
|
|
8
|
+
export const onboarding = workflow("onboarding", async (step, payload) => {
|
|
9
|
+
return 1
|
|
10
|
+
})
|
|
11
|
+
`
|
|
12
|
+
const out = extractWorkflows(src, "/app/functions/flows.ts", "flows")
|
|
13
|
+
expect(out).toEqual([
|
|
14
|
+
{
|
|
15
|
+
name: "onboarding",
|
|
16
|
+
handler: "flows.onboarding",
|
|
17
|
+
module: "/app/functions/flows.ts",
|
|
18
|
+
exportName: "onboarding",
|
|
19
|
+
},
|
|
20
|
+
])
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it("extracts multiple workflows and tolerates type annotations", () => {
|
|
24
|
+
const src = `
|
|
25
|
+
export const a = workflow("flowA", async (step) => {})
|
|
26
|
+
export const b: SomeType = workflow('flow_b', handler)
|
|
27
|
+
`
|
|
28
|
+
const out = extractWorkflows(src, "/f/x.ts", "x")
|
|
29
|
+
expect(out.map((w) => w.name)).toEqual(["flowA", "flow_b"])
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it("throws on dashes in names (Restate handler-name contract)", () => {
|
|
33
|
+
const src = `export const w = workflow("has-dash", fn)`
|
|
34
|
+
expect(() => extractWorkflows(src, "/f/x.ts", "x")).toThrow(WorkflowScanError)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it("ignores commented-out declarations", () => {
|
|
38
|
+
const src = `
|
|
39
|
+
// export const dead = workflow("dead", async () => {})
|
|
40
|
+
/* export const dead2 = workflow("dead2", fn) */
|
|
41
|
+
export const live = workflow("live", fn)
|
|
42
|
+
`
|
|
43
|
+
const out = extractWorkflows(src, "/f/x.ts", "x")
|
|
44
|
+
expect(out.map((w) => w.name)).toEqual(["live"])
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it("throws on a non-literal name", () => {
|
|
48
|
+
const src = `export const w = workflow(dynamicName, fn)`
|
|
49
|
+
expect(() => extractWorkflows(src, "/f/x.ts", "x")).toThrow(WorkflowScanError)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it("throws on a template-literal name", () => {
|
|
53
|
+
const src = "export const w = workflow(`tpl-${x}`, fn)"
|
|
54
|
+
expect(() => extractWorkflows(src, "/f/x.ts", "x")).toThrow(WorkflowScanError)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it("throws on an invalid name charset", () => {
|
|
58
|
+
const src = `export const w = workflow("has space", fn)`
|
|
59
|
+
expect(() => extractWorkflows(src, "/f/x.ts", "x")).toThrow(WorkflowScanError)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it("does not match cron/on/other call sites", () => {
|
|
63
|
+
const src = `
|
|
64
|
+
export const c = cron("0 3 * * *", fn)
|
|
65
|
+
export const o = on("user.signup", fn)
|
|
66
|
+
`
|
|
67
|
+
expect(extractWorkflows(src, "/f/x.ts", "x")).toEqual([])
|
|
68
|
+
})
|
|
69
|
+
})
|