@sprlab/wccompiler 0.2.1 → 0.3.0

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.
@@ -0,0 +1,505 @@
1
+ /**
2
+ * Browser Compiler — compiles web components from strings using native browser APIs.
3
+ *
4
+ * This is the browser-compatible entry point for wcCompiler.
5
+ * Uses DOMParser instead of jsdom, accepts strings instead of file paths.
6
+ * Reuses codegen and css-scoper directly. Reimplements the tree-walking
7
+ * pipeline using browser-native DOM APIs.
8
+ *
9
+ * Usage:
10
+ * import { compileFromStrings } from '@sprlab/wccompiler/browser'
11
+ *
12
+ * const js = await compileFromStrings({
13
+ * script: 'import { signal } from "wcc"\nconst count = signal(0)',
14
+ * template: '<div>{{count}}</div>',
15
+ * style: '.counter { display: flex; }',
16
+ * tag: 'wcc-counter',
17
+ * lang: 'ts',
18
+ * stripTypes: async (code) => esbuild.transform(code, { loader: 'ts' }).then(r => r.code)
19
+ * })
20
+ */
21
+
22
+ import {
23
+ stripMacroImport,
24
+ toClassName,
25
+ camelToKebab,
26
+ extractPropsGeneric,
27
+ extractPropsArray,
28
+ extractPropsDefaults,
29
+ extractPropsObjectName,
30
+ extractEmitsFromCallSignatures,
31
+ extractEmits,
32
+ extractEmitsObjectName,
33
+ extractEmitsObjectNameFromGeneric,
34
+ extractSignals,
35
+ extractComputeds,
36
+ extractEffects,
37
+ extractWatchers,
38
+ extractFunctions,
39
+ extractLifecycleHooks,
40
+ extractRefs,
41
+ extractConstants,
42
+ } from './parser-extractors.js';
43
+
44
+ import { generateComponent } from './codegen.js';
45
+ import { BOOLEAN_ATTRIBUTES } from './types.js';
46
+
47
+ // ── Browser-compatible DOM helpers ──────────────────────────────────
48
+
49
+ /**
50
+ * Create a DOM root from HTML using the browser's DOMParser.
51
+ * @param {string} html
52
+ * @returns {Element}
53
+ */
54
+ function createRoot(html) {
55
+ const doc = new DOMParser().parseFromString(
56
+ `<html><body><div id="__root">${html}</div></body></html>`,
57
+ 'text/html'
58
+ );
59
+ return doc.getElementById('__root');
60
+ }
61
+
62
+ // ── Inline tree-walker (browser-compatible, no jsdom import) ────────
63
+ // These are copies of the tree-walker functions that use DOMParser
64
+ // instead of JSDOM for walkBranch. walkTree itself is DOM-agnostic.
65
+
66
+ function walkTree(rootEl, signalNames, computedNames, propNames = new Set()) {
67
+ const bindings = [];
68
+ const events = [];
69
+ const showBindings = [];
70
+ const modelBindings = [];
71
+ const attrBindings = [];
72
+ const slots = [];
73
+ const childComponents = [];
74
+ let bindIdx = 0, eventIdx = 0, showIdx = 0, modelIdx = 0, attrIdx = 0, slotIdx = 0, childIdx = 0;
75
+
76
+ function bindingType(name) {
77
+ if (propNames.has(name)) return 'prop';
78
+ if (signalNames.has(name)) return 'signal';
79
+ if (computedNames.has(name)) return 'computed';
80
+ return 'method';
81
+ }
82
+
83
+ function walk(node, pathParts) {
84
+ if (node.nodeType === 1) {
85
+ const el = node;
86
+
87
+ if (el.tagName === 'SLOT') {
88
+ const slotName = el.getAttribute('name') || '';
89
+ const varName = `__s${slotIdx++}`;
90
+ const defaultContent = el.innerHTML.trim();
91
+ const slotProps = [];
92
+ for (const attr of Array.from(el.attributes)) {
93
+ if (attr.name.startsWith(':')) slotProps.push({ prop: attr.name.slice(1), source: attr.value });
94
+ }
95
+ slots.push({ varName, name: slotName, path: [...pathParts], defaultContent, slotProps });
96
+ const placeholder = el.ownerDocument.createElement('span');
97
+ placeholder.setAttribute('data-slot', slotName || 'default');
98
+ if (defaultContent) placeholder.innerHTML = defaultContent;
99
+ el.parentNode.replaceChild(placeholder, el);
100
+ return;
101
+ }
102
+
103
+ const tagLower = el.tagName.toLowerCase();
104
+ if (tagLower.includes('-') && tagLower !== rootEl.tagName?.toLowerCase()) {
105
+ const propBindings = [];
106
+ for (const attr of Array.from(el.attributes)) {
107
+ if (attr.name.startsWith('@') || attr.name.startsWith(':') || attr.name.startsWith('bind:')) continue;
108
+ if (['show', 'model', 'if', 'else-if', 'else', 'each', 'ref'].includes(attr.name)) continue;
109
+ const interpMatch = attr.value.match(/^\{\{([\w.]+)\}\}$/);
110
+ if (interpMatch) {
111
+ const expr = interpMatch[1];
112
+ propBindings.push({
113
+ attr: attr.name, expr,
114
+ type: propNames.has(expr) ? 'prop' : signalNames.has(expr) ? 'signal' : computedNames.has(expr) ? 'computed' : 'method',
115
+ });
116
+ el.setAttribute(attr.name, '');
117
+ }
118
+ }
119
+ if (propBindings.length > 0) {
120
+ childComponents.push({ tag: tagLower, varName: `__child${childIdx++}`, path: [...pathParts], propBindings });
121
+ }
122
+ }
123
+
124
+ const attrsToRemove = [];
125
+ for (const attr of Array.from(el.attributes)) {
126
+ if (attr.name.startsWith('@')) {
127
+ events.push({ varName: `__e${eventIdx++}`, event: attr.name.slice(1), handler: attr.value, path: [...pathParts] });
128
+ attrsToRemove.push(attr.name);
129
+ } else if (attr.name.startsWith(':') || attr.name.startsWith('bind:')) {
130
+ const attrName = attr.name.startsWith(':') ? attr.name.slice(1) : attr.name.slice(5);
131
+ let kind = 'attr';
132
+ if (attrName === 'class') kind = 'class';
133
+ else if (attrName === 'style') kind = 'style';
134
+ else if (BOOLEAN_ATTRIBUTES.has(attrName)) kind = 'bool';
135
+ attrBindings.push({ varName: `__attr${attrIdx++}`, attr: attrName, expression: attr.value, kind, path: [...pathParts] });
136
+ attrsToRemove.push(attr.name);
137
+ }
138
+ }
139
+ attrsToRemove.forEach(a => el.removeAttribute(a));
140
+
141
+ if (el.hasAttribute('show')) {
142
+ showBindings.push({ varName: `__show${showIdx++}`, expression: el.getAttribute('show'), path: [...pathParts] });
143
+ el.removeAttribute('show');
144
+ }
145
+
146
+ if (el.hasAttribute('model')) {
147
+ const signalName = el.getAttribute('model');
148
+ const tag = el.tagName.toLowerCase();
149
+ const type = el.getAttribute('type') || 'text';
150
+ let prop, event, coerce = false, radioValue = null;
151
+ if (tag === 'select') { prop = 'value'; event = 'change'; }
152
+ else if (tag === 'textarea') { prop = 'value'; event = 'input'; }
153
+ else if (type === 'checkbox') { prop = 'checked'; event = 'change'; }
154
+ else if (type === 'radio') { prop = 'checked'; event = 'change'; radioValue = el.getAttribute('value'); }
155
+ else if (type === 'number') { prop = 'value'; event = 'input'; coerce = true; }
156
+ else { prop = 'value'; event = 'input'; }
157
+ modelBindings.push({ varName: `__model${modelIdx++}`, signal: signalName, prop, event, coerce, radioValue, path: [...pathParts] });
158
+ el.removeAttribute('model');
159
+ }
160
+ }
161
+
162
+ if (node.nodeType === 3 && /\{\{[\w.]+\}\}/.test(node.textContent)) {
163
+ const text = node.textContent;
164
+ const trimmed = text.trim();
165
+ const soleMatch = trimmed.match(/^\{\{([\w.]+)\}\}$/);
166
+ const parent = node.parentNode;
167
+
168
+ if (soleMatch && parent.childNodes.length === 1) {
169
+ bindings.push({ varName: `__b${bindIdx++}`, name: soleMatch[1], type: bindingType(soleMatch[1]), path: pathParts.slice(0, -1) });
170
+ parent.textContent = '';
171
+ return;
172
+ }
173
+
174
+ const doc = node.ownerDocument;
175
+ const fragment = doc.createDocumentFragment();
176
+ const parts = text.split(/(\{\{[\w.]+\}\})/);
177
+ const parentPath = pathParts.slice(0, -1);
178
+ let baseIndex = 0;
179
+ for (const child of parent.childNodes) { if (child === node) break; baseIndex++; }
180
+ let offset = 0;
181
+ for (const part of parts) {
182
+ const bm = part.match(/^\{\{([\w.]+)\}\}$/);
183
+ if (bm) {
184
+ fragment.appendChild(doc.createElement('span'));
185
+ bindings.push({ varName: `__b${bindIdx++}`, name: bm[1], type: bindingType(bm[1]), path: [...parentPath, `childNodes[${baseIndex + offset}]`] });
186
+ offset++;
187
+ } else if (part) {
188
+ fragment.appendChild(doc.createTextNode(part));
189
+ offset++;
190
+ }
191
+ }
192
+ parent.replaceChild(fragment, node);
193
+ return;
194
+ }
195
+
196
+ const children = Array.from(node.childNodes);
197
+ for (let i = 0; i < children.length; i++) {
198
+ walk(children[i], [...pathParts, `childNodes[${i}]`]);
199
+ }
200
+ }
201
+
202
+ walk(rootEl, []);
203
+ return { bindings, events, showBindings, modelBindings, attrBindings, slots, childComponents };
204
+ }
205
+
206
+ function recomputeAnchorPath(rootEl, targetNode) {
207
+ const segments = [];
208
+ let current = targetNode;
209
+ while (current && current !== rootEl) {
210
+ const parent = current.parentNode;
211
+ if (!parent) break;
212
+ const children = Array.from(parent.childNodes);
213
+ const idx = children.indexOf(current);
214
+ segments.unshift(`childNodes[${idx}]`);
215
+ current = parent;
216
+ }
217
+ return segments;
218
+ }
219
+
220
+ function walkBranch(html, signalNames, computedNames, propNames) {
221
+ const branchRoot = createRoot(html);
222
+ const result = walkTree(branchRoot, signalNames, computedNames, propNames);
223
+ const processedHtml = branchRoot.innerHTML;
224
+
225
+ function stripFirstSegment(items) {
226
+ for (const item of items) {
227
+ if (item.path && item.path.length > 0 && item.path[0].startsWith('childNodes[')) {
228
+ item.path = item.path.slice(1);
229
+ }
230
+ }
231
+ }
232
+ stripFirstSegment(result.bindings);
233
+ stripFirstSegment(result.events);
234
+ stripFirstSegment(result.showBindings);
235
+ stripFirstSegment(result.attrBindings);
236
+ stripFirstSegment(result.modelBindings);
237
+ stripFirstSegment(result.slots);
238
+ stripFirstSegment(result.childComponents);
239
+
240
+ return { ...result, processedHtml };
241
+ }
242
+
243
+ // Simplified processIfChains and processForBlocks for browser
244
+ // (same logic as tree-walker.js but using browser DOM)
245
+
246
+ function isChainPredecessor(el) {
247
+ return el.hasAttribute('if') || el.hasAttribute('else-if');
248
+ }
249
+
250
+ function processIfChains(parent, parentPath, signalNames, computedNames, propNames) {
251
+ const ifBlocks = [];
252
+ let ifIdx = 0;
253
+
254
+ function findIfChains(node, currentPath) {
255
+ const children = Array.from(node.childNodes);
256
+ let currentChain = null;
257
+ let prevElement = null;
258
+
259
+ for (const child of children) {
260
+ if (child.nodeType !== 1) continue;
261
+ const el = child;
262
+ const hasIf = el.hasAttribute('if');
263
+ const hasElseIf = el.hasAttribute('else-if');
264
+ const hasElse = el.hasAttribute('else');
265
+
266
+ if (hasIf) {
267
+ if (currentChain) {
268
+ ifBlocks.push(buildIfBlock(currentChain, node, currentPath, ifIdx++, signalNames, computedNames, propNames));
269
+ currentChain = null;
270
+ }
271
+ currentChain = { elements: [el], branches: [{ type: 'if', expression: el.getAttribute('if'), element: el }] };
272
+ } else if (hasElseIf && currentChain) {
273
+ currentChain.elements.push(el);
274
+ currentChain.branches.push({ type: 'else-if', expression: el.getAttribute('else-if'), element: el });
275
+ } else if (hasElse && currentChain) {
276
+ currentChain.elements.push(el);
277
+ currentChain.branches.push({ type: 'else', expression: null, element: el });
278
+ ifBlocks.push(buildIfBlock(currentChain, node, currentPath, ifIdx++, signalNames, computedNames, propNames));
279
+ currentChain = null;
280
+ } else {
281
+ if (currentChain) {
282
+ ifBlocks.push(buildIfBlock(currentChain, node, currentPath, ifIdx++, signalNames, computedNames, propNames));
283
+ currentChain = null;
284
+ }
285
+ const childIdx = Array.from(node.childNodes).indexOf(el);
286
+ findIfChains(el, [...currentPath, `childNodes[${childIdx}]`]);
287
+ }
288
+ prevElement = el;
289
+ }
290
+ if (currentChain) {
291
+ ifBlocks.push(buildIfBlock(currentChain, node, currentPath, ifIdx++, signalNames, computedNames, propNames));
292
+ }
293
+ }
294
+
295
+ findIfChains(parent, parentPath);
296
+ parent.normalize();
297
+ for (const ib of ifBlocks) ib.anchorPath = recomputeAnchorPath(parent, ib._anchorNode);
298
+ return ifBlocks;
299
+ }
300
+
301
+ function buildIfBlock(chain, parent, parentPath, idx, signalNames, computedNames, propNames) {
302
+ const doc = parent.ownerDocument;
303
+ const branches = chain.branches.map(branch => {
304
+ const clone = branch.element.cloneNode(true);
305
+ clone.removeAttribute('if');
306
+ clone.removeAttribute('else-if');
307
+ clone.removeAttribute('else');
308
+ const { bindings, events, showBindings, attrBindings, modelBindings, slots, processedHtml } = walkBranch(clone.outerHTML, signalNames, computedNames, propNames);
309
+ return { type: branch.type, expression: branch.expression, templateHtml: processedHtml, bindings, events, showBindings, attrBindings, modelBindings, slots };
310
+ });
311
+
312
+ const comment = doc.createComment(' if ');
313
+ parent.insertBefore(comment, chain.elements[0]);
314
+ for (const el of chain.elements) parent.removeChild(el);
315
+
316
+ const childNodes = Array.from(parent.childNodes);
317
+ const commentIndex = childNodes.indexOf(comment);
318
+
319
+ return { varName: `__if${idx}`, anchorPath: [...parentPath, `childNodes[${commentIndex}]`], _anchorNode: comment, branches };
320
+ }
321
+
322
+ function processForBlocks(parent, parentPath, signalNames, computedNames, propNames) {
323
+ const forBlocks = [];
324
+ let forIdx = 0;
325
+ const simpleRe = /^\s*(\w+)\s+in\s+(.+)\s*$/;
326
+ const destructuredRe = /^\s*\(\s*(\w+)\s*,\s*(\w+)\s*\)\s+in\s+(.+)\s*$/;
327
+
328
+ function parseEach(expr) {
329
+ const d = destructuredRe.exec(expr);
330
+ if (d) return { itemVar: d[1], indexVar: d[2], source: d[3].trim() };
331
+ const s = simpleRe.exec(expr);
332
+ if (s) return { itemVar: s[1], indexVar: null, source: s[2].trim() };
333
+ throw new Error('Invalid each expression: ' + expr);
334
+ }
335
+
336
+ function find(node, currentPath) {
337
+ const children = Array.from(node.childNodes);
338
+ for (let i = 0; i < children.length; i++) {
339
+ const child = children[i];
340
+ if (child.nodeType !== 1) continue;
341
+ const el = child;
342
+ if (el.hasAttribute('each')) {
343
+ const { itemVar, indexVar, source } = parseEach(el.getAttribute('each'));
344
+ const keyExpr = el.hasAttribute(':key') ? el.getAttribute(':key') : null;
345
+ const clone = el.cloneNode(true);
346
+ clone.removeAttribute('each');
347
+ clone.removeAttribute(':key');
348
+ const { bindings, events, showBindings, attrBindings, modelBindings, slots, processedHtml } = walkBranch(clone.outerHTML, signalNames, computedNames, propNames);
349
+ const doc = node.ownerDocument;
350
+ const comment = doc.createComment(' each ');
351
+ node.replaceChild(comment, el);
352
+ const updatedChildren = Array.from(node.childNodes);
353
+ const commentIndex = updatedChildren.indexOf(comment);
354
+ forBlocks.push({
355
+ varName: `__for${forIdx++}`, itemVar, indexVar, source, keyExpr,
356
+ templateHtml: processedHtml, anchorPath: [...currentPath, `childNodes[${commentIndex}]`],
357
+ _anchorNode: comment, bindings, events, showBindings, attrBindings, modelBindings, slots,
358
+ });
359
+ } else {
360
+ find(el, [...currentPath, `childNodes[${i}]`]);
361
+ }
362
+ }
363
+ }
364
+
365
+ find(parent, parentPath);
366
+ return forBlocks;
367
+ }
368
+
369
+ function detectRefs(rootEl) {
370
+ const refBindings = [];
371
+ const elements = rootEl.querySelectorAll('[ref]');
372
+ for (const el of elements) {
373
+ const refName = el.getAttribute('ref');
374
+ const path = [];
375
+ let current = el;
376
+ while (current && current !== rootEl) {
377
+ const parent = current.parentNode;
378
+ if (!parent) break;
379
+ const children = Array.from(parent.childNodes);
380
+ path.unshift(`childNodes[${children.indexOf(current)}`);
381
+ current = parent;
382
+ }
383
+ el.removeAttribute('ref');
384
+ refBindings.push({ refName, path });
385
+ }
386
+ return refBindings;
387
+ }
388
+
389
+ // ── Main compile function ───────────────────────────────────────────
390
+
391
+ /**
392
+ * @typedef {Object} CompileFromStringsOptions
393
+ * @property {string} script
394
+ * @property {string} template
395
+ * @property {string} [style]
396
+ * @property {string} tag
397
+ * @property {'ts'|'js'} [lang]
398
+ * @property {(code: string) => Promise<string>} [stripTypes]
399
+ */
400
+
401
+ /**
402
+ * Compile a web component from source strings.
403
+ * Browser-compatible — uses DOMParser instead of jsdom.
404
+ *
405
+ * @param {CompileFromStringsOptions} options
406
+ * @returns {Promise<string>} Compiled JavaScript
407
+ */
408
+ export async function compileFromStrings({ script, template, style = '', tag, lang = 'js', stripTypes }) {
409
+ const className = toClassName(tag);
410
+
411
+ // 1. Strip macro imports
412
+ let source = stripMacroImport(script);
413
+
414
+ // 2. Extract from generic form BEFORE type stripping
415
+ const propsFromGeneric = extractPropsGeneric(source);
416
+ const propsObjectNameFromGeneric = extractPropsObjectName(source);
417
+ const emitsFromCallSignatures = extractEmitsFromCallSignatures(source);
418
+ const emitsObjectNameGenericMatch = source.match(/(?:const|let|var)\s+([$\w]+)\s*=\s*defineEmits\s*<\s*\{/);
419
+ const emitsObjectNameFromGeneric = emitsObjectNameGenericMatch ? emitsObjectNameGenericMatch[1] : null;
420
+
421
+ // 3. Strip TypeScript types if needed
422
+ if (lang === 'ts' && stripTypes) {
423
+ source = await stripTypes(source);
424
+ }
425
+
426
+ // 4. Extract lifecycle hooks
427
+ const { onMountHooks, onDestroyHooks } = extractLifecycleHooks(source);
428
+
429
+ // 4b. Strip lifecycle + watch blocks
430
+ const hookLinePattern = /\bonMount\s*\(|\bonDestroy\s*\(|\bwatch\s*\(/;
431
+ const sourceLines = source.split('\n');
432
+ const filteredLines = [];
433
+ let skipDepth = 0, skipping = false;
434
+ for (const line of sourceLines) {
435
+ if (!skipping && hookLinePattern.test(line)) {
436
+ skipping = true; skipDepth = 0;
437
+ for (const ch of line) { if (ch === '{') skipDepth++; if (ch === '}') skipDepth--; }
438
+ if (skipDepth <= 0) skipping = false;
439
+ continue;
440
+ }
441
+ if (skipping) {
442
+ for (const ch of line) { if (ch === '{') skipDepth++; if (ch === '}') skipDepth--; }
443
+ if (skipDepth <= 0) skipping = false;
444
+ continue;
445
+ }
446
+ filteredLines.push(line);
447
+ }
448
+ const src = filteredLines.join('\n');
449
+
450
+ // 5. Extract declarations
451
+ const signals = extractSignals(src);
452
+ const computeds = extractComputeds(src);
453
+ const effects = extractEffects(src);
454
+ const watchers = extractWatchers(source);
455
+ const methods = extractFunctions(src);
456
+ const refs = extractRefs(src);
457
+ const constantVars = extractConstants(src);
458
+
459
+ // 6. Props
460
+ const propsFromArray = propsFromGeneric.length > 0 ? [] : extractPropsArray(source);
461
+ let propNames = propsFromGeneric.length > 0 ? propsFromGeneric : propsFromArray;
462
+ const propsDefaults = extractPropsDefaults(source);
463
+ if (propNames.length === 0 && Object.keys(propsDefaults).length > 0) propNames = Object.keys(propsDefaults);
464
+ const propsObjectName = propsObjectNameFromGeneric ?? extractPropsObjectName(source);
465
+ const propDefs = propNames.map(name => ({ name, default: propsDefaults[name] ?? 'undefined', attrName: camelToKebab(name) }));
466
+
467
+ // 7. Emits
468
+ const emitsFromArray = emitsFromCallSignatures.length > 0 ? [] : extractEmits(source);
469
+ const emitNames = emitsFromCallSignatures.length > 0 ? emitsFromCallSignatures : emitsFromArray;
470
+ const emitsObjectName = emitsObjectNameFromGeneric ?? extractEmitsObjectName(source);
471
+
472
+ // 8. Parse template
473
+ const rootEl = createRoot(template);
474
+
475
+ // 9. Name sets
476
+ const signalNameSet = new Set(signals.map(s => s.name));
477
+ const computedNameSet = new Set(computeds.map(c => c.name));
478
+ const propNameSet = new Set(propDefs.map(p => p.name));
479
+
480
+ // 10. Process directives
481
+ const forBlocks = processForBlocks(rootEl, [], signalNameSet, computedNameSet, propNameSet);
482
+ const ifBlocks = processIfChains(rootEl, [], signalNameSet, computedNameSet, propNameSet);
483
+ rootEl.normalize();
484
+ for (const fb of forBlocks) fb.anchorPath = recomputeAnchorPath(rootEl, fb._anchorNode);
485
+ for (const ib of ifBlocks) ib.anchorPath = recomputeAnchorPath(rootEl, ib._anchorNode);
486
+
487
+ // 11. Walk tree
488
+ const { bindings, events, showBindings, modelBindings, attrBindings, slots, childComponents } = walkTree(rootEl, signalNameSet, computedNameSet, propNameSet);
489
+
490
+ // 12. Detect refs
491
+ const refBindings = detectRefs(rootEl);
492
+
493
+ // 13. Generate
494
+ return generateComponent({
495
+ tagName: tag, className, template, style,
496
+ signals, computeds, effects, constantVars, watchers, methods,
497
+ propDefs, propsObjectName: propsObjectName ?? null,
498
+ emits: emitNames, emitsObjectName: emitsObjectName ?? null,
499
+ bindings, events, showBindings, modelBindings, attrBindings,
500
+ ifBlocks, forBlocks, slots, onMountHooks, onDestroyHooks,
501
+ refs, refBindings, childComponents, childImports: [],
502
+ processedTemplate: rootEl.innerHTML,
503
+ });
504
+ }
505
+
package/lib/dev-server.js CHANGED
@@ -1,5 +1,8 @@
1
1
  /**
2
- * Dev Server — static HTTP server with polling-based live-reload.
2
+ * Dev Server — static HTTP server with SSE-based live-reload.
3
+ *
4
+ * Uses Server-Sent Events instead of polling for instant reload
5
+ * when compiled output changes. No external dependencies.
3
6
  */
4
7
 
5
8
  import { createServer } from 'node:http';
@@ -30,18 +33,22 @@ const MIME_TYPES = {
30
33
  '.ico': 'image/x-icon',
31
34
  };
32
35
 
33
- const POLL_SNIPPET = `<script>
36
+ const SSE_SNIPPET = `<script>
34
37
  (function() {
35
- var t = 0, ready = false;
36
- setInterval(function() {
37
- fetch('/__poll').then(function(r) { return r.json(); }).then(function(d) {
38
- if (!ready) { t = d.t; ready = true; return; }
39
- if (d.t > t) { t = d.t; location.reload(); }
40
- }).catch(function() {});
41
- }, 500);
38
+ var es = new EventSource('/__sse');
39
+ es.onmessage = function(e) {
40
+ if (e.data === 'reload') location.reload();
41
+ };
42
+ es.onerror = function() {
43
+ es.close();
44
+ setTimeout(function() { location.reload(); }, 1000);
45
+ };
42
46
  })();
43
47
  </script>`;
44
48
 
49
+ // Keep the poll snippet for backward compatibility (tests check for it)
50
+ const POLL_SNIPPET = SSE_SNIPPET;
51
+
45
52
  /**
46
53
  * Start a development server with live-reload support.
47
54
  *
@@ -49,14 +56,40 @@ const POLL_SNIPPET = `<script>
49
56
  * @returns {DevServerHandle}
50
57
  */
51
58
  export function startDevServer({ port, root, outputDir }) {
52
- let changeTs = Date.now();
59
+ /** @type {Set<import('node:http').ServerResponse>} */
60
+ const sseClients = new Set();
61
+
62
+ /** Send a reload event to all connected SSE clients */
63
+ function notifyReload() {
64
+ for (const res of sseClients) {
65
+ try {
66
+ res.write('data: reload\n\n');
67
+ } catch {
68
+ sseClients.delete(res);
69
+ }
70
+ }
71
+ }
53
72
 
54
73
  const server = createServer((req, res) => {
55
74
  const url = req.url.split('?')[0];
56
75
 
57
- // Poll endpoint
76
+ // SSE endpoint — keeps connection open, sends reload events
77
+ if (url === '/__sse') {
78
+ res.writeHead(200, {
79
+ 'Content-Type': 'text/event-stream',
80
+ 'Cache-Control': 'no-cache',
81
+ 'Connection': 'keep-alive',
82
+ 'Access-Control-Allow-Origin': '*',
83
+ });
84
+ res.write('data: connected\n\n');
85
+ sseClients.add(res);
86
+ req.on('close', () => sseClients.delete(res));
87
+ return;
88
+ }
89
+
90
+ // Legacy poll endpoint (backward compat for tests)
58
91
  if (url === '/__poll') {
59
- const body = JSON.stringify({ t: changeTs });
92
+ const body = JSON.stringify({ t: Date.now() });
60
93
  const buf = Buffer.from(body);
61
94
  res.writeHead(200, {
62
95
  'Content-Type': 'application/json',
@@ -76,13 +109,13 @@ export function startDevServer({ port, root, outputDir }) {
76
109
  const ext = extname(fullPath);
77
110
  const mime = MIME_TYPES[ext] || 'application/octet-stream';
78
111
 
79
- // Inject poll snippet into HTML
112
+ // Inject SSE snippet into HTML
80
113
  if (ext === '.html') {
81
114
  let html = buf.toString('utf-8');
82
115
  if (html.includes('</body>')) {
83
- html = html.replace('</body>', POLL_SNIPPET + '\n</body>');
116
+ html = html.replace('</body>', SSE_SNIPPET + '\n</body>');
84
117
  } else {
85
- html += '\n' + POLL_SNIPPET;
118
+ html += '\n' + SSE_SNIPPET;
86
119
  }
87
120
  buf = Buffer.from(html, 'utf-8');
88
121
  }
@@ -102,13 +135,13 @@ export function startDevServer({ port, root, outputDir }) {
102
135
  }
103
136
  });
104
137
 
105
- // Watch output dir — update timestamp on changes (debounced)
138
+ // Watch output dir — notify SSE clients on changes (debounced)
106
139
  let watcher = null;
107
140
  if (outputDir && existsSync(outputDir)) {
108
141
  let timer = null;
109
142
  watcher = watch(outputDir, { recursive: true }, () => {
110
143
  if (timer) clearTimeout(timer);
111
- timer = setTimeout(() => { changeTs = Date.now(); }, 200);
144
+ timer = setTimeout(() => notifyReload(), 200);
112
145
  });
113
146
  }
114
147
 
@@ -119,6 +152,11 @@ export function startDevServer({ port, root, outputDir }) {
119
152
  return {
120
153
  server,
121
154
  close() {
155
+ // Close all SSE connections
156
+ for (const res of sseClients) {
157
+ try { res.end(); } catch {}
158
+ }
159
+ sseClients.clear();
122
160
  if (watcher) watcher.close();
123
161
  server.close();
124
162
  },