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