Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,17 @@ archived by series under [docs/changelog/](docs/changelog/); see the

### Added

- **Browsers on one network find each other.** `NearbyRoom` meets the
browsers behind one public address in a Nostr room derived from that
address and the application id, lists them by the label each application
chose, and links over WebRTC to the one a person picks; the link runs on
the local network. The room key is public by construction, so every
connection code is sealed to one device's key, every presence is signed
by the key it announces, and a host answers only what its `admit`
callback accepts, asking once per session. Relays and STUN servers are the
application's ([W13](docs/bridges/web.md#w13-a-nearby-room-is-derived-from-the-networks-address),
[ADR 0034](docs/adr/0034-browsers-find-each-other-through-their-network-address.md)).

- **The browser reaches devices it has no link to, over Nostr.**
`OfflineNode.start({ nostr: { relays } })` adds the Nostr transport every
binding shares, driven from the worker over the relays the application
Expand Down
11 changes: 8 additions & 3 deletions bindings/web/scripts/test-browser.sh
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ cd "$(dirname "$0")/.."
PORT="${PORT:-8765}"
# The local Nostr relay the signalling page publishes through.
RELAY_PORT="${RELAY_PORT:-8766}"
# The local STUN server the nearby page learns its network's address from.
STUN_PORT="${STUN_PORT:-8767}"

[ -f pkg/offline_protocol_web.js ] || bash scripts/build.sh
[ -d node_modules/playwright ] || npm ci
Expand All @@ -23,9 +25,11 @@ python3 -m http.server "$PORT" --bind 127.0.0.1 >/dev/null 2>&1 &
server=$!
node test/nostr-relay.mjs "$RELAY_PORT" >/dev/null 2>&1 &
relay=$!
node test/stun-server.mjs "$STUN_PORT" >/dev/null 2>&1 &
stun=$!
cleanup() {
kill "$server" "$relay" 2>/dev/null || true
wait "$server" "$relay" 2>/dev/null || true
kill "$server" "$relay" "$stun" 2>/dev/null || true
wait "$server" "$relay" "$stun" 2>/dev/null || true
}
trap cleanup EXIT

Expand All @@ -36,4 +40,5 @@ done

pages=()
for page in test/*.html; do pages+=("$page"); done
node test/run.mjs "http://127.0.0.1:$PORT" "ws://127.0.0.1:$RELAY_PORT" 90000 "${pages[@]}"
STUN_URL="stun:127.0.0.1:$STUN_PORT" \
node test/run.mjs "http://127.0.0.1:$PORT" "ws://127.0.0.1:$RELAY_PORT" 90000 "${pages[@]}"
85 changes: 85 additions & 0 deletions bindings/web/src/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,25 @@ export class OfflineNode extends EventTarget {
room: string,
message: string,
): Promise<{ role: "offer" | "answer"; session: string; code: string; freshUntilMs: number } | null>;
/** NearbyRoom's half in the worker (W13). */
nearbyOpen(network: string, label: string): Promise<{ id: number; key: string }>;
nearbySubscription(id: number, subscriptionId: string): Promise<string>;
nearbyPresence(id: number): Promise<string>;
nearbyOffer(id: number, to: string, session: string, code: string): Promise<string>;
nearbyAnswer(id: number, to: string, session: string, code: string): Promise<string>;
nearbyOpenMessage(
id: number,
message: string,
): Promise<{
kind: "presence" | "offer" | "answer";
peer: string;
label: string;
session: string | null;
code: string | null;
createdAtMs: number;
freshUntilMs: number;
} | null>;
nearbyClose(id: number): Promise<void>;
/** Registers a carrier the page opened; resolves to its link id. */
openLink(carrier: PageCarrier): Promise<number>;
/** Bytes the carrier delivered; resolves to the reason it was closed, if it was. */
Expand Down Expand Up @@ -251,3 +270,69 @@ export class NostrSignaller {
/** Joins the device hosting `room`; rejects when no answer comes in time. */
join(room: string, carrier: WebRtcCarrier, options?: { timeoutMs?: number }): Promise<SignalledConnection>;
}

/** A device announced in a nearby room. */
export interface NearbyPeer {
/** Its key in this room; what `connect` takes. Not an identity. */
peer: string;
/** The label its application chose. A claim, not a name anyone checked. */
label: string;
/** When this device last heard it, in milliseconds since the epoch. */
seenAt: number;
}

export interface NearbyRoomOptions {
/** The relays to use, as ws:// or wss:// URLs. Required: the SDK names none. */
relays: string[];
/**
* Servers to learn the network's public address from, with at least one
* STUN server. Required unless `network` is given. The SDK names none.
*/
iceServers?: RTCIceServer[];
/** The network's public address, when the application already knows it. */
network?: string;
/** What this device is shown as to the others. At most 64 characters. */
label?: string;
/**
* Decides every offer this device receives. Anyone who can guess the
* network's address can offer, so ask the person; only `true` answers.
* `signal` aborts when the offer stops being fresh or the room closes:
* take the question down then, as nobody is waiting for the answer. A
* session is asked about once; its repeats are not asked again.
*/
admit(offer: { peer: string; label: string; signal: AbortSignal }): boolean | Promise<boolean>;
}

/**
* The devices behind this network's public address for one application,
* found through Nostr relays (W13). Each one announces a room key and a
* label, sees the others in `peers`, and links over WebRTC to the one its
* person picks. The room is weak by construction: anyone who can guess the
* address can read who is present and offer, and none can read a
* connection code, which is sealed to one device's key. A `peers` event
* carries the list whenever it changes. A `full` event carries `{ peer,
* label }` for an offer dropped because sixteen were already waiting on
* `admit`.
*/
export class NearbyRoom extends EventTarget {
constructor(node: OfflineNode, carrier: WebRtcCarrier, options: NearbyRoomOptions);
/** The devices present now, newest first. */
readonly peers: NearbyPeer[];
/** This device's key in the room, once open. */
readonly key: string | null;
/** Learns the address, subscribes, and starts announcing. A room opens once. */
open(): Promise<NearbyRoom>;
/** Links to `peer` once it admits this device; rejects when it does not answer. */
connect(peer: string, options?: { timeoutMs?: number }): Promise<SignalledConnection>;
/** Stops announcing and answering; links already made stay up. */
close(): void;
}

/**
* The network's public address, from a server-reflexive candidate: IPv4 when
* one arrives, else IPv6 after waiting `settleMs` for an IPv4 one.
*/
export function publicAddress(
iceServers: RTCIceServer[],
options?: { timeoutMs?: number; settleMs?: number },
): Promise<string>;
33 changes: 33 additions & 0 deletions bindings/web/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

export { WebRtcCarrier } from "./webrtc.js";
export { NostrSignaller } from "./nostr-signal.js";
export { NearbyRoom, publicAddress } from "./nearby.js";
import { checkRelays } from "./nostr-relays.js";

export class OfflineNode extends EventTarget {
Expand Down Expand Up @@ -184,6 +185,38 @@ export class OfflineNode extends EventTarget {
return this.#call("signalOpen", { room, message });
}

/// NearbyRoom's half in the worker (W13): open a room for this network's
/// address under a label (`{ id, key }`), its REQ text, sealed EVENT texts,
/// and what a relay message carries (`{ kind, peer, label, session, code,
/// createdAtMs, freshUntilMs }`, or null).
nearbyOpen(network, label) {
return this.#call("nearbyOpen", { network, label });
}

nearbySubscription(id, subscriptionId) {
return this.#call("nearbySubscription", { id, subscriptionId });
}

nearbyPresence(id) {
return this.#call("nearbyPresence", { id });
}

nearbyOffer(id, to, session, code) {
return this.#call("nearbyOffer", { id, to, session, code });
}

nearbyAnswer(id, to, session, code) {
return this.#call("nearbyAnswer", { id, to, session, code });
}

nearbyOpenMessage(id, message) {
return this.#call("nearbyOpenMessage", { id, message });
}

nearbyClose(id) {
return this.#call("nearbyClose", { id });
}

/// What the durable store has cost since the node started (flushes, log
/// and live bytes, compactions per log), or null for a node in memory.
stats() {
Expand Down
Loading
Loading