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.
- package/README.md +12 -0
- package/package.json +1 -1
- 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
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
28
|
-
|
|
37
|
+
const target = _classes.get(name);
|
|
38
|
+
if (target) _patchClass(target, cls);
|
|
29
39
|
}
|
|
30
40
|
};
|
|
31
41
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
document.body.appendChild(
|
|
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
|
-
|
|
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()
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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, {
|
|
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
|
|
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
|
-
|
|
140
|
-
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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(
|
|
230
|
+
} catch(_) { /* no package.json */ }
|
|
159
231
|
|
|
160
|
-
|
|
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
|
-
//
|
|
165
|
-
// -
|
|
166
|
-
//
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
|
251
|
+
const port = flags.port || 5200
|
|
181
252
|
const htmlPath = findHtml(flags.html)
|
|
182
|
-
const htmlDir
|
|
183
|
-
const srcDir
|
|
184
|
-
const sockets
|
|
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
|
|
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 ===
|
|
225
|
-
|
|
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
|
-
|
|
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
|
-
|
|
239
|
-
|
|
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
|
-
|
|
259
|
-
|
|
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(
|
|
265
|
-
const file = pathname.replace(/^\//, '')
|
|
372
|
+
const out = await compileFile(filepath)
|
|
266
373
|
if (out.errors?.length) {
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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
|
|
384
|
+
} catch(e) {
|
|
280
385
|
const file = pathname.replace(/^\//, '')
|
|
281
386
|
printStatus(file, 'fail', [{ message: e.message }])
|
|
282
|
-
|
|
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
|
|
289
|
-
const
|
|
290
|
-
if (await
|
|
291
|
-
const
|
|
292
|
-
if (await
|
|
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
|
|
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
|
-
|
|
300
|
-
|
|
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:
|
|
307
|
-
close:
|
|
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(
|
|
419
|
+
console.log(theme.start('Dev server running at ') + theme.success(`http://localhost:${port}`))
|
|
313
420
|
console.log(theme.folder('──────────────────────────────────────────────────────────────────────'))
|
|
314
421
|
}
|