@vzn/vx-dte-server
Website: https://vznjs.github.io/vx/ · source: https://github.com/vznjs/vx
The reference relay for @vzn/vx-dte, which spreads one vx run over CI
workers. CI runners take no inbound connection, so the run’s orchestrator
and its workers all dial out to a relay, which passes their messages on.
It admits only CI OIDC tokens of the repositories it names, and binds
each run to the repository that started it.
The relay is one package whose one runtime dependency is jose, which checks
the CI tokens; not even vx itself. It speaks the
Switchboard protocol (switchboard.v1): one host, the
run’s orchestrator, and its peers, the workers, behind one relay. Any
server that speaks it can take its place, and any tool can use it.
Run it
Section titled “Run it”On Cloudflare Workers
Section titled “On Cloudflare Workers”The button copies this directory into a new repository of yours and
deploys it. It asks for one var, ALLOW: who may take part, space
separated, each issuer:owner/repo:workflow. It is not a secret: keep it
in your copy’s wrangler.jsonc, since each deploy sets vars from that file.
https://token.actions.githubusercontent.com:vznjs/vx:ci.yml admits the
jobs of .github/workflows/ci.yml in vznjs/vx;
https://gitlab.com:group/project:.gitlab-ci.yml a GitLab project’s.
Repeat an entry for each repository and workflow, the orchestrator’s and
the workers’. Every other token is refused. The relay’s URL is the Cloudflare Worker’s URL, such
as https://vx-dte-server.<you>.workers.dev. Name it as relay in
dte().
Or deploy from this directory with Cloudflare’s
wrangler:
bun installbunx wrangler loginbunx wrangler deploySet ALLOW in wrangler.jsonc’s vars first.
src/worker.ts is the same relay as the one on Bun. Each run is one
Durable Object, so its sockets meet in one place. Participants hold
websockets, which the object hibernates between messages: the run is kept
in their attachments, and the object writes no storage. It sets no alarm and counts no heartbeat: a
socket’s close loses its participant, and a peer quiet on a socket that
never closed is its peers’ to lose. Issuers’ keys
are read once per isolate and held ten minutes in the data center’s
cache, so a run’s object does not fetch them again; a token naming a key
they lack is checked against a fresh read.
It fits Workers’ free plan: each participant joins on the socket it
opens, one request, and its websocket messages count 20 to one Durable
Object request, against 100 000 a day; it writes no row.
wrangler.jsonc sets the rest; change a value there, in the repository
the button made, or in the Worker’s settings in the Cloudflare dashboard:
AUDIENCE, unset, is the URL the relay is reached at. Set it only when tokens name another one. Blanks around it are dropped, and a blank one is refused, as on Bun.
On Bun
Section titled “On Bun”ALLOW=https://gitlab.com:group/app:.gitlab-ci.yml AUDIENCE=https://relay.example.com \bunx @vzn/vx-dte-server --port 8080Or as a container:
docker run -p 8080:8080 -e ALLOW=https://gitlab.com:group/app:.gitlab-ci.yml \ -e AUDIENCE=https://relay.example.com ghcr.io/vznjs/vx-dte-serverAll vx servers at once, this relay with its state on a volume included:
private/vx-servers/compose.yaml
(docker compose up).
ALLOWis the same list as on Workers (above), read from the environment as every vx server reads it. GitLab names a project by its full path,group/subgroup/project.AUDIENCEis theaudevery token must name: the URL the CI job asks its ID token for.--port(default 8080) and--host(default0.0.0.0) set where it listens.--no-authadmits every request. Use it only to try@vzn/vx-dtelocally: it listens on loopback only.
CI builds this image and carries one run through it (test.live).
Runs live in memory. A restarted relay forgets the runs it was carrying, and vx-dte joins each again. Participants hold websockets here too, so a message reaches its receiver the moment it is sent.
Each participant is one socket and one file descriptor. Bun raises the
open-file limit to the container’s hard limit (Docker’s is 524288 or
more), so it never needs a --ulimit. Measured on 4 cores (2026-10-24):
10,000 sockets over 100 runs take 41 MB over an idle relay (about 4 KB
each) and 6% of a core idle; with each worker sending one message a
second (10,000 a second) 43% of a core, every message delivered.
Environment
Section titled “Environment”RELAY: on Workers, theRelayRunDurable Object binding (wrangler.jsonc); the relay refuses every request without it.NO_COLOR, thenFORCE_COLOR, then a terminal whoseTERMis not dumb: whether the Bun relay’s status lines print in colour.
The same relay as a fetch handler, to mount in a server of your own
(src/index.ts):
createRelay(options?: RelayOptions): Relay.RelayOptionsis{ verify?, heartbeatMs?, graceMs? }:verifychecks a request’s bearer token (none admits every request) and gets the request too,heartbeatMs(default 2000) is how often participants hear from each other, said in each join’s answer, andgraceMs(default 10 000) how long a participant whose socket dropped withoutleavemay dial again before it is gone (PROTOCOL.md § Liveness). ARelayis{ fetch(req) }, plus the hooks a host of websockets drives (src/worker.tsis their user): the optionssockets, the[Kept, RelaySocket]pairs a host kept across a restart, from which the sessions are rebuilt (PROTOCOL.md § What the relay knows), andupgrade, which answers asocketrequest; and the methodsattachanddetach(its socket closed: the participant is gone unless it dials again withingraceMs), which hand the relay a participant’sRelaySocket({ send, close }), andframe, which returns the answer to send down the socket, orundefinedfor none.attachreturns theKeptits host keeps with the socket (a Durable Object’s attachment: session, id, role, the host’s key, and a peer’s host’s id), orfalse. A host closes a socketattachrefuses withcloseSocket(socket, GONE)(GONEis 4404), which says the close in a frame first (PROTOCOL.md § Sockets).serveRelay(options: ServeOptions): RelayServerserves the relay onBun.serve, websockets included.ServeOptionsis{ hostname, port, verify?, heartbeatMs? }, and aRelayServeris{ url, stop() }.createProver(): Promise<Prover>makes the host’s key for one session (PROTOCOL.md § The host’s key). AProveris{ key, proof(method, path, body) }:keyis what itsjoinnames, andproofis the value of thePROOF_HEADER(relay-proof) header of each request as the host.createVerifier(options: OidcOptions)returns thatverify.OidcOptionsis{ allow, audience, runClaims?, fetchJson?, now? }: who may take part,Alloweds{ issuer, repository?, workflow? }(an unset part admits any; a token’srepositoryClaimOf(iss)andworkflowClaimOf(iss)up to its@are matched without case), theauda token must name, run claims per issuer (defaultrunClaimsOf(iss)), and a key reader and clock for tests. It resolves aVerifiedToken,{ iss, run }, or rejects with aTokenErrorcarrying thestatus(401 or 403) the relay answers.parseAllow(list)readsALLOW’s entries intoAlloweds, eachissuer:owner/repo:workflowread from the right (an issuer holds:s), or throws naming the entry it cannot read. An issuer ishttps://, or plainhttp://only to127.0.0.1, as the cache and history servers read it.runClaimsOf(iss)names the claims that bind a run:repository_idfor GitHub Actions,project_idotherwise.repositoryClaimOf(iss)names the claim that names a token’s repository:repositoryfor GitHub Actions,project_pathotherwise.workflowClaimOf(iss)names the claim that names the workflow a token’s job is defined in:job_workflow_reffor GitHub Actions,ci_config_ref_uriotherwise. The relay matches it up to its last@, before the ref or SHA.