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