Everything about working on flue, in one place: the daily loop, how dev and an installed flue coexist on one machine, and the three ways to work on the relay. The README covers what flue is; this covers how to change it.
mise install # go, node, pnpm, pinned in mise.tomlUse pnpm, never npm. The web workspace pins it, and CI runs the exact versions mise installs.
Two terminals:
make run # terminal 1: the dev daemon on 127.0.0.1:7719
make web-dev # terminal 2: Vite with hot reload on 127.0.0.1:5173make run compiles with the dev build tag: the embedded UI becomes a
redirect to Vite, the embedded relay Worker is left out entirely, and no
web or relay build is needed. It opens the browser itself via a one-time
link that plants the auth cookie. Stick to 127.0.0.1, not localhost;
the cookie is set for that host exactly.
A production-like run, with the real embedded UI, is:
make build && bin/flue serveThe dev daemon is deliberately kept apart from an installed flue. Nothing about one can clobber the other:
| installed flue | dev loop (make run) |
|
|---|---|---|
| port | 7717 | 7719 |
| config dir | ~/.config/flue |
~/.config/flue-dev |
| cookie | flue_token_7717 |
flue_token_7719 |
| relay worker | flue-relay |
flue-relay-dev (yours to choose) |
The config dir holds the auth token, runtime.json and relay.json, so
the separation is what stops the two daemons fighting over discovery and
stops a dev relay join from rewriting the installed relay's config. The
cookie name carries the port because cookies are blind to it; without that,
logging into one UI would log you out of the other.
FLUE_DEV_PORT and FLUE_DEV_CONFIG in the Makefile move both knobs.
Any flue command aimed at the dev daemon needs the dev environment, or it talks to the installed one:
XDG_CONFIG_HOME=~/.config/flue-dev go run -tags dev ./cmd/flue statusmake test # all three suites: Go, web, relay Worker
make test-go # builds web/dist and relay/dist first; //go:embed needs both
make test-web
make test-relay
make lint # go vet (with and without the dev tag) + typecheck each package
make e2e # the end-to-end suite; not part of make test, not in CIA bare go build ./cmd/flue or go test ./... fails until web/dist and
relay/dist exist. make build sequences all of it.
make e2e is described under the end-to-end suite
below. It is deliberately out of make test and out of CI: it starts
workerd and two daemons, and a gate that goes red because a port was busy
teaches people to ignore gates.
Three tiers. Use the cheapest one that answers your question.
cd relay && pnpm test # vitest, workers pool, in-memory Durable ObjectThis is the loop for changing relay/src. No network, no account.
cd relay
echo "DAEMON_SECRET=$(openssl rand -hex 32)" > .dev.vars # gitignored; never commit
cd ../web && pnpm build
cd ../relay && pnpm dev --assets ../web/dist # ws://127.0.0.1:8787Generate the secret; do not invent one. The moment a tunnel points at this Worker it is on the public internet, and the secret is the whole credential for the daemon leg.
Two things make this tier awkward, both by design elsewhere:
- The web app decides daemon-vs-relay by hostname, and loopback means
daemon. To see relay mode against a local Worker the browser needs a
non-loopback name for it: an
/etc/hostsalias, or a tunnel. - Quick tunnels (
cloudflared tunnel --url ...) mint a new hostname every run (re-join, re-pair each time) and are sometimes provisioned IPv6-only, which a v4-only network cannot reach at all. A named tunnel on your own zone avoids both; its config lives in~/.cloudflared, never in this repo.
The daemon joins a tunnel like any relay:
XDG_CONFIG_HOME=~/.config/flue-dev go run -tags dev ./cmd/flue relay join \
wss://<tunnel-host> --secret <the .dev.vars value> \
--fleet "$(head -c 32 /dev/urandom | base64 | tr '+/' '-_' | tr -d '=')"--fleet is required and this tier never runs flue relay setup, so there
is no printed line to copy one from — mint it yourself, as above. That works
because the fleet key is just 32 random bytes in unpadded base64url and the
Worker never holds it (spec/fleet-trust.md):
nothing at the relay has to agree with the value, only the other machines in
the same dev fleet do. So keep it if you join a second dev machine — two
machines holding different fleet keys will not honour each other's device
certificates — and do not paste in the one a real relay printed.
The recommended way to exercise the real thing: deploy a relay into your own Cloudflare account under a dev name, separate from any relay an installed flue uses.
make build
XDG_CONFIG_HOME=~/.config/flue-dev bin/flue relay setup --worker flue-relay-dev
# restart make run; the dev daemon picks up relay.json and dialsThe Remote screen offers the same deploy as a card (token field, Deploy
button) against the daemon's /api/relay/* endpoints, same code, via
internal/relaydeploy. Note the dev-loop wrinkle: make run is a dev
build, which embeds no Worker (there are no bytes it could deploy) so the
card says it cannot. When you want to deploy from the card (or exercise the
relay path at all):
make run-release # build a release binary, run it on the dev port and configmake web-dev proxies to it the same as to make run, so UI hot reload
keeps working; the embedded UI at 127.0.0.1:7719 is the production-like
view of the same daemon.
Then pair a browser: Vite UI → Devices → Pair, open the QR link on the
phone. The link carries the relay's origin, so the phone talks to
flue-relay-dev.<sub>.workers.dev and never to your laptop directly.
Notes that save a bad afternoon:
- Setup needs a release binary. A dev build carries no Worker and refuses before asking for a token.
- Setup mints the secret itself, 256 bits from
crypto/rand. There is no dev-secret to choose and none to commit. - Do not re-run setup to update a relay. Every setup run deliberately mints a fresh secret and machine id (it is the recovery path), which forces every other machine to re-join and every browser to re-pair.
- To ship changed relay or web code to a deployed relay, use:
make build
XDG_CONFIG_HOME=~/.config/flue-dev bin/flue relay updateupdate redeploys the Worker and the web bundle this binary embeds over
the worker relay.json records, and changes nothing else: same secret
(the deploy preserves the binding), same machine ids, no re-pairing.
Daemons and browsers reconnect on their own.
The --worker name is the whole separation between dev and prod relays:
one account can hold both flue-relay (the default an installed flue
deploys) and flue-relay-dev, each with its own workers.dev hostname,
secret and hubs. Breaking the dev one cannot touch the real one.
make e2e # ~30 s cold, ~8 s warm; no account, no cost, no cleanupThe tier the other three cannot be: two real flue daemons, each with its own
config directory and port, joined to the real relay Worker running under
workerd, driven by the browser's own modules — fleet/enrol.ts,
relay/directory.ts, crypto/cert.ts, relay/socket.ts, fleet/fleet.ts
— imported from web/src and called the way the app calls them. It lives in
web/e2e; the header comment on fleet.e2e.ts says what each
assertion is for.
It exists because every unit suite in this repo stubs the seam between two components, and the last several bugs all lived in a seam: a fleet key read at boot on one side and live on the other, a directory route no browser could reach, a loopback tab with no fleet identity, a certificate published where a commit message said it no longer was. Each component was right about itself and wrong about its neighbour.
What it drives, in order: a daemon serving with no relay; that daemon joining one while it is already running; a browser pairing over the relay's origin; a second machine joining with nothing else done; and then each machine's own tab listing the other's sessions, input reaching a shell on the far machine, and a revoke on one machine killing the device on the other.
Three things it needs, and how it fails if they are missing:
opensslonPATH— it mints a throwaway certificate for the local relay, becauseflue relay joinrefuses anything butwss:///https://and that refusal is worth keeping. macOS's LibreSSL and Ubuntu's OpenSSL both do.- Ports 8788, 7791 and 7792 free — checked up front, so a busy port is the first line of the failure rather than a timeout twenty seconds in.
bin/flue-e2e—make e2ebuilds it. It is the ordinary binary pluscmd/flue/e2etrust.go, which is behind-tags e2eand adds exactly one root certificate authority, the one the harness just minted. No release build carries it;.goreleaser.yamlsets no tags.
There is no browser in it, and two facts are browser-enforced: CORS and CSP.
Those are asserted as the headers a browser would decide from — the relay's
absent Access-Control-Allow-Origin on GET /directory, the daemon's
connect-src — against live servers rather than fixtures. That pins the input
to the decision and not the decision.
The relay Worker and the web bundle are embedded in the flue binary; a relay's version is the version of the flue that deployed it. There is no separate relay package, deliberately: a second artifact would reintroduce the version skew the embedding exists to kill. The upgrade path is:
brew upgrade flue # or the installer
flue relay update # redeploys this release's relay; secret and pairings keptThe relay's automated tests all run against fakes. Before any release that
touches the relay, a human walks the end-to-end checklist in
RELAY.md: a real account, a phone on cellular, a kill -9
recovery, the /client/<id> isolation check.