bimba-cli 0.4.9 → 0.5.1

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.
Files changed (3) hide show
  1. package/README.md +12 -0
  2. package/package.json +1 -1
  3. package/serve.js +248 -141
package/README.md CHANGED
@@ -40,6 +40,18 @@ bunx bimba src/index.imba --serve --port 5200 --html public/index.html
40
40
  - Injects an importmap built from your `package.json` dependencies
41
41
  - Injects an HMR client that swaps component prototypes without a full page reload
42
42
 
43
+ **HMR internals:**
44
+
45
+ When a file changes, the server recompiles it and sends an `update` message over WebSocket. The browser re-imports the module with a fresh `?t=` cache-bust query.
46
+
47
+ Since Imba custom elements can't be registered twice (`customElements.define` throws on duplicates), bimba intercepts all `define` calls. On first load the class is registered normally and stored in a map. On hot reload, instead of registering again, bimba copies all methods and static properties from the new class onto the original class prototype — so existing element instances in the DOM immediately get the new `render()` and other methods without losing their state (`el.active`, `el.count`, etc.).
48
+
49
+ After patching, bimba clears each element's Imba render cache (anonymous `Symbol` keys pointing to DOM nodes) and sets `innerHTML = ''`, so the new render method starts from a clean slate. Then `imba.commit()` triggers a re-render of all mounted components.
50
+
51
+ CSS is handled automatically: Imba's runtime calls `imba_styles.register()` during module execution, which updates the `<style>` tag in place — no extra DOM work needed.
52
+
53
+ Duplicate root elements (caused by `imba.mount()` running again on re-import) are removed by a dedup pass over `document.body.children` before any other HMR logic runs.
54
+
43
55
  **HTML setup:** add a `data-entrypoint` attribute to the script tag that loads your bundle. The dev server will replace it with your `.imba` entrypoint and inject the importmap above it:
44
56
 
45
57
  ```html
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bimba-cli",
3
- "version": "0.4.9",
3
+ "version": "0.5.1",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/HeapVoid/bimba.git"
package/serve.js CHANGED
@@ -5,54 +5,153 @@ import path from 'path'
5
5
  import { theme } from './utils.js'
6
6
  import { printerr } from './plugin.js'
7
7
 
8
+ // ─── HMR Client (injected into browser) ──────────────────────────────────────
9
+
8
10
  const hmrClient = `
9
11
  <script>
10
- const _originalDefine = customElements.define.bind(customElements);
11
- const _registry = new Map();
12
- const _updated = new Set();
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
13
29
 
14
30
  customElements.define = function(name, cls, opts) {
15
- const existing = _registry.get(name);
16
- if (existing) {
17
- Object.getOwnPropertyNames(cls.prototype).forEach(key => {
18
- if (key === 'constructor') return;
19
- try { Object.defineProperty(existing.prototype, key, Object.getOwnPropertyDescriptor(cls.prototype, key)); } catch(e) {}
20
- });
21
- Object.getOwnPropertyNames(cls).forEach(key => {
22
- if (['length','name','prototype','arguments','caller'].includes(key)) return;
23
- try { Object.defineProperty(existing, key, Object.getOwnPropertyDescriptor(cls, key)); } catch(e) {}
24
- });
25
- _updated.add(name);
31
+ _hotTags.push(name);
32
+ const existing = customElements.get(name);
33
+ if (!existing) {
34
+ _origDefine(name, cls, opts);
35
+ _classes.set(name, cls);
26
36
  } else {
27
- _registry.set(name, cls);
28
- _originalDefine(name, cls, opts);
37
+ const target = _classes.get(name);
38
+ if (target) _patchClass(target, cls);
29
39
  }
30
40
  };
31
41
 
32
- function resetElement(el) {
33
- Object.getOwnPropertySymbols(el).forEach(s => {
34
- try { if (el[s] instanceof Node) el[s] = undefined; } catch(e) {}
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
+ }
35
112
  });
36
- el.innerHTML = '';
37
113
  }
38
114
 
115
+ // ── WebSocket connection ───────────────────────────────────────────────────
116
+
39
117
  let _connected = false;
40
- let _overlay = null;
118
+
119
+ function connect() {
120
+ const ws = new WebSocket('ws://' + location.host + '/__hmr__');
121
+
122
+ ws.onopen = () => {
123
+ // If we reconnect after a disconnect, reload to get fresh state
124
+ if (_connected) location.reload();
125
+ else _connected = true;
126
+ };
127
+
128
+ ws.onmessage = (e) => {
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();
134
+ };
135
+
136
+ ws.onclose = () => setTimeout(connect, 1000);
137
+ }
138
+
139
+ // ── Error overlay ──────────────────────────────────────────────────────────
41
140
 
42
141
  function showError(file, errors) {
43
- if (!_overlay) {
44
- _overlay = document.createElement('div');
45
- _overlay.id = '__bimba_error__';
46
- const s = _overlay.style;
47
- 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';
48
- _overlay.addEventListener('click', (e) => { if (e.target === _overlay) clearError(); });
49
- document.body.appendChild(_overlay);
142
+ let overlay = document.getElementById('__bimba_error__');
143
+ if (!overlay) {
144
+ overlay = document.createElement('div');
145
+ overlay.id = '__bimba_error__';
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';
147
+ overlay.addEventListener('click', e => { if (e.target === overlay) overlay.remove(); });
148
+ document.body.appendChild(overlay);
50
149
  }
51
- _overlay.innerHTML = \`
150
+ overlay.innerHTML = \`
52
151
  <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)">
53
152
  <div style="background:#ff4444;color:#fff;padding:10px 16px;font-size:13px;font-weight:600;display:flex;justify-content:space-between;align-items:center">
54
153
  <span>Compile error — \${file}</span>
55
- <span onclick="document.getElementById('__bimba_error__').remove();document.getElementById('__bimba_error__')&&(window.__bimba_overlay__=null)" style="cursor:pointer;opacity:.7;font-size:16px">✕</span>
154
+ <span onclick="document.getElementById('__bimba_error__').remove()" style="cursor:pointer;opacity:.7;font-size:16px">✕</span>
56
155
  </div>
57
156
  \${errors.map(err => \`
58
157
  <div style="padding:16px;border-bottom:1px solid #333">
@@ -65,79 +164,54 @@ const hmrClient = `
65
164
  }
66
165
 
67
166
  function clearError() {
68
- if (_overlay) { _overlay.remove(); _overlay = null; }
69
- }
70
-
71
- function connect() {
72
- const ws = new WebSocket('ws://' + location.host + '/__hmr__');
73
- ws.onopen = () => {
74
- if (_connected) {
75
- location.reload();
76
- } else {
77
- _connected = true;
78
- }
79
- };
80
- ws.onmessage = (e) => {
81
- const data = JSON.parse(e.data);
82
- if (data.type === 'update') {
83
- clearError();
84
- _updated.clear();
85
- import('/' + data.file + '?t=' + Date.now()).then(() => {
86
- const updatedClasses = [..._updated].map(n => _registry.get(n)).filter(Boolean);
87
- const found = [];
88
- if (updatedClasses.length) {
89
- document.querySelectorAll('*').forEach(el => {
90
- for (const cls of updatedClasses) {
91
- if (el instanceof cls) { found.push(el); break; }
92
- }
93
- });
94
- }
95
- found.forEach(resetElement);
96
- _updated.clear();
97
- imba.commit();
98
- });
99
- } else if (data.type === 'reload') {
100
- location.reload();
101
- } else if (data.type === 'error') {
102
- showError(data.file, data.errors);
103
- } else if (data.type === 'clear-error') {
104
- clearError();
105
- }
106
- };
107
- ws.onclose = () => {
108
- setTimeout(connect, 1000);
109
- };
167
+ const overlay = document.getElementById('__bimba_error__');
168
+ if (overlay) overlay.remove();
110
169
  }
111
170
 
112
171
  connect();
172
+ })();
113
173
  </script>`
114
174
 
115
- const _compileCache = new Map()
175
+ // ─── Server-side compile cache ────────────────────────────────────────────────
176
+
177
+ const _compileCache = new Map() // filepath → { mtime, result }
178
+ const _prevJs = new Map() // filepath → compiled js — for change detection
116
179
 
117
180
  async function compileFile(filepath) {
118
181
  const file = Bun.file(filepath)
119
182
  const stat = await file.stat()
120
183
  const mtime = stat.mtime.getTime()
184
+
121
185
  const cached = _compileCache.get(filepath)
122
- if (cached && cached.mtime === mtime) return { ...cached.result, cached: true }
186
+ if (cached && cached.mtime === mtime) return { ...cached.result, changeType: 'cached' }
187
+
123
188
  const code = await file.text()
124
- const result = compiler.compile(code, { sourcePath: filepath, platform: 'browser', sourcemap: 'inline' })
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)
125
197
  _compileCache.set(filepath, { mtime, result })
126
- return result
198
+ return { ...result, changeType }
127
199
  }
128
200
 
201
+ // ─── HTML helpers ─────────────────────────────────────────────────────────────
202
+
129
203
  function findHtml(flagHtml) {
130
204
  if (flagHtml) return flagHtml;
131
205
  const candidates = ['./index.html', './public/index.html', './src/index.html'];
132
206
  return candidates.find(p => existsSync(p)) || './index.html';
133
207
  }
134
208
 
135
- // Build importmap from package.json dependencies.
209
+ // Build an ES import map from package.json dependencies.
136
210
  // Packages with an .imba entry point are served locally; others via esm.sh.
137
211
  async function buildImportMap() {
138
212
  const imports = {
139
- "imba/runtime": "https://esm.sh/imba/runtime",
140
- "imba": "https://esm.sh/imba"
213
+ 'imba/runtime': 'https://esm.sh/imba/runtime',
214
+ 'imba': 'https://esm.sh/imba',
141
215
  };
142
216
  try {
143
217
  const pkg = JSON.parse(await Bun.file('./package.json').text());
@@ -146,44 +220,43 @@ async function buildImportMap() {
146
220
  try {
147
221
  const depPkg = JSON.parse(await Bun.file(`./node_modules/${name}/package.json`).text());
148
222
  const entry = depPkg.module || depPkg.main;
149
- if (entry && entry.endsWith('.imba')) {
150
- imports[name] = `/node_modules/${name}/${entry}`;
151
- } else {
152
- imports[name] = `https://esm.sh/${name}`;
153
- }
154
- } catch(e) {
223
+ imports[name] = (entry && entry.endsWith('.imba'))
224
+ ? `/node_modules/${name}/${entry}`
225
+ : `https://esm.sh/${name}`;
226
+ } catch(_) {
155
227
  imports[name] = `https://esm.sh/${name}`;
156
228
  }
157
229
  }
158
- } catch(e) { /* no package.json, use defaults */ }
230
+ } catch(_) { /* no package.json */ }
159
231
 
160
- const json = JSON.stringify({ imports }, null, '\t\t\t\t');
161
- 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>`;
162
233
  }
163
234
 
164
- // Transform production HTML for dev:
165
- // - removes existing importmap block
166
- // - removes <script data-bimba> from its position
167
- // - injects importmap + entrypoint script + HMR client before </head>
235
+ // Rewrite production HTML for the dev server:
236
+ // strips existing importmap + data-entrypoint script, injects importmap +
237
+ // entrypoint module + HMR client before </head>.
168
238
  function transformHtml(html, entrypoint, importMapTag) {
169
239
  html = html.replace(/<script\s+type=["']importmap["'][^>]*>[\s\S]*?<\/script>/gi, '');
170
240
  html = html.replace(/<script([^>]*)\bdata-entrypoint\b([^>]*)><\/script>/gi, '');
171
-
172
241
  const entryUrl = '/' + entrypoint.replace(/^\.\//, '').replaceAll('\\', '/');
173
- const entryScript = `\t\t<script type='module' src='${entryUrl}'></script>`;
174
-
175
- 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
+ );
176
245
  return html;
177
246
  }
178
247
 
248
+ // ─── Dev server ───────────────────────────────────────────────────────────────
249
+
179
250
  export function serve(entrypoint, flags) {
180
- const port = flags.port || 5200
251
+ const port = flags.port || 5200
181
252
  const htmlPath = findHtml(flags.html)
182
- const htmlDir = path.dirname(htmlPath)
183
- const srcDir = path.dirname(entrypoint)
184
- const sockets = new Set()
253
+ const htmlDir = path.dirname(htmlPath)
254
+ const srcDir = path.dirname(entrypoint)
255
+ const sockets = new Set()
185
256
  let importMapTag = null
186
257
 
258
+ // ── Status line (prints current compile result, fades out on success) ──────
259
+
187
260
  let _fadeTimers = []
188
261
  let _fadeId = 0
189
262
  let _statusSaved = false
@@ -201,8 +274,10 @@ export function serve(entrypoint, flags) {
201
274
  }
202
275
  const now = new Date().toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
203
276
  const status = state === 'ok' ? theme.success(' ok ') : theme.failure(' fail ')
277
+
204
278
  process.stdout.write('\x1b[s')
205
279
  _statusSaved = true
280
+
206
281
  if (errors?.length) {
207
282
  process.stdout.write(` ${theme.folder(now)} ${theme.filename(file)} ${status}\n`)
208
283
  for (const err of errors) {
@@ -211,34 +286,62 @@ export function serve(entrypoint, flags) {
211
286
  } else {
212
287
  const myId = ++_fadeId
213
288
  const plainLine = ` ${now} ${file} ok `
214
- const totalLen = plainLine.length
215
- const startDelay = 5000
216
- const charDelay = 22
217
-
289
+ const total = plainLine.length
218
290
  process.stdout.write(` ${theme.folder(now)} ${theme.filename(file)} ${status}`)
219
-
220
- for (let i = 1; i <= totalLen; i++) {
291
+ for (let i = 1; i <= total; i++) {
221
292
  _fadeTimers.push(setTimeout(() => {
222
293
  if (_fadeId !== myId) return
223
294
  process.stdout.write('\x1b[1D \x1b[1D')
224
- if (i === totalLen) {
225
- _statusSaved = false
226
- }
227
- }, startDelay + i * charDelay))
295
+ if (i === total) _statusSaved = false
296
+ }, 5000 + i * 22))
228
297
  }
229
298
  }
230
299
  }
231
300
 
301
+ // ── File watcher ───────────────────────────────────────────────────────────
302
+
303
+ function broadcast(payload) {
304
+ const msg = JSON.stringify(payload)
305
+ for (const socket of sockets) socket.send(msg)
306
+ }
307
+
232
308
  const _debounce = new Map()
233
- watch(srcDir, { recursive: true }, (_event, filename) => {
309
+
310
+ watch(srcDir, { recursive: true }, async (_event, filename) => {
234
311
  if (!filename || !filename.endsWith('.imba')) return
235
312
  if (_debounce.has(filename)) return
236
313
  _debounce.set(filename, setTimeout(() => _debounce.delete(filename), 50))
314
+
315
+ const filepath = path.join(srcDir, filename)
237
316
  const rel = path.join(path.relative('.', srcDir), filename).replaceAll('\\', '/')
238
- for (const socket of sockets)
239
- socket.send(JSON.stringify({ type: 'update', file: rel }))
317
+
318
+ try {
319
+ const out = await compileFile(filepath)
320
+
321
+ if (out.errors?.length) {
322
+ printStatus(rel, 'fail', out.errors)
323
+ broadcast({ type: 'error', file: rel, errors: out.errors.map(e => ({
324
+ message: e.message,
325
+ line: e.range?.start?.line,
326
+ snippet: e.toSnippet(),
327
+ })) })
328
+ return
329
+ }
330
+
331
+ // No change at all — skip
332
+ if (out.changeType === 'none' || out.changeType === 'cached') return
333
+
334
+ printStatus(rel, 'ok')
335
+ broadcast({ type: 'clear-error' })
336
+ broadcast({ type: 'update', file: rel })
337
+ } catch(e) {
338
+ printStatus(rel, 'fail', [{ message: e.message }])
339
+ broadcast({ type: 'error', file: rel, errors: [{ message: e.message, snippet: e.stack || e.message }] })
340
+ }
240
341
  })
241
342
 
343
+ // ── HTTP + WebSocket server ────────────────────────────────────────────────
344
+
242
345
  bunServe({
243
346
  port,
244
347
  development: true,
@@ -247,68 +350,72 @@ export function serve(entrypoint, flags) {
247
350
  const url = new URL(req.url)
248
351
  const pathname = url.pathname
249
352
 
353
+ // WebSocket upgrade for HMR
250
354
  if (pathname === '/__hmr__') {
251
355
  if (server.upgrade(req)) return undefined
252
356
  }
253
357
 
358
+ // HTML: index or any .html file
254
359
  if (pathname === '/' || pathname.endsWith('.html')) {
255
360
  const htmlFile = pathname === '/' ? htmlPath : '.' + pathname
256
361
  let html = await Bun.file(htmlFile).text()
257
362
  if (!importMapTag) importMapTag = await buildImportMap()
258
- html = transformHtml(html, entrypoint, importMapTag)
259
- return new Response(html, { headers: { 'Content-Type': 'text/html' } })
363
+ return new Response(transformHtml(html, entrypoint, importMapTag), {
364
+ headers: { 'Content-Type': 'text/html' },
365
+ })
260
366
  }
261
367
 
368
+ // Imba files: compile on demand and serve as JS
262
369
  if (pathname.endsWith('.imba')) {
370
+ const filepath = '.' + pathname
263
371
  try {
264
- const out = await compileFile('.' + pathname)
265
- const file = pathname.replace(/^\//, '')
372
+ const out = await compileFile(filepath)
266
373
  if (out.errors?.length) {
267
- if (!out.cached) {
268
- printStatus(file, 'fail', out.errors)
269
- const payload = JSON.stringify({ type: 'error', file, errors: out.errors.map(e => ({ message: e.message, line: e.range?.start?.line, snippet: e.toSnippet() })) })
270
- for (const socket of sockets) socket.send(payload)
271
- }
374
+ const file = pathname.replace(/^\//, '')
375
+ printStatus(file, 'fail', out.errors)
376
+ broadcast({ type: 'error', file, errors: out.errors.map(e => ({
377
+ message: e.message,
378
+ line: e.range?.start?.line,
379
+ snippet: e.toSnippet(),
380
+ })) })
272
381
  return new Response(out.errors.map(e => e.message).join('\n'), { status: 500 })
273
382
  }
274
- if (!out.cached) {
275
- printStatus(file, 'ok')
276
- for (const socket of sockets) socket.send(JSON.stringify({ type: 'clear-error' }))
277
- }
278
383
  return new Response(out.js, { headers: { 'Content-Type': 'application/javascript' } })
279
- } catch (e) {
384
+ } catch(e) {
280
385
  const file = pathname.replace(/^\//, '')
281
386
  printStatus(file, 'fail', [{ message: e.message }])
282
- const payload = JSON.stringify({ type: 'error', file, errors: [{ message: e.message, snippet: e.stack || e.message }] })
283
- for (const socket of sockets) socket.send(payload)
387
+ broadcast({ type: 'error', file, errors: [{ message: e.message, snippet: e.stack || e.message }] })
284
388
  return new Response(e.message, { status: 500 })
285
389
  }
286
390
  }
287
391
 
288
- // Static files: check htmlDir first (assets relative to HTML), then root (node_modules, src, etc.)
289
- const htmlDirFile = Bun.file(path.join(htmlDir, pathname))
290
- if (await htmlDirFile.exists()) return new Response(htmlDirFile)
291
- const file = Bun.file('.' + pathname)
292
- if (await file.exists()) return new Response(file)
392
+ // Static files: check htmlDir first (for assets relative to HTML), then root
393
+ const inHtmlDir = Bun.file(path.join(htmlDir, pathname))
394
+ if (await inHtmlDir.exists()) return new Response(inHtmlDir)
395
+ const inRoot = Bun.file('.' + pathname)
396
+ if (await inRoot.exists()) return new Response(inRoot)
293
397
 
294
- // SPA fallback: serve index.html only for URL-like paths (no file extension)
398
+ // SPA fallback for extension-less paths
295
399
  const lastSegment = pathname.split('/').pop()
296
400
  if (!lastSegment.includes('.')) {
297
401
  let html = await Bun.file(htmlPath).text()
298
402
  if (!importMapTag) importMapTag = await buildImportMap()
299
- html = transformHtml(html, entrypoint, importMapTag)
300
- return new Response(html, { headers: { 'Content-Type': 'text/html' } })
403
+ return new Response(transformHtml(html, entrypoint, importMapTag), {
404
+ headers: { 'Content-Type': 'text/html' },
405
+ })
301
406
  }
407
+
302
408
  return new Response('Not Found', { status: 404 })
303
409
  },
304
410
 
305
411
  websocket: {
306
- open: (ws) => sockets.add(ws),
307
- close: (ws) => sockets.delete(ws),
308
- }
412
+ open: ws => sockets.add(ws),
413
+ close: ws => sockets.delete(ws),
414
+ message: () => {},
415
+ },
309
416
  })
310
417
 
311
418
  console.log(theme.folder('──────────────────────────────────────────────────────────────────────'))
312
- console.log(theme.start(`Dev server running at `) + theme.success(`http://localhost:${port}`))
419
+ console.log(theme.start('Dev server running at ') + theme.success(`http://localhost:${port}`))
313
420
  console.log(theme.folder('──────────────────────────────────────────────────────────────────────'))
314
421
  }