Compare commits

...

65 Commits

Author SHA1 Message Date
fiatjaf
b96159ad36 better publishing built files. 2022-12-20 16:56:05 -03:00
fiatjaf
6dede4a688 use semisol relay that has our desired event on test. 2022-12-20 16:26:55 -03:00
fiatjaf
50c8bb72f9 v1.0.0-alpha 2022-12-20 16:16:59 -03:00
fiatjaf
72781e0eab nip05 typescript fixes. 2022-12-20 16:16:59 -03:00
fiatjaf
bf120c1348 relay examples on README. 2022-12-20 16:16:59 -03:00
fiatjaf
3630d377e5 test every commit on github actions. 2022-12-20 16:16:59 -03:00
fiatjaf
53b0091bf4 some fixes on relay.ts and tests. 2022-12-20 15:25:34 -03:00
fiatjaf
1a7cc5f21f updated readme with nicer examples. 2022-12-19 20:13:08 -03:00
fiatjaf
1162935f58 add a bunch of tests. 2022-12-19 20:02:01 -03:00
fiatjaf
a49d971f6a reorganize index.ts to use "export *". 2022-12-19 19:51:38 -03:00
fiatjaf
897919be3b get rid of create-hash, use noble hashes. 2022-12-19 19:50:41 -03:00
fiatjaf
39aca167fb build for commonjs, esm and a standalone bundle. 2022-12-19 15:46:31 -03:00
fiatjaf
de8bdd8370 fix typescript types everywhere, delete pool.js and refactor relay.js to use event listeners everywhere. 2022-12-18 17:02:19 -03:00
Íñigo Aréjula Aísa
46a0a342db event fields (#37) 2022-12-17 13:09:25 -03:00
Leo Wandersleb
4fe2a9c91a use default fetch if in service worker (#23) 2022-12-17 13:08:59 -03:00
fiatjaf
e62b833464 Merge pull request #41 from monlovesmango/cb-api
refactor of cb api
2022-12-17 13:06:30 -03:00
monica
100c77d2aa finalize cb api 2022-12-07 22:26:51 -06:00
Íñigo Aréjula Aísa
12be5a5338 Fix tag type
I realized that tags were an array of array, if it is correct merge, if im wrong just discard
2022-12-05 16:40:47 -03:00
monica
b955ba2a09 initial refactor of cb api 2022-12-04 21:57:15 -06:00
Íñigo Aréjula Aísa
ec805be4ab expose nip 4 functions to TS (#39) 2022-11-30 19:41:10 -03:00
Íñigo Aréjula Aísa
92fb339afb Update relay.js 2022-11-27 13:57:21 +01:00
Íñigo Aréjula Aísa
f8f125270a fix return value getRelayList 2022-11-26 11:16:20 -03:00
Íñigo Aréjula Aísa
1b798b2eee Expose relay funcs (#31) 2022-11-25 23:21:33 -03:00
Íñigo Aréjula Aísa
ae717a1a4a Documentation pool.js (#30) 2022-11-25 23:20:22 -03:00
Íñigo Aréjula Aísa
b2015c8fe5 Filter type with optional atributtes 2022-11-25 13:03:45 -03:00
fiatjaf
c5d2e3b037 github action to publish to npm on tag. 2022-11-21 20:26:58 -03:00
fiatjaf
0ef5d1e19c Merge pull request #27 from monlovesmango/Expose-EOSE-Relay-URL 2022-11-21 20:24:01 -03:00
monlovesmango
7d9d10fdb1 add relay url arg to eoseCb 2022-11-21 16:11:30 -06:00
monlovesmango
a1e1ce131a include eoseCb in sub 2022-11-21 16:09:23 -06:00
Fred
cdb07bb175 replace micro-bip with @scure/bip, fix #25 2022-10-24 06:16:11 -03:00
Leo Wandersleb
1f1bcff803 EOSE nip-15 2022-09-29 07:23:48 -03:00
fiatjaf
896af30619 release v0.24.1 2022-09-06 15:19:29 -03:00
bjong
a8542c4b56 fix: CJK characters are garbled after decryption 2022-09-06 15:17:50 -03:00
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
30 changed files with 1105 additions and 658 deletions

View File

@@ -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,
@@ -117,14 +119,25 @@
"no-unexpected-multiline": 2,
"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" } }],
"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}],
"space-before-blocks": [2, "always"],

19
.github/workflows/npm-publish.yml vendored Normal file
View File

@@ -0,0 +1,19 @@
name: publish npm package
on:
push:
tags: [v*]
jobs:
publish-npm:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: 18
- run: yarn --ignore-engines
- run: node build.js
- run: npm publish
env:
NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}}

17
.github/workflows/test.yml vendored Normal file
View File

@@ -0,0 +1,17 @@
name: test every commit
on:
push:
branches:
- master
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v3
with:
node-version: 18
- run: yarn --ignore-engines
- run: node build.js
- run: yarn test

3
.gitignore vendored
View File

@@ -2,3 +2,6 @@ node_modules
dist
yarn.lock
package-lock.json
.envrc
lib
test.html

214
README.md
View File

@@ -4,69 +4,161 @@ Tools for developing [Nostr](https://github.com/fiatjaf/nostr) clients.
## Usage
### Generating a private key and a public key
```js
import {relayPool} from 'nostr-tools'
import { generatePrivateKey, getPublicKey } from 'nostr-tools'
const pool = relayPool()
pool.setPrivateKey('<hex>') // optional
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) => {
console.log(`got an event from ${relay.url} which is already validated.`, event)
}
// subscribing to a single user
// author is the user's public key
pool.sub({cb: onEvent, filter: {author: '<hex>'}})
// or bulk follow
pool.sub({cb:(event, relay) => {...}, filter: {authors: ['<hex1>', '<hex2>', ..., '<hexn>']}})
// reuse a subscription channel
const mySubscription = pool.sub({cb: ..., filter: ....})
mySubscription.sub({filter: ....})
mySubscription.sub({cb: ...})
mySubscription.unsub()
// get specific event
const specificChannel = pool.sub({
cb: (event, relay) => {
console.log('got specific event from relay', event, relay)
specificChannel.unsub()
},
filter: {id: '<hex>'}
})
// or get a specific event plus all the events that reference it in the 'e' tag
pool.sub({ cb: (event, relay) => { ... }, filter: [{id: '<hex>'}, {'#e': '<hex>'}] })
// get all events
pool.sub({cb: (event, relay) => {...}, filter: {}})
// get recent events
pool.sub({cb: (event, relay) => {...}, filter: {since: timestamp}})
// publishing events(inside an async function):
const ev = await pool.publish(eventObject, (status, url) => {
if (status === 0) {
console.log(`publish request sent to ${url}`)
}
if (status === 1) {
console.log(`event published by ${url}`, ev)
}
})
// it will be signed automatically with the key supplied above
// or pass an already signed event to bypass this
// subscribing to a new relay
pool.addRelay('<url>')
// will automatically subscribe to the all the events called with .sub above
let sk = generatePrivateKey() # `sk` is a hex string
let pk = getPublicKey(sk) # `pk` is a hex string
```
All functions expect bytearrays as hex strings and output bytearrays as hex strings.
### Creating, signing and verifying events
For other utils please read the source (for now).
```js
import {
validateEvent,
verifySignature,
signEvent,
getEventHash,
getPublicKey
} from 'nostr-tools'
let event = {
kind: 1,
created_at: Math.floor(Date.now() / 1000),
tags: [],
content: 'hello'
}
event.id = getEventHash(event.id)
event.pubkey = getPublicKey(privateKey)
event.sig = await signEvent(event, privateKey)
let ok = validateEvent(event)
let veryOk = await verifySignature(event)
```
### Interacting with a relay
```js
import {
relayInit,
generatePrivateKey,
getPublicKey,
getEventHash,
signEvent
} from 'nostr-tools'
const relay = relayInit('wss://relay.example.com')
relay.connect()
relay.on('connect', () => {
console.log(`connected to ${relay.url}`)
})
relay.on('error', () => {
console.log(`failed to connect to ${relay.url}`)
})
// let's query for an event that exists
let sub = relay.sub([
{
ids: ['d7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027']
}
])
sub.on('event', event => {
console.log('we got the event we wanted:', event)
})
sub.on('eose', () => {
sub.unsub()
})
// let's publish a new event while simultaneously monitoring the relay for it
let sk = generatePrivateKey()
let pk = getPublicKey(sk)
let sub = relay.sub([
{
kinds: [1],
authors: [pk]
}
])
sub.on('event', event => {
console.log('got event:', event)
})
let event = {
kind: 1,
pubkey: pk,
created_at: Math.floor(Date.now() / 1000),
tags: [],
content: 'hello world'
}
event.id = getEventHash(event)
event.sig = await signEvent(event, sk)
let pub = relay.publish(event)
pub.on('ok', () => {
console.log(`{relay.url} has accepted our event`)
})
pub.on('seen', () => {
console.log(`we saw the event on {relay.url}`)
})
pub.on('failed', reason => {
console.log(`failed to publish to {relay.url}: ${reason}`)
})
await relay.close()
```
### Encrypting and decrypting direct messages
```js
import {nip04, getPublicKey, generatePrivateKey} from 'nostr-tools'
// sender
let sk1 = generatePrivateKey()
let pk1 = getPublicKey(sk1)
// receiver
let sk2 = generatePrivateKey()
let pk2 = getPublicKey(sk2)
// on the sender side
let message = 'hello'
let ciphertext = nip04.encrypt(sk1, pk2, 'hello')
let event = {
kind: 4,
pubkey: pk1,
tags: [['p', pk2]],
content: ciphertext,
...otherProperties
}
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)
})
```
Please consult the tests or [the source code](https://github.com/fiatjaf/nostr-tools) for more information that isn't available here.
### 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>
window.NostrTools.generatePrivateKey('...') // and so on
</script>
```
## License
Public domain.

47
build.js Executable file
View File

@@ -0,0 +1,47 @@
#!/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'
}
esbuild
.build({
...common,
outfile: 'lib/nostr.esm.js',
format: 'esm',
packages: 'external'
})
.then(() => console.log('esm build success.'))
esbuild
.build({
...common,
outfile: 'lib/nostr.cjs.js',
format: 'cjs',
packages: 'external'
})
.then(() => console.log('cjs build success.'))
esbuild
.build({
...common,
outfile: 'lib/nostr.bundle.js',
format: 'iife',
globalName: 'NostrTools',
define: {
window: 'self',
global: 'self',
process: '{"env": {}}'
}
})
.then(() => console.log('standalone build success.'))

View File

@@ -1,40 +0,0 @@
import {Buffer} from 'buffer'
import createHash from 'create-hash'
import * as secp256k1 from '@noble/secp256k1'
export function getBlankEvent() {
return {
kind: 255,
pubkey: null,
content: '',
tags: [],
created_at: 0
}
}
export function serializeEvent(evt) {
return JSON.stringify([
0,
evt.pubkey,
evt.created_at,
evt.kind,
evt.tags || [],
evt.content
])
}
export function getEventHash(event) {
let eventHash = createHash('sha256')
.update(Buffer.from(serializeEvent(event)))
.digest()
return Buffer.from(eventHash).toString('hex')
}
export function verifySignature(event) {
if (event.id !== getEventHash(event)) return false
return secp256k1.schnorr.verify(event.sig, event.id, event.pubkey)
}
export async function signEvent(event, key) {
return secp256k1.schnorr.sign(getEventHash(event), key)
}

49
event.test.js Normal file
View File

@@ -0,0 +1,49 @@
/* eslint-env jest */
const {
validateEvent,
verifySignature,
signEvent,
getEventHash,
getPublicKey
} = require('./lib/nostr.cjs')
const event = {
id: 'd7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027',
kind: 1,
pubkey: '22a12a128a3be27cd7fb250cbe796e692896398dc1440ae3fa567812c8107c1c',
created_at: 1670869179,
content:
'NOSTR "WINE-ACCOUNT" WITH HARVEST DATE STAMPED\n\n\n"The older the wine, the greater its reputation"\n\n\n22a12a128a3be27cd7fb250cbe796e692896398dc1440ae3fa567812c8107c1c\n\n\nNWA 2022-12-12\nAA',
tags: [['client', 'astral']],
sig: 'f110e4fdf67835fb07abc72469933c40bdc7334615610cade9554bf00945a1cebf84f8d079ec325d26fefd76fe51cb589bdbe208ac9cdbd63351ddad24a57559'
}
const unsigned = {
created_at: 1671217411,
kind: 0,
tags: [],
content:
'{"name":"fiatjaf","about":"buy my merch at fiatjaf store","picture":"https://fiatjaf.com/static/favicon.jpg","nip05":"_@fiatjaf.com"}'
}
const privateKey =
'5c6c25b7ef18d8633e97512159954e1aa22809c6b763e94b9f91071836d00217'
test('validate event', () => {
expect(validateEvent(event)).toBeTruthy()
})
test('check signature', async () => {
expect(await verifySignature(event)).toBeTruthy()
})
test('sign event', async () => {
let sig = await signEvent(unsigned, privateKey)
let hash = getEventHash(unsigned)
let pubkey = getPublicKey(privateKey)
let signed = {...unsigned, id: hash, sig, pubkey}
expect(await verifySignature(signed)).toBeTruthy()
})

69
event.ts Normal file
View File

@@ -0,0 +1,69 @@
import {Buffer} from 'buffer'
// @ts-ignore
import * as secp256k1 from '@noble/secp256k1'
import {sha256} from '@noble/hashes/sha256'
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: '',
content: '',
tags: [],
created_at: 0
}
}
export function serializeEvent(evt: Event): string {
return JSON.stringify([
0,
evt.pubkey,
evt.created_at,
evt.kind,
evt.tags,
evt.content
])
}
export function getEventHash(event: Event): string {
let eventHash = sha256(Buffer.from(serializeEvent(event)))
return Buffer.from(eventHash).toString('hex')
}
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
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 verifySignature(
event: Event & {id: string; sig: string}
): Promise<boolean> {
return secp256k1.schnorr.verify(event.sig, event.id, event.pubkey)
}
export async function signEvent(event: Event, key: string): Promise<string> {
return Buffer.from(
await secp256k1.schnorr.sign(event.id || getEventHash(event), key)
).toString('hex')
}

View File

@@ -1,27 +0,0 @@
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.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
return true
}
export function matchFilters(filters, event) {
for (let i = 0; i < filters.length; i++) {
if (matchFilter(filters[i], event)) return true
}
return false
}

42
filter.test.js Normal file
View File

@@ -0,0 +1,42 @@
/* eslint-env jest */
const {matchFilters} = require('./lib/nostr.cjs')
test('test if filters match', () => {
;[
{
filters: [{ids: ['i']}],
good: [{id: 'i'}],
bad: [{id: 'j'}]
},
{
filters: [{authors: ['abc']}, {kinds: [1, 3]}],
good: [
{pubkey: 'xyz', kind: 3},
{pubkey: 'abc', kind: 12},
{pubkey: 'abc', kind: 1}
],
bad: [{pubkey: 'hhh', kind: 12}]
},
{
filters: [{'#e': ['yyy'], since: 444}],
good: [
{
tags: [
['e', 'uuu'],
['e', 'yyy']
],
created_at: 555
}
],
bad: [{tags: [['e', 'uuu']], created_at: 111}]
}
].forEach(({filters, good, bad}) => {
good.forEach(ev => {
expect(matchFilters(filters, ev)).toBeTruthy()
})
bad.forEach(ev => {
expect(matchFilters(filters, ev)).toBeFalsy()
})
})
})

49
filter.ts Normal file
View File

@@ -0,0 +1,49 @@
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}
): boolean {
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
for (let f in filter) {
if (f[0] === '#') {
let tagName = f.slice(1)
let values = filter[`#${tagName}`]
if (
values &&
!event.tags.find(
([t, v]) => t === f.slice(1) && values.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
}
export function matchFilters(
filters: Filter[],
event: Event & {id: string}
): boolean {
for (let i = 0; i < filters.length; i++) {
if (matchFilter(filters[i], event)) return true
}
return false
}

View File

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

8
index.ts Normal file
View File

@@ -0,0 +1,8 @@
export * from './keys'
export * from './relay'
export * from './event'
export * from './filter'
export * as nip04 from './nip04'
export * as nip05 from './nip05'
export * as nip06 from './nip06'

View File

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

20
keys.test.js Normal file
View File

@@ -0,0 +1,20 @@
/* eslint-env jest */
const {generatePrivateKey, getPublicKey} = require('./lib/nostr.cjs')
test('test private key generation', () => {
expect(generatePrivateKey()).toMatch(/[a-f0-9]{64}/)
})
test('test public key generation', () => {
expect(getPublicKey(generatePrivateKey())).toMatch(/[a-f0-9]{64}/)
})
test('test public key from private key deterministic', () => {
let sk = generatePrivateKey()
let pk = getPublicKey(sk)
for (let i = 0; i < 5; i++) {
expect(getPublicKey(sk)).toEqual(pk)
}
})

10
keys.ts Normal file
View File

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

View File

@@ -1,39 +0,0 @@
import aes from 'browserify-cipher'
import {Buffer} from 'buffer'
import randomBytes from 'randombytes'
import * as secp256k1 from '@noble/secp256k1'
export function encrypt(privkey, pubkey, text) {
const key = secp256k1.getSharedSecret(privkey, '02' + pubkey)
const normalizedKey = getOnlyXFromFullSharedSecret(key)
let iv = Uint8Array.from(randomBytes(16))
var cipher = aes.createCipheriv(
'aes-256-cbc',
Buffer.from(normalizedKey, 'hex'),
iv
)
let encryptedMessage = cipher.update(text, 'utf8', 'base64')
encryptedMessage += cipher.final('base64')
return [encryptedMessage, Buffer.from(iv.buffer).toString('base64')]
}
export function decrypt(privkey, pubkey, ciphertext, iv) {
const key = secp256k1.getSharedSecret(privkey, '02' + pubkey)
const normalizedKey = getOnlyXFromFullSharedSecret(key)
var decipher = aes.createDecipheriv(
'aes-256-cbc',
Buffer.from(normalizedKey, 'hex'),
Buffer.from(iv, 'base64')
)
let decryptedMessage = decipher.update(ciphertext, 'base64')
decryptedMessage += decipher.final('utf8')
return decryptedMessage
}
function getOnlyXFromFullSharedSecret(fullSharedSecretCoordinates) {
return fullSharedSecretCoordinates.substr(2, 64)
}

14
nip04.test.js Normal file
View File

@@ -0,0 +1,14 @@
/* eslint-env jest */
const {nip04, getPublicKey, generatePrivateKey} = require('./lib/nostr.cjs')
test('encrypt and decrypt message', () => {
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'
)
})

45
nip04.ts Normal file
View File

@@ -0,0 +1,45 @@
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: string, pubkey: string, text: string): 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 encryptedMessage = cipher.update(text, 'utf8', 'base64')
encryptedMessage += cipher.final('base64')
return `${encryptedMessage}?iv=${Buffer.from(iv.buffer).toString('base64')}`
}
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)
var decipher = aes.createDecipheriv(
'aes-256-cbc',
Buffer.from(normalizedKey, 'hex'),
Buffer.from(iv, 'base64')
)
let decryptedMessage = decipher.update(cip, 'base64', 'utf8')
decryptedMessage += decipher.final('utf8')
return decryptedMessage
}
function getNormalizedX(key: Uint8Array): string {
return Buffer.from(key.slice(1, 33)).toString('hex')
}

View File

@@ -1,52 +0,0 @@
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++
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)
return null
}
}

31
nip05.ts Normal file
View File

@@ -0,0 +1,31 @@
var _fetch = fetch
export function useFetchImplementation(fetchImplementation: any) {
_fetch = fetchImplementation
}
export async function searchDomain(
domain: string,
query = ''
): Promise<{[name: string]: string}> {
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): Promise<string> {
let [name, domain] = fullname.split('@')
if (!domain) throw new Error('invalid identifier, must contain an @')
let res = await (
await _fetch(`https://${domain}/.well-known/nostr.json?name=${name}`)
).json()
return res.names && res.names[name]
}

View File

@@ -1,24 +0,0 @@
import {wordlist} from 'micro-bip39/wordlists/english'
import {
generateMnemonic,
mnemonicToSeedSync,
validateMnemonic
} from 'micro-bip39'
import {HDKey} from 'micro-bip32'
export function privateKeyFromSeed(seed) {
let root = HDKey.fromMasterSeed(Buffer.from(seed, 'hex'))
return root.derive(`m/44'/1237'/0'/0'`).privateKey.toString('hex')
}
export function seedFromWords(mnemonic) {
return Buffer.from(mnemonicToSeedSync(mnemonic, wordlist)).toString('hex')
}
export function generateSeedWords() {
return generateMnemonic(wordlist)
}
export function validateWords(words) {
return validateMnemonic(words, wordlist)
}

26
nip06.ts Normal file
View File

@@ -0,0 +1,26 @@
import {wordlist} from '@scure/bip39/wordlists/english.js'
import {
generateMnemonic,
mnemonicToSeedSync,
validateMnemonic
} from '@scure/bip39'
import {HDKey} from '@scure/bip32'
export function privateKeyFromSeed(seed: string): string {
let root = HDKey.fromMasterSeed(Buffer.from(seed, '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: string): string {
return Buffer.from(mnemonicToSeedSync(mnemonic)).toString('hex')
}
export function generateSeedWords(): string {
return generateMnemonic(wordlist)
}
export function validateWords(words: string): boolean {
return validateMnemonic(words, wordlist)
}

View File

@@ -1,36 +1,47 @@
{
"name": "nostr-tools",
"version": "0.14.1",
"version": "1.0.0-alpha2",
"description": "Tools for making a Nostr client.",
"repository": {
"type": "git",
"url": "https://github.com/fiatjaf/nostr-tools.git"
},
"main": "lib/nostr.cjs.js",
"module": "lib/nostr.esm.js",
"dependencies": {
"@noble/secp256k1": "^1.3.0",
"@noble/hashes": "^0.5.7",
"@noble/secp256k1": "^1.7.0",
"@scure/bip32": "^1.1.1",
"@scure/bip39": "^1.1.0",
"browserify-cipher": ">=1",
"buffer": ">=5",
"create-hash": "^1.2.0",
"dns-packet": "^5.2.4",
"micro-bip32": "^0.1.0",
"micro-bip39": "^0.1.3",
"randombytes": ">=2",
"buffer": "^6.0.3",
"websocket-polyfill": "^0.0.3"
},
"keywords": [
"decentralization",
"twitter",
"p2p",
"mastodon",
"ssb",
"social",
"unstoppable",
"censorship",
"censorship-resistance",
"client"
"client",
"nostr"
],
"devDependencies": {
"eslint": "^8.5.0",
"eslint-plugin-babel": "^5.3.1"
"@types/node": "^18.0.3",
"@typescript-eslint/eslint-plugin": "^5.46.1",
"@typescript-eslint/parser": "^5.46.1",
"esbuild": "0.16.9",
"esbuild-plugin-alias": "^0.2.1",
"eslint": "^8.30.0",
"eslint-plugin-babel": "^5.3.1",
"esm-loader-typescript": "^1.0.1",
"events": "^3.3.0",
"jest": "^29.3.1",
"ts-jest": "^29.0.3",
"tsd": "^0.22.0",
"typescript": "^4.9.4"
},
"scripts": {
"build": "node build.js",
"pretest": "node build.js",
"test": "jest"
}
}

166
pool.js
View File

@@ -1,166 +0,0 @@
import {getEventHash, signEvent} from './event'
import {relayConnect, normalizeRelayURL} from './relay'
export function relayPool() {
var globalPrivateKey
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
}
const relays = {}
const noticeCallbacks = []
function propagateNotice(notice, relayURL) {
for (let i = 0; i < noticeCallbacks.length; i++) {
let {relay} = relays[relayURL]
noticeCallbacks[i](notice, relay)
}
}
const activeSubscriptions = {}
const sub = ({cb, filter}, 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)
])
)
const activeCallback = cb
const activeFilters = filter
const unsub = () => {
Object.values(subControllers).forEach(sub => sub.unsub())
delete activeSubscriptions[id]
}
const sub = ({cb = activeCallback, filter = activeFilters}) => {
Object.entries(subControllers).map(([relayURL, sub]) => [
relayURL,
sub.sub({cb, filter}, id)
])
return activeSubscriptions[id]
}
const addRelay = relay => {
subControllers[relay.url] = relay.sub({cb, filter}, id)
return activeSubscriptions[id]
}
const removeRelay = relayURL => {
if (relayURL in subControllers) {
subControllers[relayURL].unsub()
if (Object.keys(subControllers).length === 0) unsub()
}
return activeSubscriptions[id]
}
activeSubscriptions[id] = {
sub,
unsub,
addRelay,
removeRelay
}
return activeSubscriptions[id]
}
return {
sub,
relays,
setPrivateKey(privateKey) {
globalPrivateKey = privateKey
},
setPolicy(key, value) {
poolPolicy[key] = value
},
addRelay(url, policy = {read: true, write: true}) {
let relayURL = normalizeRelayURL(url)
if (relayURL in relays) return
let relay = relayConnect(url, notice => {
propagateNotice(notice, relayURL)
})
relays[relayURL] = {relay, policy}
if (policy.read) {
Object.values(activeSubscriptions).forEach(subscription =>
subscription.addRelay(relay)
)
}
return relay
},
removeRelay(url) {
let relayURL = normalizeRelayURL(url)
let data = relays[relayURL]
if (!data) return
let {relay} = data
Object.values(activeSubscriptions).forEach(subscription =>
subscription.removeRelay(relay)
)
relay.close()
delete relays[relayURL]
},
onNotice(cb) {
noticeCallbacks.push(cb)
},
offNotice(cb) {
let index = noticeCallbacks.indexOf(cb)
if (index !== -1) noticeCallbacks.splice(index, 1)
},
async publish(event, statusCallback = (status, relayURL) => {}) {
event.id = getEventHash(event)
if (!event.sig) {
event.tags = event.tags || []
if (globalPrivateKey) {
event.sig = await signEvent(event, globalPrivateKey)
} 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."
)
}
}
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
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 => {
statusCallback(status, relay.url)
resolve()
})
} catch (err) {
statusCallback(-1, relay.url)
}
})
successes++
if (successes >= maxTargets) {
break
}
} catch (err) {
/***/
}
}
return event
}
}
}

179
relay.js
View File

@@ -1,179 +0,0 @@
/* global WebSocket */
import 'websocket-polyfill'
import {verifySignature} from './event'
import {matchFilters} from './filter'
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 relayConnect(url, onNotice = () => {}, onError = () => {}) {
url = normalizeRelayURL(url)
var ws, resolveOpen, untilOpen, wasClosed
var openSubs = {}
let attemptNumber = 1
let nextAttemptSeconds = 1
function resetOpenState() {
untilOpen = new Promise(resolve => {
resolveOpen = resolve
})
}
var channels = {}
function connect() {
ws = new WebSocket(url)
ws.onopen = () => {
console.log('connected to', url)
resolveOpen()
// restablish old subscriptions
if (wasClosed) {
wasClosed = false
for (let channel in openSubs) {
let filters = openSubs[channel]
let cb = channels[channel]
sub({cb, filter: filters}, channel)
}
}
}
ws.onerror = err => {
console.log('error connecting to relay', url)
onError(err)
}
ws.onclose = () => {
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 {
connect()
} 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) {
if (data[0] === 'NOTICE') {
if (data.length < 2) return
console.log('message from relay ' + url + ': ' + data[1])
onNotice(data[1])
return
}
if (data[0] === 'EVENT') {
if (data.length < 3) return
let channel = data[1]
let event = data[2]
if (
(await verifySignature(event)) &&
channels[channel] &&
matchFilters(openSubs[channel], event)
) {
channels[channel](event)
}
return
}
}
}
}
resetOpenState()
try {
connect()
} catch (err) {}
async function trySend(params) {
let msg = JSON.stringify(params)
await untilOpen
ws.send(msg)
}
const sub = ({cb, filter}, channel = Math.random().toString().slice(2)) => {
var filters = []
if (Array.isArray(filter)) {
filters = filter
} else {
filters.push(filter)
}
trySend(['REQ', channel, ...filters])
channels[channel] = cb
openSubs[channel] = filters
const activeCallback = cb
const activeFilters = filters
return {
sub: ({cb = activeCallback, filter = activeFilters}) =>
sub({cb, filter}, channel),
unsub: () => {
delete openSubs[channel]
delete channels[channel]
trySend(['CLOSE', channel])
}
}
}
return {
url,
sub,
async publish(event, statusCallback) {
try {
await trySend(['EVENT', event])
if (statusCallback) {
statusCallback(0)
let {unsub} = sub(
{
cb: () => {
statusCallback(1)
unsub()
clearTimeout(willUnsub)
},
filter: {id: event.id}
},
`monitor-${event.id.slice(0, 5)}`
)
let willUnsub = setTimeout(unsub, 5000)
}
} catch (err) {
if (statusCallback) statusCallback(-1)
}
},
close() {
ws.close()
},
get status() {
return ws.readyState
}
}
}

117
relay.test.js Normal file
View File

@@ -0,0 +1,117 @@
/* eslint-env jest */
const {
relayInit,
generatePrivateKey,
getPublicKey,
getEventHash,
signEvent
} = require('./lib/nostr.cjs')
describe('relay interaction', () => {
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])
})
})

311
relay.ts Normal file
View File

@@ -0,0 +1,311 @@
/* global WebSocket */
import 'websocket-polyfill'
import {Event, verifySignature, validateEvent} from './event'
import {Filter, matchFilters} from './filter'
export type Relay = {
url: string
status: number
connect: () => void
close: () => void
sub: (filters: Filter[], 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: (filters: Filter[], opts: SubscriptionOptions) => Sub
unsub: () => void
on: (type: 'event' | 'eose', cb: any) => void
off: (type: 'event' | 'eose', cb: any) => void
}
type SubscriptionOptions = {
skipVerification?: boolean
id?: string
}
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>
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
let isConnected = false
function resetOpenState() {
untilOpen = new Promise(resolve => {
resolveOpen = resolve
})
}
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) {}
}
async function trySend(params: [string, ...any]) {
let msg = JSON.stringify(params)
await untilOpen
ws.send(msg)
}
const sub = (
filters: Filter[],
{
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: (newFilters, newOpts = {}) =>
sub(newFilters || filters, {
skipVerification: newOpts.skipVerification || 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)
if (type === 'connect' && isConnected) {
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([{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(): Promise<void> {
closed = true // prevent ws from trying to reconnect
ws.close()
return new Promise(resolve => {
resolveClose = resolve
})
},
get status() {
return ws.readyState
}
}
}

15
tsconfig.json Normal file
View File

@@ -0,0 +1,15 @@
{
"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": "."
}
}