@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/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
+ }