Releases: NiKrause/libp2p-webrtc-qr
Release list
v0.6.0 — the readiness panel says what it can
Additive against 0.5.0; existing embeds are untouched.
<qr-status> picks its own rows. rows takes any subset of browser ipv4 ipv6 camera overall; the default stays ipv4 ipv6 overall, so nothing changes unless you ask for it.
<qr-status auto rows="browser ipv4 ipv6 camera overall"></qr-status>Two new probes behind it. probeBrowser builds a throwaway peer connection and a negotiated data channel — a browser that cannot do WebRTC fails before any question about NAT is meaningful. probeCamera reads the Permissions API and never calls getUserMedia: checking by trying would raise the very prompt the chip exists to report on, at page load. Where the state is not exposed (Safari) the chip is amber and says the answer shows itself only on the attempt.
Docs. docs/connection-security.md explains why skipEncryption is sound with a signed SDP, and when it is not. Two corrections in the README while there: skipEncryption skips Noise, not encryption — DTLS never stops — and oversized payloads have been animated BC-UR sequences since 0.3.0, not a copy/paste fallback.
v0.5.0 — the connect step as custom elements
The connect step, as elements you can drop into your own application.
import '@le-space/libp2p-webrtc-qr/elements'<qr-invite value="https://example/#i=…"></qr-invite>
<qr-scanner id="scan" label="Scan their code"></qr-scanner>
<qr-status auto></qr-status>
<qr-peers id="peers"></qr-peers>qr-invite |
renders a payload, split into an animated BC-UR sequence when one code would be too dense to read |
qr-scanner |
the camera, the scan loop, multi-frame reassembly, and the modal around them |
qr-status |
IPv4, IPv6 and a summary of what this network will allow, before anyone tries |
qr-peers |
who is connected, and how each connection is doing |
Custom elements rather than components in somebody's framework, because the
consumers of this package are plain JavaScript twice over and Svelte once, and a
fourth will not be any of those. Shadow DOM so a host stylesheet cannot reach in
and break a code that has to stay scannable; CSS custom properties so the host
can still say what it should look like.
The demo lost 717 lines to this and behaves the same.
Where the seams moved
Each element was extracted with a consumer attached rather than in isolation,
and every one of these was found by using it:
"sideEffects": falsedeleted the elements. A production build dropped the
customElements.definecalls, so they silently never existed. Dev-server tests
passed; built-bundle tests did not.- They could not be consumed by an application with a real build.
qrcodeand
the BC-UR stack are CommonJS and reach forBufferandprocess; an
application that already polyfills those resolved the same specifier two ways
and failed its build on a message about externals../elementsnow ships as a
single pre-bundled browser file with nothing left to resolve — and with no free
Bufferorprocessidentifier left for a polyfill plugin to rewrite, which
took a second round to learn. - The code was 406px wide on a 390px screen without
box-sizing: border-box. decode()throws on an element that has not rendered yet — WebKit reports
Missing source URL, which reads like a corrupt image rather than a race.
The transport entry point is unchanged
Importing the package root still gives the transport, the codec and QRSession
as plain source, and pulls in none of the UI — no QR encoder, no camera
decoder, no CBOR. Enforced by the exports map.
Additive against 0.4.0. Existing imports are untouched.
Verified
37 unit tests, 119 browser tests across Chromium, Firefox and WebKit, 6 Helia
transfer tests. Three consumers on this line: the demo, the Helia example, and a
SvelteKit application whose light theme is set entirely through custom
properties — which is what makes the theming claim something other than an
assertion.
v0.4.0 — QRSession, and acceptAnswer dials
The release where the package stops shipping only half the problem.
QRSession
The session state machine that drives the transport now lives in the package. It
existed three times before that: twice in this repository's examples, and once in
an unrelated project that re-derived it bug for bug — including its own
dial-retry loop with its own delay, reached the same way, by hitting the bug.
import { QRSession } from '@le-space/libp2p-webrtc-qr'
const session = new QRSession(node, { rtcConfiguration })
const offer = await session.createOffer() // show this
const { peerId, connection } = await session.acceptAnswer(reply)Plus acceptOffer, dial, dialProtocol, forget(peerId), and connect /
error events.
Three things that are not obvious from the transport API, now handled once:
- the init data channel must be negotiated, or the remote muxer adopts it as
an incoming stream and no real protocol stream ever arrives - the upgrade must wait for
connected, in the rightdirection - the first dial must be retried while the answering peer attaches its muxer
Both examples lost their copies: 336 lines from the demo, 197 from the Helia
example, with no change in behaviour.
acceptAnswer dials
Until something dials there is no libp2p connection — only a WebRTC one with an
upgrade context beside it. An app with a protocol of its own never notices,
because dialling that protocol does it. An app that uses whatever connection
exists — a replicating database, a pubsub topic — sees the handshake succeed and
no peer.
A second consumer hit this within an hour of existing. { dial: false } opts out
for callers that open a protocol stream themselves.
Two bugs fixed in shared code
getOutboundSession returns the newest session for a peer rather than the
first, so a reconnect no longer hands the transport a closed peer connection.
forget() drops the libp2p connection together with the offer session — leaving
either behind makes the next attempt fail with Remote closed connection during opening, which points nowhere near the cause.
Errors now carry an ICE summary — local: 6 host, 1 srflx; remote: …; ice: failed
— because a failure after clean signalling is almost always about candidate types.
Upgrading
From 0.3.0: QRSession is additive. Existing code that drives the transport
by hand keeps working.
From 0.1.x: the wire format changed in 0.2.0. PAYLOAD_VERSION went 1 → 2 and
the signature prefix with it, so a 0.1.x peer and a 0.4.0 peer cannot verify each
other's payloads — the mismatch surfaces as "signature is invalid", which reads
like forgery rather than a version difference. Both sides have to move together.
Verified
33 unit tests, 123 browser tests across Chromium, Firefox and WebKit, 6 Helia
transfer tests. Three consumers on this version: the demo, the Helia example, and
a Svelte 5 application.
v0.3.0 — payloads expire, multi-peer mesh, IPv6-aware network check
First release where the published package moves since 0.1.0 — and the first
where the git tag and the npm version are the same string.
Package: @le-space/libp2p-webrtc-qr@0.3.0
Signed payloads now expire. A signed offer used to be usable for as long as
the offerer's peer connection lived, so anyone who photographed a displayed code,
or kept a copy of an invite link from a chat, could replay it for that whole
window.
notBefore/notAftersit inside the canonical form, so the signature
covers them. Rewriting the window invalidates the signature rather than
extending the payload.- Ten minutes by default; pass
lifetimeMsto change it. - Two minutes of clock skew tolerated, because two devices that have never
spoken cannot be assumed to agree on the time. - The window is checked after signature verification, so a rewritten window
is reported as forgery rather than as an expiry — the more accurate of the two. - New exports:
DEFAULT_LIFETIME_MS,CLOCK_SKEW_MS. BothencodeSignedPayload
anddecodeSignedPayloadacceptnow, so tests do not have to wait.
Breaking
PAYLOAD_VERSION goes 1 → 2, and the signature prefix with it. A 0.1.0 peer
and a 0.3.0 peer cannot verify each other's payloads. That is deliberate:
adding signed fields changes what gets signed in both directions, and a version
that did not move would surface a format mismatch as "signature is invalid".
Why 0.3.0 and not 0.2.0
npm had 0.1.0, while every repo tag since — v0.1.1 through v0.2.1 — carried
package version 0.1.0, because those releases were about the demo. Checking out
the tag that matched a published version therefore gave you a package.json that
disagreed with it. Skipping a minor on a 0.x package costs nothing and fixes that
for good.
The transport, the multiaddr connection and the vendored @libp2p/webrtc
internals are untouched since 0.1.0.
Demo
None of this is published to npm, but it is live at
webrtc-qr.le-space.de.
More than two peers. Scanning is only needed for the first connection. Two
peers that have both reached a third learn about each other over the existing
links, exchange their signed payloads through it, and connect directly. The
forwarding peer cannot tamper with anything — signatures are verified end to end
— and thanks to the replay window it cannot replay anything either.
A network check before you scan anything. Three indicators — IPv4, IPv6, and
a summary that is green when either family is usable. Tap any of them for the
reasoning; a phone has no hover.
- IPv6 is reported separately because carrier-grade NAT is an IPv4 problem.
If both peers have a global IPv6 address there is no translation to defeat,
only a stateful firewall that ICE opens by itself — no relay, no TURN. - STUN now also runs against IPv6 literals. A reflexive candidate exists only
for an address family a STUN transaction actually used, and the IPv6 host
candidate is hidden behind mDNS like every other host candidate. When a
resolver returns A but no AAAA for the STUN hostnames, a machine with working
IPv6 gathered no IPv6 candidate at all — and the peers never exchanged the
addresses that would have beaten carrier NAT without a relay. - A bug in the previous check is fixed. Every engine masks the base behind a
reflexive candidate asraddr 0.0.0.0 rport 0, sorelatedPortis always0
and nevernull. Keying by it put IPv4 and IPv6 in one bucket, whose ports of
course differ — and an ordinary cone NAT was labelled symmetric.
The page now says who sees your address. The check asks Cloudflare and
Google what address the request arrived from; over IPv4 that is your router's,
over IPv6 this device's own. The disclosure sits above the button, so it is
readable while the choice to press it is still open.
Invite links, and a reply that finds its way back. The QR encodes a link, so
scanning with the system camera opens the demo instead of showing raw text. A
reply opened in a second tab reports back to the tab that is holding the
connection.
Verified
24 unit tests, 96 browser tests across Chromium, Firefox and WebKit, and 6 Helia
transfer tests. npm pack: 11 files, 14.3 kB.
Not yet confirmed by hand: laptop on wifi to phone on mobile data. That is the
case the IPv6 work exists for.
v0.2.1 — folded steps, Enter to send
A demo-only release. The published package is unchanged — @le-space/libp2p-webrtc-qr@0.1.0 remains current on npm.
Live: https://webrtc-qr.le-space.de
The setup steps fold away once connected
Steps 1 and 2 are finished business the moment a connection exists, and on a phone they pushed the only useful part — sending things — below the fold. They now collapse to their headings as soon as a chat stream attaches, and the message field takes focus.
Folded, not gone. The heading stays, reads "done — tap to reopen", and brings the step back for anyone who wants to connect to someone else. It is keyboard-operable and reports its state through aria-expanded. If the stream closes, the steps unfold by themselves — connecting is then the only thing left to do.
Enter sends
Next to a single-line field with a Send button, pressing Enter is what everyone tries first, and nothing happened.
Fixes a banner that was visible on every page load
display: flex on the handoff banner overrode the user agent's [hidden] { display: none }, so an empty box with a status dot sat above the page from the first paint. It shipped in v0.2.0 and was live until now.
It was found by screenshotting the collapsed state rather than trusting a green suite: all 72 tests passed while the bug was plainly on screen, because nothing had ever asserted the banner's resting state. A spec does now.
Tests
72: 18 unit, 48 browser across three real engines (2 skipped on Linux WebKit, which has no WebRTC), 6 Helia. Three specs are new — the fold-and-reopen cycle, Enter-to-send, and the banner's resting state.
One existing spec changed: the QR decode assertion is polled rather than read once. Decoding a dense code off a canvas occasionally catches a frame that has not finished painting, which surfaced as a one-off WebKit failure that would not reproduce in isolation — it passed 3/3 alone. Polling does not weaken it; a QR with the wrong contents still never matches.
Full changes in #19.
v0.2.0 — invite links
The connect step is rebuilt. The published package is unchanged — @le-space/libp2p-webrtc-qr@0.1.0 remains current on npm, and no file under packages/webrtc-qr was touched.
Live: https://webrtc-qr.le-space.de
Why
A field test with someone unfamiliar with the process, over Telegram, never produced a connection. It produced seven distinct dead ends instead:
- They could not find the input — "But where is the qr payload textarea?" The label read
QR PAYLOAD OR COPY/PASTE FALLBACK: jargon, and "fallback" reads as side path when for a remote pair it is the only path. - They sent their Peer ID twice instead of the payload. Not carelessness — the Peer ID was the prominent, labelled, copyable-looking string on the page.
- They gave up on the copy button and selected page text by hand, sending the log and the page footer along with it.
- The same box was output and input: they had to be told to delete their own payload before pasting.
- Both sides drifted out of step, ending in
Called in wrong state: stable. - Errors were raw WebRTC exceptions that say nothing about what to do.
- 27 minutes passed between invite and reply. The offer was long dead, and nothing had ever said an invite has a shelf life.
What changed
The payload travels as a link. Create an invite, send it with one tap through the system share sheet, and opening it does the rest — the page starts, verifies the invite and produces a reply link by itself.
Dead ends 1–6 do not get better error messages; they stop existing. There is no text area to find, no role to know, nothing to clear, and only one string that could be the right one.
The QR encodes the link, so a phone's own camera app opens the page with everything loaded. Cost: 29–37 characters over the raw payload — 1096 in production with STUN, against a 2200 budget. The payload rides in the URL fragment, which browsers never send to a server.
Dead end 7 cannot be designed away — an invite goes stale as its ICE candidates expire. The page says how long it stays fresh, marks it when it is not, and offers a new one.
Also: pasting connects without a second click; links wrapped by a chat app are un-wrapped before parsing; raw WebRTC errors are translated into instructions; and the Peer ID is demoted into a collapsed block that says outright it is not the thing to send.
Two bugs the field found
The answering peer was killing its own connection. After replying it waited CONNECTION_TIMEOUT — 30 seconds — for the connection to come up, then closed the peer connection. Thirty seconds is right for two phones in one room and hopelessly short over a messenger. By the time the invite creator opened the reply, the other side was already gone. That wait is now six minutes.
A reply opened in a second tab said nothing. Tapping a reply link in a messenger opens a fresh tab, which can never finish the handshake — the pending connection lives in the tab that made the invite. It hands the reply over via BroadcastChannel, but the tab in front of the user then showed nothing for it. A banner above everything now reports the outcome: "Connected in your other tab. You can close this one."
Correction: cross-engine coverage was not what #16 claimed
The three Playwright projects were not running three engines. A leftover browserName: 'chromium' in the shared use block overrode every project, so all three launched Chromium, with the firefox and webkit projects merely wearing the user-agent strings from their device descriptors. The evidence given in #16 — navigator.userAgent — was exactly the value being spoofed.
Verified now with browser.browserType().name(), which cannot be spoofed. Once the real engines started, Firefox could not launch in the container at all until HOME was set — an error that had never appeared, because Firefox had never run.
And a real gap surfaced: Playwright's WebKit build for Linux has no working WebRTC. WebKit fails exactly the four specs that need a peer connection and passes everything else. Those four now skip on Linux WebKit with that reason attached, rather than dropping WebKit or hiding the gap.
So, precisely:
| Engine | Verified |
|---|---|
| Chromium | end to end, in CI |
| Firefox | end to end, in CI — genuinely, for the first time |
| WebKit | everything except peer connections in CI; end to end only locally on macOS |
The README and roadmap now state this instead of implying uniform coverage.
Tests
79: 18 unit, 55 browser across three real engines, 6 Helia.
Nine browser specs are new, each pinning a specific failure from the field protocol — including one that waits out the old 30-second teardown before completing a handshake, and one that opens a third tab and checks the connection landed in the original one.
v0.1.4 — roadmap link, three-engine CI
A demo-only release. The published package is unchanged — @le-space/libp2p-webrtc-qr@0.1.0 remains current on npm, and no file under packages/webrtc-qr was touched.
Live: https://webrtc-qr.le-space.de
The roadmap is reachable from the demo
Where the project is going was only visible from the repository — a step further than most visitors take. There is now a Roadmap link beside GitHub in the nav, and a plain text link in the footer.
Both nav links collapse to icons on phones, as the GitHub one already did.
An accessibility defect that collapse was hiding
display: none on the label removes it from the accessibility tree, not only from view. On a phone the GitHub link therefore had no accessible name at all — a screen reader announced an unlabelled link — and the new Roadmap link would have inherited exactly the same problem.
Both now carry an explicit aria-label, and the spec asserts that every nav link has one, so it cannot quietly regress the next time a link is added.
Firefox and WebKit run in CI
Both example suites now run on Chromium, Firefox and WebKit on every push, closing #10. The Playwright container CI already uses ships all three, so no install step was needed.
Nothing broke. CompressionStream, RTCPeerConnection and negotiated data channels behave the same in Firefox and WebKit as in Chromium, and the signed handshake, the chat protocol and the bitswap file transfer all pass unchanged. WebKit was the expected risk and handled the whole path, including the negotiated init data channel and the DataChannel muxer.
Two findings recorded rather than assumed:
BarcodeDetectoris exposed by none of the three Playwright builds, Chromium included. ThejsQRfallback is the only path CI takes regardless of engine count.- The camera path is still untested everywhere. Every automated test exchanges payloads by copy/paste or programmatically, so
getUserMediaand live scanning are only verified by hand. More browsers did not close that gap, because it was never a browser-coverage gap.
The README used to list "Chromium only in CI" as the limitation. That line is replaced with the accurate one, so the document no longer implies coverage that does not exist.
Verified by hand
Reported by @NiKrause against the live demo:
- real camera scanning works in Chrome and Brave
- a phone on mobile data connects to a laptop on wifi — real ICE across separate networks, with STUN and no TURN server
- drag-and-drop of a real file transfers and downloads correctly
The mobile-to-wifi result is the significant one. It moves #11 (TURN) from "possibly necessary" to a convenience for edge cases.
Roadmap and issues
Every roadmap item now has a tracking issue, all labelled roadmap — the file keeps the reasoning, the issues carry the work. Added as item 8: multi-peer sessions and mesh bootstrapping (#14), including why a full mesh is the wrong target and why the replay window (#9) is a prerequisite rather than a refinement once signaling travels over the wire instead of across a camera.
Tests
60 tests: 18 unit, 36 browser end-to-end across three engines, 6 Helia.
v0.1.3 — link previews and canonical
A demo-only release. The published package is unchanged — @le-space/libp2p-webrtc-qr@0.1.0 remains current on npm, and nothing in packages/webrtc-qr moved.
Live: https://webrtc-qr.le-space.de
Link previews
Sharing the demo anywhere — Mastodon, Slack, LinkedIn, a mail — produced a bare link with no image and no description. For a page whose entire purpose is being shown to people, that was the largest piece of wasted reach.
It now ships Open Graph and Twitter card tags plus a 1200×630 card built from the Le-Space tokens. The QR on the card is a real code that resolves to the demo, verified by decoding the rendered PNG — so it still works when someone points a phone at a screenshot of the card.
The card is checked in rather than generated during the build: a social image should change when someone decides it should, not as a side effect of a deploy. pnpm og-image regenerates it.
A canonical URL
Not boilerplate in this case. The identical build is served from webrtc-qr.le-space.de and from every IPFS gateway that resolves the CID — real duplicate content across a dozen hosts, which is exactly what canonical exists for.
Image URLs are absolute for a related reason: a crawler that found the page under /ipfs/<cid>/ resolves a relative asset to a path that does not exist there, and the preview fails silently.
Deliberately not included
sitemap.xml, robots.txt and JSON-LD structured data. Traffic here comes from the repo, npm, the landing page and shared links — search volume for this topic is negligible, so ranking markup would be effort with optics rather than effect.
Tests
31 tests gating the deploy: 18 unit, 11 browser end-to-end (five new), 2 Helia.
The new specs check what actually breaks in practice rather than that tags exist:
- the declared image ships in the build and returns
image/png - its real pixel dimensions, read from the PNG header, match the declared width and height — and are at least 1200×630, below which the large card degrades to a thumbnail
- no image URL is relative, and
og:urlagrees with the canonical
A meta tag pointing at a deleted asset errors nowhere. It stays invisible until someone shares a link and gets a blank card.
Full changes in #5.
v0.1.2 — scannability and feedback fixes
A maintenance release for the demo. The published package is unchanged — @le-space/libp2p-webrtc-qr@0.1.0 remains current on npm, and nothing in packages/webrtc-qr moved.
Live: https://webrtc-qr.le-space.de
Both fixes come from using the demo on an actual phone rather than in a desktop browser window.
The QR code was giving away a quarter of the screen
On a 390 px phone the code rendered at 316 px — the rest lost to card padding and page margins — so it had to be pinch-zoomed before a camera could resolve it. Which is the opposite of the point: the whole interaction is meant to be hold one screen up to another camera.
Module size is what decides whether a scan catches. On narrow viewports the code now breaks out of both paddings and spans the full viewport width.
| before | after | |
|---|---|---|
| 390 px phone | 316 px | 390 px |
| desktop cap | 340 px | 440 px |
| rendered resolution | 768 px | 1280 px |
The resolution bump matters because a 3× phone showing the code full-bleed asks for over 1100 device pixels — upscaling a dense pattern blurs exactly the module edges a camera needs to find.
The demo README now says this outright instead of leaving it implicit: show the code as large as the screen allows. A squeezed code is the most common reason a scan fails, and it is the one thing a user can fix immediately.
"Create offer" looked dead
Gathering ICE candidates can take seconds, and until it finishes nothing appears on screen — so the button read as if it had never been pressed. The natural reaction is to press it again.
It now shows a spinner and "Creating offer…" while pending, keeps its width so the layout does not jump, announces itself with aria-busy, and is handed back in a finally so a failed offer does not leave it spinning forever.
Tests
26 tests, all gating the deploy: 18 unit, 6 browser end-to-end (two new), 2 Helia.
The pending-state test asserts the state was actually entered, not just that the end state is clean. With host-only ICE the spinner is too quick to catch mid-flight, so the page records that it happened — asserting only the final state would pass even if the pending state had been deleted entirely.
The phone test asserts the rendered QR width equals the viewport width, so the layout cannot silently regress.
Full changes in #4.
v0.1.1 — file transfer in the demo
A demo-only release. The published package is unchanged — @le-space/libp2p-webrtc-qr@0.1.0 remains current on npm, and nothing in packages/webrtc-qr moved.
Live: https://webrtc-qr.le-space.de
Drag and drop file transfer
Drop a file into the demo and the other browser downloads it, over the same connection the QR handshake produced. No server anywhere in the path.
The sender adds the bytes to Helia and announces only the CID over the chat stream. The receiver pulls the bytes with bitswap across the QR-negotiated libp2p connection and renders a download link with the file name, size and CID.
Chat messages became JSON envelopes so plain text and file announcements can share one stream. sendMessage and getLastReceivedMessage kept their shape, so the existing specs were untouched.
Why Helia is composed by hand
helia = withBitswap(withLibp2p(createHeliaLight(), node))
await helia.start()createHelia() is internally withBitswap(withLibp2p(withHTTP(...))), and that withHTTP layer adds trustless gateways plus a delegated routing endpoint. With it, a dropped file could arrive from trustless-gateway.link instead of from the peer across the QR connection — the demo would quietly stop demonstrating anything while still appearing to work.
Without it, bitswap over that single libp2p connection is the only path the bytes have.
Tests
24 tests, all green and gating the deploy:
- 18 unit tests on the signaling codec
- 4 browser end-to-end tests, one of them new
- 2 Helia end-to-end tests
The new spec asserts the bytes, not just that an announcement arrived: it reads the generated download link back through fetch() and compares it to what was sent, then checks the anchor carries the right download filename.
UI
A dashed drop zone below the message field — disabled until a peer is connected, coral highlight while dragging, click and keyboard accessible. Received files are listed with name, size, CID and a download link.
Full changes in #1.