Imported from NiKrause/libp2p-webrtc-qr (
AGENTS.md). Install upstream withnpx skills add NiKrause/libp2p-webrtc-qr. Copyright stays with the author.
Notes for agents
What this project has learned the hard way, ordered by how badly it hurts to rediscover it. Read the top three before changing anything in the connect path.
README.md explains what the project is; ROADMAP.md explains where it is
going. This file is neither: it is the set of facts that are expensive to learn
twice, and the questions worth asking before writing code.
1. Mobile browsers kill a waiting invite within seconds
The single most consequential fact here, and the reason several features exist.
A browser closes an RTCPeerConnection when it suspends the page — see
w3c/webrtc-pc#2489, where Chrome
did it without firing any event. On a phone, switching to a messenger to
paste an invite link is exactly that. Field testing on two Android phones: you
have a couple of seconds before the invite is dead. Only DuckDuckGo and
Safari have been seen to hold one for around ten.
Consequences already built on this:
leavingSuspendsUs()inexamples/demo/index.js— the check is named for what matters, not for "is this a phone"- the
#hurry-backhint, shown at the tap that sends someone away, because a warning on the way back is a post-mortem - the
.pc-healthreadout, which records the connection's state on the way out and on the way back — nobody can watch a screen that is in the background BROWSERS_THAT_HOLD, the single place to change when this stops being truecreateKeepAlive()in the package, which keeps the page playing audio so the browser does not suspend it in the first place. Audible by default: a stream the browser judges inaudible stops counting as playback, so silence is the failure mode rather than the polite choice — and the media notification it earns is a labelled way back into the app, which is the other half of the problem. Verified as a mechanism only; whether it survives a real app switch is a claim about Android that two phones and a messenger have to settle.
Open, and it decides how consumer apps build their share flow. Whether the
keep-alive actually holds a connection through an app switch is a claim about
Android that no test here can make. createKeepAlive() is verified as a
mechanism — the audio graph starts, loops and is released — and that is all.
The experiment that settles it, and it is cheap:
- Produce an invite on phone A.
- Switch to a messenger, paste it, come back.
- Time how long before the connection is gone — with the keep-alive running and without.
The demo starts the audio in the gesture that takes the link away — the share button and the Copy button — rather than in the one that creates the invite. That is the honest place for it, because showing a code to a camera never leaves the app and a minute of opera to do it is nobody's idea of help. It also makes this experiment sharper and the margin thinner: the audio now starts in the same gesture that hands the screen to a messenger, so if the answer below turns out to be "it does not survive", the first thing to try is arming it earlier again. 4. Repeat per browser. Vanadium on GrapheneOS and Chrome are the ones that fail today; DuckDuckGo and Safari already hold for around ten seconds, so they say least about whether this helps.
Write the answer here, in place of this block, and update
BROWSERS_THAT_HOLD if the numbers move. A result that lives in a pull request
comment is a result the next person re-derives. If it turns out not to help,
that is worth recording just as much — it would mean the round trip has to get
shorter instead, which is the question below rather than this one.
Ask before you build: is the feature you are adding making the round trip longer? Item 0 on the roadmap (a handshake inside a photograph) is behind this question, not in front of it.
Do not assume a wake lock helps: it holds the screen, not the page, and the browser drops it the moment the page is hidden.
2. Never trust one browser's word about the network
The same phone on the same Wi-Fi reports IPv6: usable in DuckDuckGo and
Firefox and IPv6: none in Chrome as an installed PWA. Whether an IPv6
reflexive candidate appears depends on the browser build and its WebRTC
IP-handling policy as much as on the route.
So: a readiness verdict is an observation about this browser, not a verdict on
the network. The wording in network.js was corrected once for exactly this —
it used to say "this network offers IPv4 only", which two screenshots disproved.
The judgements live in packages/webrtc-qr/src/elements/network.js and are
exported from ./elements:
| function | answers |
|---|---|
probeNetwork() |
what this browser can gather, per address family |
summariseNetwork() |
can I reach anyone from here |
offNetworkRisk() |
'blocked' | 'unreliable' | null — use this to gate a connect control |
offNetworkBlocked() |
narrow: blocked only |
unreliable is the 5G case — carrier NAT on IPv4, no IPv6 — which is far more
common than blocked and was silently unwarned until it was reported from a
real device.
Ask before you build: does your feature need a reliable path off the
network? If so, offNetworkRisk() is the gate, and TURN (#11) or a relay (#23)
is the answer, not a retry.
3. The barrel is the API
package.json exports only "." and "./elements". A function that exists in
a module but is not re-exported from the right barrel cannot be imported by a
consumer at all — deep imports are not a fallback.
This is not hypothetical: offNetworkBlocked shipped, rendered an alarm, was
announced as the way for an application to gate its connect button, and was
absent from elements/index.js. test/exports.test.js now asserts the surface.
Ask before you build: is this meant for yogasūcī or another consumer? Then export it and add it to that test. Note the barrel needs two DOM stubs to be importable in Node — that is why nothing checked it before.
4. Anything visible must be translatable
Every element takes a strings property that is merged over its defaults, so
a consumer replacing three labels does not lose the rest. See
elements/strings.js. QR_STATUS_STRINGS, QR_SCANNER_STRINGS,
QR_INVITE_STRINGS, QR_PEERS_STRINGS are all exported.
New user-visible text goes in the strings object, never inline. The first outside consumer needed the labels in another language before it needed anything else.
5. Tests: what is cheap, what is a trap
?ice=hostloads the app with no STUN servers, so ICE gathering is instant. Use it for anything not about candidate discovery. Forgetting it is what turned a 3-minute suite into 12 and made CI red — see the commit "Gather host candidates only in the two new specs".- The invite link strips its query (
url.search = ''), so a test that arrives via a link has to put?ice=hostback. { iceServers: [] }makes theblockedverdict deterministic without a hostile network.- Don't poll for something transient. Read it synchronously: the probe bar is
set before the first
await, so a locator poll can miss it entirely on a fast engine. Same for the progress steps — aMutationObserverinstalled viaaddInitScriptsees every value, a 20ms sampler does not. - CI runs one worker with two retries. A test that needed a retry is reported as flaky, not passed, so the instability stays visible. A test that needs them regularly is a bug report, not a reason to raise the number.
- The bitswap file-transfer test flakes in Firefox and WebKit on CI, never twice the same way, and passes alone. Check that before blaming your change.
6. Deliberate decisions, so they are not undone by reflex
- No service worker. Chrome dropped the requirement in 108 (mobile) / 112
(desktop); caching a libp2p stack is how you ship a version nobody can update.
Asserted in
installable.spec.js. - Identity:
sessionStoragein a tab,localStoragewhen installed. Two tabs must be two peers — the whole two-tab flow depends on it — but an installed app has no second tab, and a fresh key every launch means nothing can recognise you. Reset clears both. - Nothing is disabled on a bad verdict. A symmetric NAT still connects peers on the same network, and hiding the controls would block something that works.
- The answer follows the offer's format, not the answering peer's preference: a peer that sent v2 cannot read a v3 answer.
- Warnings only where they are true. A desktop never renders the mobile hints at all, so a screen reader cannot find advice that is false there. A warning shown everywhere is a warning worth ignoring.
7. Things that look like bugs and are not
- Two windows tinted alike — browser detection is best-effort;
navigator.brave.isBrave()is the only non-string signal, so Brave is checked first. DuckDuckGo does name itself on both Android and iOS. Result: local onlywith no alarm — correct beforeoffNetworkRiskexisted, wrong after. If you see it now, that is a regression.- The remote handover workflow costs Aleph credits per run and provisions a fresh VM. It went red once on a different CRN — see #76. One run is not evidence either way.
Before you ask for a review, check
- Does it still work when the page is backgrounded for ten seconds?
- Does the verdict it shows describe this browser or claim something about the network?
- Is every new judgement exported from the barrel, and every new string translatable?
- Did you use
?ice=hostwhere candidate discovery is not the point? - If you changed the connect path, did you run the full suite across all three
engines, and read the whole summary rather than
tail -4?
Connecting: relay-optional by construction
Measured on 2026-08-21, written down because the wrong version of it was in the code for months. Tracking issue: relay-button#119.
The promise
The node stays fully functional without a relay. That is a guarantee, not a default: the checkbox is off, and no relay and no directory is contacted without an explicit choice.
It is not silence, though, and this paragraph once said it was. Gathering
candidates asks the STUN servers in DEFAULT_RTC_CONFIGURATION - Cloudflare's
and Google's - for this device's public address, on every invite, every reply
and every network probe. That is what lets two networks meet without a relay;
docs-site/docs/security.md says what it costs. An app whose promise is that a
room leaves metadata nowhere passes { iceServers: [] } and stays on the local
network.
A relay is a second way in, for the case the QR path cannot serve: the other person is not here to scan anything. It is added, never substituted.
A relay has to be asked for, and then checked
Ticking the box starts the check immediately, so the answer is measured rather than assumed. Order matters and is not only about speed:
- the baked-in addresses, probed by ping
- only if none answer, Aleph discovery
That way the app talks to Aleph exactly when the known relays are silent, which is what keeps the metadata footprint small.
Which relay can do what
A circuit relay brokers the connection; the data then flows directly between
devices — measured at 1.6 s, with the relay used only for signalling. So the
2 min / 128 KB limits in go-peer's relayv2.DefaultResources() never bite for
connecting, and would for replication.
The real dividing line is not transport, it is discovery:
- A peer you already know — from a scanned QR code — needs only a route. Any
circuit relay does,
uc-go-peerincluded. - A peer you have to find needs the relay in the mesh of your gossipsub
discovery topic. A gossipsub node that has not subscribed to a topic does not
forward its payloads.
uc-go-peersubscribes touniversal-connectivity-browser-peer-discovery— aconstingo-peer/chatroom.go, not a flag. - Data that should be pinned needs a relay that stores something.
uc-go-peerstores nothing; onlyorbitdb-relayqualifies.
This is why a uc-go-peer left two simple-todo browsers at candidates: 0. Not
because it cannot form a circuit — it can, reservation in 1.5 s — but because it
was not on their discovery topic. Apps whose topics match it, or which also
subscribe to it, can use it among themselves.
Do not
- Bake a relay address in and call the result server-free.
- Report "usable network" from any ICE candidate: every device has host candidates. Only reflexive ones say anything beyond this network answers.
- Probe several addresses of the same relay at once. libp2p muxes them onto one connection and the second ping fails with a stream-limit error that is evidence for reachability, not against it.
Where this lands in webrtc-qr
qr-intro is the shared home for this. Its docstring already argues the
case — the caveats belong in one element rather than a paragraph in each
app — and the relay half belongs there too: two ways in, the checkbox, and
the result of the check. It stays off unless a consumer passes relay
configuration, so a QR-only app is unaffected.
probeNetwork and summariseNetwork live in src/elements/network.js and
are not exported from the package entry, so consumers reach them by
instantiating qr-status and listening for its probe event — simple-todo
does exactly that, and says so in a comment. The module is DOM-free, so the
export is one line.