The relay of internal/relayserve as a Cloudflare Worker.
The Go client holds both implementations to internal/relayconf; run the local matrix below
before claiming conformance for a build.
Chat content, names and credentials stay encrypted. The relay also reads routing,
ownership and coordination metadata and keeps no account table: an identity is the hash of its key.
request --> Worker (stateless) --+--> /v1/pair* --> PairGate (one object) --> Mailbox (one per nameplate)
|
+--> identity ingress --> IdentityDO (router/blob index) --> R2
|
+--> RaftMemberDO A/B/C
The Worker (src/raft-worker.js, exporting src/worker.js) decides where a request goes and does nothing else. It reads the
identity from the unsigned Codeaf-Identity header, refuses an oversized Content-Length with 413
before waking anything, and forwards the request, body unread, to that identity's Durable Object.
Routing before reading is what keeps one identity's slow put from ever delaying another identity's
Has, and it lets the identity's object count its own puts.
IdentityDO (src/identity.js) proves the caller holds a cert of the identity (which needs no
body), counts a put as in flight, reads the body under its cap, verifies the request signature over
it, checks the device is not revoked, spends the caller's rate budget, and serves. Then:
Hasnever answers no while a put for the same identity is still being received. It waits for the puts in flight (src/flight.js, a port ofinternal/blobstore/flight.go) and fails with 503 after 30 s rather than say no. A caller with no cert of the identity cannot hold a put open.- Directory and authority decisions live in three separately addressed
RaftMemberDOSQLite stores.src/raft/hosted.jsfreezes the original source on first use, streams the same genesis to A/B/C and publishes ownership only after a fresh quorum read. Commands use the original v1 proof and the shared Go state machine; JavaScript has no authoritative lease writer.src/directory.jsand its old rules remain only for immutable source conversion and legacy fixture tests. Source backups are retained until the identity is retired. An absent quorum returns retryable 503 and never reopens the old writer. - Frames live in R2 at
<identity>/f/<FrameID>(src/store.js), each validated (magics, header,cell_key_id) before it is stored. The index that finds an object inside its frame is rows in the object's SQLite (objs: rid -> frame, offset, length, primary key on rid), so it takes no memory and how many objects an identity holds is bounded by its byte quota. A frame is durable in R2 before the index learns of it; a crash between the two leaves an unindexed frame, which the next put of that same frame indexes. - A batch get,
POST /v1/store/objectswith{"rids":[...]}(1 to 256, none twice), answers a frame holding the objects of the longest prefix of the ids that fits in about 1 MiB, in request order, and stops at the first id the index does not hold (404 not_foundonly when the first is absent). It locates the ids in the object's SQLite and reads each frame it touches once, as one ranged R2 read, so a takeover uses one Worker request and one R2 read per frame, not per object. - A rid held with other bytes is
409 conflict, and the frame stores none of its objects. Puts run one at a time inside the object, so two racing puts of one rid cannot both win. /v1/store/statsis kept across evictions and restarts: counts are batched in memory and written by an alarm a few seconds after the first change (src/stats.js). A platform crash can lose at most those few seconds.- An identity is admitted on its first authenticated request, and that first sight is counted
against the caller's IP in one
NewcomerGateobject (src/newcomers.js). Existing identities are never counted again, from any address, so the cap cannot affect the current client. Admission happens before the identity's tables exist, so a refused identity stores nothing.
Directory revocation is POST /v1/dir/devices/{id}/revoke (204; unknown id 404 not_found; the
caller itself 400 self_revoke; twice is fine). A revoked device is 401 revoked on both wires, and
a device record cannot set or clear its own flag.
Directory watch is GET /v1/dir/watch, a signed WebSocket upgrade on the same object
(src/watch.js). It supplies compatibility version and presence hints: the object keeps a durable version of the
directory (the replicated application version), bumps it on every change a person could see (a heartbeat that only
moves a lease's expiry is not one), and sends {"v":N} to every socket. It accepts with the hibernation
API and answers the client's ping with the platform's auto-response, so an open, quiet socket wakes
nothing. test/request-scoped.test.js keeps the object free of timers (but Has's
bounded wait) and of outbound connections. maxWatchers (default 1000) caps sockets per identity.
Rotation is POST|GET /v1/identity/rotation through the same coordinator.
The shared Go state machine owns its rotation field in the replicated identity record. The first device to freeze owns the rotation, any device may thaw a frozen
identity, nothing thaws a retired one. Routes marked write in src/routes.js (every directory verb
that changes a record, and a frame put) are 410 rotated once the identity is frozen or retired;
reads stay open. A retire arms the object's alarm at its deadline. The alarm (src/erasure.js) writes
the tombstone first (gone in the object's storage), deletes the identity's R2 prefix a page per turn,
then erases all member copies/snapshots and source SQLite tables through a retained journal,
so a crash half way is finished by the next alarm; afterwards every
request is 410 gone and makes no table and no R2 object. The one alarm slot is shared with the stats
flush, so #arm only ever moves it earlier and the alarm handler re-arms the deadline.
The pairing mailbox (src/pair/) is unauthenticated because the joining device has no identity yet,
so it cannot become free storage: every limit is by IP (CF-Connecting-IP), boxes live one TTL and
are deleted by an alarm, and PairGate caps the live boxes. Its counters are stored, so waiting for
the object to be evicted does not reset them.
Link pairing (src/link/, docs/ux-pairing-contract.md) is the sibling for a new device that asks a paired
device to approve it: LinkGate holds the per-IP decisions and allocates the 8-character code, one
LinkRequest object per code holds the record and moves it once (pending, then approved or denied), and the
signed approve and deny routes under /v1/dir/requests/ enter through the identity coordinator.
The shared Go state machine commits device authorization, metadata and the sealed decision together.
A retained outbox delivers that decision to the request object afterward and repairs interrupted
delivery. The grant and sealed name remain opaque bytes.
The pinned modified Go Raft core and directory state machine compile into the Worker's WASI reactor. JavaScript owns authentication, storage, transport and encrypted blobs; it does not decide lease or membership policy. Original v1 bodies keep their 1 MiB HTTP limit. Large signed requests use bounded body pages and an atomic final application, preserving all metadata without exposing partial changes. Frame decoding and request verification still have JavaScript ports pinned by Go vectors; the old JavaScript directory rules serve only source conversion and legacy fixtures.
cd relay/hosted
npm ci
npm run dev # http://127.0.0.1:8787, R2 and Durable Objects simulated in workerdnpm run dev lifts the new-identity cap (the conformance suite makes one identity per case, all from
127.0.0.1); a bare wrangler dev keeps the default of 200 a day per network. Conformance, against
the running relay, from the repository root:
go test -tags relayurl ./internal/relayconf/ -relay-url=http://127.0.0.1:8787npm run dev also trusts X-Forwarded-For and keeps pairing mailboxes 4 s, which the pairing suite
needs (each case is a network of its own; ExpiryDeletes skips above a 5 s TTL). RelayFull is
skipped unless a second relay capped at two mailboxes is given with -relay-small-url
(npx wrangler dev --port 8788 --var TRUST_PROXY:1 --var 'CAF_LIMITS:{"pairMaxBoxes":2,"newIdentitiesPerIpPerDay":1000000}').
The repository's scripts/relay-conformance.sh runs its tracks sequentially on a shared
machine. CONFORMANCE_PARALLEL=1 is an opt-in for isolated runners; concurrent package
builds and disk-heavy tests can otherwise consume a local Worker's request deadlines.
npm test # hermetic unit: the vectors, the rules, quotas, the store on a real R2 simulator, body caps
npm run e2e # starts two local relays (default limits, short limits), runs every wire end to end
CONF=1 npm run e2e # the same, plus the Go relayconf suite against the default relayThe Go vectors pin the JS ports. Any change to internal/blobstore/frame.go,
internal/reqsign or internal/directory/rules.go must add a vector in the same PR:
go run ./test/hostedvectors > /tmp/v.json && VECTORS=/tmp/v.json npm test
cp /tmp/v.json relay/hosted/vectors.json # keys are fresh on every run; the file is a snapshotOne divergence is pinned on purpose: Go's encoding/json matches field names case-insensitively,
the port does not. Those frames carry js_stricter in the vectors; Go writers always emit the
canonical names, so the stricter side is the safe one.
| Binding | Kind | Purpose |
|---|---|---|
FRAMES |
R2 bucket | frames and the object index, under <identity>/ |
IDENTITY |
Durable Object IdentityDO (SQLite) |
source gate/backup, coordinator, blob index, puts in flight, caps |
RAFT_MEMBERS |
Durable Object RaftMemberDO (SQLite) |
three independently addressed coordination stores per identity |
NEWCOMERS |
Durable Object NewcomerGate (SQLite) |
new identities per IP, one object |
PAIR_GATE |
Durable Object PairGate (SQLite) |
nameplates, per-IP counters, one object |
MAILBOX |
Durable Object Mailbox |
one pairing, one per nameplate |
LINK_GATE |
Durable Object LinkGate (SQLite) |
link-request codes, per-IP counters, one object |
LINK_REQUEST |
Durable Object LinkRequest |
one pending request, one per code |
MACHINE |
Durable Object MachineDO (SQLite) |
one machine name's codeaf serve socket and the surfaces dialled to it |
MACHINE_DIAL_LIMIT |
Rate limit (30 / 60 s) |
per-network dial ceiling at the Worker front door before MACHINE wakes |
MACHINE_REGISTER_LIMIT |
Rate limit (10 / 60 s) |
per-network registration ceiling at the Worker front door before MACHINE wakes |
LEASE_POLICY |
var | amended (default: 90 s, a publish renews) or stage1 (30 s leases) |
CAF_LIMITS |
var, optional | JSON that overrides the numbers below |
BASE_PATH |
var, optional | a path prefix every route is served beneath (for example, /fabric); empty, the default, serves from the root. A request outside the prefix is 404 not_found |
TRUST_PROXY |
var, optional | 1 takes the caller's network from the first X-Forwarded-For address (the self-hosted relay's --trust-proxy); for test rigs only, never production, where CF-Connecting-IP is the one truth |
wrangler.toml declares all of them and the migration v1 that creates the four classes as
SQLite-backed; migration v2 adds the link classes, v3-raft-members adds the three-anchor
namespace and v4-machines adds MachineDO. A deployment's own configuration must carry the RAFT_MEMBERS binding, the
v3-raft-members migration, the src/raft-worker.js entry and the build command that
wrangler.toml declares; the first authenticated use of an existing identity then upgrades it in place, and that upgrade cannot
be rolled back to the single-object writer.
The frozen source refuses writes even from older Worker code. Its directory
requests return HTTP 500 internal, with the storage error
codeaf: directory upgraded; use a current relay build in the Worker log.
Running an older relay after the upgrade is not supported. Restore current code
with the same complete storage to resume; the original directory and encrypted
frames remain retained.
codeaf serve registers at /v1/engine and codeaf chat --at <name> dials at /v1/dial/<name>, the
two doors of the self-hosted codeaf relay (internal/relay). A Worker answers only WebSocket
upgrades, so both doors take a WebSocket offering the aforge-relay/1 subprotocol and answer anything
else with 426, which is what tells a client to use the WebSocket. Over it run the same bytes as
over the self-hosted relay's raw upgrade: the registration proof, then the carrier's frames, one frame
to a message (src/engine/carrier.js, a port of handshake.go and mux.go).
Each machine name has one MachineDO (src/engine/machine.js). It checks that the name is the one
the machine's key derives and that the machine holds that key, then holds the machine's socket and
splices each dialled socket onto it as one stream. It reads no payload. Every socket hibernates, and
the routing lives in socket tags and attachments, so a quiet machine costs nothing: the machine
proves the socket alive with WebSocket pings, which the platform answers without waking the object.
A second registration under a live name is refused; one with the same public key under a name whose
socket stopped answering (a pong is asked for and not given within 5 s) replaces it, which is how a
machine whose network dropped comes back before the platform has closed its old socket. A colliding
key never replaces an existing registration, silent or live.
A deployment can share a hostname by using a path prefix, such as
https://relay.example.com/fabric. BASE_PATH = "/fabric" makes the Worker serve every route
(store, directory, the /v1/dir/watch upgrade, pairing, link requests, rotation) beneath that prefix.
Prefixes may have several segments, such as /my-relay or /a/relay.
src/base.js normalizes the configured prefix and strips it once before dispatch; a
request outside it is the usual 404.
The request signature covers the path the client sent (internal/reqsign signs RequestURI()), so a
client signs /fabric/v1/... and the identity object checks the signature against the prefix put back
(signedUri), not against the stripped path. A client that signed the bare path is 401. Every Go
client joins its routes to the relay address in one helper, wireauth.Endpoint, so an address with a
path (https://host/fabric, with or without the last slash) works for all of them.
The committed request retains both the original signed URI and the configured prefix,
so every member interprets the same operation even when a joined computer leads.
The Go relay (internal/relayserve) stays at the root of its host: it has no base path. A self-hoster
who wants one puts their own proxy in front, and the proxy must pass the path on unchanged (the client
signed it), or serve the relay on a host of its own.
Copy wrangler.toml to a config of your own and choose a Worker name and R2 bucket name.
Create the bucket and deploy from relay/hosted:
cp wrangler.toml wrangler.custom.toml
# Edit wrangler.custom.toml to match the bucket name below.
npx wrangler r2 bucket create your-relay-frames
npx wrangler deploy --config wrangler.custom.toml| Limit | Default | Refusal |
|---|---|---|
| stored bytes per identity | 5 GiB | 507 full |
rotation grace: min / max / default (minGraceMs, maxGraceMs, defaultGraceMs; tests lower the min) |
1 h / 30 d / 7 d | 400 bad_grace |
| stored objects per identity (the index is SQLite rows; the byte cap rules) | 5,000,000 | 507 full |
new identities per network per day (first sight only; existing identities never counted; a network is an IPv4 address or an IPv6 /64, see src/network.js) |
200 | 429 too_many_identities, Retry-After (seconds until that network's day ends, also retry_after in the body) |
| new frames per identity per day | 5,000 | 507 full |
| requests per minute, per identity / per device | 1,200 / 600 | 429 rate_limited, Retry-After |
| puts in flight per identity (a 16 MiB frame costs 2 to 3 times its size in memory) | 2 | 429 rate_limited |
| frame body | 16 MiB (MaxFrame) |
413 bad_frame, streamed or declared |
| any other body | 1 MiB | 413 too_large (directory), 413 bad_frame (store) |
| pairing: TTL, message, messages per side | 10 min, 4 KiB, 4 | 404, 413, 409 full |
| pairing: creates per hour, writes per minute, open polls, per IP | 10, 30, 4 | 429 rate_limited, Retry-After |
| pairing: live mailboxes in all (32 KiB each, so also 64 MiB) | 2,000 | 503 full |
| link requests: TTL, create body, grant | 10 min, 1 KiB, 4 KiB | 404 gone, 413 too_big |
| link requests: creates an hour, pending at once, per IP | 10, 3 | 429 rate_limited, Retry-After |
| link requests: reads a minute, misses a minute, open polls, per IP | 60, 20, 4 | 429 rate_limited |
| link requests: live in all | 2,000 | 503 full |
machine doors: surfaces on one machine at once (MAX_STREAMS) |
16 | 429 |
machine doors: dials and registrations a minute, per network at the Worker front (MACHINE_DIAL_LIMIT, MACHINE_REGISTER_LIMIT) and per machine name in memory |
30, 10 | 429 |
Stored quotas (bytes, objects, frames a day), the stats counts and the per-IP counters live in the identity object's SQLite and survive eviction. A frame the store already holds costs no quota, so a client that resends is never refused for it. The per-minute rates are kept in memory: an eviction can only forgive a burst.
CAF_LIMITS optionally sets globalNewIdentitiesPerDay and globalStoreBytes.
Both are unlimited by default. Set either to a finite number to enable that limit;
0 closes new admission or storage respectively. Per-identity and per-network limits
still apply.
A new identity over the daily admission limit gets 429 relay_admission_full with
Retry-After. New identities at the storage limit, and new frames that would exceed
it, get 507 relay_storage_full. Existing data stays readable, held frames can be
retried, and identity erasure remains available.
Every frame is under an identity prefix. Its owner can retire the identity through the
signed rotation route (normally codeaf identity rotate); after the grace period, an
alarm writes a tombstone, deletes the prefix a page at a time, releases its global capacity,
and drops its tables. Pairing and link data have bounded TTLs. There is no automatic
inactive-identity erasure: it is off, with no retention knob in this version. A device's
hibernating socket can stay live without requests, so a request-only last-seen timer would
be unsafe. If all identity keys are lost, owner erasure cannot be authenticated; the
operator's documented admission/storage brakes still bound new stored data.
npm test runs through test/local.sh: temporary config, cache, registry and log paths,
metrics and update prompts off, no dependency on a writable home. Use that wrapper around
local Wrangler commands too. The project .npmrc keeps npm's own installation cache and
logs below the ignored .wrangler/npm-cache directory, so npm ci && npm test also
works with a read-only home. npm ci installs only the lockfile's dependencies.