The worker (_worker.js)
How the single 28,000-line worker is organized - the flat ordered router, where each family of routes sits, the response helpers, CORS, and how errors surface.
Last updated 2026-08-06
Summary#
_worker.js is a single file, 28,268 lines and 1.71 MB, that contains the entire backend: the router, every /api handler, the inlined sign-in page, all the D1 schema creators, the background jobs and the static-asset fallback. There is no framework, no route table and no module system. Routing is a flat, ordered chain of if statements inside one function, and the first match wins.
That design has one property you must hold in your head at all times: order is behavior. Several routes only work because they are checked before a broader one, and at least two blanket checks sit between families of routes.
Purpose#
The file is large on purpose. There is no build step, so there is no way to split the worker across modules without either adding a bundler or paying an import cost at the edge. Keeping one file means one deploy artefact, one place to search when a route misbehaves, and no possibility of a stale bundle.
The flat if chain is a deliberate trade too. A route table would be tidier, but it would hide the ordering that several routes depend on, and it would add an abstraction that nothing else in the codebase uses. What you lose in elegance you gain in being able to read the dispatch order top to bottom.
Architecture#
Entry point#
export default {
async fetch(request, env, ctx) {
return mvTrackedFetch(request, env, ctx, mvRouteRequest);
},
async scheduled(event, env, ctx) { /* four jobs */ }
};_worker.js:1333-1365. Two things follow:
- Every request passes through
mvTrackedFetchfirst. The tracker wraps the router so it can observe the real status and duration of the response rather than guessing at the call site. That is what keeps the Library complete: a newly added route is logged without touching its handler. scheduled()exists but Cloudflare Pages never calls it. Cron is a Workers-only feature. The handler is kept so the same code works if it is ever deployed as a Worker, and the jobs self-throttle in the database so it is safe even if both paths fire. See Background jobs and scheduling.
mvRouteRequest dispatch order#
_worker.js:1372-2439. The chain in order:
| # | Step | Line |
|---|---|---|
| 1 | new URL(request.url), stash globalThis.__mvEnv = env | 1373-1376 |
| 2 | OPTIONS short-circuit for every path (CORS preflight) | 1379-1387 |
| 3 | Billing: /api/create-checkout, /api/stripe/webhook, /api/billing/sync | 1389-1400 |
| 4 | Blanket diagnostic gate: /api/dbgdfs or any /api/diag/* requires ?key=MV_INTERNAL_SECRET, else 403 | 1405-1410 |
| 5 | Account, billing, library, chat, translate, tier-2, premium-ai, article, cron | 1413-1498 |
| 6 | Blog, ordered deliberately: /api/blog/site, /api/blog/sites, /api/blog/role, Higgsfield sign-in and callback, /api/blog/media/file/*, /api/blog/public/*, then the authenticated /api/blog/* catch-all | 1504-1540 |
| 7 | /api/tools | 1541 |
| 8 | The inline diagnostic handlers | 1545-1803 |
| 9 | Analyst, brief, originality, factcheck, schedules, opportunities | 1805-1832 |
| 10 | Google OAuth, social OAuth and publishing, Search Console | 1834-1876 |
| 11 | Benchmarks, cited sources, extension quickview, usage | 1878-1891 |
| 12 | All /api/admin/* routes | 1892-1959 |
| 13 | API keys and the public API /api/v1/analyze | 1960-1971 |
| 14 | Team, notifications, prefs, branding, rank alerts, editorial, publish, share, workflow | 1973-2065 |
| 15 | /share/<slug> public HTML | 2067 |
| 16 | /blog, /blog/<slug> HTML | 2071-2110 |
| 17 | Responsive analyzer, 8 routes | 2112-2135 |
| 18 | Competitor monitor, 10 routes | 2137-2166 |
| 19 | /sitemap.xml, /robots.txt from inline strings | 2171-2194 |
| 20 | /legal, /compare (301 to /pricing), /login (inlined HTML), logo PNGs | 2196-2249 |
| 21 | FREE_REDIRECTS (9 permanent redirects), LEGAL_REDIRECTS (7), /ext-preview.html, FREE_PAGES (9 paths) | 2252-2328 |
| 22 | The env.ASSETS fallback chain | 2331-2438 |
Order-dependent pairs to be careful with:
- The Stripe webhook is routed at step 3, before anything can read the body, because HMAC verification needs the raw body.
/api/blog/siteand/api/blog/sitesmust precede the/api/blog/*catch-all at the end of step 6.- The diagnostic gate at step 4 sits above the individual diag handlers at step 8, so a handler's own comment claiming it is safe to call unauthenticated is wrong: every diagnostic route requires
?key=.
Handler conventions#
A handler is a plain async function taking some subset of (request, env, ctx, url) and returning a Response. There is no middleware stack; shared behavior is a function you call at the top of the handler. The canonical shape:
async function handleSomething(request, env) {
try {
const body = await request.json().catch(() => ({}));
const { user_email } = body;
const gate = await enforceAiQuota(env, user_email, { cost: 1 });
if (gate) return gate; // gates return a ready Response
const db = monRequireDB(env); // throws if MONITOR_DB is unbound
await ensureSomethingSchema(db); // lazy schema, see dev-migrations
// ... work ...
return new Response(JSON.stringify({ data }), { headers: corsHeaders() });
} catch (e) {
return new Response(JSON.stringify({ error: e.message }), { status: 500, headers: corsHeaders() });
}
}Three conventions matter:
- Gates return a
Responseornull.enforceAiQuota,requirePlan,enforceAiRecoQuota,mvVerifyInternalSecretandmvAssertPublicUrlall return a ready-made errorResponsewhen they refuse andnullwhen they allow. The caller pattern is alwaysif (x) return x;. - Every handler catches its own errors. There is no global error formatter.
mvTrackedFetchrethrows anything the router throws, so an uncaught exception produces Cloudflare's own error page rather than a JSON body. Inside atry,return awaita promise instead of returning it bare. A barereturn somethingAsync()hands back the promise before it settles, so a rejection skips the handler's owncatchand becomes that error page./api/toolsdispatched every technical tool that way until 2026-09-14, and a tool that threw showed visitors Cloudflare error 1101 instead of its own message;tests/tools-dispatch-await.mjsholds the fix. - Schema creation is the handler's job. Call the feature's
ensure*Schemabefore the first query. See Schema migrations.
Response helpers#
| Helper | Adds | Line |
|---|---|---|
corsHeaders() | Content-Type: application/json, Access-Control-Allow-Origin: * | 13789-13794 |
noCacheJsonHeaders() | the above plus Cache-Control: no-store, no-cache, must-revalidate, max-age=0, Pragma: no-cache, CDN-Cache-Control: no-store, Cloudflare-CDN-Cache-Control: no-store | 28182-28191 |
corsExtensionHeaders() | ACAO: *, Allow-Methods: GET, POST, OPTIONS, Allow-Headers: Content-Type, X-Extension-Version | 16165-16172 |
monJson(obj, status) | Competitor Monitor JSON with corsHeaders() | 22993-22998 |
raJson(obj, status) | Responsive Analyzer JSON | 22155-22157 |
mvbOk(data, status) | Blog: a {data} envelope with noCacheJsonHeaders() | 24188-24190 |
mvbFail(status, code, message) | Blog: {error:{code,message}}, and rewrites 502 to 503 because Cloudflare replaces a Worker 502 body with its own text/plain page | 24191-24205 |
CORS#
The only place CORS is negotiated is the OPTIONS short-circuit at the very top of the router:
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization'corsHeaders() on the actual response sets only the origin. Two gaps follow, and both are real:
- The blog API uses
PATCH,PUTandDELETE, and/api/blog/sitesusesPATCH, but the preflight never advertises them. Cross-origin blog writes fail preflight. Same-origin calls fromdashboard.htmlare unaffected because they are not preflighted. - The custom headers
x-mv-userandx-mv-siteare not inAllow-Headers, with the same consequence.
Where a response needs a custom header to be readable by the browser, the handler sets Access-Control-Expose-Headers explicitly. The shared-cache wrapper exposes X-MV-Cache, X-MV-Cache-Age-Days and X-MV-Saved-Id.
Components#
The tracking wrapper#
mvTrackedFetch (_worker.js:17789-17878) runs on every request:
- Parses the URL. If that fails it routes untouched.
- Looks up a route descriptor with
mvActRouteFor(pathname, method). If there is none, it routes untouched. Static assets, reads and pollers take this path. - Buffers the request body only when
content-typeisapplication/jsonand the length is at or belowMV_ACT_MAX_BODY(24,000 bytes), then rebuilds an identicalRequestfrom the same text. Streaming and multipart bodies, such as blog media uploads, are never buffered. - Calls the router, timing it.
- Clones the response synchronously if it is an error or a pending-job response, so the background write cannot race the client's own read.
- Writes an
activity_logrow insidectx.waitUntil. - Rethrows if the router threw, otherwise returns the response.
The whole recording block is wrapped in try {} catch {} because tracking must never break a response. Status mapping is 429 to warning, any other status at or above 400 to failed, a thrown error to failed, otherwise success.
Routes marked aiCovered (/api/premium-ai, /api/tools, /api/analyst, /api/brief, /api/v1/analyze) log only failures, because successes are already logged by incrementUsage. MV_ACT_QUIET lists pure read and poll endpoints that are never logged at all.
Detail capture is a whitelist of body and query keys, not a blocklist. That is what keeps logo data URLs, generated HTML, webhook URLs and API keys out of the Library by construction rather than by filtering.
The SSRF guard#
mvAssertPublicUrl(rawUrl) (_worker.js:13859-13865) returns null when a URL is safe to fetch, or a ready 400 Response:
| Condition | Body |
|---|---|
| Unparseable | {"error":"Invalid URL"} |
| Protocol not http or https | {"error":"Only http(s) URLs are allowed"} |
| Blocked host | {"error":"That host is not allowed"} |
mvHostIsBlocked blocks localhost, *.localhost, *.local, *.internal, metadata, metadata.google.internal, the IPv6 loopback and link-local ranges, IPv4-mapped IPv6, and after decoding dotted, decimal, octal, hex and 32-bit integer forms, anything in 0/8, 127/8, 10/8, 169.254/16, 172.16/12, 192.168/16, 100.64/10, 192.0.0.0/24 and everything at or above 224. Malformed input is blocked.
Warning: DNS rebinding is not caught. A public hostname that resolves to a private address passes this guard. That limitation is stated in the code.
Call sites: /api/tools, /api/monitor/add (the URL and both webhook URLs), blog media import, and the responsive proxy and asset routes.
The inlined login page#
GET /login and GET /login.html never touch env.ASSETS. The worker returns LOGIN_HTML, a template literal defined at _worker.js:75 and roughly 1,258 lines long, with Content-Type: text/html; charset=utf-8, Cache-Control: no-cache, no-store, must-revalidate, Pragma: no-cache and Expires: 0. It was added to dodge a Pages caching bug that served stale or zero-byte HTML.
Important: editing login.html alone changes nothing in production. Run node tests/login-inline-sync.mjs --write and commit both files. The no-argument form is a drift check that exits 1.
The static fallback#
Described in full in System architecture. In short: fetch through env.ASSETS, return non-empty 200s, remap clean URLs to .html for document routes, then serve the branded 404 for extension-less GET paths.
Data flow#
For a metered tool call the worker does the following, in this order:
mvTrackedFetchbuffers the body and starts the timer.mvRouteRequestmatches the path.- For
/api/premium-aithe match callsmvSaveAroundPremiumAI, the shared-cache wrapper, not the handler directly. - The wrapper reads
mv_seo_cache. A fresh hit still runs the quota gate and still charges the credit, then returns the stored body withX-MV-Cache: hitandX-MV-Cache-Age-Days. - On a miss,
handlePremiumAIruns the per-tool kill switch (platform_configkeytool_off:<type>), the recommendation gate forresult_guide,enforceAiQuota, thenincrementUsage. - Provider calls run: DataForSEO through
callDataForSEOCached, then the LLM through the configured base URL. - The response is written through to
mv_seo_cacheand saved totool_results. mvBumpMetricsrecords intometrics_hourly; a failure also callsmvRecordError.mvTrackedFetchwrites the activity row in the background and returns.
Every gate, with its exact status and body, is in Request lifecycle.
Failure modes#
| Failure | What the caller sees | Why |
|---|---|---|
| Uncaught exception in a handler | Cloudflare's own error page, no JSON | mvTrackedFetch rethrows and there is no global error formatter |
MONITOR_DB unbound | 500 with the literal string MONITOR_DB not bound. Wrangler may need a redeploy. | monRequireDB throws that message and some handlers surface it directly |
| A route added below a broader match | The broader handler answers instead | First match wins in a flat chain |
A path listed in _routes.json exclude | The worker never runs for it | /blog.html, /logo-dark.png and /logo-light.png are excluded yet still have handlers in the file. Those handlers are unreachable |
Cross-origin PATCH/PUT/DELETE | Preflight failure | The OPTIONS response advertises only GET, POST, OPTIONS |
A Worker 502 from the blog API | Cloudflare's text/plain page instead of the JSON error | Which is exactly why mvbFail rewrites 502 to 503 |
A literal </script> or </body> inside an inline script | The page's script parser terminates early and the whole page breaks | Caught by node tests/guard.mjs. This killed production on 2026-04-20 |
Two verification scripts exist specifically for this file:
node tests/guard.mjsbans a literal</body,</htmlor</scriptinside any inline<script>block indashboard.html,index.html,admin.htmlandlogin.html.node tests/login-inline-sync.mjsproves the inlinedLOGIN_HTMLstill evaluates and still matcheslogin.html.
Both are described in Verification scripts.
See also
Was this article helpful?
Thanks — feedback noted for the docs team.