new nostr-tools which abstracts all the relay activity.
This commit is contained in:
parent
5921ad1080
commit
44edef63f9
|
@ -1 +1,2 @@
|
|||
node_modules
|
||||
dist
|
||||
|
|
|
@ -0,0 +1,40 @@
|
|||
# nostr-tools
|
||||
|
||||
Tools for developing [Nostr](https://github.com/fiatjaf/nostr) clients.
|
||||
|
||||
## Usage
|
||||
|
||||
```js
|
||||
import {relayPool} 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})
|
||||
|
||||
pool.onEvent((context, event, relay) => {
|
||||
console.log(`got a relay with context ${context} from ${relay.url} which is already validated.`, event)
|
||||
})
|
||||
|
||||
// subscribing to users and requesting specific users or events:
|
||||
pool.subKey('<hex>')
|
||||
pool.subKey('<hex>')
|
||||
pool.subKey('<hex>')
|
||||
pool.reqFeed()
|
||||
pool.reqEvent({id: '<hex>'})
|
||||
pool.reqKey({key: '<hex>'})
|
||||
// upon request the events will be received on .onEvent above
|
||||
|
||||
// publishing events:
|
||||
pool.publish(<event object>)
|
||||
// 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 keys called with .subKey above
|
||||
```
|
||||
|
||||
For other utils please read the source (for now).
|
39
event.js
39
event.js
|
@ -1,8 +1,6 @@
|
|||
import shajs from 'sha.js'
|
||||
import BigInteger from 'bigi'
|
||||
import schnorr from 'bip-schnorr'
|
||||
import * as secp256k1 from 'noble-secp256k1'
|
||||
|
||||
import {makeRandom32} from './utils'
|
||||
import {sha256} from './utils'
|
||||
|
||||
export function serializeEvent(evt) {
|
||||
return JSON.stringify([
|
||||
|
@ -15,27 +13,22 @@ export function serializeEvent(evt) {
|
|||
])
|
||||
}
|
||||
|
||||
export function getEventID(event) {
|
||||
let hash = shajs('sha256').update(serializeEvent(event)).digest()
|
||||
return hash.toString('hex')
|
||||
export function getEventHash(event) {
|
||||
let eventHash = sha256(Buffer.from(serializeEvent(event)))
|
||||
return Buffer.from(eventHash).toString('hex')
|
||||
}
|
||||
|
||||
export function verifySignature(event) {
|
||||
try {
|
||||
schnorr.verify(
|
||||
Buffer.from(event.pubkey, 'hex'),
|
||||
Buffer.from(getEventID(event), 'hex'),
|
||||
Buffer.from(event.sig, 'hex')
|
||||
)
|
||||
return true
|
||||
} catch (err) {
|
||||
return false
|
||||
}
|
||||
export async function verifySignature(event) {
|
||||
return await secp256k1.schnorr.verify(
|
||||
event.signature,
|
||||
getEventHash(event),
|
||||
event.pubkey
|
||||
)
|
||||
}
|
||||
|
||||
export function signEvent(event, key) {
|
||||
let eventHash = shajs('sha256').update(serializeEvent(event)).digest()
|
||||
schnorr
|
||||
.sign(new BigInteger(key, 16), eventHash, makeRandom32())
|
||||
.toString('hex')
|
||||
export async function signEvent(event, key) {
|
||||
let eventHash = getEventHash(event)
|
||||
return Buffer.from(await secp256k1.schnorr.sign(key, eventHash)).toString(
|
||||
'hex'
|
||||
)
|
||||
}
|
||||
|
|
20
index.js
20
index.js
|
@ -1,4 +1,16 @@
|
|||
export {relayConnect} from './relay'
|
||||
export {signEvent, verifySignature, serializeEvent, getEventID} from './event'
|
||||
export {pubkeyFromPrivate} from './schnorr'
|
||||
export {makeRandom32} from './utils'
|
||||
import {relayConnect} from './relay'
|
||||
import {relayPool} from './pool'
|
||||
import {signEvent, verifySignature, serializeEvent, getEventHash} from './event'
|
||||
import {makeRandom32, sha256, getPublicKey} from './utils'
|
||||
|
||||
export {
|
||||
relayConnect,
|
||||
relayPool,
|
||||
signEvent,
|
||||
verifySignature,
|
||||
serializeEvent,
|
||||
getEventHash,
|
||||
makeRandom32,
|
||||
sha256,
|
||||
getPublicKey
|
||||
}
|
||||
|
|
40
package.json
40
package.json
|
@ -1,13 +1,39 @@
|
|||
{
|
||||
"name": "nostr-tools",
|
||||
"version": "0.0.2",
|
||||
"version": "0.1.0",
|
||||
"description": "Tools for making a Nostr client.",
|
||||
"main": "index.js",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/fiatjaf/nostr.git"
|
||||
},
|
||||
"dependencies": {
|
||||
"assert": "^2.0.0",
|
||||
"bigi": "^1.4.2",
|
||||
"bip-schnorr": "^0.6.2",
|
||||
"buffer": "^6.0.3",
|
||||
"ecurve": "^1.0.6",
|
||||
"pws": "^5.0.2",
|
||||
"sha.js": "^2.4.11"
|
||||
"noble-secp256k1": "^1.1.1",
|
||||
"pws": "^5.0.2"
|
||||
},
|
||||
"scripts": {
|
||||
"prepublish": "rollup -c rollup.config.js"
|
||||
},
|
||||
"keywords": [
|
||||
"decentralization",
|
||||
"twitter",
|
||||
"p2p",
|
||||
"mastodon",
|
||||
"ssb",
|
||||
"social",
|
||||
"unstoppable",
|
||||
"censorship",
|
||||
"censorship-resistance",
|
||||
"client"
|
||||
],
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.12.10",
|
||||
"@babel/preset-env": "^7.12.11",
|
||||
"@rollup/plugin-babel": "^5.2.2",
|
||||
"@rollup/plugin-node-resolve": "^11.0.1",
|
||||
"rollup": "^2.36.1",
|
||||
"rollup-plugin-ignore": "^1.0.9",
|
||||
"rollup-plugin-terser": "^7.0.2"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,142 @@
|
|||
import {getEventHash, signEvent} from './event'
|
||||
import {relayConnect, normalizeRelayURL} from './relay'
|
||||
|
||||
export function relayPool(globalPrivateKey) {
|
||||
const relays = {}
|
||||
const globalSub = []
|
||||
const attemptCallbacks = []
|
||||
const eventCallbacks = []
|
||||
const noticeCallbacks = []
|
||||
|
||||
function propagateEvent(context, event, relayURL) {
|
||||
for (let i = 0; i < eventCallbacks.length; i++) {
|
||||
let {relay} = relays[relayURL]
|
||||
eventCallbacks[i](context, event, relay)
|
||||
}
|
||||
}
|
||||
function propagateNotice(notice, relayURL) {
|
||||
for (let i = 0; i < noticeCallbacks.length; i++) {
|
||||
let {relay} = relays[relayURL]
|
||||
noticeCallbacks[i](notice, relay)
|
||||
}
|
||||
}
|
||||
function propagateAttempt(eventId, status, relayURL) {
|
||||
for (let i = 0; i < attemptCallbacks.length; i++) {
|
||||
let {relay} = relays[relayURL]
|
||||
attemptCallbacks[i](eventId, status, relay)
|
||||
}
|
||||
}
|
||||
|
||||
async function relaysEach(fn, policyFilter) {
|
||||
for (let relayURL in relays) {
|
||||
let {relay, policy} = relays[relayURL]
|
||||
if (policyFilter.write && policy.write) {
|
||||
await fn(relay)
|
||||
} else if (policyFilter.read && policy.read) {
|
||||
await fn(relays)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
relays,
|
||||
setPrivateKey(privateKey) {
|
||||
globalPrivateKey = privateKey
|
||||
},
|
||||
addRelay(url, policy = {read: true, write: true}) {
|
||||
let relayURL = normalizeRelayURL(url)
|
||||
if (relayURL in relays) return
|
||||
|
||||
let relay = relayConnect(
|
||||
url,
|
||||
(context, event) => {
|
||||
propagateEvent(context, event, relayURL)
|
||||
},
|
||||
notice => {
|
||||
propagateNotice(notice, relayURL)
|
||||
}
|
||||
)
|
||||
relays[relayURL] = {relay, policy}
|
||||
|
||||
// automatically subscribe to everybody on this
|
||||
for (let key in globalSub) {
|
||||
relay.subKey(key)
|
||||
}
|
||||
|
||||
return relay
|
||||
},
|
||||
removeRelay(url) {
|
||||
let relayURL = normalizeRelayURL(url)
|
||||
let {relay} = relays[relayURL]
|
||||
if (!relay) return
|
||||
relay.close()
|
||||
delete relays[relayURL]
|
||||
},
|
||||
onEvent(cb) {
|
||||
eventCallbacks.push(cb)
|
||||
},
|
||||
offEvent(cb) {
|
||||
let index = eventCallbacks.indexOf(cb)
|
||||
if (index !== -1) eventCallbacks.splice(index, 1)
|
||||
},
|
||||
onNotice(cb) {
|
||||
noticeCallbacks(cb)
|
||||
},
|
||||
offNotice(cb) {
|
||||
let index = noticeCallbacks.indexOf(cb)
|
||||
if (index !== -1) noticeCallbacks.splice(index, 1)
|
||||
},
|
||||
onAttempt(cb) {
|
||||
attemptCallbacks(cb)
|
||||
},
|
||||
offAttempt(cb) {
|
||||
let index = attemptCallbacks.indexOf(cb)
|
||||
if (index !== -1) attemptCallbacks.splice(index, 1)
|
||||
},
|
||||
async publish(event) {
|
||||
if (!event.signature) {
|
||||
event.tags = event.tags || []
|
||||
|
||||
if (globalPrivateKey) {
|
||||
event.id = getEventHash(event)
|
||||
event.signature = 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."
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
await relaysEach(
|
||||
async relay => {
|
||||
try {
|
||||
await relay.publish(event)
|
||||
propagateAttempt(event.id, 'sent', relay.url)
|
||||
} catch (err) {
|
||||
propagateAttempt(event.id, 'failed', relay.url)
|
||||
}
|
||||
},
|
||||
{write: true}
|
||||
)
|
||||
|
||||
return event
|
||||
},
|
||||
async subKey(key) {
|
||||
globalSub[key] = true
|
||||
await relaysEach(async relay => relay.subKey(key), {read: true})
|
||||
},
|
||||
async unsubKey(key) {
|
||||
delete globalSub[key]
|
||||
await relaysEach(async relay => relay.unsubKey(key), {read: true})
|
||||
},
|
||||
async reqFeed(params = {}) {
|
||||
await relaysEach(async relay => relay.reqFeed(params), {read: true})
|
||||
},
|
||||
async reqEvent(params) {
|
||||
await relaysEach(async relay => relay.reqEvent(params), {read: true})
|
||||
},
|
||||
async reqKey(params) {
|
||||
await relaysEach(async relay => relay.reqKey(params), {read: true})
|
||||
}
|
||||
}
|
||||
}
|
61
relay.js
61
relay.js
|
@ -1,44 +1,81 @@
|
|||
import PersistentWebSocket from 'pws'
|
||||
import {verifySignature} from './event'
|
||||
|
||||
export function relayConnect(url, onEventCallback) {
|
||||
if (url.length && url[url.length - 1] === '/') url = url.slice(0, -1)
|
||||
export function normalizeRelayURL(url) {
|
||||
let [host, ...qs] = url.split('?')
|
||||
if (host.slice(0, 4) === 'http') host = 'ws' + host.slice(4)
|
||||
if (host.length && host[host.length - 1] === '/') host = host.slice(0, -1)
|
||||
if (host.slice(-3) !== '/ws') host = host + '/ws'
|
||||
return [host, ...qs].join('?')
|
||||
}
|
||||
|
||||
const ws = new PersistentWebSocket(url + '/ws?session=' + Math.random(), {
|
||||
export function relayConnect(url, onEvent, onNotice) {
|
||||
url = normalizeRelayURL(url)
|
||||
url = url +=
|
||||
(url.indexOf('?') !== -1 ? '&' : '?') + `session=${Math.random()}`
|
||||
|
||||
const ws = new PersistentWebSocket(url, {
|
||||
pingTimeout: 30 * 1000
|
||||
})
|
||||
|
||||
ws.onopen = () => console.log('connected to ', url)
|
||||
var isOpen
|
||||
let untilOpen = new Promise(resolve => {
|
||||
isOpen = resolve
|
||||
})
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log('connected to ', url)
|
||||
isOpen()
|
||||
}
|
||||
ws.onerror = err => console.log('error connecting', url, err)
|
||||
|
||||
ws.onmessage = e => {
|
||||
ws.onmessage = async e => {
|
||||
let data = JSON.parse(e.data)
|
||||
if (data.length > 1) {
|
||||
if (data[0] === 'notice') {
|
||||
console.log('message from relay ' + url + ' :' + data[1])
|
||||
onNotice(data[1])
|
||||
} else if (typeof data[0] === 'object') {
|
||||
onEventCallback(data[0], data[1])
|
||||
let context = data[0]
|
||||
let event = data[1]
|
||||
|
||||
if (await verifySignature(event)) {
|
||||
onEvent(context, event)
|
||||
} else {
|
||||
console.warn(
|
||||
'got event with invalid signature from ' + url,
|
||||
event,
|
||||
context
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
url,
|
||||
subKey(key) {
|
||||
async subKey(key) {
|
||||
await untilOpen
|
||||
ws.send('sub-key:' + key)
|
||||
},
|
||||
unsubKey(key) {
|
||||
async unsubKey(key) {
|
||||
await untilOpen
|
||||
ws.send('unsub-key:' + key)
|
||||
},
|
||||
homeFeed(params = {}) {
|
||||
async reqFeed(params = {}) {
|
||||
await untilOpen
|
||||
ws.send('req-feed:' + JSON.stringify(params))
|
||||
},
|
||||
reqEvent(params) {
|
||||
async reqEvent(params) {
|
||||
await untilOpen
|
||||
ws.send('req-key:' + JSON.stringify(params))
|
||||
},
|
||||
reqKey(params) {
|
||||
async reqKey(params) {
|
||||
await untilOpen
|
||||
ws.send('req-key:' + JSON.stringify(params))
|
||||
},
|
||||
sendEvent(event) {
|
||||
async publish(event) {
|
||||
await untilOpen
|
||||
ws.send(JSON.stringify(event))
|
||||
},
|
||||
close() {
|
||||
|
|
|
@ -0,0 +1,26 @@
|
|||
import {nodeResolve} from '@rollup/plugin-node-resolve'
|
||||
import {terser} from 'rollup-plugin-terser'
|
||||
import babel from '@rollup/plugin-babel'
|
||||
|
||||
import pkg from './package.json'
|
||||
|
||||
const input = 'index.js'
|
||||
|
||||
export default {
|
||||
input,
|
||||
output: {
|
||||
sourcemap: true,
|
||||
format: 'umd',
|
||||
name: 'nostr',
|
||||
file: `dist/${pkg.name}.min.js`,
|
||||
exports: 'named',
|
||||
esModule: false
|
||||
},
|
||||
plugins: [
|
||||
nodeResolve(),
|
||||
babel({
|
||||
babelHelpers: 'bundled'
|
||||
}),
|
||||
terser()
|
||||
]
|
||||
}
|
10
schnorr.js
10
schnorr.js
|
@ -1,10 +0,0 @@
|
|||
import BigInteger from 'bigi'
|
||||
import ecurve from 'ecurve'
|
||||
|
||||
const curve = ecurve.getCurveByName('secp256k1')
|
||||
const G = curve.G
|
||||
|
||||
export function pubkeyFromPrivate(privateHex) {
|
||||
const privKey = BigInteger.fromHex(privateHex)
|
||||
return G.multiply(privKey).getEncoded(true).slice(1).toString('hex')
|
||||
}
|
11
utils.js
11
utils.js
|
@ -1,5 +1,6 @@
|
|||
export function makeRandom32() {
|
||||
var array = new Uint32Array(32)
|
||||
window.crypto.getRandomValues(array)
|
||||
return Buffer.from(array)
|
||||
}
|
||||
import * as secp256k1 from 'noble-secp256k1'
|
||||
|
||||
export const makeRandom32 = () => secp256k1.utils.generateRandomPrivateKey()
|
||||
export const sha256 = m => secp256k1.utils.sha256(Uint8Array.from(m))
|
||||
export const getPublicKey = privateKey =>
|
||||
secp256k1.schnorr.getPublicKey(privateKey)
|
||||
|
|
Loading…
Reference in New Issue