@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/index.ts
ADDED
|
@@ -0,0 +1,967 @@
|
|
|
1
|
+
import path from "node:path"
|
|
2
|
+
import fs from "node:fs"
|
|
3
|
+
import type { Plugin, ViteDevServer, ResolvedConfig } from "vite"
|
|
4
|
+
import { fetchAuthUpstream, relayAuthUpstream } from "./auth-bridge.ts"
|
|
5
|
+
import { generateAll, regenerateRoutes, regenerateSchema, regenerateTriggers, regenerateWorkflows } from "./codegen.ts"
|
|
6
|
+
import { appendErrorEntry, errorsPathFor, stripAnsi } from "./error-sink.ts"
|
|
7
|
+
import {
|
|
8
|
+
APPLE_TOUCH_ICON,
|
|
9
|
+
MANIFEST_FILE,
|
|
10
|
+
PWA_ICONS,
|
|
11
|
+
buildWebManifest,
|
|
12
|
+
injectPwaTags,
|
|
13
|
+
isAppEntry,
|
|
14
|
+
resolvePwaConfig,
|
|
15
|
+
type PwaOptions,
|
|
16
|
+
} from "./pwa.ts"
|
|
17
|
+
import type { VibesServerInstance } from "@omg-dev/server"
|
|
18
|
+
import type { Schema } from "@omg-dev/schema"
|
|
19
|
+
|
|
20
|
+
export type { PwaOptions } from "./pwa.ts"
|
|
21
|
+
|
|
22
|
+
// ── Types ─────────────────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
export interface VibesOptions {
|
|
25
|
+
/** Root directory of the app (defaults to Vite's root) */
|
|
26
|
+
root?: string
|
|
27
|
+
/** SQLite database file path, relative to root (default: ".vibes/data.db") */
|
|
28
|
+
db?: string
|
|
29
|
+
/** Auth mode (default: "vibes") */
|
|
30
|
+
auth?: "vibes" | "local"
|
|
31
|
+
/** Static assets directory for production serving */
|
|
32
|
+
staticDir?: string
|
|
33
|
+
/** PWA installability for published builds — manifest + meta tags + soft
|
|
34
|
+
* install prompt. Default ON; pass `false` to opt out, or an options
|
|
35
|
+
* object to customize (see PwaOptions). */
|
|
36
|
+
pwa?: boolean | PwaOptions
|
|
37
|
+
/** Shake-to-report feedback widget for published builds — auto-mounts
|
|
38
|
+
* <VibesFeedback/> outside the app's React tree (shake / two-finger press
|
|
39
|
+
* summons it; report → agent /_report → LFG triage). Default ON; pass
|
|
40
|
+
* `false` to opt out and place <VibesFeedback/> from @omg-dev/sdk/feedback
|
|
41
|
+
* manually. */
|
|
42
|
+
feedback?: boolean
|
|
43
|
+
/** Small "omg" badge for published apps. Auto-mounts outside the app's React
|
|
44
|
+
* tree and opens a built-in "What is omg?" dialog. Default ON; pass `false`
|
|
45
|
+
* to omit it for a specific app while this rolls out. */
|
|
46
|
+
brandBadge?: boolean
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Resolve @omg-dev/pwa for `importer` and report whether its package.json declares
|
|
50
|
+
// the given subpath (e.g. "./remix-cta") in its `exports` map. Used to keep the
|
|
51
|
+
// build-time CTA injection skew-safe: an older baked @omg-dev/pwa that predates a
|
|
52
|
+
// subpath export must NOT get the import appended, or the bundle fails to resolve.
|
|
53
|
+
// Returns false on any resolution / parse hiccup — the CTA is additive, so when
|
|
54
|
+
// in doubt we skip it rather than risk breaking the user's build.
|
|
55
|
+
async function pwaDeclaresSubpathExport(
|
|
56
|
+
resolve: (source: string, importer: string) => Promise<{ id: string } | null>,
|
|
57
|
+
importer: string,
|
|
58
|
+
subpath: string,
|
|
59
|
+
): Promise<boolean> {
|
|
60
|
+
try {
|
|
61
|
+
// Resolve the always-present root export, then walk up to the owning
|
|
62
|
+
// package.json. We can't resolve "@omg-dev/pwa/package.json" directly because
|
|
63
|
+
// an older exports map may not expose "./package.json".
|
|
64
|
+
const rootEntry = await resolve("@omg-dev/pwa", importer)
|
|
65
|
+
if (!rootEntry?.id) return false
|
|
66
|
+
let dir = path.dirname(rootEntry.id.split("?")[0])
|
|
67
|
+
for (let i = 0; i < 12; i++) {
|
|
68
|
+
const pkgPath = path.join(dir, "package.json")
|
|
69
|
+
if (fs.existsSync(pkgPath)) {
|
|
70
|
+
try {
|
|
71
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"))
|
|
72
|
+
if (pkg.name === "@omg-dev/pwa") {
|
|
73
|
+
const exp = pkg.exports
|
|
74
|
+
return !!exp && typeof exp === "object" && subpath in exp
|
|
75
|
+
}
|
|
76
|
+
} catch {
|
|
77
|
+
// Unreadable/!JSON package.json — keep climbing toward the real one.
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
const parent = path.dirname(dir)
|
|
81
|
+
if (parent === dir) break
|
|
82
|
+
dir = parent
|
|
83
|
+
}
|
|
84
|
+
return false
|
|
85
|
+
} catch {
|
|
86
|
+
return false
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ── Plugin ────────────────────────────────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
export default function vibes(opts: VibesOptions = {}): Plugin {
|
|
93
|
+
let resolvedRoot: string
|
|
94
|
+
let config: ResolvedConfig
|
|
95
|
+
let serverInstance: VibesServerInstance | null = null
|
|
96
|
+
|
|
97
|
+
const pwaEnabled = opts.pwa !== false
|
|
98
|
+
const pwaOpts: PwaOptions = typeof opts.pwa === "object" ? opts.pwa : {}
|
|
99
|
+
let pwaAutoInjected = false
|
|
100
|
+
let remixCtaInjected = false
|
|
101
|
+
|
|
102
|
+
const feedbackEnabled = opts.feedback !== false
|
|
103
|
+
let feedbackInjected = false
|
|
104
|
+
const brandBadgeEnabled = opts.brandBadge !== false
|
|
105
|
+
let brandBadgeInjected = false
|
|
106
|
+
|
|
107
|
+
// Icon files present in the app's public dir — manifest entries and the
|
|
108
|
+
// apple-touch link are only emitted for files that actually exist.
|
|
109
|
+
function existingIconFiles(): string[] {
|
|
110
|
+
const publicDir = config?.publicDir
|
|
111
|
+
if (!publicDir) return []
|
|
112
|
+
return [...PWA_ICONS.map((i) => i.file), APPLE_TOUCH_ICON].filter((f) =>
|
|
113
|
+
fs.existsSync(path.join(publicDir, f)),
|
|
114
|
+
)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// An app that ships its own manifest in public/ wins over ours.
|
|
118
|
+
function userManifestHref(): string | null {
|
|
119
|
+
const publicDir = config?.publicDir
|
|
120
|
+
if (!publicDir) return null
|
|
121
|
+
if (fs.existsSync(path.join(publicDir, MANIFEST_FILE))) return `/${MANIFEST_FILE}`
|
|
122
|
+
if (fs.existsSync(path.join(publicDir, "manifest.json"))) return "/manifest.json"
|
|
123
|
+
return null
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
name: "vibes",
|
|
128
|
+
enforce: "pre",
|
|
129
|
+
|
|
130
|
+
// ── config ────────────────────────────────────────────────────────────────
|
|
131
|
+
// Without this, every POST/PATCH/DELETE that mutates a row triggers a
|
|
132
|
+
// full page reload: chokidar fires on the SQLite file (.vibes/data.db)
|
|
133
|
+
// when bun:sqlite writes, no plugin claims it via handleHotUpdate, and
|
|
134
|
+
// Vite's default for an unknown file change is `full-reload`. The same
|
|
135
|
+
// hits .vibes/routes.json, errors.jsonl, and the WAL/journal sidecars.
|
|
136
|
+
// .vibes/ is sandbox-internal — its contents should never reach the
|
|
137
|
+
// browser, so blanket-ignore it.
|
|
138
|
+
config() {
|
|
139
|
+
return {
|
|
140
|
+
// When the brand badge is on (default), it bundles the feedback sheet
|
|
141
|
+
// (no separate floating button). Expose whether feedback is actually
|
|
142
|
+
// enabled so the badge can omit it when the app opts out via
|
|
143
|
+
// vibes({ feedback: false }).
|
|
144
|
+
define: {
|
|
145
|
+
__VIBES_BRAND_FEEDBACK__: JSON.stringify(brandBadgeEnabled && feedbackEnabled),
|
|
146
|
+
},
|
|
147
|
+
server: {
|
|
148
|
+
watch: {
|
|
149
|
+
ignored: ["**/.vibes/**"],
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
}
|
|
153
|
+
},
|
|
154
|
+
|
|
155
|
+
// ── configResolved ────────────────────────────────────────────────────────
|
|
156
|
+
configResolved(resolvedConfig) {
|
|
157
|
+
config = resolvedConfig
|
|
158
|
+
resolvedRoot = opts.root ?? resolvedConfig.root
|
|
159
|
+
},
|
|
160
|
+
|
|
161
|
+
// ── transformIndexHtml (dev only) ─────────────────────────────────────────
|
|
162
|
+
// Inject a runtime-error reporter into every dev-served HTML document so
|
|
163
|
+
// crashes inside the running app reach the sandbox-local error sink
|
|
164
|
+
// (.vibes/errors.jsonl). Pi reads that sink between turns. Production
|
|
165
|
+
// bundles never see this — gated by `config.command === 'serve'`.
|
|
166
|
+
transformIndexHtml: {
|
|
167
|
+
order: "pre" as const,
|
|
168
|
+
handler(html: string) {
|
|
169
|
+
// Production analytics: when the orchestrator supplies a website id at
|
|
170
|
+
// build time, inject the self-hosted Umami tracker. Only on `build` —
|
|
171
|
+
// the dev/preview server (command === "serve") never tracks, so a
|
|
172
|
+
// builder's own preview sessions don't pollute the app's stats. The
|
|
173
|
+
// script host is configurable (VIBES_ANALYTICS_SRC) for self-hosting.
|
|
174
|
+
if (config?.command === "build") {
|
|
175
|
+
const websiteId = process.env.VIBES_ANALYTICS_WEBSITE_ID
|
|
176
|
+
if (websiteId) {
|
|
177
|
+
const src = process.env.VIBES_ANALYTICS_SRC || "https://analytics.omg.dev/script.js"
|
|
178
|
+
if (!html.includes(src)) {
|
|
179
|
+
const tag = `<script defer src="${src}" data-website-id="${websiteId}"></script>`
|
|
180
|
+
html = /<\/head>/i.test(html)
|
|
181
|
+
? html.replace(/<\/head>/i, `${tag}</head>`)
|
|
182
|
+
: tag + html
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
if (pwaEnabled) {
|
|
186
|
+
html = injectPwaTags(html, resolvePwaConfig(pwaOpts, html), {
|
|
187
|
+
hasAppleIcon: existingIconFiles().includes(APPLE_TOUCH_ICON),
|
|
188
|
+
manifestHref: userManifestHref() ?? undefined,
|
|
189
|
+
})
|
|
190
|
+
}
|
|
191
|
+
return html
|
|
192
|
+
}
|
|
193
|
+
if (config?.command !== "serve") return html
|
|
194
|
+
const appId = readPreviewAppId(resolvedRoot)
|
|
195
|
+
const authBootstrap = appId
|
|
196
|
+
? `
|
|
197
|
+
<script>
|
|
198
|
+
window.__VIBES_APP_ID = ${JSON.stringify(appId)};
|
|
199
|
+
window.__VIBES_AUTH_TOKEN_URL = "/__vibes/auth/token";
|
|
200
|
+
window.__VIBES_AUTH_SESSION_URL = "/__vibes/auth/session";
|
|
201
|
+
</script>`
|
|
202
|
+
: ""
|
|
203
|
+
const reporter = `
|
|
204
|
+
<script>
|
|
205
|
+
(function(){
|
|
206
|
+
if (window.__vibesReporterInstalled) return;
|
|
207
|
+
window.__vibesReporterInstalled = true;
|
|
208
|
+
function send(payload){
|
|
209
|
+
var msg = Object.assign({ kind: "runtime", at: Date.now() }, payload);
|
|
210
|
+
// POST into the dev server so it lands in .vibes/errors.jsonl. Use
|
|
211
|
+
// keepalive so errors thrown right before navigation still flush.
|
|
212
|
+
// Surface the response status to the parent so a 404/CORS/etc on
|
|
213
|
+
// the sink endpoint is diagnosable without looking at sandbox logs.
|
|
214
|
+
try {
|
|
215
|
+
fetch("/__vibes/runtime-error", {
|
|
216
|
+
method: "POST",
|
|
217
|
+
headers: { "Content-Type": "application/json" },
|
|
218
|
+
body: JSON.stringify(msg),
|
|
219
|
+
keepalive: true,
|
|
220
|
+
}).then(function(r){
|
|
221
|
+
if (!r.ok) {
|
|
222
|
+
try { window.parent && window.parent.postMessage(
|
|
223
|
+
{ type: "vibes:reporter-fetch", status: r.status, ok: false }, "*"); } catch (_) {}
|
|
224
|
+
}
|
|
225
|
+
}).catch(function(err){
|
|
226
|
+
try { window.parent && window.parent.postMessage(
|
|
227
|
+
{ type: "vibes:reporter-fetch", error: String(err && err.message || err), ok: false }, "*"); } catch (_) {}
|
|
228
|
+
});
|
|
229
|
+
} catch (e) {}
|
|
230
|
+
// Also notify the parent shell for UI surfacing.
|
|
231
|
+
try {
|
|
232
|
+
window.parent && window.parent.postMessage(
|
|
233
|
+
Object.assign({ type: "vibes:runtime-error" }, msg), "*");
|
|
234
|
+
} catch (e) {}
|
|
235
|
+
}
|
|
236
|
+
window.addEventListener("error", function(ev){
|
|
237
|
+
var err = ev.error;
|
|
238
|
+
send({
|
|
239
|
+
message: (err && err.message) || ev.message || "Error",
|
|
240
|
+
stack: (err && err.stack) || null,
|
|
241
|
+
source: ev.filename || null,
|
|
242
|
+
line: ev.lineno || null,
|
|
243
|
+
col: ev.colno || null,
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
window.addEventListener("unhandledrejection", function(ev){
|
|
247
|
+
var r = ev.reason;
|
|
248
|
+
send({
|
|
249
|
+
message: (r && (r.message || String(r))) || "Unhandled rejection",
|
|
250
|
+
stack: (r && r.stack) || null,
|
|
251
|
+
rejection: true,
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
var origErr = console.error;
|
|
255
|
+
console.error = function(){
|
|
256
|
+
try {
|
|
257
|
+
var args = Array.prototype.slice.call(arguments);
|
|
258
|
+
var msg = args.map(function(a){
|
|
259
|
+
if (a instanceof Error) return a.message;
|
|
260
|
+
if (typeof a === "string") return a;
|
|
261
|
+
try { return JSON.stringify(a); } catch (e) { return String(a); }
|
|
262
|
+
}).join(" ");
|
|
263
|
+
send({ message: msg, viaConsole: true });
|
|
264
|
+
} catch (e) {}
|
|
265
|
+
origErr.apply(console, arguments);
|
|
266
|
+
};
|
|
267
|
+
})();
|
|
268
|
+
</script>`
|
|
269
|
+
if (/<head[^>]*>/i.test(html)) {
|
|
270
|
+
return html.replace(/<head[^>]*>/i, function (m) { return m + authBootstrap + reporter })
|
|
271
|
+
}
|
|
272
|
+
return authBootstrap + reporter + html
|
|
273
|
+
},
|
|
274
|
+
},
|
|
275
|
+
|
|
276
|
+
// ── configureServer (dev) ─────────────────────────────────────────────────
|
|
277
|
+
async configureServer(server: ViteDevServer) {
|
|
278
|
+
// Sandbox-local error sink. Pi reads this between turns so it can
|
|
279
|
+
// see build (Vite) and runtime (browser) errors it would otherwise
|
|
280
|
+
// miss. Path is fixed; agent-server tails it.
|
|
281
|
+
const errorsPath = errorsPathFor(resolvedRoot)
|
|
282
|
+
|
|
283
|
+
// Wrap Vite's logger.error so transform/resolve/build failures land
|
|
284
|
+
// in the sink. Vite-plus calls logger.error with the rendered
|
|
285
|
+
// "[vite] Internal Server Error\n…" string we saw in the diary case.
|
|
286
|
+
const logger = server.config.logger
|
|
287
|
+
const origErr = logger.error.bind(logger)
|
|
288
|
+
logger.error = (msg: string, opts?: unknown) => {
|
|
289
|
+
try {
|
|
290
|
+
const text = typeof msg === "string" ? msg : String(msg)
|
|
291
|
+
// Skip our own self-emitted noise to avoid loops.
|
|
292
|
+
if (!text.includes("__vibes-self")) {
|
|
293
|
+
appendErrorEntry(errorsPath, {
|
|
294
|
+
kind: "server",
|
|
295
|
+
source: "vite",
|
|
296
|
+
message: stripAnsi(text).slice(0, 4000),
|
|
297
|
+
})
|
|
298
|
+
}
|
|
299
|
+
} catch {}
|
|
300
|
+
return origErr(msg, opts as never)
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Runtime-error sink: the in-iframe reporter POSTs here.
|
|
304
|
+
server.middlewares.use((req, res, next) => {
|
|
305
|
+
if (req.url !== "/__vibes/runtime-error" || req.method !== "POST") return next()
|
|
306
|
+
const chunks: Buffer[] = []
|
|
307
|
+
req.on("data", (c: Buffer) => chunks.push(c))
|
|
308
|
+
req.on("end", () => {
|
|
309
|
+
try {
|
|
310
|
+
const body = JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}") as Record<string, unknown>
|
|
311
|
+
appendErrorEntry(errorsPath, {
|
|
312
|
+
kind: "runtime",
|
|
313
|
+
source: "browser",
|
|
314
|
+
message: String(body.message ?? "").slice(0, 4000),
|
|
315
|
+
stack: body.stack ? String(body.stack).slice(0, 4000) : undefined,
|
|
316
|
+
file: body.source,
|
|
317
|
+
line: body.line,
|
|
318
|
+
col: body.col,
|
|
319
|
+
viaConsole: body.viaConsole,
|
|
320
|
+
rejection: body.rejection,
|
|
321
|
+
})
|
|
322
|
+
} catch {}
|
|
323
|
+
res.statusCode = 204
|
|
324
|
+
res.end()
|
|
325
|
+
})
|
|
326
|
+
req.on("error", () => { res.statusCode = 400; res.end() })
|
|
327
|
+
})
|
|
328
|
+
|
|
329
|
+
// Preview auth bridge. Preview iframes run on
|
|
330
|
+
// `{sandboxId}-5173.preview.omg.dev`, which does not contain the app
|
|
331
|
+
// slug. The control plane writes `.vibes/app.json`; this middleware
|
|
332
|
+
// mints tokens for that slug only, so generated app code cannot choose
|
|
333
|
+
// an arbitrary appId from a preview host.
|
|
334
|
+
server.middlewares.use((req, res, next) => {
|
|
335
|
+
if (req.url !== "/__vibes/auth/token") return next()
|
|
336
|
+
if (req.method === "OPTIONS") {
|
|
337
|
+
res.statusCode = 204
|
|
338
|
+
res.end()
|
|
339
|
+
return
|
|
340
|
+
}
|
|
341
|
+
if (req.method !== "POST") {
|
|
342
|
+
res.statusCode = 405
|
|
343
|
+
res.setHeader("Content-Type", "application/json")
|
|
344
|
+
res.end(JSON.stringify({ error: "method not allowed" }))
|
|
345
|
+
return
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const appId = readPreviewAppId(resolvedRoot)
|
|
349
|
+
if (!appId) {
|
|
350
|
+
res.statusCode = 404
|
|
351
|
+
res.setHeader("Content-Type", "application/json")
|
|
352
|
+
res.end(JSON.stringify({ error: "preview app id not configured" }))
|
|
353
|
+
return
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
req.on("data", () => {})
|
|
357
|
+
req.on("end", async () => {
|
|
358
|
+
try {
|
|
359
|
+
const upstream = await fetchAuthUpstream("token", {
|
|
360
|
+
method: "POST",
|
|
361
|
+
headers: {
|
|
362
|
+
"Content-Type": "application/json",
|
|
363
|
+
// auth.omg.dev allows dashboard-origin lazy app registration.
|
|
364
|
+
// The preview middleware is platform-owned and pins appId from
|
|
365
|
+
// disk, so this does not let app code mint for arbitrary slugs.
|
|
366
|
+
Origin: "https://omg.dev",
|
|
367
|
+
...(req.headers.cookie ? { Cookie: req.headers.cookie } : {}),
|
|
368
|
+
},
|
|
369
|
+
body: JSON.stringify({ appId }),
|
|
370
|
+
})
|
|
371
|
+
await relayAuthUpstream(res, upstream)
|
|
372
|
+
} catch (err) {
|
|
373
|
+
res.statusCode = 502
|
|
374
|
+
res.setHeader("Content-Type", "application/json")
|
|
375
|
+
res.end(JSON.stringify({ error: err instanceof Error ? err.message : "auth token proxy failed" }))
|
|
376
|
+
}
|
|
377
|
+
})
|
|
378
|
+
req.on("error", () => { res.statusCode = 400; res.end() })
|
|
379
|
+
})
|
|
380
|
+
|
|
381
|
+
// Preview session bridge. Counterpart to the token bridge above: the
|
|
382
|
+
// better-auth client's get-session read is the only auth call that goes
|
|
383
|
+
// cross-origin to auth.omg.dev, where third-party-cookie blocking / ITP
|
|
384
|
+
// can drop the .omg.dev cookie and make a signed-in dashboard user look
|
|
385
|
+
// signed-out — popping a needless login in preview. Forward it
|
|
386
|
+
// same-origin with the browser cookie so the read always sees the shared
|
|
387
|
+
// session. Read-only GET; never relays Set-Cookie (sign-in/out still go
|
|
388
|
+
// direct to auth.omg.dev through the better-auth client).
|
|
389
|
+
server.middlewares.use((req, res, next) => {
|
|
390
|
+
const path = (req.url ?? "").split("?")[0]
|
|
391
|
+
if (path !== "/__vibes/auth/session") return next()
|
|
392
|
+
if (req.method === "OPTIONS") {
|
|
393
|
+
res.statusCode = 204
|
|
394
|
+
res.end()
|
|
395
|
+
return
|
|
396
|
+
}
|
|
397
|
+
if (req.method !== "GET") {
|
|
398
|
+
res.statusCode = 405
|
|
399
|
+
res.setHeader("Content-Type", "application/json")
|
|
400
|
+
res.end(JSON.stringify({ error: "method not allowed" }))
|
|
401
|
+
return
|
|
402
|
+
}
|
|
403
|
+
;(async () => {
|
|
404
|
+
try {
|
|
405
|
+
const qs = (req.url ?? "").includes("?") ? (req.url ?? "").slice((req.url ?? "").indexOf("?")) : ""
|
|
406
|
+
const upstream = await fetchAuthUpstream("session", {
|
|
407
|
+
method: "GET",
|
|
408
|
+
headers: {
|
|
409
|
+
// auth.omg.dev trusts the dashboard origin; the preview server
|
|
410
|
+
// is platform-owned, so this can't be spoofed by app code.
|
|
411
|
+
Origin: "https://omg.dev",
|
|
412
|
+
...(req.headers.cookie ? { Cookie: req.headers.cookie } : {}),
|
|
413
|
+
},
|
|
414
|
+
}, qs)
|
|
415
|
+
await relayAuthUpstream(res, upstream)
|
|
416
|
+
} catch (err) {
|
|
417
|
+
res.statusCode = 502
|
|
418
|
+
res.setHeader("Content-Type", "application/json")
|
|
419
|
+
res.end(JSON.stringify({ error: err instanceof Error ? err.message : "auth session proxy failed" }))
|
|
420
|
+
}
|
|
421
|
+
})()
|
|
422
|
+
})
|
|
423
|
+
|
|
424
|
+
// Run initial codegen
|
|
425
|
+
await generateAll(resolvedRoot)
|
|
426
|
+
|
|
427
|
+
// Load schema
|
|
428
|
+
const schema = await loadSchema(resolvedRoot)
|
|
429
|
+
|
|
430
|
+
// Signal dev mode to @omg-dev/server so its trigger module uses the
|
|
431
|
+
// in-process scheduler + emit bus instead of forwarding to the
|
|
432
|
+
// in-VM agent (which doesn't exist outside a Firecracker).
|
|
433
|
+
process.env.VIBES_MODE = "dev"
|
|
434
|
+
|
|
435
|
+
// Create vibes server instance — dynamic import so bun:sqlite isn't loaded
|
|
436
|
+
// at config-parse time (which runs under Node.js in vite-plus)
|
|
437
|
+
const { createVibesServer } = await import("@omg-dev/server")
|
|
438
|
+
const dbPath = opts.db ?? ".vibes/data.db"
|
|
439
|
+
serverInstance = await createVibesServer({
|
|
440
|
+
root: resolvedRoot,
|
|
441
|
+
db: dbPath,
|
|
442
|
+
auth: opts.auth ?? "vibes",
|
|
443
|
+
schema: schema ?? undefined,
|
|
444
|
+
})
|
|
445
|
+
|
|
446
|
+
// Import broker for realtime invalidation
|
|
447
|
+
const { addClient, removeClient } = await import("@omg-dev/server")
|
|
448
|
+
// Import subscription registry for per-query reactive subs (Phase 1).
|
|
449
|
+
const { addSubClient, removeSubClient, handleSubMessage } = await import("@omg-dev/server")
|
|
450
|
+
|
|
451
|
+
// 25s heartbeat matches stream-worker so SDK clients see identical
|
|
452
|
+
// behavior across dev and prod.
|
|
453
|
+
const HEARTBEAT_MS = 25_000
|
|
454
|
+
server.middlewares.use((req, res, next) => {
|
|
455
|
+
if (req.url !== "/__vibes_events") return next()
|
|
456
|
+
|
|
457
|
+
res.writeHead(200, {
|
|
458
|
+
"Content-Type": "text/event-stream",
|
|
459
|
+
"Cache-Control": "no-cache",
|
|
460
|
+
Connection: "keep-alive",
|
|
461
|
+
"Access-Control-Allow-Origin": "*",
|
|
462
|
+
"X-Accel-Buffering": "no",
|
|
463
|
+
})
|
|
464
|
+
res.write(":\n\n")
|
|
465
|
+
|
|
466
|
+
const client = {
|
|
467
|
+
readyState: 1,
|
|
468
|
+
send(data: string) {
|
|
469
|
+
res.write(`data: ${data}\n\n`)
|
|
470
|
+
},
|
|
471
|
+
}
|
|
472
|
+
addClient(client)
|
|
473
|
+
|
|
474
|
+
const cleanup = () => {
|
|
475
|
+
if (client.readyState === 3) return
|
|
476
|
+
client.readyState = 3
|
|
477
|
+
clearInterval(hb)
|
|
478
|
+
removeClient(client)
|
|
479
|
+
}
|
|
480
|
+
const hb: ReturnType<typeof setInterval> = setInterval(() => {
|
|
481
|
+
try {
|
|
482
|
+
if (!res.write(":hb\n\n")) {
|
|
483
|
+
// Backpressure or already-closed; tear down.
|
|
484
|
+
cleanup()
|
|
485
|
+
}
|
|
486
|
+
} catch {
|
|
487
|
+
cleanup()
|
|
488
|
+
}
|
|
489
|
+
}, HEARTBEAT_MS)
|
|
490
|
+
|
|
491
|
+
req.on("close", cleanup)
|
|
492
|
+
})
|
|
493
|
+
|
|
494
|
+
// ── /__vibes_sub WebSocket endpoint ──────────────────────────────────
|
|
495
|
+
// Per-client subscription transport (Phase 1 of delta-push). The
|
|
496
|
+
// existing /__vibes_events SSE stays as a broadcast invalidate
|
|
497
|
+
// channel; /__vibes_sub is the per-query channel that will eventually
|
|
498
|
+
// carry row-level deltas (Phase 3). For now it just delivers a fresh
|
|
499
|
+
// snapshot on subscribe and on every write to the collection.
|
|
500
|
+
//
|
|
501
|
+
// Auth: deferred to Phase 6 hardening. An anonymous WS upgrade still
|
|
502
|
+
// works — scoped collections gate at the snapshot layer (the
|
|
503
|
+
// `_owner = ?` predicate in db.ts throws VibesAuthRequiredError,
|
|
504
|
+
// which the subscriptions module reports as `snapshot_failed`).
|
|
505
|
+
const { WebSocketServer } = await import("ws")
|
|
506
|
+
// Echo the client's chosen subprotocol so the browser doesn't drop
|
|
507
|
+
// the connection. The client uses subprotocol to smuggle the JWT
|
|
508
|
+
// (vibes-bearer.<token>) since browser WS API can't set headers.
|
|
509
|
+
const wss = new WebSocketServer({
|
|
510
|
+
noServer: true,
|
|
511
|
+
handleProtocols: (protocols) => {
|
|
512
|
+
for (const p of protocols) if (p.startsWith("vibes-bearer.")) return p
|
|
513
|
+
// No bearer? Pick any sent protocol (or false to send none).
|
|
514
|
+
const first = protocols.values().next().value
|
|
515
|
+
return first ?? false
|
|
516
|
+
},
|
|
517
|
+
})
|
|
518
|
+
|
|
519
|
+
// Build a per-dev-server auth middleware so the WS path verifies the
|
|
520
|
+
// same way prod does. Local auth mode is also acceptable; the dev
|
|
521
|
+
// plugin honours the configured opts.auth setting.
|
|
522
|
+
const { createAuthMiddleware } = await import("@omg-dev/auth")
|
|
523
|
+
const subAuthMW = createAuthMiddleware(opts.auth ?? "vibes")
|
|
524
|
+
|
|
525
|
+
const httpServer = server.httpServer
|
|
526
|
+
if (httpServer) {
|
|
527
|
+
httpServer.on("upgrade", async (req, socket, head) => {
|
|
528
|
+
try {
|
|
529
|
+
const url = new URL(req.url ?? "/", "http://localhost")
|
|
530
|
+
if (url.pathname !== "/__vibes_sub") return
|
|
531
|
+
|
|
532
|
+
// Pull the bearer subprotocol BEFORE handleUpgrade — once the
|
|
533
|
+
// upgrade is committed, errors are awkward to surface.
|
|
534
|
+
const protoHeader = (req.headers["sec-websocket-protocol"] as string | undefined) ?? ""
|
|
535
|
+
const protos = protoHeader.split(",").map(s => s.trim()).filter(Boolean)
|
|
536
|
+
const bearerProto = protos.find(p => p.startsWith("vibes-bearer."))
|
|
537
|
+
let userId: string | null = null
|
|
538
|
+
if (bearerProto) {
|
|
539
|
+
const token = bearerProto.slice("vibes-bearer.".length)
|
|
540
|
+
try {
|
|
541
|
+
const fakeReq = new Request("http://localhost/__vibes_sub", {
|
|
542
|
+
headers: { authorization: `Bearer ${token}` },
|
|
543
|
+
})
|
|
544
|
+
const result = await subAuthMW(fakeReq)
|
|
545
|
+
userId = result?.userId ?? null
|
|
546
|
+
} catch {
|
|
547
|
+
// Invalid token → anonymous; snapshot layer gates scoped subs.
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
552
|
+
const subClient = {
|
|
553
|
+
readyState: 1,
|
|
554
|
+
send(data: string) {
|
|
555
|
+
try { ws.send(data) } catch { /* ws closed mid-send */ }
|
|
556
|
+
},
|
|
557
|
+
ctx: { userId },
|
|
558
|
+
}
|
|
559
|
+
addSubClient(subClient)
|
|
560
|
+
ws.on("message", (data: Buffer | ArrayBuffer | Buffer[]) => {
|
|
561
|
+
const raw = typeof data === "string"
|
|
562
|
+
? data
|
|
563
|
+
: Buffer.isBuffer(data)
|
|
564
|
+
? data.toString("utf8")
|
|
565
|
+
: Array.isArray(data)
|
|
566
|
+
? Buffer.concat(data).toString("utf8")
|
|
567
|
+
: Buffer.from(data as ArrayBuffer).toString("utf8")
|
|
568
|
+
void handleSubMessage(subClient, raw)
|
|
569
|
+
})
|
|
570
|
+
const closeOut = () => {
|
|
571
|
+
if (subClient.readyState === 3) return
|
|
572
|
+
subClient.readyState = 3
|
|
573
|
+
removeSubClient(subClient)
|
|
574
|
+
}
|
|
575
|
+
ws.on("close", closeOut)
|
|
576
|
+
ws.on("error", closeOut)
|
|
577
|
+
})
|
|
578
|
+
} catch {
|
|
579
|
+
try { socket.destroy() } catch { /* already gone */ }
|
|
580
|
+
}
|
|
581
|
+
})
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// Dev-mode object storage proxy. In prod, browsers PUT/GET directly
|
|
585
|
+
// against Tigris via presigned URLs. Dev has no Tigris — instead the
|
|
586
|
+
// user app's storage.uploadUrl() returns a Vite-local URL that this
|
|
587
|
+
// middleware accepts:
|
|
588
|
+
//
|
|
589
|
+
// PUT /_vibes_storage/<scope>/<...>/<key>?t=<sig>&ct=<content-type>
|
|
590
|
+
// GET /_vibes_storage/<scope>/<...>/<key>?t=<sig>
|
|
591
|
+
//
|
|
592
|
+
// Files land under .vibes/storage/ inside the project. Signed tokens
|
|
593
|
+
// are HMAC-bound to (rel, action, expiresAt) so a token minted for one
|
|
594
|
+
// path can't be used for another.
|
|
595
|
+
const storageMod = await import("@omg-dev/server")
|
|
596
|
+
server.middlewares.use(async (req, res, next) => {
|
|
597
|
+
if (!req.url) return next()
|
|
598
|
+
const STORAGE_PREFIX = "/_vibes_storage/"
|
|
599
|
+
if (!req.url.startsWith(STORAGE_PREFIX)) return next()
|
|
600
|
+
try {
|
|
601
|
+
// Parse: strip prefix, split off query string.
|
|
602
|
+
const qIdx = req.url.indexOf("?")
|
|
603
|
+
const rawPath = qIdx === -1 ? req.url : req.url.slice(0, qIdx)
|
|
604
|
+
const queryStr = qIdx === -1 ? "" : req.url.slice(qIdx + 1)
|
|
605
|
+
const rel = decodeURIComponent(rawPath.slice(STORAGE_PREFIX.length))
|
|
606
|
+
const params = new URLSearchParams(queryStr)
|
|
607
|
+
const token = params.get("t") ?? ""
|
|
608
|
+
// Reject traversal / double slashes / empty up front; storage.ts
|
|
609
|
+
// helper does the same on the upload side.
|
|
610
|
+
if (!rel || rel.includes("..") || rel.includes("//")) {
|
|
611
|
+
res.statusCode = 400
|
|
612
|
+
return res.end(JSON.stringify({ error: "invalid path" }))
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// Resolve (scope, userId, key) from the relative path. Layout:
|
|
616
|
+
// users/<userId>/<key...> scope=user
|
|
617
|
+
// app/<key...> scope=app
|
|
618
|
+
let scope: "user" | "app"
|
|
619
|
+
let userId = ""
|
|
620
|
+
let key = ""
|
|
621
|
+
if (rel.startsWith("users/")) {
|
|
622
|
+
const rest = rel.slice("users/".length)
|
|
623
|
+
const slash = rest.indexOf("/")
|
|
624
|
+
if (slash <= 0) {
|
|
625
|
+
res.statusCode = 400
|
|
626
|
+
return res.end(JSON.stringify({ error: "invalid path" }))
|
|
627
|
+
}
|
|
628
|
+
scope = "user"
|
|
629
|
+
userId = rest.slice(0, slash)
|
|
630
|
+
key = rest.slice(slash + 1)
|
|
631
|
+
} else if (rel.startsWith("app/")) {
|
|
632
|
+
scope = "app"
|
|
633
|
+
key = rel.slice("app/".length)
|
|
634
|
+
} else {
|
|
635
|
+
res.statusCode = 400
|
|
636
|
+
return res.end(JSON.stringify({ error: "invalid scope prefix" }))
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
const method = (req.method ?? "GET").toUpperCase()
|
|
640
|
+
if (method === "PUT") {
|
|
641
|
+
if (!storageMod._verifyDevStorageToken(rel, "put", token)) {
|
|
642
|
+
res.statusCode = 403
|
|
643
|
+
return res.end(JSON.stringify({ error: "invalid or expired token" }))
|
|
644
|
+
}
|
|
645
|
+
const chunks: Buffer[] = []
|
|
646
|
+
let size = 0
|
|
647
|
+
const MAX = 25 * 1024 * 1024
|
|
648
|
+
req.on("data", (c: Buffer) => {
|
|
649
|
+
size += c.length
|
|
650
|
+
if (size > MAX) {
|
|
651
|
+
req.destroy()
|
|
652
|
+
res.statusCode = 413
|
|
653
|
+
res.end(JSON.stringify({ error: "exceeds 25MB" }))
|
|
654
|
+
return
|
|
655
|
+
}
|
|
656
|
+
chunks.push(c)
|
|
657
|
+
})
|
|
658
|
+
req.on("end", () => {
|
|
659
|
+
try {
|
|
660
|
+
storageMod._devStorageWrite(scope, userId, key, Buffer.concat(chunks))
|
|
661
|
+
res.statusCode = 200
|
|
662
|
+
res.setHeader("Content-Type", "application/json")
|
|
663
|
+
res.end(JSON.stringify({ ok: true, size }))
|
|
664
|
+
} catch (err) {
|
|
665
|
+
res.statusCode = 500
|
|
666
|
+
res.end(JSON.stringify({ error: String((err as Error).message) }))
|
|
667
|
+
}
|
|
668
|
+
})
|
|
669
|
+
req.on("error", () => { res.statusCode = 400; res.end() })
|
|
670
|
+
return
|
|
671
|
+
}
|
|
672
|
+
if (method === "GET" || method === "HEAD") {
|
|
673
|
+
if (!storageMod._verifyDevStorageToken(rel, "get", token)) {
|
|
674
|
+
res.statusCode = 403
|
|
675
|
+
return res.end(JSON.stringify({ error: "invalid or expired token" }))
|
|
676
|
+
}
|
|
677
|
+
const abs = storageMod._devStorageRead(scope, userId, key)
|
|
678
|
+
if (!abs) {
|
|
679
|
+
res.statusCode = 404
|
|
680
|
+
return res.end(JSON.stringify({ error: "not found" }))
|
|
681
|
+
}
|
|
682
|
+
const stat = fs.statSync(abs)
|
|
683
|
+
res.setHeader("Content-Length", String(stat.size))
|
|
684
|
+
// Best-effort content-type from extension; the browser-side <img>
|
|
685
|
+
// doesn't care about exact MIME for images, but JSON callers might.
|
|
686
|
+
const ext = path.extname(key).toLowerCase()
|
|
687
|
+
const ctMap: Record<string, string> = {
|
|
688
|
+
".png": "image/png",
|
|
689
|
+
".jpg": "image/jpeg",
|
|
690
|
+
".jpeg": "image/jpeg",
|
|
691
|
+
".gif": "image/gif",
|
|
692
|
+
".webp": "image/webp",
|
|
693
|
+
".svg": "image/svg+xml",
|
|
694
|
+
".json": "application/json",
|
|
695
|
+
".pdf": "application/pdf",
|
|
696
|
+
".mp4": "video/mp4",
|
|
697
|
+
".webm": "video/webm",
|
|
698
|
+
".txt": "text/plain",
|
|
699
|
+
}
|
|
700
|
+
res.setHeader("Content-Type", ctMap[ext] ?? "application/octet-stream")
|
|
701
|
+
res.statusCode = 200
|
|
702
|
+
if (method === "HEAD") return res.end()
|
|
703
|
+
fs.createReadStream(abs).pipe(res)
|
|
704
|
+
return
|
|
705
|
+
}
|
|
706
|
+
if (method === "OPTIONS") {
|
|
707
|
+
res.statusCode = 204
|
|
708
|
+
res.setHeader("Access-Control-Allow-Origin", "*")
|
|
709
|
+
res.setHeader("Access-Control-Allow-Methods", "PUT,GET,HEAD,OPTIONS")
|
|
710
|
+
res.setHeader("Access-Control-Allow-Headers", "Content-Type,Authorization")
|
|
711
|
+
return res.end()
|
|
712
|
+
}
|
|
713
|
+
res.statusCode = 405
|
|
714
|
+
res.end(JSON.stringify({ error: "method not allowed" }))
|
|
715
|
+
} catch (err) {
|
|
716
|
+
res.statusCode = 500
|
|
717
|
+
res.end(JSON.stringify({ error: String((err as Error).message) }))
|
|
718
|
+
}
|
|
719
|
+
})
|
|
720
|
+
|
|
721
|
+
// API + /_vibes/* handler
|
|
722
|
+
server.middlewares.use(async (req, res, next) => {
|
|
723
|
+
const isApi = req.url?.startsWith("/api/")
|
|
724
|
+
const isVibesInternal = req.url?.startsWith("/_vibes/")
|
|
725
|
+
if (!isApi && !isVibesInternal) return next()
|
|
726
|
+
|
|
727
|
+
try {
|
|
728
|
+
const url = `http://localhost${req.url}`
|
|
729
|
+
const headers: Record<string, string> = {}
|
|
730
|
+
for (const [key, value] of Object.entries(req.headers)) {
|
|
731
|
+
if (typeof value === "string") headers[key] = value
|
|
732
|
+
else if (Array.isArray(value)) headers[key] = value.join(", ")
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
const body = await new Promise<Buffer>((resolve, reject) => {
|
|
736
|
+
const chunks: Buffer[] = []
|
|
737
|
+
req.on("data", (chunk: Buffer) => chunks.push(chunk))
|
|
738
|
+
req.on("end", () => resolve(Buffer.concat(chunks)))
|
|
739
|
+
req.on("error", reject)
|
|
740
|
+
})
|
|
741
|
+
|
|
742
|
+
const fetchReq = new Request(url, {
|
|
743
|
+
method: req.method,
|
|
744
|
+
headers,
|
|
745
|
+
body: body.length > 0 ? body : undefined,
|
|
746
|
+
})
|
|
747
|
+
|
|
748
|
+
// /_vibes/* goes through fetch() (handles dispatch + dev-inspect);
|
|
749
|
+
// /api/* goes through apiHandler() (route table + auth).
|
|
750
|
+
const response = isVibesInternal
|
|
751
|
+
? await serverInstance!.fetch(fetchReq)
|
|
752
|
+
: await serverInstance!.apiHandler(fetchReq)
|
|
753
|
+
|
|
754
|
+
res.statusCode = response.status
|
|
755
|
+
response.headers.forEach((value, key) => {
|
|
756
|
+
res.setHeader(key, value)
|
|
757
|
+
})
|
|
758
|
+
const responseBody = await response.arrayBuffer()
|
|
759
|
+
res.end(Buffer.from(responseBody))
|
|
760
|
+
} catch (err) {
|
|
761
|
+
console.error("[vibes] API handler error:", err)
|
|
762
|
+
res.statusCode = 500
|
|
763
|
+
res.end(JSON.stringify({ error: "Internal server error" }))
|
|
764
|
+
}
|
|
765
|
+
})
|
|
766
|
+
|
|
767
|
+
console.log("[vibes] Dev server ready (with realtime).")
|
|
768
|
+
},
|
|
769
|
+
|
|
770
|
+
// ── handleHotUpdate ───────────────────────────────────────────────────────
|
|
771
|
+
async handleHotUpdate({ file }) {
|
|
772
|
+
const rel = path.relative(resolvedRoot, file)
|
|
773
|
+
|
|
774
|
+
if (rel === "schema.ts") {
|
|
775
|
+
console.log("[vibes] schema.ts changed — regenerating...")
|
|
776
|
+
await regenerateSchema(resolvedRoot)
|
|
777
|
+
if (serverInstance) {
|
|
778
|
+
const newSchema = await loadSchema(resolvedRoot)
|
|
779
|
+
if (newSchema) serverInstance.migrate(newSchema)
|
|
780
|
+
}
|
|
781
|
+
return []
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
if (rel.startsWith("functions/") && rel.endsWith(".ts")) {
|
|
785
|
+
console.log(`[vibes] functions/${path.basename(file)} changed — rescanning routes + triggers...`)
|
|
786
|
+
await regenerateRoutes(resolvedRoot)
|
|
787
|
+
try {
|
|
788
|
+
await regenerateTriggers(resolvedRoot)
|
|
789
|
+
await serverInstance?.reloadTriggers()
|
|
790
|
+
} catch (err) {
|
|
791
|
+
// Surface trigger scan errors prominently — they're build-time
|
|
792
|
+
// user-fixable mistakes (non-literal cron expr / topic etc).
|
|
793
|
+
console.error(`[vibes] trigger scan failed:`, err)
|
|
794
|
+
}
|
|
795
|
+
try {
|
|
796
|
+
await regenerateWorkflows(resolvedRoot)
|
|
797
|
+
await serverInstance?.reloadWorkflows()
|
|
798
|
+
} catch (err) {
|
|
799
|
+
// Same posture as triggers: scan errors (non-literal / duplicate
|
|
800
|
+
// workflow name) are user-fixable — log loudly, keep dev alive.
|
|
801
|
+
console.error(`[vibes] workflow scan failed:`, err)
|
|
802
|
+
}
|
|
803
|
+
serverInstance?.reloadFunctions()
|
|
804
|
+
return []
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
return undefined
|
|
808
|
+
},
|
|
809
|
+
|
|
810
|
+
// ── buildStart ────────────────────────────────────────────────────────────
|
|
811
|
+
async buildStart() {
|
|
812
|
+
await generateAll(resolvedRoot)
|
|
813
|
+
},
|
|
814
|
+
|
|
815
|
+
// ── transform ─────────────────────────────────────────────────────────────
|
|
816
|
+
// Published builds get two side-effect imports appended to the app entry:
|
|
817
|
+
// - @omg-dev/pwa/auto — the soft install prompt (gated by autoPrompt)
|
|
818
|
+
// - @omg-dev/pwa/remix-cta — the "Make your own" remix bridge (always on
|
|
819
|
+
// when pwa is enabled; self-gates on the baked
|
|
820
|
+
// VITE_APP_SLUG, so it's inert if not published)
|
|
821
|
+
// Build-time injection (not template source) so both survive agent rewrites
|
|
822
|
+
// of main.tsx and never load in dev preview.
|
|
823
|
+
async transform(code: string, id: string) {
|
|
824
|
+
if (config?.command !== "build") return null
|
|
825
|
+
if (!pwaEnabled && !feedbackEnabled && !brandBadgeEnabled) return null
|
|
826
|
+
if (!isAppEntry(id, resolvedRoot)) return null
|
|
827
|
+
|
|
828
|
+
const imports: string[] = []
|
|
829
|
+
|
|
830
|
+
// Platform attribution badge — opt-out via vibes({ brandBadge: false }).
|
|
831
|
+
// Build-only + outside the React tree, so it survives agent rewrites of
|
|
832
|
+
// main.tsx and never appears in dev preview.
|
|
833
|
+
if (brandBadgeEnabled && !brandBadgeInjected) {
|
|
834
|
+
const resolvedBrand = await this.resolve("@omg-dev/sdk/brand/auto", id)
|
|
835
|
+
if (!resolvedBrand) {
|
|
836
|
+
this.warn(
|
|
837
|
+
"[vibes] brand badge: @omg-dev/sdk/brand/auto is not available — badge skipped. " +
|
|
838
|
+
"Update @omg-dev/sdk, or silence this with vibes({ brandBadge: false }).",
|
|
839
|
+
)
|
|
840
|
+
} else {
|
|
841
|
+
brandBadgeInjected = true
|
|
842
|
+
imports.push(`import "@omg-dev/sdk/brand/auto";`)
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
// Shake-to-report feedback widget — opt-out via vibes({ feedback: false }).
|
|
847
|
+
// Build-only + outside the React tree, so it survives agent rewrites of
|
|
848
|
+
// main.tsx and never arms a gesture listener in the dev preview iframe.
|
|
849
|
+
//
|
|
850
|
+
// When the brand badge is on (default) it ALREADY mounts a button-less
|
|
851
|
+
// feedback sheet and summons it from its dialog, so injecting the
|
|
852
|
+
// standalone widget here would double-mount it (two floating controls —
|
|
853
|
+
// the exact duplication we're collapsing). Only inject standalone feedback
|
|
854
|
+
// when the brand badge is disabled.
|
|
855
|
+
if (feedbackEnabled && !brandBadgeEnabled && !feedbackInjected) {
|
|
856
|
+
const resolvedFb = await this.resolve("@omg-dev/sdk/feedback/auto", id)
|
|
857
|
+
if (!resolvedFb) {
|
|
858
|
+
this.warn(
|
|
859
|
+
"[vibes] feedback: @omg-dev/sdk is not installed — shake-to-report skipped. " +
|
|
860
|
+
"Add @omg-dev/sdk to dependencies, or silence this with vibes({ feedback: false }).",
|
|
861
|
+
)
|
|
862
|
+
} else {
|
|
863
|
+
feedbackInjected = true
|
|
864
|
+
imports.push(`import "@omg-dev/sdk/feedback/auto";`)
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
// Soft install prompt — opt-out via vibes({ pwa: { autoPrompt: false } }).
|
|
869
|
+
if (pwaEnabled && pwaOpts.autoPrompt !== false && !pwaAutoInjected) {
|
|
870
|
+
const resolved = await this.resolve("@omg-dev/pwa/auto", id)
|
|
871
|
+
if (!resolved) {
|
|
872
|
+
this.warn(
|
|
873
|
+
"[vibes] pwa: @omg-dev/pwa is not installed — soft install prompt skipped. " +
|
|
874
|
+
"Add @omg-dev/pwa to dependencies, or silence this with vibes({ pwa: { autoPrompt: false } }).",
|
|
875
|
+
)
|
|
876
|
+
} else {
|
|
877
|
+
pwaAutoInjected = true
|
|
878
|
+
imports.push(`import "@omg-dev/pwa/auto";`)
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
// "Make your own" remix CTA — independent of autoPrompt. It self-gates at
|
|
883
|
+
// runtime on import.meta.env.VITE_APP_SLUG (baked by the orchestrator on
|
|
884
|
+
// deploy), so it renders nothing on a non-published build.
|
|
885
|
+
//
|
|
886
|
+
// Skew guard: the remix-cta subpath shipped in @omg-dev/pwa >= 0.4.16. An app
|
|
887
|
+
// whose baked @omg-dev/pwa predates it (e.g. 0.4.12) has no "./remix-cta" in
|
|
888
|
+
// its exports map, so injecting the import hard-fails the build with
|
|
889
|
+
// "Errored while resolving @omg-dev/pwa/remix-cta". this.resolve() alone is not
|
|
890
|
+
// a reliable gate across this version skew, so only inject once we've
|
|
891
|
+
// confirmed the resolved package actually declares the subpath export.
|
|
892
|
+
//
|
|
893
|
+
// The brand badge (default on) now hosts "Make it mine" itself: same omg
|
|
894
|
+
// mark, remix CTA first, and it collapses to the plain branding badge on
|
|
895
|
+
// dismiss. So only inject the standalone remix-cta when the brand badge is
|
|
896
|
+
// off — otherwise we'd render two corner pills with the same intent.
|
|
897
|
+
if (pwaEnabled && !brandBadgeEnabled && !remixCtaInjected) {
|
|
898
|
+
const exported = await pwaDeclaresSubpathExport(
|
|
899
|
+
(source, importer) => this.resolve(source, importer),
|
|
900
|
+
id,
|
|
901
|
+
"./remix-cta",
|
|
902
|
+
)
|
|
903
|
+
if (exported) {
|
|
904
|
+
remixCtaInjected = true
|
|
905
|
+
imports.push(`import "@omg-dev/pwa/remix-cta";`)
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
if (imports.length === 0) return null
|
|
910
|
+
return { code: `${code}\n${imports.join("\n")}\n`, map: null }
|
|
911
|
+
},
|
|
912
|
+
|
|
913
|
+
// ── generateBundle ────────────────────────────────────────────────────────
|
|
914
|
+
generateBundle() {
|
|
915
|
+
if (!pwaEnabled) return
|
|
916
|
+
// An app shipping its own public/manifest wins — only fill the gap.
|
|
917
|
+
if (userManifestHref()) return
|
|
918
|
+
const indexPath = path.join(resolvedRoot, "index.html")
|
|
919
|
+
const indexHtml = fs.existsSync(indexPath) ? fs.readFileSync(indexPath, "utf-8") : ""
|
|
920
|
+
const cfg = resolvePwaConfig(pwaOpts, indexHtml)
|
|
921
|
+
this.emitFile({
|
|
922
|
+
type: "asset",
|
|
923
|
+
fileName: MANIFEST_FILE,
|
|
924
|
+
source: buildWebManifest(cfg, existingIconFiles()),
|
|
925
|
+
})
|
|
926
|
+
},
|
|
927
|
+
|
|
928
|
+
// ── closeBundle ───────────────────────────────────────────────────────────
|
|
929
|
+
closeBundle() {
|
|
930
|
+
serverInstance?.close()
|
|
931
|
+
serverInstance = null
|
|
932
|
+
},
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
// ── helpers ───────────────────────────────────────────────────────────────────
|
|
937
|
+
|
|
938
|
+
async function loadSchema(root: string): Promise<Schema | null> {
|
|
939
|
+
const schemaPath = path.join(root, "schema.ts")
|
|
940
|
+
if (!fs.existsSync(schemaPath)) return null
|
|
941
|
+
try {
|
|
942
|
+
// Cache-bust on every load — the dynamic-import module cache otherwise
|
|
943
|
+
// returns the previous value on every HMR re-import, so edits to
|
|
944
|
+
// schema.ts get pinned to whatever was first loaded at server boot.
|
|
945
|
+
const mod = (await import(`${schemaPath}?t=${Date.now()}`)) as { default: Schema }
|
|
946
|
+
return mod.default
|
|
947
|
+
} catch (err) {
|
|
948
|
+
console.error("[vibes] Failed to load schema.ts:", err)
|
|
949
|
+
return null
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
function readPreviewAppId(root: string): string | null {
|
|
954
|
+
const fromEnv = process.env.VIBES_APP_ID?.trim()
|
|
955
|
+
if (fromEnv && /^[a-z0-9-]+$/.test(fromEnv)) return fromEnv
|
|
956
|
+
|
|
957
|
+
const metaPath = path.join(root, ".vibes", "app.json")
|
|
958
|
+
if (!fs.existsSync(metaPath)) return null
|
|
959
|
+
try {
|
|
960
|
+
const meta = JSON.parse(fs.readFileSync(metaPath, "utf-8")) as { appId?: unknown }
|
|
961
|
+
return typeof meta.appId === "string" && /^[a-z0-9-]+$/.test(meta.appId)
|
|
962
|
+
? meta.appId
|
|
963
|
+
: null
|
|
964
|
+
} catch {
|
|
965
|
+
return null
|
|
966
|
+
}
|
|
967
|
+
}
|