@johnhenry/sitefile 0.0.0 → 0.0.1

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 (44) hide show
  1. package/CHANGELOG.md +104 -0
  2. package/README.md +58 -20
  3. package/lib/src/checklinks.js +2 -0
  4. package/lib/src/cli/build-image.js +2 -2
  5. package/lib/src/cli/cache.js +15 -5
  6. package/lib/src/cli/images.js +3 -3
  7. package/lib/src/cli/index.d.ts +1 -1
  8. package/lib/src/cli/index.js +1 -1
  9. package/lib/src/cli/network.d.ts +34 -0
  10. package/lib/src/cli/network.js +56 -0
  11. package/lib/src/cli/outdated.js +5 -0
  12. package/lib/src/cli/registry.js +6 -1
  13. package/lib/src/cli/servers.js +23 -7
  14. package/lib/src/cli/vendor.js +5 -0
  15. package/lib/src/cli.js +44 -8
  16. package/lib/src/errors.d.ts +25 -3
  17. package/lib/src/errors.js +9 -0
  18. package/lib/src/executor.d.ts +7 -0
  19. package/lib/src/executor.js +18 -10
  20. package/lib/src/export-formats.d.ts +10 -3
  21. package/lib/src/export-formats.js +17 -6
  22. package/lib/src/index.d.ts +2 -1
  23. package/lib/src/index.js +2 -1
  24. package/lib/src/language/ast.d.ts +9 -2
  25. package/lib/src/outdated.js +1 -1
  26. package/lib/src/planner.d.ts +1 -1
  27. package/lib/src/planner.js +2 -2
  28. package/lib/src/plugins/cache.d.ts +29 -3
  29. package/lib/src/plugins/cache.js +104 -5
  30. package/lib/src/registry.d.ts +6 -0
  31. package/lib/src/registry.js +38 -19
  32. package/lib/src/resources/https.d.ts +35 -1
  33. package/lib/src/resources/https.js +175 -18
  34. package/lib/src/resources/network.d.ts +70 -0
  35. package/lib/src/resources/network.js +304 -0
  36. package/lib/src/resources/types.d.ts +6 -0
  37. package/lib/src/secrets.js +1 -1
  38. package/lib/src/serve-rules.js +2 -1
  39. package/lib/src/servers.d.ts +14 -0
  40. package/lib/src/servers.js +89 -3
  41. package/lib/src/vendor.js +1 -1
  42. package/lib/src/wbn.d.ts +21 -0
  43. package/lib/src/wbn.js +111 -17
  44. package/package.json +2 -1
@@ -5,15 +5,21 @@ import { sha256 } from '../digest.js';
5
5
  /**
6
6
  * Content-addressed cache of TRANSFORM results (see docs/PLUGINS.md, "Caching").
7
7
  *
8
- * <dir>/entries/<key>.json manifest: which blobs make up the result
8
+ * <dir>/entries/<key>.json manifest: which blobs make up the result, and the
9
+ * PLUGIN or TOOL that produced it ("plugin")
9
10
  * <dir>/blobs/<hex> result bytes, named by their SHA-256
10
11
  *
11
12
  * The key is the SHA-256 of a canonical JSON document describing everything the
12
13
  * result can depend on. Reads verify every blob's digest and size; a damaged
13
14
  * entry is removed and counts as a miss, so the next put rewrites it.
15
+ * "plugin" is optional (entries written before it was recorded have none and are
16
+ * still read) and not part of the key.
14
17
  */
15
18
  /** Bump to invalidate every existing entry when the key or entry format changes. */
16
19
  export const CACHE_VERSION = 1;
20
+ function manifestOf(version, kind, files, plugin) {
21
+ return plugin === undefined ? { version, kind, files } : { version, kind, files, plugin };
22
+ }
17
23
  /** JSON with sorted object keys, so equal values always serialize equally. */
18
24
  export function canonicalJson(value) {
19
25
  const norm = (v) => {
@@ -51,7 +57,8 @@ export class TransformCache {
51
57
  blobPath(hex) {
52
58
  return path.join(this.dir, 'blobs', hex);
53
59
  }
54
- async get(key) {
60
+ /** `plugin`: the TRANSFORM's PLUGIN or TOOL, recorded on an entry fetched from the remote cache. */
61
+ async get(key, plugin) {
55
62
  const file = this.entryPath(key);
56
63
  let raw;
57
64
  try {
@@ -63,7 +70,7 @@ export class TransformCache {
63
70
  this.stats.misses++;
64
71
  return undefined;
65
72
  }
66
- const m = { version: CACHE_VERSION, kind: fetched.kind, files: fetched.files };
73
+ const m = manifestOf(CACHE_VERSION, fetched.kind, fetched.files, plugin);
67
74
  raw = JSON.stringify(m);
68
75
  await this.writeAtomic(file, raw, false).catch(() => { });
69
76
  }
@@ -112,7 +119,8 @@ export class TransformCache {
112
119
  const result = m.kind === 'file' ? { kind: 'file', bytes: loaded[0][1] } : { kind: 'files', files: loaded };
113
120
  return { result, entry: { kind: m.kind, files: m.files.map((f) => [f[0], f[1], f[2]]) } };
114
121
  }
115
- async put(key, result) {
122
+ /** `plugin`: the PLUGIN or TOOL that produced the result (see `usageByPlugin`, `prunePlugin`). */
123
+ async put(key, result, plugin) {
116
124
  try {
117
125
  const pairs = result.kind === 'file' ? [['', result.bytes]] : result.files;
118
126
  const files = [];
@@ -121,7 +129,7 @@ export class TransformCache {
121
129
  await this.writeAtomic(this.blobPath(hex), bytes, true);
122
130
  files.push([p, hex, bytes.byteLength]);
123
131
  }
124
- const manifest = { version: CACHE_VERSION, kind: result.kind, files };
132
+ const manifest = manifestOf(CACHE_VERSION, result.kind, files, plugin);
125
133
  await this.writeAtomic(this.entryPath(key), JSON.stringify(manifest), false);
126
134
  this.stats.writes++;
127
135
  this.remote?.used(key, { kind: result.kind, files });
@@ -198,6 +206,97 @@ export class TransformCache {
198
206
  }
199
207
  return { entries: removedEntries, blobs: removedBlobs, bytes };
200
208
  }
209
+ /**
210
+ * Remove the entries `plugin` (a PLUGIN or TOOL name) wrote that were not used for `olderThanMs`
211
+ * (0: all of them), then the blobs they used that no remaining entry references. Entries of other
212
+ * plugins, entries that record no plugin, and blobs no entry references yet are kept.
213
+ */
214
+ async prunePlugin(plugin, olderThanMs = 0, now = Date.now()) {
215
+ let removedEntries = 0;
216
+ const live = new Set();
217
+ const removed = new Set();
218
+ for (const n of await readdir(path.join(this.dir, 'entries')).catch(() => [])) {
219
+ const file = path.join(this.dir, 'entries', n);
220
+ const st = await stat(file).catch(() => undefined);
221
+ if (!st)
222
+ continue;
223
+ let m;
224
+ try {
225
+ m = JSON.parse(await readFile(file, 'utf8'));
226
+ }
227
+ catch {
228
+ continue;
229
+ }
230
+ const refs = Array.isArray(m?.files) ? m.files.map((f) => f[1]) : [];
231
+ const old = olderThanMs === 0 || now - st.mtimeMs >= olderThanMs;
232
+ if (old && m.plugin === plugin) {
233
+ await rm(file, { force: true });
234
+ removedEntries++;
235
+ for (const r of refs)
236
+ removed.add(r);
237
+ }
238
+ else {
239
+ for (const r of refs)
240
+ live.add(r);
241
+ }
242
+ }
243
+ let removedBlobs = 0;
244
+ let bytes = 0;
245
+ for (const n of removed) {
246
+ if (live.has(n))
247
+ continue;
248
+ const file = path.join(this.dir, 'blobs', n);
249
+ const st = await stat(file).catch(() => undefined);
250
+ if (!st?.isFile())
251
+ continue;
252
+ await rm(file, { force: true });
253
+ removedBlobs++;
254
+ bytes += st.size;
255
+ }
256
+ return { entries: removedEntries, blobs: removedBlobs, bytes };
257
+ }
258
+ /** Entries, blobs and bytes per plugin, sorted by name (entries that record none first). Reads no blob. */
259
+ async usageByPlugin() {
260
+ const by = new Map();
261
+ for (const n of await readdir(path.join(this.dir, 'entries')).catch(() => [])) {
262
+ if (!n.endsWith('.json'))
263
+ continue;
264
+ let m;
265
+ try {
266
+ m = JSON.parse(await readFile(path.join(this.dir, 'entries', n), 'utf8'));
267
+ }
268
+ catch {
269
+ continue;
270
+ }
271
+ if (!Array.isArray(m?.files))
272
+ continue;
273
+ const name = typeof m.plugin === 'string' ? m.plugin : undefined;
274
+ const slot = by.get(name) ?? { entries: 0, blobs: new Set() };
275
+ slot.entries++;
276
+ for (const f of m.files)
277
+ if (typeof f?.[1] === 'string')
278
+ slot.blobs.add(f[1]);
279
+ by.set(name, slot);
280
+ }
281
+ // (Array.prototype.sort puts undefined last without asking the comparator.)
282
+ const named = [...by.keys()].filter((k) => k !== undefined).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
283
+ const names = by.has(undefined) ? [undefined, ...named] : named;
284
+ const out = [];
285
+ for (const plugin of names) {
286
+ const slot = by.get(plugin);
287
+ let blobs = 0;
288
+ let bytes = 0;
289
+ for (const hex of slot.blobs) {
290
+ const st = await stat(this.blobPath(hex)).catch(() => undefined);
291
+ if (st?.isFile()) {
292
+ blobs++;
293
+ bytes += st.size;
294
+ }
295
+ }
296
+ out.push({ plugin, entries: slot.entries, blobs, bytes });
297
+ }
298
+ return out;
299
+ }
201
300
  }
202
301
  /** Digest of every regular file under `dir` (a plugin can read all of them, so any may change its output). */
203
302
  export async function digestFolder(dir, limits = { files: 2000, bytes: 128 * 1024 * 1024 }) {
@@ -1,6 +1,7 @@
1
1
  import type { Readable } from 'node:stream';
2
2
  import { type Descriptor } from './oci.js';
3
3
  import type { Credentials } from './registry-auth.js';
4
+ import { type NetworkSettings } from './resources/network.js';
4
5
  /**
5
6
  * Client for the OCI distribution API (distribution-spec v1.1): `/v2/` probe, Bearer-token and Basic
6
7
  * auth, manifest and blob GET/HEAD, monolithic blob upload, cross-repository mount, manifest PUT.
@@ -40,6 +41,11 @@ export interface RegistryOptions {
40
41
  readonly limits?: Partial<RegistryLimits> | undefined;
41
42
  /** Extra PEM CA certificate(s) (tests / private CAs). */
42
43
  readonly ca?: string | Buffer | undefined;
44
+ /**
45
+ * Proxy, host rules and timeouts (see {@link NetworkSettings}); the host rules apply to every
46
+ * request, the registry's own included. `readTimeoutMs` replaces `limits.timeoutMs`.
47
+ */
48
+ readonly network?: NetworkSettings | undefined;
43
49
  }
44
50
  export interface Body {
45
51
  readonly length: number;
@@ -7,7 +7,8 @@ import { sha256 } from './digest.js';
7
7
  import { SitefileError, isSitefileError } from './errors.js';
8
8
  import { MEDIA } from './oci.js';
9
9
  import { registryApiHost } from './ref.js';
10
- import { createGuardedLookup, redactUrl } from './resources/https.js';
10
+ import { armTimeouts, createGuardedLookup, redactUrl, routeRequest } from './resources/https.js';
11
+ import { checkHostRules } from './resources/network.js';
11
12
  import { isPublicAddress } from './resources/netpolicy.js';
12
13
  import { DEFAULT_FETCH_POLICY } from './resources/types.js';
13
14
  export const DEFAULT_REGISTRY_LIMITS = {
@@ -97,7 +98,8 @@ export class RegistryClient {
97
98
  this.registry = registry;
98
99
  this.options = options;
99
100
  this.host = registryApiHost(registry);
100
- this.limits = { ...DEFAULT_REGISTRY_LIMITS, ...options.limits };
101
+ const read = options.network?.readTimeoutMs;
102
+ this.limits = { ...DEFAULT_REGISTRY_LIMITS, ...options.limits, ...(read ? { timeoutMs: read } : {}) };
101
103
  this.loopback = isLoopbackHost(hostnameOf(this.host));
102
104
  this.insecure = this.loopback || (options.insecureRegistries ?? []).some((h) => h.toLowerCase() === this.host.toLowerCase() || h.toLowerCase() === registry.toLowerCase());
103
105
  }
@@ -120,31 +122,48 @@ export class RegistryClient {
120
122
  throw new SitefileError('E_SSRF_BLOCKED', `${this.registry}: refusing to connect to non-public address ${host} (${redactUrl(u)}); use --allow-private-network to permit it`);
121
123
  }
122
124
  }
123
- sendOnce(method, url, headers, body, timeoutMs) {
125
+ async sendOnce(method, url, headers, body, timeoutMs) {
126
+ const shown = redactUrl(url);
127
+ const network = this.options.network;
128
+ checkHostRules(url, network, shown);
129
+ const same = this.sameOrigin(url);
130
+ const host = url.hostname.replace(/^\[|\]$/g, '');
131
+ const loopbackPair = this.loopback && isLoopbackHost(host);
132
+ const policy = { ...DEFAULT_FETCH_POLICY, allowPrivateNetwork: same || loopbackPair || !!this.options.allowPrivateNetwork, network };
133
+ const h = { 'accept-encoding': 'identity', 'user-agent': 'sitefile/0.1', ...headers };
134
+ if (body)
135
+ h['content-length'] = String(body.length);
136
+ const o = { method, headers: h, lookup: createGuardedLookup(policy), agent: false, timeout: timeoutMs };
137
+ if (url.protocol === 'https:' && this.options.ca !== undefined) {
138
+ const get = tls.getCACertificates;
139
+ o.ca = [...(get ? get('default') : tls.rootCertificates), this.options.ca];
140
+ }
141
+ // The idle timeout is the request's own (`timeout`); only the connect timeout comes from `network`.
142
+ const connectOnly = network?.connectTimeoutMs ? { connectTimeoutMs: network.connectTimeoutMs } : undefined;
143
+ const connectTimeout = () => new SitefileError('E_FETCH_TIMEOUT', `${method} ${shown}: connecting timed out after ${network?.connectTimeoutMs} ms (connect timeout)`);
144
+ const idleTimeout = () => new SitefileError('E_FETCH_TIMEOUT', `${method} ${shown} timed out after ${timeoutMs} ms`);
145
+ const route = await routeRequest(url, o, policy, {
146
+ shown,
147
+ fail: (e) => (e.detail.endsWith('(connect timeout)') ? connectTimeout() : idleTimeout()),
148
+ }).catch((e) => {
149
+ // Proxy failures are registry transport failures (an insecure registry then falls back to http).
150
+ if (isSitefileError(e))
151
+ throw e.code === 'E_FETCH' ? new SitefileError('E_REGISTRY', e.detail, undefined, { cause: e }) : e;
152
+ throw new SitefileError('E_REGISTRY', `${method} ${shown} failed: ${e.message}`, undefined, { cause: e });
153
+ });
124
154
  return new Promise((resolve, reject) => {
125
- const same = this.sameOrigin(url);
126
- const host = url.hostname.replace(/^\[|\]$/g, '');
127
- const loopbackPair = this.loopback && isLoopbackHost(host);
128
- const policy = { ...DEFAULT_FETCH_POLICY, allowPrivateNetwork: same || loopbackPair || !!this.options.allowPrivateNetwork };
129
- const h = { 'accept-encoding': 'identity', 'user-agent': 'sitefile/0.1', ...headers };
130
- if (body)
131
- h['content-length'] = String(body.length);
132
- const o = { method, headers: h, lookup: createGuardedLookup(policy), agent: false, timeout: timeoutMs };
133
- if (url.protocol === 'https:' && this.options.ca !== undefined) {
134
- const get = tls.getCACertificates;
135
- o.ca = [...(get ? get('default') : tls.rootCertificates), this.options.ca];
136
- }
137
155
  let req;
138
156
  try {
139
- req = (url.protocol === 'https:' ? httpsRequest : httpRequest)(url, o);
157
+ req = route.url ? route.fn(route.url, route.options) : route.fn(route.options);
140
158
  }
141
159
  catch (e) {
142
- reject(new SitefileError('E_REGISTRY', `cannot request ${redactUrl(url)}: ${e.message}`, undefined, { cause: e }));
160
+ reject(new SitefileError('E_REGISTRY', `cannot request ${shown}: ${e.message}`, undefined, { cause: e }));
143
161
  return;
144
162
  }
145
- req.on('timeout', () => req.destroy(new SitefileError('E_FETCH_TIMEOUT', `${method} ${redactUrl(url)} timed out after ${timeoutMs} ms`)));
163
+ armTimeouts(req, connectOnly, route.proxied, () => req.destroy(connectTimeout()));
164
+ req.on('timeout', () => req.destroy(idleTimeout()));
146
165
  req.on('response', resolve);
147
- req.on('error', (e) => reject(unwrap(e) ?? new SitefileError('E_REGISTRY', `${method} ${redactUrl(url)} failed: ${e.message}`, undefined, { cause: e })));
166
+ req.on('error', (e) => reject(unwrap(e) ?? new SitefileError('E_REGISTRY', `${method} ${shown} failed: ${e.message}`, undefined, { cause: e })));
148
167
  if (!body) {
149
168
  req.end();
150
169
  return;
@@ -1,6 +1,8 @@
1
+ import { request as httpRequest, type ClientRequest, type RequestOptions } from 'node:http';
1
2
  import { type LookupFunction } from 'node:net';
2
- import { type SourceSpan } from '../errors.js';
3
+ import { SitefileError, type SourceSpan } from '../errors.js';
3
4
  import type { FetchPolicy } from './types.js';
5
+ import { type NetworkSettings } from './network.js';
4
6
  export interface FetchResult {
5
7
  readonly bytes: Uint8Array;
6
8
  /** URL of the final hop after redirects. */
@@ -38,3 +40,35 @@ export declare function createGuardedLookup(policy: FetchPolicy, span?: SourceSp
38
40
  * whole fetch.
39
41
  */
40
42
  export declare function fetchRemote(url: string, policy: FetchPolicy, span?: SourceSpan, request?: FetchRequest): Promise<FetchResult>;
43
+ /** How to send one request: directly, or through the configured proxy. */
44
+ export interface Route {
45
+ readonly fn: typeof httpRequest;
46
+ /** The URL to pass to `fn`; undefined when `options` carry the proxy's host, port and absolute path. */
47
+ readonly url?: URL | undefined;
48
+ readonly options: RequestOptions;
49
+ /** True when the connection is to a proxy (the connect timeout then covers the tunnel). */
50
+ readonly proxied: boolean;
51
+ }
52
+ export interface RouteContext {
53
+ readonly signal?: AbortSignal | undefined;
54
+ readonly span?: SourceSpan | undefined;
55
+ /** The hop's URL as shown in messages. */
56
+ readonly shown: string;
57
+ /** Records a timeout error before the request is destroyed with it. */
58
+ readonly fail?: ((e: SitefileError) => SitefileError) | undefined;
59
+ }
60
+ /** E_FETCH_TIMEOUT for a connect or read (idle) timeout. */
61
+ export declare function hopTimeout(kind: 'connect' | 'read', shown: string, network: NetworkSettings | undefined, span?: SourceSpan): SitefileError;
62
+ /**
63
+ * Apply `network`'s connect timeout (until the TCP connection is up) and read timeout (socket idle time
64
+ * once connected) to a request; `onTimeout` destroys it. For a tunnelled request the socket is already
65
+ * connected (the tunnel had the connect timeout), so only the read timeout applies.
66
+ */
67
+ export declare function armTimeouts(req: ClientRequest, network: NetworkSettings | undefined, _proxied: boolean, onTimeout: (kind: 'connect' | 'read') => void): void;
68
+ /**
69
+ * Route one request: directly (with the guarded lookup in `options`), or through `policy.network.proxy`
70
+ * unless the host is in its no-proxy list. Through a proxy, the target host is still resolved here and
71
+ * refused if it is non-public (unless `allowPrivateNetwork`), so a proxy never widens the policy; the
72
+ * proxy itself is named by the user and may be on a private address.
73
+ */
74
+ export declare function routeRequest(url: URL, options: RequestOptions, policy: FetchPolicy, ctx: RouteContext): Promise<Route>;
@@ -5,6 +5,7 @@ import * as tls from 'node:tls';
5
5
  import { isIP } from 'node:net';
6
6
  import { SitefileError, isSitefileError } from '../errors.js';
7
7
  import { isPublicAddress } from './netpolicy.js';
8
+ import { checkHostRules, proxyFor } from './network.js';
8
9
  const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
9
10
  const USER_AGENT = 'sitefile/0.1';
10
11
  /** A URL rendered without any userinfo, safe for error messages. */
@@ -37,6 +38,7 @@ export function validateHop(url, policy, span) {
37
38
  const host = stripBrackets(url.hostname);
38
39
  if (host === '')
39
40
  throw new SitefileError('E_PROTOCOL', `URL ${redactUrl(url)} has no host`, span);
41
+ checkHostRules(url, policy.network, redactUrl(url), span);
40
42
  if (isIP(host) !== 0 && !policy.allowPrivateNetwork && !isPublicAddress(host)) {
41
43
  throw new SitefileError('E_SSRF_BLOCKED', `refusing to connect to non-public address ${host} (${redactUrl(url)}); use --allow-private-network to permit it`, span);
42
44
  }
@@ -184,6 +186,8 @@ export async function fetchRemote(url, policy, span, request = {}) {
184
186
  throw e;
185
187
  if (controller.signal.aborted)
186
188
  throw timeoutError();
189
+ if (state.failure)
190
+ throw state.failure;
187
191
  throw new SitefileError('E_FETCH', `error reading ${redactUrl(current)}: ${e.message}`, span, {
188
192
  cause: e,
189
193
  });
@@ -203,32 +207,47 @@ export async function fetchRemote(url, policy, span, request = {}) {
203
207
  state.req?.destroy();
204
208
  }
205
209
  }
206
- function openHop(url, policy, signal, state, span, timeoutError, method, extra, body) {
210
+ async function openHop(url, policy, signal, state, span, timeoutError, method, extra, body) {
211
+ const options = {
212
+ method,
213
+ headers: { 'accept-encoding': 'identity', 'user-agent': USER_AGENT, accept: '*/*', ...extra, ...(body ? { 'content-length': String(body.byteLength) } : {}) },
214
+ lookup: createGuardedLookup(policy, span),
215
+ agent: false,
216
+ signal,
217
+ };
218
+ if (url.protocol === 'https:' && policy.ca !== undefined) {
219
+ // Additive: setting `ca` replaces Node's whole default store, so start from
220
+ // that store (bundled roots plus NODE_EXTRA_CA_CERTS / --use-system-ca),
221
+ // not just the bundled roots, and add the extra CA(s).
222
+ options.ca = [...defaultCaCertificates(), policy.ca];
223
+ }
224
+ const shown = redactUrl(url);
225
+ const fail = (e) => {
226
+ state.failure = e;
227
+ return e;
228
+ };
229
+ let route;
230
+ try {
231
+ route = await routeRequest(url, options, policy, { signal, span, shown, fail });
232
+ }
233
+ catch (e) {
234
+ if (signal.aborted)
235
+ throw timeoutError();
236
+ throw e;
237
+ }
207
238
  return new Promise((resolve, reject) => {
208
- const options = {
209
- method,
210
- headers: { 'accept-encoding': 'identity', 'user-agent': USER_AGENT, accept: '*/*', ...extra, ...(body ? { 'content-length': String(body.byteLength) } : {}) },
211
- lookup: createGuardedLookup(policy, span),
212
- agent: false,
213
- signal,
214
- };
215
- if (url.protocol === 'https:' && policy.ca !== undefined) {
216
- // Additive: setting `ca` replaces Node's whole default store, so start from
217
- // that store (bundled roots plus NODE_EXTRA_CA_CERTS / --use-system-ca),
218
- // not just the bundled roots, and add the extra CA(s).
219
- options.ca = [...defaultCaCertificates(), policy.ca];
220
- }
221
- const requestFn = url.protocol === 'https:' ? httpsRequest : httpRequest;
222
239
  let req;
223
240
  try {
224
- req = requestFn(url, options);
241
+ req = route.url ? route.fn(route.url, route.options) : route.fn(route.options);
225
242
  }
226
243
  catch (e) {
227
- reject(new SitefileError('E_FETCH', `cannot request ${redactUrl(url)}: ${e.message}`, span, { cause: e }));
244
+ reject(new SitefileError('E_FETCH', `cannot request ${shown}: ${e.message}`, span, { cause: e }));
228
245
  return;
229
246
  }
230
247
  state.req = req;
231
248
  state.res = undefined;
249
+ state.failure = undefined;
250
+ armTimeouts(req, policy.network, route.proxied, (kind) => req.destroy(fail(hopTimeout(kind, shown, policy.network, span))));
232
251
  req.on('response', (res) => {
233
252
  state.res = res;
234
253
  resolve(res);
@@ -243,11 +262,149 @@ function openHop(url, policy, signal, state, span, timeoutError, method, extra,
243
262
  reject(inner);
244
263
  return;
245
264
  }
246
- reject(new SitefileError('E_FETCH', `fetching ${redactUrl(url)} failed: ${e.message}`, span, { cause: e }));
265
+ reject(new SitefileError('E_FETCH', `fetching ${shown} failed: ${e.message}`, span, { cause: e }));
247
266
  });
248
267
  req.end(body);
249
268
  });
250
269
  }
270
+ /** E_FETCH_TIMEOUT for a connect or read (idle) timeout. */
271
+ export function hopTimeout(kind, shown, network, span) {
272
+ return kind === 'connect'
273
+ ? new SitefileError('E_FETCH_TIMEOUT', `fetching ${shown}: connecting timed out after ${network?.connectTimeoutMs} ms (connect timeout)`, span)
274
+ : new SitefileError('E_FETCH_TIMEOUT', `fetching ${shown}: no data for ${network?.readTimeoutMs} ms (read timeout)`, span);
275
+ }
276
+ /**
277
+ * Apply `network`'s connect timeout (until the TCP connection is up) and read timeout (socket idle time
278
+ * once connected) to a request; `onTimeout` destroys it. For a tunnelled request the socket is already
279
+ * connected (the tunnel had the connect timeout), so only the read timeout applies.
280
+ */
281
+ export function armTimeouts(req, network, _proxied, onTimeout) {
282
+ const connectMs = network?.connectTimeoutMs;
283
+ if (connectMs) {
284
+ const t = setTimeout(() => onTimeout('connect'), connectMs);
285
+ t.unref?.();
286
+ const clear = () => clearTimeout(t);
287
+ req.once('socket', (socket) => {
288
+ if (socket.connecting)
289
+ socket.once('connect', clear);
290
+ else
291
+ clear();
292
+ });
293
+ req.once('close', clear);
294
+ req.once('error', clear);
295
+ }
296
+ const readMs = network?.readTimeoutMs;
297
+ if (readMs)
298
+ req.setTimeout(readMs, () => onTimeout('read'));
299
+ }
300
+ /** Resolve `hostname` and apply the private-address rule, as a direct connection would at connect time. */
301
+ function checkResolved(hostname, policy, span) {
302
+ return new Promise((resolve, reject) => {
303
+ createGuardedLookup(policy, span)(hostname, { all: true }, (err) => {
304
+ if (!err) {
305
+ resolve();
306
+ return;
307
+ }
308
+ if (isSitefileError(err)) {
309
+ reject(err);
310
+ return;
311
+ }
312
+ reject(new SitefileError('E_FETCH', `cannot resolve ${hostname} to check it against the private-network rule before using the proxy: ${err.code ?? err.message}`, span));
313
+ });
314
+ });
315
+ }
316
+ /**
317
+ * Route one request: directly (with the guarded lookup in `options`), or through `policy.network.proxy`
318
+ * unless the host is in its no-proxy list. Through a proxy, the target host is still resolved here and
319
+ * refused if it is non-public (unless `allowPrivateNetwork`), so a proxy never widens the policy; the
320
+ * proxy itself is named by the user and may be on a private address.
321
+ */
322
+ export async function routeRequest(url, options, policy, ctx) {
323
+ const https = url.protocol === 'https:';
324
+ const proxy = proxyFor(url, policy.network);
325
+ if (!proxy)
326
+ return { fn: https ? httpsRequest : httpRequest, url, options, proxied: false };
327
+ const host = url.hostname.replace(/^\[|\]$/g, '');
328
+ if (!policy.allowPrivateNetwork && isIP(host) === 0)
329
+ await checkResolved(host, policy, ctx.span);
330
+ const port = url.port !== '' ? Number(url.port) : https ? 443 : 80;
331
+ const { lookup: _lookup, agent: _agent, ...rest } = options;
332
+ if (!https) {
333
+ const target = new URL(url.href);
334
+ target.hash = '';
335
+ return {
336
+ fn: httpRequest,
337
+ options: {
338
+ ...rest,
339
+ host: proxy.host,
340
+ port: proxy.port,
341
+ path: target.href,
342
+ agent: false,
343
+ headers: { ...options.headers, host: url.host, ...(proxy.authorization ? { 'proxy-authorization': proxy.authorization } : {}) },
344
+ },
345
+ proxied: true,
346
+ };
347
+ }
348
+ const socket = await openTunnel(proxy, host, port, policy.network, ctx);
349
+ const ca = options.ca;
350
+ return {
351
+ fn: httpsRequest,
352
+ url,
353
+ options: {
354
+ ...rest,
355
+ createConnection: () => tls.connect({ socket, host, ...(isIP(host) === 0 ? { servername: host } : {}), ...(ca !== undefined ? { ca } : {}), ALPNProtocols: ['http/1.1'] }),
356
+ },
357
+ proxied: true,
358
+ };
359
+ }
360
+ /** A CONNECT tunnel to `host:port` through `proxy`. */
361
+ function openTunnel(proxy, host, port, network, ctx) {
362
+ const authority = `${isIP(host) === 6 ? `[${host}]` : host}:${port}`;
363
+ return new Promise((resolve, reject) => {
364
+ let req;
365
+ try {
366
+ req = httpRequest({
367
+ host: proxy.host,
368
+ port: proxy.port,
369
+ method: 'CONNECT',
370
+ path: authority,
371
+ agent: false,
372
+ headers: { host: authority, 'user-agent': USER_AGENT, ...(proxy.authorization ? { 'proxy-authorization': proxy.authorization } : {}) },
373
+ ...(ctx.signal ? { signal: ctx.signal } : {}),
374
+ });
375
+ }
376
+ catch (e) {
377
+ reject(new SitefileError('E_FETCH', `fetching ${ctx.shown} failed: proxy ${proxy.shown}: ${e.message}`, ctx.span, { cause: e }));
378
+ return;
379
+ }
380
+ const record = ctx.fail ?? ((e) => e);
381
+ armTimeouts(req, network, true, (kind) => req.destroy(record(hopTimeout(kind, ctx.shown, network, ctx.span))));
382
+ const refused = (status) => status === 407
383
+ ? new SitefileError('E_FETCH', `proxy ${proxy.shown} requires authentication (HTTP 407)`, ctx.span)
384
+ : new SitefileError('E_FETCH', `proxy ${proxy.shown} refused to connect to ${authority}`, ctx.span);
385
+ req.on('connect', (res, socket, head) => {
386
+ if (res.statusCode !== 200) {
387
+ socket.destroy();
388
+ reject(refused(res.statusCode));
389
+ return;
390
+ }
391
+ socket.setTimeout(0);
392
+ socket.removeAllListeners('timeout');
393
+ if (head.length)
394
+ socket.unshift(head);
395
+ resolve(socket);
396
+ });
397
+ req.on('response', (res) => {
398
+ res.resume();
399
+ reject(refused(res.statusCode));
400
+ });
401
+ req.on('error', (e) => {
402
+ const inner = findSitefileError(e);
403
+ reject(inner ?? new SitefileError('E_FETCH', `fetching ${ctx.shown} failed: proxy ${proxy.shown}: ${e.message}`, ctx.span, { cause: e }));
404
+ });
405
+ req.end();
406
+ });
407
+ }
251
408
  /** Node's default CA store; `tls.getCACertificates` exists from Node 22.15/23.5. */
252
409
  function defaultCaCertificates() {
253
410
  const get = tls.getCACertificates;
@@ -0,0 +1,70 @@
1
+ import { type SourceSpan } from '../errors.js';
2
+ /**
3
+ * Explicit network settings for everything sitefile fetches (IMPORT over http(s), Git, registries).
4
+ * Nothing here is read from the environment implicitly: `HTTP(S)_PROXY` stays ignored, and the CLI
5
+ * reads `SITEFILE_PROXY` and friends through {@link networkFromEnv}. Embedders pass this in code
6
+ * (`FetchPolicy.network`, `RegistryOptions.network`).
7
+ */
8
+ export interface NetworkSettings {
9
+ /** `http://[user:password@]host[:port]`: a CONNECT tunnel for https targets, absolute-form requests for http. */
10
+ readonly proxy?: string | undefined;
11
+ /** Hosts reached directly, never through the proxy (`NO_PROXY` syntax, see {@link hostMatches}). */
12
+ readonly noProxy?: readonly string[] | undefined;
13
+ /** When non-empty, only hosts matching one of these patterns may be contacted. */
14
+ readonly allowHosts?: readonly string[] | undefined;
15
+ /** Hosts never contacted; a deny rule beats an allow rule. */
16
+ readonly denyHosts?: readonly string[] | undefined;
17
+ /** Time limit for establishing the TCP connection (to the target, or to the proxy), ms. */
18
+ readonly connectTimeoutMs?: number | undefined;
19
+ /** Longest wait for data once connected (idle time), ms. */
20
+ readonly readTimeoutMs?: number | undefined;
21
+ }
22
+ /** A configured HTTP proxy, parsed. */
23
+ export interface ProxyConfig {
24
+ readonly host: string;
25
+ readonly port: number;
26
+ /** `Basic …` when the URL carries credentials. */
27
+ readonly authorization?: string | undefined;
28
+ /** The proxy URL without credentials, for messages. */
29
+ readonly shown: string;
30
+ }
31
+ /** Split a list given as one string (commas and/or whitespace). */
32
+ export declare function splitList(raw: string | undefined): string[];
33
+ /** Parse a proxy URL, or throw E_USAGE. Credentials never appear in the message. */
34
+ export declare function parseProxy(raw: string): ProxyConfig;
35
+ /**
36
+ * True when `host` (as in a URL, IPv6 without brackets) on `port` matches `pattern`:
37
+ * `*` every host
38
+ * `example.com` example.com and every subdomain (`.example.com` and `*.example.com` too)
39
+ * `10.0.0.0/8`, `fd00::/8` IP-literal hosts in that network; `192.0.2.1`, `::1` that address
40
+ * any of these + `:port` only that port (`[::1]:8080` for IPv6)
41
+ * Patterns match the host as written in the URL: a name pattern never matches an IP literal and an
42
+ * IP pattern never matches a name, whatever it resolves to (the private-address rule covers that).
43
+ */
44
+ export declare function hostMatches(host: string, port: number, pattern: string): boolean;
45
+ /** Default port of an http(s) URL. */
46
+ export declare function urlPort(url: URL): number;
47
+ /**
48
+ * Apply the allow/deny host rules to one hop (every redirect, token realm and blob CDN hop included).
49
+ * Throws E_HOST_BLOCKED naming the rule; a deny rule beats an allow rule.
50
+ */
51
+ export declare function checkHostRules(url: URL, network: NetworkSettings | undefined, shown: string, span?: SourceSpan): void;
52
+ /** The proxy to use for `url`, or undefined for a direct connection (no proxy, or a no-proxy match). */
53
+ export declare function proxyFor(url: URL, network: NetworkSettings | undefined): ProxyConfig | undefined;
54
+ /** A usage problem in `network` (bad proxy URL, host pattern or timeout), or undefined. */
55
+ export declare function networkProblem(network: NetworkSettings): string | undefined;
56
+ /** Parse `SITEFILE_FETCH_TIMEOUT`: `<ms>` (connect and read) or `connect=<ms>,read=<ms>` (either part optional). */
57
+ export declare function parseFetchTimeout(raw: string): {
58
+ connectTimeoutMs?: number;
59
+ readTimeoutMs?: number;
60
+ };
61
+ /**
62
+ * Network settings from `SITEFILE_PROXY`, `SITEFILE_NO_PROXY`, `SITEFILE_ALLOW_HOSTS`,
63
+ * `SITEFILE_DENY_HOSTS` (comma- or space-separated) and `SITEFILE_FETCH_TIMEOUT`. Empty values are unset.
64
+ * `HTTP_PROXY`, `HTTPS_PROXY` and `NO_PROXY` are never read.
65
+ */
66
+ export declare function networkFromEnv(env?: NodeJS.ProcessEnv): NetworkSettings;
67
+ /** `over` on top of `base`: the proxy and timeouts are replaced, the host lists are joined (duplicates dropped). */
68
+ export declare function mergeNetwork(base: NetworkSettings, over: NetworkSettings): NetworkSettings;
69
+ /** True when any setting is present. */
70
+ export declare function hasNetworkSettings(n: NetworkSettings | undefined): boolean;