Compare commits

...

24 Commits

Author SHA1 Message Date
fiatjaf
74a0d5454a guard against some nonexisting arrays of event listeners. 2022-12-23 15:18:23 -03:00
fiatjaf
c0d1e41424 always recompute the hash when signing.
fixes https://github.com/fiatjaf/nostr-tools/issues/59
2022-12-23 15:06:21 -03:00
fiatjaf
f7e510e1c8 nip05 regex name check. 2022-12-23 15:04:24 -03:00
fiatjaf
c08bdac7a7 catch usage of global fetch for nodejs.
fixes https://github.com/fiatjaf/nostr-tools/issues/53
2022-12-23 11:36:37 -03:00
rkfg
c5b64404f6 Add limit to filter 2022-12-23 11:29:38 -03:00
adamritter
c7b26fdba2 Don't expose external API to hex representation of mnemoic 2022-12-23 11:01:10 -03:00
fiatjaf
ac698ef67d make relay.connect() an awaitable thing. 2022-12-22 08:53:40 -03:00
fiatjaf
8262a81cb2 make crypto available as a global on nip04 test. 2022-12-21 17:12:50 -03:00
fiatjaf
26e6da6ba3 we need websocket polyfill on relay tests. 2022-12-21 17:09:00 -03:00
fiatjaf
8aa31bb437 remove websocket-polyfill, instruct nodejs users to install it manually. 2022-12-21 16:23:47 -03:00
fiatjaf
4bd4469357 remove useless readable-stream dependency. 2022-12-21 16:19:59 -03:00
fiatjaf
89ae21f796 remove buffer usage everywhere. 2022-12-21 16:04:09 -03:00
fiatjaf
41a1614d89 remove browserify-cipher, use crypto.subtle for nip04. 2022-12-21 16:04:00 -03:00
fiatjaf
0500415a4e remove all the auto-reconnection code from relay. 2022-12-21 15:31:57 -03:00
fiatjaf
cee4357cab Merge pull request #50 from mmalmi/patch-1 2022-12-21 08:50:29 -03:00
Sandwich
d5cf5930d1 Fix example code in readme, resolves #47 2022-12-21 08:44:52 -03:00
Martti Malmi
a78e2036aa status code 3 (closed) for un-opened connection 2022-12-21 11:15:36 +02:00
Martti Malmi
adc1854ac6 relay.status() returns 0 when ws not created 2022-12-21 11:08:10 +02:00
fiatjaf
83148e8bdf fix small things in README. 2022-12-20 22:34:19 -03:00
fiatjaf
364c37cac5 fix autopublishing to npm. 2022-12-20 20:15:43 -03:00
fiatjaf
385cdb4ac6 README examples for nip05 and nip19. 2022-12-20 18:42:24 -03:00
fiatjaf
3f1025f551 nip05.queryProfile() and test. 2022-12-20 18:36:49 -03:00
fiatjaf
482c5affd4 add nip19. 2022-12-20 18:26:30 -03:00
fiatjaf
679ac0c133 fix standalone script URL. 2022-12-20 17:01:35 -03:00
19 changed files with 537 additions and 304 deletions

View File

@@ -24,6 +24,7 @@
"document": false,
"navigator": false,
"window": false,
"crypto": false,
"location": false,
"URL": false,
"URLSearchParams": false,

View File

@@ -14,6 +14,8 @@ jobs:
node-version: 18
- run: yarn --ignore-engines
- run: node build.js
- run: npm publish
env:
NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}}
- run: yarn test
- uses: JS-DevTools/npm-publish@v1
with:
token: ${{ secrets.NPM_TOKEN }}
greater-version-only: true

View File

@@ -2,15 +2,17 @@
Tools for developing [Nostr](https://github.com/fiatjaf/nostr) clients.
Very lean on dependencies.
## Usage
### Generating a private key and a public key
```js
import { generatePrivateKey, getPublicKey } from 'nostr-tools'
import {generatePrivateKey, getPublicKey} from 'nostr-tools'
let sk = generatePrivateKey() # `sk` is a hex string
let pk = getPublicKey(sk) # `pk` is a hex string
let sk = generatePrivateKey() // `sk` is a hex string
let pk = getPublicKey(sk) // `pk` is a hex string
```
### Creating, signing and verifying events
@@ -31,7 +33,7 @@ let event = {
content: 'hello'
}
event.id = getEventHash(event.id)
event.id = getEventHash(event)
event.pubkey = getPublicKey(privateKey)
event.sig = await signEvent(event, privateKey)
@@ -51,7 +53,7 @@ import {
} from 'nostr-tools'
const relay = relayInit('wss://relay.example.com')
relay.connect()
await relay.connect()
relay.on('connect', () => {
console.log(`connected to ${relay.url}`)
@@ -112,6 +114,59 @@ pub.on('failed', reason => {
await relay.close()
```
To use this on Node.js you first must install `websocket-polyfill` and import it:
```js
import 'websocket-polyfill'
```
### Querying profile data from a NIP-05 address
```js
import {nip05} from 'nostr-tools'
let profile = await nip05.queryProfile('jb55.com')
console.log(profile.pubkey)
// prints: 32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245
console.log(profile.relays)
// prints: [wss://relay.damus.io]
```
To use this on Node.js you first must install `node-fetch@2` and call something like this:
```js
nip05.useFetchImplementation(require('node-fetch'))
```
### Encoding and decoding NIP-19 codes
```js
import {nip19, generatePrivateKey, getPublicKey} from 'nostr-tools'
let sk = generatePrivateKey()
let nsec = nip19.nsecEncode(sk)
let {type, data} = nip19.decode(nsec)
assert(type === 'nsec')
assert(data === sk)
let pk = getPublicKey(generatePrivateKey())
let npub = nip19.npubEncode(pk)
let {type, data} = nip19.decode(npub)
assert(type === 'npub')
assert(data === pk)
let pk = getPublicKey(generatePrivateKey())
let relays = [
'wss://relay.nostr.example.mydomain.example.com',
'wss://nostr.banana.com'
]
let nprofile = nip19.nprofileEncode({pubkey: pk, relays})
let {type, data} = nip19.decode(nprofile)
assert(type === 'nprofile')
assert(data.pubkey === pk)
assert(data.relays.length === 2)
```
### Encrypting and decrypting direct messages
```js
@@ -127,7 +182,7 @@ let pk2 = getPublicKey(sk2)
// on the sender side
let message = 'hello'
let ciphertext = nip04.encrypt(sk1, pk2, 'hello')
let ciphertext = await nip04.encrypt(sk1, pk2, 'hello')
let event = {
kind: 4,
@@ -140,11 +195,10 @@ let event = {
sendEvent(event)
// on the receiver side
sub.on('event', (event) => {
let sender = event.tags.find(([k, v]) => k === 'p' && && v && v !== '')[1]
pk1 === sender
let plaintext = nip04.decrypt(sk2, pk1, event.content)
let plaintext = await nip04.decrypt(sk2, pk1, event.content)
})
```
@@ -153,7 +207,7 @@ Please consult the tests or [the source code](https://github.com/fiatjaf/nostr-t
### Using from the browser (if you don't want to use a bundler)
```html
<script src="https://unpkg.com/nostr-tools/nostr.bundle.js"></script>
<script src="https://unpkg.com/nostr-tools/lib/nostr.bundle.js"></script>
<script>
window.NostrTools.generatePrivateKey('...') // and so on
</script>

View File

@@ -1,16 +1,10 @@
#!/usr/bin/env node
const esbuild = require('esbuild')
const alias = require('esbuild-plugin-alias')
let common = {
entryPoints: ['index.ts'],
bundle: true,
plugins: [
alias({
stream: require.resolve('readable-stream')
})
],
sourcemap: 'external'
}

View File

@@ -1,8 +1,8 @@
import {Buffer} from 'buffer'
// @ts-ignore
import * as secp256k1 from '@noble/secp256k1'
import {sha256} from '@noble/hashes/sha256'
import {utf8Encoder} from './utils'
export type Event = {
id?: string
sig?: string
@@ -35,8 +35,8 @@ export function serializeEvent(evt: Event): string {
}
export function getEventHash(event: Event): string {
let eventHash = sha256(Buffer.from(serializeEvent(event)))
return Buffer.from(eventHash).toString('hex')
let eventHash = sha256(utf8Encoder.encode(serializeEvent(event)))
return secp256k1.utils.bytesToHex(eventHash)
}
export function validateEvent(event: Event): boolean {
@@ -63,7 +63,7 @@ export function verifySignature(
}
export async function signEvent(event: Event, key: string): Promise<string> {
return Buffer.from(
await secp256k1.schnorr.sign(event.id || getEventHash(event), key)
).toString('hex')
return secp256k1.utils.bytesToHex(
await secp256k1.schnorr.sign(getEventHash(event), key)
)
}

View File

@@ -6,6 +6,7 @@ export type Filter = {
authors?: string[]
since?: number
until?: number
limit?: number
[key: `#${string}`]: string[]
}

View File

@@ -6,3 +6,4 @@ export * from './filter'
export * as nip04 from './nip04'
export * as nip05 from './nip05'
export * as nip06 from './nip06'
export * as nip19 from './nip19'

View File

@@ -1,10 +1,9 @@
import * as secp256k1 from '@noble/secp256k1'
import {Buffer} from 'buffer'
export function generatePrivateKey(): string {
return Buffer.from(secp256k1.utils.randomPrivateKey()).toString('hex')
return secp256k1.utils.bytesToHex(secp256k1.utils.randomPrivateKey())
}
export function getPublicKey(privateKey: string): string {
return Buffer.from(secp256k1.schnorr.getPublicKey(privateKey)).toString('hex')
return secp256k1.utils.bytesToHex(secp256k1.schnorr.getPublicKey(privateKey))
}

View File

@@ -1,14 +1,15 @@
/* eslint-env jest */
globalThis.crypto = require('crypto')
const {nip04, getPublicKey, generatePrivateKey} = require('./lib/nostr.cjs')
test('encrypt and decrypt message', () => {
test('encrypt and decrypt message', async () => {
let sk1 = generatePrivateKey()
let sk2 = generatePrivateKey()
let pk1 = getPublicKey(sk1)
let pk2 = getPublicKey(sk2)
expect(nip04.decrypt(sk2, pk1, nip04.encrypt(sk1, pk2, 'hello'))).toEqual(
'hello'
)
expect(
await nip04.decrypt(sk2, pk1, await nip04.encrypt(sk1, pk2, 'hello'))
).toEqual('hello')
})

View File

@@ -1,45 +1,66 @@
import {Buffer} from 'buffer'
import {randomBytes} from '@noble/hashes/utils'
import * as secp256k1 from '@noble/secp256k1'
// @ts-ignore
import aes from 'browserify-cipher'
import {encode as b64encode, decode as b64decode} from 'base64-arraybuffer'
export function encrypt(privkey: string, pubkey: string, text: string): string {
import {utf8Decoder, utf8Encoder} from './utils'
export async function encrypt(
privkey: string,
pubkey: string,
text: string
): Promise<string> {
const key = secp256k1.getSharedSecret(privkey, '02' + pubkey)
const normalizedKey = getNormalizedX(key)
let iv = Uint8Array.from(randomBytes(16))
var cipher = aes.createCipheriv(
'aes-256-cbc',
Buffer.from(normalizedKey, 'hex'),
iv
let plaintext = utf8Encoder.encode(text)
let cryptoKey = await crypto.subtle.importKey(
'raw',
normalizedKey,
{name: 'AES-CBC'},
false,
['encrypt']
)
let encryptedMessage = cipher.update(text, 'utf8', 'base64')
encryptedMessage += cipher.final('base64')
let ciphertext = await crypto.subtle.encrypt(
{name: 'AES-CBC', iv},
cryptoKey,
plaintext
)
let ctb64 = b64encode(ciphertext)
let ivb64 = b64encode(iv.buffer)
return `${encryptedMessage}?iv=${Buffer.from(iv.buffer).toString('base64')}`
return `${ctb64}?iv=${ivb64}`
}
export function decrypt(
export async function decrypt(
privkey: string,
pubkey: string,
ciphertext: string
): string {
let [cip, iv] = ciphertext.split('?iv=')
data: string
): Promise<string> {
let [ctb64, ivb64] = data.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 cryptoKey = await crypto.subtle.importKey(
'raw',
normalizedKey,
{name: 'AES-CBC'},
false,
['decrypt']
)
let decryptedMessage = decipher.update(cip, 'base64', 'utf8')
decryptedMessage += decipher.final('utf8')
let ciphertext = b64decode(ctb64)
let iv = b64decode(ivb64)
return decryptedMessage
let plaintext = await crypto.subtle.decrypt(
{name: 'AES-CBC', iv},
cryptoKey,
ciphertext
)
let text = utf8Decoder.decode(plaintext)
return text
}
function getNormalizedX(key: Uint8Array): string {
return Buffer.from(key.slice(1, 33)).toString('hex')
function getNormalizedX(key: Uint8Array): Uint8Array {
return key.slice(1, 33)
}

20
nip05.test.js Normal file
View File

@@ -0,0 +1,20 @@
/* eslint-env jest */
const fetch = require('node-fetch')
const {nip05} = require('./lib/nostr.cjs')
test('fetch nip05 profiles', async () => {
nip05.useFetchImplementation(fetch)
let p1 = await nip05.queryProfile('jb55.com')
expect(p1.pubkey).toEqual(
'32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245'
)
expect(p1.relays).toEqual(['wss://relay.damus.io'])
let p2 = await nip05.queryProfile('jb55@jb55.com')
expect(p2.pubkey).toEqual(
'32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245'
)
expect(p2.relays).toEqual(['wss://relay.damus.io'])
})

View File

@@ -1,4 +1,10 @@
var _fetch = fetch
import {ProfilePointer} from './nip19'
var _fetch: any
try {
_fetch = fetch
} catch {}
export function useFetchImplementation(fetchImplementation: any) {
_fetch = fetchImplementation
@@ -19,13 +25,30 @@ export async function searchDomain(
}
}
export async function queryName(fullname: string): Promise<string> {
export async function queryProfile(
fullname: string
): Promise<ProfilePointer | null> {
let [name, domain] = fullname.split('@')
if (!domain) throw new Error('invalid identifier, must contain an @')
if (!domain) {
// if there is no @, it is because it is just a domain, so assume the name is "_"
domain = name
name = '_'
}
if (!name.match(/^[a-z0-9-_]+$/)) return null
let res = await (
await _fetch(`https://${domain}/.well-known/nostr.json?name=${name}`)
).json()
return res.names && res.names[name]
if (!res?.names?.[name]) return null
let pubkey = res.names[name] as string
let relays = (res.relays?.[pubkey] || []) as string[] // nip35
return {
pubkey,
relays
}
}

View File

@@ -1,3 +1,4 @@
import * as secp256k1 from '@noble/secp256k1'
import {wordlist} from '@scure/bip39/wordlists/english.js'
import {
generateMnemonic,
@@ -6,15 +7,11 @@ import {
} from '@scure/bip39'
import {HDKey} from '@scure/bip32'
export function privateKeyFromSeed(seed: string): string {
let root = HDKey.fromMasterSeed(Buffer.from(seed, 'hex'))
export function privateKeyFromSeedWords(mnemonic: string): string {
let root = HDKey.fromMasterSeed(mnemonicToSeedSync(mnemonic))
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: string): string {
return Buffer.from(mnemonicToSeedSync(mnemonic)).toString('hex')
return secp256k1.utils.bytesToHex(privateKey)
}
export function generateSeedWords(): string {

36
nip19.test.js Normal file
View File

@@ -0,0 +1,36 @@
/* eslint-env jest */
const {nip19, generatePrivateKey, getPublicKey} = require('./lib/nostr.cjs')
test('encode and decode nsec', () => {
let sk = generatePrivateKey()
let nsec = nip19.nsecEncode(sk)
expect(nsec).toMatch(/nsec1\w+/)
let {type, data} = nip19.decode(nsec)
expect(type).toEqual('nsec')
expect(data).toEqual(sk)
})
test('encode and decode npub', () => {
let pk = getPublicKey(generatePrivateKey())
let npub = nip19.npubEncode(pk)
expect(npub).toMatch(/npub1\w+/)
let {type, data} = nip19.decode(npub)
expect(type).toEqual('npub')
expect(data).toEqual(pk)
})
test('encode and decode nprofile', () => {
let pk = getPublicKey(generatePrivateKey())
let relays = [
'wss://relay.nostr.example.mydomain.example.com',
'wss://nostr.banana.com'
]
let nprofile = nip19.nprofileEncode({pubkey: pk, relays})
expect(nprofile).toMatch(/nprofile1\w+/)
let {type, data} = nip19.decode(nprofile)
expect(type).toEqual('nprofile')
expect(data.pubkey).toEqual(pk)
expect(data.relays).toContain(relays[0])
expect(data.relays).toContain(relays[1])
})

125
nip19.ts Normal file
View File

@@ -0,0 +1,125 @@
import * as secp256k1 from '@noble/secp256k1'
import {bech32} from 'bech32'
import {utf8Decoder, utf8Encoder} from './utils'
export type ProfilePointer = {
pubkey: string // hex
relays?: string[]
}
export type EventPointer = {
id: string // hex
relays?: string[]
}
export function decode(nip19: string): {
type: string
data: ProfilePointer | EventPointer | string
} {
let {prefix, words} = bech32.decode(nip19, 1000)
let data = new Uint8Array(bech32.fromWords(words))
if (prefix === 'nprofile') {
let tlv = parseTLV(data)
if (!tlv[0]?.[0]) throw new Error('missing TLV 0 for nprofile')
if (tlv[0][0].length !== 32) throw new Error('TLV 0 should be 32 bytes')
return {
type: 'nprofile',
data: {
pubkey: secp256k1.utils.bytesToHex(tlv[0][0]),
relays: tlv[1].map(d => utf8Decoder.decode(d))
}
}
}
if (prefix === 'nevent') {
let tlv = parseTLV(data)
if (!tlv[0]?.[0]) throw new Error('missing TLV 0 for nevent')
if (tlv[0][0].length !== 32) throw new Error('TLV 0 should be 32 bytes')
return {
type: 'nevent',
data: {
id: secp256k1.utils.bytesToHex(tlv[0][0]),
relays: tlv[1].map(d => utf8Decoder.decode(d))
}
}
}
if (prefix === 'nsec' || prefix === 'npub' || prefix === 'note') {
return {type: prefix, data: secp256k1.utils.bytesToHex(data)}
}
throw new Error(`unknown prefix ${prefix}`)
}
type TLV = {[t: number]: Uint8Array[]}
function parseTLV(data: Uint8Array): TLV {
let result: TLV = {}
let rest = data
while (rest.length > 0) {
let t = rest[0]
let l = rest[1]
let v = rest.slice(2, 2 + l)
rest = rest.slice(2 + l)
if (v.length < l) continue
result[t] = result[t] || []
result[t].push(v)
}
return result
}
export function nsecEncode(hex: string): string {
return encodeBytes('nsec', hex)
}
export function npubEncode(hex: string): string {
return encodeBytes('npub', hex)
}
export function noteEncode(hex: string): string {
return encodeBytes('note', hex)
}
function encodeBytes(prefix: string, hex: string): string {
let data = secp256k1.utils.hexToBytes(hex)
let words = bech32.toWords(data)
return bech32.encode(prefix, words, 1000)
}
export function nprofileEncode(profile: ProfilePointer): string {
let data = encodeTLV({
0: [secp256k1.utils.hexToBytes(profile.pubkey)],
1: (profile.relays || []).map(url => utf8Encoder.encode(url))
})
let words = bech32.toWords(data)
return bech32.encode('nprofile', words, 1000)
}
export function neventEncode(event: EventPointer): string {
let data = encodeTLV({
0: [secp256k1.utils.hexToBytes(event.id)],
1: (event.relays || []).map(url => utf8Encoder.encode(url))
})
let words = bech32.toWords(data)
return bech32.encode('nevent', words, 1000)
}
function encodeTLV(tlv: TLV): Uint8Array {
let entries: Uint8Array[] = []
Object.entries(tlv).forEach(([t, vs]) => {
vs.forEach(v => {
let entry = new Uint8Array(v.length + 2)
entry.set([parseInt(t)], 0)
entry.set([v.length], 1)
entry.set(v, 2)
entries.push(entry)
})
})
return secp256k1.utils.concatBytes(...entries)
}

View File

@@ -1,6 +1,6 @@
{
"name": "nostr-tools",
"version": "1.0.0-alpha2",
"version": "1.0.0-rc2",
"description": "Tools for making a Nostr client.",
"repository": {
"type": "git",
@@ -13,9 +13,8 @@
"@noble/secp256k1": "^1.7.0",
"@scure/bip32": "^1.1.1",
"@scure/bip39": "^1.1.0",
"browserify-cipher": ">=1",
"buffer": "^6.0.3",
"websocket-polyfill": "^0.0.3"
"base64-arraybuffer": "^1.0.2",
"bech32": "^2.0.0"
},
"keywords": [
"decentralization",
@@ -35,9 +34,11 @@
"esm-loader-typescript": "^1.0.1",
"events": "^3.3.0",
"jest": "^29.3.1",
"node-fetch": "2",
"ts-jest": "^29.0.3",
"tsd": "^0.22.0",
"typescript": "^4.9.4"
"typescript": "^4.9.4",
"websocket-polyfill": "^0.0.3"
},
"scripts": {
"build": "node build.js",

View File

@@ -1,5 +1,6 @@
/* eslint-env jest */
require('websocket-polyfill')
const {
relayInit,
generatePrivateKey,
@@ -8,110 +9,106 @@ const {
signEvent
} = require('./lib/nostr.cjs')
describe('relay interaction', () => {
let relay = relayInit('wss://nostr-pub.semisol.dev/')
let relay = relayInit('wss://nostr-pub.semisol.dev/')
beforeAll(() => {
relay.connect()
})
afterAll(async () => {
await relay.close()
})
test('connectivity', () => {
return expect(
new Promise(resolve => {
relay.on('connect', () => {
resolve(true)
})
relay.on('error', () => {
resolve(false)
})
})
).resolves.toBe(true)
})
test('querying', () => {
var resolve1
var resolve2
let sub = relay.sub([
{
ids: [
'd7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027'
]
}
])
sub.on('event', event => {
expect(event).toHaveProperty(
'id',
'd7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027'
)
resolve1(true)
})
sub.on('eose', () => {
resolve2(true)
})
return expect(
Promise.all([
new Promise(resolve => {
resolve1 = resolve
}),
new Promise(resolve => {
resolve2 = resolve
})
])
).resolves.toEqual([true, true])
})
test('listening (twice) and publishing', async () => {
let sk = generatePrivateKey()
let pk = getPublicKey(sk)
var resolve1
var resolve2
let sub = relay.sub([
{
kinds: [27572],
authors: [pk]
}
])
sub.on('event', event => {
expect(event).toHaveProperty('pubkey', pk)
expect(event).toHaveProperty('kind', 27572)
expect(event).toHaveProperty('content', 'nostr-tools test suite')
resolve1(true)
})
sub.on('event', event => {
expect(event).toHaveProperty('pubkey', pk)
expect(event).toHaveProperty('kind', 27572)
expect(event).toHaveProperty('content', 'nostr-tools test suite')
resolve2(true)
})
let event = {
kind: 27572,
pubkey: pk,
created_at: Math.floor(Date.now() / 1000),
tags: [],
content: 'nostr-tools test suite'
}
event.id = getEventHash(event)
event.sig = await signEvent(event, sk)
relay.publish(event)
return expect(
Promise.all([
new Promise(resolve => {
resolve1 = resolve
}),
new Promise(resolve => {
resolve2 = resolve
})
])
).resolves.toEqual([true, true])
})
beforeAll(() => {
relay.connect()
})
afterAll(async () => {
await relay.close()
})
test('connectivity', () => {
return expect(
new Promise(resolve => {
relay.on('connect', () => {
resolve(true)
})
relay.on('error', () => {
resolve(false)
})
})
).resolves.toBe(true)
})
test('querying', () => {
var resolve1
var resolve2
let sub = relay.sub([
{
ids: ['d7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027']
}
])
sub.on('event', event => {
expect(event).toHaveProperty(
'id',
'd7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027'
)
resolve1(true)
})
sub.on('eose', () => {
resolve2(true)
})
return expect(
Promise.all([
new Promise(resolve => {
resolve1 = resolve
}),
new Promise(resolve => {
resolve2 = resolve
})
])
).resolves.toEqual([true, true])
})
test('listening (twice) and publishing', async () => {
let sk = generatePrivateKey()
let pk = getPublicKey(sk)
var resolve1
var resolve2
let sub = relay.sub([
{
kinds: [27572],
authors: [pk]
}
])
sub.on('event', event => {
expect(event).toHaveProperty('pubkey', pk)
expect(event).toHaveProperty('kind', 27572)
expect(event).toHaveProperty('content', 'nostr-tools test suite')
resolve1(true)
})
sub.on('event', event => {
expect(event).toHaveProperty('pubkey', pk)
expect(event).toHaveProperty('kind', 27572)
expect(event).toHaveProperty('content', 'nostr-tools test suite')
resolve2(true)
})
let event = {
kind: 27572,
pubkey: pk,
created_at: Math.floor(Date.now() / 1000),
tags: [],
content: 'nostr-tools test suite'
}
event.id = getEventHash(event)
event.sig = await signEvent(event, sk)
relay.publish(event)
return expect(
Promise.all([
new Promise(resolve => {
resolve1 = resolve
}),
new Promise(resolve => {
resolve2 = resolve
})
])
).resolves.toEqual([true, true])
})

212
relay.ts
View File

@@ -1,7 +1,5 @@
/* global WebSocket */
import 'websocket-polyfill'
import {Event, verifySignature, validateEvent} from './event'
import {Filter, matchFilters} from './filter'
@@ -33,11 +31,8 @@ type SubscriptionOptions = {
export function relayInit(url: string): Relay {
var ws: WebSocket
var resolveOpen: () => void
var resolveClose: () => void
var untilOpen: Promise<void>
var wasClosed: boolean
var closed: boolean
var openSubs: {[id: string]: {filters: Filter[]} & SubscriptionOptions} = {}
var listeners: {
connect: Array<() => void>
@@ -63,123 +58,78 @@ export function relayInit(url: string): Relay {
failed: Array<(reason: string) => void>
}
} = {}
let attemptNumber = 1
let nextAttemptSeconds = 1
let isConnected = false
function resetOpenState() {
untilOpen = new Promise(resolve => {
resolveOpen = resolve
async function connectRelay(): Promise<void> {
return new Promise((resolve, reject) => {
ws = new WebSocket(url)
ws.onopen = () => {
listeners.connect.forEach(cb => cb())
resolve()
}
ws.onerror = () => {
listeners.error.forEach(cb => cb())
reject()
}
ws.onclose = async () => {
listeners.disconnect.forEach(cb => cb())
resolveClose()
}
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
}
}
}
})
}
function connectRelay() {
ws = new WebSocket(url)
ws.onopen = () => {
listeners.connect.forEach(cb => cb())
resolveOpen()
isConnected = true
// restablish old subscriptions
if (wasClosed) {
wasClosed = false
for (let id in openSubs) {
let {filters} = openSubs[id]
sub(filters, openSubs[id])
}
}
}
ws.onerror = () => {
isConnected = false
listeners.error.forEach(cb => cb())
}
ws.onclose = async () => {
isConnected = false
listeners.disconnect.forEach(cb => cb())
if (closed) {
// we've closed this because we wanted, so end everything
resolveClose()
return
}
// otherwise keep trying to reconnect
resetOpenState()
attemptNumber++
nextAttemptSeconds += attemptNumber ** 3
if (nextAttemptSeconds > 14400) {
nextAttemptSeconds = 14400 // 4 hours
}
console.log(
`relay ${url} 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<void> {
if (ws?.readyState && ws.readyState === 1) return // ws already open
try {
connectRelay()
} catch (err) {}
await connectRelay()
}
async function trySend(params: [string, ...any]) {
@@ -224,8 +174,9 @@ export function relayInit(url: string): Relay {
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)
let listeners = subListeners[subid]
let idx = listeners[type].indexOf(cb)
if (idx >= 0) listeners[type].splice(idx, 1)
}
}
}
@@ -233,13 +184,19 @@ export function relayInit(url: string): Relay {
return {
url,
sub,
on: (type: 'connect' | 'disconnect' | 'notice', cb: any): void => {
on: (
type: 'connect' | 'disconnect' | 'error' | 'notice',
cb: any
): void => {
listeners[type].push(cb)
if (type === 'connect' && isConnected) {
if (type === 'connect' && ws?.readyState === 1) {
cb()
}
},
off: (type: 'connect' | 'disconnect' | 'notice', cb: any): void => {
off: (
type: 'connect' | 'disconnect' | 'error' | 'notice',
cb: any
): void => {
let index = listeners[type].indexOf(cb)
if (index !== -1) listeners[type].splice(index, 1)
},
@@ -265,14 +222,14 @@ export function relayInit(url: string): Relay {
id: `monitor-${id.slice(0, 5)}`
})
let willUnsub = setTimeout(() => {
pubListeners[id].failed.forEach(cb =>
;(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())
;(pubListeners[id]?.seen || []).forEach(cb => cb())
})
}
@@ -291,21 +248,22 @@ export function relayInit(url: string): Relay {
}
},
off: (type: 'ok' | 'seen' | 'failed', cb: any) => {
let idx = pubListeners[id][type].indexOf(cb)
if (idx >= 0) pubListeners[id][type].splice(idx, 1)
let listeners = pubListeners[id]
if (!listeners) return
let idx = listeners[type].indexOf(cb)
if (idx >= 0) listeners[type].splice(idx, 1)
}
}
},
connect,
close(): Promise<void> {
closed = true // prevent ws from trying to reconnect
ws.close()
return new Promise(resolve => {
resolveClose = resolve
})
},
get status() {
return ws.readyState
return ws?.readyState ?? 3
}
}
}

2
utils.ts Normal file
View File

@@ -0,0 +1,2 @@
export const utf8Decoder = new TextDecoder('utf-8')
export const utf8Encoder = new TextEncoder()