Skip to content
Metric VaultHelp Center
Open app

System architecture

Metric Vault is a Cloudflare Pages project in advanced mode - one hand-written _worker.js answers /api/*, everything else falls through to env.ASSETS, and there is no build step.

Last updated 2026-09-14

Summary#

metricvaultai.com is a Cloudflare Pages project in advanced mode. A single hand-written _worker.js sits at the top of the published folder, so every request that is not excluded by _routes.json hits that worker first. The worker either answers the request itself (all of /api/*, plus a handful of HTML and redirect routes) or hands it back to the static files through the env.ASSETS binding.

There is no build step. No package.json, no npm install, no bundler, nothing compiled. The deploy workflow copies files into dist/ and calls wrangler pages deploy. The file you edit is the file that ships.

All persistent state lives in one D1 database bound as MONITOR_DB. Identity comes from Supabase Auth. Data comes from DataForSEO and a set of LLM providers. Background work is driven by a second, separate Cloudflare Worker because Pages cannot run cron.

Purpose#

The shape of this system is a response to three constraints.

Cost and latency. The product is a thin orchestration layer over expensive third-party APIs. Running it at the edge as a Worker means the orchestration itself is close to free and close to the user, and the money goes to the providers rather than to idle servers. The shared cache in front of those providers exists for the same reason.

One deployable unit. A single worker plus a static asset set means there is exactly one thing to deploy, one place a request can be handled, and one file to read when a route misbehaves. The cost is a 1.7 MB source file, which is a real cost, but it is a cost paid by developers rather than by users.

No toolchain to rot. Removing the build step removes an entire class of failure: dependency drift, lockfile conflicts, a bundler version that stops supporting a syntax, a CI cache that goes stale. It also means anyone can open a file, change one line, and know exactly what production will run.

Architecture#

text
                    ┌──────────────────────────────┐
   browser  ───────▶│  Cloudflare edge             │
                    │  _routes.json decides:       │
                    │   15 excluded paths → static │
                    │   everything else  → worker  │
                    └──────────────┬───────────────┘
                                   │
                    ┌──────────────▼───────────────┐
                    │  _worker.js                  │
                    │  export default { fetch }    │
                    │    → mvTrackedFetch          │
                    │       → mvRouteRequest       │
                    └──┬────────────┬──────────┬───┘
                       │            │          │
            /api/* ────┘            │          └──── everything else
                                    │                 env.ASSETS.fetch()
      ┌───────────┬──────────┬──────┴─────┬─────────────┐
      │           │          │            │             │
   MONITOR_DB  Supabase   DataForSEO   LLM providers  BLOG_MEDIA
     (D1)      (auth +     (SEO data)  (OpenAI,       (R2)
               2 tables)               Anthropic,
                                       Gemini,
                                       Perplexity,
                                       Workers AI)

   metricvault-cron (separate Worker, */15 * * * *)
        └── POST /api/cron/run  with x-mv-internal-secret
   .github/workflows/cron.yml (hourly backup)
        └── same endpoint

Cloudflare Pages advanced mode#

Pages has two modes. In the normal mode you write route files under functions/ and Cloudflare assembles a worker for you. In advanced mode you place a file literally named _worker.js at the root of the published output directory, and Cloudflare uses that file verbatim as the entire worker. Nothing is generated, nothing is merged, and the functions/ convention is ignored.

Three consequences follow, and each has caused a production incident at least once:

  1. env.ASSETS is provided automatically. Advanced mode binds the static asset set to env.ASSETS without any [assets] block in wrangler.toml. That is the only way to serve a static file once the worker is in front of everything.
  2. wrangler.toml must stay in Pages format. It carries pages_build_output_dir = "dist". The file previously used the Workers format while the workflow deployed with wrangler pages deploy, so the D1 and KV bindings were never applied and the live site failed with MONITOR_DB not bound. Wrangler may need a redeploy. The header comment in wrangler.toml records that outage.
  3. The deploy must not pass a positional directory. The workflow runs wrangler pages deploy --project-name=metricvaultai --branch=... with no directory argument, so wrangler reads pages_build_output_dir from wrangler.toml and applies the bindings declared there. Passing dist/ positionally skips the bindings entirely.

The env.ASSETS fallback#

Every request that reaches the worker and matches none of the /api or special routes falls through to a chain at the end of mvRouteRequest (_worker.js:2331-2438):

  1. const assetResponse = await env.ASSETS.fetch(request).
  2. If the status is 200 and the body is non-empty, HTML responses are re-emitted with an explicit charset=utf-8 and everything else is returned as-is.
  3. Otherwise the worker classifies the path. isAssetPath is /\.[a-z0-9]{2,5}$/i (anything with a file extension). isDocumentRoute is everything else plus the explicit set /, /dashboard, /login, /admin, /privacy, /terms.
  4. For a document route with no extension, the clean URL is remapped to its .html file (/dashboard to /dashboard.html, and so on) and re-fetched through env.ASSETS.
  5. If ASSETS still returns 404, the path has no extension and the method is GET, the worker fetches /404 then /404.html and returns that body with status 404, Content-Type: text/html; charset=utf-8 and Cache-Control: no-store.

Assets with a file extension deliberately keep their bare 404. An earlier version fell back for every 404, which served an old deployment's index.html in place of a missing /sw.js, and the wrong MIME type broke service-worker registration with a SecurityError.

Warning

Warning: the branded 404 can only upgrade a 404 that ASSETS actually returns. A Pages project configured with assets.not_found_handling = "single-page-application" answers unknown paths with 200 plus index.html, leaving nothing to catch. That setting must be "404-page" or "none".

No build step#

There is no package.json at the repository root. The "build" is a shell block in .github/workflows/deploy.yml that copies a named file list, then every top-level *.html, then the directories icons free-tools reports legal js css images into dist/, and writes an empty dist/.assetsignore.

The list is staged by directory, never by filename, because an earlier hardcoded 24-file allowlist shipped ported HTML without its assets and produced 404s in production. Full detail is in Deployment.

Components#

ComponentWhat it isWhere it lives
The workerexport default { fetch, scheduled }; fetch calls mvTrackedFetch(request, env, ctx, mvRouteRequest)_worker.js:1333-1365
The routermvRouteRequest, a flat ordered chain of if statements_worker.js:1372-2439
The tracking wrappermvTrackedFetch observes the real status and duration and writes an activity row_worker.js:17789-17878
Static assetsServed through env.ASSETS with the fallback chain above_worker.js:2331-2438
Route inclusion_routes.json: include ["/*"], 15 excluded paths_routes.json
Cache headers_headers: no-cache, no-store, must-revalidate for /login, /dashboard, /index.html, /, /version.json_headers
Customer appdashboard.html, a 7.2 MB single-page document with a client-side auth overlaydashboard.html
Sign-in pagelogin.html, inlined into the worker as LOGIN_HTML and served from there_worker.js:75, :2219-2228
Admin consoleadmin.html, a static page whose every API call is server-verifiedadmin.html
Marketing siteindex.html, pricing.html, blog.html, legal/, free-tools/repo root

Bindings#

BindingTypeResourceUsed?
MONITOR_DBD1metricvault-monitorYes. 59 tables, the whole application data layer
BLOG_MEDIAR2metricvault-blog-mediaYes, blog images only
AIWorkers AI-Yes: /api/translate only. Blog images come from Higgsfield
ASSETSPages static assetsimplicit in advanced modeYes
QUICKVIEW_CACHEKVnamespace 08fb0f9f...No. Zero code references

QUICKVIEW_CACHE is declared in wrangler.toml and read by nothing. The feature it was created for uses a D1 table instead, and the reason is written in the code at _worker.js:15880-15881: KV bindings on this Pages project reset on every deploy, so MONITOR_DB is used because it is reliably bound. The same note appears for platform_config at _worker.js:18662. Treat any documentation that mentions "the KV cache" as stale.

External systems#

SystemRoleFailure behavior
Supabase AuthIdentity. JWT in browser localStorage, verified server-side by a call to /auth/v1/userToken verification returns null; admin routes deny, customer routes fall back to body-supplied email
Supabase PostgRESTOne legacy table read and written by the worker with the service key: analysis_history. Four more (contact_messages, bug_reports, admin_activity, usage_logs) are reached only from the browser with the anon key/api/history returns 500 History not configured without the service key; saveToSupabase silently returns
DataForSEOThe real SEO data layer for most toolsFetchers return null; real-data tools return an honest empty result rather than fabricating
OpenAI / Anthropic / Gemini / PerplexityReport generation, chat, blog writing, AI-visibility measurementMixed: some routes 503, most degrade
StripeCheckout, portal, webhook and a reconcile jobCheckout 503 stripe_not_configured; the webhook fails closed
ResendAll transactional and newsletter emailSilently skipped
metricvault-cron WorkerThe clock. Cron */15 * * * *, POSTs /api/cron/runJobs simply do not run; the hourly GitHub Actions backup catches up

The second deploy#

cron-worker/ is a standalone Cloudflare Worker, not part of the Pages project. It has no storage bindings. Its only job is to fire every 15 minutes and POST to https://metricvaultai.com/api/cron/run with an x-mv-internal-secret header. It is deployed separately with cd cron-worker && wrangler deploy, and its secret is set with wrangler secret put MV_INTERNAL_SECRET. See Background jobs and scheduling.

Data flow#

A typical metered tool run:

  1. The browser posts to /api/premium-ai with a JSON body. dashboard.html patches window.fetch so every same-origin /api/ request also carries Authorization: Bearer <supabase access token>, but almost no handler reads it yet.
  2. _routes.json does not exclude /api/*, so the request reaches the worker.
  3. mvTrackedFetch looks up a route descriptor, buffers the JSON body if it is under 24,000 bytes, and calls the router.
  4. mvRouteRequest matches /api/premium-ai and calls mvSaveAroundPremiumAI, the shared-cache wrapper.
  5. The wrapper checks mv_seo_cache in D1. On a fresh hit it still runs the quota gate and still charges the credit, then returns the stored body with X-MV-Cache: hit.
  6. On a miss, handlePremiumAI runs the kill-switch check, the recommendation gate where applicable, enforceAiQuota, then incrementUsage, then the provider calls (DataForSEO through callDataForSEOCached, then the LLM).
  7. The response is written through to mv_seo_cache, saved to tool_results for the Library, and returned.
  8. mvBumpMetrics records runs, duration, cache hit or miss and estimated provider cost into metrics_hourly. A failure additionally records a grouped fingerprint in error_issues.
  9. mvTrackedFetch writes an activity_log row inside ctx.waitUntil, so it never delays the response.

The step-by-step version with every gate and its exact error body is in Request lifecycle.

Failure modes#

FailureSymptomCauseRecovery
D1 binding missingMONITOR_DB not bound. Wrangler may need a redeploy. in 500 bodieswrangler.toml in the wrong format, or a positional dist/ argument in the deployRestore Pages format, redeploy without a positional directory
MV_INTERNAL_SECRET unset/api/cron/run and /api/billing/sync return 503; all /api/diag/* return 403The secret is missing in one of its three homesSet the same value in Pages env, the cron Worker, and GitHub secrets
MV_INTERNAL_SECRET mismatchedCron workflow annotates Cron trigger returned HTTP 401 (expected 200).The three copies driftedRe-set all three
Static asset missing from dist/A page loads but its CSS or JS 404sA new directory was added and not stagedAdd the directory to the loop in deploy.yml
login.html edited aloneProduction serves the old sign-in page/login is served from the inlined LOGIN_HTML, not the assetnode tests/login-inline-sync.mjs --write, commit both files
GitHub raw unreachable/legal, /ext-preview.html, /free-tools/* and the logo PNGs failThose routes fetch from raw.githubusercontent.com at request time with an edge cacheNothing in-repo; the edge cache masks short outages
Provider key missingA tool returns an empty or degraded resultSee the table in Environment variables and secretsAdd the key in Pages env
Uncaught exception in a handlerCloudflare's own error page, not a JSON bodymvTrackedFetch rethrows; there is no global catch that formats an error responseFix the handler; every handler is expected to catch its own errors

Two structural behaviors are worth internalising because they are not symmetrical:

  • The quota gate fails closed. Any database error in enforceAiQuota returns 503 with code: "quota_check_failed" rather than letting the call through unmetered.
  • The configuration layer fails open. mvConfigAll returns an empty map on error so that "no config means every default applies, tools stay on". During a D1 outage that also means suspended accounts are momentarily not suspended and every cache override disappears.

The complete open-versus-closed matrix is in Authentication and authorization flow.

See also

Was this article helpful?