Skip to content

Commit

Merge branch 'worktree-agent-af070177ded5baf00'

syntaqxcommitted Parentsf776fea427d96bBrowse files
11 files+413−520/11 viewed
+4−4
7979 uses: actions/cache@v4
8080 with:
8181 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 }}-
8484 - name: Install
8585 run: npm ci --prefer-offline --no-audit --no-fund
8686 - name: Type checks
112112 uses: actions/cache@v4
113113 with:
114114 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 }}-
117117 - name: Install
118118 run: npm ci --prefer-offline --no-audit --no-fund
119119 - name: The site, sudo and the docs
+3−3
7474 node scripts/cli-release.mjs sign
7575 node scripts/cli-release.mjs verify
7676 - name: Cache npm's downloads
77− uses: actions/cache@v4
77+ uses: actions/cache/restore@v4
7878 with:
7979 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 }}-
8282 - name: Install Wrangler
8383 run: npm ci --prefer-offline --workspaces=false --no-audit --no-fund
8484 - name: Publish to g1t.sh/downloads/cli
+55−14
7070 timeout-minutes: 20
7171 steps:
7272 - 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.
7678 - name: Cache npm's downloads
77− uses: actions/cache@v4
79+ uses: actions/cache/restore@v4
7880 with:
7981 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 }}-
8284 - name: Install Wrangler
8385 run: npm ci --prefer-offline --workspaces=false --no-audit --no-fund
8486 - name: The manifest matches every wrangler.jsonc
8688 - name: The deploy tool's tests
8789 run: npm run test:deploy
8890
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+
89130 # Runs beside check: nothing deploys until both have succeeded.
90131 plan:
91132 name: Plan
138179 # and asks the registry only for what changed, so a dropped
139180 # connection to it rarely stops the job.
140181 - name: Cache npm's downloads
141− uses: actions/cache@v4
182+ uses: actions/cache/restore@v4
142183 with:
143184 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 }}-
146187 - name: Install Wrangler
147188 run: npm ci --prefer-offline --workspaces=false --no-audit --no-fund
148189 - name: Apply pending migrations
246287 - name: sccache
247288 if: ${{ matrix.rust || matrix.image }}
248289 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
251292 # scripts/deploy/stack.mjs) and asks the registry only for what
252293 # changed, so a connection it drops mid-install rarely stops a stage.
253294 - name: Cache npm's downloads
254− uses: actions/cache@v4
295+ uses: actions/cache/restore@v4
255296 with:
256297 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 }}-
259300 - name: Install
260301 run: node scripts/deploy.mjs install --only "${{ matrix.units }}"
261302 - name: Deploy ${{ matrix.units }}
+7−7
9797 uses: actions/cache@v4
9898 with:
9999 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 }}-
102102 - name: Install
103103 run: npm ci --prefer-offline --no-audit --no-fund
104104 - name: Build and sign
212212 uses: actions/cache@v4
213213 with:
214214 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 }}-
217217 - name: Install
218218 run: npm ci --prefer-offline --no-audit --no-fund
219219 - name: Build and sign
288288 # and asks the registry only for what changed, so a dropped
289289 # connection to it rarely stops the job.
290290 - name: Cache npm's downloads
291− uses: actions/cache@v4
291+ uses: actions/cache/restore@v4
292292 with:
293293 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 }}-
296296 - name: Install Wrangler
297297 run: npm ci --prefer-offline --workspaces=false --no-audit --no-fund
298298 - name: The manifest
+3−3
5757 steps:
5858 - uses: actions/checkout@v5
5959 - name: Cache npm's downloads
60− uses: actions/cache@v4
60+ uses: actions/cache/restore@v4
6161 with:
6262 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 }}-
6565 # EAS reads app.config.ts through the app's own `expo`.
6666 - name: Install the app's dependencies
6767 run: npm ci --prefer-offline --no-audit --no-fund -w @g1t/mobile
+3−3
6464 # and asks the registry only for what changed, so a dropped
6565 # connection to it rarely stops the job.
6666 - name: Cache npm's downloads
67− uses: actions/cache@v4
67+ uses: actions/cache/restore@v4
6868 with:
6969 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 }}-
7272 # Wrangler, from the root, and the app's own (EAS reads app.config.ts
7373 # through its `expo`).
7474 - name: Install Wrangler and the app
+3−3
245245 # and asks the registry only for what changed, so a dropped
246246 # connection to it rarely stops the job.
247247 - name: Cache npm's downloads
248− uses: actions/cache@v4
248+ uses: actions/cache/restore@v4
249249 with:
250250 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 }}-
253253 - name: Install Wrangler
254254 run: npm ci --prefer-offline --workspaces=false --no-audit --no-fund
255255 - name: Publish to g1t.sh/downloads/runner
+9−0
588588 allowed domains under **Settings → Guardrails**; a project whose guardrails
589589 turn the network restriction off runs its jobs with an open network.
590590
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+
591600 A host only workflows should reach, such as the API a deploy uploads to,
592601 goes in **Workflow-only domains** instead, limited to the workflows and
593602 environments that need it: `api.cloudflare.com | deploy.yml | production`
+49−1
125125 let concurrency = deploy.concurrency.as_ref().unwrap();
126126 assert_eq!(concurrency.group, "deploy-production");
127127 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")));
129131 // The stages share their steps (a YAML alias), and read the token only
130132 // where they deploy.
131133 let steps = |id: &str| deploy.jobs.iter().find(|j| j.id == id).unwrap().steps.len();
135137 assert!(!source.contains("cancel-in-progress: true"));
136138 }
137139
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+
138186 #[test]
139187 fn deploy_stages_follow_one_another() {
140188 let deploy = read("deploy.yml");
+135−1
11 import assert from "node:assert/strict";
22 import { test } from "node:test";
33
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";
55
66 /** A fetch that throws `failures` times, then answers 200; it counts its calls. */
77 function flaky(failures: number) {
9898 assert.equal(bufferable(new Response(new ReadableStream())), false);
9999 assert.equal(bufferable(new Response(null, { status: 304 })), false);
100100 });
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+});
+142−13
66 * than something to retry. So a request that can be sent again (a GET or
77 * HEAD with no body) is tried twice more after a short wait, and what
88 * 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`).
1219 */
1320
1421 import { normalizeHost } from "./egress.ts";
1522
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. */
1724 export const RETRY_WAITS_MS: readonly number[] = [250, 1000];
1825
1926 /**
2027 * 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
2529 * 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`).
2731 */
2832 export const BUFFERED_MAX_BYTES = 8 * 1024 * 1024;
2933
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+
3043 /** Whether an answer is to be read whole: a known length within the limit, sent as it is. */
3144 export function bufferable(response: Response): boolean {
3245 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;
3648 }
3749
3850 /** An answer whose body is read whole; throws when the body breaks off. */
4860 return (request.method === "GET" || request.method === "HEAD") && request.body === null;
4961 }
5062
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+
51176 /** What a request that could not be sent gets back. */
52177 export function upstreamFailed(host: string, error: unknown): Response {
53178 const why = error instanceof Error ? error.message : String(error);
76201 if (attempt > 1) await sleep(RETRY_WAITS_MS[attempt - 2]);
77202 try {
78203 const response = await send(request);
204+ if (tries === 1) return response;
79205 // 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;
81210 } catch (error) {
82211 failure = error;
83212 console.log("egress upstream failed", normalizeHost(host), request.method, `try ${attempt} of ${tries}`, String(error));