Skip to content

feat(bindings): carry the Nostr transport in the browser over the application's relays - #634

Merged
bahdotsh merged 12 commits into
mainfrom
feat/web-nostr-transport
Oct 25, 2026
Merged

bahdotsh merged 12 commits into
mainfrom
feat/web-nostr-transport

Conversation

@bahdotsh

@bahdotsh bahdotsh commented Oct 25, 2026 •

Copy link
Copy Markdown
Member

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.

  • The relays are the application's. start refuses nostr without a non-empty list of ws:// or wss:// URLs. No shipped file names a relay any more: the docs, two doc comments and the Nostr example listed public ones, and no_shipped_file_names_a_relay now refuses any.
  • The worker holds the sockets; Rust decides what they mean. No binding opens a relay socket in Rust, so nostr-relays.js is 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).
  • Cold contact works between two browsers. sendMessage and establishSecureSession to 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.
  • New page calls. resolveUsername, establishSecureSession (answers created, existing or pending), createInvite and parseInvite. usernameDiscovery, off by default, gates publishing and resolving together, as the spec requires.

Documented as W12 in docs/bridges/web.md.

Driver rules

Rule Value
A relay is live from the EOSE confirming this device's subscription
Keep-alive probe every 60 s; dropped after 10 s silent
Redial backoff 1 s doubling to 30 s; at once on online
Event confirmed first relay that accepts it
Event failed every relay refused or dropped
Query complete every relay answered EOSE, or after 10 s

The 10 s is the sixth hand-mirrored constant (C5). The existing guard now pins it in nostr-relays.js beside 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.
  • Negative control. With the key-package request removed, the bare-address test fails: the sender sees only identity_ready and transport_switched.
  • bash bindings/web/scripts/test-browser.sh: all six pages pass in Chromium, Firefox and WebKit, 18 of 18. That includes the new nostr-transport.html and the existing nostr-signal.html against the relay that now stores events.
  • no_shipped_file_names_a_relay fails when a relay URL is put back in docs/configuration.md.
  • Clippy is clean on the host and for wasm32, and docs build under -D warnings.

Not done

  • Browser to phone has not been run. It is the same transport and wire format, but a phone would also hit Nostr cold contact never starts: no production caller requests a stranger's key packages #633 when it initiates.
  • The signalling channel was not refactored onto the new driver. RoomChannel in nostr-signal.js keeps its own slots. Its timers are pinned by nostr-signal.html, and its liveness rule differs.
  • CI runs the Linux Test leg more often now. The rust_tests filter now covers docs/**, examples/**, bindings/** and root *.md, because the new guard reads them.
  • The internet relay is still not wired. Its handshake sets an X-Device-ID header, which a browser cannot set on a WebSocket. It is listed under "What is not built yet" in the web bridge doc.

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.
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.
@bahdotsh
bahdotsh merged commit 92a0acc into main Oct 25, 2026
42 checks passed
@bahdotsh
bahdotsh deleted the feat/web-nostr-transport branch October 25, 2026 12:04
@github-actions github-actions Bot locked and limited conversation to collaborators Oct 25, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant