Repository navigation
feat(bindings): carry the Nostr transport in the browser over the application's relays - #634
Merged
Merged
Conversation
The relays a node uses are the application's choice. The Nostr guide, the configuration and DORS references, two doc comments and the Nostr example listed public relays, and a URL in a doc or an example is a default by another name: it becomes the relay every application copies. They now show a placeholder, and the example starts with an empty list and says where to fill it. no_shipped_file_names_a_relay reads every shipped doc, example and binding source and refuses a ws:// or wss:// URL whose host is not a reserved placeholder. It reads paths outside the Rust sources, so the rust_tests filter now covers docs, examples, bindings and the root documents. The driver guide also said a resolution query completes on the first EOSE. The bridges and the username chapter wait for every relay, because the first answer hands the result to the fastest relay; the guide now says so.
…lication's relays
A browser node could reach only the tabs and devices it held a link to.
OfflineNode.start({ nostr: { relays } }) now registers the NostrTransport
every binding shares, once the address exists, and the worker holds the
relay sockets beside the engine. No binding opens a relay socket in Rust;
this is the browser's copy of the Swift and Kotlin drivers' host half, and
the worker moves text while the engine decodes, unseals and attributes
every event along the FFI's own receive path.
The relays are the application's: start refuses nostr without a non-empty
list of ws:// or wss:// URLs. A relay is live from the EOSE that confirms
this device's subscription, is probed every 60 s, and is redialled with
backoff when it stops answering. An event is confirmed by the first relay
that accepts it and fails only when every relay refused or dropped. A
resolution query completes when every relay it went to has answered, or
after 10 s, the constant the mobile trackers hold, now pinned in all three.
Reaching a device by its address alone needs the relays asked for its key
packages, and the engine never asks: a session-less send only queues, and
its own key-package push reaches only the mesh. sendMessage and
establishSecureSession now ask, which is what forms a session between two
nodes that share only a relay; the host test's negative control fails
without it. establishSecureSession answers created, existing or pending.
resolveUsername, createInvite and parseInvite match the native calls, and
usernameDiscovery (off by default) gates publishing and resolving together.
The test relay now keeps non-ephemeral events, replaces addressable ones
and answers a subscription with what it holds, which the transport's key
packages and directory records need. nostr-transport.html runs the pair in
Chromium, Firefox and WebKit; the host tests run it against an in-test
relay. Documented as W12.
…solves The page waited for both starts before listening for transport_switched, so the faster node's relay confirmation could arrive in between and be missed: Firefox timed out at the start step. Each listener now goes on as its own start resolves; the confirmation comes a relay round trip after the start reply, so it cannot be missed.
This was referenced Oct 25, 2026
The browser's relay driver kept a relay "live" by sending this device's inbox subscription again under the same id every minute. That is a NIP-01 replace, and a relay answers a replace exactly the way it answers a fresh REQ: with every stored event that matches. The inbox filter's `since` sits at the receive watermark minus an hour and change, because gift wraps backdate `created_at` by up to an hour, and it carries `limit: 500`. So every probe asked every relay for up to 500 events of history, and the worker dutifully ran each one through ECDH and AEAD before the engine's dedup threw it away. Once a minute, per relay, for as long as the page is open. On the test page one stored event arrived eight times in 1.5 s. The trick was lifted from the signalling channel, where it is free because its room filter matches only ephemeral events that no relay stores. The inbox is the opposite case. The mobile drivers never do this: they subscribe on connect and ping the socket. A browser cannot send a WebSocket ping, so the probe is now a REQ for an event id nobody can have, closed again on its EOSE. It proves the relay is still answering without asking it for anything. While at it, two more driver bugs: - Losing a relay released its pending events and answered its queries *before* flipping the node to "not live". Each release runs the engine and flushes, and a flush that still saw the node live drained the next key-package query toward an empty relay set. The query completed empty, and the transport had already spent that peer's five-minute resolution limit. The node now goes down first. - A relay the browser refuses to dial (ws:// from an https: page) or one that answers the subscription with CLOSED was dropped in silence. The page just saw a node that never went live. Both now reach the page as an `error` event, with the relay's reason. The driver's timers can be shortened through a constructor argument that only its new test page uses. That page runs the driver against the test relay, which gains `/reject` and `/closed` modes. It checks each rule above, plus that a query waits for every relay, and fails with either bug put back.
A browser node asks the relays for a peer's key packages whenever it sends to, or starts a session with, a peer it has no session with. That is the stopgap for the engine never asking on its own (#633). The check was "no session", which is wrong for a peer on a live tab or WebRTC link. The link carries the key-package exchange within a tick, so the relay query buys nothing. What it does do is publish a `#p` filter on that peer's routing tag, which tells every relay which address this node is interested in. For no reason. Skip the lookup while a link to the peer is up, and say in the code and in W12 that this request goes away once #633 lands. While at it, a liveness report that arrives before the node has an address used to record "live" before finding out there was no Nostr slot to tell, and then return an error. The next real report then looked like a repeat, so it neither flushed the outbox nor told the page. Look up the slot first. New host tests cover the linked peer (it fails without the check), liveness before an address, going down and coming back, and relay junk that must move neither the receive watermark nor the events.
`no_shipped_file_names_a_relay` reads every shipped file for a relay URL, scripts/ included, but the Linux Test leg's path filter did not list scripts/. A relay URL added to a script alone would sail through CI, and the guard would only notice on the next unrelated PR.
The junk test in the Nostr host tests grabbed the wall clock straight from std::time. The clock guard in core scans every crate the browser build compiles, tests included, and it refused it. So all three Test legs went red. Worse, cargo test --workspace stops at the first failing crate, and core comes first. The web crate's own tests, the relay guard and the completion-deadline guard never ran in CI at all. A green Web Binding job doesn't help: it builds the module and drives pages, and never runs the crate's Rust tests. Use the seam the signalling code already exposes. It returns the same Unix seconds and goes through offline_protocol_core::time, which is the whole point of the guard.
Losing a relay already says "not live" before it releases anything that relay held. Closing the driver did it the other way round: it ended every open query first, then dropped the sockets, then went down. That matters because every report to the engine runs a flush, and a flush that still sees the node live drains the next event and query toward sockets that are about to close. Same bug as the one fixed in the lost path, in the second place it could happen. So close drops every socket, goes down, then ends the queries. Going down already fails every unconfirmed event inside the transport, so the driver's own copy is just forgotten rather than reported twice. While at it, a relay that refuses the subscription (one that wants authentication) was reported to the page on *every* redial, so an error event every one to thirty seconds for the life of the node. Report it once, until the relay confirms a subscription again. The driver page now checks both, plus a query two live relays answer ending on the slower one's answer, which needed a test relay that answers late. With either fix reverted the page fails: three warnings for one refusal, and a query completed while the node was still live.
Every relay callback that runs the engine goes through the guard that turns a WebAssembly trap into a dead worker (W5). Except one: the callback that builds the subscription text. A trap there went back to the driver as an ordinary throw, which it reads as a lost relay. So it redialled, and called the already broken module again on every backoff. Not likely, but W5 is only worth anything if it holds on every path. Trap means die, here as everywhere else. While at it, the page and the worker both filled in the coldContact and usernameDiscovery defaults. Leave that to the worker, which has to do it anyway for anything that reaches it without the page.
…ecode A key-package record is sealed to a key anyone can derive from the queried address. That's by design: it's how a stranger fetches it. It also means anyone can publish a record at that address that opens fine and then holds garbage. The host passed the decode error straight up, so the page got an error event for junk on a public tag. Everything else that doesn't open on that tag is dropped quietly, and the host's own doc comment says exactly that. This one is no different: log it, drop it. The new test plants such a record under a live lookup and fails with the old error propagation. While at it, test the two option gates nothing covered: without cold contact nobody is looked up, for a session or a send, and without the directory a username lookup is refused rather than left hanging.
…guard The relay guard skipped any file whose name merely *contained* "test". That reads fine until you notice latest.md and contest.md contain it too. A shipped doc with an unlucky name could list a public relay and the guard would never look. Match "test" or "tests" as a word of the filename, or a Test/Tests suffix on the stem for the Java and Swift test classes. The unit test pins both directions, and its latest-relays.md case fails against the old substring check.
The relay guard put bindings, docs, examples, scripts and every top-level Markdown file in the Linux Test filter. That's right: the guard reads all of them. But it left nineteen narrower entries under those globs that now match nothing new, so the list no longer says what it does. Keep the globs, drop the dead entries, and say in one comment which guards read what. Nothing changes in when the job runs.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to subscribe to this conversation on GitHub.
Already have an account?
Sign in.
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this does
A browser node could reach only the tabs and devices it held a link to. With this,
OfflineNode.start({ nostr: { relays } })adds the Nostr transport every binding shares, so a browser reaches any device on the same relays by its address and can use the username directory.startrefusesnostrwithout a non-empty list ofws://orwss://URLs. No shipped file names a relay any more: the docs, two doc comments and the Nostr example listed public ones, andno_shipped_file_names_a_relaynow refuses any.nostr-relays.jsis the browser's copy of the Swift and Kotlin drivers. The engine decodes, unseals and attributes every event along the FFI's receive path (nostr_host.rs).sendMessageandestablishSecureSessionto a session-less device ask the relays for its published key packages. The engine never asks on its own, which is a core gap on native too, filed as Nostr cold contact never starts: no production caller requests a stranger's key packages #633.resolveUsername,establishSecureSession(answers created, existing or pending),createInviteandparseInvite.usernameDiscovery, off by default, gates publishing and resolving together, as the spec requires.Documented as W12 in
docs/bridges/web.md.Driver rules
onlineThe 10 s is the sixth hand-mirrored constant (C5). The existing guard now pins it in
nostr-relays.jsbeside the two mobile trackers.Testing
cargo test -p offline-protocol-web: 51 pass. Seven new host tests run two nodes against an in-test relay that stores, replaces addressable events and filters on any tag. They cover a message to a bare address, username resolution, an unclaimed name, a pending session becoming established, invites, and no publishing before a relay is live.identity_readyandtransport_switched.bash bindings/web/scripts/test-browser.sh: all six pages pass in Chromium, Firefox and WebKit, 18 of 18. That includes the newnostr-transport.htmland the existingnostr-signal.htmlagainst the relay that now stores events.no_shipped_file_names_a_relayfails when a relay URL is put back indocs/configuration.md.-D warnings.Not done
RoomChannelinnostr-signal.jskeeps its own slots. Its timers are pinned bynostr-signal.html, and its liveness rule differs.rust_testsfilter now coversdocs/**,examples/**,bindings/**and root*.md, because the new guard reads them.X-Device-IDheader, which a browser cannot set on a WebSocket. It is listed under "What is not built yet" in the web bridge doc.