From 56887630e764163b3c73bccd757e5865563ca8e1 Mon Sep 17 00:00:00 2001 From: Joe Politz Date: Mon, 27 Apr 2026 21:45:51 -0700 Subject: [PATCH 1/4] Add /load-shareurl proxy for hosts blocked on some school networks Joe says: We really want to make sure that shareurl works from raw.githubusercontent.com. Notably this proxies *only* that domain for now, specifically to make sure we support this common use case. In general I expect that less constrained users (e.g. ugrad) just fetch code happily with CORS from the browser in the long run. This also comes with some improvements to proxying, extracted into a helper: - Real size limits - Streaming, not blocking waiting for the whole response - CSP headers so if someone tries to load something executable (e.g. in SVG) it really can't run - Some filtering on redirects to avoid weird cases of redirecting to 127.0.0.1 inside the server Since we have existing proxying for images it's nice to improve this and have a good helper. Claude says (lightly edited by Joe): Some classrooms will likely load curriculum via #shareurl from raw.githubusercontent.com on networks that block GitHub. The new /load-shareurl endpoint fetches an allowlisted URL on the browser's behalf and streams the response back, so those classrooms get a working path. A small window.fetch shim in beforePyret.js routes any fetch to an allowlisted host through the proxy automatically. That covers every browser fetch path in one place: drive.js's makeUrlFile, the url/url-file import prefetches in cpo-main.js, and the Pyret runtime's F.fetch via cross-fetch. /downloadImg moves to share the same streaming-fetch helper, picking up nosniff + sandbox response headers, an IP-filtering HTTP agent (request-filtering-agent), a response size cap, and a request timeout. Co-Authored-By: Claude Opus 4.7 --- package-lock.json | 22 +++++++++ package.json | 1 + src/server.js | 99 ++++++++++++++++++++++++++++++++------- src/web/js/beforePyret.js | 21 +++++++++ 4 files changed, 127 insertions(+), 16 deletions(-) diff --git a/package-lock.json b/package-lock.json index 5a98760d..c341d949 100644 --- a/package-lock.json +++ b/package-lock.json @@ -61,6 +61,7 @@ "react-dom": "^15.7.0", "redis": "^0.10.3", "request": "^2.88.2", + "request-filtering-agent": "^3.2.0", "requirejs": "2.1.14", "s-expression": "~2.2.0", "script-loader": "^0.7.2", @@ -20023,6 +20024,27 @@ "node": ">= 6" } }, + "node_modules/request-filtering-agent": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/request-filtering-agent/-/request-filtering-agent-3.2.0.tgz", + "integrity": "sha512-tKPrKdsmTFuGG1/pBEpzTB66mDZ2lZLW8kjW4N6jj4QjnxUTKrIfv5p2zuJRfztOos86jRPD41lRaGjh+1QqDw==", + "license": "MIT", + "dependencies": { + "ipaddr.js": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/request-filtering-agent/node_modules/ipaddr.js": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.3.0.tgz", + "integrity": "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, "node_modules/request/node_modules/form-data": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.3.tgz", diff --git a/package.json b/package.json index d975ca4b..55646236 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "react-dom": "^15.7.0", "redis": "^0.10.3", "request": "^2.88.2", + "request-filtering-agent": "^3.2.0", "requirejs": "2.1.14", "s-expression": "~2.2.0", "script-loader": "^0.7.2", diff --git a/src/server.js b/src/server.js index debbe7f2..80934423 100644 --- a/src/server.js +++ b/src/server.js @@ -27,6 +27,7 @@ function start(config, onServerReady) { var csrf = require('csurf'); var googleAuth = require('./google-auth.js'); var request = require('request'); + var requestFilteringAgent = require('request-filtering-agent'); var mustache = require('mustache-express'); var url = require('url'); var fs = require('fs'); @@ -186,24 +187,70 @@ function start(config, onServerReady) { }); } - app.get("/downloadImg", function(req, response) { - var parsed = url.parse(req.url); - var googleLink = decodeURIComponent(parsed.query.slice(0)); - var googleParsed = url.parse(googleLink); - var gReq = request({url: googleLink, encoding: 'binary'}, function(error, imgResponse, body) { - if(error) { - response.status(400).send({type: "image-load-failure", error: "Unable to load image " + String(error)}); + function proxyStreamFetch(opts) { + var res = opts.res; + res.set('X-Content-Type-Options', 'nosniff'); + res.set('Content-Security-Policy', 'sandbox'); + + var parsed; + try { parsed = new URL(opts.url); } + catch (e) { return res.status(400).send({ error: 'invalid-url' }); } + if (opts.allowedHosts && !opts.allowedHosts(parsed.hostname)) { + return res.status(400).send({ error: 'host-not-allowed' }); + } + + var bytes = 0; + var upstream = request({ + url: opts.url, + timeout: opts.timeoutMs, + agent: requestFilteringAgent.useAgent(opts.url), + followRedirect: function(resp) { + if (!opts.allowedHosts) return true; + try { + var next = new URL(resp.headers.location, opts.url); + return opts.allowedHosts(next.hostname); + } catch (_) { return false; } + }, + }); + upstream.on('error', function(err) { + if (!res.headersSent) opts.onError(res, err); + }); + upstream.on('response', function(upRes) { + if (opts.contentTypeOk && !opts.contentTypeOk(upRes.headers['content-type'])) { + upstream.destroy(); + return res.status(400).send({ error: 'content-type-not-allowed', detail: upRes.headers['content-type'] }); } - else { - var h = imgResponse.headers; - var ct = h['content-type']; - if((!ct) || (ct.indexOf('image/') !== 0)) { - response.status(400).send({type: "non-image", error: "Invalid image type " + ct}); - return; - } - response.set('content-type', ct); - response.end(body, 'binary'); + res.status(upRes.statusCode); + if (upRes.headers['content-type']) { + res.set('content-type', upRes.headers['content-type']); } + upRes.on('data', function(chunk) { + bytes += chunk.length; + if (bytes > opts.maxBytes) { + upstream.destroy(); + if (!res.headersSent) res.status(502).send({ error: 'too-large' }); + else res.destroy(); + } + }); + // Pipe upRes (IncomingMessage), not upstream (request object). The + // request library's .pipe copies upstream headers verbatim, which + // would overwrite the security headers set above. + upRes.pipe(res); + }); + } + + app.get("/downloadImg", function(req, response) { + var googleLink = decodeURIComponent(url.parse(req.url).query.slice(0)); + proxyStreamFetch({ + res: response, + url: googleLink, + allowedHosts: null, + maxBytes: 10000000, + timeoutMs: 15000, + contentTypeOk: function(ct) { return ct && ct.indexOf('image/') === 0; }, + onError: function(res, err) { + res.status(400).send({ type: 'image-load-failure', error: 'Unable to load image ' + String(err) }); + }, }); }); @@ -565,6 +612,26 @@ function start(config, onServerReady) { }); + // Server-side proxy for #shareurl loads from hosts that some school networks + // block or will likely block (notably raw.githubusercontent.com). + // Eager-proxied client-side for any URL whose host is in + // SHAREURL_ALLOWED_HOSTS. We can expand this list as needed. + var SHAREURL_ALLOWED_HOSTS = new Set(['raw.githubusercontent.com']); + + app.get("/load-shareurl", function(req, res) { + proxyStreamFetch({ + res: res, + url: req.query.url, + allowedHosts: function(h) { return SHAREURL_ALLOWED_HOSTS.has(h); }, + maxBytes: 1000000, + timeoutMs: 10000, + contentTypeOk: null, + onError: function(res, err) { + res.status(502).send({ error: 'upstream-error' }); + }, + }); + }); + app.post("/share-image", function(req, res) { var driveFileId = req.body.fileId; diff --git a/src/web/js/beforePyret.js b/src/web/js/beforePyret.js index 005c2c56..08bc957c 100644 --- a/src/web/js/beforePyret.js +++ b/src/web/js/beforePyret.js @@ -3,6 +3,27 @@ var originalPageLoad = Date.now(); console.log("originalPageLoad: ", originalPageLoad); +// Transparently route browser fetches to allowlisted hosts through the +// server-side proxy at /load-shareurl. Some school networks block +// raw.githubusercontent.com directly; the proxy gives those users a working +// path. Installed as early as possible so it catches every fetch caller: +// makeUrlFile in drive.js, the url/url-file import prefetches in cpo-main.js, +// and the Pyret runtime's F.fetch trove (via cross-fetch -> window.fetch). +const SHAREURL_PROXY_HOSTS = new Set(['raw.githubusercontent.com']); +const _origFetch = window.fetch.bind(window); +window.fetch = function(input, init) { + const urlStr = (typeof input === 'string') ? input + : (typeof Request !== 'undefined' && input instanceof Request) ? input.url + : String(input); + try { + const u = new URL(urlStr, window.location.href); + if (SHAREURL_PROXY_HOSTS.has(u.hostname)) { + return _origFetch('/load-shareurl?url=' + encodeURIComponent(urlStr), init); + } + } catch (_) { /* not a parseable URL; fall through */ } + return _origFetch(input, init); +}; + const isEmbedded = window.parent !== window; var shareAPI = makeShareAPI(process.env.CURRENT_PYRET_RELEASE); From 83bd40f61580d2e3cd869a16204007f73c8da42e Mon Sep 17 00:00:00 2001 From: Joe Politz Date: Tue, 28 Apr 2026 06:46:59 -0700 Subject: [PATCH 2/4] Name proxy size/timeout limits and bump image proxy caps Claude says (Joe felt little need to edit): Replaces the four magic numbers in the /downloadImg and /load-shareurl proxy call sites with named module-level constants. Bumps the image proxy to 20 MB / 30 s so that fetches of phone-sized images and slow Drive ?export= responses don't get cut off; /load-shareurl stays at 1 MB / 10 s since it's plaintext from a fast host. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/server.js | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/src/server.js b/src/server.js index 80934423..8a413182 100644 --- a/src/server.js +++ b/src/server.js @@ -7,6 +7,18 @@ const { drive } = require("googleapis/build/src/apis/drive/index.js"); var BACKREF_KEY = "originalProgram"; +// Limits for the streaming proxy. /downloadImg gets larger/looser caps because +// images can legitimately be tens of MB; also we've seen e.g. Drive ?export= +// take a while to get going. SHAREURL is intended to always be program +// plaintext. +// NOTE(joe + claude): really the timeout maybe should be on idleness at +// startup/between bytes, not overall per completed request, but that's work to +// plumb into `request` +var IMAGE_PROXY_MAX_BYTES = 20 * 1024 * 1024; // 20 MB +var IMAGE_PROXY_TIMEOUT_MS = 30 * 1000; // 30 s +var SHAREURL_PROXY_MAX_BYTES = 1 * 1024 * 1024; // 1 MB +var SHAREURL_PROXY_TIMEOUT_MS = 10 * 1000; // 10 s + function start(config, onServerReady) { var defaultOpts = { PYRET: process.env.PYRET, @@ -245,8 +257,8 @@ function start(config, onServerReady) { res: response, url: googleLink, allowedHosts: null, - maxBytes: 10000000, - timeoutMs: 15000, + maxBytes: IMAGE_PROXY_MAX_BYTES, + timeoutMs: IMAGE_PROXY_TIMEOUT_MS, contentTypeOk: function(ct) { return ct && ct.indexOf('image/') === 0; }, onError: function(res, err) { res.status(400).send({ type: 'image-load-failure', error: 'Unable to load image ' + String(err) }); @@ -623,8 +635,8 @@ function start(config, onServerReady) { res: res, url: req.query.url, allowedHosts: function(h) { return SHAREURL_ALLOWED_HOSTS.has(h); }, - maxBytes: 1000000, - timeoutMs: 10000, + maxBytes: SHAREURL_PROXY_MAX_BYTES, + timeoutMs: SHAREURL_PROXY_TIMEOUT_MS, contentTypeOk: null, onError: function(res, err) { res.status(502).send({ error: 'upstream-error' }); From 480e8a847a1e4939d3e3f1f0b2d663101d4fa91a Mon Sep 17 00:00:00 2001 From: Joe Politz Date: Tue, 28 Apr 2026 16:03:43 -0700 Subject: [PATCH 3/4] =?UTF-8?q?Race=20direct=20vs=20proxy=20for=20shareurl?= =?UTF-8?q?=20loads,=20go=20with=20=E2=80=9Cwhatever=20succeeds=E2=80=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Joe says: Constraints: - Don't want to proxy everything (be kind to our servers) - Don't want to wait a long time for the direct request to fail (flaky network, weird school blocker) So we kick off both the proxy and the direct request the first time we have a proxy-able candidate. - If the direct request succeeds, cancel the proxy request to CPO and do direct requests for that domain from this page - If the direct request fails, use only proxy requests for that domain for this page A direct request counts as success if it returns 200 OK and text/plain (making sure we don't weirdly accept 200 OK with like... a HTML login page because of a school SSO portal) I think this is a good balance of server load and letting working clients do their thing. Claude says: Responding to Ben's PR feedback to do better than always-proxy. The first fetch to an allowlisted host now runs direct + /load-shareurl in parallel; whichever returns first that verifies (direct = 2xx text/plain) is served to the caller, and the outcome of the direct request locks per-host shouldProxy state for the rest of the page-load. So unrestricted networks pay the proxy hop only once and rapidly switch to direct; blocked networks see direct fail and switch to proxy. Header-only verification (rather than body comparison) lets us abort the in-flight proxy fetch as soon as direct is trusted, keeping server load near zero on the healthy path. proxyStreamFetch now also tears down the upstream connection when the client disconnects, so the abort actually saves bandwidth on the server side too. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/server.js | 4 ++ src/web/js/beforePyret.js | 126 +++++++++++++++++++++++++++++++++----- 2 files changed, 114 insertions(+), 16 deletions(-) diff --git a/src/server.js b/src/server.js index 8a413182..9395dab6 100644 --- a/src/server.js +++ b/src/server.js @@ -224,6 +224,10 @@ function start(config, onServerReady) { } catch (_) { return false; } }, }); + // If the client disconnects (e.g. the browser aborts /load-shareurl after + // direct succeeded), tear down the upstream connection too — otherwise + // we'd keep streaming bytes from raw.githubusercontent.com to nowhere. + res.on('close', function() { upstream.destroy(); }); upstream.on('error', function(err) { if (!res.headersSent) opts.onError(res, err); }); diff --git a/src/web/js/beforePyret.js b/src/web/js/beforePyret.js index 08bc957c..359e5c2d 100644 --- a/src/web/js/beforePyret.js +++ b/src/web/js/beforePyret.js @@ -4,24 +4,118 @@ var originalPageLoad = Date.now(); console.log("originalPageLoad: ", originalPageLoad); // Transparently route browser fetches to allowlisted hosts through the -// server-side proxy at /load-shareurl. Some school networks block -// raw.githubusercontent.com directly; the proxy gives those users a working -// path. Installed as early as possible so it catches every fetch caller: -// makeUrlFile in drive.js, the url/url-file import prefetches in cpo-main.js, -// and the Pyret runtime's F.fetch trove (via cross-fetch -> window.fetch). +// server-side proxy at /load-shareurl, but only when the direct path doesn't +// work. +// +// Strategy: the FIRST fetch to an allowlisted host fires direct + proxied in +// parallel. We decide shouldProxy for the rest of the page-load from direct's +// response *headers*: +// - direct returned 2xx with content-type text/plain -> shouldProxy=false: +// serve direct's response, abort the in-flight proxy fetch. +// - direct failed, hung past timeout, or returned anything else +// -> shouldProxy=true: +// serve proxy's response. +// A key idea is that network-blocky things sometimes return 200 with a +// message page about blocking (or an error, but that counts as a fail). We +// don't want to accidentally think that's a success. +// shouldProxy state is in-memory and per-host — never persisted, since +// reachability changes between networks and a stale value would silently +// break loads. +// +// Installed on the global fetch as early as possible so it catches every fetch +// caller; some of them are in the pyret-lang runtime and would be otherwise +// difficult to configure. const SHAREURL_PROXY_HOSTS = new Set(['raw.githubusercontent.com']); +const SHAREURL_DIRECT_TIMEOUT_MS = 5000; const _origFetch = window.fetch.bind(window); -window.fetch = function(input, init) { - const urlStr = (typeof input === 'string') ? input - : (typeof Request !== 'undefined' && input instanceof Request) ? input.url - : String(input); - try { - const u = new URL(urlStr, window.location.href); - if (SHAREURL_PROXY_HOSTS.has(u.hostname)) { - return _origFetch('/load-shareurl?url=' + encodeURIComponent(urlStr), init); - } - } catch (_) { /* not a parseable URL; fall through */ } - return _origFetch(input, init); + +const _shareurlShouldProxy = new Map(); // host -> boolean +const _shareurlShouldProxyInflight = new Map(); // host -> Promise + +function _shareurlProxyUrl(fetchInput) { + return '/load-shareurl?url=' + encodeURIComponent(_shareurlInputToUrl(fetchInput)); +} + +function _shareurlInputToUrl(fetchInput) { + return (typeof fetchInput === 'string') ? fetchInput + : (typeof Request !== 'undefined' && fetchInput instanceof Request) ? fetchInput.url + : String(fetchInput); +} + +function _shareurlVerifyDirect(r) { + if (!r.ok) return false; + const ct = (r.headers.get('content-type') || '').toLowerCase(); + // Source files served from raw.githubusercontent.com come back as + // text/plain (.arr, .json, .csv, .md all do). Anything else — HTML block + // pages, captive portals, surprise content types — we don't trust as a + // real upstream response. + return ct.startsWith('text/plain'); +} + +function _shareurlFetch(shouldProxy, fetchInput, fetchInit) { + const maybeProxyInput = shouldProxy ? _shareurlProxyUrl(fetchInput) : fetchInput; + return _origFetch(maybeProxyInput, fetchInit); +} + +function _shareurlRace(fetchInput, fetchInit) { + const proxyCtrl = new AbortController(); + const proxyP = _origFetch(_shareurlProxyUrl(fetchInput), + Object.assign({}, fetchInit, { signal: proxyCtrl.signal })); + const directP = _origFetch(fetchInput, fetchInit); + + // shouldProxy is decided from direct's headers — a timeout flips us to + // true if direct hangs at the network level (no headers, no error). + const shouldProxyPromise = Promise.race([ + directP.then(r => !_shareurlVerifyDirect(r), () => true), + new Promise(resolve => setTimeout(() => resolve(true), SHAREURL_DIRECT_TIMEOUT_MS)), + ]); + + // Caller's response: direct if its headers verify (and abort the proxy + // fetch to stop wasting server bandwidth); otherwise proxy. If proxy also + // fails, surface its error. + const responsePromise = new Promise((resolve, reject) => { + let gotSomeResponse = false; + directP.then(r => { + if (!gotSomeResponse && _shareurlVerifyDirect(r)) { + gotSomeResponse = true; + proxyCtrl.abort(); + resolve(r); + } + }).catch(() => { /* wait for proxy to resolve or reject */ }); + proxyP.then(r => { + if (!gotSomeResponse) { gotSomeResponse = true; resolve(r); } + }).catch(e => { + if (!gotSomeResponse) reject(e); + }); + }); + + return { responsePromise, shouldProxyPromise }; +} + +window.fetch = function(fetchInput, fetchInit) { + let host; + try { host = new URL(_shareurlInputToUrl(fetchInput), window.location.href).hostname; } + catch (_) { return _origFetch(fetchInput, fetchInit); } + if (!SHAREURL_PROXY_HOSTS.has(host)) return _origFetch(fetchInput, fetchInit); + + const shouldProxy = _shareurlShouldProxy.get(host); + const inflight = _shareurlShouldProxyInflight.get(host); + if (shouldProxy !== undefined) { + return _shareurlFetch(shouldProxy, fetchInput, fetchInit); + } else if (inflight) { + // shouldProxy pending: queue this fetch on it and issue a single fresh + // request once shouldProxy is decided. + return inflight.then(sp => _shareurlFetch(sp, fetchInput, fetchInit)); + } else { + // First fetch to this host this page-load: run the race. + const { responsePromise, shouldProxyPromise } = _shareurlRace(fetchInput, fetchInit); + _shareurlShouldProxyInflight.set(host, shouldProxyPromise); + shouldProxyPromise.then(sp => { + _shareurlShouldProxy.set(host, sp); + _shareurlShouldProxyInflight.delete(host); + }); + return responsePromise; + } }; const isEmbedded = window.parent !== window; From a2ca9d84095f622cee9afe981e1d9ecd7d8ab0b0 Mon Sep 17 00:00:00 2001 From: Joe Politz Date: Tue, 28 Apr 2026 22:15:09 -0700 Subject: [PATCH 4/4] Rewrite shareurl race more declaratively (Promise.any + side-race) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Joe says: I think I constrained Claude so much in this session that it had to say things I like below :-) This is post-convo with Ben where we were still unhappy with (a) variable names and (b) closed over mutable variables instead of declarative promise APIs. Claude says: Following a second pass of code review: the previous version used a new Promise((resolve, reject) => { let gotSomeResponse = false; ... }) block with a flag tracking which side had won. That conflated two concerns — "first success wins the response" and "abort proxy iff direct beat it" — into one imperative state machine. This pass separates them. The race is expressed as three composed Promise primitives: - directP: direct fetch, gated on content-type verification (rejects if direct returned the wrong shape, so it drops out of the response race entirely). - shouldProxyPromise (Promise.race): false iff direct verified before the timeout; locks the per-host policy for the page-load. - directFinishedSuccessfullyAndFirstP (Promise.race): does direct settle before proxy, AND was that settlement a verified success? Only then do we proxyCtrl.abort(). This keeps the abort safe — we never abort once proxy has already returned headers, which would error the caller's body stream mid-read. - responsePromise (Promise.any): whichever of directP or proxyP fulfills first. Tested both healthy and DevTools-blocked flows. Observed the timing edge case where both proxy and direct return 200 within a few ms of each other: shouldProxy still locks 'direct' as expected, since the verdict is decoupled from whether the abort caught proxy in time. Also adds a comment explaining the deliberate non-handling of caller- provided init.signal: the signal overwrite means a caller aborting won't cancel the proxy fetch, but the alternative (event-listener forwarding or AbortSignal.any) is more complexity than the not-quite-fully-aborted case justifies right now. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/web/js/beforePyret.js | 51 +++++++++++++++++++++++---------------- 1 file changed, 30 insertions(+), 21 deletions(-) diff --git a/src/web/js/beforePyret.js b/src/web/js/beforePyret.js index 359e5c2d..b4941603 100644 --- a/src/web/js/beforePyret.js +++ b/src/web/js/beforePyret.js @@ -59,36 +59,45 @@ function _shareurlFetch(shouldProxy, fetchInput, fetchInit) { function _shareurlRace(fetchInput, fetchInit) { const proxyCtrl = new AbortController(); + // NOTE(joe): The signal overwrite is technically not the right fetch() + // polyfill. If the caller elsewhere in the codebase provided a different + // signal (which in the fetch API is only for aborting as of April '26), that + // caller aborting through that signal won't cancel the proxy fetch. + // I'm OK letting that case slip through here in exchange for not having a + // bunch of extra event handler forwarding const proxyP = _origFetch(_shareurlProxyUrl(fetchInput), Object.assign({}, fetchInit, { signal: proxyCtrl.signal })); - const directP = _origFetch(fetchInput, fetchInit); + const directP = _origFetch(fetchInput, fetchInit).then(r => { + if (!_shareurlVerifyDirect(r)) throw new Error('direct request failed'); + return r; + }); - // shouldProxy is decided from direct's headers — a timeout flips us to - // true if direct hangs at the network level (no headers, no error). + // shouldProxy: false iff direct verified before the timeout, else true. + // Whether to proxy is decided solely on whether direct succeeds or not const shouldProxyPromise = Promise.race([ - directP.then(r => !_shareurlVerifyDirect(r), () => true), + directP.then(() => false, () => true), new Promise(resolve => setTimeout(() => resolve(true), SHAREURL_DIRECT_TIMEOUT_MS)), ]); - // Caller's response: direct if its headers verify (and abort the proxy - // fetch to stop wasting server bandwidth); otherwise proxy. If proxy also - // fails, surface its error. - const responsePromise = new Promise((resolve, reject) => { - let gotSomeResponse = false; - directP.then(r => { - if (!gotSomeResponse && _shareurlVerifyDirect(r)) { - gotSomeResponse = true; - proxyCtrl.abort(); - resolve(r); - } - }).catch(() => { /* wait for proxy to resolve or reject */ }); - proxyP.then(r => { - if (!gotSomeResponse) { gotSomeResponse = true; resolve(r); } - }).catch(e => { - if (!gotSomeResponse) reject(e); - }); + // Settlement-order check: if direct verifies before proxy returns, abort + // the in-flight proxy to stop wasting server bandwidth. We must NOT + // abort once proxy has already returned, since by then the caller is + // reading proxy's body and aborting would error its stream mid-read. + const directFinishedSuccessfullyAndFirstP = Promise.race([ + directP.then(() => true, () => false), + proxyP.then(() => false, () => false), + ]); + directFinishedSuccessfullyAndFirstP.then(directFirst => { + if (directFirst) proxyCtrl.abort(); }); + // Caller's response: whichever of direct-verified or proxy fulfills + // first. If both fail, surface proxy's error (the more authoritative + // upstream — direct's may just be 'direct-not-verified'). + const responsePromise = Promise.any([directP, proxyP]).catch( + aggErr => Promise.reject(aggErr.errors[1] || aggErr.errors[0]) + ); + return { responsePromise, shouldProxyPromise }; }