bimba-cli
Version:
The CLI tool to run Imba projects under Bun
1,414 lines (1,230 loc) • 48 kB
JavaScript
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 => ({
'&': '&',
'<': '<',
'>': '>',
'"': '"',
"'": ''',
})[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('──────────────────────────────────────────────────────────────────────'))
}