@zenithbuild/cli 0.7.5 → 0.7.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 (68) hide show
  1. package/dist/adapters/adapter-netlify.js +0 -8
  2. package/dist/adapters/adapter-vercel.js +6 -14
  3. package/dist/adapters/copy-hosted-page-runtime.js +2 -1
  4. package/dist/build/hoisted-code-transforms.d.ts +4 -1
  5. package/dist/build/hoisted-code-transforms.js +5 -3
  6. package/dist/build/page-ir-normalization.d.ts +1 -1
  7. package/dist/build/page-ir-normalization.js +33 -3
  8. package/dist/build/page-loop.js +46 -2
  9. package/dist/dev-build-session/helpers.d.ts +29 -0
  10. package/dist/dev-build-session/helpers.js +223 -0
  11. package/dist/dev-build-session/session.d.ts +24 -0
  12. package/dist/dev-build-session/session.js +204 -0
  13. package/dist/dev-build-session/state.d.ts +37 -0
  14. package/dist/dev-build-session/state.js +17 -0
  15. package/dist/dev-build-session.d.ts +1 -24
  16. package/dist/dev-build-session.js +1 -434
  17. package/dist/dev-server/css-state.d.ts +7 -0
  18. package/dist/dev-server/css-state.js +92 -0
  19. package/dist/dev-server/not-found.d.ts +23 -0
  20. package/dist/dev-server/not-found.js +129 -0
  21. package/dist/dev-server/request-handler.d.ts +1 -0
  22. package/dist/dev-server/request-handler.js +376 -0
  23. package/dist/dev-server/route-check.d.ts +9 -0
  24. package/dist/dev-server/route-check.js +100 -0
  25. package/dist/dev-server/watcher.d.ts +5 -0
  26. package/dist/dev-server/watcher.js +216 -0
  27. package/dist/dev-server.js +123 -924
  28. package/dist/images/payload.js +4 -0
  29. package/dist/manifest.js +46 -1
  30. package/dist/preview/create-preview-server.d.ts +18 -0
  31. package/dist/preview/create-preview-server.js +71 -0
  32. package/dist/preview/manifest.d.ts +42 -0
  33. package/dist/preview/manifest.js +57 -0
  34. package/dist/preview/paths.d.ts +3 -0
  35. package/dist/preview/paths.js +38 -0
  36. package/dist/preview/payload.d.ts +6 -0
  37. package/dist/preview/payload.js +34 -0
  38. package/dist/preview/request-handler.d.ts +1 -0
  39. package/dist/preview/request-handler.js +300 -0
  40. package/dist/preview/server-runner.d.ts +49 -0
  41. package/dist/preview/server-runner.js +220 -0
  42. package/dist/preview/server-script-runner-template.d.ts +1 -0
  43. package/dist/preview/server-script-runner-template.js +425 -0
  44. package/dist/preview.d.ts +5 -112
  45. package/dist/preview.js +7 -1119
  46. package/dist/resource-response.d.ts +15 -0
  47. package/dist/resource-response.js +91 -2
  48. package/dist/server-contract/constants.d.ts +5 -0
  49. package/dist/server-contract/constants.js +5 -0
  50. package/dist/server-contract/export-validation.d.ts +5 -0
  51. package/dist/server-contract/export-validation.js +59 -0
  52. package/dist/server-contract/json-serializable.d.ts +1 -0
  53. package/dist/server-contract/json-serializable.js +52 -0
  54. package/dist/server-contract/resolve.d.ts +15 -0
  55. package/dist/server-contract/resolve.js +271 -0
  56. package/dist/server-contract/result-helpers.d.ts +51 -0
  57. package/dist/server-contract/result-helpers.js +59 -0
  58. package/dist/server-contract/route-result-validation.d.ts +2 -0
  59. package/dist/server-contract/route-result-validation.js +73 -0
  60. package/dist/server-contract/stage.d.ts +6 -0
  61. package/dist/server-contract/stage.js +22 -0
  62. package/dist/server-contract.d.ts +6 -62
  63. package/dist/server-contract.js +9 -493
  64. package/dist/server-middleware.d.ts +10 -0
  65. package/dist/server-middleware.js +30 -0
  66. package/dist/server-output.js +13 -1
  67. package/dist/server-runtime/node-server.js +25 -3
  68. package/package.json +3 -3
@@ -11,26 +11,20 @@
11
11
  // V0: Uses Node.js http module + fs.watch. No external deps.
12
12
  // ---------------------------------------------------------------------------
13
13
  import { createServer } from 'node:http';
14
- import { existsSync, watch } from 'node:fs';
15
- import { readFile, stat } from 'node:fs/promises';
16
- import { performance } from 'node:perf_hooks';
17
- import { basename, dirname, extname, isAbsolute, join, relative, resolve } from 'node:path';
18
- import { appLocalRedirectLocation, imageEndpointPath, normalizeBasePath, routeCheckPath, stripBasePath } from './base-path.js';
14
+ import { readFile } from 'node:fs/promises';
15
+ import { basename, dirname, resolve } from 'node:path';
16
+ import { normalizeBasePath } from './base-path.js';
19
17
  import { resolveBuildAdapter } from './adapters/resolve-adapter.js';
20
18
  import { createDevBuildSession } from './dev-build-session.js';
21
19
  import { createStartupProfiler } from './startup-profile.js';
22
20
  import { createSilentLogger } from './ui/logger.js';
23
- import { readChangeFingerprint } from './dev-watch.js';
24
21
  import { createTrustedOriginResolver, publicHost } from './request-origin.js';
25
- import { readRequestBodyBuffer } from './request-body.js';
26
- import { buildResourceResponseDescriptor } from './resource-response.js';
27
22
  import { supportsTargetRouteCheck } from './route-check-support.js';
28
- import { clientFacingRouteMessage, defaultRouteDenyMessage, logServerException, sanitizeRouteResult } from './server-error.js';
29
- import { executeServerRoute, injectSsrPayload, loadRouteSurfaceState, resolveWithinDist, toStaticFilePath } from './preview.js';
30
- import { materializeImageMarkup } from './images/materialize.js';
31
- import { injectImageRuntimePayload } from './images/payload.js';
32
- import { handleImageRequest } from './images/service.js';
33
- import { resolveRequestRoute } from './server/resolve-request-route.js';
23
+ import { loadRouteSurfaceState } from './preview.js';
24
+ import { syncCssStateFromBuild } from './dev-server/css-state.js';
25
+ import { buildNotFoundPayload, classifyNotFound, infer404Cause, looksLikeJsonRequest, renderNotFoundHtml, traceNotFound } from './dev-server/not-found.js';
26
+ import { createDevRequestHandler } from './dev-server/request-handler.js';
27
+ import { createDevWatcher } from './dev-server/watcher.js';
34
28
  const MIME_TYPES = {
35
29
  '.html': 'text/html',
36
30
  '.js': 'application/javascript',
@@ -80,8 +74,6 @@ export async function createDevServer(options) {
80
74
  const watchRoots = new Set([pagesParentDir]);
81
75
  /** @type {import('http').ServerResponse[]} */
82
76
  const hmrClients = [];
83
- /** @type {import('fs').FSWatcher[]} */
84
- let _watchers = [];
85
77
  const sseHeartbeat = setInterval(() => {
86
78
  for (const client of hmrClients) {
87
79
  try {
@@ -92,26 +84,27 @@ export async function createDevServer(options) {
92
84
  }
93
85
  }
94
86
  }, 15000);
95
- let buildId = 0;
96
- let pendingBuildId = 0;
97
- let buildStatus = 'building'; // 'ok' | 'error' | 'building'
98
- let lastBuildMs = Date.now();
99
- let durationMs = 0;
100
- let buildError = null;
101
- let initialBuildSettled = false;
87
+ const state = {
88
+ buildId: 0,
89
+ pendingBuildId: 0,
90
+ buildStatus: 'building',
91
+ lastBuildMs: Date.now(),
92
+ durationMs: 0,
93
+ buildError: null,
94
+ initialBuildSettled: false,
95
+ currentCssAssetPath: '',
96
+ currentCssHref: '',
97
+ currentCssContent: '',
98
+ currentRouteState: { pageRoutes: [], resourceRoutes: [] }
99
+ };
102
100
  const traceEnabled = config.devTrace === true || process.env.ZENITH_DEV_TRACE === '1';
103
101
  const verboseLogging = traceEnabled || logger.mode?.logLevel === 'verbose';
104
- // Stable dev CSS endpoint points to this backing asset.
105
- let currentCssAssetPath = '';
106
- let currentCssHref = '';
107
- let currentCssContent = '';
108
102
  let actualPort = port;
109
103
  const resolveServerOrigin = createTrustedOriginResolver({
110
104
  host,
111
105
  getPort: () => actualPort,
112
106
  label: 'dev server'
113
107
  });
114
- let currentRouteState = { pageRoutes: [], resourceRoutes: [] };
115
108
  const rebuildDebounceMs = 5;
116
109
  const queuedRebuildDebounceMs = 5;
117
110
  function _publicHost() {
@@ -133,73 +126,8 @@ export async function createDevServer(options) {
133
126
  // tracing must never break the dev server
134
127
  }
135
128
  }
136
- function _classifyPath(pathname) {
137
- if (pathname.startsWith('/__zenith_dev/events'))
138
- return 'dev_events';
139
- if (pathname.startsWith('/__zenith_dev/state'))
140
- return 'dev_state';
141
- if (pathname.startsWith('/__zenith_dev/styles.css'))
142
- return 'dev_styles';
143
- if (pathname.startsWith('/assets/'))
144
- return 'asset';
145
- return 'other';
146
- }
147
129
  function _trace404(req, url, details = {}) {
148
- _trace('http_404', {
149
- method: req.method || 'GET',
150
- url: `${url.pathname}${url.search}`,
151
- classify: _classifyPath(url.pathname),
152
- ...details
153
- });
154
- }
155
- function _classifyNotFound(pathname) {
156
- const lower = String(pathname || '').toLowerCase();
157
- if (lower.startsWith('/__zenith_dev/'))
158
- return 'dev_internal';
159
- if (lower.startsWith('/__zenith/'))
160
- return 'zenith_internal';
161
- if (lower.startsWith('/_assets/')
162
- || lower.startsWith('/assets/')
163
- || lower.endsWith('.css')
164
- || lower.endsWith('.js')
165
- || lower.endsWith('.map')
166
- || lower.endsWith('.json')) {
167
- return 'asset';
168
- }
169
- return 'page';
170
- }
171
- function _routeFileHint(pathname) {
172
- const normalized = String(pathname || '/').replace(/\/+$/, '');
173
- if (normalized === '' || normalized === '/') {
174
- return 'src/pages/index.zen';
175
- }
176
- return `src/pages${normalized}.zen`;
177
- }
178
- function _infer404Cause(category) {
179
- if (category === 'dev_internal' || category === 'zenith_internal') {
180
- if (buildStatus === 'error') {
181
- return 'initial build failed';
182
- }
183
- return 'unknown Zenith dev endpoint';
184
- }
185
- if (category === 'asset') {
186
- if (buildStatus === 'error') {
187
- return 'initial build failed';
188
- }
189
- return 'asset not emitted by latest build';
190
- }
191
- return null;
192
- }
193
- function _looksLikeJsonRequest(req, pathname) {
194
- const accept = String(req.headers.accept || '').toLowerCase();
195
- const secFetchDest = String(req.headers['sec-fetch-dest'] || '').toLowerCase();
196
- if (accept.includes('application/json') || accept.includes('application/problem+json')) {
197
- return true;
198
- }
199
- if (pathname.endsWith('.json')) {
200
- return true;
201
- }
202
- return secFetchDest === 'empty';
130
+ traceNotFound(_trace, req, url, details);
203
131
  }
204
132
  function _isBuildSwapReadError(error) {
205
133
  const code = typeof error?.code === 'string' ? error.code : '';
@@ -211,7 +139,7 @@ export async function createDevServer(options) {
211
139
  });
212
140
  }
213
141
  async function _readFileForRequest(filePath, encoding = undefined) {
214
- const attempts = buildStatus === 'building' ? 200 : 1;
142
+ const attempts = state.buildStatus === 'building' ? 200 : 1;
215
143
  let lastError = null;
216
144
  for (let attempt = 0; attempt < attempts; attempt += 1) {
217
145
  try {
@@ -229,180 +157,39 @@ export async function createDevServer(options) {
229
157
  }
230
158
  throw lastError;
231
159
  }
232
- function _buildNotFoundPayload(pathname, category, cause) {
233
- const hintedPath = category === 'page'
234
- ? (stripBasePath(pathname, configuredBasePath) || pathname)
235
- : pathname;
236
- const payload = {
237
- kind: 'zenith_dev_not_found',
238
- category,
239
- requestedPath: pathname,
240
- buildId,
241
- buildStatus,
242
- cause: cause || ''
243
- };
244
- if (category === 'asset') {
245
- payload.hint = buildStatus === 'error'
246
- ? 'Dev server is running but initial build failed; fix compile errors and refresh.'
247
- : 'Check emitted assets in dist and verify the requested path.';
248
- if (pathname.endsWith('.css')) {
249
- payload.expectedCssHref = currentCssHref || null;
250
- payload.hint = buildStatus === 'error'
251
- ? `Dev server is running but initial build failed; expected CSS at ${currentCssHref || '<none>'}.`
252
- : `Requested CSS is missing; expected current href ${currentCssHref || '<none>'}.`;
253
- }
254
- return payload;
255
- }
256
- if (category === 'dev_internal' || category === 'zenith_internal') {
257
- payload.hint = buildStatus === 'error'
258
- ? 'Dev server is running but initial build failed; restart after fixing compile errors.'
259
- : 'Check Zenith dev endpoint path and dev client version.';
260
- payload.docsLink = '/docs/documentation/contracts/hmr-v1-contract.md';
261
- return payload;
262
- }
263
- const routeFile = _routeFileHint(hintedPath);
264
- payload.routeFile = routeFile;
265
- payload.cause = `no route file found at ${routeFile}`;
266
- payload.hint = `Create ${routeFile} or verify router manifest output.`;
267
- return payload;
268
- }
269
- function _renderNotFoundHtml(payload) {
270
- const escaped = (value) => String(value || '')
271
- .replaceAll('&', '&amp;')
272
- .replaceAll('<', '&lt;')
273
- .replaceAll('>', '&gt;');
274
- const details = [
275
- `Requested: ${payload.requestedPath}`,
276
- `Category: ${payload.category}`,
277
- `Build: ${payload.buildStatus} (id=${payload.buildId})`,
278
- `Cause: ${payload.cause}`,
279
- payload.expectedCssHref ? `Expected CSS href: ${payload.expectedCssHref}` : '',
280
- `Hint: ${payload.hint || 'Inspect dev server output.'}`,
281
- payload.docsLink ? `Docs: ${payload.docsLink}` : ''
282
- ].filter(Boolean).join('\n');
283
- return [
284
- '<!DOCTYPE html>',
285
- '<html><head><meta charset="utf-8"><title>Zenith Dev 404</title></head>',
286
- '<body style="font-family: ui-monospace, SFMono-Regular, Menlo, monospace; padding: 20px; background: #101216; color: #e6edf3;">',
287
- '<h1 style="margin-top:0;">Zenith Dev 404</h1>',
288
- `<pre style="white-space: pre-wrap; line-height: 1.5;">${escaped(details)}</pre>`,
289
- '</body></html>'
290
- ].join('');
291
- }
292
- function _pickCssAsset(assets) {
293
- if (!Array.isArray(assets) || assets.length === 0) {
294
- return '';
295
- }
296
- const cssAssets = assets
297
- .filter((entry) => typeof entry === 'string' && entry.endsWith('.css'))
298
- .map((entry) => entry.startsWith('/') ? entry : `/${entry}`);
299
- if (cssAssets.length === 0) {
300
- return '';
301
- }
302
- const devStable = cssAssets.find((entry) => entry.endsWith('/styles.dev.css'));
303
- if (devStable) {
304
- return devStable;
305
- }
306
- const preferred = cssAssets.find((entry) => /\/styles(\.|\/|$)/.test(entry));
307
- return preferred || cssAssets[0];
308
- }
309
- function _delay(ms) {
310
- return new Promise((resolveDelay) => setTimeout(resolveDelay, ms));
311
- }
312
- async function _waitForCssFile(absolutePath, retries = 16, delayMs = 40) {
313
- for (let i = 0; i <= retries; i++) {
314
- try {
315
- const info = await stat(absolutePath);
316
- if (info.isFile()) {
317
- return true;
318
- }
319
- }
320
- catch {
321
- // keep retrying
322
- }
323
- if (i < retries) {
324
- await _delay(delayMs);
325
- }
326
- }
327
- return false;
328
- }
329
160
  async function _syncCssStateFromBuild(buildResult, nextBuildId) {
330
- currentCssHref = `/__zenith_dev/styles.css?buildId=${nextBuildId}`;
331
- const candidate = _pickCssAsset(buildResult?.assets);
332
- if (!candidate) {
333
- _trace('css_sync_skipped', { reason: 'no_css_asset', buildId: nextBuildId });
334
- return false;
335
- }
336
- const absoluteCssPath = join(outDir, candidate);
337
- const ready = await _waitForCssFile(absoluteCssPath);
338
- if (!ready) {
339
- _trace('css_sync_skipped', {
340
- reason: 'css_not_ready',
341
- buildId: nextBuildId,
342
- cssAsset: candidate,
343
- resolvedPath: absoluteCssPath
344
- });
345
- return false;
346
- }
347
- let cssContent = '';
348
- try {
349
- cssContent = await readFile(absoluteCssPath, 'utf8');
350
- }
351
- catch {
352
- _trace('css_sync_skipped', {
353
- reason: 'css_read_failed',
354
- buildId: nextBuildId,
355
- cssAsset: candidate,
356
- resolvedPath: absoluteCssPath
357
- });
358
- return false;
359
- }
360
- if (typeof cssContent !== 'string') {
361
- _trace('css_sync_skipped', {
362
- reason: 'css_invalid_type',
363
- buildId: nextBuildId,
364
- cssAsset: candidate,
365
- resolvedPath: absoluteCssPath
366
- });
367
- return false;
368
- }
369
- if (cssContent.length === 0) {
370
- _trace('css_sync_skipped', {
371
- reason: 'css_empty',
372
- buildId: nextBuildId,
373
- cssAsset: candidate,
374
- resolvedPath: absoluteCssPath
375
- });
376
- cssContent = '/* zenith-dev: empty css */';
377
- }
378
- currentCssAssetPath = candidate;
379
- currentCssContent = cssContent;
380
- return true;
161
+ return syncCssStateFromBuild({
162
+ buildResult,
163
+ nextBuildId,
164
+ outDir,
165
+ state,
166
+ trace: _trace
167
+ });
381
168
  }
382
169
  async function _loadRoutesForRequests() {
383
- if (buildStatus === 'building' &&
384
- ((Array.isArray(currentRouteState.pageRoutes) && currentRouteState.pageRoutes.length > 0) ||
385
- (Array.isArray(currentRouteState.resourceRoutes) && currentRouteState.resourceRoutes.length > 0))) {
386
- return currentRouteState;
170
+ if (state.buildStatus === 'building' &&
171
+ ((Array.isArray(state.currentRouteState.pageRoutes) && state.currentRouteState.pageRoutes.length > 0) ||
172
+ (Array.isArray(state.currentRouteState.resourceRoutes) && state.currentRouteState.resourceRoutes.length > 0))) {
173
+ return state.currentRouteState;
387
174
  }
388
175
  try {
389
176
  const routeState = await loadRouteSurfaceState(outDir, configuredBasePath);
390
177
  if ((Array.isArray(routeState.pageRoutes) && routeState.pageRoutes.length > 0) ||
391
178
  (Array.isArray(routeState.resourceRoutes) && routeState.resourceRoutes.length > 0)) {
392
- currentRouteState = routeState;
179
+ state.currentRouteState = routeState;
393
180
  return routeState;
394
181
  }
395
182
  }
396
183
  catch (error) {
397
- if (!(Array.isArray(currentRouteState.pageRoutes) && currentRouteState.pageRoutes.length > 0) &&
398
- !(Array.isArray(currentRouteState.resourceRoutes) && currentRouteState.resourceRoutes.length > 0)) {
184
+ if (!(Array.isArray(state.currentRouteState.pageRoutes) && state.currentRouteState.pageRoutes.length > 0) &&
185
+ !(Array.isArray(state.currentRouteState.resourceRoutes) && state.currentRouteState.resourceRoutes.length > 0)) {
399
186
  throw error;
400
187
  }
401
188
  }
402
- return currentRouteState;
189
+ return state.currentRouteState;
403
190
  }
404
191
  function _broadcastEvent(type, payload = {}) {
405
- const eventBuildId = Number.isInteger(payload.buildId) ? payload.buildId : buildId;
192
+ const eventBuildId = Number.isInteger(payload.buildId) ? payload.buildId : state.buildId;
406
193
  const data = JSON.stringify({
407
194
  buildId: eventBuildId,
408
195
  ...payload
@@ -410,8 +197,8 @@ export async function createDevServer(options) {
410
197
  _trace('sse_emit', {
411
198
  type,
412
199
  buildId: eventBuildId,
413
- status: buildStatus,
414
- cssHref: currentCssHref,
200
+ status: state.buildStatus,
201
+ cssHref: state.currentCssHref,
415
202
  changedFiles: Array.isArray(payload.changedFiles) ? payload.changedFiles : undefined
416
203
  });
417
204
  for (const client of hmrClients) {
@@ -424,700 +211,112 @@ export async function createDevServer(options) {
424
211
  }
425
212
  }
426
213
  async function _runInitialBuild() {
427
- buildStatus = 'building';
428
- buildError = null;
214
+ state.buildStatus = 'building';
215
+ state.buildError = null;
429
216
  const startTime = Date.now();
430
- startupProfile.emit('initial_build_start', { buildId });
217
+ startupProfile.emit('initial_build_start', { buildId: state.buildId });
431
218
  try {
432
219
  logger.build('Initial build (id=0)', { onceKey: 'dev-initial-build' });
433
220
  const initialBuild = await buildSession.build();
434
- const cssReady = await _syncCssStateFromBuild(initialBuild, buildId);
435
- currentRouteState = await loadRouteSurfaceState(outDir, configuredBasePath);
436
- buildStatus = 'ok';
437
- buildError = null;
438
- lastBuildMs = Date.now();
439
- durationMs = lastBuildMs - startTime;
440
- if (cssReady && currentCssHref.length > 0) {
441
- logger.css(`ready (${currentCssHref})`, { onceKey: `css-ready:${buildId}:${currentCssHref}` });
221
+ const cssReady = await _syncCssStateFromBuild(initialBuild, state.buildId);
222
+ state.currentRouteState = await loadRouteSurfaceState(outDir, configuredBasePath);
223
+ state.buildStatus = 'ok';
224
+ state.buildError = null;
225
+ state.lastBuildMs = Date.now();
226
+ state.durationMs = state.lastBuildMs - startTime;
227
+ if (cssReady && state.currentCssHref.length > 0) {
228
+ logger.css(`ready (${state.currentCssHref})`, {
229
+ onceKey: `css-ready:${state.buildId}:${state.currentCssHref}`
230
+ });
442
231
  }
443
232
  _trace('state_snapshot', {
444
- status: buildStatus,
445
- buildId,
446
- cssHref: currentCssHref,
447
- durationMs
233
+ status: state.buildStatus,
234
+ buildId: state.buildId,
235
+ cssHref: state.currentCssHref,
236
+ durationMs: state.durationMs
448
237
  });
449
238
  startupProfile.emit('initial_build_complete', {
450
- buildId,
451
- status: buildStatus,
452
- durationMs,
239
+ buildId: state.buildId,
240
+ status: state.buildStatus,
241
+ durationMs: state.durationMs,
453
242
  cssReady,
454
- routes: (Array.isArray(currentRouteState.pageRoutes) ? currentRouteState.pageRoutes.length : 0) +
455
- (Array.isArray(currentRouteState.resourceRoutes) ? currentRouteState.resourceRoutes.length : 0)
243
+ routes: (Array.isArray(state.currentRouteState.pageRoutes) ? state.currentRouteState.pageRoutes.length : 0) +
244
+ (Array.isArray(state.currentRouteState.resourceRoutes) ? state.currentRouteState.resourceRoutes.length : 0)
456
245
  });
457
246
  }
458
247
  catch (err) {
459
- buildStatus = 'error';
460
- buildError = { message: err instanceof Error ? err.message : String(err) };
461
- lastBuildMs = Date.now();
462
- durationMs = lastBuildMs - startTime;
248
+ state.buildStatus = 'error';
249
+ state.buildError = { message: err instanceof Error ? err.message : String(err) };
250
+ state.lastBuildMs = Date.now();
251
+ state.durationMs = state.lastBuildMs - startTime;
463
252
  logger.error('initial build failed', {
464
253
  hint: 'fix the error and restart dev',
465
254
  error: err
466
255
  });
467
256
  _trace('state_snapshot', {
468
- status: buildStatus,
469
- buildId,
470
- durationMs,
471
- error: buildError
257
+ status: state.buildStatus,
258
+ buildId: state.buildId,
259
+ durationMs: state.durationMs,
260
+ error: state.buildError
472
261
  });
473
262
  startupProfile.emit('initial_build_complete', {
474
- buildId,
475
- status: buildStatus,
476
- durationMs,
477
- error: buildError?.message || ''
263
+ buildId: state.buildId,
264
+ status: state.buildStatus,
265
+ durationMs: state.durationMs,
266
+ error: state.buildError?.message || ''
478
267
  });
479
268
  }
480
269
  finally {
481
- initialBuildSettled = true;
270
+ state.initialBuildSettled = true;
482
271
  }
483
272
  }
484
- const server = createServer(async (req, res) => {
485
- const url = new URL(req.url, _serverOrigin());
486
- let pathname = url.pathname;
487
- // Legacy HMR endpoint (deprecated but kept alive to avoid breaking old caches instantly)
488
- if (pathname === LEGACY_DEV_STREAM_PATH) {
489
- res.writeHead(200, {
490
- 'Content-Type': EVENT_STREAM_MIME,
491
- 'Cache-Control': 'no-store',
492
- 'Connection': 'keep-alive',
493
- 'X-Zenith-Deprecated': 'true'
494
- });
495
- logger.warn('legacy HMR endpoint in use', {
496
- hint: 'use /__zenith_dev/events',
497
- onceKey: 'legacy-hmr-endpoint'
498
- });
499
- res.write(': connected\n\n');
500
- hmrClients.push(res);
501
- req.on('close', () => {
502
- const idx = hmrClients.indexOf(res);
503
- if (idx !== -1)
504
- hmrClients.splice(idx, 1);
505
- });
506
- return;
507
- }
508
- // V1 Dev State Endpoint
509
- if (pathname === '/__zenith_dev/state') {
510
- res.writeHead(200, {
511
- 'Content-Type': 'application/json',
512
- 'Cache-Control': 'no-store'
513
- });
514
- res.end(JSON.stringify({
515
- serverUrl: _serverOrigin(),
516
- buildId,
517
- status: buildStatus,
518
- lastBuildMs,
519
- durationMs,
520
- cssHref: currentCssHref,
521
- error: buildError
522
- }));
523
- return;
524
- }
525
- // V1 Dev Events Endpoint (SSE)
526
- if (pathname === '/__zenith_dev/events') {
527
- res.writeHead(200, {
528
- 'Content-Type': EVENT_STREAM_MIME,
529
- 'Cache-Control': 'no-store',
530
- 'Connection': 'keep-alive',
531
- 'X-Accel-Buffering': 'no'
532
- });
533
- res.write('retry: 1000\n');
534
- res.write('event: connected\ndata: {}\n\n');
535
- hmrClients.push(res);
536
- req.on('close', () => {
537
- const idx = hmrClients.indexOf(res);
538
- if (idx !== -1)
539
- hmrClients.splice(idx, 1);
540
- });
541
- return;
542
- }
543
- if (pathname === '/__zenith_dev/styles.css') {
544
- if (buildStatus === 'error') {
545
- const reason = typeof buildError?.message === 'string' && buildError.message.length > 0
546
- ? buildError.message
547
- : 'initial build failed';
548
- const summary = reason.length > 280 ? `${reason.slice(0, 277)}...` : reason;
549
- res.writeHead(503, {
550
- 'Content-Type': 'text/css; charset=utf-8',
551
- 'Cache-Control': 'no-store, no-cache, must-revalidate, proxy-revalidate',
552
- 'Pragma': 'no-cache',
553
- 'Expires': '0',
554
- 'X-Zenith-Dev-Error': 'build-failed'
555
- });
556
- res.end(`/* zenith-dev: css unavailable because build failed */\n/* cause: ${summary} */\n/* expected href: ${currentCssHref || '<none>'} */`);
557
- return;
558
- }
559
- if (typeof currentCssContent === 'string' && currentCssContent.length > 0) {
560
- res.writeHead(200, {
561
- 'Content-Type': 'text/css; charset=utf-8',
562
- 'Cache-Control': 'no-store, no-cache, must-revalidate, proxy-revalidate',
563
- 'Pragma': 'no-cache',
564
- 'Expires': '0'
565
- });
566
- res.end(currentCssContent);
567
- return;
568
- }
569
- if (typeof currentCssAssetPath === 'string' && currentCssAssetPath.length > 0) {
570
- try {
571
- const css = await readFile(join(outDir, currentCssAssetPath), 'utf8');
572
- if (typeof css === 'string' && css.length > 0) {
573
- currentCssContent = css;
574
- }
575
- }
576
- catch {
577
- // keep serving last known CSS body below
578
- }
579
- }
580
- if (typeof currentCssContent !== 'string') {
581
- currentCssContent = '';
582
- }
583
- if (currentCssContent.length === 0) {
584
- currentCssContent = '/* zenith-dev: css pending */';
585
- }
586
- res.writeHead(200, {
587
- 'Content-Type': 'text/css; charset=utf-8',
588
- 'Cache-Control': 'no-store, no-cache, must-revalidate, proxy-revalidate',
589
- 'Pragma': 'no-cache',
590
- 'Expires': '0'
591
- });
592
- res.end(currentCssContent);
593
- return;
594
- }
595
- if (pathname === imageEndpointPath(configuredBasePath)) {
596
- if (isStaticExportTarget) {
597
- throw new Error('not found');
598
- }
599
- await handleImageRequest(req, res, {
600
- requestUrl: url,
601
- projectRoot,
602
- config: config.images
603
- });
604
- return;
605
- }
606
- if (pathname === routeCheckPath(configuredBasePath)) {
607
- try {
608
- if (!routeCheckEnabled) {
609
- res.writeHead(501, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
610
- res.end(JSON.stringify({ error: 'route_check_unsupported' }));
611
- return;
612
- }
613
- if (!initialBuildSettled && buildStatus === 'building') {
614
- res.writeHead(503, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
615
- res.end(JSON.stringify({
616
- error: 'initial_build_pending',
617
- message: 'initial build still in progress'
618
- }));
619
- return;
620
- }
621
- // Security: Require explicitly designated header to prevent public oracle probing
622
- if (req.headers['x-zenith-route-check'] !== '1') {
623
- res.writeHead(403, { 'Content-Type': 'application/json' });
624
- res.end(JSON.stringify({ error: 'forbidden', message: 'invalid request context' }));
625
- return;
626
- }
627
- const targetPath = String(url.searchParams.get('path') || '/');
628
- // Security: Prevent protocol/domain injection in path
629
- if (targetPath.includes('://') || targetPath.startsWith('//') || /[\r\n]/.test(targetPath)) {
630
- res.writeHead(400, { 'Content-Type': 'application/json' });
631
- res.end(JSON.stringify({ error: 'invalid_path_format' }));
632
- return;
633
- }
634
- const targetUrl = new URL(targetPath, url.origin);
635
- if (targetUrl.origin !== url.origin) {
636
- res.writeHead(400, { 'Content-Type': 'application/json' });
637
- res.end(JSON.stringify({ error: 'external_route_evaluation_forbidden' }));
638
- return;
639
- }
640
- const canonicalTargetPath = stripBasePath(targetUrl.pathname, configuredBasePath);
641
- if (canonicalTargetPath === null) {
642
- res.writeHead(404, { 'Content-Type': 'application/json' });
643
- res.end(JSON.stringify({ error: 'route_not_found' }));
644
- return;
645
- }
646
- const canonicalTargetUrl = new URL(targetUrl.toString());
647
- canonicalTargetUrl.pathname = canonicalTargetPath;
648
- const routes = await _loadRoutesForRequests();
649
- const resolvedCheck = resolveRequestRoute(canonicalTargetUrl, routes.pageRoutes || []);
650
- if (!resolvedCheck.matched || !resolvedCheck.route) {
651
- res.writeHead(404, { 'Content-Type': 'application/json' });
652
- res.end(JSON.stringify({ error: 'route_not_found' }));
653
- return;
654
- }
655
- const checkResult = await executeServerRoute({
656
- source: resolvedCheck.route.server_script || '',
657
- sourcePath: resolvedCheck.route.server_script_path || '',
658
- params: resolvedCheck.params,
659
- requestUrl: targetUrl.toString(),
660
- requestMethod: req.method || 'GET',
661
- requestHeaders: req.headers,
662
- routePattern: resolvedCheck.route.path,
663
- routeFile: resolvedCheck.route.server_script_path || '',
664
- routeId: resolvedCheck.route.route_id || '',
665
- guardOnly: true
666
- });
667
- // Security: Enforce relative or same-origin redirects
668
- if (checkResult && checkResult.result && checkResult.result.kind === 'redirect') {
669
- const loc = appLocalRedirectLocation(checkResult.result.location || '/', configuredBasePath);
670
- checkResult.result.location = loc;
671
- if (loc.includes('://') || loc.startsWith('//')) {
672
- try {
673
- const parsedLoc = new URL(loc);
674
- if (parsedLoc.origin !== targetUrl.origin) {
675
- checkResult.result.location = appLocalRedirectLocation('/', configuredBasePath);
676
- }
677
- }
678
- catch {
679
- checkResult.result.location = appLocalRedirectLocation('/', configuredBasePath);
680
- }
681
- }
682
- }
683
- res.writeHead(200, {
684
- 'Content-Type': 'application/json',
685
- 'Cache-Control': 'no-store, no-cache, must-revalidate, proxy-revalidate',
686
- 'Pragma': 'no-cache',
687
- 'Expires': '0',
688
- 'Vary': 'Cookie'
689
- });
690
- res.end(JSON.stringify({
691
- result: sanitizeRouteResult(checkResult?.result || checkResult),
692
- routeId: resolvedCheck.route.route_id || '',
693
- to: targetUrl.toString()
694
- }));
695
- return;
696
- }
697
- catch {
698
- res.writeHead(500, { 'Content-Type': 'application/json' });
699
- res.end(JSON.stringify({ error: 'route_check_failed' }));
700
- return;
701
- }
702
- }
703
- let resolvedPathFor404 = null;
704
- let staticRootFor404 = null;
705
- try {
706
- const canonicalPath = stripBasePath(pathname, configuredBasePath);
707
- if (!initialBuildSettled && buildStatus === 'building') {
708
- const pendingPayload = {
709
- kind: 'zenith_dev_build_pending',
710
- requestedPath: pathname,
711
- buildId,
712
- buildStatus,
713
- hint: 'Initial build is still running. Retry shortly or inspect /__zenith_dev/state.'
714
- };
715
- if (_looksLikeJsonRequest(req, pathname)) {
716
- res.writeHead(503, {
717
- 'Content-Type': 'application/json',
718
- 'Cache-Control': 'no-store'
719
- });
720
- res.end(JSON.stringify(pendingPayload));
721
- return;
722
- }
723
- res.writeHead(503, {
724
- 'Content-Type': 'text/html; charset=utf-8',
725
- 'Cache-Control': 'no-store'
726
- });
727
- res.end([
728
- '<!DOCTYPE html>',
729
- '<html><head><meta charset="utf-8"><title>Zenith Dev Building</title></head>',
730
- '<body style="font-family: ui-monospace, SFMono-Regular, Menlo, monospace; padding: 20px; background: #101216; color: #e6edf3;">',
731
- '<h1 style="margin-top:0;">Zenith Dev Building</h1>',
732
- `<pre style="white-space: pre-wrap; line-height: 1.5;">Requested: ${pathname}\nStatus: initial build running\nHint: ${pendingPayload.hint}</pre>`,
733
- '</body></html>'
734
- ].join(''));
735
- return;
736
- }
737
- if (canonicalPath === null) {
738
- throw new Error('not found');
739
- }
740
- const requestExt = extname(canonicalPath);
741
- if (requestExt && requestExt !== '.html') {
742
- const assetPath = isStaticExportTarget
743
- ? resolveWithinDist(outDir, pathname)
744
- : join(outDir, canonicalPath);
745
- resolvedPathFor404 = assetPath;
746
- staticRootFor404 = outDir;
747
- if (!assetPath) {
748
- throw new Error('not found');
749
- }
750
- const asset = await _readFileForRequest(assetPath);
751
- const mime = MIME_TYPES[requestExt] || 'application/octet-stream';
752
- res.writeHead(200, { 'Content-Type': mime });
753
- res.end(asset);
754
- return;
755
- }
756
- const routes = await _loadRoutesForRequests();
757
- const canonicalUrl = new URL(url.toString());
758
- canonicalUrl.pathname = canonicalPath;
759
- const resolvedResource = resolveRequestRoute(canonicalUrl, routes.resourceRoutes || []);
760
- if (resolvedResource.matched && resolvedResource.route) {
761
- const requestMethod = req.method || 'GET';
762
- const requestBodyBuffer = requestMethod === 'GET' || requestMethod === 'HEAD'
763
- ? null
764
- : await readRequestBodyBuffer(req);
765
- const execution = await executeServerRoute({
766
- source: resolvedResource.route.server_script || '',
767
- sourcePath: resolvedResource.route.server_script_path || '',
768
- params: resolvedResource.params,
769
- requestUrl: url.toString(),
770
- requestMethod,
771
- requestHeaders: req.headers,
772
- requestBodyBuffer,
773
- routePattern: resolvedResource.route.path,
774
- routeFile: resolvedResource.route.server_script_path || '',
775
- routeId: resolvedResource.route.route_id || '',
776
- routeKind: 'resource'
777
- });
778
- const descriptor = buildResourceResponseDescriptor(execution?.result, configuredBasePath, Array.isArray(execution?.setCookies) ? execution.setCookies : []);
779
- res.writeHead(descriptor.status, appendSetCookieHeaders(descriptor.headers, descriptor.setCookies));
780
- if ((req.method || 'GET').toUpperCase() === 'HEAD') {
781
- res.end();
782
- return;
783
- }
784
- res.end(descriptor.body);
785
- return;
786
- }
787
- const resolved = resolveRequestRoute(canonicalUrl, routes.pageRoutes || []);
788
- let filePath = null;
789
- if (isStaticExportTarget) {
790
- filePath = toStaticFilePath(outDir, pathname);
791
- }
792
- else if (resolved.matched && resolved.route) {
793
- if (verboseLogging) {
794
- logger.router(`${req.method || 'GET'} ${pathname} -> ${resolved.route.path} params=${JSON.stringify(resolved.params)}`);
795
- }
796
- const output = resolved.route.output.startsWith('/')
797
- ? resolved.route.output.slice(1)
798
- : resolved.route.output;
799
- filePath = resolveWithinDist(outDir, output);
800
- }
801
- else {
802
- filePath = toStaticFilePath(outDir, canonicalPath);
803
- }
804
- resolvedPathFor404 = filePath;
805
- staticRootFor404 = outDir;
806
- if (!filePath) {
807
- throw new Error('not found');
808
- }
809
- let ssrPayload = null;
810
- let routeExecution = null;
811
- if (resolved.matched && resolved.route?.server_script && resolved.route.prerender !== true) {
812
- try {
813
- const requestMethod = req.method || 'GET';
814
- const requestBodyBuffer = requestMethod === 'GET' || requestMethod === 'HEAD'
815
- ? null
816
- : await readRequestBodyBuffer(req);
817
- routeExecution = await executeServerRoute({
818
- source: resolved.route.server_script,
819
- sourcePath: resolved.route.server_script_path || '',
820
- params: resolved.params,
821
- requestUrl: url.toString(),
822
- requestMethod,
823
- requestHeaders: req.headers,
824
- requestBodyBuffer,
825
- routePattern: resolved.route.path,
826
- routeFile: resolved.route.server_script_path || '',
827
- routeId: resolved.route.route_id || ''
828
- });
829
- }
830
- catch (error) {
831
- logServerException('dev server route execution failed', error);
832
- ssrPayload = {
833
- __zenith_error: {
834
- status: 500,
835
- code: 'LOAD_FAILED',
836
- message: error instanceof Error ? error.message : String(error || '')
837
- }
838
- };
839
- }
840
- const trace = routeExecution?.trace || { guard: 'none', action: 'none', load: 'none' };
841
- const routeId = resolved.route.route_id || '';
842
- const setCookies = Array.isArray(routeExecution?.setCookies) ? routeExecution.setCookies : [];
843
- if (verboseLogging) {
844
- logger.router(`${routeId || resolved.route.path} guard=${trace.guard} action=${trace.action} load=${trace.load}`);
845
- }
846
- const result = routeExecution?.result;
847
- if (result && result.kind === 'redirect') {
848
- const status = Number.isInteger(result.status) ? result.status : 302;
849
- res.writeHead(status, appendSetCookieHeaders({
850
- Location: appLocalRedirectLocation(result.location, configuredBasePath),
851
- 'Cache-Control': 'no-store'
852
- }, setCookies));
853
- res.end('');
854
- return;
855
- }
856
- if (result && result.kind === 'deny') {
857
- const status = Number.isInteger(result.status) ? result.status : 403;
858
- res.writeHead(status, appendSetCookieHeaders({ 'Content-Type': 'text/plain; charset=utf-8' }, setCookies));
859
- res.end(clientFacingRouteMessage(status, result.message));
860
- return;
861
- }
862
- if (result && result.kind === 'data' && result.data && typeof result.data === 'object' && !Array.isArray(result.data)) {
863
- ssrPayload = result.data;
864
- }
865
- }
866
- let content = await _readFileForRequest(filePath, 'utf8');
867
- if (resolved.matched) {
868
- content = await materializeImageMarkup({
869
- html: content,
870
- payload: buildSession.getImageRuntimePayload(),
871
- imageMaterialization: Array.isArray(resolved.route?.image_materialization)
872
- ? resolved.route.image_materialization
873
- : []
874
- });
875
- }
876
- if (ssrPayload) {
877
- content = injectSsrPayload(content, ssrPayload);
878
- }
879
- if (!IMAGE_RUNTIME_TAG_RE.test(content)) {
880
- content = injectImageRuntimePayload(content, buildSession.getImageRuntimePayload());
881
- }
882
- res.writeHead(Number.isInteger(routeExecution?.status) ? routeExecution.status : 200, appendSetCookieHeaders({
883
- 'Content-Type': 'text/html'
884
- }, Array.isArray(routeExecution?.setCookies) ? routeExecution.setCookies : []));
885
- res.end(content);
886
- }
887
- catch (error) {
888
- const category = _classifyNotFound(pathname);
889
- const cause = _infer404Cause(category);
890
- const payload = _buildNotFoundPayload(pathname, category, cause);
891
- if (buildStatus === 'error' && typeof buildError?.message === 'string') {
892
- payload.buildError = buildError.message.length > 600
893
- ? `${buildError.message.slice(0, 597)}...`
894
- : buildError.message;
895
- }
896
- const displayCategory = category === 'page' ? 'page' : 'asset';
897
- logger.warn(`404 ${displayCategory}: ${pathname} (buildId=${buildId}) -> cause: ${payload.cause || cause || 'not found'}`);
898
- _trace404(req, url, {
899
- reason: 'not_found',
900
- category,
901
- cause: payload.cause || cause || 'not_found',
902
- staticRoot: staticRootFor404,
903
- resolvedPath: resolvedPathFor404,
904
- error: error instanceof Error ? error.message : String(error || '')
905
- });
906
- if (_looksLikeJsonRequest(req, pathname)) {
907
- res.writeHead(404, {
908
- 'Content-Type': 'application/json',
909
- 'Cache-Control': 'no-store'
910
- });
911
- res.end(JSON.stringify(payload));
912
- return;
913
- }
914
- res.writeHead(404, {
915
- 'Content-Type': 'text/html; charset=utf-8',
916
- 'Cache-Control': 'no-store'
917
- });
918
- res.end(_renderNotFoundHtml(payload));
919
- }
273
+ state.hmrClients = hmrClients;
274
+ const server = createServer(createDevRequestHandler({
275
+ outDir,
276
+ projectRoot,
277
+ imageConfig: config.images,
278
+ configuredBasePath,
279
+ routeCheckEnabled,
280
+ isStaticExportTarget,
281
+ logger,
282
+ verboseLogging,
283
+ buildSession,
284
+ state,
285
+ serverOrigin: _serverOrigin,
286
+ loadRoutesForRequests: _loadRoutesForRequests,
287
+ readFileForRequest: _readFileForRequest,
288
+ trace404: _trace404,
289
+ looksLikeJsonRequest,
290
+ classifyNotFound,
291
+ infer404Cause,
292
+ buildNotFoundPayload,
293
+ renderNotFoundHtml,
294
+ appendSetCookieHeaders,
295
+ MIME_TYPES,
296
+ EVENT_STREAM_MIME,
297
+ LEGACY_DEV_STREAM_PATH,
298
+ IMAGE_RUNTIME_TAG_RE
299
+ }));
300
+ const watcherController = createDevWatcher({
301
+ watchRoots,
302
+ resolvedOutDir,
303
+ resolvedOutDirTmp,
304
+ projectRoot,
305
+ rebuildDebounceMs,
306
+ queuedRebuildDebounceMs,
307
+ buildSession,
308
+ outDir,
309
+ configuredBasePath,
310
+ logger,
311
+ startupProfile,
312
+ state,
313
+ syncCssStateFromBuild: _syncCssStateFromBuild,
314
+ broadcastEvent: _broadcastEvent,
315
+ trace: _trace
920
316
  });
921
- /**
922
- * Broadcast HMR reload to all connected clients.
923
- */
924
- function _broadcastReload() {
925
- for (const client of hmrClients) {
926
- try {
927
- client.write('data: reload\n\n');
928
- }
929
- catch {
930
- // client disconnected
931
- }
932
- }
933
- }
934
- let _buildDebounce = null;
935
- let _queuedFiles = new Set();
936
- const _lastQueuedFingerprints = new Map();
937
- let _buildInFlight = false;
938
- function _isWithin(parent, child) {
939
- const rel = relative(parent, child);
940
- return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel));
941
- }
942
- function _toDisplayPath(absPath) {
943
- const rel = relative(projectRoot, absPath);
944
- if (rel === '')
945
- return '.';
946
- if (!rel.startsWith('..') && !isAbsolute(rel)) {
947
- return rel;
948
- }
949
- return absPath;
950
- }
951
- function _shouldIgnoreChange(absPath) {
952
- if (_isWithin(resolvedOutDir, absPath)) {
953
- return true;
954
- }
955
- if (_isWithin(resolvedOutDirTmp, absPath)) {
956
- return true;
957
- }
958
- const rel = relative(projectRoot, absPath);
959
- if (rel.startsWith('..') || isAbsolute(rel)) {
960
- return false;
961
- }
962
- const segments = rel.split(/[\\/]+/g);
963
- return segments.includes('node_modules')
964
- || segments.includes('.git')
965
- || segments.includes('.zenith')
966
- || segments.includes('target')
967
- || segments.includes('.turbo');
968
- }
969
- /**
970
- * Start watching source roots for changes.
971
- */
972
- function _startWatcher() {
973
- const watcherStartedAt = performance.now();
974
- const triggerBuildDrain = (delayMs = rebuildDebounceMs) => {
975
- if (_buildDebounce !== null) {
976
- clearTimeout(_buildDebounce);
977
- }
978
- _buildDebounce = setTimeout(() => {
979
- _buildDebounce = null;
980
- void drainBuildQueue();
981
- }, delayMs);
982
- };
983
- const drainBuildQueue = async () => {
984
- if (_buildInFlight) {
985
- return;
986
- }
987
- const changedFiles = Array.from(_queuedFiles);
988
- const changed = changedFiles.map(_toDisplayPath).sort();
989
- if (changed.length === 0) {
990
- return;
991
- }
992
- _queuedFiles.clear();
993
- _buildInFlight = true;
994
- const cycleBuildId = pendingBuildId + 1;
995
- pendingBuildId = cycleBuildId;
996
- buildStatus = 'building';
997
- logger.build(`Rebuild (id=${cycleBuildId})`);
998
- _broadcastEvent('build_start', { buildId: cycleBuildId, changedFiles: changed });
999
- const startTime = Date.now();
1000
- const previousCssAssetPath = currentCssAssetPath;
1001
- const previousCssContent = currentCssContent;
1002
- const onlyCss = changed.length > 0 && changed.every((f) => f.endsWith('.css'));
1003
- try {
1004
- const buildResult = await buildSession.build({ changedFiles, logger });
1005
- const cssReady = await _syncCssStateFromBuild(buildResult, cycleBuildId);
1006
- if (!onlyCss) {
1007
- currentRouteState = await loadRouteSurfaceState(outDir, configuredBasePath);
1008
- }
1009
- const cssChanged = cssReady && (currentCssAssetPath !== previousCssAssetPath ||
1010
- currentCssContent !== previousCssContent);
1011
- buildId = cycleBuildId;
1012
- buildStatus = 'ok';
1013
- buildError = null;
1014
- lastBuildMs = Date.now();
1015
- durationMs = lastBuildMs - startTime;
1016
- logger.build(`Complete (id=${cycleBuildId}, ${durationMs}ms)`);
1017
- _broadcastEvent('build_complete', {
1018
- buildId: cycleBuildId,
1019
- durationMs,
1020
- status: buildStatus,
1021
- cssHref: currentCssHref,
1022
- changedFiles: changed
1023
- });
1024
- _trace('state_snapshot', {
1025
- status: buildStatus,
1026
- buildId: cycleBuildId,
1027
- cssHref: currentCssHref,
1028
- durationMs,
1029
- changedFiles: changed
1030
- });
1031
- if (cssChanged && currentCssHref.length > 0) {
1032
- logger.css(`ready (${currentCssHref})`);
1033
- logger.hmr(`css_update (buildId=${cycleBuildId})`);
1034
- _broadcastEvent('css_update', { href: currentCssHref, changedFiles: changed });
1035
- }
1036
- if (!onlyCss) {
1037
- logger.hmr(`reload (buildId=${cycleBuildId})`);
1038
- _broadcastEvent('reload', { changedFiles: changed });
1039
- }
1040
- else {
1041
- _trace('css_only_update', {
1042
- buildId: cycleBuildId,
1043
- cssHref: currentCssHref,
1044
- cssChanged,
1045
- changedFiles: changed
1046
- });
1047
- }
1048
- }
1049
- catch (err) {
1050
- const fullError = err instanceof Error ? err.message : String(err);
1051
- buildStatus = 'error';
1052
- buildError = { message: fullError.length > 10000 ? fullError.slice(0, 10000) + '... (truncated)' : fullError };
1053
- lastBuildMs = Date.now();
1054
- durationMs = lastBuildMs - startTime;
1055
- logger.error('rebuild failed', {
1056
- hint: 'fix the error and save again',
1057
- error: err
1058
- });
1059
- _broadcastEvent('build_error', { buildId: cycleBuildId, ...buildError, changedFiles: changed });
1060
- _trace('state_snapshot', {
1061
- status: buildStatus,
1062
- buildId,
1063
- cssHref: currentCssHref,
1064
- durationMs,
1065
- error: buildError
1066
- });
1067
- }
1068
- finally {
1069
- _buildInFlight = false;
1070
- if (_queuedFiles.size > 0) {
1071
- triggerBuildDrain(queuedRebuildDebounceMs);
1072
- }
1073
- }
1074
- };
1075
- const roots = Array.from(watchRoots);
1076
- for (const root of roots) {
1077
- if (!existsSync(root))
1078
- continue;
1079
- try {
1080
- const watcher = watch(root, { recursive: true }, (_eventType, filename) => {
1081
- if (!filename) {
1082
- return;
1083
- }
1084
- const changedPath = resolve(root, String(filename));
1085
- if (_shouldIgnoreChange(changedPath)) {
1086
- return;
1087
- }
1088
- void (async () => {
1089
- const fingerprint = await readChangeFingerprint(changedPath);
1090
- if (_lastQueuedFingerprints.get(changedPath) === fingerprint) {
1091
- return;
1092
- }
1093
- _lastQueuedFingerprints.set(changedPath, fingerprint);
1094
- _queuedFiles.add(changedPath);
1095
- triggerBuildDrain();
1096
- })();
1097
- });
1098
- _watchers.push(watcher);
1099
- }
1100
- catch {
1101
- // fs.watch recursive may not be supported on this platform/root
1102
- }
1103
- }
1104
- startupProfile.emit('watcher_ready', {
1105
- roots: roots.length,
1106
- activeWatchers: _watchers.length,
1107
- durationMs: startupProfile.roundMs(performance.now() - watcherStartedAt)
1108
- });
1109
- }
1110
317
  const closeServer = () => {
1111
318
  clearInterval(sseHeartbeat);
1112
- for (const watcher of _watchers) {
1113
- try {
1114
- watcher.close();
1115
- }
1116
- catch {
1117
- // ignore close errors
1118
- }
1119
- }
1120
- _watchers = [];
319
+ watcherController.close();
1121
320
  for (const client of hmrClients) {
1122
321
  try {
1123
322
  client.end();
@@ -1140,16 +339,16 @@ export async function createDevServer(options) {
1140
339
  startupProfile.emit('server_bound', {
1141
340
  host: _publicHost(),
1142
341
  port: actualPort,
1143
- buildStatus
342
+ buildStatus: state.buildStatus
1144
343
  });
1145
344
  _trace('server_bound', {
1146
345
  host: _publicHost(),
1147
346
  port: actualPort,
1148
- buildStatus
347
+ buildStatus: state.buildStatus
1149
348
  });
1150
349
  try {
1151
350
  await _runInitialBuild();
1152
- _startWatcher();
351
+ watcherController.start();
1153
352
  if (!settled) {
1154
353
  settled = true;
1155
354
  resolve({