support for connect & disconnect

This commit is contained in:
tiero
2023-01-04 03:35:48 +01:00
parent f8f67d6749
commit d7903a88bd
9 changed files with 245 additions and 203 deletions

View File

@@ -5,9 +5,10 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
<title>Playground</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.9.4/css/bulma.min.css">
</head>
<body style="background-color:lightgray;"}}>
<body>
<div id="root"></div>
<script src="./index.tsx" type="module"></script>
</body>

View File

@@ -1,97 +0,0 @@
import 'react-app-polyfill/ie11';
import * as React from 'react';
import { useEffect } from 'react';
import * as ReactDOM from 'react-dom';
import { Session } from '../src/index';
import { generatePrivateKey, getPublicKey, nip04, relayInit } from 'nostr-tools'
const App = () => {
/* useEffect(() => {
(async () => {
})();
}, []); */
const [walletKey, setWalletKey] = React.useState<{ pk: string; sk: string }>();
const [sessionHolder, setSession] = React.useState<Session>();
const [request, setRequest] = React.useState<any>();
const newWallet = () => {
//this is the wallet public key
let sk = generatePrivateKey()
let pk = getPublicKey(sk)
setWalletKey({ pk, sk });
}
const newSession = async () => {
const session = new Session({
name: 'Auth',
description: 'lorem ipsum dolor sit amet',
url: 'https://vulpem.com',
icons: ['https://vulpem.com/1000x860-p-500.422be1bc.png'],
});
await session.pair(walletKey?.pk);
setSession(session);
};
const listen = async () => {
if (!sessionHolder) return;
// let's query for an event to this wallet pub key
const relay = relayInit('wss://nostr.vulpem.com');
await relay.connect()
relay.on('connect', () => {
console.log(`wallet: connected to ${relay.url}`)
})
relay.on('error', () => {
console.log(`wallet: failed to connect to ${relay.url}`)
})
let sub = relay.sub([{ kinds: [4] }])
// on the receiver side
sub.on('event', async (event) => {
if (!walletKey) return;
const mention = event.tags.find(([k, v]) => k === 'p' && v && v !== '')[1]
if (mention !== walletKey.pk) return;
const plaintext = await nip04.decrypt(walletKey?.sk, sessionHolder.pubKey, event.content);
console.log('wallet', event.id, event.pubkey, JSON.parse(plaintext));
setRequest(JSON.parse(plaintext));
})
sub.on('eose', () => {
sub.unsub()
})
}
return (
<div>
<h1>💸 Wallet</h1>
{walletKey && <p> 🔎 Wallet Pub: {walletKey?.pk} </p>}
<button onClick={newWallet}>Create Wallet</button>
<button disabled={!sessionHolder} onClick={listen}>Listen</button>
{request && <div>
<h1>📨 Incoming Request</h1>
<img height={80} src={request?.icons[0]} />
<p>
Name <b>{request?.name}</b>
</p>
<p>
Description <b>{request?.description}</b>
</p>
<p>
URL: <b>{request?.url}</b>
</p>
</div>}
<hr />
<h1> App </h1>
<button disabled={!walletKey} onClick={newSession}>New Session</button>
</div>
);
};
ReactDOM.render(<App />, document.getElementById('root'));

View File

@@ -1,57 +1,151 @@
import * as React from 'react';
import { useEffect, useState } from 'react';
import { useStatePersist } from 'use-state-persist';
import * as ReactDOM from 'react-dom';
import { NostrRPC } from '../src/rpc';
import { broadcastToRelay, Connect, connectToRelay, ConnectURI } from '../src';
import { QRCodeSVG } from 'qrcode.react';
import { getEventHash, getPublicKey, Event } from 'nostr-tools';
class Server extends NostrRPC {
async ping(): Promise<string> {
return 'pong';
}
}
const server = new Server({
secretKey:
'ed779ff047f99c95f732b22c9f8f842afb870c740aab591776ebc7b64e83cf6c',
});
const client = new NostrRPC({
secretKey:
'5acff99d1ad3e1706360d213fd69203312d9b5e91a2d5f2e06100cc6f686e5b3',
const secretKey = "5acff99d1ad3e1706360d213fd69203312d9b5e91a2d5f2e06100cc6f686e5b3";
const connectURI = new ConnectURI({
target: getPublicKey(secretKey),
relayURL: 'wss://nostr.vulpem.com',
metadata: {
name: 'Example',
description: '🔉🔉🔉',
url: 'https://example.com',
icons: ['https://example.com/icon.png'],
},
});
const App = () => {
const [response, setResponse] = useState('');
const [pubkey, setPubkey] = useStatePersist('@pubkey', '');
const [getPublicKeyReply, setGetPublicKeyReply] = useState('');
const [eventWithSig, setEvent] = useState({});
useEffect(() => {
(async () => {
const sub = await server.listen();
sub.on('event', (evt) => {
console.log('server received message', evt);
const target = pubkey.length > 0 ? pubkey : undefined;
const connect = new Connect({
secretKey,
target,
});
connect.events.on('connect', (pubkey: string) => {
console.log('We are connected to ' + pubkey)
setPubkey(pubkey);
});
connect.events.on('disconnect', () => {
console.log('We got disconnected')
setEvent({});
setPubkey('');
setGetPublicKeyReply('');
});
await connect.init();
})();
}, []);
const makeCall = async () => {
const result = await client.call({
target: server.self.pubkey,
request: { method: 'ping' },
const getPub = async () => {
if (pubkey.length === 0) return;
const connect = new Connect({
secretKey,
target: pubkey,
});
setResponse(result);
const pk = await connect.getPublicKey();
setGetPublicKeyReply(pk);
}
const sendMessage = async () => {
if (pubkey.length === 0) return;
const connect = new Connect({
secretKey,
target: pubkey,
});
let event: Event = {
kind: 1,
pubkey: pubkey,
created_at: Math.floor(Date.now() / 1000),
tags: [],
content: "Running Nostr Connect 🔌"
};
event.id = getEventHash(event)
event.sig = await connect.signEvent(event);
const relay = await connectToRelay('wss://relay.damus.io');
await broadcastToRelay(relay, event);
setEvent(event);
}
const isConnected = () => {
return pubkey.length > 0;
}
return (
<div>
<h1>Nostr Connect Playground</h1>
<p>Server pubkey: {server.self.pubkey}</p>
<p>Client pubkey: {client.self.pubkey}</p>
<hr />
<button onClick={makeCall}>Ping</button>
<br />
<p> {response} </p>
<>
<section className="container">
<div className='content'>
<h1 className='title'>Nostr Connect Playground</h1>
</div>
<div className='content'>
<p className='subtitle is-6'><strong>Nostr ID</strong> {getPublicKey(secretKey)}</p>
</div>
<div className='content'>
<p className='subtitle is-6'><strong>Status</strong> {isConnected() ? '🟢 Connected' : '🔴 Disconnected'}</p>
</div>
{!isConnected() && <div className='content has-text-centered'>
<div className='notification is-info'>
<h2 className='title is-5'>Connect with Nostr</h2>
<QRCodeSVG value={connectURI.toString()} />
<input
className='input is-info'
type='text'
value={connectURI.toString()}
readOnly
/>
</div>
</div>}
</section>
<section className="container mt-6">
{
isConnected() &&
<>
<div className='content'>
<h2 className='title is-5'>Get Public Key</h2>
<button className='button is-info' onClick={getPub}>
Get public key
</button>
{getPublicKeyReply.length > 0 && <input
className='input is-info mt-3'
type='text'
value={getPublicKeyReply}
readOnly
/>}
</div>
<div className='content'>
<h2 className='title is-5'>Send a message with text <b>Running Nostr Connect 🔌</b></h2>
<button className='button is-info' onClick={sendMessage}>
Send message to Nostr
</button>
{
Object.keys(eventWithSig).length > 0 &&
<textarea
className="textarea"
readOnly
rows={12}
defaultValue={JSON.stringify(eventWithSig, null, 2)}
/>
}
</div>
</>
}
</section>
</>
)
}
ReactDOM.render(<App />, document.getElementById('root'));

View File

@@ -8,7 +8,9 @@
"build": "parcel build index.html"
},
"dependencies": {
"react-app-polyfill": "^1.0.0"
"qrcode.react": "^3.1.0",
"react-app-polyfill": "^1.0.0",
"use-state-persist": "^0.3.1"
},
"alias": {
"react": "../node_modules/react",
@@ -18,7 +20,10 @@
"devDependencies": {
"@types/react": "^16.9.11",
"@types/react-dom": "^16.8.4",
"events": "^3.1.0",
"parcel": "^2.8.2",
"path-browserify": "^1.0.0",
"process": "^0.11.10",
"typescript": "^3.4.5"
}
}

View File

@@ -1002,6 +1002,16 @@ escape-string-regexp@^1.0.5:
resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4"
integrity sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==
events@^3.1.0:
version "3.3.0"
resolved "https://registry.yarnpkg.com/events/-/events-3.3.0.tgz#31a95ad0a924e2d2c419a813aeb2c4e878ea7400"
integrity sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==
fast-deep-equal@^3.1.3:
version "3.1.3"
resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525"
integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==
get-port@^4.2.0:
version "4.2.0"
resolved "https://registry.yarnpkg.com/get-port/-/get-port-4.2.0.tgz#e37368b1e863b7629c43c5a323625f95cf24b119"
@@ -1265,6 +1275,11 @@ parse-json@^5.0.0:
json-parse-even-better-errors "^2.3.0"
lines-and-columns "^1.1.6"
path-browserify@^1.0.0:
version "1.0.1"
resolved "https://registry.yarnpkg.com/path-browserify/-/path-browserify-1.0.1.tgz#d98454a9c3753d5790860f16f68867b9e46be1fd"
integrity sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==
path-type@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b"
@@ -1314,6 +1329,11 @@ posthtml@^0.16.4, posthtml@^0.16.5:
posthtml-parser "^0.11.0"
posthtml-render "^3.0.0"
process@^0.11.10:
version "0.11.10"
resolved "https://registry.yarnpkg.com/process/-/process-0.11.10.tgz#7332300e840161bda3e69a1d1d91a7d4bc16f182"
integrity sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==
promise@^8.0.3:
version "8.3.0"
resolved "https://registry.yarnpkg.com/promise/-/promise-8.3.0.tgz#8cb333d1edeb61ef23869fbb8a4ea0279ab60e0a"
@@ -1321,6 +1341,11 @@ promise@^8.0.3:
dependencies:
asap "~2.0.6"
qrcode.react@^3.1.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/qrcode.react/-/qrcode.react-3.1.0.tgz#5c91ddc0340f768316fbdb8fff2765134c2aecd8"
integrity sha512-oyF+Urr3oAMUG/OiOuONL3HXM+53wvuH3mtIWQrYmsXoAq0DkvZp2RYUWFSMFtbdOpuS++9v+WAkzNVkMlNW6Q==
raf@^3.4.1:
version "3.4.1"
resolved "https://registry.yarnpkg.com/raf/-/raf-3.4.1.tgz#0742e99a4a6552f445d73e3ee0328af0ff1ede39"
@@ -1458,6 +1483,13 @@ update-browserslist-db@^1.0.9:
escalade "^3.1.1"
picocolors "^1.0.0"
use-state-persist@^0.3.1:
version "0.3.1"
resolved "https://registry.yarnpkg.com/use-state-persist/-/use-state-persist-0.3.1.tgz#ab65aacfdeb4adcba96a7bba60d5bdd7c1531d7c"
integrity sha512-bNU/9uZMNZDhFGSFfC2DOtsLvNU41FU2/87AVsC2kJuEKfqD4ksCdqGD9mUVfoGkahu4nZAMYvvmOVwpT2kTYw==
dependencies:
fast-deep-equal "^3.1.3"
utility-types@^3.10.0:
version "3.10.0"
resolved "https://registry.yarnpkg.com/utility-types/-/utility-types-3.10.0.tgz#ea4148f9a741015f05ed74fd615e1d20e6bed82b"

View File

@@ -60,6 +60,7 @@
"typescript": "^4.9.4"
},
"dependencies": {
"events": "^3.3.0",
"nostr-tools": "^1.0.1"
}
}

View File

@@ -9,13 +9,7 @@ export interface Metadata {
icons?: string[];
}
export enum SessionStatus {
Paired = 'paired',
Unpaired = 'unpaired',
}
export class ConnectURI {
status: SessionStatus = SessionStatus.Unpaired;
target: string;
metadata: Metadata;
relayURL: string;
@@ -68,14 +62,13 @@ export class ConnectURI {
relay: this.relayURL,
secretKey,
});
const response = await rpc.call({
await rpc.call({
target: this.target,
request: {
method: 'connect',
params: [getPublicKey(secretKey)],
},
});
if (!response) throw new Error('Invalid response from remote');
}, { skipResponse: true });
return;
}
@@ -85,29 +78,22 @@ export class ConnectURI {
relay: this.relayURL,
secretKey,
});
const response = await rpc.call({
await rpc.call({
target: this.target,
request: {
method: 'disconnect',
params: [],
},
});
if (!response) throw new Error('Invalid response from remote');
}, { skipResponse: true });
return;
}
}
export enum ConnectStatus {
Connected = 'connected',
Disconnected = 'disconnected',
}
export class Connect {
rpc: NostrRPC;
target?: string;
events = new EventEmitter();
status = ConnectStatus.Disconnected;
constructor({
target,
@@ -121,7 +107,6 @@ export class Connect {
this.rpc = new NostrRPC({ relay, secretKey });
if (target) {
this.target = target;
this.status = ConnectStatus.Connected;
}
}
@@ -140,19 +125,24 @@ export class Connect {
} catch (ignore) {
return;
}
// ignore all the events that are not NostrRPCRequest events
if (!isValidRequest(payload)) return;
// ignore all the request that are not connect
if (payload.method !== 'connect') return;
// ignore all the request that are not for us
switch (payload.method) {
case 'connect':
if (!payload.params || payload.params.length !== 1) return;
const [pubkey] = payload.params;
this.status = ConnectStatus.Connected;
this.target = pubkey;
this.events.emit('connect', pubkey);
return
case 'disconnect':
this.target = undefined;
this.events.emit('disconnect');
return;
default:
return;
}
});
}
@@ -163,12 +153,8 @@ export class Connect {
this.events.off(evt, cb);
}
private isConnected() {
return this.status === ConnectStatus.Connected;
}
async getPublicKey(): Promise<string> {
if (!this.target || !this.isConnected()) throw new Error('Not connected');
if (!this.target) throw new Error('Not connected');
const response = await this.rpc.call({
target: this.target,
@@ -180,8 +166,19 @@ export class Connect {
return response as string;
}
async signEvent(_event: Event): Promise<Event> {
throw new Error('Not implemented');
async signEvent(event: Event): Promise<string> {
if (!this.target) throw new Error('Not connected');
const signature = await this.rpc.call({
target: this.target,
request: {
method: 'sign_event',
params: [event],
},
});
console.log('signature', signature);
return signature as string;
}
async getRelays(): Promise<{

View File

@@ -9,6 +9,7 @@ import {
Event,
Sub,
Filter,
Relay,
} from 'nostr-tools';
export interface NostrRPCRequest {
@@ -46,38 +47,19 @@ export class NostrRPC {
method: string;
params?: any[];
};
}): Promise<any> {
const relay = await relayInit(this.relay);
}, opts?: { skipResponse?: boolean, timeout?: number }): Promise<any> {
// connect to relay
const relay = await connectToRelay(this.relay);
// prepare request to be sent
const request = prepareRequest(id, method, params);
const event = await prepareEvent(this.self.secret, target, request);
// connect to relay
await relay.connect();
await new Promise<void>((resolve, reject) => {
relay.on('connect', () => {
resolve();
});
relay.on('error', () => {
reject(`not possible to connect to ${relay.url}`);
});
});
// send request via relay
await new Promise<void>((resolve, reject) => {
relay.on('error', () => {
reject(`failed to connect to ${relay.url}`);
});
const pub = relay.publish(event);
pub.on('failed', (reason: any) => {
reject(reason);
});
pub.on('seen', () => {
resolve();
});
});
await broadcastToRelay(relay, event);
// waiting for response from remote
if (opts && opts.skipResponse === true) return Promise.resolve();
return new Promise<void>((resolve, reject) => {
let sub = relay.sub([
{
@@ -122,16 +104,7 @@ export class NostrRPC {
}
async listen(): Promise<Sub> {
const relay = relayInit(this.relay);
await relay.connect();
await new Promise<void>((resolve, reject) => {
relay.on('connect', () => {
resolve();
});
relay.on('error', () => {
reject(`not possible to connect to ${relay.url}`);
});
});
const relay = await connectToRelay(this.relay);
let sub = relay.sub([
{
@@ -159,6 +132,7 @@ export class NostrRPC {
if (!isValidRequest(payload)) return;
// handle request
if (typeof this[payload.method] !== 'function') Promise.resolve();
const response = await this.handleRequest(payload);
const body = prepareResponse(
@@ -292,3 +266,33 @@ export function isValidResponse(payload: any): boolean {
return true;
}
export async function connectToRelay(realayURL: string) {
const relay = relayInit(realayURL);
await relay.connect();
await new Promise<void>((resolve, reject) => {
relay.on('connect', () => {
resolve();
});
relay.on('error', () => {
reject(`not possible to connect to ${relay.url}`);
});
});
return relay;
}
export async function broadcastToRelay(relay: Relay, event: Event) {
// send request via relay
return await new Promise<void>((resolve, reject) => {
relay.on('error', () => {
reject(`failed to connect to ${relay.url}`);
});
const pub = relay.publish(event);
pub.on('failed', (reason: any) => {
reject(reason);
});
pub.on('seen', () => {
resolve();
});
});
}

View File

@@ -2984,6 +2984,11 @@ esutils@^2.0.2:
resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64"
integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==
events@^3.3.0:
version "3.3.0"
resolved "https://registry.yarnpkg.com/events/-/events-3.3.0.tgz#31a95ad0a924e2d2c419a813aeb2c4e878ea7400"
integrity sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==
exec-sh@^0.3.2:
version "0.3.6"
resolved "https://registry.yarnpkg.com/exec-sh/-/exec-sh-0.3.6.tgz#ff264f9e325519a60cb5e273692943483cca63bc"