diff --git a/.eslintrc.json b/.eslintrc.json index 1bf4935..3954f0d 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -1,5 +1,9 @@ { "root": true, + + "parser": "@typescript-eslint/parser", + "plugins": ["@typescript-eslint"], + "parserOptions": { "ecmaVersion": 9, "ecmaFeatures": { @@ -14,9 +18,7 @@ "node": true }, - "plugins": [ - "babel" - ], + "plugins": ["babel"], "globals": { "document": false, @@ -33,23 +35,23 @@ "rules": { "accessor-pairs": 2, - "arrow-spacing": [2, { "before": true, "after": true }], + "arrow-spacing": [2, {"before": true, "after": true}], "block-spacing": [2, "always"], - "brace-style": [2, "1tbs", { "allowSingleLine": true }], + "brace-style": [2, "1tbs", {"allowSingleLine": true}], "comma-dangle": 0, - "comma-spacing": [2, { "before": false, "after": true }], + "comma-spacing": [2, {"before": false, "after": true}], "comma-style": [2, "last"], "constructor-super": 2, "curly": [0, "multi-line"], "dot-location": [2, "property"], "eol-last": 2, "eqeqeq": [2, "allow-null"], - "generator-star-spacing": [2, { "before": true, "after": true }], - "handle-callback-err": [2, "^(err|error)$" ], + "generator-star-spacing": [2, {"before": true, "after": true}], + "handle-callback-err": [2, "^(err|error)$"], "indent": 0, "jsx-quotes": [2, "prefer-double"], - "key-spacing": [2, { "beforeColon": false, "afterColon": true }], - "keyword-spacing": [2, { "before": true, "after": true }], + "key-spacing": [2, {"beforeColon": false, "afterColon": true}], + "keyword-spacing": [2, {"before": true, "after": true}], "new-cap": 0, "new-parens": 0, "no-array-constructor": 2, @@ -81,12 +83,12 @@ "no-irregular-whitespace": 2, "no-iterator": 2, "no-label-var": 2, - "no-labels": [2, { "allowLoop": false, "allowSwitch": false }], + "no-labels": [2, {"allowLoop": false, "allowSwitch": false}], "no-lone-blocks": 2, "no-mixed-spaces-and-tabs": 2, "no-multi-spaces": 2, "no-multi-str": 2, - "no-multiple-empty-lines": [2, { "max": 2 }], + "no-multiple-empty-lines": [2, {"max": 2}], "no-native-reassign": 2, "no-negated-in-lhs": 2, "no-new": 0, @@ -115,23 +117,34 @@ "no-undef": 2, "no-undef-init": 2, "no-unexpected-multiline": 2, - "no-unneeded-ternary": [2, { "defaultAssignment": false }], + "no-unneeded-ternary": [2, {"defaultAssignment": false}], "no-unreachable": 2, - "no-unused-vars": [2, { "vars": "local", "args": "none", "varsIgnorePattern": "^_"}], + "no-unused-vars": [ + 2, + {"vars": "local", "args": "none", "varsIgnorePattern": "^_"} + ], "no-useless-call": 2, "no-useless-constructor": 2, "no-with": 2, - "one-var": [0, { "initialized": "never" }], - "operator-linebreak": [2, "after", { "overrides": { "?": "before", ":": "before" } }], + "one-var": [0, {"initialized": "never"}], + "operator-linebreak": [ + 2, + "after", + {"overrides": {"?": "before", ":": "before"}} + ], "padded-blocks": [2, "never"], - "quotes": [2, "single", { "avoidEscape": true, "allowTemplateLiterals": true }], + "quotes": [ + 2, + "single", + {"avoidEscape": true, "allowTemplateLiterals": true} + ], "semi": [2, "never"], - "semi-spacing": [2, { "before": false, "after": true }], + "semi-spacing": [2, {"before": false, "after": true}], "space-before-blocks": [2, "always"], "space-before-function-paren": 0, "space-in-parens": [2, "never"], "space-infix-ops": 2, - "space-unary-ops": [2, { "words": true, "nonwords": false }], + "space-unary-ops": [2, {"words": true, "nonwords": false}], "spaced-comment": 0, "template-curly-spacing": [2, "never"], "use-isnan": 2, diff --git a/event.js b/event.ts similarity index 66% rename from event.js rename to event.ts index 4d731ce..2b8750b 100644 --- a/event.js +++ b/event.ts @@ -1,18 +1,29 @@ import {Buffer} from 'buffer' +// @ts-ignore import createHash from 'create-hash' import * as secp256k1 from '@noble/secp256k1' -export function getBlankEvent() { +export type Event = { + id?: string + sig?: string + kind: number + tags: string[][] + pubkey: string + content: string + created_at: number +} + +export function getBlankEvent(): Event { return { kind: 255, - pubkey: null, + pubkey: '', content: '', tags: [], created_at: 0 } } -export function serializeEvent(evt) { +export function serializeEvent(evt: Event): string { return JSON.stringify([ 0, evt.pubkey, @@ -23,14 +34,14 @@ export function serializeEvent(evt) { ]) } -export function getEventHash(event) { +export function getEventHash(event: Event): string { let eventHash = createHash('sha256') .update(Buffer.from(serializeEvent(event))) .digest() return Buffer.from(eventHash).toString('hex') } -export function validateEvent(event) { +export function validateEvent(event: Event): boolean { if (event.id !== getEventHash(event)) return false if (typeof event.content !== 'string') return false if (typeof event.created_at !== 'number') return false @@ -47,11 +58,13 @@ export function validateEvent(event) { return true } -export function verifySignature(event) { +export function verifySignature( + event: Event & {id: string; sig: string} +): Promise { return secp256k1.schnorr.verify(event.sig, event.id, event.pubkey) } -export async function signEvent(event, key) { +export async function signEvent(event: Event, key: string): Promise { return Buffer.from( await secp256k1.schnorr.sign(getEventHash(event), key) ).toString('hex') diff --git a/filter.js b/filter.ts similarity index 56% rename from filter.js rename to filter.ts index 37cf3a5..3f9858d 100644 --- a/filter.js +++ b/filter.ts @@ -1,4 +1,15 @@ -export function matchFilter(filter, event) { +import {Event} from './event' + +export type Filter = { + ids?: string[] + kinds?: number[] + authors?: string[] + since?: number + until?: number + [key: `#${string}`]: string[] +} + +export function matchFilter(filter: Filter, event: Event & {id: string}) { if (filter.ids && filter.ids.indexOf(event.id) === -1) return false if (filter.kinds && filter.kinds.indexOf(event.kind) === -1) return false if (filter.authors && filter.authors.indexOf(event.pubkey) === -1) @@ -6,10 +17,12 @@ export function matchFilter(filter, event) { for (let f in filter) { if (f[0] === '#') { + let tagName = f.slice(1) + let values = filter[`#${tagName}`] if ( - filter[f] && + values && !event.tags.find( - ([t, v]) => t === f.slice(1) && filter[f].indexOf(v) !== -1 + ([t, v]) => t === f.slice(1) && values.indexOf(v) !== -1 ) ) return false @@ -22,7 +35,7 @@ export function matchFilter(filter, event) { return true } -export function matchFilters(filters, event) { +export function matchFilters(filters: Filter[], event: Event & {id: string}) { for (let i = 0; i < filters.length; i++) { if (matchFilter(filters[i], event)) return true } diff --git a/index.d.ts b/index.d.ts deleted file mode 100644 index fdfc424..0000000 --- a/index.d.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { type Buffer } from 'buffer'; - -// these should be available from the native @noble/secp256k1 type -// declarations, but they somehow aren't so instead: copypasta -declare type Hex = Uint8Array | string; -declare type PrivKey = Hex | bigint | number; - -declare enum EventKind { - Metadata = 0, - Text = 1, - RelayRec = 2, - Contacts = 3, - DM = 4, - Deleted = 5, -} - -// event.js -declare type Event = { - signature?:string, - id?:string - kind: EventKind, - pubkey?: string, - content: string, - tags: string[][], - created_at: number, -}; - -declare function getBlankEvent(): Event; -declare function serializeEvent(event: Event): string; -declare function getEventHash(event: Event): string; -declare function validateEvent(event: Event): boolean; -declare function validateSignature(event: Event): boolean; -declare function signEvent(event: Event, key: PrivKey): Promise; - -// filter.js -declare type Filter = { - ids?: string[], - kinds?: EventKind[], - authors?: string[], - since?: number, - until?: number, - "#e"?: string[], - "#p"?: string[], -}; - -declare function matchFilter(filter: Filter, event: Event): boolean; -declare function matchFilters(filters: Filter[], event: Event): boolean; - -// general -declare type ClientMessage = - ["EVENT", Event] | - ["REQ", string, Filter[]] | - ["CLOSE", string]; - -declare type ServerMessage = - ["EVENT", string, Event] | - ["NOTICE", unknown]; - -// keys.js -declare function generatePrivateKey(): string; -declare function getPublicKey(privateKey: Buffer): string; - -// pool.js -declare type RelayPolicy = { - read: boolean, - write: boolean, -}; - -declare type SubscriptionCallback = (event: Event, relay: string) => void; - -declare type SubscriptionOptions = { - cb: SubscriptionCallback, - filter: Filter, - skipVerification: boolean - // TODO: thread through how `beforeSend` actually works before trying to type it - // beforeSend(event: Event): -}; - -declare type Subscription = { - unsub(): void, -}; - -declare type PublishCallback = (status: number) => void; - -// relay.js -declare type Relay = { - url: string, - sub: SubscriptionCallback, - publish: (event: Event, cb: PublishCallback) => Promise, -}; - -declare type PoolPublishCallback = (status: number, relay: string) => void; - -declare type RelayPool = { - setPrivateKey(key: string): void, - addRelay(url: string, opts?: RelayPolicy): Relay, - removeRelay(url:string):void, - getRelayList():{url:string,policy:RelayPolicy}[], - relayChangePolicy():Relay, - sub(opts: SubscriptionOptions, id?: string): Subscription, - publish(event: Event, cb: PoolPublishCallback): Promise, - close: () => void, - status: number, -}; - -declare function relayPool(): RelayPool; - -// nip04.js -declare function decrypt(privkey: string, pubkey: string, ciphertext: string): string; -declare function encrypt(privkey: string, pubkey: string, text: string): string; -// nip05.js - -// nip06.js diff --git a/index.test-d.ts b/index.test-d.ts deleted file mode 100644 index 371febf..0000000 --- a/index.test-d.ts +++ /dev/null @@ -1,42 +0,0 @@ -import * as process from 'process'; -import { - relayPool, - getBlankEvent, - validateEvent, - RelayPool, - Event as NEvent -} from './index.js'; -import { expectType } from 'tsd'; - -const pool = relayPool(); -expectType(pool); - -const privkey = process.env.NOSTR_PRIVATE_KEY; -const pubkey = process.env.NOSTR_PUBLIC_KEY; - -const message = { - ...getBlankEvent(), - kind: 1, - content: `just saying hi from pid ${process.pid}`, - pubkey, -}; - -const publishCb = (status: number, url: string) => { - console.log({ status, url }); -}; - -pool.setPrivateKey(privkey!); - -const publishF = pool.publish(message, publishCb); -expectType>(publishF); - -publishF.then((event) => { - expectType(event); - - console.info({ event }); - - if (!validateEvent(event)) { - console.error(`event failed to validate!`); - process.exit(1); - } -}); diff --git a/index.js b/index.ts similarity index 56% rename from index.js rename to index.ts index a742c3a..acfa038 100644 --- a/index.js +++ b/index.ts @@ -1,6 +1,5 @@ -import { generatePrivateKey, getPublicKey } from './keys.js' -import { relayInit } from './relay.js' -import { relayPool } from './pool.js' +import {generatePrivateKey, getPublicKey} from './keys' +import {relayInit} from './relay' import { getBlankEvent, signEvent, @@ -8,13 +7,12 @@ import { verifySignature, serializeEvent, getEventHash -} from './event.js' -import { matchFilter, matchFilters } from './filter.js' +} from './event' +import {matchFilter, matchFilters} from './filter' export { generatePrivateKey, relayInit, - relayPool, signEvent, validateEvent, verifySignature, @@ -25,4 +23,3 @@ export { matchFilter, matchFilters } - diff --git a/keys.js b/keys.ts similarity index 69% rename from keys.js rename to keys.ts index e6e0abd..6fbf82a 100644 --- a/keys.js +++ b/keys.ts @@ -1,10 +1,10 @@ import * as secp256k1 from '@noble/secp256k1' import {Buffer} from 'buffer' -export function generatePrivateKey() { +export function generatePrivateKey(): string { return Buffer.from(secp256k1.utils.randomPrivateKey()).toString('hex') } -export function getPublicKey(privateKey) { +export function getPublicKey(privateKey: string): string { return Buffer.from(secp256k1.schnorr.getPublicKey(privateKey)).toString('hex') } diff --git a/nip04.js b/nip04.ts similarity index 77% rename from nip04.js rename to nip04.ts index c1afa37..5e069ce 100644 --- a/nip04.js +++ b/nip04.ts @@ -1,9 +1,10 @@ -import aes from 'browserify-cipher' import {Buffer} from 'buffer' import {randomBytes} from '@noble/hashes/utils' import * as secp256k1 from '@noble/secp256k1' +// @ts-ignore +import aes from 'browserify-cipher' -export function encrypt(privkey, pubkey, text) { +export function encrypt(privkey: string, pubkey: string, text: string): string { const key = secp256k1.getSharedSecret(privkey, '02' + pubkey) const normalizedKey = getNormalizedX(key) @@ -19,7 +20,11 @@ export function encrypt(privkey, pubkey, text) { return `${encryptedMessage}?iv=${Buffer.from(iv.buffer).toString('base64')}` } -export function decrypt(privkey, pubkey, ciphertext) { +export function decrypt( + privkey: string, + pubkey: string, + ciphertext: string +): string { let [cip, iv] = ciphertext.split('?iv=') let key = secp256k1.getSharedSecret(privkey, '02' + pubkey) let normalizedKey = getNormalizedX(key) @@ -35,8 +40,6 @@ export function decrypt(privkey, pubkey, ciphertext) { return decryptedMessage } -function getNormalizedX(key) { - return typeof key === 'string' - ? key.substr(2, 64) - : Buffer.from(key.slice(1, 33)).toString('hex') +function getNormalizedX(key: Uint8Array): string { + return Buffer.from(key.slice(1, 33)).toString('hex') } diff --git a/nip05.js b/nip05.js deleted file mode 100644 index 368c5ae..0000000 --- a/nip05.js +++ /dev/null @@ -1,32 +0,0 @@ -import crossFetch from 'cross-fetch' - -const f = (typeof XMLHttpRequest == 'function') - ? crossFetch - : fetch -export async function searchDomain(domain, query = '') { - try { - let res = await ( - await f(`https://${domain}/.well-known/nostr.json?name=${query}`) - ).json() - - return res.names - } catch (_) { - return [] - } -} - -export async function queryName(fullname) { - try { - let [name, domain] = fullname.split('@') - if (!domain) return null - - let res = await ( - await f(`https://${domain}/.well-known/nostr.json?name=${name}`) - ).json() - - return res.names && res.names[name] - } catch (e) { - console.error(`${e}`) - return null - } -} diff --git a/nip05.ts b/nip05.ts new file mode 100644 index 0000000..aafe250 --- /dev/null +++ b/nip05.ts @@ -0,0 +1,33 @@ +var _fetch = fetch + +export function useFetchImplementation(fetchImplementation: any) { + _fetch = fetchImplementation +} + +export async function searchDomain(domain: string, query = '') { + try { + let res = await ( + await _fetch(`https://${domain}/.well-known/nostr.json?name=${query}`) + ).json() + + return res.names + } catch (_) { + return [] + } +} + +export async function queryName(fullname: string) { + try { + let [name, domain] = fullname.split('@') + if (!domain) return null + + let res = await ( + await _fetch(`https://${domain}/.well-known/nostr.json?name=${name}`) + ).json() + + return res.names && res.names[name] + } catch (e) { + console.error(`${e}`) + return null + } +} diff --git a/nip06.js b/nip06.ts similarity index 50% rename from nip06.js rename to nip06.ts index 129cc66..b2bec3e 100644 --- a/nip06.js +++ b/nip06.ts @@ -6,21 +6,21 @@ import { } from '@scure/bip39' import {HDKey} from '@scure/bip32' -export function privateKeyFromSeed(seed) { +export function privateKeyFromSeed(seed: string): string { let root = HDKey.fromMasterSeed(Buffer.from(seed, 'hex')) - return Buffer.from(root.derive(`m/44'/1237'/0'/0/0`).privateKey).toString( - 'hex' - ) + let privateKey = root.derive(`m/44'/1237'/0'/0/0`).privateKey + if (!privateKey) throw new Error('could not derive private key') + return Buffer.from(privateKey).toString('hex') } -export function seedFromWords(mnemonic) { +export function seedFromWords(mnemonic: string): string { return Buffer.from(mnemonicToSeedSync(mnemonic)).toString('hex') } -export function generateSeedWords() { +export function generateSeedWords(): string { return generateMnemonic(wordlist) } -export function validateWords(words) { +export function validateWords(words: string): boolean { return validateMnemonic(words, wordlist) } diff --git a/package.json b/package.json index f22e5d6..d80ef19 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nostr-tools", - "version": "0.24.1", + "version": "0.25.0", "description": "Tools for making a Nostr client.", "repository": { "type": "git", @@ -15,32 +15,28 @@ "browserify-cipher": ">=1", "buffer": ">=5", "create-hash": "^1.2.0", - "cross-fetch": "^3.1.4", "websocket-polyfill": "^0.0.3" }, "keywords": [ "decentralization", - "twitter", - "p2p", - "mastodon", - "ssb", "social", - "unstoppable", - "censorship", "censorship-resistance", - "client" + "client", + "nostr" ], "devDependencies": { "@esbuild-plugins/node-globals-polyfill": "^0.1.1", "@types/node": "^18.0.3", + "@typescript-eslint/eslint-plugin": "^5.46.1", + "@typescript-eslint/parser": "^5.46.1", "esbuild": "^0.14.38", "esbuild-plugin-alias": "^0.2.1", - "eslint": "^8.5.0", + "eslint": "^8.30.0", "eslint-plugin-babel": "^5.3.1", "esm-loader-typescript": "^1.0.1", "events": "^3.3.0", "tsd": "^0.22.0", - "typescript": "^4.7.4" + "typescript": "^4.9.4" }, "scripts": { "prepublish": "node build.cjs", diff --git a/pool.js b/pool.js deleted file mode 100644 index c7fe148..0000000 --- a/pool.js +++ /dev/null @@ -1,274 +0,0 @@ -import { getEventHash, verifySignature, signEvent } from './event.js' -import { relayInit, normalizeRelayURL } from './relay.js' - -export function relayPool() { - var globalPrivateKey - var globalSigningFunction - - const poolPolicy = { - // setting this to a number will cause events to be published to a random - // set of relays only, instead of publishing to all relays all the time - randomChoice: null, - - // setting this to true will cause .publish() calls to wait until the event has - // been published -- or at least attempted to be published -- to all relays - wait: false - } - - // map with all the relays where the url is the id - // Map - const relays = {} - const openSubs = {} - const activeSubscriptions = {} - const poolListeners = { notice: [], connection: [], disconnection: [], error: [] } - - // sub creates a Subscription object {sub:Function, unsub:Function, addRelay:Function,removeRelay :Function } - const sub = ({ filter, beforeSend, skipVerification }, id) => { - - // check if it has an id, if not assign one - if (!id) id = Math.random().toString().slice(2) - // save sub settings - openSubs[id] = { - filter, - beforeSend, - skipVerification, - } - - const subListeners = { event: [], eose: [] } - const subControllers = Object.fromEntries( - // Convert the map to a Relay[] - Object.values(relays) - // takes only relays that can be read - .filter(({ policy }) => policy.read) - // iterate all the relays and create the array [url:string,sub:SubscriptionCallback, listeners] - .map(({ relay }) => [ - relay.url, - relay.sub(openSubs[id], id), - ]) - ) - - // Unsub deletes itself - const unsub = () => { - // iterate the map of subControllers and call the unsub function of it relays - Object.values(subControllers).forEach(sub => sub.unsub()) - delete openSubs[id] - delete activeSubscriptions[id] - } - - - const sub = ({ - filter = openSubs[id].filter, - beforeSend = openSubs[id].beforeSend, - skipVerification = openSubs[id].skipVerification } - ) => { - // update sub settings - openSubs[id] = { - filter, - beforeSend, - skipVerification, - } - // update relay subs - Object.entries(subControllers).forEach(([relayURL, sub]) => { - sub.sub(openSubs[id], id) - }) - - // returns the current suscripcion - return activeSubscriptions[id] - } - // addRelay adds a relay to the subControllers map so the current subscription can use it - const addRelay = relay => { - for (let type of Object.keys(subListeners)) { - if (subListeners[type].length) subListeners[type].forEach(cb => relay.on(type, cb, id)) - } - subControllers[relay.url] = relay.sub(openSubs[id], id) - return activeSubscriptions[id] - } - // removeRelay deletes a relay from the subControllers map, it also handles the unsubscription from the relay - const removeRelay = relayURL => { - if (relayURL in subControllers) { - subControllers[relayURL].unsub() - delete subControllers[relayURL] - if (Object.keys(subControllers).length === 0) unsub() - } - return activeSubscriptions[id] - } - // on creates listener for sub ('EVENT', 'EOSE', etc) - const on = (type, cb) => { - subListeners[type].push(cb) - Object.values(relays).filter(({ policy }) => policy.read).forEach(({ relay }) => relay.on(type, cb, id)) - return activeSubscriptions[id] - } - // off destroys listener for sub ('EVENT', 'EOSE', etc) - const off = (type, cb) => { - if (!subListeners[type].length) return - let index = subListeners[type].indexOf(cb) - if (index !== -1) subListeners[type].splice(index, 1) - Object.values(relays).forEach(({ relay }) => relay.off(type, cb, id)) - return activeSubscriptions[id] - } - - // add the object created to activeSubscriptions map - activeSubscriptions[id] = { - sub, - unsub, - addRelay, - removeRelay, - on, - off - } - - return activeSubscriptions[id] - } - - return { - sub, - relays, - setPrivateKey(privateKey) { - globalPrivateKey = privateKey - }, - registerSigningFunction(fn) { - globalSigningFunction = fn - }, - setPolicy(key, value) { - poolPolicy[key] = value - }, - // addRelay adds a relay to the pool and to all its subscriptions - addRelay(url, policy = { read: true, write: true }) { - let relayURL = normalizeRelayURL(url) - if (relayURL in relays) return - - let relay = relayInit(url) - - for (let type of Object.keys(poolListeners)) { - let cbs = poolListeners[type] || [] - if (cbs.length) poolListeners[type].forEach(cb => relay.on(type, cb)) - } - - if (policy.read) { - Object.values(activeSubscriptions).forEach(sub => sub.addRelay(relay)) - } - relay.connect() - relays[relayURL] = { relay, policy } - - return relay - }, - // remove relay deletes the relay from the pool and from all its subscriptions - removeRelay(url) { - let relayURL = normalizeRelayURL(url) - let data = relays[relayURL] - if (!data) return - - let { relay } = data - Object.values(activeSubscriptions).forEach(sub => sub.removeRelay(relayURL)) - relay.close() - delete relays[relayURL] - }, - // getRelayList return an array with all the relays stored - getRelayList() { - return Object.values(relays) - }, - - relayChangePolicy(url, policy = { read: true, write: true }) { - let relayURL = normalizeRelayURL(url) - let data = relays[relayURL] - if (!data) return - - let { relay } = data - if (relays[relayURL].policy.read === true && policy.read === false) - Object.values(activeSubscriptions).forEach(sub => sub.removeRelay(relayURL)) - else if (relays[relayURL].policy.read === false && policy.read === true) - Object.values(activeSubscriptions).forEach(sub => sub.addRelay(relay)); - - relays[relayURL].policy = policy - return relays[relayURL] - }, - on(type, cb) { - poolListeners[type] = poolListeners[type] || [] - poolListeners[type].push(cb) - Object.values(relays).forEach(({ relay }) => relay.on(type, cb)) - }, - off(type, cb) { - let index = poolListeners[type].indexOf(cb) - if (index !== -1) poolListeners[type].splice(index, 1) - Object.values(relays).forEach(({ relay }) => relay.off(type, cb)) - }, - - // publish send a event to the relays - async publish(event, statusCallback) { - event.id = getEventHash(event) - - // if the event is not signed then sign it - if (!event.sig) { - event.tags = event.tags || [] - - if (globalPrivateKey) { - event.sig = await signEvent(event, globalPrivateKey) - } else if (globalSigningFunction) { - event.sig = await globalSigningFunction(event) - if (!event.sig) { - // abort here - return - } else { - // check - if (!(await verifySignature(event))) - throw new Error( - 'signature provided by custom signing function is invalid.' - ) - } - } else { - throw new Error( - "can't publish unsigned event. either sign this event beforehand, provide a signing function or pass a private key while initializing this relay pool so it can be signed automatically." - ) - } - } - - // get the writable relays - let writeable = Object.values(relays) - .filter(({ policy }) => policy.write) - .sort(() => Math.random() - 0.5) // random - - let maxTargets = poolPolicy.randomChoice - ? poolPolicy.randomChoice - : writeable.length - - let successes = 0 - - // if the pool policy set to wait until event send - if (poolPolicy.wait) { - for (let i = 0; i < writeable.length; i++) { - let { relay } = writeable[i] - - try { - await new Promise(async (resolve, reject) => { - try { - await relay.publish(event, status => { - if (statusCallback) statusCallback(status, relay.url) - resolve() - }) - } catch (err) { - if (statusCallback) statusCallback(-1, relay.url) - } - }) - - successes++ - if (successes >= maxTargets) { - break - } - } catch (err) { - /***/ - } - } - // if the pool policy dont want to wait until event send - } else { - writeable.forEach(async ({ relay }) => { - let callback = statusCallback - ? status => statusCallback(status, relay.url) - : null - relay.publish(event, callback) - }) - } - - return event - } - } -} diff --git a/relay.js b/relay.js deleted file mode 100644 index d55a41b..0000000 --- a/relay.js +++ /dev/null @@ -1,214 +0,0 @@ -/* global WebSocket */ - -import 'websocket-polyfill' - -import { verifySignature, validateEvent } from './event.js' -import { matchFilters } from './filter.js' - -export function normalizeRelayURL(url) { - let [host, ...qs] = url.trim().split('?') - if (host.slice(0, 4) === 'http') host = 'ws' + host.slice(4) - if (host.slice(0, 2) !== 'ws') host = 'wss://' + host - if (host.length && host[host.length - 1] === '/') host = host.slice(0, -1) - return [host, ...qs].join('?') -} - -export function relayInit(url) { - let relay = normalizeRelayURL(url) // set relay url - - var ws, resolveOpen, untilOpen, wasClosed, closed - var openSubs = {} - var listeners = { - event: { '_': [] }, - eose: { '_': [] }, - connection: { '_': [] }, - disconnection: { '_': [] }, - error: { '_': [] }, - notice: { '_': [] }, - } - let attemptNumber = 1 - let nextAttemptSeconds = 1 - - function resetOpenState() { - untilOpen = new Promise(resolve => { - resolveOpen = resolve - }) - } - - function connectRelay() { - ws = new WebSocket(relay) - - ws.onopen = () => { - listeners.connection._.forEach(cb => cb({ type: 'connection', relay })) - resolveOpen() - - // restablish old subscriptions - if (wasClosed) { - wasClosed = false - for (let id in openSubs) { - sub(openSubs[id], id) - } - } - } - ws.onerror = error => { - listeners.error._.forEach(cb => cb({ type: 'error', relay, error })) - } - ws.onclose = async () => { - listeners.disconnection._.forEach(cb => cb({ type: 'disconnection', relay })) - if (closed) return - resetOpenState() - attemptNumber++ - nextAttemptSeconds += attemptNumber ** 3 - if (nextAttemptSeconds > 14400) { - nextAttemptSeconds = 14400 // 4 hours - } - console.log( - `relay ${relay} connection closed. reconnecting in ${nextAttemptSeconds} seconds.` - ) - setTimeout(async () => { - try { - connectRelay() - } catch (err) { } - }, nextAttemptSeconds * 1000) - - wasClosed = true - } - - ws.onmessage = async e => { - var data - try { - data = JSON.parse(e.data) - } catch (err) { - data = e.data - } - - if (data.length >= 1) { - switch (data[0]) { - case 'EVENT': - if (data.length !== 3) return // ignore empty or malformed EVENT - - let id = data[1] - let event = data[2] - if (validateEvent(event) && openSubs[id] && - (openSubs[id].skipVerification || verifySignature(event)) && - matchFilters(openSubs[id].filter, event) - ) { - if (listeners.event[id]?.length) listeners.event[id].forEach(cb => cb({ type: 'event', relay, id, event })) - if (listeners.event._.length) listeners.event._.forEach(cb => cb({ type: 'event', relay, id, event })) - } - return - case 'EOSE': { - if (data.length !== 2) return // ignore empty or malformed EOSE - - let id = data[1] - if (listeners.eose[id]?.length) listeners.eose[data[1]].forEach(cb => cb({ type: 'eose', relay, id })) - if (listeners.eose._.length) listeners.eose._.forEach(cb => cb({ type: 'eose', relay, id })) - return - } - case 'NOTICE': - if (data.length !== 2) return // ignore empty or malformed NOTICE - - let notice = data[1] - if (listeners.notice._.length) listeners.notice._.forEach(cb => cb({ type: 'notice', relay, notice })) - return - } - } - } - } - - resetOpenState() - - async function connect() { - if (ws?.readyState && ws.readyState === 1) return // ws already open - try { - connectRelay() - } catch (err) { } - } - - async function trySend(params) { - let msg = JSON.stringify(params) - - await untilOpen - ws.send(msg) - } - - const sub = ({ filter, beforeSend, skipVerification }, id = Math.random().toString().slice(2)) => { - var filters = [] - if (Array.isArray(filter)) { - filters = filter - } else { - filters.push(filter) - } - filter = filters - - if (beforeSend) { - const beforeSendResult = beforeSend({ filter, relay, id }) - filter = beforeSendResult.filter - } - - openSubs[id] = { - filter, - beforeSend, - skipVerification, - } - trySend(['REQ', id, ...filter]) - - return { - sub: ({ - filter = openSubs[id].filter, - beforeSend = openSubs[id].beforeSend, - skipVerification = openSubs[id].skipVerification } - ) => sub({ filter, beforeSend, skipVerification }, id), - unsub: () => { - delete openSubs[id] - delete listeners.event[id] - delete listeners.eose[id] - trySend(['CLOSE', id]) - } - } - } - - function on(type, cb, id = '_') { - listeners[type][id] = listeners[type][id] || [] - listeners[type][id].push(cb) - } - - function off(type, cb, id = '_') { - if (!listeners[type][id].length) return - let index = listeners[type][id].indexOf(cb) - if (index !== -1) listeners[type][id].splice(index, 1) - } - - return { - url, - sub, - on, - off, - async publish(event, statusCallback) { - try { - await trySend(['EVENT', event]) - if (statusCallback) { - let id = `monitor-${event.id.slice(0, 5)}` - statusCallback(0) - let { unsub } = sub({ filter: { ids: [event.id] } }, id) - on('event', () => { - statusCallback(1) - unsub() - clearTimeout(willUnsub) - }, id) - let willUnsub = setTimeout(unsub, 5000) - } - } catch (err) { - if (statusCallback) statusCallback(-1) - } - }, - connect, - close() { - closed = true // prevent ws from trying to reconnect - ws.close() - }, - get status() { - return ws.readyState - } - } -} diff --git a/relay.ts b/relay.ts new file mode 100644 index 0000000..bc021f4 --- /dev/null +++ b/relay.ts @@ -0,0 +1,301 @@ +/* global WebSocket */ + +import 'websocket-polyfill' + +import {Event, verifySignature, validateEvent} from './event' +import {Filter, matchFilters} from './filter' + +export function normalizeRelayURL(url: string): string { + let [host, ...qs] = url.trim().split('?') + if (host.slice(0, 4) === 'http') host = 'ws' + host.slice(4) + if (host.slice(0, 2) !== 'ws') host = 'wss://' + host + if (host.length && host[host.length - 1] === '/') host = host.slice(0, -1) + return [host, ...qs].join('?') +} + +export type Relay = { + url: string + status: number + connect: () => void + close: () => void + sub: (opts: SubscriptionOptions) => Sub + publish: (event: Event) => Pub + on: (type: 'connect' | 'disconnect' | 'notice', cb: any) => void + off: (type: 'connect' | 'disconnect' | 'notice', cb: any) => void +} +export type Pub = { + on: (type: 'ok' | 'seen' | 'failed', cb: any) => void + off: (type: 'ok' | 'seen' | 'failed', cb: any) => void +} +export type Sub = { + sub: (opts: SubscriptionOptions) => Sub + unsub: () => void + on: (type: 'event' | 'eose', cb: any) => void + off: (type: 'event' | 'eose', cb: any) => void +} + +type SubscriptionOptions = { + filters: Filter[] + skipVerification?: boolean + id?: string +} + +export function relayInit(url: string): Relay { + let relay = normalizeRelayURL(url) // set relay url + + var ws: WebSocket + var resolveOpen: () => void + var untilOpen: Promise + var wasClosed: boolean + var closed: boolean + var openSubs: {[id: string]: SubscriptionOptions} = {} + var listeners: { + connect: Array<() => void> + disconnect: Array<() => void> + error: Array<() => void> + notice: Array<(msg: string) => void> + } = { + connect: [], + disconnect: [], + error: [], + notice: [] + } + var subListeners: { + [subid: string]: { + event: Array<(event: Event) => void> + eose: Array<() => void> + } + } + var pubListeners: { + [eventid: string]: { + ok: Array<() => void> + seen: Array<() => void> + failed: Array<(reason: string) => void> + } + } + let attemptNumber = 1 + let nextAttemptSeconds = 1 + + function resetOpenState() { + untilOpen = new Promise(resolve => { + resolveOpen = resolve + }) + } + + function connectRelay() { + ws = new WebSocket(relay) + + ws.onopen = () => { + listeners.connect.forEach(cb => cb()) + resolveOpen() + + // restablish old subscriptions + if (wasClosed) { + wasClosed = false + for (let id in openSubs) { + sub(openSubs[id]) + } + } + } + ws.onerror = () => { + listeners.error.forEach(cb => cb()) + } + ws.onclose = async () => { + listeners.disconnect.forEach(cb => cb()) + if (closed) return + resetOpenState() + attemptNumber++ + nextAttemptSeconds += attemptNumber ** 3 + if (nextAttemptSeconds > 14400) { + nextAttemptSeconds = 14400 // 4 hours + } + console.log( + `relay ${relay} connection closed. reconnecting in ${nextAttemptSeconds} seconds.` + ) + setTimeout(async () => { + try { + connectRelay() + } catch (err) {} + }, nextAttemptSeconds * 1000) + + wasClosed = true + } + + ws.onmessage = async e => { + var data + try { + data = JSON.parse(e.data) + } catch (err) { + data = e.data + } + + if (data.length >= 1) { + switch (data[0]) { + case 'EVENT': + if (data.length !== 3) return // ignore empty or malformed EVENT + + let id = data[1] + let event = data[2] + if ( + validateEvent(event) && + openSubs[id] && + (openSubs[id].skipVerification || verifySignature(event)) && + matchFilters(openSubs[id].filters, event) + ) { + openSubs[id] + subListeners[id]?.event.forEach(cb => cb(event)) + } + return + case 'EOSE': { + if (data.length !== 2) return // ignore empty or malformed EOSE + let id = data[1] + subListeners[id]?.eose.forEach(cb => cb()) + return + } + case 'OK': { + if (data.length < 3) return // ignore empty or malformed OK + let id: string = data[1] + let ok: boolean = data[2] + let reason: string = data[3] || '' + if (ok) pubListeners[id]?.ok.forEach(cb => cb()) + else pubListeners[id]?.failed.forEach(cb => cb(reason)) + return + } + case 'NOTICE': + if (data.length !== 2) return // ignore empty or malformed NOTICE + let notice = data[1] + listeners.notice.forEach(cb => cb(notice)) + return + } + } + } + } + + resetOpenState() + + async function connect(): Promise { + if (ws?.readyState && ws.readyState === 1) return // ws already open + try { + connectRelay() + } catch (err) {} + } + + async function trySend(params: [string, ...any]) { + let msg = JSON.stringify(params) + + await untilOpen + ws.send(msg) + } + + const sub = ({ + filters, + skipVerification = false, + id = Math.random().toString().slice(2) + }: SubscriptionOptions): Sub => { + let subid = id + + openSubs[subid] = { + id: subid, + filters, + skipVerification + } + trySend(['REQ', subid, ...filters]) + + return { + sub: ({ + filters = openSubs[subid].filters, + skipVerification = openSubs[subid].skipVerification + }) => sub({filters, skipVerification, id: subid}), + unsub: () => { + delete openSubs[subid] + delete subListeners[subid] + trySend(['CLOSE', subid]) + }, + on: (type: 'event' | 'eose', cb: any): void => { + subListeners[subid] = subListeners[subid] || { + event: [], + eose: [] + } + subListeners[subid][type].push(cb) + }, + off: (type: 'event' | 'eose', cb: any): void => { + let idx = subListeners[subid][type].indexOf(cb) + if (idx >= 0) subListeners[subid][type].splice(idx, 1) + } + } + } + + return { + url, + sub, + on: (type: 'connect' | 'disconnect' | 'notice', cb: any): void => { + listeners[type].push(cb) + }, + off: (type: 'connect' | 'disconnect' | 'notice', cb: any): void => { + let index = listeners[type].indexOf(cb) + if (index !== -1) listeners[type].splice(index, 1) + }, + publish(event: Event): Pub { + if (!event.id) throw new Error(`event ${event} has no id`) + let id = event.id + + var sent = false + var mustMonitor = false + + trySend(['EVENT', event]) + .then(() => { + sent = true + if (mustMonitor) { + startMonitoring() + mustMonitor = false + } + }) + .catch(() => {}) + + const startMonitoring = () => { + let monitor = sub({ + filters: [{ids: [id]}], + id: `monitor-${id.slice(0, 5)}` + }) + let willUnsub = setTimeout(() => { + pubListeners[id].failed.forEach(cb => + cb('event not seen after 5 seconds') + ) + monitor.unsub() + }, 5000) + monitor.on('event', () => { + clearTimeout(willUnsub) + pubListeners[id].seen.forEach(cb => cb()) + }) + } + + return { + on: (type: 'ok' | 'seen' | 'failed', cb: any) => { + pubListeners[id] = pubListeners[id] || { + ok: [], + seen: [], + failed: [] + } + pubListeners[id][type].push(cb) + + if (type === 'seen') { + if (sent) startMonitoring() + else mustMonitor = true + } + }, + off: (type: 'ok' | 'seen' | 'failed', cb: any) => { + let idx = pubListeners[id][type].indexOf(cb) + if (idx >= 0) pubListeners[id][type].splice(idx, 1) + } + } + }, + connect, + close() { + closed = true // prevent ws from trying to reconnect + ws.close() + }, + get status() { + return ws.readyState + } + } +} diff --git a/tsconfig.json b/tsconfig.json index 5e9fb16..22bbdb2 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,25 +1,15 @@ { - "compilerOptions": { - "module": "es2020", - "target": "es2020", - "lib": ["dom", "es2020"], - "esModuleInterop": true, - "moduleResolution": "node", - "allowSyntheticDefaultImports": true, - "declaration": true, - "strict": true, - "noImplicitAny": true, - "noImplicitThis": true, - "strictNullChecks": true, - "strictFunctionTypes": true, - "baseUrl": "./", - "typeRoots": ["."], - "types": ["node"], - "noEmit": true, - "forceConsistentCasingInFileNames": true - }, - "files": [ - "index.d.ts", - "t/nostr-tools-tests.ts" - ] + "compilerOptions": { + "module": "esnext", + "target": "esnext", + "lib": ["dom", "dom.iterable", "esnext"], + "declaration": true, + "strict": true, + "moduleResolution": "node", + "skipLibCheck": true, + "esModuleInterop": true, + "emitDeclarationOnly": true, + "outDir": "dist", + "rootDir": "." + } }