@zenithbuild/cli 0.6.5 → 0.6.7

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.
Files changed (43) hide show
  1. package/dist/build.d.ts +32 -0
  2. package/dist/build.js +193 -548
  3. package/dist/compiler-bridge-runner.d.ts +5 -0
  4. package/dist/compiler-bridge-runner.js +70 -0
  5. package/dist/component-instance-ir.d.ts +6 -0
  6. package/dist/component-instance-ir.js +0 -20
  7. package/dist/component-occurrences.d.ts +6 -0
  8. package/dist/component-occurrences.js +6 -28
  9. package/dist/dev-server.d.ts +18 -0
  10. package/dist/dev-server.js +76 -116
  11. package/dist/dev-watch.d.ts +1 -0
  12. package/dist/dev-watch.js +19 -0
  13. package/dist/index.d.ts +8 -0
  14. package/dist/index.js +6 -28
  15. package/dist/manifest.d.ts +23 -0
  16. package/dist/manifest.js +22 -48
  17. package/dist/preview.d.ts +100 -0
  18. package/dist/preview.js +418 -488
  19. package/dist/resolve-components.d.ts +39 -0
  20. package/dist/resolve-components.js +30 -104
  21. package/dist/server/resolve-request-route.d.ts +39 -0
  22. package/dist/server/resolve-request-route.js +104 -113
  23. package/dist/server-contract.d.ts +39 -0
  24. package/dist/server-contract.js +15 -67
  25. package/dist/toolchain-paths.d.ts +23 -0
  26. package/dist/toolchain-paths.js +111 -39
  27. package/dist/toolchain-runner.d.ts +33 -0
  28. package/dist/toolchain-runner.js +170 -0
  29. package/dist/types/generate-env-dts.d.ts +5 -0
  30. package/dist/types/generate-env-dts.js +4 -2
  31. package/dist/types/generate-routes-dts.d.ts +8 -0
  32. package/dist/types/generate-routes-dts.js +7 -5
  33. package/dist/types/index.d.ts +14 -0
  34. package/dist/types/index.js +16 -7
  35. package/dist/ui/env.d.ts +18 -0
  36. package/dist/ui/env.js +0 -12
  37. package/dist/ui/format.d.ts +33 -0
  38. package/dist/ui/format.js +7 -45
  39. package/dist/ui/logger.d.ts +59 -0
  40. package/dist/ui/logger.js +3 -32
  41. package/dist/version-check.d.ts +54 -0
  42. package/dist/version-check.js +41 -98
  43. package/package.json +17 -5
@@ -10,22 +10,15 @@
10
10
  //
11
11
  // V0: Uses Node.js http module + fs.watch. No external deps.
12
12
  // ---------------------------------------------------------------------------
13
-
14
13
  import { createServer } from 'node:http';
15
14
  import { existsSync, watch } from 'node:fs';
16
15
  import { readFile, stat } from 'node:fs/promises';
17
16
  import { basename, dirname, extname, isAbsolute, join, relative, resolve } from 'node:path';
18
17
  import { build } from './build.js';
19
18
  import { createSilentLogger } from './ui/logger.js';
20
- import {
21
- executeServerRoute,
22
- injectSsrPayload,
23
- loadRouteManifest,
24
- resolveWithinDist,
25
- toStaticFilePath
26
- } from './preview.js';
19
+ import { readChangeFingerprint } from './dev-watch.js';
20
+ import { executeServerRoute, injectSsrPayload, loadRouteManifest, resolveWithinDist, toStaticFilePath } from './preview.js';
27
21
  import { resolveRequestRoute } from './server/resolve-request-route.js';
28
-
29
22
  const MIME_TYPES = {
30
23
  '.html': 'text/html',
31
24
  '.js': 'application/javascript',
@@ -35,10 +28,8 @@ const MIME_TYPES = {
35
28
  '.jpg': 'image/jpeg',
36
29
  '.svg': 'image/svg+xml'
37
30
  };
38
-
39
31
  // Note: V0 HMR script injection has been moved to the runtime client.
40
32
  // This server purely hosts the V1 HMR contract endpoints.
41
-
42
33
  /**
43
34
  * Create and start a development server.
44
35
  *
@@ -46,16 +37,8 @@ const MIME_TYPES = {
46
37
  * @returns {Promise<{ server: import('http').Server, port: number, close: () => void }>}
47
38
  */
48
39
  export async function createDevServer(options) {
49
- const {
50
- pagesDir,
51
- outDir,
52
- port = 3000,
53
- host = '127.0.0.1',
54
- config = {},
55
- logger: providedLogger = null
56
- } = options;
40
+ const { pagesDir, outDir, port = 3000, host = '127.0.0.1', config = {}, logger: providedLogger = null } = options;
57
41
  const logger = providedLogger || createSilentLogger();
58
-
59
42
  const resolvedPagesDir = resolve(pagesDir);
60
43
  const resolvedOutDir = resolve(outDir);
61
44
  const resolvedOutDirTmp = resolve(dirname(resolvedOutDir), `${basename(resolvedOutDir)}.tmp`);
@@ -64,7 +47,6 @@ export async function createDevServer(options) {
64
47
  ? dirname(pagesParentDir)
65
48
  : pagesParentDir;
66
49
  const watchRoots = new Set([pagesParentDir]);
67
-
68
50
  /** @type {import('http').ServerResponse[]} */
69
51
  const hmrClients = [];
70
52
  /** @type {import('fs').FSWatcher[]} */
@@ -73,12 +55,12 @@ export async function createDevServer(options) {
73
55
  for (const client of hmrClients) {
74
56
  try {
75
57
  client.write(': ping\n\n');
76
- } catch {
58
+ }
59
+ catch {
77
60
  // client disconnected
78
61
  }
79
62
  }
80
63
  }, 15000);
81
-
82
64
  let buildId = 0;
83
65
  let pendingBuildId = 0;
84
66
  let buildStatus = 'ok'; // 'ok' | 'error' | 'building'
@@ -87,44 +69,44 @@ export async function createDevServer(options) {
87
69
  let buildError = null;
88
70
  const traceEnabled = config.devTrace === true || process.env.ZENITH_DEV_TRACE === '1';
89
71
  const verboseLogging = traceEnabled || logger.mode?.logLevel === 'verbose';
90
-
91
72
  // Stable dev CSS endpoint points to this backing asset.
92
73
  let currentCssAssetPath = '';
93
74
  let currentCssHref = '';
94
75
  let currentCssContent = '';
95
76
  let actualPort = port;
96
-
97
77
  function _publicHost() {
98
78
  if (host === '0.0.0.0' || host === '::') {
99
79
  return '127.0.0.1';
100
80
  }
101
81
  return host;
102
82
  }
103
-
104
83
  function _serverOrigin() {
105
84
  return `http://${_publicHost()}:${actualPort}`;
106
85
  }
107
-
108
86
  function _trace(event, payload = {}) {
109
- if (!traceEnabled) return;
87
+ if (!traceEnabled)
88
+ return;
110
89
  try {
111
90
  const detail = Object.keys(payload).length > 0
112
91
  ? `${event} ${JSON.stringify(payload)}`
113
92
  : event;
114
93
  logger.verbose('BUILD', detail);
115
- } catch {
94
+ }
95
+ catch {
116
96
  // tracing must never break the dev server
117
97
  }
118
98
  }
119
-
120
99
  function _classifyPath(pathname) {
121
- if (pathname.startsWith('/__zenith_dev/events')) return 'dev_events';
122
- if (pathname.startsWith('/__zenith_dev/state')) return 'dev_state';
123
- if (pathname.startsWith('/__zenith_dev/styles.css')) return 'dev_styles';
124
- if (pathname.startsWith('/assets/')) return 'asset';
100
+ if (pathname.startsWith('/__zenith_dev/events'))
101
+ return 'dev_events';
102
+ if (pathname.startsWith('/__zenith_dev/state'))
103
+ return 'dev_state';
104
+ if (pathname.startsWith('/__zenith_dev/styles.css'))
105
+ return 'dev_styles';
106
+ if (pathname.startsWith('/assets/'))
107
+ return 'asset';
125
108
  return 'other';
126
109
  }
127
-
128
110
  function _trace404(req, url, details = {}) {
129
111
  _trace('http_404', {
130
112
  method: req.method || 'GET',
@@ -133,7 +115,6 @@ export async function createDevServer(options) {
133
115
  ...details
134
116
  });
135
117
  }
136
-
137
118
  function _pickCssAsset(assets) {
138
119
  if (!Array.isArray(assets) || assets.length === 0) {
139
120
  return '';
@@ -147,11 +128,9 @@ export async function createDevServer(options) {
147
128
  const preferred = cssAssets.find((entry) => /\/styles(\.|\/|$)/.test(entry));
148
129
  return preferred || cssAssets[0];
149
130
  }
150
-
151
131
  function _delay(ms) {
152
132
  return new Promise((resolveDelay) => setTimeout(resolveDelay, ms));
153
133
  }
154
-
155
134
  async function _waitForCssFile(absolutePath, retries = 16, delayMs = 40) {
156
135
  for (let i = 0; i <= retries; i++) {
157
136
  try {
@@ -159,7 +138,8 @@ export async function createDevServer(options) {
159
138
  if (info.isFile()) {
160
139
  return true;
161
140
  }
162
- } catch {
141
+ }
142
+ catch {
163
143
  // keep retrying
164
144
  }
165
145
  if (i < retries) {
@@ -168,7 +148,6 @@ export async function createDevServer(options) {
168
148
  }
169
149
  return false;
170
150
  }
171
-
172
151
  async function _syncCssStateFromBuild(buildResult, nextBuildId) {
173
152
  currentCssHref = `/__zenith_dev/styles.css?buildId=${nextBuildId}`;
174
153
  const candidate = _pickCssAsset(buildResult?.assets);
@@ -176,7 +155,6 @@ export async function createDevServer(options) {
176
155
  _trace('css_sync_skipped', { reason: 'no_css_asset', buildId: nextBuildId });
177
156
  return false;
178
157
  }
179
-
180
158
  const absoluteCssPath = join(outDir, candidate);
181
159
  const ready = await _waitForCssFile(absoluteCssPath);
182
160
  if (!ready) {
@@ -188,11 +166,11 @@ export async function createDevServer(options) {
188
166
  });
189
167
  return false;
190
168
  }
191
-
192
169
  let cssContent = '';
193
170
  try {
194
171
  cssContent = await readFile(absoluteCssPath, 'utf8');
195
- } catch {
172
+ }
173
+ catch {
196
174
  _trace('css_sync_skipped', {
197
175
  reason: 'css_read_failed',
198
176
  buildId: nextBuildId,
@@ -219,12 +197,10 @@ export async function createDevServer(options) {
219
197
  });
220
198
  cssContent = '/* zenith-dev: empty css */';
221
199
  }
222
-
223
200
  currentCssAssetPath = candidate;
224
201
  currentCssContent = cssContent;
225
202
  return true;
226
203
  }
227
-
228
204
  function _broadcastEvent(type, payload = {}) {
229
205
  const eventBuildId = Number.isInteger(payload.buildId) ? payload.buildId : buildId;
230
206
  const data = JSON.stringify({
@@ -241,12 +217,12 @@ export async function createDevServer(options) {
241
217
  for (const client of hmrClients) {
242
218
  try {
243
219
  client.write(`event: ${type}\ndata: ${data}\n\n`);
244
- } catch {
220
+ }
221
+ catch {
245
222
  // client disconnected
246
223
  }
247
224
  }
248
225
  }
249
-
250
226
  // Initial build
251
227
  try {
252
228
  logger.build('Initial build (id=0)', { onceKey: 'dev-initial-build' });
@@ -255,7 +231,8 @@ export async function createDevServer(options) {
255
231
  if (currentCssHref.length > 0) {
256
232
  logger.css(`ready (${currentCssHref})`, { onceKey: `css-ready:${buildId}:${currentCssHref}` });
257
233
  }
258
- } catch (err) {
234
+ }
235
+ catch (err) {
259
236
  buildStatus = 'error';
260
237
  buildError = { message: err instanceof Error ? err.message : String(err) };
261
238
  logger.error('initial build failed', {
@@ -263,14 +240,12 @@ export async function createDevServer(options) {
263
240
  error: err
264
241
  });
265
242
  }
266
-
267
243
  const server = createServer(async (req, res) => {
268
244
  const requestBase = typeof req.headers.host === 'string' && req.headers.host.length > 0
269
245
  ? `http://${req.headers.host}`
270
246
  : _serverOrigin();
271
247
  const url = new URL(req.url, requestBase);
272
248
  let pathname = url.pathname;
273
-
274
249
  // Legacy HMR endpoint (deprecated but kept alive to avoid breaking old caches instantly)
275
250
  if (pathname === '/__zenith_hmr') {
276
251
  res.writeHead(200, {
@@ -287,11 +262,11 @@ export async function createDevServer(options) {
287
262
  hmrClients.push(res);
288
263
  req.on('close', () => {
289
264
  const idx = hmrClients.indexOf(res);
290
- if (idx !== -1) hmrClients.splice(idx, 1);
265
+ if (idx !== -1)
266
+ hmrClients.splice(idx, 1);
291
267
  });
292
268
  return;
293
269
  }
294
-
295
270
  // V1 Dev State Endpoint
296
271
  if (pathname === '/__zenith_dev/state') {
297
272
  res.writeHead(200, {
@@ -309,7 +284,6 @@ export async function createDevServer(options) {
309
284
  }));
310
285
  return;
311
286
  }
312
-
313
287
  // V1 Dev Events Endpoint (SSE)
314
288
  if (pathname === '/__zenith_dev/events') {
315
289
  res.writeHead(200, {
@@ -323,11 +297,11 @@ export async function createDevServer(options) {
323
297
  hmrClients.push(res);
324
298
  req.on('close', () => {
325
299
  const idx = hmrClients.indexOf(res);
326
- if (idx !== -1) hmrClients.splice(idx, 1);
300
+ if (idx !== -1)
301
+ hmrClients.splice(idx, 1);
327
302
  });
328
303
  return;
329
304
  }
330
-
331
305
  if (pathname === '/__zenith_dev/styles.css') {
332
306
  if (typeof currentCssContent === 'string' && currentCssContent.length > 0) {
333
307
  res.writeHead(200, {
@@ -345,7 +319,8 @@ export async function createDevServer(options) {
345
319
  if (typeof css === 'string' && css.length > 0) {
346
320
  currentCssContent = css;
347
321
  }
348
- } catch {
322
+ }
323
+ catch {
349
324
  // keep serving last known CSS body below
350
325
  }
351
326
  }
@@ -364,7 +339,6 @@ export async function createDevServer(options) {
364
339
  res.end(currentCssContent);
365
340
  return;
366
341
  }
367
-
368
342
  if (pathname === '/__zenith/route-check') {
369
343
  try {
370
344
  // Security: Require explicitly designated header to prevent public oracle probing
@@ -373,23 +347,19 @@ export async function createDevServer(options) {
373
347
  res.end(JSON.stringify({ error: 'forbidden', message: 'invalid request context' }));
374
348
  return;
375
349
  }
376
-
377
350
  const targetPath = String(url.searchParams.get('path') || '/');
378
-
379
351
  // Security: Prevent protocol/domain injection in path
380
352
  if (targetPath.includes('://') || targetPath.startsWith('//') || /[\r\n]/.test(targetPath)) {
381
353
  res.writeHead(400, { 'Content-Type': 'application/json' });
382
354
  res.end(JSON.stringify({ error: 'invalid_path_format' }));
383
355
  return;
384
356
  }
385
-
386
357
  const targetUrl = new URL(targetPath, url.origin);
387
358
  if (targetUrl.origin !== url.origin) {
388
359
  res.writeHead(400, { 'Content-Type': 'application/json' });
389
360
  res.end(JSON.stringify({ error: 'external_route_evaluation_forbidden' }));
390
361
  return;
391
362
  }
392
-
393
363
  const routes = await loadRouteManifest(outDir);
394
364
  const resolvedCheck = resolveRequestRoute(targetUrl, routes);
395
365
  if (!resolvedCheck.matched || !resolvedCheck.route) {
@@ -397,7 +367,6 @@ export async function createDevServer(options) {
397
367
  res.end(JSON.stringify({ error: 'route_not_found' }));
398
368
  return;
399
369
  }
400
-
401
370
  const checkResult = await executeServerRoute({
402
371
  source: resolvedCheck.route.server_script || '',
403
372
  sourcePath: resolvedCheck.route.server_script_path || '',
@@ -419,12 +388,12 @@ export async function createDevServer(options) {
419
388
  if (parsedLoc.origin !== targetUrl.origin) {
420
389
  checkResult.result.location = '/'; // Fallback to root for open redirect attempt
421
390
  }
422
- } catch {
391
+ }
392
+ catch {
423
393
  checkResult.result.location = '/';
424
394
  }
425
395
  }
426
396
  }
427
-
428
397
  res.writeHead(200, {
429
398
  'Content-Type': 'application/json',
430
399
  'Cache-Control': 'no-store, no-cache, must-revalidate, proxy-revalidate',
@@ -438,13 +407,13 @@ export async function createDevServer(options) {
438
407
  to: targetUrl.toString()
439
408
  }));
440
409
  return;
441
- } catch {
410
+ }
411
+ catch {
442
412
  res.writeHead(500, { 'Content-Type': 'application/json' });
443
413
  res.end(JSON.stringify({ error: 'route_check_failed' }));
444
414
  return;
445
415
  }
446
416
  }
447
-
448
417
  let resolvedPathFor404 = null;
449
418
  let staticRootFor404 = null;
450
419
  try {
@@ -459,32 +428,26 @@ export async function createDevServer(options) {
459
428
  res.end(asset);
460
429
  return;
461
430
  }
462
-
463
431
  const routes = await loadRouteManifest(outDir);
464
432
  const resolved = resolveRequestRoute(url, routes);
465
433
  let filePath = null;
466
-
467
434
  if (resolved.matched && resolved.route) {
468
435
  if (verboseLogging) {
469
- logger.router(
470
- `${req.method || 'GET'} ${pathname} -> ${resolved.route.path} params=${JSON.stringify(resolved.params)}`
471
- );
436
+ logger.router(`${req.method || 'GET'} ${pathname} -> ${resolved.route.path} params=${JSON.stringify(resolved.params)}`);
472
437
  }
473
438
  const output = resolved.route.output.startsWith('/')
474
439
  ? resolved.route.output.slice(1)
475
440
  : resolved.route.output;
476
441
  filePath = resolveWithinDist(outDir, output);
477
- } else {
442
+ }
443
+ else {
478
444
  filePath = toStaticFilePath(outDir, pathname);
479
445
  }
480
-
481
446
  resolvedPathFor404 = filePath;
482
447
  staticRootFor404 = outDir;
483
-
484
448
  if (!filePath) {
485
449
  throw new Error('not found');
486
450
  }
487
-
488
451
  let ssrPayload = null;
489
452
  if (resolved.matched && resolved.route?.server_script && resolved.route.prerender !== true) {
490
453
  let routeExecution = null;
@@ -500,7 +463,8 @@ export async function createDevServer(options) {
500
463
  routeFile: resolved.route.server_script_path || '',
501
464
  routeId: resolved.route.route_id || ''
502
465
  });
503
- } catch (error) {
466
+ }
467
+ catch (error) {
504
468
  ssrPayload = {
505
469
  __zenith_error: {
506
470
  code: 'LOAD_FAILED',
@@ -508,15 +472,11 @@ export async function createDevServer(options) {
508
472
  }
509
473
  };
510
474
  }
511
-
512
475
  const trace = routeExecution?.trace || { guard: 'none', load: 'none' };
513
476
  const routeId = resolved.route.route_id || '';
514
477
  if (verboseLogging) {
515
- logger.router(
516
- `${routeId || resolved.route.path} guard=${trace.guard} load=${trace.load}`
517
- );
478
+ logger.router(`${routeId || resolved.route.path} guard=${trace.guard} load=${trace.load}`);
518
479
  }
519
-
520
480
  const result = routeExecution?.result;
521
481
  if (result && result.kind === 'redirect') {
522
482
  const status = Number.isInteger(result.status) ? result.status : 302;
@@ -537,14 +497,14 @@ export async function createDevServer(options) {
537
497
  ssrPayload = result.data;
538
498
  }
539
499
  }
540
-
541
500
  let content = await readFile(filePath, 'utf8');
542
501
  if (ssrPayload) {
543
502
  content = injectSsrPayload(content, ssrPayload);
544
503
  }
545
504
  res.writeHead(200, { 'Content-Type': 'text/html' });
546
505
  res.end(content);
547
- } catch {
506
+ }
507
+ catch {
548
508
  _trace404(req, url, {
549
509
  reason: 'not_found',
550
510
  staticRoot: staticRootFor404,
@@ -554,7 +514,6 @@ export async function createDevServer(options) {
554
514
  res.end('404 Not Found');
555
515
  }
556
516
  });
557
-
558
517
  /**
559
518
  * Broadcast HMR reload to all connected clients.
560
519
  */
@@ -562,30 +521,29 @@ export async function createDevServer(options) {
562
521
  for (const client of hmrClients) {
563
522
  try {
564
523
  client.write('data: reload\n\n');
565
- } catch {
524
+ }
525
+ catch {
566
526
  // client disconnected
567
527
  }
568
528
  }
569
529
  }
570
-
571
530
  let _buildDebounce = null;
572
531
  let _queuedFiles = new Set();
532
+ const _lastQueuedFingerprints = new Map();
573
533
  let _buildInFlight = false;
574
-
575
534
  function _isWithin(parent, child) {
576
535
  const rel = relative(parent, child);
577
536
  return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel));
578
537
  }
579
-
580
538
  function _toDisplayPath(absPath) {
581
539
  const rel = relative(projectRoot, absPath);
582
- if (rel === '') return '.';
540
+ if (rel === '')
541
+ return '.';
583
542
  if (!rel.startsWith('..') && !isAbsolute(rel)) {
584
543
  return rel;
585
544
  }
586
545
  return absPath;
587
546
  }
588
-
589
547
  function _shouldIgnoreChange(absPath) {
590
548
  if (_isWithin(resolvedOutDir, absPath)) {
591
549
  return true;
@@ -604,7 +562,6 @@ export async function createDevServer(options) {
604
562
  || segments.includes('target')
605
563
  || segments.includes('.turbo');
606
564
  }
607
-
608
565
  /**
609
566
  * Start watching source roots for changes.
610
567
  */
@@ -618,7 +575,6 @@ export async function createDevServer(options) {
618
575
  void drainBuildQueue();
619
576
  }, delayMs);
620
577
  };
621
-
622
578
  const drainBuildQueue = async () => {
623
579
  if (_buildInFlight) {
624
580
  return;
@@ -628,39 +584,33 @@ export async function createDevServer(options) {
628
584
  return;
629
585
  }
630
586
  _queuedFiles.clear();
631
-
632
587
  _buildInFlight = true;
633
588
  const cycleBuildId = pendingBuildId + 1;
634
589
  pendingBuildId = cycleBuildId;
635
590
  buildStatus = 'building';
636
591
  logger.build(`Rebuild (id=${cycleBuildId})`);
637
592
  _broadcastEvent('build_start', { buildId: cycleBuildId, changedFiles: changed });
638
-
639
593
  const startTime = Date.now();
640
594
  const previousCssAssetPath = currentCssAssetPath;
641
595
  const previousCssContent = currentCssContent;
642
596
  try {
643
597
  const buildResult = await build({ pagesDir, outDir, config, logger });
644
598
  const cssReady = await _syncCssStateFromBuild(buildResult, cycleBuildId);
645
- const cssChanged = cssReady && (
646
- currentCssAssetPath !== previousCssAssetPath ||
647
- currentCssContent !== previousCssContent
648
- );
599
+ const cssChanged = cssReady && (currentCssAssetPath !== previousCssAssetPath ||
600
+ currentCssContent !== previousCssContent);
649
601
  buildId = cycleBuildId;
650
602
  buildStatus = 'ok';
651
603
  buildError = null;
652
604
  lastBuildMs = Date.now();
653
605
  durationMs = lastBuildMs - startTime;
654
606
  logger.build(`Complete (id=${cycleBuildId}, ${durationMs}ms)`);
655
-
656
607
  _broadcastEvent('build_complete', {
657
608
  buildId: cycleBuildId,
658
609
  durationMs,
659
610
  status: buildStatus,
660
611
  cssHref: currentCssHref,
661
612
  changedFiles: changed
662
- }
663
- );
613
+ });
664
614
  _trace('state_snapshot', {
665
615
  status: buildStatus,
666
616
  buildId: cycleBuildId,
@@ -668,18 +618,17 @@ export async function createDevServer(options) {
668
618
  durationMs,
669
619
  changedFiles: changed
670
620
  });
671
-
672
621
  if (cssChanged && currentCssHref.length > 0) {
673
622
  logger.css(`ready (${currentCssHref})`);
674
623
  logger.hmr(`css_update (buildId=${cycleBuildId})`);
675
624
  _broadcastEvent('css_update', { href: currentCssHref, changedFiles: changed });
676
625
  }
677
-
678
626
  const onlyCss = changed.length > 0 && changed.every((f) => f.endsWith('.css'));
679
627
  if (!onlyCss) {
680
628
  logger.hmr(`reload (buildId=${cycleBuildId})`);
681
629
  _broadcastEvent('reload', { changedFiles: changed });
682
- } else {
630
+ }
631
+ else {
683
632
  _trace('css_only_update', {
684
633
  buildId: cycleBuildId,
685
634
  cssHref: currentCssHref,
@@ -687,7 +636,8 @@ export async function createDevServer(options) {
687
636
  changedFiles: changed
688
637
  });
689
638
  }
690
- } catch (err) {
639
+ }
640
+ catch (err) {
691
641
  const fullError = err instanceof Error ? err.message : String(err);
692
642
  buildStatus = 'error';
693
643
  buildError = { message: fullError.length > 10000 ? fullError.slice(0, 10000) + '... (truncated)' : fullError };
@@ -697,7 +647,6 @@ export async function createDevServer(options) {
697
647
  hint: 'fix the error and save again',
698
648
  error: err
699
649
  });
700
-
701
650
  _broadcastEvent('build_error', { buildId: cycleBuildId, ...buildError, changedFiles: changed });
702
651
  _trace('state_snapshot', {
703
652
  status: buildStatus,
@@ -706,17 +655,18 @@ export async function createDevServer(options) {
706
655
  durationMs,
707
656
  error: buildError
708
657
  });
709
- } finally {
658
+ }
659
+ finally {
710
660
  _buildInFlight = false;
711
661
  if (_queuedFiles.size > 0) {
712
662
  triggerBuildDrain(20);
713
663
  }
714
664
  }
715
665
  };
716
-
717
666
  const roots = Array.from(watchRoots);
718
667
  for (const root of roots) {
719
- if (!existsSync(root)) continue;
668
+ if (!existsSync(root))
669
+ continue;
720
670
  try {
721
671
  const watcher = watch(root, { recursive: true }, (_eventType, filename) => {
722
672
  if (!filename) {
@@ -726,21 +676,27 @@ export async function createDevServer(options) {
726
676
  if (_shouldIgnoreChange(changedPath)) {
727
677
  return;
728
678
  }
729
- _queuedFiles.add(changedPath);
730
- triggerBuildDrain();
679
+ void (async () => {
680
+ const fingerprint = await readChangeFingerprint(changedPath);
681
+ if (_lastQueuedFingerprints.get(changedPath) === fingerprint) {
682
+ return;
683
+ }
684
+ _lastQueuedFingerprints.set(changedPath, fingerprint);
685
+ _queuedFiles.add(changedPath);
686
+ triggerBuildDrain();
687
+ })();
731
688
  });
732
689
  _watchers.push(watcher);
733
- } catch {
690
+ }
691
+ catch {
734
692
  // fs.watch recursive may not be supported on this platform/root
735
693
  }
736
694
  }
737
695
  }
738
-
739
696
  return new Promise((resolve) => {
740
697
  server.listen(port, host, () => {
741
698
  actualPort = server.address().port;
742
699
  _startWatcher();
743
-
744
700
  resolve({
745
701
  server,
746
702
  port: actualPort,
@@ -749,13 +705,17 @@ export async function createDevServer(options) {
749
705
  for (const watcher of _watchers) {
750
706
  try {
751
707
  watcher.close();
752
- } catch {
708
+ }
709
+ catch {
753
710
  // ignore close errors
754
711
  }
755
712
  }
756
713
  _watchers = [];
757
714
  for (const client of hmrClients) {
758
- try { client.end(); } catch { }
715
+ try {
716
+ client.end();
717
+ }
718
+ catch { }
759
719
  }
760
720
  hmrClients.length = 0;
761
721
  server.close();
@@ -0,0 +1 @@
1
+ export function readChangeFingerprint(absPath: any): Promise<string>;
@@ -0,0 +1,19 @@
1
+ import { stat } from 'node:fs/promises';
2
+ export async function readChangeFingerprint(absPath) {
3
+ try {
4
+ const info = await stat(absPath);
5
+ const kind = info.isDirectory()
6
+ ? 'dir'
7
+ : info.isFile()
8
+ ? 'file'
9
+ : 'other';
10
+ return `${kind}:${info.mtimeMs}:${info.size}`;
11
+ }
12
+ catch (error) {
13
+ const code = error && typeof error === 'object' ? error.code : '';
14
+ if (code === 'ENOENT' || code === 'ENOTDIR') {
15
+ return 'missing';
16
+ }
17
+ return `error:${String(code || 'unknown')}`;
18
+ }
19
+ }
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * CLI entry point.
4
+ *
5
+ * @param {string[]} args - Process arguments (without node and script paths)
6
+ * @param {string} [cwd] - Working directory override
7
+ */
8
+ export function cli(args: string[], cwd?: string): Promise<void>;