bimba-cli 0.5.0 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/serve.js CHANGED
@@ -5,50 +5,146 @@ import path from 'path'
5
5
  import { theme } from './utils.js'
6
6
  import { printerr } from './plugin.js'
7
7
 
8
- // HMR client for CSS-only mode (injects styles without reload)
9
- const hmrClientCssOnly = `
8
+ // ─── HMR Client (injected into browser) ──────────────────────────────────────
9
+
10
+ const hmrClient = `
10
11
  <script>
12
+ (function() {
13
+ // ── Custom element registry with prototype patching ────────────────────────
14
+ //
15
+ // On initial page load: tags are not registered yet → call original define,
16
+ // store the class in _classes map.
17
+ //
18
+ // On hot reload: the re-imported module calls customElements.define() again.
19
+ // The tag is already registered (browser ignores duplicate defines).
20
+ // Instead of ignoring the new class, we patch the prototype of the original
21
+ // class with all new methods. This means:
22
+ // - Existing element instances immediately get new render/methods
23
+ // - Instance properties (el.active, el.count, etc.) are preserved
24
+ // - CSS is auto-updated by imba_styles.register() during module execution
25
+ //
26
+ const _origDefine = customElements.define.bind(customElements);
27
+ const _classes = new Map(); // tagName → first-registered constructor
28
+ let _hotTags = []; // tags defined during the current hot import
29
+
30
+ customElements.define = function(name, cls, opts) {
31
+ _hotTags.push(name);
32
+ const existing = customElements.get(name);
33
+ if (!existing) {
34
+ _origDefine(name, cls, opts);
35
+ _classes.set(name, cls);
36
+ } else {
37
+ const target = _classes.get(name);
38
+ if (target) _patchClass(target, cls);
39
+ }
40
+ };
41
+
42
+ const _skipStatics = new Set(['length', 'name', 'prototype', 'caller', 'arguments']);
43
+
44
+ // Copy all own property descriptors from source to target, skipping keys
45
+ // that match the shouldSkip predicate. Handles both string and symbol keys.
46
+ function _copyDescriptors(target, source, shouldSkip) {
47
+ for (const key of Object.getOwnPropertyNames(source)) {
48
+ if (shouldSkip(key)) continue;
49
+ const d = Object.getOwnPropertyDescriptor(source, key);
50
+ if (d) try { Object.defineProperty(target, key, d); } catch(_) {}
51
+ }
52
+ for (const key of Object.getOwnPropertySymbols(source)) {
53
+ const d = Object.getOwnPropertyDescriptor(source, key);
54
+ if (d) try { Object.defineProperty(target, key, d); } catch(_) {}
55
+ }
56
+ }
57
+
58
+ function _patchClass(target, source) {
59
+ _copyDescriptors(target.prototype, source.prototype, k => k === 'constructor');
60
+ _copyDescriptors(target, source, k => _skipStatics.has(k));
61
+ }
62
+
63
+ // ── HMR update handler ─────────────────────────────────────────────────────
64
+
65
+ function _applyUpdate(file) {
66
+ clearError();
67
+ _hotTags = [];
68
+
69
+ import('/' + file + '?t=' + Date.now()).then(() => {
70
+ const updatedTags = _hotTags.slice();
71
+ _hotTags = [];
72
+
73
+ // Always remove duplicate root elements. Re-importing a module with a
74
+ // fresh ?t= query causes top-level code (e.g. imba.mount()) to run again,
75
+ // which can append a second copy of the root tag to body.
76
+ const seen = new Set();
77
+ [...document.body.children].forEach(el => {
78
+ const tag = el.tagName.toLowerCase();
79
+ if (seen.has(tag)) el.remove();
80
+ else seen.add(tag);
81
+ });
82
+
83
+ // JS changed: find all instances of the updated tag types, reset their
84
+ // render output (innerHTML), then let imba re-render from current state.
85
+ //
86
+ // innerHTML = '' removes rendered DOM children but does NOT touch instance
87
+ // properties — so el.active, el.selectedTab etc. survive.
88
+ //
89
+ // We also clear any symbol keys that point to DOM Nodes (Imba's render
90
+ // cache) so the new render method (which uses new module-scoped symbols)
91
+ // starts clean and doesn't leave orphaned nodes.
92
+
93
+ const toReset = new Set();
94
+
95
+ updatedTags.forEach(tagName => {
96
+ document.querySelectorAll(tagName).forEach(el => {
97
+ toReset.add(el);
98
+ });
99
+ });
100
+
101
+ toReset.forEach(el => {
102
+ // Clear Imba render cache (symbol → Node mappings)
103
+ Object.getOwnPropertySymbols(el).forEach(s => {
104
+ try { if (el[s] instanceof Node) el[s] = undefined; } catch(_) {}
105
+ });
106
+ el.innerHTML = '';
107
+ });
108
+
109
+ if (toReset.size > 0 && typeof imba !== 'undefined') {
110
+ imba.commit();
111
+ }
112
+ });
113
+ }
114
+
115
+ // ── WebSocket connection ───────────────────────────────────────────────────
116
+
11
117
  let _connected = false;
12
- let _styleEl = null;
13
118
 
14
119
  function connect() {
15
120
  const ws = new WebSocket('ws://' + location.host + '/__hmr__');
121
+
16
122
  ws.onopen = () => {
17
- if (_connected) {
18
- location.reload();
19
- } else {
20
- _connected = true;
21
- }
123
+ // If we reconnect after a disconnect, reload to get fresh state
124
+ if (_connected) location.reload();
125
+ else _connected = true;
22
126
  };
127
+
23
128
  ws.onmessage = (e) => {
24
- const data = JSON.parse(e.data);
25
- if (data.type === 'css-update') {
26
- if (!_styleEl) {
27
- _styleEl = document.createElement('style');
28
- _styleEl.id = '__bimba_hmr_css__';
29
- document.head.appendChild(_styleEl);
30
- }
31
- _styleEl.textContent = data.css;
32
- } else if (data.type === 'reload') {
33
- location.reload();
34
- } else if (data.type === 'error') {
35
- showError(data.file, data.errors);
36
- } else if (data.type === 'clear-error') {
37
- clearError();
38
- }
39
- };
40
- ws.onclose = () => {
41
- setTimeout(connect, 1000);
129
+ const msg = JSON.parse(e.data);
130
+ if (msg.type === 'update') _applyUpdate(msg.file);
131
+ else if (msg.type === 'reload') location.reload();
132
+ else if (msg.type === 'error') showError(msg.file, msg.errors);
133
+ else if (msg.type === 'clear-error') clearError();
42
134
  };
135
+
136
+ ws.onclose = () => setTimeout(connect, 1000);
43
137
  }
44
138
 
139
+ // ── Error overlay ──────────────────────────────────────────────────────────
140
+
45
141
  function showError(file, errors) {
46
142
  let overlay = document.getElementById('__bimba_error__');
47
143
  if (!overlay) {
48
144
  overlay = document.createElement('div');
49
145
  overlay.id = '__bimba_error__';
50
146
  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';
51
- overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
147
+ overlay.addEventListener('click', e => { if (e.target === overlay) overlay.remove(); });
52
148
  document.body.appendChild(overlay);
53
149
  }
54
150
  overlay.innerHTML = \`
@@ -73,161 +169,49 @@ const hmrClientCssOnly = `
73
169
  }
74
170
 
75
171
  connect();
172
+ })();
76
173
  </script>`
77
174
 
78
- // Full HMR client (swaps component prototypes and resets elements)
79
- const hmrClientFull = `
80
- <script>
81
- const _originalDefine = customElements.define.bind(customElements);
82
- const _registry = new Map();
83
- const _updated = new Set();
84
-
85
- customElements.define = function(name, cls, opts) {
86
- const existing = _registry.get(name);
87
- if (existing) {
88
- Object.getOwnPropertyNames(cls.prototype).forEach(key => {
89
- if (key === 'constructor') return;
90
- try { Object.defineProperty(existing.prototype, key, Object.getOwnPropertyDescriptor(cls.prototype, key)); } catch(e) {}
91
- });
92
- Object.getOwnPropertyNames(cls).forEach(key => {
93
- if (['length','name','prototype','arguments','caller'].includes(key)) return;
94
- try { Object.defineProperty(existing, key, Object.getOwnPropertyDescriptor(cls, key)); } catch(e) {}
95
- });
96
- _updated.add(name);
97
- } else {
98
- _registry.set(name, cls);
99
- _originalDefine(name, cls, opts);
100
- }
101
- };
102
-
103
- function resetElement(el) {
104
- Object.getOwnPropertySymbols(el).forEach(s => {
105
- try { if (el[s] instanceof Node) el[s] = undefined; } catch(e) {}
106
- });
107
- el.innerHTML = '';
108
- }
109
-
110
- let _connected = false;
111
- let _overlay = null;
112
-
113
- function showError(file, errors) {
114
- if (!_overlay) {
115
- _overlay = document.createElement('div');
116
- _overlay.id = '__bimba_error__';
117
- const s = _overlay.style;
118
- s.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';
119
- _overlay.addEventListener('click', (e) => { if (e.target === _overlay) clearError(); });
120
- document.body.appendChild(_overlay);
121
- }
122
- _overlay.innerHTML = \`
123
- <div style="background:#1a1a1a;border:1px solid #ff4444;border-radius:8px;max-width:860px;width:100%;max-height:90vh;overflow:auto;box-shadow:0 0 40px rgba(255,68,68,.3)">
124
- <div style="background:#ff4444;color:#fff;padding:10px 16px;font-size:13px;font-weight:600;display:flex;justify-content:space-between;align-items:center">
125
- <span>Compile error — \${file}</span>
126
- <span onclick="document.getElementById('__bimba_error__').remove();document.getElementById('__bimba_error__')&&(window.__bimba_overlay__=null)" style="cursor:pointer;opacity:.7;font-size:16px">✕</span>
127
- </div>
128
- \${errors.map(err => \`
129
- <div style="padding:16px;border-bottom:1px solid #333">
130
- <div style="color:#ff8080;font-size:13px;margin-bottom:10px">\${err.message}\${err.line ? \` <span style="color:#888">line \${err.line}</span>\` : ''}</div>
131
- \${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">\${err.snippet.replace(/</g,'&lt;')}</pre>\` : ''}
132
- </div>
133
- \`).join('')}
134
- </div>
135
- \`;
136
- }
137
-
138
- function clearError() {
139
- if (_overlay) { _overlay.remove(); _overlay = null; }
140
- }
175
+ // ─── Server-side compile cache ────────────────────────────────────────────────
141
176
 
142
- function connect() {
143
- const ws = new WebSocket('ws://' + location.host + '/__hmr__');
144
- ws.onopen = () => {
145
- if (_connected) {
146
- location.reload();
147
- } else {
148
- _connected = true;
149
- }
150
- };
151
- ws.onmessage = (e) => {
152
- const data = JSON.parse(e.data);
153
- if (data.type === 'update') {
154
- clearError();
155
- _updated.clear();
156
- import('/' + data.file + '?t=' + Date.now()).then(() => {
157
- const updatedClasses = [..._updated].map(n => _registry.get(n)).filter(Boolean);
158
- const found = [];
159
- if (updatedClasses.length) {
160
- document.querySelectorAll('*').forEach(el => {
161
- for (const cls of updatedClasses) {
162
- if (el instanceof cls) { found.push(el); break; }
163
- }
164
- });
165
- }
166
- found.forEach(resetElement);
167
- _updated.clear();
168
- imba.commit();
169
- });
170
- } else if (data.type === 'reload') {
171
- location.reload();
172
- } else if (data.type === 'error') {
173
- showError(data.file, data.errors);
174
- } else if (data.type === 'clear-error') {
175
- clearError();
176
- }
177
- };
178
- ws.onclose = () => {
179
- setTimeout(connect, 1000);
180
- };
181
- }
182
-
183
- connect();
184
- </script>`
185
-
186
- const _compileCache = new Map()
187
- // Store previous versions to detect what changed
188
- const _versionHistory = new Map()
177
+ const _compileCache = new Map() // filepath → { mtime, result }
178
+ const _prevJs = new Map() // filepath compiled js — for change detection
189
179
 
190
180
  async function compileFile(filepath) {
191
181
  const file = Bun.file(filepath)
192
182
  const stat = await file.stat()
193
183
  const mtime = stat.mtime.getTime()
184
+
194
185
  const cached = _compileCache.get(filepath)
195
- if (cached && cached.mtime === mtime) return { ...cached.result, cached: true }
186
+ if (cached && cached.mtime === mtime) return { ...cached.result, changeType: 'cached' }
187
+
196
188
  const code = await file.text()
197
- const result = compiler.compile(code, { sourcePath: filepath, platform: 'browser', sourcemap: 'inline' })
198
-
199
- // Track what changed compared to previous version
200
- const prev = _versionHistory.get(filepath)
201
- let changeType = 'full' // default
202
- if (prev) {
203
- const cssChanged = prev.css !== result.css
204
- const jsChanged = prev.js !== result.js
205
- if (cssChanged && !jsChanged) {
206
- changeType = 'css-only'
207
- } else if (jsChanged) {
208
- changeType = 'full'
209
- } else {
210
- changeType = 'none'
211
- }
212
- }
213
-
214
- _versionHistory.set(filepath, { css: result.css, js: result.js })
189
+ const result = compiler.compile(code, {
190
+ sourcePath: filepath,
191
+ platform: 'browser',
192
+ sourcemap: 'inline',
193
+ })
194
+
195
+ const changeType = _prevJs.get(filepath) === result.js ? 'none' : 'full'
196
+ _prevJs.set(filepath, result.js)
215
197
  _compileCache.set(filepath, { mtime, result })
216
198
  return { ...result, changeType }
217
199
  }
218
200
 
201
+ // ─── HTML helpers ─────────────────────────────────────────────────────────────
202
+
219
203
  function findHtml(flagHtml) {
220
204
  if (flagHtml) return flagHtml;
221
205
  const candidates = ['./index.html', './public/index.html', './src/index.html'];
222
206
  return candidates.find(p => existsSync(p)) || './index.html';
223
207
  }
224
208
 
225
- // Build importmap from package.json dependencies.
209
+ // Build an ES import map from package.json dependencies.
226
210
  // Packages with an .imba entry point are served locally; others via esm.sh.
227
211
  async function buildImportMap() {
228
212
  const imports = {
229
- "imba/runtime": "https://esm.sh/imba/runtime",
230
- "imba": "https://esm.sh/imba"
213
+ 'imba/runtime': 'https://esm.sh/imba/runtime',
214
+ 'imba': 'https://esm.sh/imba',
231
215
  };
232
216
  try {
233
217
  const pkg = JSON.parse(await Bun.file('./package.json').text());
@@ -236,50 +220,47 @@ async function buildImportMap() {
236
220
  try {
237
221
  const depPkg = JSON.parse(await Bun.file(`./node_modules/${name}/package.json`).text());
238
222
  const entry = depPkg.module || depPkg.main;
239
- if (entry && entry.endsWith('.imba')) {
240
- imports[name] = `/node_modules/${name}/${entry}`;
241
- } else {
242
- imports[name] = `https://esm.sh/${name}`;
243
- }
244
- } catch(e) {
223
+ imports[name] = (entry && entry.endsWith('.imba'))
224
+ ? `/node_modules/${name}/${entry}`
225
+ : `https://esm.sh/${name}`;
226
+ } catch(_) {
245
227
  imports[name] = `https://esm.sh/${name}`;
246
228
  }
247
229
  }
248
- } catch(e) { /* no package.json, use defaults */ }
230
+ } catch(_) { /* no package.json */ }
249
231
 
250
- const json = JSON.stringify({ imports }, null, '\t\t\t\t');
251
- return `\t\t<script type="importmap">\n\t\t\t${json}\n\t\t</script>`;
232
+ return `\t\t<script type="importmap">\n\t\t\t${JSON.stringify({ imports }, null, '\t\t\t\t')}\n\t\t</script>`;
252
233
  }
253
234
 
254
- // Transform production HTML for dev:
255
- // - removes existing importmap block
256
- // - removes <script data-bimba> from its position
257
- // - injects importmap + entrypoint script + HMR client before </head>
258
- function transformHtml(html, entrypoint, importMapTag, hmrMode) {
235
+ // Rewrite production HTML for the dev server:
236
+ // strips existing importmap + data-entrypoint script, injects importmap +
237
+ // entrypoint module + HMR client before </head>.
238
+ function transformHtml(html, entrypoint, importMapTag) {
259
239
  html = html.replace(/<script\s+type=["']importmap["'][^>]*>[\s\S]*?<\/script>/gi, '');
260
240
  html = html.replace(/<script([^>]*)\bdata-entrypoint\b([^>]*)><\/script>/gi, '');
261
-
262
241
  const entryUrl = '/' + entrypoint.replace(/^\.\//, '').replaceAll('\\', '/');
263
- const entryScript = `\t\t<script type='module' src='${entryUrl}'></script>`;
264
-
265
- const hmrClient = hmrMode === 'full' ? hmrClientFull : hmrClientCssOnly;
266
-
267
- html = html.replace('</head>', `${importMapTag}\n${entryScript}\n${hmrClient}\n\t</head>`);
242
+ html = html.replace('</head>',
243
+ `${importMapTag}\n\t\t<script type='module' src='${entryUrl}'></script>\n${hmrClient}\n\t</head>`
244
+ );
268
245
  return html;
269
246
  }
270
247
 
248
+ // ─── Dev server ───────────────────────────────────────────────────────────────
249
+
271
250
  export function serve(entrypoint, flags) {
272
- const port = flags.port || 5200
251
+ const port = flags.port || 5200
273
252
  const htmlPath = findHtml(flags.html)
274
- const htmlDir = path.dirname(htmlPath)
275
- const srcDir = path.dirname(entrypoint)
276
- const sockets = new Set()
253
+ const htmlDir = path.dirname(htmlPath)
254
+ const srcDir = path.dirname(entrypoint)
255
+ const sockets = new Set()
277
256
  let importMapTag = null
278
- const hmrMode = flags.hmrMode || 'css'
257
+
258
+ // ── Status line (prints current compile result, fades out on success) ──────
279
259
 
280
260
  let _fadeTimers = []
281
261
  let _fadeId = 0
282
262
  let _statusSaved = false
263
+ const _isTTY = process.stdout.isTTY
283
264
 
284
265
  function cancelFade() {
285
266
  _fadeTimers.forEach(t => clearTimeout(t))
@@ -287,6 +268,22 @@ export function serve(entrypoint, flags) {
287
268
  }
288
269
 
289
270
  function printStatus(file, state, errors) {
271
+ // non-TTY (pipes, Claude Code bash, CI): plain newline-terminated output,
272
+ // no ANSI cursor tricks, no fade-out — so logs stay readable.
273
+ if (!_isTTY) {
274
+ const now = new Date().toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
275
+ const tag = state === 'ok' ? 'ok' : 'fail'
276
+ process.stdout.write(` ${now} ${file} ${tag}\n`)
277
+ if (errors?.length) {
278
+ for (const err of errors) {
279
+ const msg = err.message || String(err)
280
+ const line = err.range?.start?.line
281
+ process.stdout.write(` ${msg}${line ? ` (line ${line})` : ''}\n`)
282
+ }
283
+ }
284
+ return
285
+ }
286
+
290
287
  cancelFade()
291
288
  if (_statusSaved) {
292
289
  process.stdout.write('\x1b[u\x1b[J')
@@ -294,8 +291,10 @@ export function serve(entrypoint, flags) {
294
291
  }
295
292
  const now = new Date().toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
296
293
  const status = state === 'ok' ? theme.success(' ok ') : theme.failure(' fail ')
294
+
297
295
  process.stdout.write('\x1b[s')
298
296
  _statusSaved = true
297
+
299
298
  if (errors?.length) {
300
299
  process.stdout.write(` ${theme.folder(now)} ${theme.filename(file)} ${status}\n`)
301
300
  for (const err of errors) {
@@ -304,34 +303,62 @@ export function serve(entrypoint, flags) {
304
303
  } else {
305
304
  const myId = ++_fadeId
306
305
  const plainLine = ` ${now} ${file} ok `
307
- const totalLen = plainLine.length
308
- const startDelay = 5000
309
- const charDelay = 22
310
-
306
+ const total = plainLine.length
311
307
  process.stdout.write(` ${theme.folder(now)} ${theme.filename(file)} ${status}`)
312
-
313
- for (let i = 1; i <= totalLen; i++) {
308
+ for (let i = 1; i <= total; i++) {
314
309
  _fadeTimers.push(setTimeout(() => {
315
310
  if (_fadeId !== myId) return
316
311
  process.stdout.write('\x1b[1D \x1b[1D')
317
- if (i === totalLen) {
318
- _statusSaved = false
319
- }
320
- }, startDelay + i * charDelay))
312
+ if (i === total) _statusSaved = false
313
+ }, 5000 + i * 22))
321
314
  }
322
315
  }
323
316
  }
324
317
 
318
+ // ── File watcher ───────────────────────────────────────────────────────────
319
+
320
+ function broadcast(payload) {
321
+ const msg = JSON.stringify(payload)
322
+ for (const socket of sockets) socket.send(msg)
323
+ }
324
+
325
325
  const _debounce = new Map()
326
- watch(srcDir, { recursive: true }, (_event, filename) => {
326
+
327
+ watch(srcDir, { recursive: true }, async (_event, filename) => {
327
328
  if (!filename || !filename.endsWith('.imba')) return
328
329
  if (_debounce.has(filename)) return
329
330
  _debounce.set(filename, setTimeout(() => _debounce.delete(filename), 50))
331
+
332
+ const filepath = path.join(srcDir, filename)
330
333
  const rel = path.join(path.relative('.', srcDir), filename).replaceAll('\\', '/')
331
- for (const socket of sockets)
332
- socket.send(JSON.stringify({ type: 'update', file: rel }))
334
+
335
+ try {
336
+ const out = await compileFile(filepath)
337
+
338
+ if (out.errors?.length) {
339
+ printStatus(rel, 'fail', out.errors)
340
+ broadcast({ type: 'error', file: rel, errors: out.errors.map(e => ({
341
+ message: e.message,
342
+ line: e.range?.start?.line,
343
+ snippet: e.toSnippet(),
344
+ })) })
345
+ return
346
+ }
347
+
348
+ // No change at all — skip
349
+ if (out.changeType === 'none' || out.changeType === 'cached') return
350
+
351
+ printStatus(rel, 'ok')
352
+ broadcast({ type: 'clear-error' })
353
+ broadcast({ type: 'update', file: rel })
354
+ } catch(e) {
355
+ printStatus(rel, 'fail', [{ message: e.message }])
356
+ broadcast({ type: 'error', file: rel, errors: [{ message: e.message, snippet: e.stack || e.message }] })
357
+ }
333
358
  })
334
359
 
360
+ // ── HTTP + WebSocket server ────────────────────────────────────────────────
361
+
335
362
  bunServe({
336
363
  port,
337
364
  development: true,
@@ -340,84 +367,72 @@ export function serve(entrypoint, flags) {
340
367
  const url = new URL(req.url)
341
368
  const pathname = url.pathname
342
369
 
370
+ // WebSocket upgrade for HMR
343
371
  if (pathname === '/__hmr__') {
344
372
  if (server.upgrade(req)) return undefined
345
373
  }
346
374
 
375
+ // HTML: index or any .html file
347
376
  if (pathname === '/' || pathname.endsWith('.html')) {
348
377
  const htmlFile = pathname === '/' ? htmlPath : '.' + pathname
349
378
  let html = await Bun.file(htmlFile).text()
350
379
  if (!importMapTag) importMapTag = await buildImportMap()
351
- html = transformHtml(html, entrypoint, importMapTag, hmrMode)
352
- return new Response(html, { headers: { 'Content-Type': 'text/html' } })
380
+ return new Response(transformHtml(html, entrypoint, importMapTag), {
381
+ headers: { 'Content-Type': 'text/html' },
382
+ })
353
383
  }
354
384
 
385
+ // Imba files: compile on demand and serve as JS
355
386
  if (pathname.endsWith('.imba')) {
387
+ const filepath = '.' + pathname
356
388
  try {
357
- const out = await compileFile('.' + pathname)
358
- const file = pathname.replace(/^\//, '')
389
+ const out = await compileFile(filepath)
359
390
  if (out.errors?.length) {
360
- if (!out.cached) {
361
- printStatus(file, 'fail', out.errors)
362
- const payload = JSON.stringify({ type: 'error', file, errors: out.errors.map(e => ({ message: e.message, line: e.range?.start?.line, snippet: e.toSnippet() })) })
363
- for (const socket of sockets) socket.send(payload)
364
- }
391
+ const file = pathname.replace(/^\//, '')
392
+ printStatus(file, 'fail', out.errors)
393
+ broadcast({ type: 'error', file, errors: out.errors.map(e => ({
394
+ message: e.message,
395
+ line: e.range?.start?.line,
396
+ snippet: e.toSnippet(),
397
+ })) })
365
398
  return new Response(out.errors.map(e => e.message).join('\n'), { status: 500 })
366
399
  }
367
- if (!out.cached) {
368
- printStatus(file, 'ok')
369
- for (const socket of sockets) socket.send(JSON.stringify({ type: 'clear-error' }))
370
-
371
- // Send appropriate update type based on HMR mode and what changed
372
- if (hmrMode === 'full') {
373
- // Full mode: send 'update' for prototype swapping (CSS is included in JS via runtime)
374
- for (const socket of sockets) socket.send(JSON.stringify({ type: 'update', file }))
375
- } else {
376
- // CSS-only mode
377
- if (out.changeType === 'css-only') {
378
- // Only CSS changed: inject styles without reload
379
- for (const socket of sockets) socket.send(JSON.stringify({ type: 'css-update', file, css: out.css }))
380
- } else if (out.changeType === 'full') {
381
- // JS changed in CSS-only mode: full page reload
382
- for (const socket of sockets) socket.send(JSON.stringify({ type: 'reload' }))
383
- }
384
- // 'none' change type: do nothing, already up-to-date
385
- }
386
- }
387
400
  return new Response(out.js, { headers: { 'Content-Type': 'application/javascript' } })
388
- } catch (e) {
401
+ } catch(e) {
389
402
  const file = pathname.replace(/^\//, '')
390
403
  printStatus(file, 'fail', [{ message: e.message }])
391
- const payload = JSON.stringify({ type: 'error', file, errors: [{ message: e.message, snippet: e.stack || e.message }] })
392
- for (const socket of sockets) socket.send(payload)
404
+ broadcast({ type: 'error', file, errors: [{ message: e.message, snippet: e.stack || e.message }] })
393
405
  return new Response(e.message, { status: 500 })
394
406
  }
395
407
  }
396
408
 
397
- // Static files: check htmlDir first (assets relative to HTML), then root (node_modules, src, etc.)
398
- const htmlDirFile = Bun.file(path.join(htmlDir, pathname))
399
- if (await htmlDirFile.exists()) return new Response(htmlDirFile)
400
- const file = Bun.file('.' + pathname)
401
- if (await file.exists()) return new Response(file)
409
+ // Static files: check htmlDir first (for assets relative to HTML), then root
410
+ const inHtmlDir = Bun.file(path.join(htmlDir, pathname))
411
+ if (await inHtmlDir.exists()) return new Response(inHtmlDir)
412
+ const inRoot = Bun.file('.' + pathname)
413
+ if (await inRoot.exists()) return new Response(inRoot)
402
414
 
403
- // SPA fallback: serve index.html only for URL-like paths (no file extension)
415
+ // SPA fallback for extension-less paths
404
416
  const lastSegment = pathname.split('/').pop()
405
417
  if (!lastSegment.includes('.')) {
406
418
  let html = await Bun.file(htmlPath).text()
407
419
  if (!importMapTag) importMapTag = await buildImportMap()
408
- html = transformHtml(html, entrypoint, importMapTag, hmrMode)
409
- return new Response(html, { headers: { 'Content-Type': 'text/html' } })
420
+ return new Response(transformHtml(html, entrypoint, importMapTag), {
421
+ headers: { 'Content-Type': 'text/html' },
422
+ })
410
423
  }
424
+
411
425
  return new Response('Not Found', { status: 404 })
412
426
  },
413
427
 
414
428
  websocket: {
415
- open: (ws) => sockets.add(ws),
416
- close: (ws) => sockets.delete(ws),
417
- }
429
+ open: ws => sockets.add(ws),
430
+ close: ws => sockets.delete(ws),
431
+ message: () => {},
432
+ },
418
433
  })
419
434
 
420
435
  console.log(theme.folder('──────────────────────────────────────────────────────────────────────'))
421
- console.log(theme.start(`Dev server running at `) + theme.success(`http://localhost:${port}`))
436
+ console.log(theme.start('Dev server running at ') + theme.success(`http://localhost:${port}`))
422
437
  console.log(theme.folder('──────────────────────────────────────────────────────────────────────'))
423
438
  }