@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.
- package/README.md +4 -1
- package/dist/build/compiler-runtime.js +3 -0
- package/dist/build/page-loop-state.d.ts +1 -4
- package/dist/build/page-loop-state.js +10 -9
- package/dist/build/page-loop.js +8 -7
- package/dist/build/server-script.js +13 -36
- package/dist/build/type-declarations.js +1 -54
- package/dist/dev-build-session/helpers.js +27 -7
- package/dist/dev-build-session/session.js +19 -10
- package/dist/dev-server/build-error-response.d.ts +21 -0
- package/dist/dev-server/build-error-response.js +48 -0
- package/dist/dev-server/port-fallback.d.ts +15 -0
- package/dist/dev-server/port-fallback.js +61 -0
- package/dist/dev-server/request-handler.js +12 -1
- package/dist/dev-server/watcher.js +15 -0
- package/dist/dev-server.d.ts +5 -2
- package/dist/dev-server.js +37 -45
- package/dist/images/remote-fetch.d.ts +12 -0
- package/dist/images/remote-fetch.js +257 -0
- package/dist/images/service.d.ts +10 -0
- package/dist/images/service.js +9 -46
- package/dist/index.js +12 -2
- package/dist/manifest.js +6 -1
- package/dist/resource-response.js +25 -8
- package/dist/resource-route-module.js +5 -22
- package/dist/route-classification.d.ts +10 -0
- package/dist/route-classification.js +17 -0
- package/dist/route-handler-export-analysis.d.ts +22 -0
- package/dist/route-handler-export-analysis.js +41 -0
- package/dist/server-output.js +6 -16
- package/dist/server-route-names.d.ts +2 -0
- package/dist/server-route-names.js +38 -0
- package/dist/server-runtime/node-server.js +5 -2
- package/dist/types/generate-env-dts.js +2 -44
- package/dist/types/zenith-env-dts.d.ts +4 -0
- package/dist/types/zenith-env-dts.js +96 -0
- 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) {
|
package/dist/dev-server.d.ts
CHANGED
|
@@ -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
|
}>;
|
package/dist/dev-server.js
CHANGED
|
@@ -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
|
|
72
|
+
const inferredProjectRoot = basename(pagesParentDir) === 'src'
|
|
72
73
|
? dirname(pagesParentDir)
|
|
73
74
|
: pagesParentDir;
|
|
74
|
-
const
|
|
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 =
|
|
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
|
-
|
|
329
|
+
try {
|
|
330
|
+
server.close();
|
|
331
|
+
}
|
|
332
|
+
catch { }
|
|
328
333
|
};
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
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
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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
|
+
}
|
package/dist/images/service.d.ts
CHANGED
|
@@ -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';
|
package/dist/images/service.js
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
}
|