Running other people's code on the edge that serves their sites

A static host that starts running its customers' code inherits every problem of a shared domain at once. How functions were built here — one isolate per deploy, a gateway in front of every fetch, headers rewritten on the way out — and what we measured before writing any of it.

Until recently, nothing a customer published here could execute. A site was files, and the worst a file could do was be served.

That changed with functions. A folder named _functions in a publish now holds JavaScript files, and each one answers the path it is named after, on the site's own address — a webhook, a call to an API with a key the browser never sees, a form handled the owner's way. The file syntax is the one Cloudflare Pages Functions already taught people: api/hello.js answers /api/hello, [id].js matches one segment, [[path]].js matches the rest.

The syntax was the easy part. A host that runs code written by strangers, on a domain those strangers share, has to answer a different set of questions, and most of them were answered by measuring rather than by reading.

A function is part of a deploy

The first decision removed a whole class of bugs: there is no such thing as a function here, separately from a deploy.

The files under _functions are uploaded with the pages, recorded in the same version, and go live in the same single write that makes any version live. Rolling back to an earlier version brings back its functions too. A page and the code it calls can never come from two different releases, and there is no second go-live switch to forget to flip.

The source itself is never served. The router answers 404 for anything under /_functions/, and it checks the path after decoding it, so /%5Ffunctions/api/hello.js does not slip past a check written against the raw URL.

One isolate per deploy

The code runs on Dynamic Workers: a Worker of ours loads a customer's modules at request time into a fresh isolate. The obvious alternative was Workers for Platforms, which is mature and has logging built in. It also means uploading a script through Cloudflare's API on every publish and again on every rollback, from the API that manages accounts. A Dynamic Worker reads its modules from object storage the first time it is called, so the serving path still never touches the control plane — the property described in the post on the two planes.

Every isolate is identified by a string, and the string is the security boundary:

TEXT
fn:{siteId}:{deployId}:{revision}

An isolate is effectively a cache. Its id decides whose code runs and who shares its global memory. Drop the site id, and two sites that ever shared a deploy id would run each other's code. Drop the deploy id, and a new release keeps running the previous code until the isolate happens to be evicted. It is the same rule as the edge cache key, for the same reason, and it got the same treatment: each segment was deleted in turn and the suite had to go red. All three mutants died, including in tests that check behaviour rather than spell the id out.

The id is also the price. Cloudflare charges $0.002 for each unique Dynamic Worker per day, where unique means a new id or new code. One id per deploy makes that a few cents a month for a site that is called daily.

What the id does not contain is just as deliberate. Keys and limits are not baked into the isolate; they are passed on every call. Change a key and it reaches running code within about a minute, with no new publish, and the isolate never holds a secret it was not handed on that request.

What we measured first

Before any of this was written, a throwaway Worker ran the riskiest assumptions on production. Six of the answers changed the design:

Question What happened What it changed
Can customer code write to the edge cache? Refused: "Cache API is not yet supported for dynamically-loaded workers" "Not yet" is a promise to change, so the test re-runs after every runtime upgrade
Does a timeout stop the code? The isolate kept running for at least 20 seconds after we stopped waiting Timeouts only answer the visitor early. The real ceiling is CPU time and the number of outbound calls
Does a 10 ms CPU limit hold? Calls of about 1.2 s of CPU: the first two ran in full, the third was cut halfway, the rest at about 15 ms Each new isolate gets a few seconds of grace, and the cost model now counts it
What reaches us when the code throws? The full message, including a test string shaped like a secret Visitors get a fixed error code, never the message
Does console.log reach a tail Worker? No There are no logs for owners yet
Can the code open a raw TCP socket? No — the gateway exports no connect() Only fetch leaves the isolate

The third row is the one worth remembering. The limit is real, but it is enforced per isolate over time, not per call, and a design that assumed otherwise would have priced the worst case wrong.

A gateway in front of every fetch

A Dynamic Worker's outbound traffic can be routed through a gateway of the host's choosing. Ours parses every URL with the WHATWG URL parser before checking it, so 127.1, 0x7f.0.0.1 and 2130706433 all become 127.0.0.1 first and are refused as the loopback address they are. It allows http and https on ports 80 and 443, refuses addresses that are not globally routable, and refuses our own domains and Cloudflare's shared ones. Redirects are handed back to the code instead of followed, so every hop passes the gateway again. A call may make ten requests out.

It also marks every request it sends with a hop header, and the router answers 508 to any request carrying that header, on any path, before it even looks up the host. Without that, a function could call another customer's site through its custom domain, which calls another, and the platform's own bandwidth becomes the amplifier.

A hostname whose DNS answer is a private address is the one thing a check on names cannot catch. That is why the Worker also runs with global_fetch_strictly_public, which routes every fetch as if it came from the public internet.

The domain everybody shares

Customer sites live on subdomains of one domain, and that domain is not yet on the Public Suffix List the way github.io and netlify.app are. To a browser, every site on it is the same site. Static files could not exploit that much. A function sets response headers, and some headers act on the whole registrable domain.

Clear-Site-Data: "cookies" is the sharpest example: it affects the entire registered domain, including subdomains. One function returning it to an <img> tag would sign every visitor out of every password-protected site on the platform. So the router rebuilds every response from a function:

  • It drops headers that reach beyond the site — Clear-Site-Data, WWW-Authenticate, Origin-Agent-Cluster, NEL, Report-To and a few more.
  • It rebuilds each Set-Cookie, always without Domain, and refuses any name the platform uses for its own cookies. Owners are told to name a session cookie with the __Host- prefix, which a sibling site cannot overwrite.
  • It forces Cache-Control: no-store. A phishing page cached as immutable for a year is a page no kill switch can take back.

Requests are rebuilt on the way in, too. The code never sees the platform's cookies or the headers that describe a visitor's network. A call from a page on another site of the shared domain is refused, because otherwise that page could post to a function with the visitor's cookies attached.

Preview addresses never run code

Every version has its own preview address, and here every publish that completes goes live at once. So a preview is always an older version — very often one that was rolled back because something was wrong with it. A function with a hole would stay callable there, with today's keys, at an address that was public while it was live.

Function paths on a preview address therefore answer 404. The cost is real: there is no way to try a function before it is live. Publish, and roll back if it is wrong.

What it does not do yet

  • No logs for owners. console.log does not reach anything we can store, and a raw log of customer code would hold secrets and visitors' data.
  • Anybody who can reach a route can spend its allowance. Calls count against the account's month, and past it every function of the account answers 429 until the month turns. A per-site circuit breaker limits a burst; it does not make a public route private.
  • No npm in drag and drop. There is still no build step on our side. The CLI bundles each route on the owner's machine; a folder dropped in the browser must be self-contained.

Functions run on the plans that include them, and what they accept, return and refuse is in the functions documentation.

This site is the product it is written about.

Drag a folder in and read the link back. No account and no card to publish.