| 1 | // `npm ci` that survives a dropped connection: run as |
| 2 | // |
| 3 | // node scripts/deploy/npm-ci.mjs [npm ci's flags…] |
| 4 | // |
| 5 | // npm retries a request that fails before its answer starts, but not one |
| 6 | // whose body breaks off midway (`ECONNRESET`, `network aborted`), and an |
| 7 | // install can also stall with nothing more arriving. Either fails a whole |
| 8 | // job that passes when run again. So an install whose output names a |
| 9 | // network failure, or that ends without saying why, is run again (up to |
| 10 | // `ATTEMPTS` times, after `WAITS_MS`), and one that goes `STALL_MS` |
| 11 | // without a line of output is stopped and run again. npm's cache keeps |
| 12 | // what the failed attempt already fetched, so a second try is quick. |
| 13 | // Anything else, such as a lockfile out of step, fails at once. |
| 14 | |
| 15 | import { spawn } from "node:child_process"; |
| 16 | import { fileURLToPath } from "node:url"; |
| 17 | |
| 18 | export const ATTEMPTS = 3; |
| 19 | export const WAITS_MS = [5_000, 15_000]; |
| 20 | /** How long an install may print nothing before it counts as stalled. */ |
| 21 | export const STALL_MS = Number(process.env.G1T_NPM_STALL_MS) || 12 * 60_000; |
| 22 | |
| 23 | /** npm's words for a connection that failed: worth trying again. */ |
| 24 | const NETWORK = /\b(ECONNRESET|ECONNREFUSED|ETIMEDOUT|ESOCKETTIMEDOUT|EAI_AGAIN|EPIPE|ERR_SOCKET_TIMEOUT)\b|network aborted|socket hang up|network timeout|request to \S+ failed/i; |
| 25 | |
| 26 | /** |
| 27 | * Whether a failed install is worth running again: its output names a |
| 28 | * network failure, it stalled, or it failed without an `npm error` line |
| 29 | * that says why (a dropped connection can end npm before it writes one). |
| 30 | */ |
| 31 | export function retryable(output, { stalled = false } = {}) { |
| 32 | if (stalled) return true; |
| 33 | if (NETWORK.test(output)) return true; |
| 34 | return !/^npm (error|ERR!)/m.test(output); |
| 35 | } |
| 36 | |
| 37 | /** The npm arguments for these flags: `ci` and the flags as given. */ |
| 38 | export function npmArgs(flags) { |
| 39 | return ["ci", ...flags]; |
| 40 | } |
| 41 | |
| 42 | /** One install: its exit code, what it printed, and whether it stalled. */ |
| 43 | function attempt(args, { stallMs, onLine }) { |
| 44 | return new Promise((resolve) => { |
| 45 | const child = spawn("npm", args, { shell: process.platform === "win32", stdio: ["ignore", "pipe", "pipe"] }); |
| 46 | let output = ""; |
| 47 | let stalled = false; |
| 48 | let timer; |
| 49 | const quiet = () => { |
| 50 | clearTimeout(timer); |
| 51 | timer = setTimeout(() => { |
| 52 | stalled = true; |
| 53 | child.kill("SIGKILL"); |
| 54 | }, stallMs); |
| 55 | }; |
| 56 | quiet(); |
| 57 | const take = (stream, out) => |
| 58 | stream.on("data", (chunk) => { |
| 59 | const text = chunk.toString(); |
| 60 | output += text; |
| 61 | out.write(text); |
| 62 | onLine?.(text); |
| 63 | quiet(); |
| 64 | }); |
| 65 | take(child.stdout, process.stdout); |
| 66 | take(child.stderr, process.stderr); |
| 67 | child.on("close", (code) => { |
| 68 | clearTimeout(timer); |
| 69 | resolve({ code: stalled ? 1 : (code ?? 1), output, stalled }); |
| 70 | }); |
| 71 | }); |
| 72 | } |
| 73 | |
| 74 | /** Runs `npm ci` with `flags`, again when it fails in a way worth retrying. Resolves to its exit code. */ |
| 75 | export async function npmCi(flags, { attempts = ATTEMPTS, waits = WAITS_MS, stallMs = STALL_MS, run = attempt, sleep = (ms) => new Promise((r) => setTimeout(r, ms)), log = console.error } = {}) { |
| 76 | const args = npmArgs(flags); |
| 77 | for (let tries = 1; ; tries++) { |
| 78 | const done = await run(args, { stallMs }); |
| 79 | if (done.code === 0) return 0; |
| 80 | const why = done.stalled ? `printed nothing for ${Math.round(stallMs / 1000)} s` : `exited ${done.code}`; |
| 81 | if (tries >= attempts || !retryable(done.output, done)) { |
| 82 | log(`npm ci ${why}${tries > 1 ? ` on try ${tries} of ${attempts}` : ""}; not trying again.`); |
| 83 | return done.code || 1; |
| 84 | } |
| 85 | const wait = waits[Math.min(tries - 1, waits.length - 1)]; |
| 86 | log(`npm ci ${why}, which looks like a dropped connection: trying again in ${wait / 1000} s (try ${tries + 1} of ${attempts}).`); |
| 87 | await sleep(wait); |
| 88 | } |
| 89 | } |
| 90 | |
| 91 | if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { |
| 92 | process.exitCode = await npmCi(process.argv.slice(2)); |
| 93 | } |