Files
connect/src/rpc.ts
2023-01-23 12:15:21 +01:00

310 lines
7.0 KiB
TypeScript

import EventEmitter from 'events';
import {
Event,
Filter,
getEventHash,
getPublicKey,
nip04,
Relay,
relayInit,
signEvent,
Sub,
validateEvent,
verifySignature,
} from 'nostr-tools';
export interface NostrRPCRequest {
id: string;
method: string;
params: any[];
}
export interface NostrRPCResponse {
id: string;
result: any;
error: any;
}
export class NostrRPC {
relay: string;
self: { pubkey: string; secret: string };
event: Event | undefined;
// this is for implementing the response handlers for each method
[key: string]: any;
// events
events = new EventEmitter();
constructor(opts: { relay?: string; secretKey: string }) {
this.relay = opts.relay || 'wss://nostr.vulpem.com';
this.self = {
pubkey: getPublicKey(opts.secretKey),
secret: opts.secretKey,
};
}
async call(
{
target,
request: { id = randomID(), method, params = [] },
}: {
target: string;
request: {
id?: string;
method: string;
params?: any[];
};
},
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);
await broadcastToRelay(relay, event);
// waiting for response from remote
if (opts && opts.skipResponse === true) return Promise.resolve();
return new Promise<void>((resolve, reject) => {
const sub = relay.sub([
{
kinds: [24133],
authors: [target],
'#p': [this.self.pubkey],
limit: 1,
} as Filter,
]);
sub.on('event', async (event: Event) => {
let payload;
/* eslint-disable @typescript-eslint/no-unused-vars */
try {
const plaintext = await nip04.decrypt(
this.self.secret,
event.pubkey,
event.content
);
if (!plaintext) throw new Error('failed to decrypt event');
payload = JSON.parse(plaintext);
} catch (ignore) {
return;
}
// ignore all the events that are not NostrRPCResponse events
if (!isValidResponse(payload)) return;
// ignore all the events that are not for this request
if (payload.id !== id) return;
// if the response is an error, reject the promise
if (payload.error) {
reject(payload.error);
}
// if the response is a result, resolve the promise
if (payload.result) {
resolve(payload.result);
}
});
});
}
async listen(): Promise<Sub> {
const relay = await connectToRelay(this.relay);
const sub = relay.sub([
{
kinds: [24133],
'#p': [this.self.pubkey],
since: now(),
} as Filter,
]);
sub.on('event', async (event: Event) => {
let payload: NostrRPCRequest;
/* eslint-disable @typescript-eslint/no-unused-vars */
try {
const plaintext = await nip04.decrypt(
this.self.secret,
event.pubkey,
event.content
);
if (!plaintext) throw new Error('failed to decrypt event');
payload = JSON.parse(plaintext);
} catch (ignore) {
return;
}
// ignore all the events that are not NostrRPCRequest events
if (!isValidRequest(payload)) return;
// handle request
if (typeof this[payload.method] !== 'function') Promise.resolve();
const response = await this.handleRequest(payload, event);
const body = prepareResponse(
response.id,
response.result,
response.error
);
const responseEvent = await prepareEvent(
this.self.secret,
event.pubkey,
body
);
// send response via relay
await new Promise<void>((resolve, reject) => {
const pub = relay.publish(responseEvent);
pub.on('failed', (reason: any) => {
reject(reason);
});
pub.on('seen', () => {
resolve();
});
});
});
return sub;
}
private async handleRequest(
request: NostrRPCRequest,
event: Event
): Promise<NostrRPCResponse> {
const { id, method, params } = request;
let result = null;
let error = null;
try {
this.event = event;
result = await this[method](...params);
this.event = undefined;
} catch (e) {
if (e instanceof Error) {
error = e.message;
} else {
error = 'unknown error';
}
}
return {
id,
result,
error,
};
}
}
export function now(): number {
return Math.floor(Date.now() / 1000);
}
export function randomID(): string {
return Math.random()
.toString()
.slice(2);
}
export function prepareRequest(
id: string,
method: string,
params: any[]
): string {
return JSON.stringify({
id,
method,
params,
});
}
export function prepareResponse(id: string, result: any, error: any): string {
return JSON.stringify({
id,
result,
error,
});
}
export async function prepareEvent(
secretKey: string,
pubkey: string,
content: string
): Promise<Event> {
const cipherText = await nip04.encrypt(secretKey, pubkey, content);
const event: Event = {
kind: 24133,
created_at: now(),
pubkey: getPublicKey(secretKey),
tags: [['p', pubkey]],
content: cipherText,
};
const id = getEventHash(event);
const sig = signEvent(event, secretKey);
const signedEvent = { ...event, id, sig };
const ok = validateEvent(signedEvent);
const veryOk = verifySignature(signedEvent);
if (!ok || !veryOk) {
throw new Error('Event is not valid');
}
return signedEvent;
}
export function isValidRequest(payload: any): boolean {
if (!payload) return false;
const keys = Object.keys(payload);
if (
!keys.includes('id') ||
!keys.includes('method') ||
!keys.includes('params')
)
return false;
return true;
}
export function isValidResponse(payload: any): boolean {
if (!payload) return false;
const keys = Object.keys(payload);
if (
!keys.includes('id') ||
!keys.includes('result') ||
!keys.includes('error')
)
return false;
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(new Error(`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(new Error(`failed to connect to ${relay.url}`));
});
const pub = relay.publish(event);
pub.on('failed', (reason: any) => {
reject(reason);
});
pub.on('seen', () => {
resolve();
});
});
}