diff --git a/README.md b/README.md
index 82fa5b88..b4a9980f 100644
--- a/README.md
+++ b/README.md
@@ -102,6 +102,8 @@ This part is unchanged from the original project. Follow @masterking32's guide o
- Who has access: **Anyone**
6. Copy the **Deployment ID** (the long random string in the URL).
+> **Alternative backend — Apps Script + Cloudflare Worker.** A variant in [`assets/apps_script/Code.cfw.gs`](assets/apps_script/Code.cfw.gs) + [`assets/cloudflare/worker.js`](assets/cloudflare/worker.js) turns Apps Script into a thin forwarder and offloads the actual fetch to a Cloudflare Worker you deploy. The win on day one is **latency** (~10-50 ms at the CF edge vs ~250-500 ms in Apps Script — visibly snappier for browsing and Telegram). It does **not** reduce your daily 20k Apps Script `UrlFetchApp` count, because today's mhrv-rs always sends single-URL relay requests; the batch path on the GAS+Worker side is wired and ready (`ceil(N/40)` quota per N-URL batch) but no shipping client emits it. Trade-offs: worse for YouTube long-form (30 s wall vs 6 min), no fix for Cloudflare anti-bot, **not compatible with `mode: "full"`** (no tunnel-ops support → won't help WhatsApp/messengers on Android full mode). Full setup and trade-off table in [`assets/cloudflare/README.md`](assets/cloudflare/README.md). mhrv-rs needs no config changes — same `mode: "apps_script"`, same `script_id`, same `auth_key`.
+
#### Can't reach `script.google.com` from your network?
If your ISP is already blocking Google Apps Script (or all of Google), you need Step 1's browser connection to succeed *before* you have a relay to use. `mhrv-rs` ships a `direct` mode for exactly this — SNI-rewrite tunnel only, no Apps Script relay required. (Was named `google_only` before v1.9 — the old name is still accepted in config files.)
@@ -499,6 +501,10 @@ Donations cover hosting, self-hosted CI runner costs, and continued maintenance.
> **نکته:** اگر نمیدانید رمز `AUTH_KEY` چه بگذارید، یک رشتهٔ تصادفی ۱۶ تا ۲۴ کاراکتری بسازید. مهم فقط این است که **دقیقاً همان رشته** را در برنامه هم وارد کنید.
+
+
+> **پشتیبان جایگزین — `Apps Script` + `Cloudflare Worker`.** نسخهای در [`assets/apps_script/Code.cfw.gs`](assets/apps_script/Code.cfw.gs) بههمراه [`assets/cloudflare/worker.js`](assets/cloudflare/worker.js) وجود دارد که `Apps Script` را به یک رلهٔ نازک تبدیل میکند و کار `fetch` واقعی را به یک `Cloudflare Worker` که خودتان مستقر میکنید میسپارد. سود روز اول این کار **کاهش تأخیر** است (~۱۰ تا ۵۰ میلیثانیه روی لبهٔ `CF` به جای ۲۵۰ تا ۵۰۰ میلیثانیه روی `Apps Script` — برای مرور وب و تلگرام محسوس). سهمیهٔ روزانهٔ `UrlFetchApp` (~۲۰٬۰۰۰) را کاهش **نمیدهد**، چون امروز `mhrv-rs` همیشه درخواست تکآدرسی میفرستد؛ مسیر دستهای روی `GAS+Worker` آماده و سیمکشی شده (`ceil(N/40)` سهمیه بهازای دستهٔ `N` آدرسی) ولی هیچ کلاینتی فعلاً آن را تولید نمیکند. مبادلات: ویدیوی طولانی یوتیوب بدتر میشود (دیوار ۳۰ ثانیه به جای ۶ دقیقه)، ضدبات `Cloudflare` را حل نمیکند، و **با `mode: "full"` سازگار نیست** (پشتیبانی از عملیات تونل ندارد → برای واتساَپ و سایر مسنجرها روی اندرویدِ تونل کامل کمکی نمیکند). راهنمای کامل استقرار و جدول مبادلات در [`assets/cloudflare/README.fa.md`](assets/cloudflare/README.fa.md). در `mhrv-rs` هیچ تنظیمی تغییر نمیکند — همان `mode: "apps_script"`، همان `script_id`، همان `auth_key`.
+
#### به `script.google.com` هم دسترسی ندارید؟
اگر `ISP` شما از قبل `Apps Script` (یا کل گوگل) را مسدود کرده، برای مرحلهٔ ۱ باید مرورگرتان **اول** به `script.google.com` برسد — قبل از اینکه رلهای داشته باشید. `mhrv-rs` یک حالت `direct` دقیقاً برای همین دارد — فقط تونل بازنویسی `SNI`، بدون نیاز به رلهٔ `Apps Script`. (قبل از v1.9 این حالت `google_only` نام داشت — نام قدیمی همچنان در فایل کانفیگ پذیرفته میشود.)
diff --git a/assets/apps_script/Code.cfw.gs b/assets/apps_script/Code.cfw.gs
new file mode 100644
index 00000000..f455fe20
--- /dev/null
+++ b/assets/apps_script/Code.cfw.gs
@@ -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 =
+ '
Web App' +
+ 'The script completed but did not return anything.
' +
+ '';
+
+// ── 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
+ );
+}
diff --git a/assets/apps_script/README.md b/assets/apps_script/README.md
index 1cf339a2..6af81d64 100644
--- a/assets/apps_script/README.md
+++ b/assets/apps_script/README.md
@@ -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:
-- Raw link:
+## 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:
+ - 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.
diff --git a/assets/cloudflare/README.fa.md b/assets/cloudflare/README.fa.md
new file mode 100644
index 00000000..4b183940
--- /dev/null
+++ b/assets/cloudflare/README.fa.md
@@ -0,0 +1,110 @@
+
+
+# خروجی 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` مستقر شدهٔ شما بعد از احراز هویت چه میکند.
+
+ایدهٔ اصلی: . این کپی یک بررسی `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`
+
+۱. وارد شوید → **`Workers & Pages`** → **`Create`** → **`Hello World`** → **`Deploy`**.
+۲. روی **`Edit code`** بزنید، کد پیشفرض را پاک کنید و محتوای [`worker.js`](worker.js) را paste کنید.
+۳. ثابت `AUTH_KEY` در بالای فایل را به رمز قوی خودتان تغییر دهید.
+۴. روی **`Deploy`** بزنید. آدرس `*.workers.dev` را کپی کنید — در مرحلهٔ بعد لازم است.
+
+### ۲. استقرار `Apps Script`
+
+۱. وارد با حساب گوگلتان شوید → **`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` بفرستد. تنظیم تکرمز سادهترین حالتِ درست است.
+
+## بررسی اینکه کار میکند
+
+همان روش پشتیبان استاندارد: را از طریق پروکسی باز کنید. باید یک `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` کند است، این تغییر کمکی نمیکند — این مسئلهٔ متفاوتی است که نیاز به طراحی جداگانه دارد.
+
+
diff --git a/assets/cloudflare/README.md b/assets/cloudflare/README.md
new file mode 100644
index 00000000..403fe81b
--- /dev/null
+++ b/assets/cloudflare/README.md
@@ -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: . 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 → **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 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 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.
diff --git a/assets/cloudflare/worker.js b/assets/cloudflare/worker.js
new file mode 100644
index 00000000..f672194b
--- /dev/null
+++ b/assets/cloudflare/worker.js
@@ -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" },
+ });
+}