Did not apply the patch. It set a global dispatcher on the npm undici package and rewrote the lockfile. Node's built-in fetch does not use that dispatcher, and node:undici is not importable. fetch() reads HTTP_PROXY, HTTPS_PROXY and NO_PROXY only at startup, and only when that startup had NODE_USE_ENV_PROXY=1 (or --use-env-proxy). That is Node >=22.21.0 and >=24.0.0. tools/fetch-assets.mjs restarts itself once with NODE_USE_ENV_PROXY=1 before downloading. The default fetch in the downloader and the index cache rejects when a proxy is set but this process cannot use it, including Node 22.0–22.20 and 23, instead of fetching directly. No new dependency. Engines stay >=22. Tests: node --test test/assets-env-proxy.test.js — 8 pass, 0 fail. node --test test/asset-network.test.js test/assets-diy.test.js test/fetch-assets-shrink.test.js test/assets.test.js — 84 pass, 0 fail. Before this commit: npm run -s lint — 0 errors (135 existing warnings); npm run -s typecheck — ok; npm run -s check:imports — exit 0 (3 pre-existing sim-node notes). docs, version and package tests: 46 pass and 1 fail while env-proxy.mjs was still untracked; after git add, node --test test/package.test.js — 10 pass, 0 fail.
241 lines
11 KiB
JavaScript
241 lines
11 KiB
JavaScript
// Concurrent, idempotent, verifying downloader for the asset pipeline.
|
|
//
|
|
// - Bounded concurrency (default 16) over a job list; each job writes exactly one
|
|
// file under the output root, atomically (temp file + rename), so a partial
|
|
// download never appears at the final path.
|
|
// - Per source: up to `retries` attempts with exponential backoff on network
|
|
// errors, timeouts, 403/429/5xx and payloads that fail format validation;
|
|
// a 404/410 moves on immediately. Each candidate uses its opt-in prefix proxy
|
|
// first (when enabled), then the original URL and jsDelivr fallback. The proxy gets one short attempt
|
|
// per file and shares a run-wide circuit breaker with the index/font fetchers.
|
|
// - HTTP(S)_PROXY: the default fetch uses it only when this process was started
|
|
// with NODE_USE_ENV_PROXY=1 (Node >=22.21 or >=24). Otherwise it fails closed
|
|
// (tools/assets/env-proxy.mjs). Pass fetchImpl to bypass that, as tests do.
|
|
// - Idempotent: an existing file is kept when its size matches the ledger entry
|
|
// of a previous download or the expected byte count from research, or (when
|
|
// neither is known) when it passes format validation. The ledger lives in
|
|
// .cache/assets-ledger.json. `keepExisting` (fetch-assets --add-only) keeps
|
|
// every existing file as it is; `written` = the files this run wrote.
|
|
|
|
import { mkdir, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises';
|
|
import { dirname, join } from 'node:path';
|
|
import { MirrorPolicy } from './network.mjs';
|
|
import { validate } from './formats.mjs';
|
|
import { guardDefaultFetch } from './env-proxy.mjs';
|
|
|
|
/**
|
|
* @typedef {{ rel: string, urls: string[], kind: string, bytes?: number, mutable?: boolean }} Job
|
|
* @typedef {{ status: 'ok'|'skip'|'miss'|'error', bytes: number, url?: string, error?: string, sizeChanged?: boolean }} JobResult
|
|
*/
|
|
|
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
|
|
class HttpError extends Error {
|
|
constructor(status, url) { super(`HTTP ${status} ${url}`); this.status = status; }
|
|
}
|
|
|
|
function fmtMB(n) { return (n / 1048576).toFixed(1) + ' MB'; }
|
|
|
|
export class Downloader {
|
|
/**
|
|
* @param {object} o
|
|
* @param {string} o.root absolute output directory (public/assets)
|
|
* @param {string} o.ledgerPath absolute path of the ledger JSON
|
|
* @param {number} [o.concurrency]
|
|
* @param {number} [o.retries]
|
|
* @param {number} [o.timeoutMs]
|
|
* @param {boolean} [o.force] re-download even when files exist
|
|
* @param {boolean} [o.keepExisting] never re-download or rewrite an existing file (a checkout sharing public/assets)
|
|
* @param {(msg:string)=>void} [o.log]
|
|
* @param {typeof fetch} [o.fetchImpl]
|
|
* @param {number} [o.backoffMs] base retry delay (doubles per attempt)
|
|
* @param {'direct'|'mirror'} [o.source] preferred download source
|
|
* @param {string} [o.proxyPrefix] HTTPS prefix for GitHub downloads
|
|
* @param {MirrorPolicy} [o.mirrorPolicy] invocation-wide policy and mirror circuit breaker
|
|
*/
|
|
constructor({ root, ledgerPath, concurrency = 16, retries = 3, timeoutMs = 120000, force = false, keepExisting = false, log = console.log, fetchImpl = globalThis.fetch, backoffMs = 400, source = 'direct', proxyPrefix, mirrorPolicy }) {
|
|
this.root = root;
|
|
this.ledgerPath = ledgerPath;
|
|
this.concurrency = Math.max(1, Math.min(64, Number(concurrency) || 16));
|
|
this.retries = Math.max(1, Number(retries) || 3);
|
|
this.timeoutMs = timeoutMs;
|
|
this.force = force && !keepExisting;
|
|
this.keepExisting = !!keepExisting;
|
|
/** @type {Set<string>} the files (paths under root) this run wrote */
|
|
this.written = new Set();
|
|
this.log = log;
|
|
this.fetch = guardDefaultFetch(fetchImpl);
|
|
this.network = mirrorPolicy ?? new MirrorPolicy({ source, proxyPrefix, log });
|
|
this.backoffMs = Math.max(0, Number(backoffMs) || 0);
|
|
this.ledger = { files: {} };
|
|
this.totals = { ok: 0, skip: 0, miss: 0, error: 0, bytesDownloaded: 0, sizeChanged: 0 };
|
|
this.dirty = 0;
|
|
this.saving = false;
|
|
}
|
|
|
|
/** Load the ledger (missing or corrupt ledger → empty). */
|
|
async loadLedger() {
|
|
try {
|
|
const j = JSON.parse(await readFile(this.ledgerPath, 'utf8'));
|
|
if (j && typeof j === 'object' && j.files && typeof j.files === 'object') this.ledger = j;
|
|
} catch { /* first run */ }
|
|
}
|
|
|
|
/** Persist the ledger. */
|
|
async saveLedger() {
|
|
await mkdir(dirname(this.ledgerPath), { recursive: true });
|
|
const tmp = this.ledgerPath + '.tmp';
|
|
await writeFile(tmp, JSON.stringify(this.ledger));
|
|
await rename(tmp, this.ledgerPath);
|
|
this.dirty = 0;
|
|
}
|
|
|
|
/**
|
|
* Decide whether an existing file can be kept.
|
|
* @param {Job} job
|
|
* @returns {Promise<number>} size of the kept file, or -1 to (re)download
|
|
*/
|
|
async existingSize(job) {
|
|
if (this.force) return -1;
|
|
const abs = join(this.root, job.rel);
|
|
let st;
|
|
try { st = await stat(abs); } catch { return -1; }
|
|
if (!st.isFile() || st.size <= 0) return -1;
|
|
if (this.keepExisting) return st.size;
|
|
const led = this.ledger.files[job.rel];
|
|
if (job.mutable) {
|
|
// Post-processed files (atlases) change size; validate content instead.
|
|
try { return validate(job.kind, await readFile(abs)) ? st.size : -1; } catch { return -1; }
|
|
}
|
|
if (led && led.bytes === st.size) return st.size;
|
|
if (job.bytes && job.bytes === st.size) return st.size;
|
|
if (led && led.bytes !== st.size) return -1; // changed since we wrote it
|
|
// Unknown provenance (e.g. ledger deleted): keep it only if it validates.
|
|
try { return validate(job.kind, await readFile(abs)) ? st.size : -1; } catch { return -1; }
|
|
}
|
|
|
|
/**
|
|
* Fetch one URL with retries.
|
|
* @returns {Promise<{buf:Buffer}|{notFound:true}|{skipped:true}|{error:string}>}
|
|
*/
|
|
async fetchWithRetries(url, kind) {
|
|
if (this.network.skip(url)) return { skipped: true };
|
|
let lastErr = 'unknown error';
|
|
const attempts = this.network.isProxy(url) ? 1 : this.retries;
|
|
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
try {
|
|
const res = await this.network.request(url, this.fetch, {
|
|
headers: { 'user-agent': 'stronghold-protocol-fetch-assets/1.0' },
|
|
redirect: 'follow',
|
|
}, this.timeoutMs);
|
|
if (res.status === 404 || res.status === 410) {
|
|
try { await res.body?.cancel(); } catch { /* ignore */ }
|
|
this.network.succeeded(url); // a missing asset is not a proxy outage
|
|
return { notFound: true };
|
|
}
|
|
if (!res.ok) {
|
|
try { await res.body?.cancel(); } catch { /* ignore */ }
|
|
throw new HttpError(res.status, url);
|
|
}
|
|
const buf = await this.network.readBody(url, res);
|
|
const len = Number(res.headers.get('content-length'));
|
|
const enc = res.headers.get('content-encoding');
|
|
if (!enc && Number.isFinite(len) && len > 0 && len !== buf.length) {
|
|
throw new Error(`truncated body ${buf.length}/${len} ${url}`);
|
|
}
|
|
if (!validate(kind, buf)) throw new Error(`invalid ${kind} payload (${buf.length} B) ${url}`);
|
|
this.network.succeeded(url);
|
|
return { buf };
|
|
} catch (e) {
|
|
lastErr = e?.message || String(e);
|
|
this.network.failed(url);
|
|
if (attempt < attempts && this.backoffMs) await sleep(this.backoffMs * 2 ** (attempt - 1) + Math.floor(Math.random() * this.backoffMs));
|
|
}
|
|
}
|
|
return { error: lastErr };
|
|
}
|
|
|
|
/**
|
|
* Download one job (all candidate URLs and mirrors).
|
|
* @param {Job} job
|
|
* @returns {Promise<JobResult>}
|
|
*/
|
|
async runJob(job) {
|
|
const kept = await this.existingSize(job);
|
|
if (kept >= 0) return { status: 'skip', bytes: kept };
|
|
let lastError = null;
|
|
for (const url of job.urls) {
|
|
const sources = this.network.urls(url);
|
|
for (const src of sources) {
|
|
const r = await this.fetchWithRetries(src, job.kind);
|
|
if (r.notFound || r.skipped) continue;
|
|
if (r.error) { lastError = r.error; continue; }
|
|
const abs = join(this.root, job.rel);
|
|
await mkdir(dirname(abs), { recursive: true });
|
|
const tmp = `${abs}.part${process.pid}`;
|
|
try {
|
|
await writeFile(tmp, r.buf);
|
|
await rename(tmp, abs);
|
|
} catch (e) {
|
|
try { await unlink(tmp); } catch { /* ignore */ }
|
|
return { status: 'error', bytes: 0, error: `write failed: ${e.message}` };
|
|
}
|
|
this.ledger.files[job.rel] = { url: src, bytes: r.buf.length };
|
|
this.written.add(job.rel);
|
|
this.dirty++;
|
|
const sizeChanged = !!(job.bytes && job.bytes !== r.buf.length && !job.mutable);
|
|
return { status: 'ok', bytes: r.buf.length, url: src, sizeChanged };
|
|
}
|
|
}
|
|
if (lastError) this.network.directFailureHint();
|
|
return lastError ? { status: 'error', bytes: 0, error: lastError } : { status: 'miss', bytes: 0, error: 'not found (404) on all sources' };
|
|
}
|
|
|
|
/**
|
|
* Run a list of jobs with bounded concurrency. Duplicate `rel`s are run once.
|
|
* @param {Job[]} jobs
|
|
* @param {string} label progress label
|
|
* @returns {Promise<Map<string, JobResult>>}
|
|
*/
|
|
async run(jobs, label = 'download') {
|
|
const unique = [];
|
|
const seen = new Set();
|
|
for (const j of jobs) { if (!seen.has(j.rel)) { seen.add(j.rel); unique.push(j); } }
|
|
const results = new Map();
|
|
const t0 = Date.now();
|
|
let done = 0; let bytes = 0; let lastLog = 0;
|
|
const counts = { ok: 0, skip: 0, miss: 0, error: 0 };
|
|
const progress = (force) => {
|
|
const now = Date.now();
|
|
if (!force && now - lastLog < 2000) return;
|
|
lastLog = now;
|
|
const secs = Math.max(0.001, (now - t0) / 1000);
|
|
this.log(`[${label}] ${done}/${unique.length} ok=${counts.ok} skip=${counts.skip} miss=${counts.miss} err=${counts.error} ` +
|
|
`${fmtMB(bytes)} ${(bytes / 1048576 / secs).toFixed(2)} MB/s`);
|
|
};
|
|
let next = 0;
|
|
const worker = async () => {
|
|
while (next < unique.length) {
|
|
const job = unique[next++];
|
|
let r;
|
|
try { r = await this.runJob(job); } catch (e) { r = { status: 'error', bytes: 0, error: e?.message || String(e) }; }
|
|
results.set(job.rel, r);
|
|
counts[r.status]++;
|
|
this.totals[r.status]++;
|
|
if (r.status === 'ok') { bytes += r.bytes; this.totals.bytesDownloaded += r.bytes; }
|
|
if (r.sizeChanged) this.totals.sizeChanged++;
|
|
done++;
|
|
if (this.dirty >= 200 && !this.saving) {
|
|
this.saving = true;
|
|
try { await this.saveLedger(); } catch { /* retried at the end */ } finally { this.saving = false; }
|
|
}
|
|
progress(false);
|
|
}
|
|
};
|
|
await Promise.all(Array.from({ length: Math.min(this.concurrency, unique.length) }, worker));
|
|
if (unique.length) progress(true);
|
|
await this.saveLedger();
|
|
return results;
|
|
}
|
|
}
|