@sprlab/wccompiler 0.2.1 → 0.4.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.
- package/README.md +154 -34
- package/bin/wcc.js +4 -5
- package/bin/wcc.test.js +23 -16
- package/lib/codegen.js +336 -114
- package/lib/compiler-browser.js +526 -0
- package/lib/compiler.js +225 -91
- package/lib/dev-server.js +55 -17
- package/lib/parser-extractors.js +1029 -0
- package/lib/parser.js +36 -929
- package/lib/reactive-runtime.js +35 -4
- package/lib/sfc-parser.js +262 -0
- package/lib/tree-walker.js +18 -10
- package/lib/types.js +11 -0
- package/package.json +3 -3
- package/types/wcc.d.ts +6 -6
- package/types/wcc.test.js +2 -2
- package/lib/printer.js +0 -118
package/lib/compiler.js
CHANGED
|
@@ -1,107 +1,238 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Compiler — orchestrates the full compilation pipeline for wcCompiler v2.
|
|
3
3
|
*
|
|
4
|
-
* Pipeline: parse → jsdom template → tree-walk → codegen
|
|
4
|
+
* Pipeline: parse SFC → jsdom template → tree-walk → codegen
|
|
5
5
|
*
|
|
6
|
-
* Takes a .
|
|
7
|
-
* JavaScript web component string.
|
|
6
|
+
* Takes a .wcc file path and produces a self-contained JavaScript web component string.
|
|
8
7
|
*/
|
|
9
8
|
|
|
10
9
|
import { JSDOM } from 'jsdom';
|
|
11
|
-
import { readFileSync,
|
|
12
|
-
import {
|
|
13
|
-
import { parse } from './parser.js';
|
|
10
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
11
|
+
import { dirname, extname, basename, resolve } from 'node:path';
|
|
14
12
|
import { walkTree, processIfChains, processForBlocks, recomputeAnchorPath, detectRefs } from './tree-walker.js';
|
|
15
13
|
import { generateComponent } from './codegen.js';
|
|
14
|
+
import { parseSFC } from './sfc-parser.js';
|
|
15
|
+
import {
|
|
16
|
+
stripMacroImport,
|
|
17
|
+
toClassName,
|
|
18
|
+
camelToKebab,
|
|
19
|
+
extractPropsGeneric,
|
|
20
|
+
extractPropsArray,
|
|
21
|
+
extractPropsDefaults,
|
|
22
|
+
extractPropsObjectName,
|
|
23
|
+
extractEmitsFromCallSignatures,
|
|
24
|
+
extractEmits,
|
|
25
|
+
extractEmitsObjectName,
|
|
26
|
+
extractEmitsObjectNameFromGeneric,
|
|
27
|
+
extractSignals,
|
|
28
|
+
extractComputeds,
|
|
29
|
+
extractEffects,
|
|
30
|
+
extractWatchers,
|
|
31
|
+
extractFunctions,
|
|
32
|
+
extractLifecycleHooks,
|
|
33
|
+
extractRefs,
|
|
34
|
+
extractConstants,
|
|
35
|
+
validatePropsAssignment,
|
|
36
|
+
validateDuplicateProps,
|
|
37
|
+
validatePropsConflicts,
|
|
38
|
+
validateEmitsAssignment,
|
|
39
|
+
validateDuplicateEmits,
|
|
40
|
+
validateEmitsConflicts,
|
|
41
|
+
validateUndeclaredEmits,
|
|
42
|
+
} from './parser-extractors.js';
|
|
43
|
+
import { stripTypes } from './parser.js';
|
|
44
|
+
|
|
16
45
|
/**
|
|
17
|
-
* Resolve a child component's
|
|
18
|
-
*
|
|
46
|
+
* Resolve a child component's source file path by tag name.
|
|
47
|
+
*
|
|
48
|
+
* Searches for a file named after the tag in the source directory,
|
|
49
|
+
* trying extensions in priority order: .wcc, .js, .ts
|
|
19
50
|
*
|
|
20
|
-
* @param {string} tag —
|
|
21
|
-
* @param {string} sourceDir — Directory of the parent component
|
|
22
|
-
* @param {object} [config] — Optional config
|
|
23
|
-
* @returns {string | null} Relative import path (e.g., './wcc-
|
|
51
|
+
* @param {string} tag — The custom element tag name (e.g., 'wcc-child')
|
|
52
|
+
* @param {string} sourceDir — Directory of the parent component
|
|
53
|
+
* @param {object} [config] — Optional config (reserved for future use)
|
|
54
|
+
* @returns {string | null} Relative import path (e.g., './wcc-child.js') or null if not found
|
|
24
55
|
*/
|
|
25
56
|
function resolveChildComponent(tag, sourceDir, config) {
|
|
26
|
-
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
searchDirs.push(parentDir);
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
for (const dir of searchDirs) {
|
|
36
|
-
if (!existsSync(dir)) continue;
|
|
37
|
-
try {
|
|
38
|
-
const entries = readdirSync(dir, { withFileTypes: true, recursive: true });
|
|
39
|
-
for (const entry of entries) {
|
|
40
|
-
if (!entry.isFile()) continue;
|
|
41
|
-
const ext = extname(entry.name);
|
|
42
|
-
if (ext !== '.js' && ext !== '.ts') continue;
|
|
43
|
-
if (entry.name.includes('.test.')) continue;
|
|
44
|
-
if (entry.name.endsWith('.d.ts')) continue;
|
|
45
|
-
|
|
46
|
-
const fullPath = resolve(dir, entry.parentPath ? relative(dir, entry.parentPath) : '', entry.name);
|
|
47
|
-
try {
|
|
48
|
-
const content = readFileSync(fullPath, 'utf-8');
|
|
49
|
-
// Quick check: does this file define the component with the matching tag?
|
|
50
|
-
const tagMatch = content.match(/defineComponent\(\s*\{[^}]*tag\s*:\s*['"]([^'"]+)['"]/);
|
|
51
|
-
if (tagMatch && tagMatch[1] === tag) {
|
|
52
|
-
// Compute relative path from sourceDir to this file, with .js extension
|
|
53
|
-
let relPath = relative(sourceDir, fullPath);
|
|
54
|
-
// Ensure .js extension (replace .ts)
|
|
55
|
-
relPath = relPath.replace(/\.ts$/, '.js');
|
|
56
|
-
// Ensure starts with ./
|
|
57
|
-
if (!relPath.startsWith('.')) relPath = './' + relPath;
|
|
58
|
-
return relPath;
|
|
59
|
-
}
|
|
60
|
-
} catch {
|
|
61
|
-
// Skip files that can't be read
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
} catch {
|
|
65
|
-
// Skip dirs that can't be listed
|
|
57
|
+
const extensions = ['.wcc', '.js', '.ts'];
|
|
58
|
+
for (const ext of extensions) {
|
|
59
|
+
const candidate = resolve(sourceDir, `${tag}${ext}`);
|
|
60
|
+
if (existsSync(candidate)) {
|
|
61
|
+
// Return as a relative .js import path (compiled output)
|
|
62
|
+
return `./${tag}.js`;
|
|
66
63
|
}
|
|
67
64
|
}
|
|
68
|
-
|
|
69
65
|
return null;
|
|
70
66
|
}
|
|
71
67
|
|
|
72
68
|
/**
|
|
73
|
-
* Compile a single .
|
|
69
|
+
* Compile a single .wcc SFC file into a self-contained JS component.
|
|
70
|
+
*
|
|
71
|
+
* Reads the file, parses the SFC blocks, extracts reactive declarations
|
|
72
|
+
* from the script block using parser-extractors.js, and processes template
|
|
73
|
+
* and style through the existing pipeline (jsdom → tree-walker → codegen).
|
|
74
74
|
*
|
|
75
|
-
* @param {string} filePath — Absolute or relative path to the
|
|
75
|
+
* @param {string} filePath — Absolute or relative path to the .wcc file
|
|
76
76
|
* @param {object} [config] — Optional config (reserved for future options)
|
|
77
77
|
* @returns {Promise<string>} The generated JavaScript component code
|
|
78
78
|
*/
|
|
79
|
-
|
|
80
|
-
// 1.
|
|
81
|
-
const
|
|
79
|
+
async function compileSFC(filePath, config) {
|
|
80
|
+
// 1. Read and parse the SFC file
|
|
81
|
+
const rawSource = readFileSync(filePath, 'utf-8');
|
|
82
|
+
const fileName = basename(filePath);
|
|
83
|
+
const descriptor = parseSFC(rawSource, fileName);
|
|
82
84
|
|
|
83
|
-
// 2.
|
|
84
|
-
|
|
85
|
-
|
|
85
|
+
// 2. Process script block — mirrors parser.js logic
|
|
86
|
+
let source = stripMacroImport(descriptor.script);
|
|
87
|
+
|
|
88
|
+
// 3. Extract props/emits from generic forms BEFORE type stripping
|
|
89
|
+
const propsFromGeneric = extractPropsGeneric(source);
|
|
90
|
+
const propsObjectNameFromGeneric = extractPropsObjectName(source);
|
|
91
|
+
const emitsFromCallSignatures = extractEmitsFromCallSignatures(source);
|
|
92
|
+
const emitsObjectNameFromGeneric = extractEmitsObjectNameFromGeneric(source);
|
|
86
93
|
|
|
87
|
-
//
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
const propNames = new Set((parseResult.propDefs || []).map(p => p.name));
|
|
94
|
+
// 4. Validate props/emits assignment (before type strip)
|
|
95
|
+
validatePropsAssignment(source, filePath);
|
|
96
|
+
validateEmitsAssignment(source, filePath);
|
|
91
97
|
|
|
92
|
-
//
|
|
93
|
-
|
|
98
|
+
// 5. Strip TypeScript types if lang === 'ts'
|
|
99
|
+
if (descriptor.lang === 'ts') {
|
|
100
|
+
source = await stripTypes(source);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// 6. Extract component metadata
|
|
104
|
+
const tagName = descriptor.tag;
|
|
105
|
+
const className = toClassName(tagName);
|
|
106
|
+
const template = descriptor.template;
|
|
107
|
+
const style = descriptor.style;
|
|
94
108
|
|
|
95
|
-
//
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
109
|
+
// 7. Extract lifecycle hooks (before other extractions)
|
|
110
|
+
const { onMountHooks, onDestroyHooks } = extractLifecycleHooks(source);
|
|
111
|
+
|
|
112
|
+
// 7b. Strip lifecycle/watcher blocks from source for extraction
|
|
113
|
+
let sourceForExtraction = source;
|
|
114
|
+
const hookLinePattern = /\bonMount\s*\(|\bonDestroy\s*\(|\bwatch\s*\(/;
|
|
115
|
+
const sourceLines = sourceForExtraction.split('\n');
|
|
116
|
+
const filteredLines = [];
|
|
117
|
+
let skipDepth = 0;
|
|
118
|
+
let skipping = false;
|
|
119
|
+
for (const line of sourceLines) {
|
|
120
|
+
if (!skipping && hookLinePattern.test(line)) {
|
|
121
|
+
skipping = true;
|
|
122
|
+
skipDepth = 0;
|
|
123
|
+
for (const ch of line) {
|
|
124
|
+
if (ch === '{') skipDepth++;
|
|
125
|
+
if (ch === '}') skipDepth--;
|
|
126
|
+
}
|
|
127
|
+
if (skipDepth <= 0) skipping = false;
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
if (skipping) {
|
|
131
|
+
for (const ch of line) {
|
|
132
|
+
if (ch === '{') skipDepth++;
|
|
133
|
+
if (ch === '}') skipDepth--;
|
|
134
|
+
}
|
|
135
|
+
if (skipDepth <= 0) skipping = false;
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
filteredLines.push(line);
|
|
139
|
+
}
|
|
140
|
+
sourceForExtraction = filteredLines.join('\n');
|
|
141
|
+
|
|
142
|
+
// 8. Extract reactive declarations and functions
|
|
143
|
+
const signals = extractSignals(sourceForExtraction);
|
|
144
|
+
const computeds = extractComputeds(sourceForExtraction);
|
|
145
|
+
const effects = extractEffects(sourceForExtraction);
|
|
146
|
+
const watchers = extractWatchers(source);
|
|
147
|
+
const methods = extractFunctions(sourceForExtraction);
|
|
148
|
+
const refs = extractRefs(sourceForExtraction);
|
|
149
|
+
const constantVars = extractConstants(sourceForExtraction);
|
|
150
|
+
|
|
151
|
+
// 9. Extract props (array form — after type strip, if generic didn't find any)
|
|
152
|
+
const propsFromArray = propsFromGeneric.length > 0 ? [] : extractPropsArray(source);
|
|
153
|
+
let propNames = propsFromGeneric.length > 0 ? propsFromGeneric : propsFromArray;
|
|
154
|
+
|
|
155
|
+
// 10. Extract props defaults
|
|
156
|
+
const propsDefaults = extractPropsDefaults(source);
|
|
157
|
+
if (propNames.length === 0 && Object.keys(propsDefaults).length > 0) {
|
|
158
|
+
propNames = Object.keys(propsDefaults);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// 11. Extract propsObjectName
|
|
162
|
+
const propsObjectName = propsObjectNameFromGeneric ?? extractPropsObjectName(source);
|
|
163
|
+
|
|
164
|
+
// 12. Validate props
|
|
165
|
+
validateDuplicateProps(propNames, filePath);
|
|
166
|
+
const signalNameSet = new Set(signals.map(s => s.name));
|
|
167
|
+
const computedNameSet = new Set(computeds.map(c => c.name));
|
|
168
|
+
const constantNameSet = new Set(constantVars.map(v => v.name));
|
|
169
|
+
validatePropsConflicts(propsObjectName, signalNameSet, computedNameSet, constantNameSet, filePath);
|
|
170
|
+
|
|
171
|
+
/** @type {import('./types.js').PropDef[]} */
|
|
172
|
+
const propDefs = propNames.map(name => ({
|
|
173
|
+
name,
|
|
174
|
+
default: propsDefaults[name] ?? 'undefined',
|
|
175
|
+
attrName: camelToKebab(name),
|
|
176
|
+
}));
|
|
177
|
+
|
|
178
|
+
// 13. Extract emits
|
|
179
|
+
const emitsFromArray = emitsFromCallSignatures.length > 0 ? [] : extractEmits(source);
|
|
180
|
+
const emitNames = emitsFromCallSignatures.length > 0 ? emitsFromCallSignatures : emitsFromArray;
|
|
181
|
+
const emitsObjectName = emitsObjectNameFromGeneric ?? extractEmitsObjectName(source);
|
|
182
|
+
|
|
183
|
+
// 14. Validate emits
|
|
184
|
+
validateDuplicateEmits(emitNames, filePath);
|
|
185
|
+
const propNameSet = new Set(propNames);
|
|
186
|
+
validateEmitsConflicts(emitsObjectName, signalNameSet, computedNameSet, constantNameSet, propNameSet, propsObjectName, filePath);
|
|
187
|
+
validateUndeclaredEmits(source, emitsObjectName, emitNames, filePath);
|
|
188
|
+
|
|
189
|
+
// 15. Build initial ParseResult
|
|
190
|
+
/** @type {import('./types.js').ParseResult} */
|
|
191
|
+
const parseResult = {
|
|
192
|
+
tagName,
|
|
193
|
+
className,
|
|
194
|
+
template,
|
|
195
|
+
style,
|
|
196
|
+
signals,
|
|
197
|
+
computeds,
|
|
198
|
+
effects,
|
|
199
|
+
constantVars,
|
|
200
|
+
watchers,
|
|
201
|
+
methods,
|
|
202
|
+
propDefs,
|
|
203
|
+
propsObjectName: propsObjectName ?? null,
|
|
204
|
+
emits: emitNames,
|
|
205
|
+
emitsObjectName: emitsObjectName ?? null,
|
|
206
|
+
bindings: [],
|
|
207
|
+
events: [],
|
|
208
|
+
processedTemplate: null,
|
|
209
|
+
ifBlocks: [],
|
|
210
|
+
showBindings: [],
|
|
211
|
+
forBlocks: [],
|
|
212
|
+
onMountHooks,
|
|
213
|
+
onDestroyHooks,
|
|
214
|
+
modelBindings: [],
|
|
215
|
+
attrBindings: [],
|
|
216
|
+
slots: [],
|
|
217
|
+
refs,
|
|
218
|
+
refBindings: [],
|
|
219
|
+
childComponents: [],
|
|
220
|
+
childImports: [],
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
// 16. Process template through jsdom → tree-walker → codegen (same as compile())
|
|
224
|
+
const dom = new JSDOM(`<div id="__root">${template}</div>`);
|
|
225
|
+
const rootEl = dom.window.document.getElementById('__root');
|
|
226
|
+
|
|
227
|
+
const signalNames = new Set(signals.map(s => s.name));
|
|
228
|
+
const computedNames = new Set(computeds.map(c => c.name));
|
|
229
|
+
const propNamesSet = new Set(propDefs.map(p => p.name));
|
|
230
|
+
|
|
231
|
+
const forBlocks = processForBlocks(rootEl, [], signalNames, computedNames, propNamesSet);
|
|
232
|
+
const ifBlocks = processIfChains(rootEl, [], signalNames, computedNames, propNamesSet);
|
|
99
233
|
|
|
100
|
-
// 6. Normalize DOM after all directive processing to merge adjacent text nodes
|
|
101
234
|
rootEl.normalize();
|
|
102
235
|
|
|
103
|
-
// 7. Recompute anchor paths after normalization since text node merging
|
|
104
|
-
// may have changed childNode indices
|
|
105
236
|
for (const fb of forBlocks) {
|
|
106
237
|
fb.anchorPath = recomputeAnchorPath(rootEl, fb._anchorNode);
|
|
107
238
|
}
|
|
@@ -109,16 +240,11 @@ export async function compile(filePath, config) {
|
|
|
109
240
|
ib.anchorPath = recomputeAnchorPath(rootEl, ib._anchorNode);
|
|
110
241
|
}
|
|
111
242
|
|
|
112
|
-
|
|
113
|
-
const { bindings, events, showBindings, modelBindings, attrBindings, slots, childComponents } = walkTree(rootEl, signalNames, computedNames, propNames);
|
|
243
|
+
const { bindings, events, showBindings, modelBindings, attrBindings, slots, childComponents } = walkTree(rootEl, signalNames, computedNames, propNamesSet);
|
|
114
244
|
|
|
115
|
-
// 9. Detect refs (after walkTree — ref attributes are compile-time directives)
|
|
116
245
|
const refBindings = detectRefs(rootEl);
|
|
117
246
|
|
|
118
|
-
//
|
|
119
|
-
const refs = parseResult.refs || [];
|
|
120
|
-
|
|
121
|
-
// REF_NOT_FOUND: templateRef('name') with no matching ref="name" in template
|
|
247
|
+
// 17. Validate refs
|
|
122
248
|
for (const decl of refs) {
|
|
123
249
|
if (!refBindings.find(b => b.refName === decl.refName)) {
|
|
124
250
|
const error = new Error(`templateRef('${decl.refName}') has no matching ref="${decl.refName}" in template`);
|
|
@@ -127,18 +253,16 @@ export async function compile(filePath, config) {
|
|
|
127
253
|
throw error;
|
|
128
254
|
}
|
|
129
255
|
}
|
|
130
|
-
|
|
131
|
-
// Unused ref warning: ref="name" in template with no matching templateRef('name') in script
|
|
132
256
|
for (const binding of refBindings) {
|
|
133
257
|
if (!refs.find(d => d.refName === binding.refName)) {
|
|
134
258
|
console.warn(`Warning: ref="${binding.refName}" in template has no matching templateRef('${binding.refName}') in script`);
|
|
135
259
|
}
|
|
136
260
|
}
|
|
137
261
|
|
|
138
|
-
//
|
|
139
|
-
const
|
|
262
|
+
// 17b. Validate model bindings
|
|
263
|
+
const constantNamesForModel = new Set(constantVars.map(v => v.name));
|
|
140
264
|
for (const mb of modelBindings) {
|
|
141
|
-
if (
|
|
265
|
+
if (propNamesSet.has(mb.signal)) {
|
|
142
266
|
const error = new Error(`model cannot bind to prop '${mb.signal}' (read-only)`);
|
|
143
267
|
/** @ts-expect-error — custom error code */
|
|
144
268
|
error.code = 'MODEL_READONLY';
|
|
@@ -150,7 +274,7 @@ export async function compile(filePath, config) {
|
|
|
150
274
|
error.code = 'MODEL_READONLY';
|
|
151
275
|
throw error;
|
|
152
276
|
}
|
|
153
|
-
if (
|
|
277
|
+
if (constantNamesForModel.has(mb.signal)) {
|
|
154
278
|
const error = new Error(`model cannot bind to constant '${mb.signal}' (read-only)`);
|
|
155
279
|
/** @ts-expect-error — custom error code */
|
|
156
280
|
error.code = 'MODEL_READONLY';
|
|
@@ -164,7 +288,7 @@ export async function compile(filePath, config) {
|
|
|
164
288
|
}
|
|
165
289
|
}
|
|
166
290
|
|
|
167
|
-
//
|
|
291
|
+
// 18. Resolve child component imports
|
|
168
292
|
/** @type {import('./types.js').ChildComponentImport[]} */
|
|
169
293
|
const childImports = [];
|
|
170
294
|
if (childComponents.length > 0) {
|
|
@@ -181,7 +305,7 @@ export async function compile(filePath, config) {
|
|
|
181
305
|
}
|
|
182
306
|
}
|
|
183
307
|
|
|
184
|
-
//
|
|
308
|
+
// 19. Merge tree-walker results into ParseResult
|
|
185
309
|
parseResult.bindings = bindings;
|
|
186
310
|
parseResult.events = events;
|
|
187
311
|
parseResult.showBindings = showBindings;
|
|
@@ -193,9 +317,19 @@ export async function compile(filePath, config) {
|
|
|
193
317
|
parseResult.refBindings = refBindings;
|
|
194
318
|
parseResult.childComponents = childComponents;
|
|
195
319
|
parseResult.childImports = childImports;
|
|
196
|
-
// Recompute processedTemplate after all directive replacements (including ref removal)
|
|
197
320
|
parseResult.processedTemplate = rootEl.innerHTML;
|
|
198
321
|
|
|
199
|
-
//
|
|
322
|
+
// 20. Generate component
|
|
200
323
|
return generateComponent(parseResult);
|
|
201
324
|
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Compile a single .wcc SFC file into a self-contained JS component.
|
|
328
|
+
*
|
|
329
|
+
* @param {string} filePath — Absolute or relative path to the .wcc file
|
|
330
|
+
* @param {object} [config] — Optional config (reserved for future options)
|
|
331
|
+
* @returns {Promise<string>} The generated JavaScript component code
|
|
332
|
+
*/
|
|
333
|
+
export async function compile(filePath, config) {
|
|
334
|
+
return compileSFC(filePath, config);
|
|
335
|
+
}
|
package/lib/dev-server.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Dev Server — static HTTP server with
|
|
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
|
|
36
|
+
const SSE_SNIPPET = `<script>
|
|
34
37
|
(function() {
|
|
35
|
-
var
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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:
|
|
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
|
|
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>',
|
|
116
|
+
html = html.replace('</body>', SSE_SNIPPET + '\n</body>');
|
|
84
117
|
} else {
|
|
85
|
-
html += '\n' +
|
|
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 —
|
|
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(() =>
|
|
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
|
},
|