Compare commits

..

39 Commits

Author SHA1 Message Date
fiatjaf
9f9e822c6d allow skipping signature verification. 2022-08-05 16:36:27 -03:00
Lennon Day-Reynolds
821a8f7895 TypeScript definitions (#18) 2022-07-15 15:49:49 -03:00
fiatjaf
2f7e3f8473 bump version. 2022-06-22 20:08:48 -03:00
monlovesmango
536dbcbffe Update pool.js 2022-06-22 20:07:25 -03:00
monlovesmango
ed52d2a8d4 updating cb property for subControllers entries
when updating subscription or adding new relays, subsequent events that are received have the relay as undefined. by updating cb property for the subControllers entries to be an arrow function (when calling sub.sub or sub.addRelay), subsequent events now return the relay url appropriately
2022-06-22 20:07:25 -03:00
fiatjaf
faf8e62120 maybe fix a bug with calling sub.sub() 2022-06-04 18:34:54 -03:00
fiatjaf
dc489bf387 build esm module that can be imported from browsers.
closes https://github.com/fiatjaf/nostr-tools/issues/14
2022-05-08 20:49:36 -03:00
Ricardo Arturo Cabral Mejia
60ce13e17d chore: bump version to 0.23.0 2022-04-10 19:51:35 -03:00
Ricardo Arturo Cabral Mejia
727bcb05a8 feat: add beforeSend hook to sub() 2022-04-10 19:51:35 -03:00
monlovesmango
c236e41f80 import 'Buffer'
'Buffer' wasn't imported initially and was causing issues when I tried to use generatePrivateKey in a client I am building. not sure why Branle has no error, maybe I am doing something wrong?
2022-04-06 18:34:50 -03:00
fiatjaf
f04bc0cee1 fix filter on statusCallback: id -> ids 2022-02-15 21:03:44 -03:00
fiatjaf
e63479ee7f nip05 more strict. enforce the presence of "_" for domain names. 2022-02-12 20:37:23 -03:00
fiatjaf
c47f091d9b update noble secp256k1 and ensure we always return hex. 2022-02-11 16:27:23 -03:00
Melvin Carvalho
4c785279bc remove => from onEvent function in README.md. 2022-02-03 09:31:03 -03:00
fiatjaf
6786641b1d are you kidding me? 2022-01-25 17:06:26 -03:00
fiatjaf
0396db5ed6 nip04 string key is actually x and y, so we must get only 32 bytes of x. 2022-01-25 16:25:10 -03:00
fiatjaf
0c8e7a74f5 fix previous commit because noble is returning different values depending on [unknown], sometimes uint8array, sometimes hex. 2022-01-25 15:41:49 -03:00
fiatjaf
c66a2acda1 encrypt uint8array to hex. 2022-01-24 21:00:51 -03:00
fiatjaf
6f07c756e5 change nip04 functions interfaces. 2022-01-24 20:21:26 -03:00
fiatjaf
f6bcda8d8d support _ names in nip05. 2022-01-17 17:12:48 -03:00
fiatjaf
4b666e421b update nip05 to well-known version. 2022-01-17 16:37:19 -03:00
fiatjaf
454366f6a2 allow signing events with a custom signing function on pool.publish() 2022-01-12 22:32:45 -03:00
fiatjaf
3d6f9a41e0 prevent blocking waiting times on publish (unless "wait" is set in the pool policy). 2022-01-12 17:39:24 -03:00
fiatjaf
e3631ba806 fix and update nip06. 2022-01-06 21:46:34 -03:00
fiatjaf
89f11e214d fix filter matching for tags. 2022-01-02 19:46:19 -03:00
fiatjaf
bb09e25512 fix tag in matchFilter for kinds and ids. 2022-01-01 21:18:37 -03:00
fiatjaf
1b5c314436 nip-01 update: everything as arrays on filters. 2022-01-01 20:49:05 -03:00
fiatjaf
2230f32d11 use randomBytes from @noble/hashes. 2022-01-01 14:59:12 -03:00
fiatjaf
b271d6c06b fix .kind filter validator. 2022-01-01 10:26:55 -03:00
fiatjaf
76624a0f23 validateEvent() function. 2022-01-01 10:04:36 -03:00
fiatjaf
1f1a6380f0 fix getPublicKey to return the bip340 key. 2022-01-01 10:03:36 -03:00
fiatjaf
a46568d55c fix argument to micro-bip32 2021-12-31 23:09:43 -03:00
fiatjaf
ff4e63ecdf fix param order for verifySignature. 2021-12-31 22:53:27 -03:00
fiatjaf
01dd5b7a3c bring back @noble/secp256k1 along with micro-bip32. 2021-12-31 22:47:45 -03:00
fiatjaf
16536340e5 small fix on pool.removeRelay() 2021-12-31 22:25:33 -03:00
fiatjaf
1037eee335 trim relay url on normalize. 2021-12-31 22:03:02 -03:00
fiatjaf
5ce1b4c9f7 only initiate subscriptions for new relays added with read:true 2021-12-31 20:50:02 -03:00
fiatjaf
7bc9083bc5 randomChoice pool policy. 2021-12-30 21:46:54 -03:00
fiatjaf
ce214ebbab small tweaks on relayConnect. 2021-12-30 15:02:05 -03:00
16 changed files with 477 additions and 161 deletions

2
.gitignore vendored
View File

@@ -2,3 +2,5 @@ node_modules
dist
yarn.lock
package-lock.json
nostr.js
.envrc

View File

@@ -15,7 +15,7 @@ pool.addRelay('ws://some.relay.com', {read: true, write: true})
pool.addRelay('ws://other.relay.cool', {read: true, write: true})
// example callback function for a subscription
function onEvent(event, relay) => {
function onEvent(event, relay) {
console.log(`got an event from ${relay.url} which is already validated.`, event)
}
@@ -70,3 +70,24 @@ pool.addRelay('<url>')
All functions expect bytearrays as hex strings and output bytearrays as hex strings.
For other utils please read the source (for now).
### Using from the browser (if you don't want to use a bundler)
You can import nostr-tools as an ES module. Just add a script tag like this:
```html
<script type="module">
import {generatePrivateKey} from 'https://unpkg.com/nostr-tools/nostr.js'
console.log(generatePrivateKey())
</script>
```
And import whatever function you would import from `"nostr-tools"` in a bundler.
## TypeScript
This module has hand-authored TypeScript declarations. `npm run check-ts` will run a lint-check script to ensure the typings can be loaded and call at least a few standard library functions. It's not at all comprehensive and likely to contain bugs. Issues welcome; tag @rcoder as needed.
## License
Public domain.

25
build.cjs Executable file
View File

@@ -0,0 +1,25 @@
#!/usr/bin/env node
const esbuild = require('esbuild')
const alias = require('esbuild-plugin-alias')
const nodeGlobals = require('@esbuild-plugins/node-globals-polyfill').default
const buildOptions = {
entryPoints: ['index.js'],
outfile: 'nostr.js',
bundle: true,
format: 'esm',
plugins: [
alias({
stream: require.resolve('readable-stream')
}),
nodeGlobals({buffer: true})
],
define: {
window: 'self',
global: 'self'
},
loader: {'.js': 'jsx'}
}
esbuild.build(buildOptions).then(() => console.log('build success.'))

View File

@@ -1,6 +1,6 @@
import {Buffer} from 'buffer'
import createHash from 'create-hash'
import {signSchnorr, verifySchnorr} from 'tiny-secp256k1'
import * as secp256k1 from '@noble/secp256k1'
export function getBlankEvent() {
return {
@@ -18,7 +18,7 @@ export function serializeEvent(evt) {
evt.pubkey,
evt.created_at,
evt.kind,
evt.tags || [],
evt.tags,
evt.content
])
}
@@ -30,17 +30,29 @@ export function getEventHash(event) {
return Buffer.from(eventHash).toString('hex')
}
export function verifySignature(event) {
export function validateEvent(event) {
if (event.id !== getEventHash(event)) return false
return verifySchnorr(
Buffer.from(event.id, 'hex'),
Buffer.from(event.pubkey, 'hex'),
Buffer.from(event.sig, 'hex')
)
if (typeof event.content !== 'string') return false
if (typeof event.created_at !== 'number') return false
if (!Array.isArray(event.tags)) return false
for (let i = 0; i < event.tags.length; i++) {
let tag = event.tags[i]
if (!Array.isArray(tag)) return false
for (let j = 0; j < tag.length; j++) {
if (typeof tag[j] === 'object') return false
}
}
return true
}
export function signEvent(event, key) {
let eventHash = Buffer.from(getEventHash(event), 'hex')
let keyB = Buffer.from(key, 'hex')
return Buffer.from(signSchnorr(eventHash, keyB)).toString('hex')
export function verifySignature(event) {
return secp256k1.schnorr.verify(event.sig, event.id, event.pubkey)
}
export async function signEvent(event, key) {
return Buffer.from(
await secp256k1.schnorr.sign(getEventHash(event), key)
).toString('hex')
}

View File

@@ -1,20 +1,23 @@
export function matchFilter(filter, event) {
if (filter.id && event.id !== filter.id) return false
if (filter.kind && event.kind !== filter.kind) return false
if (filter.author && event.pubkey !== filter.author) return false
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)
return false
if (
filter['#e'] &&
!event.tags.find(([t, v]) => t === 'e' && v === filter['#e'])
)
return false
if (
filter['#p'] &&
!event.tags.find(([t, v]) => t === 'p' && v === filter['#p'])
)
return false
if (filter.since && event.created_at <= filter.since) return false
for (let f in filter) {
if (f[0] === '#') {
if (
filter[f] &&
!event.tags.find(
([t, v]) => t === f.slice(1) && filter[f].indexOf(v) !== -1
)
)
return false
}
}
if (filter.since && event.created_at < filter.since) return false
if (filter.until && event.created_at >= filter.until) return false
return true
}

107
index.d.ts vendored Normal file
View File

@@ -0,0 +1,107 @@
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 = {
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<[Uint8Array, number]>;
// 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<Event>,
};
declare type PoolPublishCallback = (status: number, relay: string) => void;
declare type RelayPool = {
setPrivateKey(key: string): void,
addRelay(url: string, opts?: RelayPolicy): Relay,
sub(opts: SubscriptionOptions, id?: string): Subscription,
publish(event: Event, cb: PoolPublishCallback): Promise<Event>,
close: () => void,
status: number,
};
declare function relayPool(): RelayPool;
// nip04.js
// nip05.js
// nip06.js

View File

@@ -1,20 +1,22 @@
import {generatePrivateKey, getPublicKey} from './keys'
import {relayConnect} from './relay'
import {relayPool} from './pool'
import {generatePrivateKey, getPublicKey} from './keys.js'
import {relayConnect} from './relay.js'
import {relayPool} from './pool.js'
import {
getBlankEvent,
signEvent,
validateEvent,
verifySignature,
serializeEvent,
getEventHash
} from './event'
import {matchFilter, matchFilters} from './filter'
} from './event.js'
import {matchFilter, matchFilters} from './filter.js'
export {
generatePrivateKey,
relayConnect,
relayPool,
signEvent,
validateEvent,
verifySignature,
serializeEvent,
getEventHash,

42
index.test-d.ts Normal file
View File

@@ -0,0 +1,42 @@
import * as process from 'process';
import {
relayPool,
getBlankEvent,
validateEvent,
RelayPool,
Event as NEvent
} from './index.js';
import { expectType } from 'tsd';
const pool = relayPool();
expectType<RelayPool>(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<Promise<NEvent>>(publishF);
publishF.then((event) => {
expectType<NEvent>(event);
console.info({ event });
if (!validateEvent(event)) {
console.error(`event failed to validate!`);
process.exit(1);
}
});

17
keys.js
View File

@@ -1,19 +1,10 @@
import randomBytes from 'randombytes'
import {isPrivate, pointFromScalar} from 'tiny-secp256k1'
import * as secp256k1 from '@noble/secp256k1'
import {Buffer} from 'buffer'
export function generatePrivateKey() {
let i = 8
while (i--) {
let r32 = Buffer.from(randomBytes(32))
if (isPrivate(r32)) return r32.toString('hex')
}
throw new Error(
'Valid private key was not found in 8 iterations. PRNG is broken'
)
return Buffer.from(secp256k1.utils.randomPrivateKey()).toString('hex')
}
export function getPublicKey(privateKey) {
return Buffer.from(pointFromScalar(Buffer.from(privateKey, 'hex'), true))
.toString('hex')
.slice(2)
return Buffer.from(secp256k1.schnorr.getPublicKey(privateKey)).toString('hex')
}

View File

@@ -1,11 +1,11 @@
import aes from 'browserify-cipher'
import {Buffer} from 'buffer'
import randomBytes from 'randombytes'
import {randomBytes} from '@noble/hashes/utils'
import * as secp256k1 from '@noble/secp256k1'
export function encrypt(privkey, pubkey, text) {
const key = secp256k1.getSharedSecret(privkey, '02' + pubkey)
const normalizedKey = getOnlyXFromFullSharedSecret(key)
const normalizedKey = getNormalizedX(key)
let iv = Uint8Array.from(randomBytes(16))
var cipher = aes.createCipheriv(
@@ -16,24 +16,27 @@ export function encrypt(privkey, pubkey, text) {
let encryptedMessage = cipher.update(text, 'utf8', 'base64')
encryptedMessage += cipher.final('base64')
return [encryptedMessage, Buffer.from(iv.buffer).toString('base64')]
return `${encryptedMessage}?iv=${Buffer.from(iv.buffer).toString('base64')}`
}
export function decrypt(privkey, pubkey, ciphertext, iv) {
const key = secp256k1.getSharedSecret(privkey, '02' + pubkey)
const normalizedKey = getOnlyXFromFullSharedSecret(key)
export function decrypt(privkey, pubkey, ciphertext) {
let [cip, iv] = ciphertext.split('?iv=')
let key = secp256k1.getSharedSecret(privkey, '02' + pubkey)
let normalizedKey = getNormalizedX(key)
var decipher = aes.createDecipheriv(
'aes-256-cbc',
Buffer.from(normalizedKey, 'hex'),
Buffer.from(iv, 'base64')
)
let decryptedMessage = decipher.update(ciphertext, 'base64')
let decryptedMessage = decipher.update(cip, 'base64')
decryptedMessage += decipher.final('utf8')
return decryptedMessage
}
function getOnlyXFromFullSharedSecret(fullSharedSecretCoordinates) {
return fullSharedSecretCoordinates.substr(2, 64)
function getNormalizedX(key) {
return typeof key === 'string'
? key.substr(2, 64)
: Buffer.from(key.slice(1, 33)).toString('hex')
}

View File

@@ -1,52 +1,28 @@
import {Buffer} from 'buffer'
import dnsPacket from 'dns-packet'
const dohProviders = [
'cloudflare-dns.com',
'fi.doh.dns.snopyta.org',
'basic.bravedns.com',
'hydra.plan9-ns1.com',
'doh.pl.ahadns.net',
'dns.flatuslifir.is',
'doh.dns.sb',
'doh.li'
]
let counter = 0
export async function keyFromDomain(domain) {
let host = dohProviders[counter % dohProviders.length]
let buf = dnsPacket.encode({
type: 'query',
id: Math.floor(Math.random() * 65534),
flags: dnsPacket.RECURSION_DESIRED,
questions: [
{
type: 'TXT',
name: `_nostrkey.${domain}`
}
]
})
let fetching = fetch(`https://${host}/dns-query`, {
method: 'POST',
headers: {
'Content-Type': 'application/dns-message',
'Content-Length': Buffer.byteLength(buf)
},
body: buf
})
counter++
import fetch from 'cross-fetch'
export async function searchDomain(domain, query = '') {
try {
let response = Buffer.from(await (await fetching).arrayBuffer())
let {answers} = dnsPacket.decode(response)
if (answers.length === 0) return null
return Buffer.from(answers[0].data[0]).toString()
} catch (err) {
console.log(`error querying DNS for ${domain} on ${host}`, err)
let res = await (
await fetch(`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 fetch(`https://${domain}/.well-known/nostr.json?name=${name}`)
).json()
return res.names && res.names[name]
} catch (_) {
return null
}
}

View File

@@ -1,21 +1,20 @@
import {wordlist} from 'micro-bip39/wordlists/english'
import {wordlist} from 'micro-bip39/wordlists/english.js'
import {
generateMnemonic,
mnemonicToSeedSync,
validateMnemonic
} from 'micro-bip39'
import BIP32Factory from 'bip32'
import * as ecc from 'tiny-secp256k1'
const bip32 = BIP32Factory(ecc)
import {HDKey} from 'micro-bip32'
export function privateKeyFromSeed(seed) {
let root = bip32.fromSeed(Buffer.from(seed, 'hex'))
return root.derivePath(`m/44'/1237'/0'/0'`).privateKey.toString('hex')
let root = HDKey.fromMasterSeed(Buffer.from(seed, 'hex'))
return Buffer.from(root.derive(`m/44'/1237'/0'/0/0`).privateKey).toString(
'hex'
)
}
export function seedFromWords(mnemonic) {
return Buffer.from(mnemonicToSeedSync(mnemonic, wordlist)).toString('hex')
return Buffer.from(mnemonicToSeedSync(mnemonic)).toString('hex')
}
export function generateSeedWords() {

View File

@@ -1,21 +1,21 @@
{
"name": "nostr-tools",
"version": "0.12.3",
"version": "0.24.0",
"description": "Tools for making a Nostr client.",
"repository": {
"type": "git",
"url": "https://github.com/fiatjaf/nostr-tools.git"
},
"type": "module",
"dependencies": {
"@noble/secp256k1": "^1.3.0",
"bip32": "^3.0.1",
"@noble/hashes": "^0.5.7",
"@noble/secp256k1": "^1.5.2",
"browserify-cipher": ">=1",
"buffer": ">=5",
"create-hash": "^1.2.0",
"dns-packet": "^5.2.4",
"cross-fetch": "^3.1.4",
"micro-bip32": "^0.1.0",
"micro-bip39": "^0.1.3",
"randombytes": ">=2",
"tiny-secp256k1": "^2.1.2",
"websocket-polyfill": "^0.0.3"
},
"keywords": [
@@ -31,7 +31,19 @@
"client"
],
"devDependencies": {
"@esbuild-plugins/node-globals-polyfill": "^0.1.1",
"@types/node": "^18.0.3",
"esbuild": "^0.14.38",
"esbuild-plugin-alias": "^0.2.1",
"eslint": "^8.5.0",
"eslint-plugin-babel": "^5.3.1"
"eslint-plugin-babel": "^5.3.1",
"esm-loader-typescript": "^1.0.1",
"events": "^3.3.0",
"tsd": "^0.22.0",
"typescript": "^4.7.4"
},
"scripts": {
"prepublish": "node build.cjs",
"check-ts": "tsd && node --no-warnings --loader=esm-loader-typescript index.test-d.ts"
}
}

123
pool.js
View File

@@ -1,7 +1,19 @@
import {getEventHash, signEvent} from './event'
import {relayConnect, normalizeRelayURL} from './relay'
import {getEventHash, verifySignature, signEvent} from './event.js'
import {relayConnect, normalizeRelayURL} from './relay.js'
export function relayPool(globalPrivateKey) {
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
}
const relays = {}
const noticeCallbacks = []
@@ -14,32 +26,42 @@ export function relayPool(globalPrivateKey) {
const activeSubscriptions = {}
const sub = ({cb, filter}, id = Math.random().toString().slice(2)) => {
const sub = ({cb, filter, beforeSend}, id) => {
if (!id) id = Math.random().toString().slice(2)
const subControllers = Object.fromEntries(
Object.values(relays)
.filter(({policy}) => policy.read)
.map(({relay}) => [
relay.url,
relay.sub({filter, cb: event => cb(event, relay.url)}, id)
relay.sub({cb: event => cb(event, relay.url), filter, beforeSend}, id)
])
)
const activeCallback = cb
const activeFilters = filter
const activeBeforeSend = beforeSend
const unsub = () => {
Object.values(subControllers).forEach(sub => sub.unsub())
delete activeSubscriptions[id]
}
const sub = ({cb = activeCallback, filter = activeFilters}) => {
const sub = ({
cb = activeCallback,
filter = activeFilters,
beforeSend = activeBeforeSend
}) => {
Object.entries(subControllers).map(([relayURL, sub]) => [
relayURL,
sub.sub({cb, filter}, id)
sub.sub({cb: event => cb(event, relayURL), filter, beforeSend}, id)
])
return activeSubscriptions[id]
}
const addRelay = relay => {
subControllers[relay.url] = relay.sub({cb, filter}, id)
subControllers[relay.url] = relay.sub(
{cb: event => cb(event, relay.url), filter, beforeSend},
id
)
return activeSubscriptions[id]
}
const removeRelay = relayURL => {
@@ -66,25 +88,35 @@ export function relayPool(globalPrivateKey) {
setPrivateKey(privateKey) {
globalPrivateKey = privateKey
},
async addRelay(url, policy = {read: true, write: true}) {
registerSigningFunction(fn) {
globalSigningFunction = fn
},
setPolicy(key, value) {
poolPolicy[key] = value
},
addRelay(url, policy = {read: true, write: true}) {
let relayURL = normalizeRelayURL(url)
if (relayURL in relays) return
let relay = await relayConnect(url, notice => {
let relay = relayConnect(url, notice => {
propagateNotice(notice, relayURL)
})
relays[relayURL] = {relay, policy}
Object.values(activeSubscriptions).forEach(subscription =>
subscription.addRelay(relay)
)
if (policy.read) {
Object.values(activeSubscriptions).forEach(subscription =>
subscription.addRelay(relay)
)
}
return relay
},
removeRelay(url) {
let relayURL = normalizeRelayURL(url)
let {relay} = relays[relayURL]
if (!relay) return
let data = relays[relayURL]
if (!data) return
let {relay} = data
Object.values(activeSubscriptions).forEach(subscription =>
subscription.removeRelay(relay)
)
@@ -98,32 +130,75 @@ export function relayPool(globalPrivateKey) {
let index = noticeCallbacks.indexOf(cb)
if (index !== -1) noticeCallbacks.splice(index, 1)
},
async publish(event, statusCallback = (status, relayURL) => {}) {
event.id = await getEventHash(event)
async publish(event, statusCallback) {
event.id = getEventHash(event)
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 or pass a private key while initializing this relay pool so it can be signed automatically."
"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."
)
}
}
Object.values(relays)
let writeable = Object.values(relays)
.filter(({policy}) => policy.write)
.map(async ({relay}) => {
.sort(() => Math.random() - 0.5) // random
let maxTargets = poolPolicy.randomChoice
? poolPolicy.randomChoice
: writeable.length
let successes = 0
if (poolPolicy.wait) {
for (let i = 0; i < writeable.length; i++) {
let {relay} = writeable[i]
try {
await relay.publish(event, status =>
statusCallback(status, relay.url)
)
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) {
statusCallback(-1, relay.url)
/***/
}
}
} else {
writeable.forEach(async ({relay}) => {
let callback = statusCallback
? status => statusCallback(status, relay.url)
: null
relay.publish(event, callback)
})
}
return event
}

View File

@@ -2,22 +2,23 @@
import 'websocket-polyfill'
import {verifySignature} from './event'
import {matchFilters} from './filter'
import {verifySignature, validateEvent} from './event.js'
import {matchFilters} from './filter.js'
export function normalizeRelayURL(url) {
let [host, ...qs] = url.split('?')
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 relayConnect(url, onNotice) {
export function relayConnect(url, onNotice = () => {}, onError = () => {}) {
url = normalizeRelayURL(url)
var ws, resolveOpen, untilOpen, wasClosed
var openSubs = {}
var isSetToSkipVerification = {}
let attemptNumber = 1
let nextAttemptSeconds = 1
@@ -46,8 +47,9 @@ export function relayConnect(url, onNotice) {
}
}
}
ws.onerror = () => {
ws.onerror = err => {
console.log('error connecting to relay', url)
onError(err)
}
ws.onclose = () => {
resetOpenState()
@@ -92,7 +94,8 @@ export function relayConnect(url, onNotice) {
let event = data[2]
if (
(await verifySignature(event)) &&
validateEvent(event) &&
(isSetToSkipVerification[channel] || verifySignature(event)) &&
channels[channel] &&
matchFilters(openSubs[channel], event)
) {
@@ -117,7 +120,10 @@ export function relayConnect(url, onNotice) {
ws.send(msg)
}
const sub = ({cb, filter}, channel = Math.random().toString().slice(2)) => {
const sub = (
{cb, filter, beforeSend, skipVerification},
channel = Math.random().toString().slice(2)
) => {
var filters = []
if (Array.isArray(filter)) {
filters = filter
@@ -125,19 +131,30 @@ export function relayConnect(url, onNotice) {
filters.push(filter)
}
if (beforeSend) {
const beforeSendResult = beforeSend({filter, relay: url, channel})
filters = beforeSendResult.filter
}
trySend(['REQ', channel, ...filters])
channels[channel] = cb
openSubs[channel] = filters
isSetToSkipVerification[channel] = skipVerification
const activeCallback = cb
const activeFilters = filters
const activeBeforeSend = beforeSend
return {
sub: ({cb = activeCallback, filter = activeFilters}) =>
sub({cb, filter}, channel),
sub: ({
cb = activeCallback,
filter = activeFilters,
beforeSend = activeBeforeSend
}) => sub({cb, filter, beforeSend, skipVerification}, channel),
unsub: () => {
delete openSubs[channel]
delete channels[channel]
delete isSetToSkipVerification[channel]
trySend(['CLOSE', channel])
}
}
@@ -146,22 +163,26 @@ export function relayConnect(url, onNotice) {
return {
url,
sub,
async publish(event, statusCallback = status => {}) {
async publish(event, statusCallback) {
try {
await trySend(['EVENT', event])
statusCallback(0)
let {unsub} = sub(
{
cb: () => {
statusCallback(1)
if (statusCallback) {
statusCallback(0)
let {unsub} = sub(
{
cb: () => {
statusCallback(1)
unsub()
clearTimeout(willUnsub)
},
filter: {ids: [event.id]}
},
filter: {id: event.id}
},
`monitor-${event.id.slice(0, 5)}`
)
setTimeout(unsub, 5000)
`monitor-${event.id.slice(0, 5)}`
)
let willUnsub = setTimeout(unsub, 5000)
}
} catch (err) {
statusCallback(-1)
if (statusCallback) statusCallback(-1)
}
},
close() {

25
tsconfig.json Normal file
View File

@@ -0,0 +1,25 @@
{
"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"
]
}