Files
f5fc0e65-d46a-4e61-a985-586…/vite.config.ts
kudinDmitriyUp 5b10641bc2 Initial commit
2026-05-09 11:38:20 +00:00

158 lines
6.4 KiB
TypeScript

import { defineConfig, type Plugin } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import path from 'path'
import * as fs from 'fs'
// In-sandbox bridge for Bob-AI's post-commit render check.
//
// On every HMR update (and on first mount) the iframed app POSTs a JSON
// payload to `/__webild/render-status` describing whether the page actually
// rendered (`{ok:true, rootChildren, bodyTextLen}`) or failed
// (`{ok:false, reason, error, stack, ...}`). This middleware is the dev-only
// receiver: it writes the latest payload to `/tmp/webild-render-status-<port>.json`
// inside the sandbox filesystem.
//
// Bob-AI does NOT receive these POSTs directly — it reads the file from the
// sandbox via the E2B SDK after each commit. This avoids needing a public
// callback endpoint and keeps the probe a no-op on production deploys (the
// app is never iframed there, so reportRenderStatus short-circuits).
//
// `apply: 'serve'` keeps this out of `vite build`. Endpoint paths intentionally
// short-circuit before any 404/SPA handler kicks in.
function webildRenderStatusPlugin(): Plugin {
return {
name: 'webild-render-status',
apply: 'serve',
configureServer(server) {
const port = server.config.server?.port ?? 3000
const statusFile = `/tmp/webild-render-status-${port}.json`
server.middlewares.use((req, res, next) => {
if (!req.url || !req.url.startsWith('/__webild/render-status')) return next()
try {
if (req.method === 'POST') {
const chunks: Buffer[] = []
req.on('data', (chunk: Buffer) => {
chunks.push(chunk)
// hard cap to avoid unbounded memory if a probe goes haywire
if (chunks.reduce((n, c) => n + c.length, 0) > 64 * 1024) {
res.statusCode = 413
res.end('payload too large')
req.destroy()
}
})
req.on('end', () => {
try {
const body = Buffer.concat(chunks).toString('utf8').trim() || '{}'
const incoming = JSON.parse(body) as { ok?: boolean }
// Preserve ok:false against ok:true overwrites. The probe
// emits multiple POSTs per HMR cycle: a section-level
// boundary fires synchronously when its child throws (with
// ok:false), and the App-level useRenderProbe fires ~1.5s
// later checking body text length (with ok:true if text is
// present — and a section-error placeholder DOES have
// visible text). Without this guard, the late ok:true
// would overwrite the early ok:false, and Bob-AI's
// post-commit poll would see "everything fine" while a
// section is actually broken. ok:false ALWAYS overwrites
// (later failure replaces earlier failure with fresher
// info); ok:true only overwrites if existing payload is
// also ok:true or absent. DELETE clears the file
// regardless — that's how Bob-AI resets state per commit.
let shouldWrite = true
if (incoming.ok !== false) {
try {
const existingRaw = fs.readFileSync(statusFile, 'utf8')
const existing = JSON.parse(existingRaw) as { ok?: boolean }
if (existing.ok === false) shouldWrite = false
} catch {
// file missing or unreadable — go ahead and write
}
}
if (shouldWrite) {
fs.writeFileSync(statusFile, body, 'utf8')
}
res.statusCode = 204
res.end()
} catch (err) {
res.statusCode = 400
res.setHeader('Content-Type', 'text/plain')
res.end(String((err as Error)?.message || err))
}
})
req.on('error', () => {
if (!res.headersSent) {
res.statusCode = 500
res.end()
}
})
return
}
if (req.method === 'GET') {
try {
const content = fs.readFileSync(statusFile, 'utf8')
res.statusCode = 200
res.setHeader('Content-Type', 'application/json')
res.end(content)
} catch {
res.statusCode = 404
res.end()
}
return
}
if (req.method === 'DELETE') {
try {
fs.unlinkSync(statusFile)
} catch {
// already absent
}
res.statusCode = 204
res.end()
return
}
res.statusCode = 405
res.end()
} catch (err) {
if (!res.headersSent) {
res.statusCode = 500
res.end(String((err as Error)?.message || err))
}
}
})
},
}
}
export default defineConfig({
plugins: [react(), tailwindcss(), webildRenderStatusPlugin()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
port: 3000,
host: true,
// CRITICAL for the e2b shared pool: forbid Vite's default behaviour of
// auto-picking another port on EADDRINUSE. The pool maps each port to a
// specific projectSlug; if Vite for project A silently lands on the slot
// belonging to project B, /webild-ready.json on the e2b host responds
// with B's project id and the frontend sanity check fails (slugMismatch),
// or worse, the iframe loads B's preview instead of A's. Better to crash
// loudly so the outer while-loop + stop-sentinel can clean up.
strictPort: true,
// E2B exposes each port on `<port>-<sandboxId>.sandbox.webild.io`. Vite 8's
// leading-dot wildcard (`.sandbox.webild.io`) refuses some patterns like
// `<port>-<sandboxId>.sandbox.webild.io` with 403. These are dev-only
// sandboxes — host check has no security value, so disable it entirely.
allowedHosts: true,
hmr: {
// Browser connects via the same e2b-proxied https host on port 443,
// but the dev server itself listens on raw `port`. Without this Vite
// tells the client to open `wss://localhost:3000` and HMR fails.
clientPort: 443,
protocol: 'wss',
},
},
})