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
+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" },
});
}