bimba-cli 0.5.0 → 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.
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
- }
141
-
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>`
175
+ // ─── Server-side compile cache ────────────────────────────────────────────────
185
176
 
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,46 +220,42 @@ 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
@@ -294,8 +274,10 @@ export function serve(entrypoint, flags) {
294
274
  }
295
275
  const now = new Date().toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
296
276
  const status = state === 'ok' ? theme.success(' ok ') : theme.failure(' fail ')
277
+
297
278
  process.stdout.write('\x1b[s')
298
279
  _statusSaved = true
280
+
299
281
  if (errors?.length) {
300
282
  process.stdout.write(` ${theme.folder(now)} ${theme.filename(file)} ${status}\n`)
301
283
  for (const err of errors) {
@@ -304,34 +286,62 @@ export function serve(entrypoint, flags) {
304
286
  } else {
305
287
  const myId = ++_fadeId
306
288
  const plainLine = ` ${now} ${file} ok `
307
- const totalLen = plainLine.length
308
- const startDelay = 5000
309
- const charDelay = 22
310
-
289
+ const total = plainLine.length
311
290
  process.stdout.write(` ${theme.folder(now)} ${theme.filename(file)} ${status}`)
312
-
313
- for (let i = 1; i <= totalLen; i++) {
291
+ for (let i = 1; i <= total; i++) {
314
292
  _fadeTimers.push(setTimeout(() => {
315
293
  if (_fadeId !== myId) return
316
294
  process.stdout.write('\x1b[1D \x1b[1D')
317
- if (i === totalLen) {
318
- _statusSaved = false
319
- }
320
- }, startDelay + i * charDelay))
295
+ if (i === total) _statusSaved = false
296
+ }, 5000 + i * 22))
321
297
  }
322
298
  }
323
299
  }
324
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
+
325
308
  const _debounce = new Map()
326
- watch(srcDir, { recursive: true }, (_event, filename) => {
309
+
310
+ watch(srcDir, { recursive: true }, async (_event, filename) => {
327
311
  if (!filename || !filename.endsWith('.imba')) return
328
312
  if (_debounce.has(filename)) return
329
313
  _debounce.set(filename, setTimeout(() => _debounce.delete(filename), 50))
314
+
315
+ const filepath = path.join(srcDir, filename)
330
316
  const rel = path.join(path.relative('.', srcDir), filename).replaceAll('\\', '/')
331
- for (const socket of sockets)
332
- 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
+ }
333
341
  })
334
342
 
343
+ // ── HTTP + WebSocket server ────────────────────────────────────────────────
344
+
335
345
  bunServe({
336
346
  port,
337
347
  development: true,
@@ -340,84 +350,72 @@ export function serve(entrypoint, flags) {
340
350
  const url = new URL(req.url)
341
351
  const pathname = url.pathname
342
352
 
353
+ // WebSocket upgrade for HMR
343
354
  if (pathname === '/__hmr__') {
344
355
  if (server.upgrade(req)) return undefined
345
356
  }
346
357
 
358
+ // HTML: index or any .html file
347
359
  if (pathname === '/' || pathname.endsWith('.html')) {
348
360
  const htmlFile = pathname === '/' ? htmlPath : '.' + pathname
349
361
  let html = await Bun.file(htmlFile).text()
350
362
  if (!importMapTag) importMapTag = await buildImportMap()
351
- html = transformHtml(html, entrypoint, importMapTag, hmrMode)
352
- return new Response(html, { headers: { 'Content-Type': 'text/html' } })
363
+ return new Response(transformHtml(html, entrypoint, importMapTag), {
364
+ headers: { 'Content-Type': 'text/html' },
365
+ })
353
366
  }
354
367
 
368
+ // Imba files: compile on demand and serve as JS
355
369
  if (pathname.endsWith('.imba')) {
370
+ const filepath = '.' + pathname
356
371
  try {
357
- const out = await compileFile('.' + pathname)
358
- const file = pathname.replace(/^\//, '')
372
+ const out = await compileFile(filepath)
359
373
  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
- }
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
+ })) })
365
381
  return new Response(out.errors.map(e => e.message).join('\n'), { status: 500 })
366
382
  }
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
383
  return new Response(out.js, { headers: { 'Content-Type': 'application/javascript' } })
388
- } catch (e) {
384
+ } catch(e) {
389
385
  const file = pathname.replace(/^\//, '')
390
386
  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)
387
+ broadcast({ type: 'error', file, errors: [{ message: e.message, snippet: e.stack || e.message }] })
393
388
  return new Response(e.message, { status: 500 })
394
389
  }
395
390
  }
396
391
 
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)
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)
402
397
 
403
- // SPA fallback: serve index.html only for URL-like paths (no file extension)
398
+ // SPA fallback for extension-less paths
404
399
  const lastSegment = pathname.split('/').pop()
405
400
  if (!lastSegment.includes('.')) {
406
401
  let html = await Bun.file(htmlPath).text()
407
402
  if (!importMapTag) importMapTag = await buildImportMap()
408
- html = transformHtml(html, entrypoint, importMapTag, hmrMode)
409
- return new Response(html, { headers: { 'Content-Type': 'text/html' } })
403
+ return new Response(transformHtml(html, entrypoint, importMapTag), {
404
+ headers: { 'Content-Type': 'text/html' },
405
+ })
410
406
  }
407
+
411
408
  return new Response('Not Found', { status: 404 })
412
409
  },
413
410
 
414
411
  websocket: {
415
- open: (ws) => sockets.add(ws),
416
- close: (ws) => sockets.delete(ws),
417
- }
412
+ open: ws => sockets.add(ws),
413
+ close: ws => sockets.delete(ws),
414
+ message: () => {},
415
+ },
418
416
  })
419
417
 
420
418
  console.log(theme.folder('──────────────────────────────────────────────────────────────────────'))
421
- 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}`))
422
420
  console.log(theme.folder('──────────────────────────────────────────────────────────────────────'))
423
421
  }