Skip to content
216 linesCodeBlameRaw
1/**
2 * How the outbound handler sends a sandbox's request on to an allowed
3 * host. An upstream that resets the connection or fails outright would
4 * otherwise throw out of the handler, and the sandbox would see its own
5 * connection reset, which tools such as npm count as a failure rather
6 * than something to retry. So a request that can be sent again (a GET or
7 * HEAD with no body) is tried twice more after a short wait, and what
8 * still fails comes back as a `502` saying which host and why, which
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`).
19 */
20
21import { normalizeHost } from "./egress.ts";
22
23/** The waits before the second and third tries, in milliseconds; also before each resumption of a body. */
24export const RETRY_WAITS_MS: readonly number[] = [250, 1000];
25
26/**
27 * The largest answer to a replayable request that is read whole before the
28 * sandbox is answered. An answer read whole first is tried again like any
29 * other failure. Package tarballs and most API answers are well under this;
30 * larger ones stream, and resume when they break off (`resuming`).
31 */
32export const BUFFERED_MAX_BYTES = 8 * 1024 * 1024;
33
34/** The `content-length` of an answer sent as it is (no `content-encoding`), or null. */
35function 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
43/** Whether an answer is to be read whole: a known length within the limit, sent as it is. */
44export function bufferable(response: Response): boolean {
45 if (response.body === null) return false;
46 const length = plainLength(response);
47 return length !== null && length <= BUFFERED_MAX_BYTES;
48}
49
50/** An answer whose body is read whole; throws when the body breaks off. */
51async function whole(response: Response): Promise<Response> {
52 const body = await response.arrayBuffer();
53 const expected = Number(response.headers.get("content-length"));
54 if (body.byteLength !== expected) throw new Error(`the answer broke off after ${body.byteLength} of ${expected} bytes`);
55 return new Response(body, { status: response.status, statusText: response.statusText, headers: response.headers });
56}
57
58/** Whether a request can be sent again as it was: no body, nothing changed. */
59export function replayable(request: Request): boolean {
60 return (request.method === "GET" || request.method === "HEAD") && request.body === null;
61}
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 */
70export 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`. */
81function 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 */
95export 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
176/** What a request that could not be sent gets back. */
177export function upstreamFailed(host: string, error: unknown): Response {
178 const why = error instanceof Error ? error.message : String(error);
179 return new Response(`g1t egress: ${normalizeHost(host)} could not be reached: ${why}\n`, {
180 status: 502,
181 headers: { "content-type": "text/plain; charset=utf-8", "x-g1t-egress": "upstream-failed" },
182 });
183}
184
185const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
186
187/**
188 * Sends `request` with `send` (`fetch`), trying a replayable one again on
189 * a thrown error, and answering `502` when it cannot. Each failure is one
190 * line of the Worker's log.
191 */
192export async function forward(
193 request: Request,
194 send: (request: Request) => Promise<Response> = fetch,
195 sleep: (ms: number) => Promise<void> = wait,
196): Promise<Response> {
197 const host = new URL(request.url).host;
198 const tries = replayable(request) ? RETRY_WAITS_MS.length + 1 : 1;
199 let failure: unknown = null;
200 for (let attempt = 1; attempt <= tries; attempt++) {
201 if (attempt > 1) await sleep(RETRY_WAITS_MS[attempt - 2]);
202 try {
203 const response = await send(request);
204 if (tries === 1) return response;
205 // Read whole first when it can be sent again, so a body that breaks off is retried too.
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;
210 } catch (error) {
211 failure = error;
212 console.log("egress upstream failed", normalizeHost(host), request.method, `try ${attempt} of ${tries}`, String(error));
213 }
214 }
215 return upstreamFailed(host, failure);
216}