Skip to content
218 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 // The rest is joined on byte for byte, so it must come as it is, like the start did.
83 const encoding = (response.headers.get("content-encoding") ?? "identity").toLowerCase();
84 if (response.status !== 206 || response.body === null || encoding !== "identity") return false;
85 const range = /^bytes (\d+)-(\d+)\/(\d+)$/.exec((response.headers.get("content-range") ?? "").trim());
86 return range !== null && Number(range[1]) === offset && Number(range[2]) === length - 1 && Number(range[3]) === length;
87}
88
89/**
90 * `response`'s body, joined across breaks: when it breaks off (an error, or
91 * an end short of its length), the rest is asked for with
92 * `Range: bytes=<received>-` and `If-Range`, after the waits of
93 * `RETRY_WAITS_MS`, and read on from there. When the rest cannot be had
94 * (no `206` for exactly that range, or every try failed), the body errors
95 * as it would have. Each break is one line of the Worker's log.
96 */
97export function resuming(
98 request: Request,
99 response: Response,
100 from: { validator: string; length: number },
101 send: (request: Request) => Promise<Response>,
102 sleep: (ms: number) => Promise<void>,
103): Response {
104 const host = normalizeHost(new URL(request.url).host);
105 const { validator, length } = from;
106 let reader = (response.body as ReadableStream<Uint8Array>).getReader();
107 let received = 0;
108 let resumes = 0;
109
110 /** A reader for the rest from `received`, or the reason there is none. */
111 const reopen = async (broke: unknown): Promise<ReadableStreamDefaultReader<Uint8Array>> => {
112 let failure = broke;
113 while (resumes < RETRY_WAITS_MS.length) {
114 const wait = RETRY_WAITS_MS[resumes++];
115 console.log("egress body broke off", host, `${received} of ${length} bytes`, `resuming, try ${resumes} of ${RETRY_WAITS_MS.length}`, String(failure));
116 await sleep(wait);
117 const headers = new Headers(request.headers);
118 headers.set("range", `bytes=${received}-`);
119 headers.set("if-range", validator);
120 let rest: Response;
121 try {
122 rest = await send(new Request(request.url, { method: "GET", headers, redirect: request.redirect }));
123 } catch (error) {
124 failure = error;
125 continue;
126 }
127 if (restFrom(rest, received, length)) return (rest.body as ReadableStream<Uint8Array>).getReader();
128 await rest.body?.cancel().catch(() => {});
129 // The file changed, or the host would not send that range: there is no rest to join on.
130 const why = `the rest could not be had (${rest.status} ${rest.headers.get("content-range") ?? "without a range"})`;
131 console.log("egress body not resumed", host, `${received} of ${length} bytes`, why);
132 throw new Error(`the answer broke off after ${received} of ${length} bytes, and ${why}`);
133 }
134 console.log("egress body not resumed", host, `${received} of ${length} bytes`, String(failure));
135 throw failure instanceof Error ? failure : new Error(String(failure));
136 };
137
138 const body = new ReadableStream<Uint8Array>({
139 async pull(controller) {
140 for (;;) {
141 let chunk: ReadableStreamReadResult<Uint8Array>;
142 try {
143 chunk = await reader.read();
144 if (chunk.done && received < length) throw new Error(`the answer ended after ${received} of ${length} bytes`);
145 } catch (error) {
146 try {
147 reader = await reopen(error);
148 } catch (final) {
149 controller.error(final);
150 return;
151 }
152 continue;
153 }
154 if (chunk.done) {
155 controller.close();
156 return;
157 }
158 if (received + chunk.value.byteLength > length) {
159 controller.error(new Error(`the answer ran past its ${length} bytes`));
160 return;
161 }
162 received += chunk.value.byteLength;
163 controller.enqueue(chunk.value);
164 return;
165 }
166 },
167 cancel(reason) {
168 return reader.cancel(reason);
169 },
170 });
171 // In the Worker, a stream of fixed length keeps the answer's
172 // `content-length`, so the sandbox can tell a whole answer from a short one.
173 const Fixed = (globalThis as { FixedLengthStream?: new (length: number) => TransformStream<Uint8Array, Uint8Array> }).FixedLengthStream;
174 const sent = Fixed ? body.pipeThrough(new Fixed(length)) : body;
175 return new Response(sent, { status: response.status, statusText: response.statusText, headers: response.headers });
176}
177
178/** What a request that could not be sent gets back. */
179export function upstreamFailed(host: string, error: unknown): Response {
180 const why = error instanceof Error ? error.message : String(error);
181 return new Response(`g1t egress: ${normalizeHost(host)} could not be reached: ${why}\n`, {
182 status: 502,
183 headers: { "content-type": "text/plain; charset=utf-8", "x-g1t-egress": "upstream-failed" },
184 });
185}
186
187const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
188
189/**
190 * Sends `request` with `send` (`fetch`), trying a replayable one again on
191 * a thrown error, and answering `502` when it cannot. Each failure is one
192 * line of the Worker's log.
193 */
194export async function forward(
195 request: Request,
196 send: (request: Request) => Promise<Response> = fetch,
197 sleep: (ms: number) => Promise<void> = wait,
198): Promise<Response> {
199 const host = new URL(request.url).host;
200 const tries = replayable(request) ? RETRY_WAITS_MS.length + 1 : 1;
201 let failure: unknown = null;
202 for (let attempt = 1; attempt <= tries; attempt++) {
203 if (attempt > 1) await sleep(RETRY_WAITS_MS[attempt - 2]);
204 try {
205 const response = await send(request);
206 if (tries === 1) return response;
207 // Read whole first when it can be sent again, so a body that breaks off is retried too.
208 if (bufferable(response)) return await whole(response);
209 // Too large to hold: streamed, and resumed where it breaks off.
210 const from = resumeFrom(request, response);
211 return from ? resuming(request, response, from, send, sleep) : response;
212 } catch (error) {
213 failure = error;
214 console.log("egress upstream failed", normalizeHost(host), request.method, `try ${attempt} of ${tries}`, String(error));
215 }
216 }
217 return upstreamFailed(host, failure);
218}