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