@zenithbuild/cli 0.5.0-beta.2.6 → 0.6.2

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