Skip to content

.phaze: Edge data (Cloudflare)

---platform and ---cloudflare are edge-data boundaries — a subsystem distinct from the four core boundaries (---page, ---data, ---state, ---props). The core four compile to exports, a loader, state, or params; these two compile to instance-scope edgeSignals whose values come from a built-in resolver reading Cloudflare’s per-request intelligence.

Unlike ---data (where you write name: value), these list bare field names — one per line — and the value comes from the resolver, not from you. ---platform answers “how is the user connecting?” (device + connection tech); ---cloudflare answers “who / where is the request from?” (geo / identity / bot).

The resolvers read two Cloudflare-specific request sources during SSR:

  • request.cf — the IncomingRequestCfProperties object Cloudflare attaches to every Worker request. Geo (country, city, timezone, regionCode, isEUCountry), connection (clientTcpRtt, clientQuicRtt, httpProtocol, edgeL4.deliveryRate), TLS (tlsVersion, tlsCipher), network (asn, asOrganization, colo), and botManagement all live here.

  • Cloudflare request headers — for the few fields not on request.cf:

    HeaderUsed for
    CF-Device-Typedevice — not delivered to Workers; the User-Agent sniff is the real path (see below)
    CF-Connecting-IPconnectingIp
    True-Client-IPtrueClientIp (Enterprise)
    CF-Rayray
    CF-Workerworker
    CF-Visitorvisitor.scheme
---platform
device
perf
---cloudflare
country
city
timezone
---
Compiles to
import { edgeSignal, __platform, __cloudflare } from '@madenowhere/phaze-cloudflare/runtime'
const device = edgeSignal(() => __platform().device)
const perf = edgeSignal(() => __platform().perf)
const country = edgeSignal(() => __cloudflare().country)
const city = edgeSignal(() => __cloudflare().city)
const timezone = edgeSignal(() => __cloudflare().timezone)

The edgeSignal / __platform / __cloudflare imports are injected by phaze-compile’s babel pass (same merge-or-prepend as revalidate / @global) — you never write them.

Each field becomes an instance-scope edgeSignal — a real phaze Signal<T>. It resolves once on the edge during SSR (from request.cf + CF request headers), serializes into the page payload, and rehydrates on the client with no fetch — so it reads the same on both sides. Read it like any signal:

<p>{city} · {timezone}</p> {/* signal-as-child auto-binds */}
watch(console.log(device(), city())) {/* or read explicitly */}
FieldTypeDerived from
device'mobile' | 'tablet' | 'desktop'a coarse User-Agent sniff. The CF-Device-Type header is preferred when present, but Cloudflare does not send it to Workers — see the caching caveat below. Not request.cf.deviceType — no such field exists.
mobilebooleandevice !== 'desktop' — a convenience flag
perf{ latency, protocol, throughput }latency ← cf.clientTcpRtt ?? cf.clientQuicRtt; protocol ← cf.httpProtocol; throughput ← cf.edgeL4.deliveryRate
tls{ version, cipher }version ← cf.tlsVersion; cipher ← cf.tlsCipher
network{ asn, asOrganization, colo }cf.asn / cf.asOrganization / cf.colo

A page that renders differently per device cannot be cached under a URL-only key. That includes any page using device-variant fences — ---mobile / ---tablet / ---desktop — because the matched variant is rendered into the HTML and the resolved device is inlined into the hydration payload, which the client reads once to decide which chunk to import. Serve that document to a second device and the browser renders the wrong variant and fetches the wrong chunks, never requesting its own.

Cloudflare has a setting for this — Cache by device type — and it does not apply to Workers. It is an Automatic Platform Optimization feature that sends CF-Device-Type to an origin, and a Worker on a custom domain has none. Cloudflare’s Workers Cache docs state it plainly: “No zone configuration for caching applies to Workers Caching.” Measured on a live deployment with the rule enabled, only cf-connecting-ip, cf-ray and cf-visitor were delivered — no cf-device-type. Cache Rules, Edge TTL, the device cache key and Purge Everything each changed nothing, while the Worker’s own CDN-Cache-Control controlled the cache to the second.

What Workers Caching does honour is Vary. Declare it on the cache rule:

cloudflare({
cache: {
rules: {
'/**': { maxAge: minutes(1), swr: days(1), headers: { Vary: 'User-Agent' } },
},
},
})

User-Agent rather than client hints: Sec-CH-UA-Mobile is two values where a UA string is thousands, but Safari and Firefox send no client hints, so a hint-keyed cache leaves every Safari visitor sharing one entry. The UA is also exactly what resolveDevice reads, so the cache key and the rendered variant cannot disagree. The cost is one cached copy per distinct UA string instead of one per URL.

---cloudflare — who / where the request is from

Section titled “---cloudflare — who / where the request is from”
FieldTypeDerived from
countrystring | undefinedcf.country (ISO 3166-1 alpha-2; same value as the CF-IPCountry header)
eubooleancf.isEUCountry === '1'
regionstring | undefinedcf.regionCode
citystring | undefinedcf.city
timezonestring | undefinedcf.timezone (IANA, e.g. America/New_York)
connectingIpstring | nullthe CF-Connecting-IP header
trueClientIpstring | nullthe True-Client-IP header (Enterprise)
raystring | nullthe CF-Ray header
workerstring | nullthe CF-Worker header — the owning Worker zone’s hostname, stamped on Worker subrequests (loop detection)
visitor{ scheme }scheme ← the CF-Visitor header’s JSON (e.g. {"scheme":"https"}), parsed defensively — undefined if absent/malformed
bot{ score, verified }cf.botManagement.score / cf.botManagement.verifiedBot (requires Bot Management)

Flat vs grouped. device / mobile and every ---cloudflare scalar carry a single value — device() is the string. perf / tls / network / visitor / bot carry an object — read a member off the signal: perf().latency, tls().version, visitor().scheme, bot().score. The compile shape is identical; only the resolver’s return type differs.

  • Bare field names only — one identifier per line (same trailing-whitespace + // comment tolerance as the other fences). No name: value; the value is the resolver’s, not yours.
  • Unknown or duplicate fields surface a diagnostic and are skipped. The allowed sets are exactly the fields tabled above.
  • Instance-scope, not module-scope — edgeSignal must run during render, so the declarations hoist into the component body (like Local ---state), not above it. They’re in scope in the trailing region AND in every ---mobile / ---tablet / ---desktop variant body.
  • Initial state only — after hydration each is a regular signal; you may .set() it, but the edge value is just the initial value (it never re-resolves client-side).