mirror of
https://github.com/hzrd149/nostrudel.git
synced 2025-09-28 12:37:23 +02:00
finish basic webrtc classes
This commit is contained in:
151
src/classes/nostr-webrtc-broker.ts
Normal file
151
src/classes/nostr-webrtc-broker.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import { SubCloser } from "nostr-tools/abstract-pool";
|
||||
import EventEmitter from "eventemitter3";
|
||||
import { generateSecretKey, nip19, NostrEvent } from "nostr-tools";
|
||||
import { bytesToHex, hexToBytes } from "@noble/hashes/utils";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import NostrWebRTCPeer, { Pool, RTCDescriptionEventKind, Signer } from "./nostr-webrtc-peer";
|
||||
import { isHex } from "../helpers/nip19";
|
||||
import { logger } from "../helpers/debug";
|
||||
import SimpleSigner from "./simple-signer";
|
||||
|
||||
type EventMap = {
|
||||
call: [NostrEvent];
|
||||
};
|
||||
|
||||
export default class NostrWebRtcBroker extends EventEmitter<EventMap> {
|
||||
log = logger.extend("NostrWebRtcBroker");
|
||||
signer: Signer;
|
||||
pool: Pool;
|
||||
defaultRelays: string[];
|
||||
|
||||
peers = new Map<string, NostrWebRTCPeer>();
|
||||
signers = new Map<string, Signer>();
|
||||
relays = new Map<string, string[]>();
|
||||
|
||||
constructor(signer: Signer, pool: Pool, relays: string[]) {
|
||||
super();
|
||||
this.signer = signer;
|
||||
this.pool = pool;
|
||||
this.defaultRelays = relays;
|
||||
}
|
||||
|
||||
getConnection(pubkey: string) {
|
||||
return this.peers.get(pubkey);
|
||||
}
|
||||
|
||||
async requestConnection(uri: string) {
|
||||
const { pubkey, relays, key } = NostrWebRtcBroker.parseNostrWebRtcURI(uri);
|
||||
|
||||
const cached = this.peers.get(pubkey);
|
||||
if (cached) return cached;
|
||||
|
||||
this.log(`Creating new connection for ${pubkey}`);
|
||||
|
||||
// set signer
|
||||
let signer = this.signer;
|
||||
if (key) {
|
||||
signer = new SimpleSigner(key);
|
||||
this.signers.set(pubkey, signer);
|
||||
}
|
||||
|
||||
// set relays
|
||||
if (relays.length > 0) this.relays.set(pubkey, relays);
|
||||
else this.relays.set(pubkey, this.defaultRelays);
|
||||
|
||||
const peer = new NostrWebRTCPeer(signer, this.pool, relays.length > 0 ? relays : this.defaultRelays);
|
||||
this.peers.set(pubkey, peer);
|
||||
await peer.makeCall(pubkey);
|
||||
|
||||
return peer;
|
||||
}
|
||||
|
||||
setPeerSigner(pubkey: string, signer: Signer) {
|
||||
this.signers.set(pubkey, signer);
|
||||
}
|
||||
|
||||
async answerCall(event: NostrEvent): Promise<NostrWebRTCPeer> {
|
||||
if (this.peers.has(event.pubkey)) throw new Error("Already have a peer connection for this pubkey");
|
||||
|
||||
// set signer
|
||||
let signer = this.signers.get(event.pubkey);
|
||||
if (!signer) {
|
||||
signer = this.signer;
|
||||
this.signers.set(event.pubkey, signer);
|
||||
}
|
||||
|
||||
const peer = new NostrWebRTCPeer(signer, this.pool, this.defaultRelays);
|
||||
this.peers.set(event.pubkey, peer);
|
||||
await peer.answerCall(event);
|
||||
|
||||
return peer;
|
||||
}
|
||||
|
||||
closeConnection(pubkey: string) {
|
||||
const peer = this.peers.get(pubkey);
|
||||
if (peer) {
|
||||
this.log(`Hanging up connection to ${pubkey}`);
|
||||
peer.hangup();
|
||||
this.peers.delete(pubkey);
|
||||
}
|
||||
}
|
||||
|
||||
listening = false;
|
||||
subscription?: SubCloser;
|
||||
|
||||
async listenForCalls() {
|
||||
if (this.listening) throw new Error("Already listening");
|
||||
|
||||
this.log("Listening for calls");
|
||||
|
||||
this.listening = true;
|
||||
this.subscription = this.pool.subscribeMany(
|
||||
this.defaultRelays,
|
||||
[{ kinds: [RTCDescriptionEventKind], "#p": [await this.signer.getPublicKey()], since: dayjs().unix() }],
|
||||
{
|
||||
onevent: (event) => {
|
||||
this.emit("call", event);
|
||||
},
|
||||
onclose: () => {
|
||||
this.listening = false;
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
stopListening() {
|
||||
if (!this.listening) return;
|
||||
|
||||
this.log("Stop listening for calls");
|
||||
|
||||
if (this.subscription) this.subscription.close();
|
||||
this.subscription = undefined;
|
||||
this.listening = false;
|
||||
}
|
||||
|
||||
static parseNostrWebRtcURI(uri: string | URL) {
|
||||
const url = typeof uri === "string" ? new URL(uri) : uri;
|
||||
if (url.protocol !== "webrtc+nostr:") throw new Error("Incorrect protocol");
|
||||
const parsedPath = nip19.decode(url.pathname);
|
||||
const keyParam = url.searchParams.get("key");
|
||||
const relays = url.searchParams.getAll("relay");
|
||||
if (parsedPath.type !== "npub") throw new Error("Incorrect npub");
|
||||
const pubkey = parsedPath.data;
|
||||
if (keyParam && !isHex(keyParam)) throw new Error("Key must be in hex format");
|
||||
const key = keyParam ? hexToBytes(keyParam) : null;
|
||||
return { pubkey, key, relays };
|
||||
}
|
||||
|
||||
static createNostrWebRtcURI(pubkey: string, relays: string[], key?: Uint8Array | boolean) {
|
||||
const uri = new URL(`webrtc+nostr:${nip19.npubEncode(pubkey)}`);
|
||||
for (const relay of relays) uri.searchParams.append("relay", relay);
|
||||
if (key === true) uri.searchParams.append("key", bytesToHex(generateSecretKey()));
|
||||
else if (key instanceof Uint8Array) uri.searchParams.append("key", bytesToHex(key));
|
||||
return uri.toString();
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
// @ts-expect-error
|
||||
window.NostrWebRtcBroker = NostrWebRtcBroker;
|
||||
}
|
@@ -1,23 +1,15 @@
|
||||
import { Debugger } from "debug";
|
||||
import EventEmitter from "eventemitter3";
|
||||
import dayjs from "dayjs";
|
||||
import {
|
||||
EventTemplate,
|
||||
Filter,
|
||||
NostrEvent,
|
||||
SimplePool,
|
||||
finalizeEvent,
|
||||
generateSecretKey,
|
||||
getPublicKey,
|
||||
nip44,
|
||||
} from "nostr-tools";
|
||||
import { EventTemplate, Filter, NostrEvent } from "nostr-tools";
|
||||
import { SubCloser, SubscribeManyParams } from "nostr-tools/abstract-pool";
|
||||
|
||||
import { logger } from "../../../helpers/debug";
|
||||
import { logger } from "../helpers/debug";
|
||||
|
||||
const RTCDescriptionEventKind = 25050;
|
||||
const RTCICEEventKind = 25051;
|
||||
type Signer = {
|
||||
export const RTCDescriptionEventKind = 25050;
|
||||
export const RTCICEEventKind = 25051;
|
||||
|
||||
export type Signer = {
|
||||
getPublicKey: () => Promise<string> | string;
|
||||
signEvent: (event: EventTemplate) => Promise<NostrEvent> | NostrEvent;
|
||||
nip44: {
|
||||
@@ -26,42 +18,18 @@ type Signer = {
|
||||
};
|
||||
};
|
||||
|
||||
type Pool = {
|
||||
export type Pool = {
|
||||
subscribeMany(relays: string[], filters: Filter[], params: SubscribeManyParams): SubCloser;
|
||||
publish(relays: string[], event: NostrEvent): Promise<string>[];
|
||||
};
|
||||
|
||||
type EventMap = {
|
||||
connect: [];
|
||||
disconnect: [];
|
||||
incomingCall: [NostrEvent];
|
||||
connected: [];
|
||||
disconnected: [];
|
||||
message: [string];
|
||||
};
|
||||
|
||||
class SimpleSigner {
|
||||
key: Uint8Array;
|
||||
constructor() {
|
||||
this.key = generateSecretKey();
|
||||
}
|
||||
|
||||
async getPublicKey() {
|
||||
return getPublicKey(this.key);
|
||||
}
|
||||
async signEvent(event: EventTemplate) {
|
||||
return finalizeEvent(event, this.key);
|
||||
}
|
||||
|
||||
nip44 = {
|
||||
encrypt: async (pubkey: string, plaintext: string) =>
|
||||
nip44.v2.encrypt(plaintext, nip44.v2.utils.getConversationKey(this.key, pubkey)),
|
||||
decrypt: async (pubkey: string, ciphertext: string) =>
|
||||
nip44.v2.decrypt(ciphertext, nip44.v2.utils.getConversationKey(this.key, pubkey)),
|
||||
};
|
||||
}
|
||||
|
||||
const defaultPool = new SimplePool();
|
||||
|
||||
class WebRTCPeer extends EventEmitter<EventMap> {
|
||||
export default class NostrWebRTCPeer extends EventEmitter<EventMap> {
|
||||
log: Debugger;
|
||||
signer: Signer;
|
||||
pool: Pool;
|
||||
@@ -72,7 +40,6 @@ class WebRTCPeer extends EventEmitter<EventMap> {
|
||||
connection?: RTCPeerConnection;
|
||||
channel?: RTCDataChannel;
|
||||
|
||||
listening = false;
|
||||
subscription?: SubCloser;
|
||||
|
||||
async isCaller() {
|
||||
@@ -90,9 +57,9 @@ class WebRTCPeer extends EventEmitter<EventMap> {
|
||||
|
||||
private candidateQueue: RTCIceCandidateInit[] = [];
|
||||
|
||||
constructor(signer: Signer, pool: Pool = defaultPool, relays?: string[], iceServers?: RTCIceServer[]) {
|
||||
constructor(signer: Signer, pool: Pool, relays?: string[], iceServers?: RTCIceServer[]) {
|
||||
super();
|
||||
this.log = logger.extend(`webrtc`);
|
||||
this.log = logger.extend(`NostrWebRTCPeer`);
|
||||
this.signer = signer;
|
||||
this.pool = pool;
|
||||
|
||||
@@ -115,7 +82,7 @@ class WebRTCPeer extends EventEmitter<EventMap> {
|
||||
this.connection.onicegatheringstatechange = this.flushCandidateQueue.bind(this);
|
||||
|
||||
this.connection.ondatachannel = ({ channel }) => {
|
||||
this.log("Got data channel", channel);
|
||||
this.log("Got data channel", channel.id, channel.label);
|
||||
|
||||
if (channel.label !== "nostr") return;
|
||||
|
||||
@@ -125,6 +92,17 @@ class WebRTCPeer extends EventEmitter<EventMap> {
|
||||
this.channel.onmessage = this.handleChannelMessage.bind(this);
|
||||
};
|
||||
|
||||
this.connection.onconnectionstatechange = (event) => {
|
||||
switch (this.connection?.connectionState) {
|
||||
case "connected":
|
||||
this.emit("connected");
|
||||
break;
|
||||
case "disconnected":
|
||||
this.emit("disconnected");
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
return this.connection;
|
||||
}
|
||||
|
||||
@@ -140,7 +118,7 @@ class WebRTCPeer extends EventEmitter<EventMap> {
|
||||
created_at: dayjs().unix(),
|
||||
});
|
||||
|
||||
this.log(`Publishing ICE candidates`, this.candidateQueue);
|
||||
this.log(`Publishing ${this.candidateQueue.length} ICE candidates`);
|
||||
await this.pool.publish(this.relays, iceEvent);
|
||||
this.candidateQueue = [];
|
||||
}
|
||||
@@ -149,7 +127,6 @@ class WebRTCPeer extends EventEmitter<EventMap> {
|
||||
async makeCall(peer: string) {
|
||||
if (this.peer) throw new Error("Already calling peer");
|
||||
|
||||
this.stopListening();
|
||||
const pc = this.createConnection();
|
||||
|
||||
this.channel = pc.createDataChannel("nostr", { ordered: true });
|
||||
@@ -168,7 +145,7 @@ class WebRTCPeer extends EventEmitter<EventMap> {
|
||||
created_at: dayjs().unix(),
|
||||
});
|
||||
|
||||
this.log("Created offer", offer);
|
||||
this.log("Created offer");
|
||||
|
||||
// listen for answers and ice events
|
||||
this.subscription = this.pool.subscribeMany(
|
||||
@@ -199,14 +176,14 @@ class WebRTCPeer extends EventEmitter<EventMap> {
|
||||
}
|
||||
},
|
||||
onclose: () => {
|
||||
this.log("Subscription Closed");
|
||||
this.log("Signaling subscription closed");
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
this.peer = peer;
|
||||
|
||||
this.log("Publishing event", offerEvent);
|
||||
this.log("Publishing event", offerEvent.id);
|
||||
await this.pool.publish(this.relays, offerEvent);
|
||||
await pc.setLocalDescription(offer);
|
||||
|
||||
@@ -222,7 +199,7 @@ class WebRTCPeer extends EventEmitter<EventMap> {
|
||||
const answer = JSON.parse(plaintext) as RTCSessionDescriptionInit;
|
||||
if (answer.type !== "answer") throw new Error("Unexpected rtc description type");
|
||||
|
||||
this.log("Got answer", answer);
|
||||
this.log("Got answer");
|
||||
|
||||
await pc.setRemoteDescription(answer);
|
||||
|
||||
@@ -230,7 +207,6 @@ class WebRTCPeer extends EventEmitter<EventMap> {
|
||||
}
|
||||
|
||||
async answerCall(event: NostrEvent) {
|
||||
this.stopListening();
|
||||
const pc = this.createConnection();
|
||||
|
||||
this.log(`Answering call ${event.id} from ${event.pubkey}`);
|
||||
@@ -240,6 +216,8 @@ class WebRTCPeer extends EventEmitter<EventMap> {
|
||||
if (offer.type !== "offer") throw new Error("Unexpected rtc description type");
|
||||
|
||||
this.relays = event.tags.filter((t) => t[0] === "relay" && t[1]).map((t) => t[1]);
|
||||
this.log(`Switching to callers signaling relays`, this.relays);
|
||||
|
||||
await pc.setRemoteDescription(offer);
|
||||
|
||||
const answer = await pc.createAnswer();
|
||||
@@ -254,7 +232,7 @@ class WebRTCPeer extends EventEmitter<EventMap> {
|
||||
created_at: dayjs().unix(),
|
||||
});
|
||||
|
||||
this.log("Created answer", answer);
|
||||
this.log("Created answer");
|
||||
|
||||
this.peer = event.pubkey;
|
||||
this.offerEvent = event;
|
||||
@@ -275,12 +253,12 @@ class WebRTCPeer extends EventEmitter<EventMap> {
|
||||
}
|
||||
},
|
||||
onclose: () => {
|
||||
this.log("Subscription Closed");
|
||||
this.log("Signaling subscription closed");
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
this.log("Publishing event", answerEvent);
|
||||
this.log("Publishing event", answerEvent.id);
|
||||
|
||||
await this.pool.publish(this.relays, answerEvent);
|
||||
await pc.setLocalDescription(answer);
|
||||
@@ -297,39 +275,13 @@ class WebRTCPeer extends EventEmitter<EventMap> {
|
||||
const plaintext = await this.signer.nip44.decrypt(event.pubkey, event.content);
|
||||
const candidates = JSON.parse(plaintext) as RTCIceCandidateInit[];
|
||||
|
||||
this.log("Got candidates", candidates);
|
||||
this.log(`Got ${candidates.length} candidates`);
|
||||
|
||||
for (let candidate of candidates) {
|
||||
await pc.addIceCandidate(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
async listenForCall() {
|
||||
if (this.listening) throw new Error("Already listening");
|
||||
|
||||
this.listening = true;
|
||||
this.subscription = this.pool.subscribeMany(
|
||||
this.relays,
|
||||
[{ kinds: [RTCDescriptionEventKind], "#p": [await this.signer.getPublicKey()], since: dayjs().unix() }],
|
||||
{
|
||||
onevent: (event) => {
|
||||
this.emit("incomingCall", event);
|
||||
},
|
||||
onclose: () => {
|
||||
this.listening = false;
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
stopListening() {
|
||||
if (!this.listening) return;
|
||||
|
||||
if (this.subscription) this.subscription.close();
|
||||
this.subscription = undefined;
|
||||
this.listening = false;
|
||||
}
|
||||
|
||||
private onChannelStateChange() {
|
||||
const readyState = this.channel?.readyState;
|
||||
console.log("Send channel state is: " + readyState);
|
||||
@@ -343,14 +295,15 @@ class WebRTCPeer extends EventEmitter<EventMap> {
|
||||
this.channel?.send(message);
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
hangup() {
|
||||
this.log("Closing data channel");
|
||||
if (this.channel) this.channel.close();
|
||||
this.log("Closing connection");
|
||||
if (this.connection) this.connection.close();
|
||||
}
|
||||
}
|
||||
|
||||
// @ts-expect-error
|
||||
window.SimpleSigner = SimpleSigner;
|
||||
// @ts-expect-error
|
||||
window.WebRTCPeer = WebRTCPeer;
|
||||
if (import.meta.env.DEV) {
|
||||
// @ts-expect-error
|
||||
window.WebRTCPeer = NostrWebRTCPeer;
|
||||
}
|
31
src/classes/simple-signer.ts
Normal file
31
src/classes/simple-signer.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { EventTemplate, finalizeEvent, generateSecretKey, getPublicKey, nip04, nip44 } from "nostr-tools";
|
||||
|
||||
export default class SimpleSigner {
|
||||
key: Uint8Array;
|
||||
constructor(key?: Uint8Array) {
|
||||
this.key = key || generateSecretKey();
|
||||
}
|
||||
|
||||
async getPublicKey() {
|
||||
return getPublicKey(this.key);
|
||||
}
|
||||
async signEvent(event: EventTemplate) {
|
||||
return finalizeEvent(event, this.key);
|
||||
}
|
||||
|
||||
nip04 = {
|
||||
encrypt: async (pubkey: string, plaintext: string) => nip04.encrypt(this.key, pubkey, plaintext),
|
||||
decrypt: async (pubkey: string, ciphertext: string) => nip04.decrypt(this.key, pubkey, ciphertext),
|
||||
};
|
||||
nip44 = {
|
||||
encrypt: async (pubkey: string, plaintext: string) =>
|
||||
nip44.v2.encrypt(plaintext, nip44.v2.utils.getConversationKey(this.key, pubkey)),
|
||||
decrypt: async (pubkey: string, ciphertext: string) =>
|
||||
nip44.v2.decrypt(ciphertext, nip44.v2.utils.getConversationKey(this.key, pubkey)),
|
||||
};
|
||||
}
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
// @ts-expect-error
|
||||
window.SimpleSigner = SimpleSigner;
|
||||
}
|
118
src/classes/webrtc-relay-client.ts
Normal file
118
src/classes/webrtc-relay-client.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import NostrWebRTCPeer from "./nostr-webrtc-peer";
|
||||
import { AbstractRelay, AbstractRelayConstructorOptions } from "nostr-tools/abstract-relay";
|
||||
|
||||
export class WebRtcWebSocket extends EventTarget implements WebSocket {
|
||||
binaryType: BinaryType = "blob";
|
||||
bufferedAmount: number = 0;
|
||||
extensions: string = "";
|
||||
protocol: string = "webrtc";
|
||||
|
||||
peer: NostrWebRTCPeer;
|
||||
url: string;
|
||||
|
||||
onclose: ((this: WebSocket, ev: CloseEvent) => any) | null = null;
|
||||
onerror: ((this: WebSocket, ev: Event) => any) | null = null;
|
||||
onmessage: ((this: WebSocket, ev: MessageEvent) => any) | null = null;
|
||||
onopen: ((this: WebSocket, ev: Event) => any) | null = null;
|
||||
|
||||
constructor(peer: NostrWebRTCPeer) {
|
||||
super();
|
||||
this.peer = peer;
|
||||
this.url = `webrtc+nostr:` + peer.answerEvent?.pubkey;
|
||||
|
||||
this.peer.on("message", this.handleMessage, this);
|
||||
this.peer.on("connected", this.handleConnect, this);
|
||||
this.peer.on("disconnected", this.handleDisconnect, this);
|
||||
|
||||
if (this.readyState === WebRtcWebSocket.OPEN) {
|
||||
setTimeout(() => this.handleConnect(), 100);
|
||||
}
|
||||
}
|
||||
|
||||
get readyState() {
|
||||
const state = this.peer.connection?.connectionState;
|
||||
switch (state) {
|
||||
case "closed":
|
||||
case "disconnected":
|
||||
return this.CLOSED;
|
||||
case "failed":
|
||||
return this.CLOSED;
|
||||
case "connected":
|
||||
return this.OPEN;
|
||||
case "new":
|
||||
case "connecting":
|
||||
default:
|
||||
return this.CONNECTING;
|
||||
}
|
||||
}
|
||||
|
||||
private handleMessage(data: string) {
|
||||
const event = new MessageEvent("message", { data });
|
||||
this.onmessage?.(event);
|
||||
this.dispatchEvent(event);
|
||||
}
|
||||
private handleConnect() {
|
||||
const event = new Event("open");
|
||||
this.onopen?.(event);
|
||||
this.dispatchEvent(event);
|
||||
}
|
||||
private handleDisconnect() {
|
||||
const event = new CloseEvent("close", { reason: "none" });
|
||||
this.onclose?.(event);
|
||||
this.dispatchEvent(event);
|
||||
|
||||
this.peer.off("message", this.handleMessage, this);
|
||||
this.peer.off("connected", this.handleConnect, this);
|
||||
this.peer.off("disconnected", this.handleDisconnect, this);
|
||||
}
|
||||
|
||||
send(data: string | ArrayBufferLike | Blob | ArrayBufferView): void;
|
||||
send(data: string | ArrayBufferLike | Blob | ArrayBufferView): void;
|
||||
send(data: unknown): void {
|
||||
if (typeof data === "string") {
|
||||
this.peer.send(data);
|
||||
} else throw new Error("Unsupported data type");
|
||||
}
|
||||
|
||||
close(code?: number, reason?: string): void;
|
||||
close(code?: number, reason?: string): void;
|
||||
close(code?: unknown, reason?: unknown): void {
|
||||
this.peer.hangup();
|
||||
|
||||
this.peer.off("message", this.handleMessage, this);
|
||||
this.peer.off("connected", this.handleConnect, this);
|
||||
this.peer.off("disconnected", this.handleDisconnect, this);
|
||||
}
|
||||
|
||||
readonly CONNECTING = WebSocket.CONNECTING;
|
||||
readonly OPEN = WebSocket.OPEN;
|
||||
readonly CLOSING = WebSocket.CLOSING;
|
||||
readonly CLOSED = WebSocket.CLOSED;
|
||||
static readonly CONNECTING = WebSocket.CONNECTING;
|
||||
static readonly OPEN = WebSocket.OPEN;
|
||||
static readonly CLOSING = WebSocket.CLOSING;
|
||||
static readonly CLOSED = WebSocket.CLOSED;
|
||||
}
|
||||
|
||||
export default class WebRtcRelayClient extends AbstractRelay {
|
||||
constructor(peer: NostrWebRTCPeer, opts: AbstractRelayConstructorOptions) {
|
||||
super("wss://example.com", opts);
|
||||
|
||||
// @ts-expect-error
|
||||
this.url = `webrtc+nostr:` + peer.answerEvent?.pubkey;
|
||||
|
||||
this.connectionTimeout = 30_000;
|
||||
|
||||
// @ts-expect-error
|
||||
this._WebSocket = function () {
|
||||
return new WebRtcWebSocket(peer);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
// @ts-expect-error
|
||||
window.WebRtcWebSocket = WebRtcWebSocket;
|
||||
// @ts-expect-error
|
||||
window.WebRtcRelayClient = WebRtcRelayClient;
|
||||
}
|
108
src/classes/webrtc-relay-server.ts
Normal file
108
src/classes/webrtc-relay-server.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import EventEmitter from "eventemitter3";
|
||||
import { Filter, NostrEvent } from "nostr-tools";
|
||||
import { AbstractRelay, Subscription } from "nostr-tools/abstract-relay";
|
||||
|
||||
import NostrWebRTCPeer from "./nostr-webrtc-peer";
|
||||
import { logger } from "../helpers/debug";
|
||||
|
||||
type EventMap = {
|
||||
call: [NostrEvent];
|
||||
};
|
||||
|
||||
export default class WebRtcRelayServer extends EventEmitter<EventMap> {
|
||||
log = logger.extend("WebRtcRelayServer");
|
||||
|
||||
peer: NostrWebRTCPeer;
|
||||
upstream: AbstractRelay;
|
||||
|
||||
// A map of subscriptions
|
||||
subscriptions = new Map<string, Subscription>();
|
||||
|
||||
constructor(peer: NostrWebRTCPeer, upstream: AbstractRelay) {
|
||||
super();
|
||||
this.peer = peer;
|
||||
this.upstream = upstream;
|
||||
|
||||
this.peer.on("message", this.handleMessage, this);
|
||||
this.peer.on("disconnected", this.handleDisconnect, this);
|
||||
}
|
||||
|
||||
private send(data: any[]) {
|
||||
this.peer.send(JSON.stringify(data));
|
||||
}
|
||||
|
||||
async handleMessage(message: string) {
|
||||
let data;
|
||||
|
||||
try {
|
||||
data = JSON.parse(message);
|
||||
|
||||
if (!Array.isArray(data)) throw new Error("Message is not an array");
|
||||
|
||||
// Pass the data to appropriate handler
|
||||
switch (data[0]) {
|
||||
case "REQ":
|
||||
case "COUNT":
|
||||
await this.handleSubscriptionMessage(data);
|
||||
break;
|
||||
case "EVENT":
|
||||
await this.handleEventMessage(data);
|
||||
break;
|
||||
case "CLOSE":
|
||||
await this.handleCloseMessage(data);
|
||||
break;
|
||||
}
|
||||
} catch (err) {
|
||||
this.log("Failed to handle message", message, err);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
handleSubscriptionMessage(data: any[]) {
|
||||
const [_, id, ...filters] = data as [string, string, ...Filter[]];
|
||||
|
||||
let sub = this.subscriptions.get(id);
|
||||
if (sub) {
|
||||
sub.filters = filters;
|
||||
sub.fire();
|
||||
} else {
|
||||
sub = this.upstream.subscribe(filters, {
|
||||
onevent: (event) => this.send(["EVENT", id, event]),
|
||||
onclose: (reason) => this.send(["CLOSED", id, reason]),
|
||||
oneose: () => this.send(["EOSE", id]),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
handleCloseMessage(data: any[]) {
|
||||
const [_, id] = data as [string, string, ...Filter[]];
|
||||
|
||||
let sub = this.subscriptions.get(id);
|
||||
if (sub) {
|
||||
sub.close();
|
||||
this.subscriptions.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
async handleEventMessage(data: any[]) {
|
||||
const [_, event] = data as [string, NostrEvent];
|
||||
|
||||
try {
|
||||
const result = await this.upstream.publish(event);
|
||||
this.peer.send(JSON.stringify(["OK", event.id, true, result]));
|
||||
} catch (error) {
|
||||
if (error instanceof Error) this.peer.send(JSON.stringify(["OK", event.id, false, error.message]));
|
||||
}
|
||||
}
|
||||
|
||||
handleDisconnect() {
|
||||
for (const [id, sub] of this.subscriptions) sub.close();
|
||||
this.subscriptions.clear();
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.peer.off("message", this.handleMessage, this);
|
||||
this.peer.off("disconnected", this.handleDisconnect, this);
|
||||
}
|
||||
}
|
@@ -17,7 +17,7 @@ export default function useThreadTimelineLoader(
|
||||
const refs = focusedEvent && getThreadReferences(focusedEvent);
|
||||
const rootPointer = refs?.root?.e || (focusedEvent && { id: focusedEvent?.id });
|
||||
|
||||
const readRelays = unique([...relays, ...(rootPointer?.relays ?? [])]);
|
||||
const readRelays = useMemo(() => unique([...relays, ...(rootPointer?.relays ?? [])]), [relays, rootPointer?.relays]);
|
||||
|
||||
const timelineId = `${rootPointer?.id}-thread`;
|
||||
const timeline = useTimelineLoader(
|
||||
|
@@ -1,3 +1,6 @@
|
||||
import { generateSecretKey } from "nostr-tools";
|
||||
import { bytesToHex, hexToBytes } from "@noble/hashes/utils";
|
||||
|
||||
import { PersistentSubject } from "../classes/subject";
|
||||
|
||||
class NullableLocalStorageEntry<T = string> extends PersistentSubject<T | null> {
|
||||
@@ -49,25 +52,40 @@ class LocalStorageEntry<T = string> extends PersistentSubject<T> {
|
||||
decode?: (raw: string) => T;
|
||||
encode?: (value: T) => string | null;
|
||||
|
||||
constructor(key: string, fallback: T, decode?: (raw: string) => T, encode?: (value: T) => string | null) {
|
||||
setDefault = false;
|
||||
|
||||
constructor(
|
||||
key: string,
|
||||
fallback: T,
|
||||
decode?: (raw: string) => T,
|
||||
encode?: (value: T) => string | null,
|
||||
setDefault = false,
|
||||
) {
|
||||
let value = fallback;
|
||||
if (localStorage.hasOwnProperty(key)) {
|
||||
const raw = localStorage.getItem(key);
|
||||
|
||||
if (decode && raw) value = decode(raw);
|
||||
else if (raw) value = raw as T;
|
||||
} else if (setDefault) {
|
||||
const encoded = encode ? encode(fallback) : String(fallback);
|
||||
if (!encoded) throw new Error("encode can not return null when setDefault is set");
|
||||
localStorage.setItem(key, encoded);
|
||||
}
|
||||
|
||||
super(value);
|
||||
|
||||
this.key = key;
|
||||
this.decode = decode;
|
||||
this.encode = encode;
|
||||
this.fallback = fallback;
|
||||
this.setDefault = setDefault;
|
||||
}
|
||||
|
||||
next(value: T) {
|
||||
const encoded = this.encode ? this.encode(value) : String(value);
|
||||
if (encoded !== null) localStorage.setItem(this.key, encoded);
|
||||
else if (this.setDefault && encoded) localStorage.setItem(this.key, encoded);
|
||||
else localStorage.removeItem(this.key);
|
||||
|
||||
super.next(value);
|
||||
@@ -112,10 +130,27 @@ const enableNoteThreadDrawer = new LocalStorageEntry(
|
||||
(v) => String(v),
|
||||
);
|
||||
|
||||
// webrtc relay
|
||||
const webRtcUseLocalIdentity = new LocalStorageEntry(
|
||||
"nostr-webrtc-use-identity",
|
||||
true,
|
||||
(raw) => raw === "true",
|
||||
(v) => String(v),
|
||||
);
|
||||
const webRtcLocalIdentity = new LocalStorageEntry(
|
||||
"nostr-webrtc-identity",
|
||||
generateSecretKey(),
|
||||
(raw) => hexToBytes(raw),
|
||||
(key) => bytesToHex(key),
|
||||
true,
|
||||
);
|
||||
|
||||
const localSettings = {
|
||||
idbMaxEvents,
|
||||
wasmPersistForDays,
|
||||
enableNoteThreadDrawer,
|
||||
webRtcUseLocalIdentity,
|
||||
webRtcLocalIdentity,
|
||||
};
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
|
113
src/services/webrtc-relays.ts
Normal file
113
src/services/webrtc-relays.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { NostrEvent, SimplePool } from "nostr-tools";
|
||||
import { AbstractRelay } from "nostr-tools/abstract-relay";
|
||||
|
||||
import { logger } from "../helpers/debug";
|
||||
import NostrWebRtcBroker from "../classes/nostr-webrtc-broker";
|
||||
import WebRtcRelayClient from "../classes/webrtc-relay-client";
|
||||
import WebRtcRelayServer from "../classes/webrtc-relay-server";
|
||||
import verifyEventMethod from "./verify-event";
|
||||
import SimpleSigner from "../classes/simple-signer";
|
||||
import { localRelay } from "./local-relay";
|
||||
import localSettings from "./local-settings";
|
||||
|
||||
class WebRtcRelaysService {
|
||||
log = logger.extend("NostrWebRtcBroker");
|
||||
broker: NostrWebRtcBroker;
|
||||
upstream: AbstractRelay | null;
|
||||
|
||||
approved: string[] = [];
|
||||
|
||||
calls: NostrEvent[] = [];
|
||||
get answered() {
|
||||
return this.calls.filter((event) => this.broker.peers.has(event.pubkey));
|
||||
}
|
||||
get unanswered() {
|
||||
return this.calls.filter((event) => this.broker.peers.has(event.pubkey) === false);
|
||||
}
|
||||
|
||||
clients = new Map<string, WebRtcRelayClient>();
|
||||
servers = new Map<string, WebRtcRelayServer>();
|
||||
|
||||
get relays() {
|
||||
return Array.from(this.clients.values());
|
||||
}
|
||||
|
||||
constructor(broker: NostrWebRtcBroker, upstream: AbstractRelay | null) {
|
||||
this.upstream = upstream;
|
||||
this.broker = broker;
|
||||
}
|
||||
|
||||
async handleCall(event: NostrEvent) {
|
||||
if (!this.calls.includes(event)) {
|
||||
this.log(`Received request from ${event.pubkey}`);
|
||||
this.calls.push(event);
|
||||
}
|
||||
|
||||
if (this.approved.includes(event.pubkey)) {
|
||||
this.log(`Answering call from ${event.pubkey}`);
|
||||
const peer = await this.broker.answerCall(event);
|
||||
if (!peer.peer) return;
|
||||
|
||||
if (this.upstream) {
|
||||
const server = new WebRtcRelayServer(peer, this.upstream);
|
||||
this.servers.set(peer.peer, server);
|
||||
}
|
||||
|
||||
const client = new WebRtcRelayClient(peer, {
|
||||
websocketImplementation: WebSocket,
|
||||
verifyEvent: verifyEventMethod,
|
||||
});
|
||||
this.clients.set(peer.peer, client);
|
||||
}
|
||||
}
|
||||
|
||||
async acceptCall(event: NostrEvent) {
|
||||
this.log(`Accepting connection from ${event.pubkey}`);
|
||||
this.approved.push(event.pubkey);
|
||||
await this.handleCall(event);
|
||||
}
|
||||
|
||||
async connect(uri: string) {
|
||||
this.log(`Connecting to ${uri}`);
|
||||
const peer = await this.broker.requestConnection(uri);
|
||||
if (!peer.peer) return;
|
||||
|
||||
if (this.upstream) {
|
||||
const server = new WebRtcRelayServer(peer, this.upstream);
|
||||
this.servers.set(peer.peer, server);
|
||||
}
|
||||
|
||||
const client = new WebRtcRelayClient(peer, {
|
||||
websocketImplementation: WebSocket,
|
||||
verifyEvent: verifyEventMethod,
|
||||
});
|
||||
this.clients.set(peer.peer, client);
|
||||
await client.connect();
|
||||
}
|
||||
|
||||
start() {
|
||||
this.broker.listenForCalls();
|
||||
this.broker.on("call", this.handleCall, this);
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.broker.stopListening();
|
||||
this.broker.off("call", this.handleCall, this);
|
||||
}
|
||||
}
|
||||
|
||||
const signer = new SimpleSigner(localSettings.webRtcLocalIdentity.value);
|
||||
|
||||
const webRtcRelaysService = new WebRtcRelaysService(
|
||||
new NostrWebRtcBroker(signer, new SimplePool(), ["wss://nos.lol"]),
|
||||
localRelay as AbstractRelay | null,
|
||||
);
|
||||
|
||||
webRtcRelaysService.start();
|
||||
|
||||
// if (import.meta.env.DEV) {
|
||||
// @ts-expect-error
|
||||
window.webRtcRelaysService = webRtcRelaysService;
|
||||
// }
|
||||
|
||||
export default webRtcRelaysService;
|
@@ -91,15 +91,19 @@ export default function RelaysView() {
|
||||
NIP-05 Relays
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
as={RouterLink}
|
||||
to="/relays/contacts"
|
||||
leftIcon={<UserSquare boxSize={6} />}
|
||||
colorScheme={location.pathname.startsWith("/relays/contacts") ? "primary" : undefined}
|
||||
>
|
||||
Contact List Relays
|
||||
</Button>
|
||||
{account && (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
as={RouterLink}
|
||||
to="/relays/contacts"
|
||||
leftIcon={<UserSquare boxSize={6} />}
|
||||
colorScheme={location.pathname.startsWith("/relays/contacts") ? "primary" : undefined}
|
||||
>
|
||||
Contact List Relays
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{/* {account && (
|
||||
<>
|
||||
<Heading size="sm" mt="2">
|
||||
|
@@ -1,30 +1,122 @@
|
||||
import { Button, ButtonGroup, Code, Flex, Heading, Link, Text } from "@chakra-ui/react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
ButtonGroup,
|
||||
Flex,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
Heading,
|
||||
Input,
|
||||
useForceUpdate,
|
||||
useInterval,
|
||||
} from "@chakra-ui/react";
|
||||
import { getPublicKey, nip19 } from "nostr-tools";
|
||||
|
||||
import BackButton from "../../../components/router/back-button";
|
||||
import useCurrentAccount from "../../../hooks/use-current-account";
|
||||
import { useUserDNSIdentity } from "../../../hooks/use-user-dns-identity";
|
||||
import { Link as RouterLink } from "react-router-dom";
|
||||
|
||||
import { RelayFavicon } from "../../../components/relay-favicon";
|
||||
import { QrCodeIcon } from "../../../components/icons";
|
||||
import webRtcRelaysService from "../../../services/webrtc-relays";
|
||||
import useSubject from "../../../hooks/use-subject";
|
||||
import localSettings from "../../../services/local-settings";
|
||||
import { CopyIconButton } from "../../../components/copy-icon-button";
|
||||
import QRCodeScannerButton from "../../../components/qr-code/qr-code-scanner-button";
|
||||
import UserAvatar from "../../../components/user/user-avatar";
|
||||
import UserName from "../../../components/user/user-name";
|
||||
|
||||
import "./connect";
|
||||
function WebRtcRelaysPage() {
|
||||
const update = useForceUpdate();
|
||||
const identity = useSubject(localSettings.webRtcLocalIdentity);
|
||||
const pubkey = useMemo(() => getPublicKey(identity), [identity]);
|
||||
const npub = useMemo(() => nip19.npubEncode(pubkey), [pubkey]);
|
||||
|
||||
const uri = "webrtc+nostr:" + npub;
|
||||
|
||||
const [connectURI, setConnectURI] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
webRtcRelaysService.broker.on("call", update);
|
||||
|
||||
return () => {
|
||||
webRtcRelaysService.broker.off("call", update);
|
||||
};
|
||||
}, [update]);
|
||||
|
||||
useInterval(update, 1000);
|
||||
|
||||
export default function WebRtcRelaysView() {
|
||||
return (
|
||||
<Flex gap="2" direction="column" overflow="auto hidden" flex={1} px={{ base: "2", lg: 0 }}>
|
||||
<Flex gap="2" alignItems="center" wrap="wrap">
|
||||
<BackButton hideFrom="lg" size="sm" />
|
||||
<Heading size="lg">WebRTC Relays</Heading>
|
||||
|
||||
<ButtonGroup size="sm" ml="auto">
|
||||
<Button leftIcon={<QrCodeIcon />}>Pair</Button>
|
||||
<Button colorScheme="primary">Connect</Button>
|
||||
</ButtonGroup>
|
||||
</Flex>
|
||||
|
||||
{/* <Text fontStyle="italic" mt="-2">
|
||||
These relays cant be modified by noStrudel, they must be set manually on your
|
||||
</Text> */}
|
||||
<FormControl>
|
||||
<FormLabel>WebRTC Connection URI</FormLabel>
|
||||
<Flex gap="2" alignItems="center">
|
||||
<UserAvatar pubkey={pubkey} size="sm" />
|
||||
<Input readOnly userSelect="all" value={uri} />
|
||||
<CopyIconButton value={uri} aria-label="Copy Npub" />
|
||||
</Flex>
|
||||
</FormControl>
|
||||
|
||||
<Heading size="md">Connect:</Heading>
|
||||
<Flex gap="2">
|
||||
<Input placeholder="webrtc+nostr:npub1..." value={connectURI} onChange={(e) => setConnectURI(e.target.value)} />
|
||||
<QRCodeScannerButton onData={(data) => setConnectURI(data)} />
|
||||
<Button
|
||||
colorScheme="primary"
|
||||
onClick={() => {
|
||||
webRtcRelaysService.connect(connectURI);
|
||||
setConnectURI("");
|
||||
}}
|
||||
>
|
||||
Connect
|
||||
</Button>
|
||||
</Flex>
|
||||
|
||||
{webRtcRelaysService.answered.length > 0 && (
|
||||
<>
|
||||
<Heading size="md">Connections:</Heading>
|
||||
{webRtcRelaysService.answered.map((event) => (
|
||||
<Flex key={event.id} borderWidth="1px" rounded="md" p="2" alignItems="center" gap="2">
|
||||
<UserAvatar pubkey={event.pubkey} size="sm" />
|
||||
<UserName pubkey={event.pubkey} />
|
||||
<Button size="sm" ml="auto" colorScheme="red">
|
||||
Close
|
||||
</Button>
|
||||
</Flex>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{webRtcRelaysService.unanswered.length > 0 && (
|
||||
<>
|
||||
<Heading size="md">Connection Requests:</Heading>
|
||||
{webRtcRelaysService.unanswered.map((event) => (
|
||||
<Flex key={event.id} borderWidth="1px" rounded="md" p="2" alignItems="center" gap="2">
|
||||
<UserAvatar pubkey={event.pubkey} size="sm" />
|
||||
<UserName pubkey={event.pubkey} />
|
||||
<Button
|
||||
size="sm"
|
||||
ml="auto"
|
||||
colorScheme="green"
|
||||
onClick={() => {
|
||||
webRtcRelaysService.acceptCall(event);
|
||||
update();
|
||||
}}
|
||||
>
|
||||
Accept
|
||||
</Button>
|
||||
</Flex>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
export default function WebRtcRelaysView() {
|
||||
if (webRtcRelaysService) {
|
||||
return <WebRtcRelaysPage />;
|
||||
}
|
||||
return <Heading>WebRTC Relays don't work without</Heading>;
|
||||
}
|
||||
|
Reference in New Issue
Block a user