UNPKG

bimba-cli

Version:

The CLI tool to run Imba projects under Bun

1,414 lines (1,230 loc) 48 kB
import { serve as bunServe } from 'bun' import * as compiler from 'imba/compiler' import { mkdirSync, watch, existsSync, statSync, writeFileSync, realpathSync } from 'fs' import path from 'path' import { imbaPlugin } from './plugin.js' import { IMBA_RUNTIME_DEFINES, theme } from './utils.js' // ─── HMR Client (injected into browser) ────────────────────────────────────── const hmrClient = ` <script> (function() { // ── Custom element registry with prototype patching ──────────────────────── // // On initial page load: tags are not registered yet → call original define, // store the class in _classes map. // // On hot reload: the re-imported module calls customElements.define() again. // The tag is already registered (browser ignores duplicate defines). // Instead of ignoring the new class, we patch the prototype of the original // class with all new methods. This means: // - Existing element instances immediately get new render/methods // - Instance properties (el.active, el.count, etc.) are preserved // - CSS is auto-updated by imba_styles.register() during module execution // const _origDefine = customElements.define.bind(customElements); const _classes = new Map(); // tagName → first-registered constructor const _newClasses = new Map(); // tagName → latest class from HMR import const _oldNs = new Map(); // tagName → previous _ns_ (saved before _patchClass wipes it) let _collector = null; // when set, captures tag names defined during one HMR import customElements.define = function(name, cls, opts) { if (_collector) _collector.push(name); const existing = customElements.get(name); if (!existing) { _origDefine(name, cls, opts); _classes.set(name, cls); } else { _newClasses.set(name, cls); const target = _classes.get(name); if (target) { // Save old _ns_ before _patchClass overwrites prototype descriptors if (target.prototype._ns_) _oldNs.set(name, target.prototype._ns_); // Always patch: even CSS-only changes may update methods. _patchClass(target, cls); } } }; const _skipStatics = new Set(['length', 'name', 'prototype', 'caller', 'arguments']); // Copy all own property descriptors from source to target, skipping keys // that match the shouldSkip predicate. Handles both string and symbol keys. function _copyDescriptors(target, source, shouldSkip) { for (const key of Object.getOwnPropertyNames(source)) { if (shouldSkip(key)) continue; const d = Object.getOwnPropertyDescriptor(source, key); if (d) try { Object.defineProperty(target, key, d); } catch(_) {} } for (const key of Object.getOwnPropertySymbols(source)) { const d = Object.getOwnPropertyDescriptor(source, key); if (d) try { Object.defineProperty(target, key, d); } catch(_) {} } } function _patchClass(target, source) { _copyDescriptors(target.prototype, source.prototype, k => k === 'constructor'); _copyDescriptors(target, source, k => _skipStatics.has(k)); } // ── HMR update handler ───────────────────────────────────────────────────── // Updates are serialized via a promise queue. Without this, two file edits // arriving back-to-back would race on the shared collector and on imba's // reconcile loop, with the second update potentially missing tags from // the first. let _queue = Promise.resolve(); function _applyUpdate(file, slots) { _queue = _queue.then(() => _doUpdate(file, slots)).catch(err => { // Safety net: any uncaught failure during HMR → full reload. // Better to lose state than to leave a broken page. console.error('[bimba HMR] reload due to error:', err); location.reload(); }); } // Walk a subtree and call disconnectedCallback on each custom element. // Used before destroying inner DOM on the shifted path so imba/web-component // teardown logic (event listeners, observers, etc.) runs cleanly. function _disconnectDescendants(root) { const all = root.querySelectorAll('*'); for (const el of all) { if (el.tagName.includes('-')) { try { el.disconnectedCallback && el.disconnectedCallback(); } catch(_) {} } } } async function _doUpdate(file, slots) { clearError(file); const bodyBefore = new Set(document.body.children); const tagsBefore = new Set(); for (const el of bodyBefore) tagsBefore.add(el.tagName.toLowerCase()); const collected = []; const prev = _collector; _collector = collected; try { await import('/' + file + '?t=' + Date.now()); } finally { _collector = prev; } // Sync _ns_ (CSS namespace) from the new classes. imba_defineTag sets // _ns_ on NewClass.prototype AFTER register$ calls customElements.define, // so _patchClass missed it. Now that import is done, all _ns_ values are set. // Save old→new mapping for className patching below. const _nsPatches = []; // [{ oldParts, newParts }] for (const tag of collected) { const newCls = _newClasses.get(tag); const oldCls = _classes.get(tag); const newNs = newCls?.prototype._ns_; const oldNs = _oldNs.get(tag); if (oldNs && newNs && oldNs !== newNs) { oldCls.prototype._ns_ = newNs; // _ns_ uses '_' separator (z12kthg6_bc), className uses '-' (z12kthg6-bc) _nsPatches.push({ oldParts: oldNs.trim().split(/\\s+/).map(s => s.replace(/_/g, '-')), newParts: newNs.trim().split(/\\s+/).map(s => s.replace(/_/g, '-')), }); } else if (newNs && oldCls && oldCls.prototype._ns_ !== newNs) { oldCls.prototype._ns_ = newNs; } _oldNs.delete(tag); } // Destructive HMR: wipe inner DOM and re-render each collected tag. // Always destructive regardless of slots value. Imba's reconciliation // uses slot-tracking symbols (this[$sym] === 1) to skip re-creating // elements on re-render. Even "stable" edits (static text, attributes) // won't apply unless we clear those symbols and force a fresh render. // _patchClass already ran above, so the new render() method is in place. for (const tag of collected) { const els = document.querySelectorAll(tag); els.forEach(el => { const state = {}; for (const k of Object.keys(el)) state[k] = el[k]; _disconnectDescendants(el); for (const sym of Object.getOwnPropertySymbols(el)) { if (Symbol.keyFor(sym) !== undefined) continue; try { delete el[sym]; } catch(_) {} } el.innerHTML = ''; Object.assign(el, state); try { el.render && el.render(); } catch(e) { console.error('[bimba] render error:', e); } try { el.connectedCallback && el.connectedCallback(); } catch(_) {} try { el.mount && el.mount(); } catch(_) {} }); } if (typeof imba !== 'undefined') imba.commit(); // Patch className on ALL custom elements: replace old CSS namespace // hashes with new ones. Must be global because subclass elements // (e.g. panel-agent < basic-panel) inherit the parent's _ns_ hash // but querySelectorAll('basic-panel') won't find them. if (_nsPatches.length) { document.querySelectorAll('*').forEach(el => { if (!el.tagName.includes('-')) return; let cn = el.className; if (!cn) return; let changed = false; for (const { oldParts, newParts } of _nsPatches) { for (let i = 0; i < Math.min(oldParts.length, newParts.length); i++) { if (cn.includes(oldParts[i])) { cn = cn.split(oldParts[i]).join(newParts[i]); changed = true; } } } if (changed) el.className = cn; }); } // Smart body dedupe: remove duplicate top-level elements created by re-import for (const el of [...document.body.children]) { if (bodyBefore.has(el)) continue; if (tagsBefore.has(el.tagName.toLowerCase())) el.remove(); } } // ── WebSocket connection ─────────────────────────────────────────────────── let _connected = false; function connect() { const ws = new WebSocket('ws://' + location.host + '/__hmr__'); ws.onopen = () => { if (_connected) location.reload(); else _connected = true; }; ws.onmessage = (e) => { const msg = JSON.parse(e.data); if (msg.type === 'update') _applyUpdate(msg.file, msg.slots); else if (msg.type === 'reload') location.reload(); else if (msg.type === 'error') showError(msg.file, msg.errors, msg.time); else if (msg.type === 'clear-error') clearError(msg.file); }; ws.onclose = () => setTimeout(connect, 1000); } // ── Error overlay ────────────────────────────────────────────────────────── const _compileErrors = new Map(); function normalizeFile(file) { let value = String(file || '').split(/[?#]/)[0].split(String.fromCharCode(92)).join('/'); while (value.startsWith('./')) value = value.slice(2); while (value.startsWith('/')) value = value.slice(1); return value; } function sameFile(left, right) { const a = normalizeFile(left); const b = normalizeFile(right); if (!a || !b) return false; return a === b || a.endsWith('/' + b) || b.endsWith('/' + a); } function escapeHtml(value) { return String(value ?? '').replace(/[&<>"']/g, ch => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;', })[ch]); } function renderErrors() { let overlay = document.getElementById('__bimba_error__'); if (!_compileErrors.size) { if (overlay) overlay.remove(); return; } if (!overlay) { overlay = document.createElement('div'); overlay.id = '__bimba_error__'; overlay.style.cssText = 'position:fixed;inset:0;z-index:99999;background:rgba(0,0,0,.85);display:flex;align-items:center;justify-content:center;font-family:monospace;padding:24px;box-sizing:border-box'; overlay.addEventListener('click', e => { if (e.target === overlay) overlay.remove(); }); document.body.appendChild(overlay); } const files = Array.from(_compileErrors.entries()); overlay.innerHTML = '<div style="background:#1a1a1a;border:1px solid #ff4444;border-radius:8px;max-width:900px;width:100%;max-height:90vh;overflow:auto;box-shadow:0 0 40px rgba(255,68,68,.3)">' + '<div style="background:#ff4444;color:#fff;padding:10px 16px;font-size:13px;font-weight:600;display:flex;justify-content:space-between;align-items:center">' + '<span>Compile errors — ' + files.length + '</span>' + '<span onclick="document.getElementById(\\'__bimba_error__\\').remove()" style="cursor:pointer;opacity:.7;font-size:16px">✕</span>' + '</div>' + files.map(([displayFile, item]) => { const errors = item.errors || []; return '<div style="border-bottom:1px solid #333">' + '<div style="padding:10px 16px;background:#241616;color:#ffd1d1;font-size:13px;font-weight:600;display:flex;justify-content:space-between;gap:16px">' + '<span>' + escapeHtml(displayFile) + '</span>' + '<span style="opacity:.75;font-weight:400">' + escapeHtml(item.time || '') + '</span>' + '</div>' + errors.map(err => '<div style="padding:16px;border-top:1px solid #333">' + '<div style="color:#ff8080;font-size:13px;margin-bottom:10px">' + escapeHtml(err.message) + (err.line ? ' <span style="color:#888">line ' + escapeHtml(err.line) + '</span>' : '') + '</div>' + (err.snippet ? '<pre style="margin:0;padding:10px;background:#111;border-radius:4px;font-size:12px;line-height:1.6;color:#ccc;overflow-x:auto;white-space:pre">' + escapeHtml(err.snippet) + '</pre>' : '') + '</div>' ).join('') + '</div>'; }).join('') + '</div>'; } function showError(file, errors, time) { const displayFile = normalizeFile(file); for (const key of Array.from(_compileErrors.keys())) { if (sameFile(key, displayFile)) _compileErrors.delete(key); } _compileErrors.set(displayFile, { errors: Array.isArray(errors) ? errors : [errors], time: time || new Date().toLocaleTimeString(), }); renderErrors(); } function clearError(file) { if (file) { const displayFile = normalizeFile(file); for (const key of Array.from(_compileErrors.keys())) { if (sameFile(key, displayFile)) _compileErrors.delete(key); } } else { _compileErrors.clear(); } renderErrors(); } connect(); })(); </script>` // ─── Server-side compile cache ──────────────────────────────────────────────── const _compileCache = new Map() // filepath → { stamp, result } const _prevJs = new Map() // filepath → compiled js — for change detection const _prevSlots = new Map() // filepath → previous symbol slot count const _importScanner = new Bun.Transpiler({ loader: 'js' }) function dropFileState(filepath) { const abs = path.resolve(filepath) _compileCache.delete(abs) _prevJs.delete(abs) _prevSlots.delete(abs) } // Imba compiles tag render-cache slots as anonymous local Symbols at module top // level: `var $4 = Symbol(), $11 = Symbol(), ...; let c$0 = Symbol();`. Each // re-import of the file creates fresh Symbol objects, so old slot data on live // element instances no longer matches the new render's keys, and imba's diff // can't reuse cached children — it appends new ones, causing duplication. // // We rewrite each `<name> = Symbol()` clause so that the Symbol is read from a // per-file global cache, keyed by the variable name. On the first compilation // the cache is populated; on every subsequent compilation the same Symbol // objects are reused, slot keys stay stable, and imba's renderer happily // diff-updates existing DOM in place. // // Caveat: stability is keyed by name. If the user adds/removes elements in the // template, slot indices shift and the same name now points to a semantically // different slot. We detect this by counting slots — if the count changes vs // the previous compilation, we mark the file `slots: 'shifted'` and the client // falls back to the destructive wipe-and-render path. Pure CSS/text edits keep // counts unchanged → true in-place HMR. function stabilizeSymbols(js, filepath) { let count = 0 const out = js.replace( /([A-Za-z_$][\w$]*)\s*=\s*Symbol\(\)/g, (_m, name) => { count++; return `${name} = (__bsyms__[${JSON.stringify(name)}] ||= Symbol())` } ) if (count === 0) return { js, slotCount: 0 } const fileKey = JSON.stringify(filepath) const bootstrap = `const __bsyms__ = ((globalThis.__bimba_syms ||= {})[${fileKey}] ||= {});\n` return { js: bootstrap + out, slotCount: count } } // Imba's compile result puts `errors` on the prototype as a getter, so plain // object spread (`{...result}`) silently strips it. We always normalize to a // plain shape with `errors` as an own property — otherwise downstream callers // see no errors and serve empty 200s for broken files. function _normalizeResult(result, extras) { return { js: result.js, errors: result.errors || [], slots: result.slots, ...extras, } } function isBareSpecifier(specifier) { if (!specifier) return false if (specifier.startsWith('.') || specifier.startsWith('/')) return false if (/^[a-zA-Z][a-zA-Z\d+.-]*:/.test(specifier)) return false if (specifier.startsWith('#')) return false return true } function escapeRegExp(value) { return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') } function rewriteBareImports(js) { let imports = [] try { imports = _importScanner.scanImports(js) } catch (_) { return js } for (const specifier of new Set(imports.map(item => item.path).filter(isBareSpecifier))) { const target = vendorUrl(specifier) const escaped = escapeRegExp(specifier) const staticPattern = new RegExp(`((?:import|export)\\s+(?:[^'"]*?\\s+from\\s*)?)(['"])${escaped}\\2`, 'g') js = js.replace(staticPattern, (_match, prefix, quote) => `${prefix}${quote}${target}${quote}`) const dynamicPattern = new RegExp(`(\\bimport\\s*\\(\\s*)(['"])${escaped}\\2`, 'g') js = js.replace(dynamicPattern, (_match, prefix, quote) => `${prefix}${quote}${target}${quote}`) } return js } function isMissingFileError(error) { return error?.code === 'ENOENT' || String(error?.message || error).includes('ENOENT: no such file or directory') } function fileStamp(abs) { try { const stat = statSync(abs) if (!stat.isFile()) return null return `${stat.mtimeMs ?? stat.mtime?.getTime?.() ?? 0}:${stat.size ?? 0}` } catch (error) { if (isMissingFileError(error)) return null throw error } } function missingCompileResult(filepath) { dropFileState(filepath) return { js: '', errors: [], slots: null, changeType: 'missing', missing: true } } async function compileFile(filepath) { const abs = path.resolve(filepath) while (true) { const stamp = fileStamp(abs) if (!stamp) return missingCompileResult(abs) const cached = _compileCache.get(abs) if (cached && cached.stamp === stamp) { if (fileStamp(abs) !== stamp) continue return _normalizeResult(cached.result, { changeType: 'cached' }) } const file = Bun.file(abs) let code try { code = await file.text() } catch (error) { if (isMissingFileError(error)) return missingCompileResult(abs) throw error } // A save can land while an older request is compiling. Never publish, // cache, or report a result unless it still matches the current file. if (fileStamp(abs) !== stamp) continue let result try { result = compiler.compile(code, { sourcePath: filepath, platform: 'browser', sourcemap: 'inline', }) } catch (error) { result = { js: '', errors: [error], slots: null } } if (fileStamp(abs) !== stamp) continue const errors = result.errors || [] if (!errors.length && result.js) { const { js, slotCount } = stabilizeSymbols(result.js, abs) result.js = rewriteBareImports(js) const prev = _prevSlots.get(abs) result.slots = (prev === undefined || prev === slotCount) ? 'stable' : 'shifted' _prevSlots.set(abs, slotCount) } // Bake errors as an own property so caching/spreading preserves them. const baked = { js: result.js, errors, slots: result.slots } const changeType = _prevJs.get(abs) === baked.js ? 'none' : 'full' _prevJs.set(abs, baked.js) _compileCache.set(abs, { stamp, result: baked }) return _normalizeResult(baked, { changeType }) } } // ─── HTML helpers ───────────────────────────────────────────────────────────── function findHtml(flagHtml) { if (flagHtml) return flagHtml; const candidates = ['./index.html', './public/index.html', './src/index.html']; return candidates.find(p => existsSync(p)) || './index.html'; } // ─── Vendor modules ────────────────────────────────────────────────────────── const _vendorCache = new Map() // entrypoint → { mtime, code } const IMBA_VENDOR_EXTERNALS = ['imba', 'imba/*'] function shouldExternalizeImbaForVendor(entrypoint) { return entrypoint !== 'imba' && !entrypoint.startsWith('imba/') } function vendorUrl(specifier) { return '/__bimba_vendor__/' + encodeURIComponent(specifier) } function resolveFileCandidate(filepath) { const candidates = [ filepath, filepath + '.js', filepath + '.mjs', filepath + '.cjs', filepath + '.imba', filepath + '.css', path.join(filepath, 'index.js'), path.join(filepath, 'index.mjs'), path.join(filepath, 'index.cjs'), path.join(filepath, 'index.imba'), ] for (const candidate of candidates) { if (!existsSync(candidate)) continue try { if (statSync(candidate).isFile()) return candidate } catch (_) { // ignore vanished files and continue resolving } } return null } function vendorSpecifierFromPath(pathname) { const prefix = '/__bimba_vendor__/' if (!pathname.startsWith(prefix)) return null const specifier = decodeURIComponent(pathname.slice(prefix.length)) return specifier || null } function vendorEntrypoint(entrypoint) { if (path.isAbsolute(entrypoint)) return entrypoint const dir = path.join(process.cwd(), 'node_modules', '.cache', 'bimba', 'vendor-entry') mkdirSync(dir, { recursive: true }) const name = encodeURIComponent(entrypoint).replace(/%/g, '_') const file = path.join(dir, name + '.js') const specifier = JSON.stringify(entrypoint) const code = [ `export * from ${specifier};`, `import * as mod from ${specifier};`, `export default (mod.default ?? mod);`, '', ].join('\n') writeFileSync(file, code) return file } async function bundleVendor(entrypoint) { try { const stat = path.isAbsolute(entrypoint) && existsSync(entrypoint) ? statSync(entrypoint) : null const mtime = stat?.mtimeMs || 0 const cached = _vendorCache.get(entrypoint) if (cached && cached.mtime === mtime) return cached const buildEntrypoint = vendorEntrypoint(entrypoint) const externalizeImba = shouldExternalizeImbaForVendor(entrypoint) const result = await Bun.build({ entrypoints: [buildEntrypoint], target: 'browser', format: 'esm', write: false, minify: false, sourcemap: 'none', packages: 'bundle', external: externalizeImba ? IMBA_VENDOR_EXTERNALS : [], define: IMBA_RUNTIME_DEFINES, plugins: [imbaPlugin], }) if (!result.success) { return { mtime, errors: result.logs.map(log => String(log)) } } const output = result.outputs.find(output => output.path.endsWith('.js')) || result.outputs[0] if (!output) return { mtime, errors: ['Bun.build did not return a JavaScript output'] } let code = await output.text() if (externalizeImba) code = rewriteBareImports(code) const css = (await Promise.all( result.outputs .filter(output => output.path.endsWith('.css')) .map(output => output.text()) )).join('\n') if (css) { const id = JSON.stringify('vendor:' + entrypoint) code = [ `const __bimba_vendor_css_id = ${id};`, `let __bimba_vendor_css = document.querySelector('style[data-bimba-css=' + JSON.stringify(__bimba_vendor_css_id) + ']');`, `if (!__bimba_vendor_css) { __bimba_vendor_css = document.createElement('style'); __bimba_vendor_css.setAttribute('data-bimba-css', __bimba_vendor_css_id); document.head.appendChild(__bimba_vendor_css); }`, `__bimba_vendor_css.textContent = ${JSON.stringify(css)};`, code, ].join('\n') } const bundled = { mtime, code } _vendorCache.set(entrypoint, bundled) return bundled } catch (error) { return { mtime: 0, errors: [error?.message || String(error)] } } } async function serveJavaScriptFile(filepath) { const js = rewriteBareImports(await Bun.file(filepath).text()) return new Response(js, { headers: { 'Content-Type': 'application/javascript' } }) } // Rewrite production HTML for the dev server: // strips existing importmap + data-entrypoint script, then injects the Imba // entrypoint module + HMR client before </head>. function transformHtml(html, entrypoint) { html = html.replace(/<script\s+type=["']importmap["'][^>]*>[\s\S]*?<\/script>/gi, '') html = html.replace(/<script([^>]*)\bdata-entrypoint\b([^>]*)><\/script>/gi, '') const entryUrl = '/' + entrypoint.replace(/^\.\//, '').replaceAll('\\', '/') html = html.replace('</head>', `\t\t<script type='module' src='${entryUrl}'></script>\n${hmrClient}\n\t</head>` ) return html } // ─── Dev server ─────────────────────────────────────────────────────────────── export function serve(entrypoint, flags) { const port = flags.port || 5200 const htmlPath = findHtml(flags.html) const htmlDir = path.dirname(htmlPath) const srcDir = path.dirname(entrypoint) const sockets = new Set() // ── Live status block (shows only the current compile state) ─────────────── let _statusRows = 0 let _statusTimer = null let _statusFile = null let _statusInline = false let _statusWidth = 0 let _eraseTimers = [] const _isTTY = process.stdout.isTTY function stripAnsi(text) { return String(text).replace(/\x1b\[[0-9;?]*[A-Za-z]/g, '') } function renderedRows(lines) { const columns = Math.max(1, process.stdout.columns || 80) return lines.reduce((total, line) => { const length = stripAnsi(line).length return total + Math.max(1, Math.ceil(length / columns)) }, 0) } function clearStatus(file) { if (file && _statusFile && _statusFile !== file) return false if (_statusTimer) { clearTimeout(_statusTimer) _statusTimer = null } _eraseTimers.forEach(timer => clearTimeout(timer)) _eraseTimers = [] if (_isTTY && _statusInline) { process.stdout.write('\r\x1b[J') } else if (_isTTY && _statusRows) { process.stdout.write(`\x1b[${_statusRows}A\r\x1b[J`) } _statusRows = 0 _statusFile = null _statusInline = false _statusWidth = 0 return true } function formatErrorLines(errors) { const lines = [] for (const err of errors || []) { const message = errorMessage(err) const line = errorLine(err) lines.push(` ${theme.error(' ' + message + ' ')}${line != null ? theme.margin(` line ${line + 1} `) : ''}`) const snippet = errorSnippet(err) if (snippet && snippet !== message) { lines.push(...String(snippet).split('\n').slice(0, 6).map(item => ` ${theme.code(item)}`)) } lines.push('') } return lines } function statusLines(file, state, errors) { const now = new Date().toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit', second: '2-digit' }) const status = state === 'ok' ? theme.success(' ok ') : theme.failure(' fail ') const lines = [` ${theme.folder(now)} ${theme.filename(file)} ${status}`] if (errors?.length) lines.push('', ...formatErrorLines(errors)) return lines } function printStatus(file, state, errors, options = {}) { // non-TTY (pipes, Claude Code bash, CI): plain newline-terminated output, // no ANSI cursor tricks, no fade-out — so logs stay readable. if (!_isTTY) { const now = new Date().toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit', second: '2-digit' }) const tag = state === 'ok' ? 'ok' : 'fail' process.stdout.write(` ${now} ${file} ${tag}\n`) if (errors?.length) { for (const err of errors) { const msg = err.message || String(err) const line = err.range?.start?.line process.stdout.write(` ${msg}${line ? ` (line ${line})` : ''}\n`) } } return } clearStatus() _statusFile = file const lines = statusLines(file, state, errors) if (options.fadeAfter && lines.length === 1) { const plain = stripAnsi(lines[0]) process.stdout.write(lines[0]) _statusRows = 1 _statusInline = true _statusWidth = plain.length _statusTimer = setTimeout(() => eraseStatus(file), options.fadeAfter) return } process.stdout.write(lines.join('\n') + '\n') _statusRows = renderedRows(lines) if (options.clearAfter) _statusTimer = setTimeout(() => clearStatus(file), options.clearAfter) } function eraseStatus(file) { if (file && _statusFile && _statusFile !== file) return _statusTimer = null if (!_isTTY || !_statusInline) { clearStatus(file) return } const total = _statusWidth for (let i = 1; i <= total; i++) { _eraseTimers.push(setTimeout(() => { if (!_statusInline || _statusFile !== file) return process.stdout.write('\x1b[1D \x1b[1D') if (i === total) { _statusRows = 0 _statusFile = null _statusInline = false _statusWidth = 0 _eraseTimers = [] } }, i * 22)) } } // ── File watcher ─────────────────────────────────────────────────────────── const _activeErrors = new Map() const _terminalErrors = new Map() function broadcast(payload) { const msg = JSON.stringify(payload) for (const socket of sockets) socket.send(msg) } function normalizeFile(file) { let value = String(file || '') value = value.split(/[?#]/)[0] if (path.isAbsolute(value)) { const rel = path.relative(process.cwd(), value) if (!rel.startsWith('..')) value = rel } value = value.replaceAll('\\', '/') while (value.startsWith('./')) value = value.slice(2) while (value.startsWith('/')) value = value.slice(1) return value } const srcRoot = path.resolve(srcDir) const srcRel = normalizeFile(srcRoot) function unprefixFile(file) { return normalizeFile(file).replace(/^(?:html|css|js|static):/, '') } function fileVariants(file) { const key = unprefixFile(file) const variants = new Set([key]) if (srcRel && key.startsWith(srcRel + '/')) variants.add(key.slice(srcRel.length + 1)) else if (srcRel && key) variants.add(srcRel + '/' + key) return Array.from(variants).filter(Boolean) } function terminalErrorKey(file) { const variants = fileVariants(file) const rooted = variants.find(variant => srcRel && variant.startsWith(srcRel + '/')) return `path:${rooted || variants[0] || normalizeFile(file)}` } function fileCandidates(file) { const candidates = [] for (const variant of fileVariants(file)) { candidates.push(path.resolve(variant)) if (!variant.startsWith(srcRel + '/')) candidates.push(path.resolve(srcRoot, variant)) } return candidates } function physicalFileKey(file) { for (const candidate of fileCandidates(file)) { try { const stat = statSync(candidate) if (!stat.isFile()) continue const real = realpathSync(candidate).replaceAll('\\', '/') return `fs:${stat.dev}:${stat.ino}:${real}` } catch(_) { // ignore non-existing aliases } } return null } function errorKey(file) { return physicalFileKey(file) || `path:${normalizeFile(file)}` } function sameFile(left, right) { const leftPhysical = physicalFileKey(left) const rightPhysical = physicalFileKey(right) if (leftPhysical && rightPhysical && leftPhysical === rightPhysical) return true const lefts = fileVariants(left) const rights = fileVariants(right) for (const a of lefts) { for (const b of rights) { if (a === b || a.endsWith('/' + b) || b.endsWith('/' + a)) return true } } return false } function takeError(file) { let previous = null const target = errorKey(file) const keys = Array.from(_activeErrors.keys()) for (const key of keys) { const item = _activeErrors.get(key) const storedFile = item?.file || key.replace(/^path:/, '') if (key !== target && !sameFile(storedFile, file)) continue previous ||= _activeErrors.get(key) _activeErrors.delete(key) } return previous } function errorMessage(error) { return error?.message || String(error) } function errorLine(error) { return error?.range?.start?.line ?? error?.line } function errorSnippet(error) { try { return error?.toSnippet?.() || error?.snippet || error?.stack || errorMessage(error) } catch(_) { return error?.snippet || error?.stack || errorMessage(error) } } function serializeErrors(errors) { return errors.map(error => ({ message: errorMessage(error), line: errorLine(error), snippet: errorSnippet(error), })) } function normalizeErrors(errors) { const list = Array.isArray(errors) ? errors : [errors] const seen = new Set() const normalized = [] for (const error of list) { const serialized = { message: errorMessage(error), line: errorLine(error), snippet: errorSnippet(error), } const key = JSON.stringify(serialized) if (seen.has(key)) continue seen.add(key) normalized.push(error) } return normalized } function errorSignature(errors) { return serializeErrors(errors) .map(error => [error.message, error.line ?? ''].join('\n')) .join('\n---\n') } function terminalErrorSignature(errors) { return serializeErrors(errors) .map(error => [error.message, error.line ?? ''].join('\n')) .join('\n---\n') } function renderActiveErrors() { if (!_isTTY) return false if (!_activeErrors.size) { clearStatus() return false } clearStatus() const lines = [] for (const item of _activeErrors.values()) { if (lines.length) lines.push('') lines.push(...statusLines(item.file, 'fail', item.errors)) } process.stdout.write(lines.join('\n') + '\n') _statusFile = null _statusRows = renderedRows(lines) return true } function existingFileForError(file) { for (const candidate of fileCandidates(file)) { try { const stat = statSync(candidate) if (stat.isFile()) return candidate } catch(_) { // ignore aliases that no longer exist } } return null } async function reconcileActiveErrors() { if (!_activeErrors.size) return false let changed = false const items = Array.from(_activeErrors.values()) for (const item of items) { const filepath = existingFileForError(item.file) if (!filepath) { if (takeError(item.file)) { _terminalErrors.delete(terminalErrorKey(item.file)) broadcast({ type: 'clear-error', file: item.file }) changed = true } continue } if (!filepath.endsWith('.imba')) continue const out = await compileFile(filepath) if (out.errors?.length) continue if (takeError(item.file)) { _terminalErrors.delete(terminalErrorKey(item.file)) broadcast({ type: 'clear-error', file: item.file }) changed = true } } return changed } function showTrackedError(item) { const file = item.file if (_isTTY) renderActiveErrors() else printStatus(file, 'fail', item.errors) broadcast({ type: 'error', file, time: item.time, errors: item.payload }) } function reportError(file, errors) { const display = normalizeFile(file) const key = errorKey(display) const terminalKey = terminalErrorKey(display) const list = normalizeErrors(errors) const signature = errorSignature(list) const printSignature = terminalErrorSignature(list) const previous = takeError(display) const now = Date.now() const recent = _terminalErrors.get(terminalKey) const duplicate = previous?.signature === signature || previous?.printSignature === printSignature || recent?.signature === printSignature const item = { file: display, signature, printSignature, errors: list, payload: serializeErrors(list), time: new Date().toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit', second: '2-digit' }), } _activeErrors.set(key, item) _terminalErrors.set(terminalKey, { signature: printSignature, time: now }) // Repeated reports of the same active error update the browser overlay, // but never print or re-render another terminal entry until clear-error. if (duplicate) { broadcast({ type: 'error', file: display, time: item.time, errors: item.payload }) return } showTrackedError(item) } function errorText(errors) { const list = normalizeErrors(errors) return list.map(errorMessage).join('\n') } function errorResponse(file, errors, status = 500) { reportError(file, errors) return new Response(errorText(errors), { status }) } function clearError(file) { const key = file ? normalizeFile(file) : null const wasStatusFile = key && _statusFile && sameFile(_statusFile, key) const hadError = key ? !!takeError(key) : _activeErrors.size > 0 if (!key) { _activeErrors.clear() _terminalErrors.clear() } else if (hadError) { _terminalErrors.delete(terminalErrorKey(key)) } let showedNext = false if (_isTTY && _activeErrors.size && (!key || hadError || wasStatusFile)) showedNext = renderActiveErrors() else if (!key || hadError || wasStatusFile) clearStatus(key) broadcast({ type: 'clear-error', file: key }) return { cleared: hadError || wasStatusFile, file: key, showedNext } } async function markSuccess(file) { const key = normalizeFile(file) const result = clearError(key) const reconciled = await reconcileActiveErrors() const active = _activeErrors.size let showedNext = result.showedNext if (_isTTY && reconciled) { showedNext = active ? renderActiveErrors() : false if (!showedNext && result.showedNext) clearStatus() } const shouldPrint = (result?.cleared || reconciled) && !showedNext && !active if (shouldPrint) { printStatus(key, 'ok', null, { fadeAfter: 3500 }) } return { cleared: !!result?.cleared || reconciled, printed: !!shouldPrint, showedNext: !!showedNext, active } } const _debounce = new Map() const _watchVersion = new Map() function watchedFile(filename) { filename = filename && String(filename) if (!filename) return null let filepath if (path.isAbsolute(filename)) { filepath = path.resolve(filename) } else { const rel = normalizeFile(filename) filepath = (rel === srcRel || rel.startsWith(srcRel + '/')) ? path.resolve(filename) : path.resolve(srcRoot, filename) } const rel = normalizeFile(filepath) return { filepath, rel } } function scheduleCompile(filename) { const file = watchedFile(filename) if (!file || !file.rel.endsWith('.imba')) return const version = (_watchVersion.get(file.rel) || 0) + 1 _watchVersion.set(file.rel, version) const pending = _debounce.get(file.rel) if (pending) clearTimeout(pending) _debounce.set(file.rel, setTimeout(() => { _debounce.delete(file.rel) compileChangedFile(file, version) }, 150)) } function isCurrentChange(file, version) { return _watchVersion.get(file.rel) === version } async function compileChangedFile(file, version) { const { filepath, rel } = file try { if (!existsSync(filepath)) { if (!isCurrentChange(file, version)) return dropFileState(filepath) clearError(rel) return } const out = await compileFile(filepath) if (!isCurrentChange(file, version)) return if (out.missing) { clearError(rel) return } if (out.errors?.length) { reportError(rel, out.errors) return } const success = await markSuccess(rel) // No change at all — skip if (out.changeType === 'none' || out.changeType === 'cached') return if (!success.printed && !success.showedNext && !success.active) printStatus(rel, 'ok', null, { fadeAfter: 3500 }) broadcast({ type: 'update', file: rel, slots: out.slots || 'shifted' }) } catch(e) { if (!isCurrentChange(file, version)) return if (isMissingFileError(e)) { dropFileState(filepath) clearError(rel) return } reportError(rel, [{ message: e.message, snippet: e.stack || e.message }]) } } watch(srcDir, { recursive: true }, (_event, filename) => { scheduleCompile(filename) }) // ── HTTP + WebSocket server ──────────────────────────────────────────────── bunServe({ port, development: true, fetch: async (req, server) => { const url = new URL(req.url) const pathname = url.pathname try { // WebSocket upgrade for HMR if (pathname === '/__hmr__') { if (server.upgrade(req)) return undefined } if (pathname.startsWith('/__bimba_vendor__/')) { const specifier = vendorSpecifierFromPath(pathname) const file = 'vendor:' + (specifier || pathname) const bundled = specifier ? await bundleVendor(specifier) : null if (bundled?.code) { await markSuccess(file) return new Response(bundled.code, { headers: { 'Content-Type': 'application/javascript' } }) } return errorResponse(file, bundled?.errors || [`Could not bundle vendor module: ${specifier}`]) } // HTML: index or any .html file if (pathname === '/' || pathname.endsWith('.html')) { const htmlFile = pathname === '/' ? htmlPath : '.' + pathname const file = 'html:' + normalizeFile(htmlFile) try { let html = await Bun.file(htmlFile).text() await markSuccess(file) return new Response(transformHtml(html, entrypoint), { headers: { 'Content-Type': 'text/html' }, }) } catch (error) { if (isMissingFileError(error)) { clearError(file) return new Response('Not Found', { status: 404 }) } return errorResponse(file, [error]) } } // Imba files: compile on demand and serve as JS if (pathname.endsWith('.imba')) { const filepath = '.' + pathname const file = normalizeFile(pathname) try { const out = await compileFile(filepath) if (out.missing) { clearError(file) return new Response('Not Found', { status: 404 }) } if (out.errors?.length) { return errorResponse(file, out.errors) } await markSuccess(file) return new Response(out.js, { headers: { 'Content-Type': 'application/javascript' } }) } catch(e) { if (isMissingFileError(e)) { dropFileState(filepath) clearError(file) return new Response('Not Found', { status: 404 }) } return errorResponse(file, [{ message: e.message, snippet: e.stack || e.message }]) } } // CSS files imported from JS: wrap as a JS module that injects a <style> tag. // Without this, `import './styles.css'` inside an ESM package fails because // the browser expects a JS module response, not raw CSS. if (pathname.endsWith('.css')) { const cssPath = resolveFileCandidate(path.join(htmlDir, pathname)) || resolveFileCandidate('.' + pathname) const cssFile = cssPath ? Bun.file(cssPath) : null const file = 'css:' + normalizeFile(cssPath || pathname) try { if (cssFile && await cssFile.exists()) { if (req.headers.get('sec-fetch-dest') === 'style') { await markSuccess(file) return new Response(cssFile, { headers: { 'Content-Type': 'text/css' } }) } const css = await cssFile.text() const id = JSON.stringify(pathname) const js = [ `const id = ${id};`, `let el = document.querySelector('style[data-bimba-css=' + JSON.stringify(id) + ']');`, `if (!el) { el = document.createElement('style'); el.setAttribute('data-bimba-css', id); document.head.appendChild(el); }`, `el.textContent = ${JSON.stringify(css)};`, ].join('\n') await markSuccess(file) return new Response(js, { headers: { 'Content-Type': 'application/javascript' } }) } } catch (error) { if (isMissingFileError(error)) { clearError(file) return new Response('Not Found', { status: 404 }) } return errorResponse(file, [error]) } } if (!pathname.startsWith('/node_modules/') && (pathname.endsWith('.js') || pathname.endsWith('.mjs'))) { const jsFile = resolveFileCandidate(path.join(htmlDir, pathname)) || resolveFileCandidate('.' + pathname) if (jsFile) { const file = 'js:' + normalizeFile(jsFile) try { const response = await serveJavaScriptFile(jsFile) await markSuccess(file) return response } catch (error) { if (isMissingFileError(error)) { clearError(file) return new Response('Not Found', { status: 404 }) } return errorResponse(file, [error]) } } } // Direct node_modules URLs (from user import maps or explicit imports) // are bundled through Bun too, so browser/cjs/exports handling stays // in one place. if (pathname.startsWith('/node_modules/')) { const resolved = resolveFileCandidate('.' + pathname) if (resolved?.endsWith('.imba')) { const out = await compileFile(resolved) const file = normalizeFile(resolved) if (out.missing) { clearError(file) return new Response('Not Found', { status: 404 }) } if (out.errors?.length) { return errorResponse(file, out.errors) } await markSuccess(file) return new Response(out.js, { headers: { 'Content-Type': 'application/javascript' } }) } if (resolved) { const file = 'vendor:' + normalizeFile(pathname) const bundled = await bundleVendor(path.resolve(resolved)) if (bundled?.code) { await markSuccess(file) return new Response(bundled.code, { headers: { 'Content-Type': 'application/javascript' } }) } return errorResponse(file, bundled?.errors || [`Could not bundle ${pathname}`]) } } // Static files: check htmlDir first (for assets relative to HTML), then root try { const inHtmlDirPath = path.join(htmlDir, pathname) const inHtmlDir = Bun.file(inHtmlDirPath) if (await inHtmlDir.exists()) { await markSuccess('static:' + normalizeFile(inHtmlDirPath)) return new Response(inHtmlDir) } const inRootPath = '.' + pathname const inRoot = Bun.file(inRootPath) if (await inRoot.exists()) { await markSuccess('static:' + normalizeFile(inRootPath)) return new Response(inRoot) } } catch (error) { if (!isMissingFileError(error)) return errorResponse('static:' + normalizeFile(pathname), [error]) } // Try extensions for extensionless paths (e.g. node_modules imports) const lastSegment = pathname.split('/').pop() if (!lastSegment.includes('.')) { // Try .imba first (compile on the fly), then .js/.mjs const imbaPath = '.' + pathname + '.imba' if (existsSync(imbaPath)) { const out = await compileFile(imbaPath) const file = normalizeFile(imbaPath) if (out.missing) { clearError(file) return new Response('Not Found', { status: 404 }) } if (out.errors?.length) { return errorResponse(file, out.errors) } await markSuccess(file) return new Response(out.js, { headers: { 'Content-Type': 'application/javascript' } }) } for (const ext of ['.js', '.mjs']) { const withExt = '.' + pathname + ext if (existsSync(withExt)) { const file = 'js:' + normalizeFile(withExt) try { const response = await serveJavaScriptFile(withExt) await markSuccess(file) return response } catch (error) { if (isMissingFileError(error)) { clearError(file) return new Response('Not Found', { status: 404 }) } return errorResponse(file, [error]) } } } } // SPA fallback for extension-less paths if (!lastSegment.includes('.')) { const file = 'html:' + normalizeFile(htmlPath) try { let html = await Bun.file(htmlPath).text() await markSuccess(file) return new Response(transformHtml(html, entrypoint), { headers: { 'Content-Type': 'text/html' }, }) } catch (error) { if (isMissingFileError(error)) { clearError(file) return new Response('Not Found', { status: 404 }) } return errorResponse(file, [error]) } } return new Response('Not Found', { status: 404 }) } catch (error) { const file = 'server:' + normalizeFile(pathname || req.url) if (isMissingFileError(error)) { clearError(file) return new Response('Not Found', { status: 404 }) } return errorResponse(file, [error]) } }, websocket: { open: ws => { sockets.add(ws) }, close: ws => { sockets.delete(ws) }, message: () => {}, }, }) console.log(theme.folder('──────────────────────────────────────────────────────────────────────')) console.log(theme.start('Dev server running at ') + theme.success(`http://localhost:${port}`)) console.log(theme.folder('──────────────────────────────────────────────────────────────────────')) }