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/README.md +12 -8
- package/index.js +1 -11
- package/package.json +1 -1
- package/serve.js +239 -241
- package/.claude/agents/kfc/spec-design.md +0 -158
- package/.claude/agents/kfc/spec-impl.md +0 -39
- package/.claude/agents/kfc/spec-judge.md +0 -125
- package/.claude/agents/kfc/spec-requirements.md +0 -123
- package/.claude/agents/kfc/spec-system-prompt-loader.md +0 -38
- package/.claude/agents/kfc/spec-tasks.md +0 -183
- package/.claude/agents/kfc/spec-test.md +0 -108
- package/.claude/settings/kfc-settings.json +0 -24
- package/.claude/system-prompts/spec-workflow-starter.md +0 -306
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
|
|
9
|
-
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
|
25
|
-
if
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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',
|
|
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
|
-
//
|
|
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,'<')}</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
|
-
//
|
|
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,
|
|
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, {
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
|
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
|
-
|
|
230
|
-
|
|
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
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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(
|
|
230
|
+
} catch(_) { /* no package.json */ }
|
|
249
231
|
|
|
250
|
-
|
|
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
|
-
//
|
|
255
|
-
// -
|
|
256
|
-
//
|
|
257
|
-
|
|
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
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
|
251
|
+
const port = flags.port || 5200
|
|
273
252
|
const htmlPath = findHtml(flags.html)
|
|
274
|
-
const htmlDir
|
|
275
|
-
const srcDir
|
|
276
|
-
const sockets
|
|
253
|
+
const htmlDir = path.dirname(htmlPath)
|
|
254
|
+
const srcDir = path.dirname(entrypoint)
|
|
255
|
+
const sockets = new Set()
|
|
277
256
|
let importMapTag = null
|
|
278
|
-
|
|
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
|
|
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 ===
|
|
318
|
-
|
|
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
|
-
|
|
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
|
-
|
|
332
|
-
|
|
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
|
-
|
|
352
|
-
|
|
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(
|
|
358
|
-
const file = pathname.replace(/^\//, '')
|
|
372
|
+
const out = await compileFile(filepath)
|
|
359
373
|
if (out.errors?.length) {
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
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
|
|
384
|
+
} catch(e) {
|
|
389
385
|
const file = pathname.replace(/^\//, '')
|
|
390
386
|
printStatus(file, 'fail', [{ message: e.message }])
|
|
391
|
-
|
|
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
|
|
398
|
-
const
|
|
399
|
-
if (await
|
|
400
|
-
const
|
|
401
|
-
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)
|
|
402
397
|
|
|
403
|
-
// SPA fallback
|
|
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
|
-
|
|
409
|
-
|
|
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:
|
|
416
|
-
close:
|
|
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(
|
|
419
|
+
console.log(theme.start('Dev server running at ') + theme.success(`http://localhost:${port}`))
|
|
422
420
|
console.log(theme.folder('──────────────────────────────────────────────────────────────────────'))
|
|
423
421
|
}
|