@zenithbuild/cli 0.7.9 → 0.7.11

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 (37) hide show
  1. package/README.md +4 -1
  2. package/dist/build/compiler-runtime.js +3 -0
  3. package/dist/build/page-loop-state.d.ts +1 -4
  4. package/dist/build/page-loop-state.js +10 -9
  5. package/dist/build/page-loop.js +8 -7
  6. package/dist/build/server-script.js +13 -36
  7. package/dist/build/type-declarations.js +1 -54
  8. package/dist/dev-build-session/helpers.js +27 -7
  9. package/dist/dev-build-session/session.js +19 -10
  10. package/dist/dev-server/build-error-response.d.ts +21 -0
  11. package/dist/dev-server/build-error-response.js +48 -0
  12. package/dist/dev-server/port-fallback.d.ts +15 -0
  13. package/dist/dev-server/port-fallback.js +61 -0
  14. package/dist/dev-server/request-handler.js +12 -1
  15. package/dist/dev-server/watcher.js +15 -0
  16. package/dist/dev-server.d.ts +5 -2
  17. package/dist/dev-server.js +37 -45
  18. package/dist/images/remote-fetch.d.ts +12 -0
  19. package/dist/images/remote-fetch.js +257 -0
  20. package/dist/images/service.d.ts +10 -0
  21. package/dist/images/service.js +9 -46
  22. package/dist/index.js +12 -2
  23. package/dist/manifest.js +6 -1
  24. package/dist/resource-response.js +25 -8
  25. package/dist/resource-route-module.js +5 -22
  26. package/dist/route-classification.d.ts +10 -0
  27. package/dist/route-classification.js +17 -0
  28. package/dist/route-handler-export-analysis.d.ts +22 -0
  29. package/dist/route-handler-export-analysis.js +41 -0
  30. package/dist/server-output.js +6 -16
  31. package/dist/server-route-names.d.ts +2 -0
  32. package/dist/server-route-names.js +38 -0
  33. package/dist/server-runtime/node-server.js +5 -2
  34. package/dist/types/generate-env-dts.js +2 -44
  35. package/dist/types/zenith-env-dts.d.ts +4 -0
  36. package/dist/types/zenith-env-dts.js +96 -0
  37. package/package.json +3 -6
@@ -3,6 +3,8 @@ import { performance } from 'node:perf_hooks';
3
3
  import { isAbsolute, relative, resolve } from 'node:path';
4
4
  import { readChangeFingerprint } from '../dev-watch.js';
5
5
  import { loadRouteSurfaceState } from '../preview.js';
6
+ const CONFIG_FILE_NAMES = new Set(['zenith.config.js', 'zenith.config.ts']);
7
+ const CONFIG_CHANGED_MESSAGE = 'Config changed. Restart `zenith dev` to apply config updates.';
6
8
  export function createDevWatcher(options) {
7
9
  const { watchRoots, resolvedOutDir, resolvedOutDirTmp, projectRoot, rebuildDebounceMs, queuedRebuildDebounceMs, buildSession, outDir, configuredBasePath, logger, startupProfile, state, syncCssStateFromBuild, broadcastEvent, trace } = options;
8
10
  /** @type {import('fs').FSWatcher[]} */
@@ -42,6 +44,10 @@ export function createDevWatcher(options) {
42
44
  || segments.includes('target')
43
45
  || segments.includes('.turbo');
44
46
  }
47
+ function isConfigFileChange(absPath) {
48
+ const rel = relative(projectRoot, absPath).replace(/\\/g, '/');
49
+ return CONFIG_FILE_NAMES.has(rel);
50
+ }
45
51
  const triggerBuildDrain = (delayMs = rebuildDebounceMs) => {
46
52
  if (buildDebounce !== null) {
47
53
  clearTimeout(buildDebounce);
@@ -166,6 +172,15 @@ export function createDevWatcher(options) {
166
172
  if (shouldIgnoreChange(changedPath)) {
167
173
  return;
168
174
  }
175
+ if (isConfigFileChange(changedPath)) {
176
+ logger.warn(CONFIG_CHANGED_MESSAGE, {
177
+ onceKey: `config-change:${changedPath}`
178
+ });
179
+ trace('config_change_restart_required', {
180
+ path: toDisplayPath(changedPath)
181
+ });
182
+ return;
183
+ }
169
184
  void (async () => {
170
185
  const fingerprint = await readChangeFingerprint(changedPath);
171
186
  if (lastQueuedFingerprints.get(changedPath) === fingerprint) {
@@ -1,12 +1,13 @@
1
1
  /**
2
2
  * Create and start a development server.
3
3
  *
4
- * @param {{ pagesDir: string, outDir: string, port?: number, host?: string, config?: object, logger?: object | null }} options
5
- * @returns {Promise<{ server: import('http').Server, port: number, close: () => void }>}
4
+ * @param {{ pagesDir: string, outDir: string, projectRoot?: string, port?: number, host?: string, config?: object, logger?: object | null }} options
5
+ * @returns {Promise<{ server: import('http').Server, port: number, requestedPort: number, portFallback: object | null, close: () => void }>}
6
6
  */
7
7
  export function createDevServer(options: {
8
8
  pagesDir: string;
9
9
  outDir: string;
10
+ projectRoot?: string;
10
11
  port?: number;
11
12
  host?: string;
12
13
  config?: object;
@@ -14,5 +15,7 @@ export function createDevServer(options: {
14
15
  }): Promise<{
15
16
  server: import("http").Server;
16
17
  port: number;
18
+ requestedPort: number;
19
+ portFallback: object | null;
17
20
  close: () => void;
18
21
  }>;
@@ -25,6 +25,7 @@ import { syncCssStateFromBuild } from './dev-server/css-state.js';
25
25
  import { buildNotFoundPayload, classifyNotFound, infer404Cause, looksLikeJsonRequest, renderNotFoundHtml, traceNotFound } from './dev-server/not-found.js';
26
26
  import { createDevRequestHandler } from './dev-server/request-handler.js';
27
27
  import { createDevWatcher } from './dev-server/watcher.js';
28
+ import { listenWithPortFallback } from './dev-server/port-fallback.js';
28
29
  const MIME_TYPES = {
29
30
  '.html': 'text/html',
30
31
  '.js': 'application/javascript',
@@ -52,12 +53,12 @@ function appendSetCookieHeaders(headers, setCookies = []) {
52
53
  /**
53
54
  * Create and start a development server.
54
55
  *
55
- * @param {{ pagesDir: string, outDir: string, port?: number, host?: string, config?: object, logger?: object | null }} options
56
- * @returns {Promise<{ server: import('http').Server, port: number, close: () => void }>}
56
+ * @param {{ pagesDir: string, outDir: string, projectRoot?: string, port?: number, host?: string, config?: object, logger?: object | null }} options
57
+ * @returns {Promise<{ server: import('http').Server, port: number, requestedPort: number, portFallback: object | null, close: () => void }>}
57
58
  */
58
59
  export async function createDevServer(options) {
59
60
  const startupProfile = createStartupProfiler('cli-dev-server');
60
- const { pagesDir, outDir, port = 3000, host = '127.0.0.1', config = {}, logger: providedLogger = null } = options;
61
+ const { pagesDir, outDir, projectRoot: providedProjectRoot = null, port = 3000, host = '127.0.0.1', config = {}, logger: providedLogger = null } = options;
61
62
  const logger = providedLogger || createSilentLogger();
62
63
  const buildSession = createDevBuildSession({ pagesDir, outDir, config, logger });
63
64
  const configuredBasePath = normalizeBasePath(config.basePath || '/');
@@ -68,10 +69,11 @@ export async function createDevServer(options) {
68
69
  const resolvedOutDir = resolve(outDir);
69
70
  const resolvedOutDirTmp = resolve(dirname(resolvedOutDir), `${basename(resolvedOutDir)}.tmp`);
70
71
  const pagesParentDir = dirname(resolvedPagesDir);
71
- const projectRoot = basename(pagesParentDir) === 'src'
72
+ const inferredProjectRoot = basename(pagesParentDir) === 'src'
72
73
  ? dirname(pagesParentDir)
73
74
  : pagesParentDir;
74
- const watchRoots = new Set([pagesParentDir]);
75
+ const projectRoot = resolve(providedProjectRoot || inferredProjectRoot);
76
+ const watchRoots = new Set([projectRoot, pagesParentDir]);
75
77
  /** @type {import('http').ServerResponse[]} */
76
78
  const hmrClients = [];
77
79
  const sseHeartbeat = setInterval(() => {
@@ -97,7 +99,7 @@ export async function createDevServer(options) {
97
99
  currentCssContent: '',
98
100
  currentRouteState: { pageRoutes: [], resourceRoutes: [] }
99
101
  };
100
- const traceEnabled = config.devTrace === true || process.env.ZENITH_DEV_TRACE === '1';
102
+ const traceEnabled = process.env.ZENITH_DEV_TRACE === '1';
101
103
  const verboseLogging = traceEnabled || logger.mode?.logLevel === 'verbose';
102
104
  let actualPort = port;
103
105
  const resolveServerOrigin = createTrustedOriginResolver({
@@ -324,46 +326,36 @@ export async function createDevServer(options) {
324
326
  catch { }
325
327
  }
326
328
  hmrClients.length = 0;
327
- server.close();
329
+ try {
330
+ server.close();
331
+ }
332
+ catch { }
328
333
  };
329
- return new Promise((resolve, reject) => {
330
- let settled = false;
331
- server.once('error', (error) => {
332
- if (!settled) {
333
- settled = true;
334
- reject(error);
335
- }
334
+ try {
335
+ const listenResult = await listenWithPortFallback({ server, port, host });
336
+ actualPort = listenResult.port;
337
+ startupProfile.emit('server_bound', {
338
+ host: _publicHost(),
339
+ port: actualPort,
340
+ buildStatus: state.buildStatus
336
341
  });
337
- server.listen(port, host, async () => {
338
- actualPort = server.address().port;
339
- startupProfile.emit('server_bound', {
340
- host: _publicHost(),
341
- port: actualPort,
342
- buildStatus: state.buildStatus
343
- });
344
- _trace('server_bound', {
345
- host: _publicHost(),
346
- port: actualPort,
347
- buildStatus: state.buildStatus
348
- });
349
- try {
350
- await _runInitialBuild();
351
- watcherController.start();
352
- if (!settled) {
353
- settled = true;
354
- resolve({
355
- server,
356
- port: actualPort,
357
- close: closeServer
358
- });
359
- }
360
- }
361
- catch (error) {
362
- if (!settled) {
363
- settled = true;
364
- reject(error);
365
- }
366
- }
342
+ _trace('server_bound', {
343
+ host: _publicHost(),
344
+ port: actualPort,
345
+ buildStatus: state.buildStatus
367
346
  });
368
- });
347
+ await _runInitialBuild();
348
+ watcherController.start();
349
+ return {
350
+ server,
351
+ port: actualPort,
352
+ requestedPort: listenResult.requestedPort,
353
+ portFallback: listenResult.portFallback,
354
+ close: closeServer
355
+ };
356
+ }
357
+ catch (error) {
358
+ closeServer();
359
+ throw error;
360
+ }
369
361
  }
@@ -0,0 +1,12 @@
1
+ export function isLocalNetworkAddress(address: any): boolean;
2
+ export function resolveRemoteTarget(remoteUrl: any, config: any, lookupImpl?: typeof lookup): Promise<{
3
+ url: import("node:url").URL;
4
+ address: string;
5
+ family: number;
6
+ requestUrl: import("node:url").URL;
7
+ }>;
8
+ export function validateRemoteTarget(remoteUrl: any, config: any): Promise<import("node:url").URL>;
9
+ export function fetchRemoteImage(remote: any, config: any, fetchImpl?: typeof fetchPinnedRemoteUrl, lookupImpl?: typeof lookup): Promise<any>;
10
+ import { lookup } from 'node:dns/promises';
11
+ declare function fetchPinnedRemoteUrl(requestUrl: any, options?: {}): Promise<any>;
12
+ export {};
@@ -0,0 +1,257 @@
1
+ import { lookup } from 'node:dns/promises';
2
+ import http from 'node:http';
3
+ import https from 'node:https';
4
+ import { isIP } from 'node:net';
5
+ import { Readable } from 'node:stream';
6
+ import { matchRemotePattern } from './shared.js';
7
+ const MAX_REMOTE_REDIRECTS = 5;
8
+ const PINNED_REMOTE_TARGET = Symbol('zenithPinnedRemoteTarget');
9
+ function parseIpv4(address) {
10
+ if (!address) {
11
+ return null;
12
+ }
13
+ const parts = String(address).split('.');
14
+ if (parts.length !== 4) {
15
+ return null;
16
+ }
17
+ const octets = parts.map((part) => {
18
+ if (!/^\d+$/.test(part)) {
19
+ return null;
20
+ }
21
+ const value = Number.parseInt(part, 10);
22
+ return value >= 0 && value <= 255 ? value : null;
23
+ });
24
+ return octets.every((part) => part !== null) ? octets : null;
25
+ }
26
+ function isBlockedIpv4(address) {
27
+ const octets = parseIpv4(address);
28
+ if (!octets) {
29
+ return false;
30
+ }
31
+ const [a, b, c, d] = octets;
32
+ if (a === 0 || a === 10 || a === 127 || a >= 224) {
33
+ return true;
34
+ }
35
+ if (a === 100 && b >= 64 && b <= 127) {
36
+ return true;
37
+ }
38
+ if (a === 169 && b === 254) {
39
+ return true;
40
+ }
41
+ if (a === 172 && b >= 16 && b <= 31) {
42
+ return true;
43
+ }
44
+ if (a === 192 && (b === 168 || (b === 0 && (c === 0 || c === 2)) || (b === 88 && c === 99))) {
45
+ return true;
46
+ }
47
+ if (a === 198 && (b === 18 || b === 19 || (b === 51 && c === 100))) {
48
+ return true;
49
+ }
50
+ if (a === 203 && b === 0 && c === 113) {
51
+ return true;
52
+ }
53
+ return a === 255 && b === 255 && c === 255 && d === 255;
54
+ }
55
+ function leadingIpv6Hextet(address) {
56
+ const first = String(address).toLowerCase().replace(/^\[|\]$/g, '').split('%')[0].split(':')[0];
57
+ return Number.parseInt(first || '0', 16);
58
+ }
59
+ function mappedIpv4Address(address) {
60
+ const normalized = String(address || '').toLowerCase().replace(/^\[|\]$/g, '').split('%')[0];
61
+ if (normalized.includes('.')) {
62
+ const candidate = normalized.slice(normalized.lastIndexOf(':') + 1);
63
+ return parseIpv4(candidate) ? candidate : null;
64
+ }
65
+ const mappedHex = normalized.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i);
66
+ if (mappedHex) {
67
+ const high = Number.parseInt(mappedHex[1], 16);
68
+ const low = Number.parseInt(mappedHex[2], 16);
69
+ if (Number.isFinite(high) && Number.isFinite(low)) {
70
+ return `${(high >> 8) & 0xff}.${high & 0xff}.${(low >> 8) & 0xff}.${low & 0xff}`;
71
+ }
72
+ }
73
+ return null;
74
+ }
75
+ function isBlockedIpv6(address) {
76
+ const normalized = String(address || '').toLowerCase().replace(/^\[|\]$/g, '').split('%')[0];
77
+ if (!normalized) {
78
+ return false;
79
+ }
80
+ const mapped = mappedIpv4Address(normalized);
81
+ if (mapped) {
82
+ return isBlockedIpv4(mapped);
83
+ }
84
+ if (normalized === '::' || normalized === '::1') {
85
+ return true;
86
+ }
87
+ const first = leadingIpv6Hextet(normalized);
88
+ if (!Number.isFinite(first)) {
89
+ return false;
90
+ }
91
+ return (first & 0xfe00) === 0xfc00
92
+ || (first & 0xffc0) === 0xfe80
93
+ || (first & 0xff00) === 0xff00;
94
+ }
95
+ function normalizeHostnameAddress(hostname) {
96
+ return String(hostname || '').replace(/^\[|\]$/g, '').split('%')[0];
97
+ }
98
+ export function isLocalNetworkAddress(address) {
99
+ const normalized = normalizeHostnameAddress(address);
100
+ if (!normalized) {
101
+ return false;
102
+ }
103
+ if (isBlockedIpv4(normalized)) {
104
+ return true;
105
+ }
106
+ return isBlockedIpv6(normalized);
107
+ }
108
+ function isLoopbackHostname(hostname) {
109
+ const normalized = String(hostname || '').toLowerCase();
110
+ return normalized === 'localhost' || normalized.endsWith('.localhost');
111
+ }
112
+ async function resolveRemoteAddress(url, config, lookupImpl = lookup) {
113
+ const hostname = normalizeHostnameAddress(url.hostname);
114
+ const allowLocalNetwork = Boolean(config.dangerouslyAllowLocalNetwork);
115
+ if (!allowLocalNetwork && (isLoopbackHostname(hostname) || isLocalNetworkAddress(hostname))) {
116
+ throw new Error('[Zenith:Image] Loopback and local network image fetches are blocked');
117
+ }
118
+ const literalFamily = isIP(hostname);
119
+ if (literalFamily) {
120
+ return {
121
+ address: hostname,
122
+ family: literalFamily
123
+ };
124
+ }
125
+ const resolved = await lookupImpl(hostname, { all: true });
126
+ if (!Array.isArray(resolved) || resolved.length === 0) {
127
+ throw new Error('[Zenith:Image] Remote image hostname did not resolve');
128
+ }
129
+ if (!allowLocalNetwork && resolved.some((entry) => isLocalNetworkAddress(entry.address))) {
130
+ throw new Error('[Zenith:Image] Private network image fetches are blocked');
131
+ }
132
+ return {
133
+ address: resolved[0].address,
134
+ family: resolved[0].family || isIP(resolved[0].address)
135
+ };
136
+ }
137
+ function buildPinnedUrl(url, address, family) {
138
+ const pinned = new URL(url.toString());
139
+ pinned.hostname = family === 6 ? `[${address}]` : address;
140
+ return pinned;
141
+ }
142
+ export async function resolveRemoteTarget(remoteUrl, config, lookupImpl = lookup) {
143
+ const url = new URL(remoteUrl);
144
+ if (!matchRemotePattern(url, config.remotePatterns)) {
145
+ throw new Error('[Zenith:Image] Remote URL is not allowed by images.remotePatterns');
146
+ }
147
+ const resolved = await resolveRemoteAddress(url, config, lookupImpl);
148
+ return {
149
+ url,
150
+ address: resolved.address,
151
+ family: resolved.family,
152
+ requestUrl: buildPinnedUrl(url, resolved.address, resolved.family)
153
+ };
154
+ }
155
+ function remoteFetchHeaders(target) {
156
+ return {
157
+ 'Accept': 'image/avif,image/webp,image/png,image/jpeg,image/*;q=0.8,*/*;q=0.1',
158
+ 'Host': target.url.host
159
+ };
160
+ }
161
+ function createRemoteFetchOptions(target) {
162
+ return {
163
+ headers: remoteFetchHeaders(target),
164
+ redirect: 'manual',
165
+ [PINNED_REMOTE_TARGET]: target
166
+ };
167
+ }
168
+ function normalizeRequestHeaders(headers = {}) {
169
+ if (headers instanceof Headers) {
170
+ return Object.fromEntries(headers.entries());
171
+ }
172
+ return { ...headers };
173
+ }
174
+ function responseHeadersFromNode(headers) {
175
+ const out = new Headers();
176
+ for (const [key, value] of Object.entries(headers || {})) {
177
+ if (Array.isArray(value)) {
178
+ for (const item of value) {
179
+ out.append(key, String(item));
180
+ }
181
+ continue;
182
+ }
183
+ if (value !== undefined) {
184
+ out.set(key, String(value));
185
+ }
186
+ }
187
+ return out;
188
+ }
189
+ function nodeRequestOptions(target, options = {}) {
190
+ const url = target.url;
191
+ const protocol = url.protocol;
192
+ if (protocol !== 'http:' && protocol !== 'https:') {
193
+ throw new Error('[Zenith:Image] Remote image protocol must be http or https');
194
+ }
195
+ const headers = normalizeRequestHeaders(options.headers);
196
+ const requestOptions = {
197
+ protocol,
198
+ hostname: target.address,
199
+ port: url.port || (protocol === 'https:' ? 443 : 80),
200
+ method: 'GET',
201
+ path: `${url.pathname}${url.search}`,
202
+ headers
203
+ };
204
+ const originalHostname = normalizeHostnameAddress(url.hostname);
205
+ if (protocol === 'https:' && !isIP(originalHostname)) {
206
+ requestOptions.servername = originalHostname;
207
+ }
208
+ return requestOptions;
209
+ }
210
+ async function fetchPinnedRemoteUrl(requestUrl, options = {}) {
211
+ const target = options[PINNED_REMOTE_TARGET];
212
+ if (!target) {
213
+ return fetch(requestUrl, options);
214
+ }
215
+ const transport = target.url.protocol === 'https:' ? https : http;
216
+ return new Promise((resolve, reject) => {
217
+ const request = transport.request(nodeRequestOptions(target, options), (response) => {
218
+ const status = response.statusCode || 502;
219
+ const body = status === 204 || status === 205 || status === 304
220
+ ? null
221
+ : Readable.toWeb(response);
222
+ resolve(new Response(body, {
223
+ status,
224
+ statusText: response.statusMessage || '',
225
+ headers: responseHeadersFromNode(response.headers)
226
+ }));
227
+ });
228
+ request.on('error', reject);
229
+ request.end();
230
+ });
231
+ }
232
+ export async function validateRemoteTarget(remoteUrl, config) {
233
+ return (await resolveRemoteTarget(remoteUrl, config)).url;
234
+ }
235
+ export async function fetchRemoteImage(remote, config, fetchImpl = fetchPinnedRemoteUrl, lookupImpl = lookup) {
236
+ let current = remote instanceof URL ? remote : new URL(String(remote));
237
+ for (let redirectCount = 0; redirectCount <= MAX_REMOTE_REDIRECTS; redirectCount += 1) {
238
+ const target = await resolveRemoteTarget(current.toString(), config, lookupImpl);
239
+ current = target.url;
240
+ const response = await fetchImpl(target.requestUrl, createRemoteFetchOptions(target));
241
+ if (response.status < 300 || response.status >= 400) {
242
+ return response;
243
+ }
244
+ const location = response.headers.get('location');
245
+ if (!location) {
246
+ throw new Error('[Zenith:Image] Remote image redirect is missing a Location header');
247
+ }
248
+ try {
249
+ await response.body?.cancel?.();
250
+ }
251
+ catch {
252
+ // Ignore body cancellation errors while redirecting.
253
+ }
254
+ current = new URL(location, current);
255
+ }
256
+ throw new Error('[Zenith:Image] Remote image redirected too many times');
257
+ }
@@ -14,3 +14,13 @@ export function handleImageFetchRequest(request: Request | {
14
14
  config?: Record<string, unknown>;
15
15
  }): Promise<Response>;
16
16
  export function handleImageRequest(_req: any, res: any, options: any): Promise<boolean>;
17
+ export namespace __imageServiceTestHooks {
18
+ export { fetchRemoteImage };
19
+ export { isLocalNetworkAddress };
20
+ export { resolveRemoteTarget };
21
+ export { validateRemoteTarget };
22
+ }
23
+ import { fetchRemoteImage } from './remote-fetch.js';
24
+ import { isLocalNetworkAddress } from './remote-fetch.js';
25
+ import { resolveRemoteTarget } from './remote-fetch.js';
26
+ import { validateRemoteTarget } from './remote-fetch.js';
@@ -1,9 +1,9 @@
1
- import { lookup } from 'node:dns/promises';
2
1
  import { existsSync } from 'node:fs';
3
2
  import { mkdir, readFile, stat, writeFile, readdir } from 'node:fs/promises';
4
3
  import { dirname, extname, join, relative, resolve } from 'node:path';
5
4
  import sharp from 'sharp';
6
- import { buildLocalImageKey, buildLocalVariantAssetPath, matchRemotePattern, normalizeImageConfig, normalizeImageFormat } from './shared.js';
5
+ import { fetchRemoteImage, isLocalNetworkAddress, resolveRemoteTarget, validateRemoteTarget } from './remote-fetch.js';
6
+ import { buildLocalImageKey, buildLocalVariantAssetPath, normalizeImageConfig, normalizeImageFormat } from './shared.js';
7
7
  const RASTER_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.webp', '.avif']);
8
8
  const MIME_BY_FORMAT = {
9
9
  avif: 'image/avif',
@@ -12,28 +12,6 @@ const MIME_BY_FORMAT = {
12
12
  jpg: 'image/jpeg',
13
13
  jpeg: 'image/jpeg'
14
14
  };
15
- function isPrivateIp(address) {
16
- if (!address) {
17
- return false;
18
- }
19
- if (address === '::1' || address === '127.0.0.1') {
20
- return true;
21
- }
22
- if (address.startsWith('10.') || address.startsWith('192.168.')) {
23
- return true;
24
- }
25
- if (/^172\.(1[6-9]|2\d|3[0-1])\./.test(address)) {
26
- return true;
27
- }
28
- if (/^(fc|fd)/i.test(address.replace(/:/g, ''))) {
29
- return true;
30
- }
31
- return false;
32
- }
33
- function isLoopbackHostname(hostname) {
34
- const normalized = String(hostname || '').toLowerCase();
35
- return normalized === 'localhost' || normalized === '127.0.0.1' || normalized === '::1';
36
- }
37
15
  function mimeTypeForFormat(format) {
38
16
  return MIME_BY_FORMAT[normalizeImageFormat(format)] || 'application/octet-stream';
39
17
  }
@@ -167,22 +145,6 @@ export async function buildImageArtifacts(options) {
167
145
  await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
168
146
  return { manifest };
169
147
  }
170
- async function validateRemoteTarget(remoteUrl, config) {
171
- const url = new URL(remoteUrl);
172
- if (!matchRemotePattern(url, config.remotePatterns)) {
173
- throw new Error('[Zenith:Image] Remote URL is not allowed by images.remotePatterns');
174
- }
175
- if (!config.dangerouslyAllowLocalNetwork) {
176
- if (isLoopbackHostname(url.hostname)) {
177
- throw new Error('[Zenith:Image] Loopback and local network image fetches are blocked');
178
- }
179
- const resolved = await lookup(url.hostname, { all: true });
180
- if (resolved.some((entry) => isPrivateIp(entry.address))) {
181
- throw new Error('[Zenith:Image] Private network image fetches are blocked');
182
- }
183
- }
184
- return url;
185
- }
186
148
  async function readRemoteBuffer(response, maxBytes) {
187
149
  const reader = response.body?.getReader?.();
188
150
  if (!reader) {
@@ -273,12 +235,7 @@ async function createImageResponse(options) {
273
235
  : mimeTypeForFormat(format || 'jpg');
274
236
  return createBufferResponse(200, contentType, cached, config.minimumCacheTTL);
275
237
  }
276
- const response = await fetch(remote, {
277
- headers: {
278
- 'Accept': 'image/avif,image/webp,image/png,image/jpeg,image/*;q=0.8,*/*;q=0.1'
279
- },
280
- redirect: 'follow'
281
- });
238
+ const response = await fetchRemoteImage(remote, config);
282
239
  if (!response.ok) {
283
240
  throw new Error(`[Zenith:Image] Remote image fetch failed with status ${response.status}`);
284
241
  }
@@ -330,3 +287,9 @@ export async function handleImageRequest(_req, res, options) {
330
287
  await sendResponse(res, response);
331
288
  return true;
332
289
  }
290
+ export const __imageServiceTestHooks = {
291
+ fetchRemoteImage,
292
+ isLocalNetworkAddress,
293
+ resolveRemoteTarget,
294
+ validateRemoteTarget
295
+ };
package/dist/index.js CHANGED
@@ -124,8 +124,18 @@ export async function cli(args, cwd) {
124
124
  : resolvePort(args.slice(1), 3000);
125
125
  const host = process.env.ZENITH_DEV_HOST || '127.0.0.1';
126
126
  logger.dev('Starting dev server…');
127
- const dev = await createDevServer({ pagesDir, outDir, port, host, config, logger });
128
- logger.ok(`http://${host === '0.0.0.0' ? '127.0.0.1' : host}:${dev.port}`);
127
+ const dev = await createDevServer({ pagesDir, outDir, projectRoot, port, host, config, logger });
128
+ const displayHost = host === '0.0.0.0' ? '127.0.0.1' : host;
129
+ const servingUrl = `http://${displayHost}:${dev.port}`;
130
+ if (dev.portFallback) {
131
+ const occupied = Array.isArray(dev.portFallback.occupiedPorts)
132
+ ? dev.portFallback.occupiedPorts.join(', ')
133
+ : String(dev.requestedPort);
134
+ logger.warn(`Requested port ${dev.requestedPort} is occupied; using ${dev.port}.`, {
135
+ hint: `Occupied port(s): ${occupied}; serving at ${servingUrl}`
136
+ });
137
+ }
138
+ logger.ok(servingUrl);
129
139
  // Graceful shutdown
130
140
  process.on('SIGINT', () => {
131
141
  dev.close();
package/dist/manifest.js CHANGED
@@ -21,6 +21,7 @@ import { extractServerScript } from './build/server-script.js';
21
21
  import { analyzeResourceRouteModule, isResourceRouteFile } from './resource-route-module.js';
22
22
  import { composeServerScriptEnvelope, resolveAdjacentServerModules } from './server-script-composition.js';
23
23
  import { validateStaticExportPaths } from './static-export-paths.js';
24
+ import { classifyPageRoute } from './route-classification.js';
24
25
  /**
25
26
  * @typedef {{
26
27
  * path: string,
@@ -156,12 +157,16 @@ function buildPageManifestEntry({ fullPath, root, routePath, compilerOpts }) {
156
157
  const exportPaths = Array.isArray(composed.serverScript?.export_paths)
157
158
  ? validateStaticExportPaths(routePath, composed.serverScript.export_paths, fullPath)
158
159
  : [];
160
+ const classification = classifyPageRoute({
161
+ file: relative(root, fullPath),
162
+ serverScript: composed.serverScript
163
+ });
159
164
  return {
160
165
  path: routePath,
161
166
  file: relative(root, fullPath),
162
167
  route_kind: 'page',
163
168
  path_kind: _isDynamic(routePath) ? 'dynamic' : 'static',
164
- render_mode: composed.serverScript && composed.serverScript.prerender !== true ? 'server' : 'prerender',
169
+ render_mode: classification.renderMode,
165
170
  params: extractRouteParams(routePath),
166
171
  ...(exportPaths.length > 0 ? { export_paths: exportPaths } : {})
167
172
  };
@@ -28,6 +28,24 @@ function createReadableStreamFromAsyncIterable(iterable) {
28
28
  }
29
29
  });
30
30
  }
31
+ const SSE_METADATA_CONTROL_RE = /[\x00-\x1F\x7F]/u;
32
+ function serializeSseMetadata(value, field) {
33
+ const serialized = String(value);
34
+ if (SSE_METADATA_CONTROL_RE.test(serialized)) {
35
+ throw new Error(`[Zenith] sse ${field} metadata must be a single line without control characters.`);
36
+ }
37
+ return serialized;
38
+ }
39
+ function serializeSseRetry(value) {
40
+ if (!Number.isSafeInteger(value) || value < 0) {
41
+ throw new Error('[Zenith] sse retry metadata must be a non-negative safe integer.');
42
+ }
43
+ return String(value);
44
+ }
45
+ function serializeSseData(value) {
46
+ const raw = typeof value === 'string' ? value : JSON.stringify(value);
47
+ return String(raw ?? '').replace(/\r\n/g, '\n').replace(/\r/g, '\n');
48
+ }
31
49
  function createSseStream(events) {
32
50
  const iterator = events[Symbol.asyncIterator]();
33
51
  const encoder = new TextEncoder();
@@ -40,14 +58,13 @@ function createSseStream(events) {
40
58
  return;
41
59
  }
42
60
  let chunk = '';
43
- if (value.event)
44
- chunk += `event: ${value.event}\n`;
45
- if (value.id)
46
- chunk += `id: ${value.id}\n`;
47
- if (value.retry)
48
- chunk += `retry: ${value.retry}\n`;
49
- const data = typeof value.data === 'string' ? value.data : JSON.stringify(value.data);
50
- const lines = data.split('\n');
61
+ if (value.event !== undefined)
62
+ chunk += `event: ${serializeSseMetadata(value.event, 'event')}\n`;
63
+ if (value.id !== undefined)
64
+ chunk += `id: ${serializeSseMetadata(value.id, 'id')}\n`;
65
+ if (value.retry !== undefined)
66
+ chunk += `retry: ${serializeSseRetry(value.retry)}\n`;
67
+ const lines = serializeSseData(value.data).split('\n');
51
68
  for (const line of lines) {
52
69
  chunk += `data: ${line}\n`;
53
70
  }