Merge branch 'worktree-agent-af070177ded5baf00'
11 files+413−520/11 viewed
| 79 | 79 | uses: actions/cache@v4 | |
| 80 | 80 | with: | |
| 81 | 81 | path: ~/.npm | |
| 82 | − | key: npm-${{ runner.os }}-${{ hashFiles('package-lock.json') }} | |
| 83 | − | restore-keys: npm-${{ runner.os }}- | |
| 82 | + | key: npm-full-${{ runner.os }}-${{ hashFiles('package-lock.json') }} | |
| 83 | + | restore-keys: npm-full-${{ runner.os }}- | |
| 84 | 84 | - name: Install | |
| 85 | 85 | run: npm ci --prefer-offline --no-audit --no-fund | |
| 86 | 86 | - name: Type checks | |
| ⋯ | |||
| 112 | 112 | uses: actions/cache@v4 | |
| 113 | 113 | with: | |
| 114 | 114 | path: ~/.npm | |
| 115 | − | key: npm-${{ runner.os }}-${{ hashFiles('package-lock.json') }} | |
| 116 | − | restore-keys: npm-${{ runner.os }}- | |
| 115 | + | key: npm-full-${{ runner.os }}-${{ hashFiles('package-lock.json') }} | |
| 116 | + | restore-keys: npm-full-${{ runner.os }}- | |
| 117 | 117 | - name: Install | |
| 118 | 118 | run: npm ci --prefer-offline --no-audit --no-fund | |
| 119 | 119 | - name: The site, sudo and the docs | |
| 74 | 74 | node scripts/cli-release.mjs sign | |
| 75 | 75 | node scripts/cli-release.mjs verify | |
| 76 | 76 | - name: Cache npm's downloads | |
| 77 | − | uses: actions/cache@v4 | |
| 77 | + | uses: actions/cache/restore@v4 | |
| 78 | 78 | with: | |
| 79 | 79 | path: ~/.npm | |
| 80 | − | key: npm-${{ runner.os }}-${{ hashFiles('package-lock.json') }} | |
| 81 | − | restore-keys: npm-${{ runner.os }}- | |
| 80 | + | key: npm-full-${{ runner.os }}-${{ hashFiles('package-lock.json') }} | |
| 81 | + | restore-keys: npm-full-${{ runner.os }}- | |
| 82 | 82 | - name: Install Wrangler | |
| 83 | 83 | run: npm ci --prefer-offline --workspaces=false --no-audit --no-fund | |
| 84 | 84 | - name: Publish to g1t.sh/downloads/cli |
| 70 | 70 | timeout-minutes: 20 | |
| 71 | 71 | steps: | |
| 72 | 72 | - uses: actions/checkout@v5 | |
| 73 | − | # npm's downloads, kept between runs: an install reads them first | |
| 74 | − | # and asks the registry only for what changed, so a dropped | |
| 75 | − | # connection to it rarely stops the job. | |
| 73 | + | # npm's downloads for the whole lockfile, which the npm-cache job | |
| 74 | + | # keeps: an install reads them first and asks the registry only for | |
| 75 | + | # what changed, so a dropped connection to it rarely stops the job. | |
| 76 | + | # Only restored: a job that installs part of the lockfile never | |
| 77 | + | # saves, or its part would stand in for the whole under that key. | |
| 76 | 78 | - name: Cache npm's downloads | |
| 77 | − | uses: actions/cache@v4 | |
| 79 | + | uses: actions/cache/restore@v4 | |
| 78 | 80 | with: | |
| 79 | 81 | path: ~/.npm | |
| 80 | − | key: npm-${{ runner.os }}-${{ hashFiles('package-lock.json') }} | |
| 81 | − | restore-keys: npm-${{ runner.os }}- | |
| 82 | + | key: npm-full-${{ runner.os }}-${{ hashFiles('package-lock.json') }} | |
| 83 | + | restore-keys: npm-full-${{ runner.os }}- | |
| 82 | 84 | - name: Install Wrangler | |
| 83 | 85 | run: npm ci --prefer-offline --workspaces=false --no-audit --no-fund | |
| 84 | 86 | - name: The manifest matches every wrangler.jsonc | |
| ⋯ | |||
| 86 | 88 | - name: The deploy tool's tests | |
| 87 | 89 | run: npm run test:deploy | |
| 88 | 90 | ||
| 91 | + | # npm's downloads for the whole lockfile, saved once for each lockfile | |
| 92 | + | # on main, which every install of this and the other workflows starts | |
| 93 | + | # from. Before, every job saved its own part under one key, and the | |
| 94 | + | # first to finish (Wrangler alone, 73 MB) stood in for the rest: each | |
| 95 | + | # stage then downloaded hundreds of MB through the runner's egress, | |
| 96 | + | # 50 MB tarballs among them. Nothing waits for this job, and a failed | |
| 97 | + | # download only leaves the cache as it was. | |
| 98 | + | npm-cache: | |
| 99 | + | name: npm's downloads | |
| 100 | + | runs-on: ubuntu-latest | |
| 101 | + | timeout-minutes: 20 | |
| 102 | + | steps: | |
| 103 | + | - uses: actions/checkout@v5 | |
| 104 | + | - name: Look for this lockfile's downloads | |
| 105 | + | id: saved | |
| 106 | + | uses: actions/cache/restore@v4 | |
| 107 | + | with: | |
| 108 | + | path: ~/.npm | |
| 109 | + | key: npm-full-${{ runner.os }}-${{ hashFiles('package-lock.json') }} | |
| 110 | + | lookup-only: true | |
| 111 | + | - name: Start from the last lockfile's | |
| 112 | + | if: ${{ steps.saved.outputs.cache-hit != 'true' }} | |
| 113 | + | uses: actions/cache/restore@v4 | |
| 114 | + | with: | |
| 115 | + | path: ~/.npm | |
| 116 | + | key: npm-full-${{ runner.os }}-${{ hashFiles('package-lock.json') }} | |
| 117 | + | restore-keys: npm-full-${{ runner.os }}- | |
| 118 | + | - name: Download every package | |
| 119 | + | id: download | |
| 120 | + | if: ${{ steps.saved.outputs.cache-hit != 'true' }} | |
| 121 | + | continue-on-error: true | |
| 122 | + | run: npm ci --prefer-offline --ignore-scripts --no-audit --no-fund | |
| 123 | + | - name: Save them | |
| 124 | + | if: ${{ steps.saved.outputs.cache-hit != 'true' && steps.download.outcome == 'success' }} | |
| 125 | + | uses: actions/cache/save@v4 | |
| 126 | + | with: | |
| 127 | + | path: ~/.npm | |
| 128 | + | key: npm-full-${{ runner.os }}-${{ hashFiles('package-lock.json') }} | |
| 129 | + | ||
| 89 | 130 | # Runs beside check: nothing deploys until both have succeeded. | |
| 90 | 131 | plan: | |
| 91 | 132 | name: Plan | |
| ⋯ | |||
| 138 | 179 | # and asks the registry only for what changed, so a dropped | |
| 139 | 180 | # connection to it rarely stops the job. | |
| 140 | 181 | - name: Cache npm's downloads | |
| 141 | − | uses: actions/cache@v4 | |
| 182 | + | uses: actions/cache/restore@v4 | |
| 142 | 183 | with: | |
| 143 | 184 | path: ~/.npm | |
| 144 | − | key: npm-${{ runner.os }}-${{ hashFiles('package-lock.json') }} | |
| 145 | − | restore-keys: npm-${{ runner.os }}- | |
| 185 | + | key: npm-full-${{ runner.os }}-${{ hashFiles('package-lock.json') }} | |
| 186 | + | restore-keys: npm-full-${{ runner.os }}- | |
| 146 | 187 | - name: Install Wrangler | |
| 147 | 188 | run: npm ci --prefer-offline --workspaces=false --no-audit --no-fund | |
| 148 | 189 | - name: Apply pending migrations | |
| ⋯ | |||
| 246 | 287 | - name: sccache | |
| 247 | 288 | if: ${{ matrix.rust || matrix.image }} | |
| 248 | 289 | run: bash scripts/sccache.sh install | |
| 249 | − | # npm's downloads, kept between runs as the check and migrate jobs | |
| 250 | − | # keep them: the install reads them first (--prefer-offline, in | |
| 290 | + | # npm's downloads for the whole lockfile, only restored, as the | |
| 291 | + | # check job restores them: the install reads them first (--prefer-offline, in | |
| 251 | 292 | # scripts/deploy/stack.mjs) and asks the registry only for what | |
| 252 | 293 | # changed, so a connection it drops mid-install rarely stops a stage. | |
| 253 | 294 | - name: Cache npm's downloads | |
| 254 | − | uses: actions/cache@v4 | |
| 295 | + | uses: actions/cache/restore@v4 | |
| 255 | 296 | with: | |
| 256 | 297 | path: ~/.npm | |
| 257 | − | key: npm-${{ runner.os }}-${{ hashFiles('package-lock.json') }} | |
| 258 | − | restore-keys: npm-${{ runner.os }}- | |
| 298 | + | key: npm-full-${{ runner.os }}-${{ hashFiles('package-lock.json') }} | |
| 299 | + | restore-keys: npm-full-${{ runner.os }}- | |
| 259 | 300 | - name: Install | |
| 260 | 301 | run: node scripts/deploy.mjs install --only "${{ matrix.units }}" | |
| 261 | 302 | - name: Deploy ${{ matrix.units }} | |
| 97 | 97 | uses: actions/cache@v4 | |
| 98 | 98 | with: | |
| 99 | 99 | path: ~/.npm | |
| 100 | − | key: npm-${{ runner.os }}-${{ hashFiles('package-lock.json') }} | |
| 101 | − | restore-keys: npm-${{ runner.os }}- | |
| 100 | + | key: npm-full-${{ runner.os }}-${{ hashFiles('package-lock.json') }} | |
| 101 | + | restore-keys: npm-full-${{ runner.os }}- | |
| 102 | 102 | - name: Install | |
| 103 | 103 | run: npm ci --prefer-offline --no-audit --no-fund | |
| 104 | 104 | - name: Build and sign | |
| ⋯ | |||
| 212 | 212 | uses: actions/cache@v4 | |
| 213 | 213 | with: | |
| 214 | 214 | path: ~/.npm | |
| 215 | − | key: npm-${{ runner.os }}-${{ hashFiles('package-lock.json') }} | |
| 216 | − | restore-keys: npm-${{ runner.os }}- | |
| 215 | + | key: npm-full-${{ runner.os }}-${{ hashFiles('package-lock.json') }} | |
| 216 | + | restore-keys: npm-full-${{ runner.os }}- | |
| 217 | 217 | - name: Install | |
| 218 | 218 | run: npm ci --prefer-offline --no-audit --no-fund | |
| 219 | 219 | - name: Build and sign | |
| ⋯ | |||
| 288 | 288 | # and asks the registry only for what changed, so a dropped | |
| 289 | 289 | # connection to it rarely stops the job. | |
| 290 | 290 | - name: Cache npm's downloads | |
| 291 | − | uses: actions/cache@v4 | |
| 291 | + | uses: actions/cache/restore@v4 | |
| 292 | 292 | with: | |
| 293 | 293 | path: ~/.npm | |
| 294 | − | key: npm-${{ runner.os }}-${{ hashFiles('package-lock.json') }} | |
| 295 | − | restore-keys: npm-${{ runner.os }}- | |
| 294 | + | key: npm-full-${{ runner.os }}-${{ hashFiles('package-lock.json') }} | |
| 295 | + | restore-keys: npm-full-${{ runner.os }}- | |
| 296 | 296 | - name: Install Wrangler | |
| 297 | 297 | run: npm ci --prefer-offline --workspaces=false --no-audit --no-fund | |
| 298 | 298 | - name: The manifest | |
| 57 | 57 | steps: | |
| 58 | 58 | - uses: actions/checkout@v5 | |
| 59 | 59 | - name: Cache npm's downloads | |
| 60 | − | uses: actions/cache@v4 | |
| 60 | + | uses: actions/cache/restore@v4 | |
| 61 | 61 | with: | |
| 62 | 62 | path: ~/.npm | |
| 63 | − | key: npm-${{ runner.os }}-${{ hashFiles('package-lock.json') }} | |
| 64 | − | restore-keys: npm-${{ runner.os }}- | |
| 63 | + | key: npm-full-${{ runner.os }}-${{ hashFiles('package-lock.json') }} | |
| 64 | + | restore-keys: npm-full-${{ runner.os }}- | |
| 65 | 65 | # EAS reads app.config.ts through the app's own `expo`. | |
| 66 | 66 | - name: Install the app's dependencies | |
| 67 | 67 | run: npm ci --prefer-offline --no-audit --no-fund -w @g1t/mobile |
| 64 | 64 | # and asks the registry only for what changed, so a dropped | |
| 65 | 65 | # connection to it rarely stops the job. | |
| 66 | 66 | - name: Cache npm's downloads | |
| 67 | − | uses: actions/cache@v4 | |
| 67 | + | uses: actions/cache/restore@v4 | |
| 68 | 68 | with: | |
| 69 | 69 | path: ~/.npm | |
| 70 | − | key: npm-${{ runner.os }}-${{ hashFiles('package-lock.json') }} | |
| 71 | − | restore-keys: npm-${{ runner.os }}- | |
| 70 | + | key: npm-full-${{ runner.os }}-${{ hashFiles('package-lock.json') }} | |
| 71 | + | restore-keys: npm-full-${{ runner.os }}- | |
| 72 | 72 | # Wrangler, from the root, and the app's own (EAS reads app.config.ts | |
| 73 | 73 | # through its `expo`). | |
| 74 | 74 | - name: Install Wrangler and the app |
| 245 | 245 | # and asks the registry only for what changed, so a dropped | |
| 246 | 246 | # connection to it rarely stops the job. | |
| 247 | 247 | - name: Cache npm's downloads | |
| 248 | − | uses: actions/cache@v4 | |
| 248 | + | uses: actions/cache/restore@v4 | |
| 249 | 249 | with: | |
| 250 | 250 | path: ~/.npm | |
| 251 | − | key: npm-${{ runner.os }}-${{ hashFiles('package-lock.json') }} | |
| 252 | − | restore-keys: npm-${{ runner.os }}- | |
| 251 | + | key: npm-full-${{ runner.os }}-${{ hashFiles('package-lock.json') }} | |
| 252 | + | restore-keys: npm-full-${{ runner.os }}- | |
| 253 | 253 | - name: Install Wrangler | |
| 254 | 254 | run: npm ci --prefer-offline --workspaces=false --no-audit --no-fund | |
| 255 | 255 | - name: Publish to g1t.sh/downloads/runner |
| 588 | 588 | allowed domains under **Settings → Guardrails**; a project whose guardrails | |
| 589 | 589 | turn the network restriction off runs its jobs with an open network. | |
| 590 | 590 | ||
| 591 | + | A request to an allowed host that fails on the way is handled for the job. | |
| 592 | + | A `GET` or `HEAD` with no body is sent again, twice, after a short wait; one | |
| 593 | + | that still fails gets `502` with `x-g1t-egress: upstream-failed` and a body | |
| 594 | + | naming the host and the error, which npm, pip and most tools retry. A | |
| 595 | + | request with a body is never sent twice. An answer up to 8 MB that breaks | |
| 596 | + | off partway is fetched again whole, and a larger one, such as a 50 MB | |
| 597 | + | package tarball, is resumed from where it stopped with a `Range` request | |
| 598 | + | when its host takes ranges, so the job receives one unbroken answer. | |
| 599 | + | ||
| 591 | 600 | A host only workflows should reach, such as the API a deploy uploads to, | |
| 592 | 601 | goes in **Workflow-only domains** instead, limited to the workflows and | |
| 593 | 602 | environments that need it: `api.cloudflare.com | deploy.yml | production` |
| 125 | 125 | let concurrency = deploy.concurrency.as_ref().unwrap(); | |
| 126 | 126 | assert_eq!(concurrency.group, "deploy-production"); | |
| 127 | 127 | assert_eq!(concurrency.cancel_in_progress, json!(false)); | |
| 128 | − | assert_eq!(deploy.job_order(), ["check", "plan", "migrate", "core", "edge", "front", "smoke"]); | |
| 128 | + | assert_eq!(deploy.job_order(), ["check", "npm-cache", "plan", "migrate", "core", "edge", "front", "smoke"]); | |
| 129 | + | // Nothing waits for npm's downloads to be saved. | |
| 130 | + | assert!(deploy.jobs.iter().all(|job| !job.needs.iter().any(|n| n == "npm-cache"))); | |
| 129 | 131 | // The stages share their steps (a YAML alias), and read the token only | |
| 130 | 132 | // where they deploy. | |
| 131 | 133 | let steps = |id: &str| deploy.jobs.iter().find(|j| j.id == id).unwrap().steps.len(); | |
| ⋯ | |||
| 135 | 137 | assert!(!source.contains("cancel-in-progress: true")); | |
| 136 | 138 | } | |
| 137 | 139 | ||
| 140 | + | /// Whether a step's `run` installs the whole lockfile: `npm ci` with no | |
| 141 | + | /// workspace narrowing it, and not the deploy tool's install of some units. | |
| 142 | + | fn installs_everything(run: &str) -> bool { | |
| 143 | + | run.split('\n').any(|line| { | |
| 144 | + | let line = line.trim(); | |
| 145 | + | line.starts_with("npm ci") && !line.contains(" -w ") && !line.contains("--workspace") | |
| 146 | + | }) | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | #[test] | |
| 150 | + | fn npm_downloads_are_cached_whole_and_only_whole_installs_save_them() { | |
| 151 | + | // Every job's npm cache is the whole lockfile's, under one key. A job | |
| 152 | + | // that installs part of it only restores: had it saved, its part would | |
| 153 | + | // stand in for the whole under that key (the first job to finish used | |
| 154 | + | // to save Wrangler alone, and every later install downloaded the rest). | |
| 155 | + | let mut saving = Vec::new(); | |
| 156 | + | for entry in std::fs::read_dir(workflows_dir()).unwrap() { | |
| 157 | + | let path = entry.unwrap().path(); | |
| 158 | + | if path.extension().and_then(|e| e.to_str()) != Some("yml") { | |
| 159 | + | continue; | |
| 160 | + | } | |
| 161 | + | let name = path.file_name().unwrap().to_string_lossy().to_string(); | |
| 162 | + | for job in read(&name).jobs { | |
| 163 | + | let whole = job.steps.iter().any(|s| s.run.as_deref().is_some_and(installs_everything)); | |
| 164 | + | for step in &job.steps { | |
| 165 | + | let Some(uses) = step.uses.as_deref() else { continue }; | |
| 166 | + | if !uses.starts_with("actions/cache") || step.raw["with"]["path"] != json!("~/.npm") { | |
| 167 | + | continue; | |
| 168 | + | } | |
| 169 | + | let key = step.raw["with"]["key"].as_str().unwrap_or_default(); | |
| 170 | + | assert_eq!(key, "npm-full-${{ runner.os }}-${{ hashFiles('package-lock.json') }}", "{name} {}", job.id); | |
| 171 | + | if let Some(prefix) = step.raw["with"]["restore-keys"].as_str() { | |
| 172 | + | assert_eq!(prefix, "npm-full-${{ runner.os }}-", "{name} {}", job.id); | |
| 173 | + | } | |
| 174 | + | let saves = uses.starts_with("actions/cache@") || uses.starts_with("actions/cache/save@"); | |
| 175 | + | assert!(!saves || whole, "{name} {}: saves npm's cache but installs only part of the lockfile", job.id); | |
| 176 | + | if saves { | |
| 177 | + | saving.push(format!("{name} {}", job.id)); | |
| 178 | + | } | |
| 179 | + | } | |
| 180 | + | } | |
| 181 | + | } | |
| 182 | + | // main's whole cache is kept by the deploy workflow, beside its check. | |
| 183 | + | assert!(saving.contains(&"deploy.yml npm-cache".to_owned()), "{saving:?}"); | |
| 184 | + | } | |
| 185 | + | ||
| 138 | 186 | #[test] | |
| 139 | 187 | fn deploy_stages_follow_one_another() { | |
| 140 | 188 | let deploy = read("deploy.yml"); | |
| 1 | 1 | import assert from "node:assert/strict"; | |
| 2 | 2 | import { test } from "node:test"; | |
| 3 | 3 | ||
| 4 | − | import { BUFFERED_MAX_BYTES, RETRY_WAITS_MS, bufferable, forward } from "./forward.ts"; | |
| 4 | + | import { BUFFERED_MAX_BYTES, RETRY_WAITS_MS, bufferable, forward, resumeFrom } from "./forward.ts"; | |
| 5 | 5 | ||
| 6 | 6 | /** A fetch that throws `failures` times, then answers 200; it counts its calls. */ | |
| 7 | 7 | function flaky(failures: number) { | |
| ⋯ | |||
| 98 | 98 | assert.equal(bufferable(new Response(new ReadableStream())), false); | |
| 99 | 99 | assert.equal(bufferable(new Response(null, { status: 304 })), false); | |
| 100 | 100 | }); | |
| 101 | + | ||
| 102 | + | /** A file larger than is read whole, served by an upstream that takes byte ranges. */ | |
| 103 | + | const LARGE = BUFFERED_MAX_BYTES + 4096; | |
| 104 | + | const file = Uint8Array.from({ length: LARGE }, (_, i) => (i * 7) % 251); | |
| 105 | + | const ETAG = '"0136d6b610a8ef0251f65aecb4c9fc24"'; | |
| 106 | + | ||
| 107 | + | /** A body of `bytes` that breaks off after `cut` of them (null: sent whole), in chunks as a network body comes. */ | |
| 108 | + | function breaking(bytes: Uint8Array, cut: number | null): ReadableStream<Uint8Array> { | |
| 109 | + | const end = cut ?? bytes.length; | |
| 110 | + | let at = 0; | |
| 111 | + | // Pulled a chunk at a time: an error in `start` would drop what was queued before it. | |
| 112 | + | return new ReadableStream<Uint8Array>({ | |
| 113 | + | pull(controller) { | |
| 114 | + | if (at < end) { | |
| 115 | + | controller.enqueue(bytes.slice(at, Math.min(at + 65536, end))); | |
| 116 | + | at += 65536; | |
| 117 | + | } else if (cut === null) controller.close(); | |
| 118 | + | else controller.error(new Error("aborted")); | |
| 119 | + | }, | |
| 120 | + | }); | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | /** | |
| 124 | + | * An upstream for `file`: each answer in turn breaks off after the byte | |
| 125 | + | * of the file given in `cuts` (null: sent whole). A range request with the | |
| 126 | + | * file's ETag gets a `206` with the rest, as registry.npmjs.org answers | |
| 127 | + | * one, unless `ranges` is false. | |
| 128 | + | */ | |
| 129 | + | function rangedUpstream(cuts: (number | null)[], options: { ranges?: boolean } = {}) { | |
| 130 | + | const requests: Request[] = []; | |
| 131 | + | const send = async (request: Request) => { | |
| 132 | + | requests.push(request); | |
| 133 | + | const cut = cuts[requests.length - 1] ?? null; | |
| 134 | + | const range = /^bytes=(\d+)-$/.exec(request.headers.get("range") ?? ""); | |
| 135 | + | if (range && options.ranges !== false && request.headers.get("if-range") === ETAG) { | |
| 136 | + | const start = Number(range[1]); | |
| 137 | + | const rest = file.slice(start); | |
| 138 | + | return new Response(breaking(rest, cut === null ? null : cut - start), { | |
| 139 | + | status: 206, | |
| 140 | + | headers: { "content-length": String(rest.length), "content-range": `bytes ${start}-${LARGE - 1}/${LARGE}`, "accept-ranges": "bytes", etag: ETAG }, | |
| 141 | + | }); | |
| 142 | + | } | |
| 143 | + | return new Response(breaking(file, cut), { | |
| 144 | + | headers: { "content-length": String(LARGE), "content-type": "application/octet-stream", "accept-ranges": "bytes", etag: ETAG }, | |
| 145 | + | }); | |
| 146 | + | }; | |
| 147 | + | return { requests, send }; | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | const tarball = () => | |
| 151 | + | new Request("https://registry.npmjs.org/@pagefind/linux-x64/-/linux-x64-1.5.2.tgz", { headers: { "user-agent": "npm/11" } }); | |
| 152 | + | ||
| 153 | + | test("a large tarball that breaks off midway is resumed from where it stopped, and arrives whole", async () => { | |
| 154 | + | waits.length = 0; | |
| 155 | + | const { requests, send } = rangedUpstream([3_000_000, 6_000_000, null]); | |
| 156 | + | const response = await forward(tarball(), send, sleep); | |
| 157 | + | assert.equal(response.status, 200); | |
| 158 | + | assert.equal(response.headers.get("content-length"), String(LARGE)); | |
| 159 | + | const body = new Uint8Array(await response.arrayBuffer()); | |
| 160 | + | assert.equal(body.length, LARGE); | |
| 161 | + | assert.deepEqual(body, file); | |
| 162 | + | assert.equal(requests.length, 3); | |
| 163 | + | assert.equal(requests[0].headers.get("range"), null); | |
| 164 | + | assert.equal(requests[1].headers.get("range"), "bytes=3000000-"); | |
| 165 | + | assert.equal(requests[2].headers.get("range"), "bytes=6000000-"); | |
| 166 | + | assert.equal(requests[1].headers.get("if-range"), ETAG); | |
| 167 | + | assert.equal(requests[2].headers.get("user-agent"), "npm/11"); | |
| 168 | + | assert.deepEqual(waits, [...RETRY_WAITS_MS]); | |
| 169 | + | }); | |
| 170 | + | ||
| 171 | + | test("a large tarball sent whole streams as it is, asked for once", async () => { | |
| 172 | + | const { requests, send } = rangedUpstream([null]); | |
| 173 | + | const response = await forward(tarball(), send, sleep); | |
| 174 | + | assert.deepEqual(new Uint8Array(await response.arrayBuffer()), file); | |
| 175 | + | assert.equal(requests.length, 1); | |
| 176 | + | }); | |
| 177 | + | ||
| 178 | + | test("a body that ends short of its length is resumed too", async () => { | |
| 179 | + | const upstream = rangedUpstream([null]); | |
| 180 | + | let calls = 0; | |
| 181 | + | const send = async (request: Request) => { | |
| 182 | + | calls += 1; | |
| 183 | + | if (calls > 1) return upstream.send(request); | |
| 184 | + | return new Response(breaking(file.slice(0, 1_000_000), null), { | |
| 185 | + | headers: { "content-length": String(LARGE), "accept-ranges": "bytes", etag: ETAG }, | |
| 186 | + | }); | |
| 187 | + | }; | |
| 188 | + | const response = await forward(tarball(), send, sleep); | |
| 189 | + | assert.deepEqual(new Uint8Array(await response.arrayBuffer()), file); | |
| 190 | + | assert.equal(calls, 2); | |
| 191 | + | assert.equal(upstream.requests[0].headers.get("range"), "bytes=1000000-"); | |
| 192 | + | }); | |
| 193 | + | ||
| 194 | + | test("a body that keeps breaking off errors after its resumptions, never handing on a short answer as whole", async () => { | |
| 195 | + | const { requests, send } = rangedUpstream([1_000_000, 2_000_000, 3_000_000]); | |
| 196 | + | const response = await forward(tarball(), send, sleep); | |
| 197 | + | await assert.rejects(response.arrayBuffer()); | |
| 198 | + | assert.equal(requests.length, 1 + RETRY_WAITS_MS.length); | |
| 199 | + | }); | |
| 200 | + | ||
| 201 | + | test("a resumption that fails to connect is tried again", async () => { | |
| 202 | + | const upstream = rangedUpstream([2_000_000, null]); | |
| 203 | + | let calls = 0; | |
| 204 | + | const send = async (request: Request) => { | |
| 205 | + | calls += 1; | |
| 206 | + | if (calls === 2) throw new Error("Network connection lost."); | |
| 207 | + | return upstream.send(request); | |
| 208 | + | }; | |
| 209 | + | const response = await forward(tarball(), send, sleep); | |
| 210 | + | assert.deepEqual(new Uint8Array(await response.arrayBuffer()), file); | |
| 211 | + | assert.equal(calls, 3); | |
| 212 | + | }); | |
| 213 | + | ||
| 214 | + | test("no rest is joined on when the host answers a range with the whole file", async () => { | |
| 215 | + | const { requests, send } = rangedUpstream([1_000_000, null], { ranges: false }); | |
| 216 | + | const response = await forward(tarball(), send, sleep); | |
| 217 | + | await assert.rejects(response.arrayBuffer()); | |
| 218 | + | assert.equal(requests.length, 2); | |
| 219 | + | }); | |
| 220 | + | ||
| 221 | + | test("only a whole, plain answer to a GET, from a host that takes ranges, is resumable", () => { | |
| 222 | + | const get = new Request("https://registry.npmjs.org/x.tgz"); | |
| 223 | + | const answer = (headers: Record<string, string>, status = 200) => | |
| 224 | + | new Response("x", { status, headers: { "content-length": "1", "accept-ranges": "bytes", etag: ETAG, ...headers } }); | |
| 225 | + | assert.deepEqual(resumeFrom(get, answer({})), { validator: ETAG, length: 1 }); | |
| 226 | + | const modified = "Thu, 16 Apr 2026 23:38:31 GMT"; | |
| 227 | + | assert.deepEqual(resumeFrom(get, answer({ etag: 'W/"weak"', "last-modified": modified })), { validator: modified, length: 1 }); | |
| 228 | + | assert.equal(resumeFrom(get, answer({ etag: 'W/"weak"' })), null); | |
| 229 | + | assert.equal(resumeFrom(get, answer({ "accept-ranges": "none" })), null); | |
| 230 | + | assert.equal(resumeFrom(get, answer({ "content-encoding": "gzip" })), null); | |
| 231 | + | assert.equal(resumeFrom(get, answer({}, 206)), null); | |
| 232 | + | assert.equal(resumeFrom(new Request(get.url, { headers: { range: "bytes=0-" } }), answer({})), null); | |
| 233 | + | assert.equal(resumeFrom(new Request(get.url, { method: "HEAD" }), answer({})), null); | |
| 234 | + | }); | |
| 6 | 6 | * than something to retry. So a request that can be sent again (a GET or | |
| 7 | 7 | * HEAD with no body) is tried twice more after a short wait, and what | |
| 8 | 8 | * still fails comes back as a `502` saying which host and why, which | |
| 9 | − | * those tools do retry. It never throws. An answer of known, modest size | |
| 10 | − | * to such a request is read whole before it is handed on, so one whose | |
| 11 | − | * body breaks off midway is tried again too (`BUFFERED_MAX_BYTES`). | |
| 9 | + | * those tools do retry. It never throws. | |
| 10 | + | * | |
| 11 | + | * An upstream can also break off in the middle of a body, after its | |
| 12 | + | * headers have been handed on: npm then fails with `ECONNRESET` and | |
| 13 | + | * `network aborted`, and never retries it. So an answer of known, modest | |
| 14 | + | * size to such a request is read whole before it is handed on, and tried | |
| 15 | + | * again when it breaks off (`BUFFERED_MAX_BYTES`). A larger one, such as a | |
| 16 | + | * 50 MB package tarball, streams as it comes, and when it breaks off the | |
| 17 | + | * rest is asked for with a `Range` request from where it stopped and | |
| 18 | + | * joined on, so the sandbox sees one unbroken answer (`resuming`). | |
| 12 | 19 | */ | |
| 13 | 20 | ||
| 14 | 21 | import { normalizeHost } from "./egress.ts"; | |
| 15 | 22 | ||
| 16 | − | /** The waits before the second and third tries, in milliseconds. */ | |
| 23 | + | /** The waits before the second and third tries, in milliseconds; also before each resumption of a body. */ | |
| 17 | 24 | export const RETRY_WAITS_MS: readonly number[] = [250, 1000]; | |
| 18 | 25 | ||
| 19 | 26 | /** | |
| 20 | 27 | * The largest answer to a replayable request that is read whole before the | |
| 21 | − | * sandbox is answered. An upstream can also break off in the middle of a | |
| 22 | − | * body, after its headers: npm then reports `network aborted` with a | |
| 23 | − | * truncated tarball, and by then the answer was already the sandbox's, too | |
| 24 | − | * late to try again. An answer read whole first is tried again like any | |
| 28 | + | * sandbox is answered. An answer read whole first is tried again like any | |
| 25 | 29 | * other failure. Package tarballs and most API answers are well under this; | |
| 26 | − | * larger ones, and ones of unknown length, stream as they come. | |
| 30 | + | * larger ones stream, and resume when they break off (`resuming`). | |
| 27 | 31 | */ | |
| 28 | 32 | export const BUFFERED_MAX_BYTES = 8 * 1024 * 1024; | |
| 29 | 33 | ||
| 34 | + | /** The `content-length` of an answer sent as it is (no `content-encoding`), or null. */ | |
| 35 | + | function plainLength(response: Response): number | null { | |
| 36 | + | const encoding = (response.headers.get("content-encoding") ?? "identity").toLowerCase(); | |
| 37 | + | if (encoding !== "identity") return null; | |
| 38 | + | const header = response.headers.get("content-length"); | |
| 39 | + | const length = header === null ? NaN : Number(header); | |
| 40 | + | return Number.isSafeInteger(length) && length >= 0 ? length : null; | |
| 41 | + | } | |
| 42 | + | ||
| 30 | 43 | /** Whether an answer is to be read whole: a known length within the limit, sent as it is. */ | |
| 31 | 44 | export function bufferable(response: Response): boolean { | |
| 32 | 45 | if (response.body === null) return false; | |
| 33 | − | const length = Number(response.headers.get("content-length") ?? NaN); | |
| 34 | − | const encoding = (response.headers.get("content-encoding") ?? "identity").toLowerCase(); | |
| 35 | − | return Number.isFinite(length) && length <= BUFFERED_MAX_BYTES && encoding === "identity"; | |
| 46 | + | const length = plainLength(response); | |
| 47 | + | return length !== null && length <= BUFFERED_MAX_BYTES; | |
| 36 | 48 | } | |
| 37 | 49 | ||
| 38 | 50 | /** An answer whose body is read whole; throws when the body breaks off. */ | |
| ⋯ | |||
| 48 | 60 | return (request.method === "GET" || request.method === "HEAD") && request.body === null; | |
| 49 | 61 | } | |
| 50 | 62 | ||
| 63 | + | /** | |
| 64 | + | * What a broken-off body can be resumed against: the validator that makes | |
| 65 | + | * sure the rest comes from the same file (a strong `ETag`, else | |
| 66 | + | * `Last-Modified`), and its whole length. Null when it cannot be: only a | |
| 67 | + | * whole `200` to a GET that asked for no range, of known length, sent as it | |
| 68 | + | * is, from a host that takes byte ranges. | |
| 69 | + | */ | |
| 70 | + | export function resumeFrom(request: Request, response: Response): { validator: string; length: number } | null { | |
| 71 | + | if (request.method !== "GET" || request.headers.has("range") || response.status !== 200 || response.body === null) return null; | |
| 72 | + | if (!(response.headers.get("accept-ranges") ?? "").toLowerCase().split(/[,\s]+/).includes("bytes")) return null; | |
| 73 | + | const length = plainLength(response); | |
| 74 | + | if (length === null || length === 0) return null; | |
| 75 | + | const etag = response.headers.get("etag"); | |
| 76 | + | const validator = etag && !etag.startsWith("W/") ? etag : response.headers.get("last-modified"); | |
| 77 | + | return validator ? { validator, length } : null; | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | /** Whether a `206` carries exactly the rest of the file from `offset`. */ | |
| 81 | + | function restFrom(response: Response, offset: number, length: number): boolean { | |
| 82 | + | if (response.status !== 206 || response.body === null) return false; | |
| 83 | + | const range = /^bytes (\d+)-(\d+)\/(\d+)$/.exec((response.headers.get("content-range") ?? "").trim()); | |
| 84 | + | return range !== null && Number(range[1]) === offset && Number(range[2]) === length - 1 && Number(range[3]) === length; | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | /** | |
| 88 | + | * `response`'s body, joined across breaks: when it breaks off (an error, or | |
| 89 | + | * an end short of its length), the rest is asked for with | |
| 90 | + | * `Range: bytes=<received>-` and `If-Range`, after the waits of | |
| 91 | + | * `RETRY_WAITS_MS`, and read on from there. When the rest cannot be had | |
| 92 | + | * (no `206` for exactly that range, or every try failed), the body errors | |
| 93 | + | * as it would have. Each break is one line of the Worker's log. | |
| 94 | + | */ | |
| 95 | + | export function resuming( | |
| 96 | + | request: Request, | |
| 97 | + | response: Response, | |
| 98 | + | from: { validator: string; length: number }, | |
| 99 | + | send: (request: Request) => Promise<Response>, | |
| 100 | + | sleep: (ms: number) => Promise<void>, | |
| 101 | + | ): Response { | |
| 102 | + | const host = normalizeHost(new URL(request.url).host); | |
| 103 | + | const { validator, length } = from; | |
| 104 | + | let reader = (response.body as ReadableStream<Uint8Array>).getReader(); | |
| 105 | + | let received = 0; | |
| 106 | + | let resumes = 0; | |
| 107 | + | ||
| 108 | + | /** A reader for the rest from `received`, or the reason there is none. */ | |
| 109 | + | const reopen = async (broke: unknown): Promise<ReadableStreamDefaultReader<Uint8Array>> => { | |
| 110 | + | let failure = broke; | |
| 111 | + | while (resumes < RETRY_WAITS_MS.length) { | |
| 112 | + | const wait = RETRY_WAITS_MS[resumes++]; | |
| 113 | + | console.log("egress body broke off", host, `${received} of ${length} bytes`, `resuming, try ${resumes} of ${RETRY_WAITS_MS.length}`, String(failure)); | |
| 114 | + | await sleep(wait); | |
| 115 | + | const headers = new Headers(request.headers); | |
| 116 | + | headers.set("range", `bytes=${received}-`); | |
| 117 | + | headers.set("if-range", validator); | |
| 118 | + | let rest: Response; | |
| 119 | + | try { | |
| 120 | + | rest = await send(new Request(request.url, { method: "GET", headers, redirect: request.redirect })); | |
| 121 | + | } catch (error) { | |
| 122 | + | failure = error; | |
| 123 | + | continue; | |
| 124 | + | } | |
| 125 | + | if (restFrom(rest, received, length)) return (rest.body as ReadableStream<Uint8Array>).getReader(); | |
| 126 | + | await rest.body?.cancel().catch(() => {}); | |
| 127 | + | // The file changed, or the host would not send that range: there is no rest to join on. | |
| 128 | + | const why = `the rest could not be had (${rest.status} ${rest.headers.get("content-range") ?? "without a range"})`; | |
| 129 | + | console.log("egress body not resumed", host, `${received} of ${length} bytes`, why); | |
| 130 | + | throw new Error(`the answer broke off after ${received} of ${length} bytes, and ${why}`); | |
| 131 | + | } | |
| 132 | + | console.log("egress body not resumed", host, `${received} of ${length} bytes`, String(failure)); | |
| 133 | + | throw failure instanceof Error ? failure : new Error(String(failure)); | |
| 134 | + | }; | |
| 135 | + | ||
| 136 | + | const body = new ReadableStream<Uint8Array>({ | |
| 137 | + | async pull(controller) { | |
| 138 | + | for (;;) { | |
| 139 | + | let chunk: ReadableStreamReadResult<Uint8Array>; | |
| 140 | + | try { | |
| 141 | + | chunk = await reader.read(); | |
| 142 | + | if (chunk.done && received < length) throw new Error(`the answer ended after ${received} of ${length} bytes`); | |
| 143 | + | } catch (error) { | |
| 144 | + | try { | |
| 145 | + | reader = await reopen(error); | |
| 146 | + | } catch (final) { | |
| 147 | + | controller.error(final); | |
| 148 | + | return; | |
| 149 | + | } | |
| 150 | + | continue; | |
| 151 | + | } | |
| 152 | + | if (chunk.done) { | |
| 153 | + | controller.close(); | |
| 154 | + | return; | |
| 155 | + | } | |
| 156 | + | if (received + chunk.value.byteLength > length) { | |
| 157 | + | controller.error(new Error(`the answer ran past its ${length} bytes`)); | |
| 158 | + | return; | |
| 159 | + | } | |
| 160 | + | received += chunk.value.byteLength; | |
| 161 | + | controller.enqueue(chunk.value); | |
| 162 | + | return; | |
| 163 | + | } | |
| 164 | + | }, | |
| 165 | + | cancel(reason) { | |
| 166 | + | return reader.cancel(reason); | |
| 167 | + | }, | |
| 168 | + | }); | |
| 169 | + | // In the Worker, a stream of fixed length keeps the answer's | |
| 170 | + | // `content-length`, so the sandbox can tell a whole answer from a short one. | |
| 171 | + | const Fixed = (globalThis as { FixedLengthStream?: new (length: number) => TransformStream<Uint8Array, Uint8Array> }).FixedLengthStream; | |
| 172 | + | const sent = Fixed ? body.pipeThrough(new Fixed(length)) : body; | |
| 173 | + | return new Response(sent, { status: response.status, statusText: response.statusText, headers: response.headers }); | |
| 174 | + | } | |
| 175 | + | ||
| 51 | 176 | /** What a request that could not be sent gets back. */ | |
| 52 | 177 | export function upstreamFailed(host: string, error: unknown): Response { | |
| 53 | 178 | const why = error instanceof Error ? error.message : String(error); | |
| ⋯ | |||
| 76 | 201 | if (attempt > 1) await sleep(RETRY_WAITS_MS[attempt - 2]); | |
| 77 | 202 | try { | |
| 78 | 203 | const response = await send(request); | |
| 204 | + | if (tries === 1) return response; | |
| 79 | 205 | // Read whole first when it can be sent again, so a body that breaks off is retried too. | |
| 80 | − | return tries > 1 && bufferable(response) ? await whole(response) : response; | |
| 206 | + | if (bufferable(response)) return await whole(response); | |
| 207 | + | // Too large to hold: streamed, and resumed where it breaks off. | |
| 208 | + | const from = resumeFrom(request, response); | |
| 209 | + | return from ? resuming(request, response, from, send, sleep) : response; | |
| 81 | 210 | } catch (error) { | |
| 82 | 211 | failure = error; | |
| 83 | 212 | console.log("egress upstream failed", normalizeHost(host), request.method, `try ${attempt} of ${tries}`, String(error)); | |