feat(npm): install.js network resilience for slow / firewalled networks
A community user from China reported `npm install deepseek-tui` took 18 minutes through a CN npm mirror. The bottleneck is the GitHub Releases binary fetch (~46 MB across two binaries), not the npm tarball (which is 6.9 kB). The CN mirror does NOT proxy GitHub release downloads, so any user behind a slow or lossy connection is hitting the GitHub fetch directly with no resilience. Four behaviors added to `npm/deepseek-tui/scripts/install.js`: 1. **Retry with exponential backoff.** Up to 5 attempts on network errors (ECONNRESET, ECONNREFUSED, ETIMEDOUT, EAI_AGAIN, network/host unreachable, EPIPE, ECONNABORTED) and 5xx upstream responses. Backoff `1s, 2s, 4s, 8s, 16s` with ±20% jitter. 4xx and checksum-mismatch are flagged non-retryable so we don't thrash on permanent failures. Final error includes the underlying message and the attempt count. 2. **Per-attempt total timeout + stall detector.** Total timeout defaults to 5 minutes per attempt (`DEEPSEEK_TUI_DOWNLOAD_TIMEOUT_MS`, alias `DEEPSEEK_DOWNLOAD_TIMEOUT_MS`). A stall detector aborts the request when no bytes arrive for 30 s (`DEEPSEEK_TUI_DOWNLOAD_STALL_MS`, alias `DEEPSEEK_DOWNLOAD_STALL_MS`) so a hung connection doesn't waste the whole timeout. Both budgets are surfaced in the error so the user can dial them up if they're on a slow pipe. 3. **HTTPS_PROXY / HTTP_PROXY support — pure Node, no new dependencies.** Detects `HTTPS_PROXY` / `HTTP_PROXY` (and the lowercase variants) and routes through the proxy via CONNECT tunneling. `NO_PROXY` exclusion list honored, with `*` and dotted- suffix matching. Proxy auth via standard `user:pass@` URL form is passed through as `Proxy-Authorization: Basic ...`. Pure-Node implementation using `net` + `tls` + `http` + `https` builtins — no `https-proxy-agent` dependency added. 4. **Download progress indicator.** Writes to stderr every ~1 MB or every 2 s in TTY mode using `\r` to overwrite a single line. Non-TTY mode (CI, piped) emits one line per 5 MB so logs stay reasonable. Suppressed when `DEEPSEEK_TUI_QUIET_INSTALL=1` or when `npm_config_loglevel` is `silent` or `error`. Falls back to `N MB downloaded` when the response has no `Content-Length`. Public API unchanged: existing callers of `getBinaryPath` and `run` keep working identically when no new env vars are set. The escape hatch `DEEPSEEK_TUI_DISABLE_INSTALL=1` still exits cleanly. Verified locally: * `node -c install.js` and module-load syntax checks. * `DEEPSEEK_TUI_FORCE_DOWNLOAD=1 DEEPSEEK_TUI_VERSION=0.8.10 node install.js` — real GitHub Releases download succeeded with visible progress, both binaries landed. * `HTTPS_PROXY=http://invalid.proxy.local:9999 ... node install.js` — proxy path exercised, fails cleanly with the bad host named in the error message after retries exhausted. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,10 +1,12 @@
|
||||
const fs = require("fs");
|
||||
const https = require("https");
|
||||
const http = require("http");
|
||||
const net = require("net");
|
||||
const tls = require("tls");
|
||||
const crypto = require("crypto");
|
||||
const { URL } = require("url");
|
||||
const { mkdir, chmod, stat, rename, readFile, unlink, writeFile } = fs.promises;
|
||||
const { createWriteStream } = fs;
|
||||
const { pipeline } = require("stream/promises");
|
||||
const path = require("path");
|
||||
|
||||
const {
|
||||
@@ -16,6 +18,46 @@ const {
|
||||
const { preflightGlibc } = require("./preflight-glibc");
|
||||
const pkg = require("../package.json");
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 300_000; // 5 minutes per attempt
|
||||
const DEFAULT_STALL_MS = 30_000; // abort if no bytes for 30s
|
||||
const MAX_ATTEMPTS = 5;
|
||||
const BASE_BACKOFF_MS = 1_000;
|
||||
|
||||
const RETRYABLE_NET_CODES = new Set([
|
||||
"ECONNRESET",
|
||||
"ECONNREFUSED",
|
||||
"ETIMEDOUT",
|
||||
"EAI_AGAIN",
|
||||
"ENETUNREACH",
|
||||
"EHOSTUNREACH",
|
||||
"EPIPE",
|
||||
"ECONNABORTED",
|
||||
]);
|
||||
|
||||
class NonRetryableError extends Error {
|
||||
constructor(message) {
|
||||
super(message);
|
||||
this.name = "NonRetryableError";
|
||||
this.nonRetryable = true;
|
||||
}
|
||||
}
|
||||
|
||||
class HttpStatusError extends Error {
|
||||
constructor(status, url) {
|
||||
super(`Request failed with status ${status}: ${url}`);
|
||||
this.name = "HttpStatusError";
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
class DownloadTimeoutError extends Error {
|
||||
constructor(message) {
|
||||
super(message);
|
||||
this.name = "DownloadTimeoutError";
|
||||
this.code = "EDOWNLOADTIMEOUT";
|
||||
}
|
||||
}
|
||||
|
||||
function resolvePackageVersion() {
|
||||
const configuredVersion =
|
||||
process.env.DEEPSEEK_TUI_VERSION ||
|
||||
@@ -44,45 +86,705 @@ function binaryPaths() {
|
||||
};
|
||||
}
|
||||
|
||||
async function httpGet(url) {
|
||||
const client = url.startsWith("https:") ? https : http;
|
||||
const response = await new Promise((resolve, reject) => {
|
||||
client.get(url, (res) => {
|
||||
const status = res.statusCode || 0;
|
||||
if (status >= 300 && status < 400 && res.headers.location) {
|
||||
resolve({ redirect: res.headers.location, response: null });
|
||||
return;
|
||||
}
|
||||
if (status !== 200) {
|
||||
reject(new Error(`Request failed with status ${status}: ${url}`));
|
||||
return;
|
||||
}
|
||||
resolve({ redirect: null, response: res });
|
||||
}).on("error", reject);
|
||||
});
|
||||
return response;
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Logging / progress
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function isQuietInstall() {
|
||||
if (process.env.DEEPSEEK_TUI_QUIET_INSTALL === "1") {
|
||||
return true;
|
||||
}
|
||||
const level = (process.env.npm_config_loglevel || "").toLowerCase();
|
||||
return level === "silent" || level === "error";
|
||||
}
|
||||
|
||||
async function download(url, destination) {
|
||||
const resolved = await httpGet(url);
|
||||
if (resolved.redirect) {
|
||||
return download(resolved.redirect, destination);
|
||||
function logInfo(message) {
|
||||
if (isQuietInstall()) {
|
||||
return;
|
||||
}
|
||||
process.stderr.write(`deepseek-tui: ${message}\n`);
|
||||
}
|
||||
|
||||
function envInt(name, fallback) {
|
||||
const raw = process.env[name];
|
||||
if (!raw) {
|
||||
return fallback;
|
||||
}
|
||||
const parsed = Number.parseInt(String(raw).trim(), 10);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
return fallback;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function downloadTimeoutMs() {
|
||||
return envInt(
|
||||
"DEEPSEEK_TUI_DOWNLOAD_TIMEOUT_MS",
|
||||
envInt("DEEPSEEK_DOWNLOAD_TIMEOUT_MS", DEFAULT_TIMEOUT_MS),
|
||||
);
|
||||
}
|
||||
|
||||
function downloadStallMs() {
|
||||
return envInt(
|
||||
"DEEPSEEK_TUI_DOWNLOAD_STALL_MS",
|
||||
envInt("DEEPSEEK_DOWNLOAD_STALL_MS", DEFAULT_STALL_MS),
|
||||
);
|
||||
}
|
||||
|
||||
function formatMb(bytes) {
|
||||
return (bytes / (1024 * 1024)).toFixed(0);
|
||||
}
|
||||
|
||||
function createProgressReporter(assetName, totalBytes) {
|
||||
if (isQuietInstall()) {
|
||||
return { onChunk: () => {}, finish: () => {} };
|
||||
}
|
||||
const isTty = !!process.stderr.isTTY;
|
||||
const interactive = isTty;
|
||||
const tickBytes = interactive ? 1 * 1024 * 1024 : 5 * 1024 * 1024;
|
||||
const tickMs = 2_000;
|
||||
|
||||
let received = 0;
|
||||
let lastBytesPrinted = 0;
|
||||
let lastTimePrinted = 0;
|
||||
let everPrinted = false;
|
||||
|
||||
const render = (final) => {
|
||||
if (totalBytes && totalBytes > 0) {
|
||||
const pct = Math.min(100, Math.round((received / totalBytes) * 100));
|
||||
const line = `deepseek-tui: downloading ${assetName}: ${formatMb(received)} / ${formatMb(totalBytes)} MB (${pct}%)`;
|
||||
if (interactive) {
|
||||
process.stderr.write(`${line}\r`);
|
||||
} else {
|
||||
process.stderr.write(`${line}\n`);
|
||||
}
|
||||
} else {
|
||||
const line = `deepseek-tui: downloading ${assetName}: ${formatMb(received)} MB downloaded`;
|
||||
if (interactive) {
|
||||
process.stderr.write(`${line}\r`);
|
||||
} else {
|
||||
process.stderr.write(`${line}\n`);
|
||||
}
|
||||
}
|
||||
everPrinted = true;
|
||||
lastBytesPrinted = received;
|
||||
lastTimePrinted = Date.now();
|
||||
};
|
||||
|
||||
return {
|
||||
onChunk(chunkLen) {
|
||||
received += chunkLen;
|
||||
const now = Date.now();
|
||||
if (
|
||||
received - lastBytesPrinted >= tickBytes ||
|
||||
(interactive && now - lastTimePrinted >= tickMs)
|
||||
) {
|
||||
render(false);
|
||||
}
|
||||
},
|
||||
finish() {
|
||||
// Final line — always render once.
|
||||
render(true);
|
||||
if (interactive && everPrinted) {
|
||||
// Move past the carriage-return line and emit a "done" footer.
|
||||
process.stderr.write("\n");
|
||||
}
|
||||
process.stderr.write(`deepseek-tui: ${assetName} ... done.\n`);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Proxy support (HTTPS_PROXY / HTTP_PROXY / NO_PROXY) — pure Node, CONNECT
|
||||
// tunnel + TLS upgrade for HTTPS targets.
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function getProxyUrl(targetUrl) {
|
||||
const isHttps = targetUrl.protocol === "https:";
|
||||
const candidates = isHttps
|
||||
? ["HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy"]
|
||||
: ["HTTP_PROXY", "http_proxy"];
|
||||
for (const name of candidates) {
|
||||
const raw = process.env[name];
|
||||
if (raw && String(raw).trim() !== "") {
|
||||
return String(raw).trim();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function shouldBypassProxy(host) {
|
||||
const raw = process.env.NO_PROXY || process.env.no_proxy;
|
||||
if (!raw) {
|
||||
return false;
|
||||
}
|
||||
const lower = String(host).toLowerCase();
|
||||
for (const part of String(raw).split(",")) {
|
||||
const entry = part.trim().toLowerCase();
|
||||
if (!entry) {
|
||||
continue;
|
||||
}
|
||||
if (entry === "*") {
|
||||
return true;
|
||||
}
|
||||
// Strip leading dot and any explicit port.
|
||||
const stripped = entry.replace(/^\./, "").replace(/:.*$/, "");
|
||||
if (!stripped) {
|
||||
continue;
|
||||
}
|
||||
if (lower === stripped || lower.endsWith(`.${stripped}`)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function parseProxy(proxyStr) {
|
||||
// Accept "http://user:pass@host:port" and bare "host:port".
|
||||
const normalized = /^[a-z][a-z0-9+\-.]*:\/\//i.test(proxyStr)
|
||||
? proxyStr
|
||||
: `http://${proxyStr}`;
|
||||
const u = new URL(normalized);
|
||||
const port = u.port
|
||||
? Number.parseInt(u.port, 10)
|
||||
: u.protocol === "https:"
|
||||
? 443
|
||||
: 80;
|
||||
let auth = null;
|
||||
if (u.username) {
|
||||
const user = decodeURIComponent(u.username);
|
||||
const pass = u.password ? decodeURIComponent(u.password) : "";
|
||||
auth = Buffer.from(`${user}:${pass}`).toString("base64");
|
||||
}
|
||||
return {
|
||||
protocol: u.protocol,
|
||||
host: u.hostname,
|
||||
port,
|
||||
auth,
|
||||
raw: proxyStr,
|
||||
};
|
||||
}
|
||||
|
||||
function connectThroughProxy(proxy, targetHost, targetPort, timeoutMs) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const socket = net.connect({ host: proxy.host, port: proxy.port });
|
||||
let settled = false;
|
||||
const fail = (err) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
try {
|
||||
socket.destroy();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
reject(err);
|
||||
};
|
||||
|
||||
const timer = timeoutMs > 0
|
||||
? setTimeout(() => fail(new DownloadTimeoutError(
|
||||
`proxy CONNECT to ${proxy.host}:${proxy.port} timed out after ${timeoutMs} ms`,
|
||||
)), timeoutMs)
|
||||
: null;
|
||||
|
||||
socket.once("error", (err) => {
|
||||
if (timer) clearTimeout(timer);
|
||||
// Surface proxy host so the user can fix it.
|
||||
const wrapped = new Error(
|
||||
`proxy connection failed (${proxy.host}:${proxy.port}): ${err.message}`,
|
||||
);
|
||||
wrapped.code = err.code;
|
||||
fail(wrapped);
|
||||
});
|
||||
|
||||
socket.once("connect", () => {
|
||||
const lines = [
|
||||
`CONNECT ${targetHost}:${targetPort} HTTP/1.1`,
|
||||
`Host: ${targetHost}:${targetPort}`,
|
||||
"User-Agent: deepseek-tui-installer",
|
||||
"Proxy-Connection: keep-alive",
|
||||
];
|
||||
if (proxy.auth) {
|
||||
lines.push(`Proxy-Authorization: Basic ${proxy.auth}`);
|
||||
}
|
||||
const req = `${lines.join("\r\n")}\r\n\r\n`;
|
||||
|
||||
let buf = Buffer.alloc(0);
|
||||
const onData = (chunk) => {
|
||||
buf = Buffer.concat([buf, chunk]);
|
||||
const idx = buf.indexOf("\r\n\r\n");
|
||||
if (idx === -1) {
|
||||
if (buf.length > 16 * 1024) {
|
||||
socket.removeListener("data", onData);
|
||||
fail(new Error(
|
||||
`proxy ${proxy.host}:${proxy.port} returned an oversized response header`,
|
||||
));
|
||||
}
|
||||
return;
|
||||
}
|
||||
socket.removeListener("data", onData);
|
||||
const head = buf.slice(0, idx).toString("utf8");
|
||||
const firstLine = head.split(/\r?\n/, 1)[0] || "";
|
||||
const m = firstLine.match(/^HTTP\/\d\.\d\s+(\d{3})/);
|
||||
if (!m) {
|
||||
fail(new Error(`proxy ${proxy.host}:${proxy.port} returned invalid CONNECT reply: ${firstLine}`));
|
||||
return;
|
||||
}
|
||||
const code = Number.parseInt(m[1], 10);
|
||||
if (code !== 200) {
|
||||
fail(new Error(
|
||||
`proxy ${proxy.host}:${proxy.port} refused CONNECT to ${targetHost}:${targetPort}: HTTP ${code}`,
|
||||
));
|
||||
return;
|
||||
}
|
||||
if (timer) clearTimeout(timer);
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
// Any bytes past the header belong to the tunneled stream — but in
|
||||
// practice CONNECT 200 has no body; if it did, we'd lose those bytes
|
||||
// here. Keep it simple: trust well-behaved proxies.
|
||||
resolve(socket);
|
||||
};
|
||||
socket.on("data", onData);
|
||||
socket.write(req, "utf8");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// HTTP request with timeout, stall detection, and proxy support.
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function httpRequest(rawUrl, opts = {}) {
|
||||
const totalTimeoutMs = opts.totalTimeoutMs ?? downloadTimeoutMs();
|
||||
const stallMs = opts.stallMs ?? downloadStallMs();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let url;
|
||||
try {
|
||||
url = new URL(rawUrl);
|
||||
} catch (err) {
|
||||
reject(new NonRetryableError(`Invalid URL: ${rawUrl} (${err.message})`));
|
||||
return;
|
||||
}
|
||||
if (url.protocol !== "https:" && url.protocol !== "http:") {
|
||||
reject(new NonRetryableError(`Unsupported protocol: ${url.protocol}`));
|
||||
return;
|
||||
}
|
||||
|
||||
const proxyStr = !shouldBypassProxy(url.hostname) ? getProxyUrl(url) : null;
|
||||
const isHttps = url.protocol === "https:";
|
||||
const port = url.port
|
||||
? Number.parseInt(url.port, 10)
|
||||
: isHttps
|
||||
? 443
|
||||
: 80;
|
||||
|
||||
let totalTimer = null;
|
||||
let stallTimer = null;
|
||||
let settled = false;
|
||||
let req = null;
|
||||
let res = null;
|
||||
|
||||
const cleanup = () => {
|
||||
if (totalTimer) {
|
||||
clearTimeout(totalTimer);
|
||||
totalTimer = null;
|
||||
}
|
||||
if (stallTimer) {
|
||||
clearTimeout(stallTimer);
|
||||
stallTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const fail = (err) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
try {
|
||||
if (req && !req.destroyed) req.destroy();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
if (res && !res.destroyed) res.destroy();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
reject(err);
|
||||
};
|
||||
|
||||
if (totalTimeoutMs > 0) {
|
||||
totalTimer = setTimeout(() => {
|
||||
fail(new DownloadTimeoutError(
|
||||
`download exceeded total timeout of ${totalTimeoutMs} ms ` +
|
||||
`(set DEEPSEEK_TUI_DOWNLOAD_TIMEOUT_MS to raise it; current stall budget is ${stallMs} ms)`,
|
||||
));
|
||||
}, totalTimeoutMs);
|
||||
}
|
||||
|
||||
const armStallTimer = () => {
|
||||
if (stallMs <= 0) return;
|
||||
if (stallTimer) clearTimeout(stallTimer);
|
||||
stallTimer = setTimeout(() => {
|
||||
fail(new DownloadTimeoutError(
|
||||
`download stalled — no bytes received for ${stallMs} ms ` +
|
||||
`(set DEEPSEEK_TUI_DOWNLOAD_STALL_MS to raise it; total budget is ${totalTimeoutMs} ms)`,
|
||||
));
|
||||
}, stallMs);
|
||||
};
|
||||
|
||||
const launch = (socket) => {
|
||||
const reqOptions = {
|
||||
method: "GET",
|
||||
host: url.hostname,
|
||||
port,
|
||||
path: `${url.pathname}${url.search || ""}`,
|
||||
headers: {
|
||||
Host: url.host,
|
||||
"User-Agent": "deepseek-tui-installer",
|
||||
Accept: "*/*",
|
||||
Connection: "close",
|
||||
},
|
||||
};
|
||||
if (socket) {
|
||||
reqOptions.createConnection = () => socket;
|
||||
if (isHttps) {
|
||||
// Wrap raw TCP socket from CONNECT in TLS.
|
||||
const tlsSocket = tls.connect({
|
||||
socket,
|
||||
servername: url.hostname,
|
||||
ALPNProtocols: ["http/1.1"],
|
||||
});
|
||||
tlsSocket.once("error", (err) => fail(err));
|
||||
reqOptions.createConnection = () => tlsSocket;
|
||||
}
|
||||
}
|
||||
const client = isHttps ? https : http;
|
||||
try {
|
||||
req = client.request(reqOptions, (response) => {
|
||||
res = response;
|
||||
armStallTimer();
|
||||
response.on("data", () => {
|
||||
armStallTimer();
|
||||
});
|
||||
response.on("end", () => {
|
||||
cleanup();
|
||||
});
|
||||
response.on("error", (err) => fail(err));
|
||||
|
||||
const status = response.statusCode || 0;
|
||||
if (status >= 300 && status < 400 && response.headers.location) {
|
||||
cleanup();
|
||||
settled = true;
|
||||
response.resume();
|
||||
resolve({ redirect: response.headers.location, response: null });
|
||||
return;
|
||||
}
|
||||
if (status < 200 || status >= 300) {
|
||||
const err = new HttpStatusError(status, rawUrl);
|
||||
// 4xx: non-retryable; 5xx: retryable.
|
||||
if (status >= 400 && status < 500) {
|
||||
err.nonRetryable = true;
|
||||
}
|
||||
fail(err);
|
||||
return;
|
||||
}
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
// Hand the live response stream to the caller.
|
||||
resolve({ redirect: null, response });
|
||||
});
|
||||
req.once("error", (err) => fail(err));
|
||||
req.once("socket", (s) => {
|
||||
// Belt-and-suspenders: surface socket-level errors quickly.
|
||||
s.once("error", (err) => fail(err));
|
||||
});
|
||||
req.end();
|
||||
} catch (err) {
|
||||
fail(err);
|
||||
}
|
||||
};
|
||||
|
||||
if (proxyStr) {
|
||||
let proxy;
|
||||
try {
|
||||
proxy = parseProxy(proxyStr);
|
||||
} catch (err) {
|
||||
fail(new NonRetryableError(
|
||||
`Invalid proxy URL "${proxyStr}": ${err.message}`,
|
||||
));
|
||||
return;
|
||||
}
|
||||
if (!isHttps) {
|
||||
// Plain HTTP through proxy — send absolute URI, no CONNECT.
|
||||
const client = http;
|
||||
try {
|
||||
req = client.request(
|
||||
{
|
||||
host: proxy.host,
|
||||
port: proxy.port,
|
||||
method: "GET",
|
||||
path: rawUrl,
|
||||
headers: {
|
||||
Host: url.host,
|
||||
"User-Agent": "deepseek-tui-installer",
|
||||
Accept: "*/*",
|
||||
Connection: "close",
|
||||
...(proxy.auth ? { "Proxy-Authorization": `Basic ${proxy.auth}` } : {}),
|
||||
},
|
||||
},
|
||||
(response) => {
|
||||
res = response;
|
||||
armStallTimer();
|
||||
response.on("data", () => armStallTimer());
|
||||
response.on("end", () => cleanup());
|
||||
response.on("error", (err) => fail(err));
|
||||
const status = response.statusCode || 0;
|
||||
if (status >= 300 && status < 400 && response.headers.location) {
|
||||
cleanup();
|
||||
settled = true;
|
||||
response.resume();
|
||||
resolve({ redirect: response.headers.location, response: null });
|
||||
return;
|
||||
}
|
||||
if (status < 200 || status >= 300) {
|
||||
const err = new HttpStatusError(status, rawUrl);
|
||||
if (status >= 400 && status < 500) err.nonRetryable = true;
|
||||
fail(err);
|
||||
return;
|
||||
}
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolve({ redirect: null, response });
|
||||
},
|
||||
);
|
||||
req.once("error", (err) => fail(err));
|
||||
req.end();
|
||||
} catch (err) {
|
||||
fail(err);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// HTTPS through proxy: CONNECT tunnel + TLS upgrade.
|
||||
connectThroughProxy(proxy, url.hostname, port, Math.max(stallMs, 5_000))
|
||||
.then((tcpSocket) => {
|
||||
if (settled) {
|
||||
try { tcpSocket.destroy(); } catch { /* ignore */ }
|
||||
return;
|
||||
}
|
||||
const tlsSocket = tls.connect({
|
||||
socket: tcpSocket,
|
||||
servername: url.hostname,
|
||||
ALPNProtocols: ["http/1.1"],
|
||||
});
|
||||
tlsSocket.once("error", (err) => fail(err));
|
||||
tlsSocket.once("secureConnect", () => {
|
||||
if (settled) {
|
||||
try { tlsSocket.destroy(); } catch { /* ignore */ }
|
||||
return;
|
||||
}
|
||||
const reqOptions = {
|
||||
method: "GET",
|
||||
createConnection: () => tlsSocket,
|
||||
path: `${url.pathname}${url.search || ""}`,
|
||||
headers: {
|
||||
Host: url.host,
|
||||
"User-Agent": "deepseek-tui-installer",
|
||||
Accept: "*/*",
|
||||
Connection: "close",
|
||||
},
|
||||
};
|
||||
try {
|
||||
req = https.request(reqOptions, (response) => {
|
||||
res = response;
|
||||
armStallTimer();
|
||||
response.on("data", () => armStallTimer());
|
||||
response.on("end", () => cleanup());
|
||||
response.on("error", (err) => fail(err));
|
||||
const status = response.statusCode || 0;
|
||||
if (status >= 300 && status < 400 && response.headers.location) {
|
||||
cleanup();
|
||||
settled = true;
|
||||
response.resume();
|
||||
resolve({ redirect: response.headers.location, response: null });
|
||||
return;
|
||||
}
|
||||
if (status < 200 || status >= 300) {
|
||||
const err = new HttpStatusError(status, rawUrl);
|
||||
if (status >= 400 && status < 500) err.nonRetryable = true;
|
||||
fail(err);
|
||||
return;
|
||||
}
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolve({ redirect: null, response });
|
||||
});
|
||||
req.once("error", (err) => fail(err));
|
||||
req.end();
|
||||
} catch (err) {
|
||||
fail(err);
|
||||
}
|
||||
});
|
||||
})
|
||||
.catch((err) => fail(err));
|
||||
return;
|
||||
}
|
||||
|
||||
// No proxy — direct connection.
|
||||
launch(null);
|
||||
});
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Retry wrapper
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function isRetryable(err) {
|
||||
if (!err) return false;
|
||||
if (err.nonRetryable) return false;
|
||||
if (err instanceof NonRetryableError) return false;
|
||||
if (err instanceof DownloadTimeoutError) return true;
|
||||
if (err instanceof HttpStatusError) {
|
||||
return err.status >= 500;
|
||||
}
|
||||
if (err.code && RETRYABLE_NET_CODES.has(err.code)) return true;
|
||||
// Network-flavored messages we may see without a code.
|
||||
const msg = String(err.message || "").toLowerCase();
|
||||
if (msg.includes("network") && msg.includes("unreachable")) return true;
|
||||
if (msg.includes("socket hang up")) return true;
|
||||
if (msg.includes("aborted")) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function backoffDelay(attempt) {
|
||||
// attempt is 1-indexed; first retry waits ~1s.
|
||||
const base = BASE_BACKOFF_MS * 2 ** (attempt - 1);
|
||||
const jitter = (Math.random() * 0.4 - 0.2) * base; // ±20%
|
||||
return Math.max(0, Math.round(base + jitter));
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function withRetry(label, fn) {
|
||||
let lastErr;
|
||||
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
||||
try {
|
||||
return await fn(attempt);
|
||||
} catch (err) {
|
||||
lastErr = err;
|
||||
if (!isRetryable(err) || attempt === MAX_ATTEMPTS) {
|
||||
break;
|
||||
}
|
||||
const wait = backoffDelay(attempt);
|
||||
logInfo(
|
||||
`${label} failed (attempt ${attempt}/${MAX_ATTEMPTS}): ${err.message}; retrying in ${wait} ms`,
|
||||
);
|
||||
await sleep(wait);
|
||||
}
|
||||
}
|
||||
const msg = lastErr && lastErr.message ? lastErr.message : String(lastErr);
|
||||
const wrapped = new Error(
|
||||
`${label} failed after ${MAX_ATTEMPTS} attempt(s): ${msg}`,
|
||||
);
|
||||
if (lastErr && lastErr.stack) {
|
||||
wrapped.cause = lastErr;
|
||||
}
|
||||
throw wrapped;
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Public download primitives (now retry + progress aware)
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function followRedirects(url, opts) {
|
||||
const maxRedirects = 10;
|
||||
let current = url;
|
||||
for (let hop = 0; hop < maxRedirects; hop++) {
|
||||
const result = await httpRequest(current, opts);
|
||||
if (result.redirect) {
|
||||
try {
|
||||
current = new URL(result.redirect, current).toString();
|
||||
} catch {
|
||||
current = result.redirect;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
throw new NonRetryableError(`too many redirects starting at ${url}`);
|
||||
}
|
||||
|
||||
function streamToFile(response, destination, progress) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const sink = createWriteStream(destination);
|
||||
let done = false;
|
||||
const finish = (err) => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
if (err) {
|
||||
sink.destroy();
|
||||
reject(err);
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
response.on("data", (chunk) => {
|
||||
if (progress) progress.onChunk(chunk.length);
|
||||
});
|
||||
response.on("error", (err) => finish(err));
|
||||
sink.on("error", (err) => finish(err));
|
||||
sink.on("finish", () => finish(null));
|
||||
response.pipe(sink);
|
||||
});
|
||||
}
|
||||
|
||||
async function download(url, destination, options = {}) {
|
||||
await mkdir(path.dirname(destination), { recursive: true });
|
||||
await pipeline(resolved.response, createWriteStream(destination));
|
||||
const assetName = options.assetName || path.basename(destination);
|
||||
await withRetry(`download ${assetName}`, async (attempt) => {
|
||||
const result = await followRedirects(url, {
|
||||
totalTimeoutMs: downloadTimeoutMs(),
|
||||
stallMs: downloadStallMs(),
|
||||
});
|
||||
const response = result.response;
|
||||
const lenHeader = response.headers["content-length"];
|
||||
const total = lenHeader ? Number.parseInt(lenHeader, 10) : 0;
|
||||
const progress = createProgressReporter(assetName, Number.isFinite(total) ? total : 0);
|
||||
if (attempt > 1) {
|
||||
logInfo(`retry attempt ${attempt}/${MAX_ATTEMPTS} for ${assetName}`);
|
||||
}
|
||||
try {
|
||||
await streamToFile(response, destination, progress);
|
||||
} catch (err) {
|
||||
// Ensure we don't leave a partial file confusing future attempts.
|
||||
try {
|
||||
await unlink(destination);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
progress.finish();
|
||||
});
|
||||
}
|
||||
|
||||
async function downloadText(url) {
|
||||
const resolved = await httpGet(url);
|
||||
if (resolved.redirect) {
|
||||
return downloadText(resolved.redirect);
|
||||
}
|
||||
const chunks = [];
|
||||
resolved.response.setEncoding("utf8");
|
||||
for await (const chunk of resolved.response) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
return chunks.join("");
|
||||
return withRetry(`fetch ${url}`, async () => {
|
||||
const result = await followRedirects(url, {
|
||||
totalTimeoutMs: downloadTimeoutMs(),
|
||||
stallMs: downloadStallMs(),
|
||||
});
|
||||
const response = result.response;
|
||||
const chunks = [];
|
||||
response.setEncoding("utf8");
|
||||
for await (const chunk of response) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
return chunks.join("");
|
||||
});
|
||||
}
|
||||
|
||||
async function readLocalVersion(file) {
|
||||
@@ -122,11 +824,13 @@ async function sha256File(filePath) {
|
||||
async function verifyChecksum(filePath, assetName, checksums) {
|
||||
const expected = checksums.get(assetName);
|
||||
if (!expected) {
|
||||
throw new Error(`Checksum manifest is missing ${assetName}`);
|
||||
throw new NonRetryableError(`Checksum manifest is missing ${assetName}`);
|
||||
}
|
||||
const actual = await sha256File(filePath);
|
||||
if (actual !== expected) {
|
||||
throw new Error(
|
||||
// Bytes are corrupted; another fetch is unlikely to help without a fix
|
||||
// upstream. Mark non-retryable.
|
||||
throw new NonRetryableError(
|
||||
`Checksum mismatch for ${assetName}: expected ${expected}, got ${actual}`,
|
||||
);
|
||||
}
|
||||
@@ -152,7 +856,7 @@ async function ensureBinary(targetPath, assetName, version, repo, getChecksums)
|
||||
const checksums = await getChecksums();
|
||||
const url = releaseAssetUrl(assetName, version, repo);
|
||||
const destination = `${targetPath}.${process.pid}.${Date.now()}.download`;
|
||||
await download(url, destination);
|
||||
await download(url, destination, { assetName });
|
||||
try {
|
||||
await verifyChecksum(destination, assetName, checksums);
|
||||
preflightGlibc(destination);
|
||||
|
||||
Reference in New Issue
Block a user