Edge Middleware examplesCloudflare

Edge Middleware

Thirteen middleware recipes on one Worker.

edge-middleware-recipes

Vercel's Edge Middleware examples — thirteen small Next.js apps whose whole point is a middleware.ts — ported as one Cloudflare Worker of recipes: each middleware.ts becomes a module under apps/recipes/src/recipes/ with its behaviour intact, mounted by a Hono router under its own path, with the demo pages it rewrites to and a test that proves the upstream behaviour — and packaged as an Orun baseline: one command takes a new product from nothing to live on stage and prod, in its own repository, under its own name, in ONE landing.

The middleware logic is the upstream's wherever it is plain web-standard code, comments included. What changes is the Vercel-only primitive underneath — geolocation(req) is request.cf, Edge Config is a var, Upstash is the Workers Rate Limiting binding, NextResponse.rewrite is an in-process dispatch. What the baseline adds is everything needed to make a copy of it somebody else's — built the way cirrus-baseline is (its v22 one-landing shape). See specs/epics/edge-middleware-recipes for the design (the full primitive map), the milestones and the as-built status.

Instantiating a product

git clone --depth 1 --branch <baseline-tag> https://github.com/sourceplane/edge-middleware-recipes
cd edge-middleware-recipes
orun new --blueprint repo-blueprint.yaml --out ~/products/acme-recipes --run-hooks --resume \
  --set reponame=acme-recipes --set productname="Acme Recipes" \
  --set githuborg=acme-inc --set subdomain=<workers-dev-subdomain>

Or, once the platform's registry lists it, from the Orun console (Baselines → edge-middleware-recipes → Build). Two phases — 01-product (the repository, then the whole product in one pull request, deployed to stage and prod by one convergence and smoke-tested) and 02-docs (the live deployment recorded, the promotion gate put back). BOOTSTRAP.md is the operator guide; docs/phases/ has a page per phase.

The new repository receives the product only — the Worker, its tests, its CI, its tooling, a README of its own and its operating contract — renamed throughout to the product's name, repository and Cloudflare Worker. None of this repository's machinery ships. The product deploys and serves every recipe with no secret but the two the platform brokers; the basic-auth recipe answers a setup page until its owner sets BASIC_AUTH_USER / BASIC_AUTH_PASSWORD (orun secrets set, pushed by the next deploy).

What makes it a baselineWhere
The console card (inputs, secrets, programme)blueprint.yaml
The build document (modules, phases, hooks)repo-blueprint.yaml
This tree's identity, and the bootstrap's task contracts.baseline/
The rename engine (the product-name seam is apps/recipes/src/site.ts)tooling/rebrand/rebrand.mjs
The bootstrap's helpers (deferrals, redeploy marker, work-tree strip)tooling/bootstrap/
The deployment-record rendererhooks/render-deployment-docs.sh
The product's README and context pack.baseline/product/README.md, ai/context/
The baseline's tests: contract, leak, offline bootstrap, end to endtesting/, .github/workflows/baseline.yml
Cutting a baseline-vN tag.github/workflows/tag.yml

The recipes

/ lists them with curl lines to try. Each route is the upstream directory's name.

RouteWhat it doesVercel original
/geolocationReads the visitor's country, city and region, adds currency and languages from a country table, rewrites to a page that renders themgeolocation — geolocation(req) → request.cf
/i18nLocale from Accept-Language, country from the visitor's location; rewrites / to /<locale>/<country> and renders that locale's dictionaryi18n
/basic-auth-passwordChecks Authorization: Basic; anything else is rewritten to a 401 with WWW-Authenticatebasic-auth-password — hard-coded pair → Worker secrets
/rate-limit-any-framework5 requests per 10 s per IP; the sixth is redirected to /blocked.htmlrate-limit-any-framework — Upstash → the ratelimits binding
/ab-testing-simpleAssigns /home and /marketing visitors a bucket in a cookie and rewrites to that bucket's pageab-testing-simple
/maintenance-pageServes the maintenance page for /big-promo while a flag is on, a setup dialog when it is missingmaintenance-page — Edge Config → MAINTENANCE_MODE var
/cookiesRewrites / to the beta or non-beta page from the beta cookiecookies
/add-headerAdds x-modified-edge: true to every responseadd-header
/modify-request-headerAdds, overrides and deletes request headers before the route runs; the page prints what it receivedmodify-request-header
/query-params-filterStrips every query parameter but allowed and redirects (307) to the clean URLquery-params-filter
/json-responseAnswers from the middleware with {"message":"hello world!"}json-response
/user-agent-based-renderingRewrites / to the mobile or desktop page from the User-Agentuser-agent-based-rendering — userAgent() → ua-parser-js
/redirects-bloom-filterChecks every path against a Bloom filter of 50,000 redirects and redirects on a hit (307/308)redirects-bloom-filter — Route Handler fetch → in-process lookup

What is here

SurfaceWhere
The product's name and source linkapps/recipes/src/site.ts
The Worker — / index, one module per recipeapps/recipes/src/index.ts, apps/recipes/src/recipes/<name>.ts
The Edge Middleware verbs on a Worker (rewrite, geolocation, ipAddress, userAgent)apps/recipes/src/lib/middleware.ts
Upstream data kept as shipped (country table, 50,000 redirects + generated Bloom filter, dictionaries, buckets)apps/recipes/src/recipes/<name>/
The generators (country table, Bloom filter)apps/recipes/scripts/
Worker config (name, compatibility, per-environment vars, the rate limiting binding)apps/recipes/wrangler.jsonc
Secrets for wrangler devapps/recipes/.dev.vars.example → .dev.vars
Deploy contractapps/recipes/component.yaml (cloudflare-worker-turbo)
Tests — one per recipe, in-process through app.request()tests/recipes/src/*.test.ts

Getting started

pnpm install
pnpm typecheck
pnpm lint
pnpm test             # 28 tests: every recipe's upstream behaviour and the site identity, in-process (no workerd)
pnpm build            # wrangler deploy --dry-run → apps/recipes/dist (the bundle, no upload)

Run the Worker locally under workerd and try the recipes:

cp apps/recipes/.dev.vars.example apps/recipes/.dev.vars   # the basic-auth pair
pnpm --filter @app/recipes dev                             # http://localhost:8787

curl -s http://localhost:8787/                             # the index, with a curl line per recipe
curl -s -H 'Accept-Language: fr' http://localhost:8787/i18n | grep Bonjour
curl -si -u 4dmin:testpwd123 http://localhost:8787/basic-auth-password | head -1
for i in 1 2 3 4 5 6; do curl -s -o /dev/null -w '%{http_code} ' http://localhost:8787/rate-limit-any-framework; done
curl -si 'http://localhost:8787/query-params-filter?a=b&allowed=test' | grep -i location
curl -si http://localhost:8787/redirects-bloom-filter/blog/should-redirect-to-google | grep -i location

request.cf (geolocation, i18n) is real under wrangler dev — wrangler fetches the machine's own. To see the maintenance page: pnpm --filter @app/recipes dev -- --var MAINTENANCE_MODE:true.

Add a recipe: a module in apps/recipes/src/recipes/ that exports a Hono app on its own base path and a meta entry, registered in src/recipes/index.ts; its app.use(<matcher>, …) is the upstream's export const config = { matcher }.

How it maps to Vercel

VercelHere
Thirteen Next.js apps, each run by Vercel's framework builder with its middleware.ts at the edgeOne Cloudflare Worker: Hono routes each recipe under /<name>; export default app is the module handler
geolocation(req) (@vercel/functions), ipAddress(req) (@vercel/edge), userAgent(req) (next/server)request.cf, CF-Connecting-IP, ua-parser-js — apps/recipes/src/lib/middleware.ts
NextResponse.next() · .rewrite(url) · .redirect(url) · .json(body)await next() · rewrite(app, c, url) (in-process dispatch) · c.redirect(url, 307) · c.json(body)
Edge Config (isInMaintenanceMode)The MAINTENANCE_MODE var per environment (a flag that must flip without a deploy: Workers KV)
@upstash/ratelimit over Vercel KVThe Workers Rate Limiting binding (ratelimits in wrangler.jsonc)
vc dev · vc build · vc deploywrangler dev · wrangler deploy --dry-run --outdir dist · wrangler deploy --env <stage|prod>
Deploy on pushOrun deploys <repo>-recipes-{stage,prod} on merge to main (apps/recipes/component.yaml)
"Deploy with Vercel" (clone the template)orun new --blueprint repo-blueprint.yaml — a new repository, branded, deployed

CI

WorkflowWhat it runsShips to a product
ci.ymlthe Orun pipeline, and the only workflow a product carries: orun plan --changed, then one job per lane. A pull request runs the verify lanes (the Worker built, type-checked and dry-deployed per environment; the tests package's lane installs it and checks its structure — they do not lint or run the tests; baseline.yml's e2e tier runs those before each tag, and a product's README asks its developers to run them before a push), except the bootstrap's own landings (orun/BASE-*), which are checked in the box before they are pushed (checks: local) and whose PR runs plan alone; a merge deploys stage → prod. It plans nothing while intent.yaml names the placeholder workspace ws_CHANGE_ME, as this repository does. A manual run (Orunbase's Deploy button, or Run workflow on main) plans the environment and components it names under the push-to-main trigger, so they deploy as a merge deploys themyes
baseline.ymlthe baseline's own tiers: contract tests, the blueprint read by the pinned orun, the leak gate, the offline bootstrap, the product built, dry-deployed and servedno
tag.ymlcuts a baseline-vN tag (manual)no

Deploying this repository by hand

A product built from the baseline is deployed by its own bootstrap. To deploy THIS repository instead: set execution.state.workspace in intent.yaml to an Orun workspace (orun cloud link) with a Cloudflare connection, and ci.yml turns on — a merge to main deploys the Worker to stage, then prod. The basic-auth pair is optional: until it is set per environment (orun secrets set BASIC_AUTH_USER --env stage, or wrangler secret put), that recipe answers its setup page. The rate limiting binding needs no resource: wrangler creates the namespace from wrangler.jsonc on deploy (one namespace_id per environment).