import path from 'node:path'; import fs from 'node:fs'; import { fileURLToPath } from 'node:url'; import type { Plugin, ViteDevServer } from 'vite'; export type LiveTsxApi = { getSource: () => string; setSource: (source: string) => void; }; const ALLOWED_PREFIXES = ['src/app/components/', 'src/app/features/']; function resolveAppSourceFile(projectRoot: string, relPath: string): string | null { const normalized = relPath.replace(/\\/g, '/').replace(/^\/+/, ''); if (normalized.includes('..') || path.isAbsolute(normalized)) return null; if (!ALLOWED_PREFIXES.some((p) => normalized.startsWith(p))) return null; if (!/\.(tsx|ts)$/.test(normalized)) return null; const abs = path.resolve(projectRoot, normalized); const root = path.resolve(projectRoot); if (!abs.startsWith(root + path.sep) && abs !== root) return null; return abs; } function readBody(req: NodeJS.ReadableStream): Promise { return new Promise((resolve, reject) => { const chunks: Buffer[] = []; req.on('data', (c) => chunks.push(c)); req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))); req.on('error', reject); }); } function bumpLivePreview(server: ViteDevServer | undefined) { if (!server) return; server.ws.send({ type: 'custom', event: 'playground:live-updated', data: { revision: Date.now() }, }); } export function liveTsxPlugin(projectRoot: string, initialSource: string): Plugin { const liveFile = path.resolve(projectRoot, '.cache/playground-live.tsx'); fs.mkdirSync(path.dirname(liveFile), { recursive: true }); fs.writeFileSync(liveFile, initialSource, 'utf8'); let source = initialSource; let server: ViteDevServer | undefined; const api: LiveTsxApi = { getSource: () => source, setSource: (next) => { source = next; fs.writeFileSync(liveFile, next, 'utf8'); if (!server) return; for (const m of server.moduleGraph.urlToModuleMap.values()) { if (m.file === liveFile) server.moduleGraph.invalidateModule(m); } const byId = server.moduleGraph.getModuleById(liveFile); if (byId) server.moduleGraph.invalidateModule(byId); bumpLivePreview(server); }, }; return { name: 'playground-live-tsx', configureServer(devServer) { server = devServer; (devServer as ViteDevServer & { playgroundLive?: LiveTsxApi }).playgroundLive = api; devServer.middlewares.use('/__playground/live', (req, res, next) => { if (req.method === 'GET') { res.setHeader('Content-Type', 'text/plain; charset=utf-8'); res.end(source); return; } if (req.method === 'POST') { void readBody(req).then((body) => { api.setSource(body); res.statusCode = 204; res.end(); }); return; } next(); }); // Read/write real Cinny component sources (strings, markup, etc.). devServer.middlewares.use('/__playground/source', (req, res, next) => { const url = new URL(req.url ?? '', 'http://playground.local'); const rel = url.searchParams.get('path') ?? ''; const abs = resolveAppSourceFile(projectRoot, rel); if (!abs) { res.statusCode = 400; res.end('Invalid or disallowed path'); return; } if (req.method === 'GET') { if (!fs.existsSync(abs)) { res.statusCode = 404; res.end('Not found'); return; } res.setHeader('Content-Type', 'text/plain; charset=utf-8'); res.end(fs.readFileSync(abs, 'utf8')); return; } if (req.method === 'POST') { void readBody(req).then((body) => { fs.mkdirSync(path.dirname(abs), { recursive: true }); fs.writeFileSync(abs, body, 'utf8'); const mod = server?.moduleGraph.getModuleById(abs) ?? [...(server?.moduleGraph.urlToModuleMap.values() ?? [])].find((m) => m.file === abs); if (mod && server) server.moduleGraph.invalidateModule(mod); bumpLivePreview(server); res.statusCode = 204; res.end(); }); return; } next(); }); }, resolveId(id) { const clean = id.split('?')[0]; if ( clean === '/@playground/live.tsx' || clean === 'virtual:playground-live' || clean.endsWith('/.cache/playground-live.tsx') ) { return liveFile; } return undefined; }, }; } export function readDefaultLiveSource(projectRoot: string): string { return fs.readFileSync(path.join(projectRoot, 'src/playground/defaultLive.tsx'), 'utf8'); } export function projectRootDir(): string { return path.dirname(fileURLToPath(import.meta.url)); }