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