feat(cfw): add Apps Script + Cloudflare Worker alternative backend

This commit is contained in:
dazzling-no-more
2026-04-30 16:41:19 +04:00
parent 777a28a16b
commit 9013eb9ef7
6 changed files with 888 additions and 8 deletions
+360
View File
@@ -0,0 +1,360 @@
/**
* DomainFront Relay — Apps Script with Cloudflare Worker exit.
*
* Variant of Code.gs that off-loads the actual outbound HTTP fetch to
* a Cloudflare Worker. Apps Script becomes a thin auth-and-forward
* relay; Cloudflare does the work and pays the latency.
*
* mhrv-rs ──► Apps Script (this file) ──► Cloudflare Worker ──► target
* ▲ inbound auth & batch ▲ outbound fetch + base64
*
* Wire protocol with mhrv-rs is identical to Code.gs:
* 1. Single: POST { k, m, u, h, b, ct, r } → { s, h, b }
* 2. Batch: POST { k, q: [{m,u,h,b,ct,r}, ...] } → { q: [{s,h,b}, ...] }
* Both shapes are forwarded to the Worker as one POST per call
* from Apps Script: single mode posts {k, u, m, ...} once, batch
* mode posts {k, q: [...]} once. The Worker fans out batches
* internally via Promise.all. This is the design choice that
* makes Code.cfw.gs actually save GAS UrlFetchApp quota — without
* it we'd have to fetchAll(N worker calls) and end up at parity
* with the standard Code.gs.
*
* Trade-off summary (read before deploying):
* + Per-call latency drops from ~250-500 ms (Apps Script internal
* hop) to ~10-50 ms (CF edge). Visibly snappier for chat-style
* workloads (Telegram, page navigation).
* + Apps Script *runtime* quota (90 min/day on consumer accounts)
* stretches significantly because each call now spends almost all
* its time in the network leg to the Worker, not in the body
* fetch + base64 + header processing.
* + Apps Script *UrlFetchApp count* quota stretches roughly Nx for
* an N-URL batch because the batch is sent as a small number of
* POSTs to the Worker (one per chunk of WORKER_BATCH_CHUNK URLs),
* not fanned out per-URL via fetchAll. For mhrv-rs's typical
* 5-30 URL batches that's 1 GAS call (vs N under standard
* Code.gs). Single non-batched requests still count 1:1.
* - YouTube long-form streaming gets WORSE. Apps Script allows
* ~6 min wall per execution; CF Workers cap at 30 s wall. The
* SABR cliff hits sooner. For YouTube-heavy use, keep the
* standard Code.gs (apps_script mode).
* - Batch mode now has a per-batch wall, not per-URL: Promise.all
* resolves only when every fetch finishes, so the slowest URL
* dominates. mhrv-rs already retries failed batch items
* individually, so failure modes are graceful, but it's a real
* behavioural change vs Code.gs's per-URL fetchAll wall.
* - Cloudflare anti-bot challenges on destination sites can be
* stricter — exit IP is now in CF's own range, which CF's
* anti-bot fingerprints as a worker-internal request. This is
* a different problem than DPI bypass; not solved by either
* variant.
*
* Deployment:
* 1. Deploy assets/cloudflare/worker.js to Cloudflare Workers first
* (set its AUTH_KEY to a strong secret).
* 2. Note the *.workers.dev URL of that Worker.
* 3. Open https://script.google.com → New project, delete default code.
* 4. Paste THIS entire file.
* 5. Set AUTH_KEY (must match the Worker's AUTH_KEY and your mhrv-rs
* config's auth_key — all three identical).
* 6. Set WORKER_URL to your *.workers.dev URL (must include https://).
* 7. Deploy → New deployment → Web app
* Execute as: Me | Who has access: Anyone
* 8. Copy the Deployment ID into mhrv-rs config.json as "script_id".
* mhrv-rs does not need to know about Cloudflare; it talks to
* Apps Script the same way it always has.
*
* CHANGE THESE TWO CONSTANTS BELOW.
*
* Upstream credit for the GAS-→-Worker pattern: github.com/denuitt1/mhr-cfw.
* This file inherits the hardening (decoy-on-bad-auth, hop-loop guard)
* from the standard Code.gs.
*/
const AUTH_KEY = "CHANGE_ME_TO_A_STRONG_SECRET";
// Full https://… URL of the Cloudflare Worker you deployed using
// assets/cloudflare/worker.js. Must include the scheme.
const WORKER_URL = "https://CHANGE_ME.workers.dev";
// ── Sentinels — DO NOT EDIT ─────────────────────────────────
// These two constants are NOT configuration. They are the literal
// template-default values used by the fail-closed check in doPost so
// that a forgotten edit (AUTH_KEY or WORKER_URL still set to the
// placeholder) returns a loud error instead of silently accepting the
// placeholder secret or POSTing to a bogus URL. Configure AUTH_KEY
// and WORKER_URL above; leave these alone.
const DEFAULT_AUTH_KEY = "CHANGE_ME_TO_A_STRONG_SECRET";
const DEFAULT_WORKER_URL = "https://CHANGE_ME.workers.dev";
// Must match the Worker's MAX_BATCH_SIZE. Batches larger than this
// are split into chunks of this size and dispatched via fetchAll —
// each chunk costs 1 GAS UrlFetchApp call, so an N-URL batch costs
// ceil(N/CHUNK) calls (still much cheaper than the per-URL cost
// under standard Code.gs's fetchAll).
const WORKER_BATCH_CHUNK = 40;
// Active-probing defense — same semantics as Code.gs. Bad-auth and
// malformed POST bodies receive a decoy HTML page that looks like a
// placeholder Apps Script web app instead of the JSON `{e}` error,
// so probes can't fingerprint the deployment as a relay endpoint.
// Flip to `true` only during initial setup if you need to debug an
// "unauthorized" loop, then flip back before sharing the deployment.
const DIAGNOSTIC_MODE = false;
const SKIP_HEADERS = {
host: 1, connection: 1, "content-length": 1,
"transfer-encoding": 1, "proxy-connection": 1, "proxy-authorization": 1,
"priority": 1, te: 1,
};
const DECOY_HTML =
'<!DOCTYPE html><html><head><title>Web App</title></head>' +
'<body><p>The script completed but did not return anything.</p>' +
'</body></html>';
// ── Request Handlers ────────────────────────────────────────
function _decoyOrError(jsonBody) {
if (DIAGNOSTIC_MODE) return _json(jsonBody);
return ContentService
.createTextOutput(DECOY_HTML)
.setMimeType(ContentService.MimeType.HTML);
}
function doPost(e) {
try {
// Fail-closed if either constant is still the template default.
// Without this, a forgotten edit would either accept the placeholder
// secret as valid auth or POST to a literal "CHANGE_ME" URL — both
// are silent failure modes a deploy might miss. Surface them loud.
if (AUTH_KEY === DEFAULT_AUTH_KEY) {
return _json({ e: "configure AUTH_KEY in Code.cfw.gs" });
}
if (WORKER_URL === DEFAULT_WORKER_URL) {
return _json({ e: "configure WORKER_URL in Code.cfw.gs" });
}
var req = JSON.parse(e.postData.contents);
if (req.k !== AUTH_KEY) return _decoyOrError({ e: "unauthorized" });
if (Array.isArray(req.q)) return _doBatch(req.q);
return _doSingle(req);
} catch (err) {
return _decoyOrError({ e: String(err) });
}
}
function doGet(e) {
return ContentService
.createTextOutput(DECOY_HTML)
.setMimeType(ContentService.MimeType.HTML);
}
// ── Worker Forwarding ──────────────────────────────────────
/**
* Strip headers that must not be forwarded (hop-by-hop / Apps-Script-
* managed). Returns a fresh header map; the input is never mutated.
*/
function _scrubHeaders(rawHeaders) {
var out = {};
if (rawHeaders && typeof rawHeaders === "object") {
for (var k in rawHeaders) {
if (rawHeaders.hasOwnProperty(k) && !SKIP_HEADERS[k.toLowerCase()]) {
out[k] = rawHeaders[k];
}
}
}
return out;
}
/**
* Normalize one request item into the shape the Worker expects.
* Used for both single and batch paths — single mode wraps this in
* `{k, ...item}`; batch mode wraps it in `{k, q: [item, ...]}`.
* Auth key is added at envelope level by callers, not per-item.
*/
function _normalizeItem(item) {
return {
u: item.u,
m: (item.m || "GET").toUpperCase(),
h: _scrubHeaders(item.h),
b: item.b || null,
ct: item.ct || null,
r: item.r !== false,
};
}
function _workerFetchOptions(payload) {
return {
url: WORKER_URL,
method: "post",
contentType: "application/json",
payload: JSON.stringify(payload),
muteHttpExceptions: true,
followRedirects: true,
validateHttpsCertificates: true,
};
}
// ── Single Request ─────────────────────────────────────────
function _doSingle(req) {
if (!req.u || typeof req.u !== "string" || !req.u.match(/^https?:\/\//i)) {
return _json({ e: "bad url" });
}
var item = _normalizeItem(req);
var envelope = {
k: AUTH_KEY,
u: item.u,
m: item.m,
h: item.h,
b: item.b,
ct: item.ct,
r: item.r,
};
var opts = _workerFetchOptions(envelope);
// muteHttpExceptions covers HTTP-level errors (4xx/5xx come back as
// a normal HTTPResponse). It does NOT cover network-level failures
// — DNS resolution failure, TLS handshake failure, connection
// timeout to *.workers.dev, etc. — those throw. Catch and surface
// them as `{e}` so the operator debugging "why isn't my deployment
// responding?" gets a useful signal instead of the doPost outer
// catch returning the decoy HTML page (which makes the deployment
// look like a bad-auth probe to the client). Auth has already
// passed at this point so the probe-defence argument doesn't apply.
var resp;
try {
resp = UrlFetchApp.fetch(opts.url, opts);
} catch (err) {
return _json({ e: "worker unreachable: " + String(err) });
}
return _json(_parseWorkerJson(resp));
}
// ── Batch Request ──────────────────────────────────────────
/**
* Forward a batch to the Worker, chunking when needed. Each chunk
* becomes ONE POST to the Worker; the Worker fans out across the URLs
* in the chunk via Promise.all and returns `{q: [...]}` in the same
* order. Multiple chunks fire in parallel via UrlFetchApp.fetchAll.
*
* Quota cost: ceil(N / WORKER_BATCH_CHUNK) GAS UrlFetchApp calls for
* an N-URL batch. For typical mhrv-rs batches of 5-30 URLs this is
* exactly 1 call (vs N under standard Code.gs's fetchAll). Larger
* batches gracefully degrade to a few calls instead of failing under
* the Worker's own MAX_BATCH_SIZE soft cap.
*
* Bad-URL items are filtered locally so the Worker only sees valid
* inputs, then re-interleaved into the result array in original order
* so mhrv-rs's batch-index assumptions hold.
*/
function _doBatch(items) {
var validItems = [];
var errorMap = {};
for (var i = 0; i < items.length; i++) {
var item = items[i];
if (!item.u || typeof item.u !== "string" || !item.u.match(/^https?:\/\//i)) {
errorMap[i] = "bad url";
continue;
}
validItems.push(_normalizeItem(item));
}
var workerResults = [];
if (validItems.length > 0) {
// Split into chunks ≤ WORKER_BATCH_CHUNK so each Worker call stays
// under the Worker's MAX_BATCH_SIZE cap. Single-chunk fast path
// avoids the fetchAll overhead for the common case.
var chunks = [];
for (var c = 0; c < validItems.length; c += WORKER_BATCH_CHUNK) {
chunks.push(validItems.slice(c, c + WORKER_BATCH_CHUNK));
}
var fetchOpts = chunks.map(function(chunk) {
return _workerFetchOptions({ k: AUTH_KEY, q: chunk });
});
// muteHttpExceptions covers HTTP-level errors. Network-level
// failures (DNS, TLS, connection timeout to *.workers.dev) still
// throw — catch and convert to per-chunk `{e}` errors that get
// spread across each chunk's slots. mhrv-rs's per-item retry
// then handles them individually instead of getting the decoy
// HTML page from the doPost outer catch. See _doSingle for why
// the probe-defence argument doesn't apply post-auth.
var responses;
try {
if (fetchOpts.length === 1) {
responses = [UrlFetchApp.fetch(fetchOpts[0].url, fetchOpts[0])];
} else {
responses = UrlFetchApp.fetchAll(fetchOpts);
}
} catch (err) {
var unreachable = { e: "worker unreachable: " + String(err) };
for (var u = 0; u < validItems.length; u++) workerResults.push(unreachable);
// Skip the per-response loop below by returning early through the
// reassembly code path.
responses = null;
}
for (var r = 0; responses && r < responses.length; r++) {
var parsed = _parseWorkerJson(responses[r]);
if (parsed && Array.isArray(parsed.q)) {
for (var k = 0; k < parsed.q.length; k++) {
workerResults.push(parsed.q[k]);
}
} else {
// Per-chunk failure (worker error, parse failure, auth, etc).
// Spread the same error to every slot in this chunk so mhrv-rs
// retries each item individually rather than masking the
// failure. Other chunks are unaffected.
var slotErr = (parsed && parsed.e)
? { e: parsed.e }
: { e: "worker batch failure" };
for (var s = 0; s < chunks[r].length; s++) workerResults.push(slotErr);
}
}
}
// Reassemble into the original order: validated slots get their
// worker result; invalid slots get their pre-flight error.
var results = [];
var wi = 0;
for (var j = 0; j < items.length; j++) {
if (errorMap.hasOwnProperty(j)) {
results.push({ e: errorMap[j] });
} else {
results.push(workerResults[wi++] || { e: "missing worker response" });
}
}
return _json({ q: results });
}
// ── Worker response handling ───────────────────────────────
/**
* Parse the Worker's JSON envelope. Worker errors come back as
* `{e: "..."}` — pass them through to the client unchanged so mhrv-rs
* sees the same error-shape it would for a direct-fetch failure in
* Code.gs. On HTTP errors from the Worker itself (auth failure, 5xx,
* etc.), wrap into `{e}` so the client gets a useful message instead
* of a parse-failure.
*/
function _parseWorkerJson(resp) {
var code = resp.getResponseCode();
var text = resp.getContentText();
try {
return JSON.parse(text);
} catch (err) {
return { e: "worker " + code + ": " + (text.length > 200 ? text.substring(0, 200) + "…" : text) };
}
}
function _json(obj) {
return ContentService.createTextOutput(JSON.stringify(obj)).setMimeType(
ContentService.MimeType.JSON
);
}
+13 -8
View File
@@ -1,13 +1,18 @@
# Apps Script source (mirrored)
# Apps Script source
The file `Code.gs` next to this README is a verbatim snapshot of the upstream script you deploy in your own Google Apps Script project:
Three deploy-ready Apps Script files live here. They all speak the same `{k, m, u, h, b, ct, r}` wire protocol with `mhrv-rs`, so the client just points its `script_id` at whichever deployment you want — no mode change required.
- Upstream: <https://github.com/masterking32/MasterHttpRelayVPN/blob/python_testing/apps_script/Code.gs>
- Raw link: <https://raw.githubusercontent.com/masterking32/MasterHttpRelayVPN/refs/heads/python_testing/apps_script/Code.gs>
## Variants and origins
This copy lives in our repo for two reasons:
- **`Code.gs`** — standard relay. **Verbatim mirror of upstream.** Apps Script does the outbound fetch itself. This is the default choice for most users.
- Upstream: <https://github.com/masterking32/MasterHttpRelayVPN/blob/python_testing/apps_script/Code.gs>
- Credit: [@masterking32](https://github.com/masterking32). We do not modify this file.
- The mirror lives here so that (a) users on networks where `raw.githubusercontent.com` is unreachable can still deploy from a `git clone` / ZIP, and (b) we have a snapshot to diff against if upstream changes silently break the informal relay protocol.
1. **Survives upstream outages**: if the user is on a network where raw.githubusercontent.com is temporarily unreachable but they can clone or ZIP this repo, they still have the deploy-ready file.
2. **Pins what we tested against**: the relay protocol between `mhrv-rs` and the script is informal; upstream changes can silently break us. Keeping a snapshot here lets us diff and see if a spec drift is responsible for any reported breakage.
- **`CodeFull.gs`** — superset of `Code.gs` that additionally proxies raw-TCP / UDP via `tunnel-node` (used by `mode: "full"`). **Maintained in this repo** — written for this Rust port and not present upstream. Deploy this if you want full-tunnel mode; details in the file's header comment.
All credit for `Code.gs` goes to [@masterking32](https://github.com/masterking32) — we do not modify it. If you're using mhrv-rs, follow the upstream deploy instructions in the script's header comment. The only edit **you** must make is the `AUTH_KEY` constant — set it to a strong secret and reuse that exact string in your `mhrv-rs` config.
- **`Code.cfw.gs`** — Apps Script becomes a thin auth+forward layer; the actual outbound fetch happens on a Cloudflare Worker you also deploy ([`assets/cloudflare/`](../cloudflare/)). **Derivative work — not unmodified upstream.** The pattern of forwarding through a Cloudflare Worker came from [denuitt1/mhr-cfw](https://github.com/denuitt1/mhr-cfw); this file inherits hardening from `Code.gs` (decoy-on-bad-auth, fail-closed sentinels) and adds chunked batch forwarding (`Promise.all` on the Worker side, `ceil(N/40)` GAS calls per batch) that the upstream `mhr-cfw` does not have. Faster per-call latency, worse YouTube long-form, no fix for Cloudflare anti-bot. Read [`assets/cloudflare/README.md`](../cloudflare/README.md) before choosing this one.
## What you must edit before deploying
For every variant: change `AUTH_KEY` from its placeholder to a strong secret, and use that same string in your `mhrv-rs` config's `auth_key`. `Code.cfw.gs` additionally requires setting `WORKER_URL` to your deployed Cloudflare Worker URL; `CodeFull.gs` additionally requires `TUNNEL_SERVER_URL` and `TUNNEL_AUTH_KEY` for the tunnel-node leg.
+110
View File
@@ -0,0 +1,110 @@
<div dir="rtl">
# خروجی Cloudflare Worker (پشتیبان جایگزین برای Apps Script)
> *English: [README.md](README.md)*
این پوشه یک **Cloudflare Worker** ارائه می‌کند که همراه با [`assets/apps_script/Code.cfw.gs`](../apps_script/Code.cfw.gs) شکل متفاوتی از حالت `apps_script` به شما می‌دهد:
```
mhrv-rs ──► Apps Script (Code.cfw.gs) ──► Cloudflare Worker ──► مقصد
▲ فقط احراز هویت و فوروارد ▲ گرفتن داده + base64
```
پشتیبان استاندارد ([`assets/apps_script/Code.gs`](../apps_script/Code.gs)) خودِ `Apps Script` کار `fetch` به مقصد را انجام می‌دهد. این نسخه‌ٔ جایگزین، `Apps Script` را به یک رلهٔ نازک تبدیل می‌کند و کارِ اصلی را به لبهٔ `Cloudflare` می‌سپارد. **خود `mhrv-rs` تغییر نمی‌کند** — همان پاکت `JSON` روی سیم، همان `mode: "apps_script"` در `config.json`، همان `script_id`. تنها تفاوت این است که `Apps Script` مستقر شدهٔ شما بعد از احراز هویت چه می‌کند.
ایدهٔ اصلی: <https://github.com/denuitt1/mhr-cfw>. این کپی یک بررسی `AUTH_KEY` روی خود `Worker` اضافه می‌کند، رفتار «صفحهٔ تقلبی برای کلید نامعتبر» را از `Code.gs` به ارث می‌برد، و یک محافظ در برابر حلقه‌شدن دارد.
## چه‌وقت ارزش راه‌اندازی دارد؟
✅ مرور وب، باز کردن صفحات جدید، ترافیک گفتگومحور — به‌طور محسوسی سریع‌تر می‌شود. تأخیر هر تماس از کف ۲۵۰ تا ۵۰۰ میلی‌ثانیه‌ٔ `Apps Script` به ۱۰ تا ۵۰ میلی‌ثانیه‌ٔ لبهٔ `Cloudflare` کاهش می‌یابد.
✅ تلگرام بلادرنگ — پیام‌های کوتاه و مکرر بیشترین سود را می‌برند.
✅ شبکه‌هایی که در آن‌ها ابتدا سهمیهٔ **زمان اجرای `Apps Script`** (۹۰ دقیقه در روز برای حساب‌های مصرفی گوگل) تمام می‌شود، نه شمارش `URL fetch`. در این حالت `GAS` تقریباً هیچ زمانی صرف هر تماس نمی‌کند.
**امروز هیچ کاهشی در شمارش روزانهٔ `UrlFetchApp` به دست نمی‌آورید.** مسیر رلهٔ `HTTP` در `mhrv-rs` همیشه فقط یک پاکت تک‌آدرسی می‌فرستد و هیچ‌گاه شکل دسته‌ای `q: [...]` را تولید نمی‌کند، پس هر درخواست کاربر همچنان یک `UrlFetchApp` در `GAS` مصرف می‌کند — مستقل از اینکه کدام نسخهٔ `Code.gs` را مستقر کرده باشید. مسیر `Code.cfw.gs` به سمت `Worker` *قابلیت* پشتیبانی از دسته را دارد (قطعه‌بندی ۴۰‌تایی، پخش‌سازی روی `Worker` با `Promise.all`، هزینهٔ `ceil(N / 40)` به جای `N`)، ولی این شاخه از هیچ کلاینت موجودی فراخوانی نمی‌شود. **تا زمانی که `mhrv-rs` خودش `HTTP relay` را دسته‌بندی نکند، سقف روزانهٔ ~۲۰٬۰۰۰ مصرف نسبت به `Code.gs` تغییر نمی‌کند.** این پشتیبانی برای سازگاری آینده در کد نگه داشته شده — هزینه‌ای ندارد و روزی که کلاینتِ دسته‌بندی‌کننده برسد، خود به خود فعال می‌شود.
❌ ویدیوهای طولانی یوتیوب — **بدتر** می‌شود، نه بهتر. `Apps Script` تا حدود ۶ دقیقه دیوار اجرا (`wall`) به ازای هر فراخوانی می‌دهد؛ `Cloudflare Workers` در ۳۰ ثانیه قطع می‌کنند. صخرهٔ `SABR` زودتر فرا می‌رسد. برای استفادهٔ یوتیوب‌محور، روی `Code.gs` بمانید.
❌ سایت‌هایی که پشت ضدبات `Cloudflare` هستند (توییتر/`X`، `OpenAI`، …) — `IP` خروجی حالا داخل خود `Cloudflare` است، که ضدبات `Cloudflare` آن را به‌عنوان «درخواست داخلی `Worker`» انگشت‌نگاری می‌کند. اغلب **سختگیرانه‌تر** از `IP` گوگل برخورد می‌شود. این مشکلی جدا از عبور از `DPI` است و هیچ‌کدام از این دو نسخه آن را حل نمی‌کنند.
❌ اگر/زمانی که `HTTP relay` دسته‌ای فعال شود، سقف ۳۰ ثانیه‌ٔ `Cloudflare` روی **کندترین آدرس در هر قطعه** اعمال خواهد شد، نه به‌ازای هر `URL` — یک مقصد قفل‌شده می‌تواند کل قطعهٔ ۴۰ آدرسی را به `timeout` بکشاند. تلاش مجدد تک‌به‌تک در `mhrv-rs` این را پوشش می‌دهد، اما تفاوت رفتاری نسبت به دیوار `per-URL` در `fetchAll` استانداردِ `Code.gs` است. (امروز بی‌اثر است چون کلاینت دسته نمی‌فرستد.)
## راه‌اندازی
سه رشتهٔ هم‌خوان نیاز دارید: یک `AUTH_KEY` که بین `worker.js`، `Code.cfw.gs` و `config.json` خود `mhrv-rs` مشترک است. یک رمز تصادفی قوی انتخاب کنید و در هر سه جا paste کنید.
### ۱. استقرار `Worker`
۱. وارد <https://dash.cloudflare.com/> شوید → **`Workers & Pages`** → **`Create`** → **`Hello World`** → **`Deploy`**.
۲. روی **`Edit code`** بزنید، کد پیش‌فرض را پاک کنید و محتوای [`worker.js`](worker.js) را paste کنید.
۳. ثابت `AUTH_KEY` در بالای فایل را به رمز قوی خودتان تغییر دهید.
۴. روی **`Deploy`** بزنید. آدرس `*.workers.dev` را کپی کنید — در مرحلهٔ بعد لازم است.
### ۲. استقرار `Apps Script`
۱. وارد <https://script.google.com> با حساب گوگلتان شوید → **`New project`** → کد پیش‌فرض را پاک کنید.
۲. محتوای [`../apps_script/Code.cfw.gs`](../apps_script/Code.cfw.gs) را paste کنید.
۳. هر دو ثابت بالای فایل را تنظیم کنید:
- مقدار `AUTH_KEY` را همان رمزی بگذارید که در `worker.js` گذاشتید.
- مقدار `WORKER_URL` را آدرس کامل `https://…workers.dev` همان `Worker` که الان مستقر کردید بگذارید (حتماً با پیشوند `https://`).
۴. از مسیر **`Deploy → New deployment → Web app`** استقرار را شروع کنید: مقدار `Execute as` را روی **`Me`** و `Who has access` را روی **`Anyone`** بگذارید.
۵. سپس **`Deployment ID`** را کپی کنید.
### ۳. اشاره دادن `mhrv-rs` به این `Apps Script`
در `config.json` (یا از طریق فرم `UI`):
```json
{
"mode": "apps_script",
"script_id": "PASTE_DEPLOYMENT_ID_HERE",
"auth_key": "SAME_SECRET_AS_BOTH_FILES_ABOVE"
}
```
تمام. `mhrv-rs` لازم نیست بداند `Cloudflare` در کار است؛ از نگاه او این `script_id` مثل هر `Deployment` دیگری رفتار می‌کند. اگر چند `Deployment` دارید (بعضی استاندارد، بعضی `CFW`)، می‌توانید همه را در `script_ids: [...]` بگذارید — `round-robin` و `parallel-relay` همچنان روی همه‌شان کار می‌کند.
## چرا هر سه `AUTH_KEY` باید یکی باشند؟
- **بین `mhrv-rs` و `Apps Script`**: جلوی این را می‌گیرد که هر `POST` تصادفی روی آدرس `*.googleusercontent.com` شما رله شود. درخواست‌هایی که این کلید را نداشته باشند، یک صفحهٔ `HTML` تقلبی می‌گیرند (به‌خاطر `DIAGNOSTIC_MODE = false` در `Code.cfw.gs`) و `Deployment` شما به‌جای یک تونل، شبیه یک پروژهٔ فراموش‌شده دیده می‌شود.
- **بین `Apps Script` و `Worker`**: اگر آدرس `Worker` لو برود، جلوی این را می‌گیرد که به یک رلهٔ `HTTP` باز برای مهاجم تبدیل شود. بدون این بررسی، `Worker` شما برای هر کسی که `URL` را پیدا کند، قابل سوءاستفاده است. نسخهٔ بالادست `mhr-cfw` این بررسی را ندارد؛ این کپی آن را اضافه می‌کند.
اگر می‌خواهید برای امنیت بیشتر روی هر بخش رمز جدا داشته باشید، `Code.cfw.gs` را ویرایش کنید تا یک `k` متفاوت از آن چیزی که از `mhrv-rs` می‌گیرد به `Worker` بفرستد. تنظیم تک‌رمز ساده‌ترین حالتِ درست است.
## بررسی اینکه کار می‌کند
همان روش پشتیبان استاندارد: <https://ipleak.net> را از طریق پروکسی باز کنید. باید یک `IP` متعلق به `Cloudflare` ببینید (چون `fetch` واقعی حالا از شبکهٔ `Cloudflare` خارج می‌شود)، نه یک `IP` متعلق به گوگل که با `Code.gs` می‌دیدید. اگر `IP` واقعی خودتان را ببینید، پروکسی استفاده نمی‌شود؛ اگر `IP` گوگل ببینید، اشتباهاً `Code.gs` را به‌جای `Code.cfw.gs` مستقر کرده‌اید.
دکمهٔ **`Test`** در `UI` دسکتاپ همچنان کار می‌کند — یک درخواست `HEAD` از طریق هر `Apps Script Deployment` که تنظیم کرده‌اید رله می‌کند.
## جدول مقایسه در یک نگاه
| محور | `Code.gs` (استاندارد) | `Code.cfw.gs` (این نسخه) |
|---|---|---|
| کف تأخیر هر تماس | ۲۵۰–۵۰۰ میلی‌ثانیه (هاپ داخلی `GAS`) | ۱۰–۵۰ میلی‌ثانیه (لبهٔ `CF`) |
| سهمیهٔ `UrlFetchApp` در روز، **آنچه `mhrv-rs` امروز می‌فرستد** | ۱ سهمیه به‌ازای هر درخواست | ۱ سهمیه به‌ازای هر درخواست — یکسان (`mhrv-rs` فقط پاکت تک‌آدرسی تولید می‌کند) |
| سهمیهٔ `UrlFetchApp` در روز، **اگر کلاینتی در آینده دسته بفرستد** | تعداد `N` سهمیه (یکی برای هر آدرس از طریق `fetchAll`) | تعداد `ceil(N / 40)` سهمیه (قطعه‌بندی ۴۰‌تایی؛ پخش‌سازی روی `Worker` با `Promise.all`) |
| سقف درخواست `Cloudflare Workers` در روز (پلن رایگان) | ندارد | ۱۰۰٬۰۰۰ — بسیار بالاتر از چیزی که `GAS` می‌تواند تغذیه‌اش کند؛ گلوگاه نیست |
| سهمیهٔ زمان اجرای `Apps Script` در روز | ۹۰ دقیقه، اغلب گلوگاه | ۹۰ دقیقه، به‌ندرت گلوگاه |
| دیوار اجرای هر فراخوانی | ~۶ دقیقه، به‌ازای هر آدرس | ۳۰ ثانیه، به‌ازای هر تماس (اگر دسته‌بندی فعال شود، به‌ازای هر قطعه) |
| سقف اندازهٔ پاسخ | ~۵۰ مگابایت (مستندات `Apps Script`) | محدود به حافظهٔ `Worker` (۱۲۸ مگابایت در پلن رایگان)؛ در عمل با تبدیل `base64` چند ده مگابایت |
| حروف بزرگ/کوچک هدرهای پاسخ | همان‌طور که مبدأ فرستاده | کاملاً کوچک می‌شود (`Headers.forEach` در `Workers` نرمال می‌کند). فقط برای ابزارهای پایین‌دستی که نام هدر را حساس به حروف مقایسه می‌کنند مهم است؛ `mhrv-rs` خود حساس به حروف نیست. |
| پخش ویدیوی طولانی یوتیوب | قابل قبول (صخرهٔ ۶ دقیقه) | بدتر (صخرهٔ ۳۰ ثانیه) |
| سرعت تلگرام / گفتگو | پایه | محسوساً بهتر |
| ضدبات `Cloudflare` روی مقصد | یک `IP` دیتاسنتر | یک `IP` داخلی `Worker` (اغلب سخت‌گیرانه‌تر) |
| کش پاسخ روی `Spreadsheet` | موجود (اختیاری) | در این نسخه نیست |
| پیچیدگی استقرار | ۱ چیز برای نگه‌داری | ۲ چیز که باید همگام بمانند |
اگر این مبادلات به نفع شماست، این نسخه را مستقر کنید. اگر نیست — یا حساب `Cloudflare` ندارید — روی `Code.gs` بمانید.
## محدودیت مهم: این نسخه با `mode: "full"` کار نمی‌کند
این فایل فقط مسیر **رلهٔ `HTTP`** (حالت‌های ۱ و ۲ در `CodeFull.gs`) را پورت می‌کند. عملیات تونل `TCP/UDP` خام (حالت‌های ۳ و ۴ در `CodeFull.gs` که برای `mode: "full"` و کاربری اپلیکیشن‌های موبایل مثل واتس‌اَپ روی اندروید لازم‌اند) در `Code.cfw.gs` پشتیبانی نمی‌شوند. اگر در حالت `full` هستید و `WhatsApp` کند است، این تغییر کمکی نمی‌کند — این مسئلهٔ متفاوتی است که نیاز به طراحی جداگانه دارد.
</div>
+97
View File
@@ -0,0 +1,97 @@
# Cloudflare Worker exit (alternative Apps Script backend)
> *فارسی: [README.fa.md](README.fa.md)*
This directory ships a **Cloudflare Worker** that pairs with [`assets/apps_script/Code.cfw.gs`](../apps_script/Code.cfw.gs) to give you a different shape of `apps_script` mode:
```
mhrv-rs ──► Apps Script (Code.cfw.gs) ──► Cloudflare Worker ──► target
▲ thin auth + forward ▲ outbound fetch + base64
```
The standard backend (`assets/apps_script/Code.gs`) does the outbound fetch from inside Apps Script directly. This variant makes Apps Script a thin relay and pushes the actual fetch to Cloudflare's edge. **mhrv-rs itself is unchanged** — same JSON envelope on the wire, same `mode: "apps_script"` in `config.json`, same `script_id`. The only thing that's different is what your deployed Apps Script does after it authenticates the request.
Original idea: <https://github.com/denuitt1/mhr-cfw>. This copy adds an `AUTH_KEY` check on the Worker, the decoy-on-bad-auth treatment from `Code.gs`, and a hop-loop guard.
## When this is worth it
✅ Browsing, page navigation, chat-style traffic — visibly snappier. Per-call latency drops from the ~250-500 ms Apps Script floor to ~10-50 ms at the CF edge.
✅ Telegram realtime — small frequent messages benefit most.
✅ Networks where the Apps Script *runtime* quota (90 min/day on consumer Google accounts) is what you hit before the URL-fetch count cap. GAS spends almost no time per call here.
**No `UrlFetchApp` daily-count relief today.** mhrv-rs's HTTP relay path emits a single-URL envelope per request, never the `q: [...]` batch shape, so each user request still consumes one GAS UrlFetchApp call regardless of which `Code.gs` variant is deployed. The `Code.cfw.gs` ↔ Worker path *is* batch-aware (chunks at 40, Worker fans out via `Promise.all`, costs `ceil(N / 40)` per batch instead of N), but that branch is unreachable from any shipping client. **Until/unless mhrv-rs grows HTTP-relay batching, the daily 20k-fetch ceiling is unchanged from `Code.gs`.** The ready batching support is left in place for forward compatibility — it costs nothing and goes live the day a batching client lands.
❌ YouTube long-form video — gets **worse**, not better. Apps Script allows ~6 min wall per execution; CF Workers cap at 30 s. The SABR cliff arrives sooner. Stay on `Code.gs` for YouTube-heavy use.
❌ Sites behind Cloudflare anti-bot (Twitter/X, OpenAI, etc.) — exit IP becomes a Workers IP, which CF's own anti-bot fingerprints as a worker-internal request. Often *stricter* than a Google IP. This is a separate problem from DPI bypass and neither variant fixes it.
❌ When/if HTTP-relay batching ships, the 30 s wall would apply to **the slowest URL in each chunk**, not per-URL — a single hung target could drag a 40-URL chunk to timeout. mhrv-rs's existing per-item retry would absorb this, but it's a behavioral change vs the per-URL `fetchAll` wall under `Code.gs`. (Inert today since no batching client exists.)
## Setup
You need three matching strings: an `AUTH_KEY` shared between `worker.js`, `Code.cfw.gs`, and your `mhrv-rs` `config.json`. Pick a strong random secret once and paste it into all three.
### 1. Deploy the Worker
1. Open <https://dash.cloudflare.com/> → **Workers & Pages****Create****Hello World****Deploy**.
2. Click **Edit code**, delete the template, and paste the contents of [`worker.js`](worker.js).
3. Change the `AUTH_KEY` constant near the top of the file to your strong secret.
4. **Deploy**. Copy the `*.workers.dev` URL — you'll need it next.
### 2. Deploy the Apps Script
1. Open <https://script.google.com> while signed into your Google account → **New project** → delete the default code.
2. Paste the contents of [`../apps_script/Code.cfw.gs`](../apps_script/Code.cfw.gs).
3. Set both constants at the top:
- `AUTH_KEY` — the same secret you set in `worker.js`.
- `WORKER_URL` — the full `https://…workers.dev` URL of the Worker you just deployed (must include the scheme).
4. **Deploy → New deployment → Web app**: *Execute as* = **Me**, *Who has access* = **Anyone**.
5. Copy the **Deployment ID**.
### 3. Point mhrv-rs at the Apps Script
In `config.json` (or via the UI's config form):
```json
{
"mode": "apps_script",
"script_id": "PASTE_DEPLOYMENT_ID_HERE",
"auth_key": "SAME_SECRET_AS_BOTH_FILES_ABOVE"
}
```
That's it. mhrv-rs doesn't need to know Cloudflare exists; from its perspective, the `script_id` deployment behaves like any other. If you have multiple deployments (some plain, some CFW), `script_ids: [...]` round-robins across all of them and the parallel-relay fan-out still works.
## Why three matching `AUTH_KEY`s
- **mhrv-rs ↔ Apps Script**: prevents random POSTs to your `*.googleusercontent.com` deployment from being relayed. Probes that don't carry the key get the decoy HTML page (`DIAGNOSTIC_MODE = false` in `Code.cfw.gs`), so the deployment looks like a forgotten placeholder rather than a tunnel.
- **Apps Script ↔ Worker**: prevents random POSTs to your `*.workers.dev` Worker from being relayed if the Worker URL ever leaks. Without this check the Worker becomes an open HTTP-relay for arbitrary attackers. The upstream `mhr-cfw` Worker omits it; this copy adds it back.
If you want compartmentalization (different secret on each leg), edit `Code.cfw.gs` to send a different `k` to the Worker than the one it accepts from mhrv-rs. The single-secret setup is the simplest correct configuration.
## Verifying it works
Same procedure as the standard backend: open <https://ipleak.net> through the proxy. You should see a Cloudflare-owned IP (since the actual fetch now exits Cloudflare's network), not a Google-owned one as you would with `Code.gs`. If you see your real IP, the proxy isn't being used; if you see a Google IP, you deployed `Code.gs` instead of `Code.cfw.gs`.
The `Test` button in the desktop UI still works — it does a HEAD relay through whichever Apps Script deployment you configured.
## Trade-off table at a glance
| Axis | `Code.gs` (standard) | `Code.cfw.gs` (this variant) |
|---|---|---|
| Per-call latency floor | ~250-500 ms (GAS internal hop) | ~10-50 ms (CF edge) |
| Apps Script `UrlFetchApp`/day, **what mhrv-rs sends today** | 1 quota / request | 1 quota / request — same (mhrv-rs only emits single-URL envelopes) |
| Apps Script `UrlFetchApp`/day, **if a future client batches** | N quota (one per URL via `fetchAll`) | `ceil(N / 40)` quota (chunks at 40, Worker fans out via `Promise.all`) |
| CF Workers requests/day (free tier) | n/a | 100 000 — far above what GAS can feed it; not the binding ceiling |
| Apps Script runtime/day | 90 min, often binding | 90 min, rarely binding |
| Per-execution wall budget | ~6 min, per-URL | 30 s, per-call (would become per-chunk if batching ships) |
| Per-response size cap | ~50 MB (Apps Script doc'd) | bounded by Worker memory (128 MB free tier); ~tens of MB in practice with the base64 conversion |
| Response header casing | preserved as origin sent it | lowercased (Workers' `Headers.forEach` normalises). Matters only for downstream tools that compare header names case-sensitively; mhrv-rs is case-insensitive. |
| YouTube long-form playback | OK (6-min cliff) | WORSE (30-s cliff) |
| Telegram / chat snappiness | baseline | noticeably better |
| Cloudflare anti-bot on target | datacenter IP | worker-internal IP (often stricter) |
| Spreadsheet response cache | available (opt-in) | not in this variant |
| Deployment complexity | 1 thing to maintain | 2 things to keep in sync |
If those trade-offs land on the right side for you, deploy this variant. If not — or if you don't have a Cloudflare account — stay on `Code.gs`.
## Important limitation: not compatible with `mode: "full"`
`Code.cfw.gs` only ports the HTTP-relay path (modes 1 + 2 in `CodeFull.gs`). The raw-TCP/UDP tunnel ops that `mode: "full"` depends on (modes 3 + 4 in `CodeFull.gs` — required for Android full-mode coverage of WhatsApp / Telegram / messengers / any non-HTTPS-MITM-able app) are **not** ported. If you're on full mode and looking for messenger speed-ups, this variant won't help — that's a different design that would need to ride on top of Cloudflare's TCP Sockets API + Durable Objects, with no equivalent for UDP. See the discussion in [issue #380](https://github.com/therealaleph/MasterHttpRelayVPN-RUST/issues/380) for context.
+302
View File
@@ -0,0 +1,302 @@
/**
* MHR-CFW Exit Worker — Cloudflare Workers companion to Code.cfw.gs.
*
* Architecture (alternative backend, opt-in):
* mhrv-rs → Apps Script (Code.cfw.gs) → THIS Worker → target site
*
* Apps Script in this configuration is a thin relay: it authenticates
* the inbound request from mhrv-rs, then forwards to this Worker. The
* Worker does the actual outbound fetch(es), base64-encodes the body,
* and returns the same JSON envelope shape the standard Code.gs would
* have returned. The mhrv-rs client is unaware that the work happened
* on Cloudflare — same `{u, m, h, b, ct, r}` request, same `{s, h, b}`
* response.
*
* Two request shapes are accepted:
* 1. Single: { k, u, m, h, b, ct, r } → { s, h, b }
* 2. Batch: { k, q: [{u,m,h,b,ct,r}, ...] } → { q: [{s,h,b} | {e}, ...] }
*
* The batch shape is what makes this design actually save Apps Script
* UrlFetchApp quota. Without it, Code.cfw.gs would have to do
* `UrlFetchApp.fetchAll(N worker calls)` to fan out an N-URL batch,
* which costs N quota — same as the standard Code.gs. With it,
* Code.cfw.gs does ONE fetch to this Worker (1 quota) and we fan out
* inside the Worker via Promise.all. For a typical mhrv-rs batch of
* 5-30 URLs that's a 5-30x reduction in GAS daily quota.
*
* Why bother:
* - Faster per-call latency (~10-50 ms at CF edge vs ~250-500 ms in
* Apps Script), which matters most for many small requests
* (Telegram realtime, page navigation chatter).
* - Apps Script *runtime* quota (90 min/day on consumer accounts)
* stretches further because GAS spends each call almost entirely
* on its single forward to the Worker rather than on body fetch
* + base64 + header munging.
* - With the batch shape (above), Apps Script *UrlFetchApp count*
* quota also stretches roughly Nx for an N-URL batch — typically
* 5-30x for mhrv-rs.
*
* What this does NOT change:
* - Cloudflare anti-bot challenges on the destination. The exit IP
* becomes a Workers IP (inside Cloudflare's network), which CF's
* own anti-bot can fingerprint as a worker-internal request —
* often *stricter* than a Google IP. This is a different problem
* than DPI bypass; see docs.
* - YouTube long-form streaming gets WORSE, not better. Apps Script
* allows ~6 min wall per execution; CF Workers cap at 30s wall.
* The SABR cliff arrives sooner. Keep the standard `apps_script`
* mode (Code.gs) for YouTube-heavy use.
* - The 30s wall now applies to the *slowest URL in the batch*
* because Promise.all only resolves once every fetch finishes.
* mhrv-rs already retries failed batch items individually, so a
* single slow target degrades to a per-item timeout rather than
* a hard failure — but it's a real behavioural difference vs the
* per-URL wall under the standard Code.gs path.
*
* Deployment:
* 1. Cloudflare dashboard → Workers & Pages → Create → Hello World
* 2. Edit code → delete the template, paste this entire file
* 3. Change AUTH_KEY below to the same value you set in Code.cfw.gs
* AND in your mhrv-rs config.json (auth_key). All three must match.
* 4. Deploy. Note the *.workers.dev URL; paste it into Code.cfw.gs as
* WORKER_URL.
*
* SECURITY NOTE: this Worker accepts unauthenticated POSTs from anyone
* who knows the URL unless AUTH_KEY is changed. The check below is
* cheap; do not skip it. The point of the AUTH_KEY is to keep the
* Worker from becoming an open HTTP-relay for arbitrary attackers if
* its URL leaks. Same secret as Code.cfw.gs by convention — if you
* want compartmentalisation, use a different one and have Code.cfw.gs
* forward both keys.
*
* Hardened over the upstream mhr-cfw worker.js by adding the AUTH_KEY
* check and batch handling. Upstream credit: github.com/denuitt1/mhr-cfw.
*/
const AUTH_KEY = "CHANGE_ME_TO_A_STRONG_SECRET";
const DEFAULT_AUTH_KEY = "CHANGE_ME_TO_A_STRONG_SECRET";
// Loop-prevention tag. The Worker tags its OUTBOUND request to the
// target with `x-relay-hop: 1` (see processOne). If a subsequent
// request comes back into the Worker with that header set, the Worker
// has been chained back to itself somehow — most likely the user's
// `item.u` resolved to this Worker's own URL. Bail out instead of
// fetching to avoid a stack-overflow loop.
//
// Note: Code.cfw.gs does NOT set this header on its GAS→Worker call
// (and could not check for it on inbound anyway — Apps Script's
// doPost event doesn't expose request headers). So this guard
// catches Worker-↔-Worker cycles, not GAS-↔-Worker cycles. The
// `targetUrl.hostname === selfHost` check in processOne is the
// primary defence for the common misconfiguration.
const RELAY_HOP_HEADER = "x-relay-hop";
// Soft cap on batch size. Cloudflare Workers allow up to 50
// subrequests per invocation on the free tier (1000 on paid). We
// keep a margin for retries and internal CF traffic. mhrv-rs's
// typical batches are 5-30 URLs so this is rarely the binding limit.
//
// **Must match `WORKER_BATCH_CHUNK` in Code.cfw.gs.** If the GAS side
// chunks at a different size, oversized chunks here return a top-level
// error and the entire chunk's slots fail. Tune both together.
const MAX_BATCH_SIZE = 40;
// Hop-by-hop headers and headers Cloudflare manages itself. Stripped
// before forwarding so the inbound request doesn't poison the outbound.
// Kept in sync with Code.cfw.gs / Code.gs SKIP_HEADERS so the Worker
// is correct as a defence-in-depth even when called directly (the
// AUTH_KEY check is the primary gate, but GAS scrubs first in the
// normal flow).
const SKIP_HEADERS = new Set([
"host",
"connection",
"content-length",
"transfer-encoding",
"proxy-connection",
"proxy-authorization",
"priority",
"te",
]);
export default {
async fetch(request) {
// Fail-closed if the deployer forgot to change AUTH_KEY from the
// template default. Without this guard a forgotten edit would
// accept any client that also happens to send the placeholder —
// effectively running as an open relay. Prefer a loud 500 over
// a silent open door.
if (AUTH_KEY === DEFAULT_AUTH_KEY) {
return json({ e: "configure AUTH_KEY in worker.js" }, 500);
}
if (request.method !== "POST") {
return json({ e: "method not allowed" }, 405);
}
if (request.headers.get(RELAY_HOP_HEADER) === "1") {
return json({ e: "loop detected" }, 508);
}
let req;
try {
req = await request.json();
} catch (_err) {
return json({ e: "bad json" }, 400);
}
if (!req || req.k !== AUTH_KEY) {
// Same shape as Code.cfw.gs unauthorized so downstream errors are
// uniform. The Worker URL is generally not user-discoverable; the
// GAS in front of it is the public surface, and probes hit GAS
// first. We don't bother with the decoy-HTML treatment here.
return json({ e: "unauthorized" }, 401);
}
const selfHost = new URL(request.url).hostname;
// Batch mode: { k, q: [{u,m,h,b,ct,r}, ...] }. Process all items in
// parallel via Promise.all. Per-item failures are per-item `{e}`s in
// the response array; the envelope itself stays 200 unless the batch
// is malformed at the top level.
if (Array.isArray(req.q)) {
if (req.q.length === 0) return json({ q: [] });
if (req.q.length > MAX_BATCH_SIZE) {
return json({
e: "batch too large (" + req.q.length + " > " + MAX_BATCH_SIZE + ")",
}, 400);
}
const results = await Promise.all(
req.q.map((item) => processOne(item, selfHost).catch((err) => ({
e: "fetch failed: " + String(err),
})))
);
return json({ q: results });
}
// Single mode: { k, u, m, h, b, ct, r }
let result;
try {
result = await processOne(req, selfHost);
} catch (err) {
return json({ e: "fetch failed: " + String(err) }, 502);
}
if (result.e) {
// Per-item validation errors get HTTP 400 in single mode so
// mhrv-rs sees the same shape as in standard Code.gs ("bad url"
// etc are already client-error-coded there).
return json(result, 400);
}
return json(result);
},
};
/**
* Process one item, whether it came in as the top-level single
* request or as one slot of a batch. Returns a plain object — never
* throws to the caller; Promise.all's .catch above only triggers on
* exceptions from this function's own internals (programmer error).
*
* Result shape mirrors what Code.gs would return for the same item:
* - Success: { s: status, h: {...}, b: base64Body }
* - Validation / fetch failure: { e: "..." }
*/
async function processOne(item, selfHost) {
if (!item || typeof item !== "object") {
return { e: "bad item" };
}
if (!item.u || typeof item.u !== "string" || !/^https?:\/\//i.test(item.u)) {
return { e: "bad url" };
}
let targetUrl;
try {
targetUrl = new URL(item.u);
} catch (_err) {
return { e: "bad url" };
}
if (targetUrl.hostname === selfHost) {
return { e: "self-fetch blocked" };
}
const headers = new Headers();
if (item.h && typeof item.h === "object") {
for (const [k, v] of Object.entries(item.h)) {
if (SKIP_HEADERS.has(k.toLowerCase())) continue;
try {
headers.set(k, v);
} catch (_err) {
// Worker rejects some headers (e.g. forbidden ones); skip
// rather than fail the whole item.
}
}
}
headers.set(RELAY_HOP_HEADER, "1");
const method = (item.m || "GET").toUpperCase();
const fetchOptions = {
method,
headers,
redirect: item.r === false ? "manual" : "follow",
};
// Code.gs/UrlFetchApp tolerates a body on GET/HEAD (browsers don't
// do this, but custom clients sometimes do); Workers' native fetch
// throws TypeError if you set a body on a body-prohibited method.
// To match Code.gs's permissiveness, silently drop the body for
// those methods rather than failing the whole item.
const bodyAllowed = method !== "GET" && method !== "HEAD";
if (item.b && bodyAllowed) {
try {
const binary = Uint8Array.from(atob(item.b), (c) => c.charCodeAt(0));
fetchOptions.body = binary;
if (item.ct && !headers.has("content-type")) {
headers.set("content-type", item.ct);
}
} catch (_err) {
return { e: "bad body base64" };
}
}
let resp;
try {
resp = await fetch(targetUrl.toString(), fetchOptions);
} catch (err) {
return { e: "fetch failed: " + String(err) };
}
const buffer = await resp.arrayBuffer();
const uint8 = new Uint8Array(buffer);
// Avoid call-stack overflow from String.fromCharCode.apply on big
// bodies — chunk the conversion.
let binary = "";
const chunkSize = 0x8000;
for (let i = 0; i < uint8.length; i += chunkSize) {
binary += String.fromCharCode.apply(null, uint8.subarray(i, i + chunkSize));
}
const base64 = btoa(binary);
// Note: Headers.forEach delivers keys lowercased per the Fetch
// spec, whereas Code.gs's getAllHeaders preserves the origin's
// casing. mhrv-rs treats headers case-insensitively, but anything
// downstream that does a case-sensitive string compare will see
// a backend-dependent difference. There is no Workers API to
// recover the origin casing, so we accept the divergence.
const responseHeaders = {};
resp.headers.forEach((v, k) => {
responseHeaders[k] = v;
});
return {
s: resp.status,
h: responseHeaders,
b: base64,
};
}
function json(obj, status = 200) {
return new Response(JSON.stringify(obj), {
status,
headers: { "content-type": "application/json" },
});
}