import fs from 'node:fs' import path from 'node:path' import type { Plugin } from 'vite' /** * The grain lab's back end — dev only. * * The lab is a browser tool whose whole point is to hand its findings to * someone editing the source, so it needs somewhere on disk to put them. * `apply: 'serve'` keeps every byte of this out of production builds. * * State lives in `.lab/state.json` at the app root, which survives a dev * server restart — that is the requirement: a note written before a restart * has to still be there after it, or the loop of "annotate, fix, reload" * loses everything each time the fix lands. * * Writes are atomic (temp file then rename), because the panel saves on every * slider drag and a half-written JSON file read back at the next boot would * silently empty the whole session's work. */ export type LabNote = { id: string /** the shared selector — often `.btn`, which is every button on the site */ selector: string /** what the page was showing when it was written */ route: string text: string /** open until someone marks it done in the source */ done: boolean at: string /* Identity, added after a note left on the contact page's submit button was carried out on the home hero's CTA: both are `.btn`. Optional, because notes written before this exist on disk and still have to load. */ /** a selector matching that one element only */ path?: string /** the element's own words — the fastest way to find it in the source */ label?: string tag?: string /** how many elements on that page shared `selector` */ shared?: number /** set from outside while the change this note asks for is being made */ working?: boolean /** images pasted into the note, as paths under `.lab/shots/` */ shots?: string[] } /** one alternative in a variant set — a ruleset and a name for it */ export type LabVariantOption = { key: string; label: string; css: string; note?: string } /** Alternatives for one element: written by the agent, decided in the browser. `chosen` is the whole handshake — `null` while the reader is comparing, then the key they committed to (or `''` for "keep the original"). */ export type LabVariantSet = { id: string selector: string label?: string route: string ask: string options: LabVariantOption[] chosen: string | null at: string } export type LabState = { /** the grain settings, keyed by selector */ entries: { selector: string; cfg: { op: number; blend: string; size: number } }[] notes: LabNote[] variants: LabVariantSet[] } const EMPTY: LabState = { entries: [], notes: [], variants: [] } export function labPlugin(): Plugin { const file = (root: string) => path.join(root, '.lab', 'state.json') const read = (root: string): LabState => { try { return { ...EMPTY, ...JSON.parse(fs.readFileSync(file(root), 'utf8')) } } catch { return EMPTY } } /* What the browser last posted. The file watcher below pushes every change back to the page, and without this it would push the page's own save straight back at it — a loop that fights whatever is being typed. */ let mine = '' const write = (root: string, next: LabState) => { const f = file(root) fs.mkdirSync(path.dirname(f), { recursive: true }) const body = JSON.stringify(next, null, 2) mine = body const tmp = `${f}.tmp` fs.writeFileSync(tmp, body) fs.renameSync(tmp, f) } return { name: 'phx-grain-lab', apply: 'serve', configureServer(server) { const root = server.config.root /* Push the file at the page whenever something else changes it. The other writer is whoever is acting on the notes: they mark a note `working` when they start on it and `done` when the change lands, and the page has no way to know that happened — it is a file on disk, and the person watching the page is looking at the page. A custom HMR event is the cheapest live channel that already exists in dev, and it costs nothing in a build because none of this ships. `fs.watch` on the directory rather than the file: the writes are atomic (temp file then rename), so the inode the file watcher was holding is gone after the first save. */ const dir = path.dirname(file(root)) fs.mkdirSync(dir, { recursive: true }) let timer: NodeJS.Timeout | undefined const watcher = fs.watch(dir, (_e, name) => { if (name && name !== 'state.json') return clearTimeout(timer) timer = setTimeout(() => { let body: string try { body = fs.readFileSync(file(root), 'utf8') } catch { return } if (body === mine) return // the page's own save, coming back mine = body try { server.ws.send({ type: 'custom', event: 'phx:lab', data: JSON.parse(body) }) } catch { /* a half-written file from an editor — the next event will carry it */ } }, 80) }) server.httpServer?.once('close', () => watcher.close()) /* Pasted images. A note about a visual is very often a note ABOUT a picture — a screenshot of the bug, a reference someone has been sent. Typing a description of an image you are looking at is the same lossy round-trip this whole tool exists to remove, so the field takes a paste and the bytes land next to the note. Written under `.lab/shots/` with a generated name: the clipboard does not carry a filename, and trusting one from the wire would be a path traversal. The response is the path the note stores and the panel renders. */ const shots = () => path.join(root, '.lab', 'shots') server.middlewares.use('/__lab/shot', (req, res) => { if (req.method === 'POST') { const type = String(req.headers['content-type'] ?? '') const ext = /^image\/(png|jpeg|webp|gif|avif)$/.exec(type)?.[1] if (!ext) { res.statusCode = 415 res.end(JSON.stringify({ ok: false, error: `not an image: ${type}` })) return } const chunks: Buffer[] = [] let size = 0 req.on('data', (c: Buffer) => { size += c.length // a clipboard screenshot is a few hundred KB; anything past this // is a mistake, and the dev server should not hold it in memory if (size > 12_000_000) { res.statusCode = 413 res.end(JSON.stringify({ ok: false, error: 'too large' })) req.destroy() return } chunks.push(c) }) req.on('end', () => { if (res.writableEnded) return try { fs.mkdirSync(shots(), { recursive: true }) const name = `${Date.now().toString(36)}-${Math.random() .toString(36) .slice(2, 6)}.${ext === 'jpeg' ? 'jpg' : ext}` fs.writeFileSync(path.join(shots(), name), Buffer.concat(chunks)) server.config.logger.info( ` \x1b[36m➜\x1b[0m lab: shot → .lab/shots/${name}`, ) res.setHeader('content-type', 'application/json') res.end(JSON.stringify({ ok: true, path: `.lab/shots/${name}` })) } catch (err) { res.statusCode = 500 res.end(JSON.stringify({ ok: false, error: String(err) })) } }) return } // GET /__lab/shot/ — serving them back for the thumbnails. // `basename` is the whole guard: nothing from the URL reaches the // path except a single file name. if (req.method === 'GET') { const name = path.basename(decodeURIComponent((req.url ?? '').split('?')[0])) const file = path.join(shots(), name) if (!name || !fs.existsSync(file)) { res.statusCode = 404 res.end() return } const ext = path.extname(name).slice(1).toLowerCase() res.setHeader('content-type', `image/${ext === 'jpg' ? 'jpeg' : ext}`) res.end(fs.readFileSync(file)) return } res.statusCode = 405 res.end() }) server.middlewares.use('/__lab/state', (req, res) => { res.setHeader('content-type', 'application/json') if (req.method === 'GET') { res.end(JSON.stringify(read(root))) return } if (req.method === 'POST') { let body = '' req.on('data', (c) => (body += c)) req.on('end', () => { try { const next = JSON.parse(body) as LabState write(root, next) // so the terminal shows the loop working, and so a note is // visible to whoever is watching the dev server const open = next.notes.filter((n) => !n.done) const undecided = (next.variants ?? []).filter((v) => v.chosen === null) server.config.logger.info( ` \x1b[36m➜\x1b[0m lab: ${next.entries.length} grain, ${open.length} open note(s), ` + `${undecided.length} variant set(s) → .lab/state.json`, ) /* The newest open note printed in full, identity and all — the log is where this loop is actually read, and `.btn` on its own never said which button. */ const last = open.at(-1) if (last) server.config.logger.info( ` \x1b[2m${last.selector}${last.label ? ` "${last.label}"` : ''} ` + `(${last.route})${last.shared && last.shared > 1 ? ` [1 of ${last.shared}]` : ''}` + `\x1b[0m ${last.text}`, ) res.end(JSON.stringify({ ok: true })) } catch (err) { res.statusCode = 400 res.end(JSON.stringify({ ok: false, error: String(err) })) } }) return } res.statusCode = 405 res.end(JSON.stringify({ ok: false })) }) }, } }