Compare commits

..

1 Commits

Author SHA1 Message Date
fiatjaf
d46c5f947c fix tests, .seenOn() method for pools. 2023-02-09 15:11:09 -03:00
71 changed files with 1011 additions and 8564 deletions

View File

@@ -1,9 +0,0 @@
root = true
[*]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true

View File

@@ -2,7 +2,7 @@
"root": true, "root": true,
"parser": "@typescript-eslint/parser", "parser": "@typescript-eslint/parser",
"plugins": ["@typescript-eslint", "babel"], "plugins": ["@typescript-eslint"],
"parserOptions": { "parserOptions": {
"ecmaVersion": 9, "ecmaVersion": 9,
@@ -18,6 +18,8 @@
"node": true "node": true
}, },
"plugins": ["babel"],
"globals": { "globals": {
"document": false, "document": false,
"navigator": false, "navigator": false,
@@ -101,6 +103,7 @@
"no-octal-escape": 2, "no-octal-escape": 2,
"no-path-concat": 0, "no-path-concat": 0,
"no-proto": 2, "no-proto": 2,
"no-redeclare": 2,
"no-regex-spaces": 2, "no-regex-spaces": 2,
"no-return-assign": 0, "no-return-assign": 0,
"no-self-assign": 2, "no-self-assign": 2,
@@ -150,13 +153,5 @@
"wrap-iife": [2, "any"], "wrap-iife": [2, "any"],
"yield-star-spacing": [2, "both"], "yield-star-spacing": [2, "both"],
"yoda": [0] "yoda": [0]
},
"overrides": [
{
"files": ["**/*.test.ts"],
"env": { "jest/globals": true },
"plugins": ["jest"],
"extends": ["plugin:jest/recommended"]
} }
]
} }

View File

@@ -16,7 +16,6 @@ jobs:
- run: just install-dependencies - run: just install-dependencies
- run: just build - run: just build
- run: just test - run: just test
- run: just emit-types
- uses: JS-DevTools/npm-publish@v1 - uses: JS-DevTools/npm-publish@v1
with: with:
token: ${{ secrets.NPM_TOKEN }} token: ${{ secrets.NPM_TOKEN }}

View File

@@ -9,10 +9,11 @@ jobs:
test: test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v2
- uses: actions/setup-node@v3 - uses: actions/setup-node@v3
with: with:
node-version: 18 node-version: 18
- uses: extractions/setup-just@v1 - uses: extractions/setup-just@v1
- run: just install-dependencies - run: just install-dependencies
- run: just build
- run: just test - run: just test

24
LICENSE
View File

@@ -1,24 +0,0 @@
This is free and unencumbered software released into the public domain.
Anyone is free to copy, modify, publish, use, compile, sell, or
distribute this software, either in source code form or as a compiled
binary, for any purpose, commercial or non-commercial, and by any
means.
In jurisdictions that recognize copyright laws, the author or authors
of this software dedicate any and all copyright interest in the
software to the public domain. We make this dedication for the benefit
of the public at large and to the detriment of our heirs and
successors. We intend this dedication to be an overt act of
relinquishment in perpetuity of all present and future rights to this
software under copyright law.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.
For more information, please refer to <https://unlicense.org>

View File

@@ -4,14 +4,6 @@ Tools for developing [Nostr](https://github.com/fiatjaf/nostr) clients.
Only depends on _@scure_ and _@noble_ packages. Only depends on _@scure_ and _@noble_ packages.
This package is only providing lower-level functionality. If you want an easy-to-use fully-fledged solution that abstracts the hard parts of Nostr and makes decisions on your behalf, take a look at [NDK](https://github.com/nostr-dev-kit/ndk) and [@snort/system](https://www.npmjs.com/package/@snort/system).
## Installation
```bash
npm install nostr-tools # or yarn add nostr-tools
```
## Usage ## Usage
### Generating a private key and a public key ### Generating a private key and a public key
@@ -29,7 +21,7 @@ let pk = getPublicKey(sk) // `pk` is a hex string
import { import {
validateEvent, validateEvent,
verifySignature, verifySignature,
getSignature, signEvent,
getEventHash, getEventHash,
getPublicKey getPublicKey
} from 'nostr-tools' } from 'nostr-tools'
@@ -43,7 +35,7 @@ let event = {
} }
event.id = getEventHash(event) event.id = getEventHash(event)
event.sig = getSignature(event, privateKey) event.sig = signEvent(event, privateKey)
let ok = validateEvent(event) let ok = validateEvent(event)
let veryOk = verifySignature(event) let veryOk = verifySignature(event)
@@ -57,10 +49,12 @@ import {
generatePrivateKey, generatePrivateKey,
getPublicKey, getPublicKey,
getEventHash, getEventHash,
getSignature signEvent
} from 'nostr-tools' } from 'nostr-tools'
const relay = relayInit('wss://relay.example.com') const relay = relayInit('wss://relay.example.com')
await relay.connect()
relay.on('connect', () => { relay.on('connect', () => {
console.log(`connected to ${relay.url}`) console.log(`connected to ${relay.url}`)
}) })
@@ -68,8 +62,6 @@ relay.on('error', () => {
console.log(`failed to connect to ${relay.url}`) console.log(`failed to connect to ${relay.url}`)
}) })
await relay.connect()
// let's query for an event that exists // let's query for an event that exists
let sub = relay.sub([ let sub = relay.sub([
{ {
@@ -106,16 +98,25 @@ let event = {
content: 'hello world' content: 'hello world'
} }
event.id = getEventHash(event) event.id = getEventHash(event)
event.sig = getSignature(event, sk) event.sig = signEvent(event, sk)
await relay.publish(event) 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}`)
})
let events = await relay.list([{kinds: [0, 1]}]) let events = await relay.list([{kinds: [0, 1]}])
let event = await relay.get({ let event = await relay.get({
ids: ['44e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245'] ids: ['44e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245']
}) })
relay.close() await relay.close()
``` ```
To use this on Node.js you first must install `websocket-polyfill` and import it: To use this on Node.js you first must install `websocket-polyfill` and import it:
@@ -127,33 +128,31 @@ import 'websocket-polyfill'
### Interacting with multiple relays ### Interacting with multiple relays
```js ```js
import {SimplePool} from 'nostr-tools' import {pool} from 'nostr-tools'
const pool = new SimplePool() const pool = new SimplePool()
let relays = ['wss://relay.example.com', 'wss://relay.example2.com'] let relays = ['wss://relay.example.com', 'wss://relay.example2.com']
let sub = pool.sub( let relay = await pool.ensureRelay('wss://relay.example3.com')
[...relays, 'wss://relay.example3.com'],
[
{
authors: [
'32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245'
]
}
]
)
let subs = pool.sub([...relays, relay], {
authors: ['32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245']
})
subs.forEach(sub =>
sub.on('event', event => { sub.on('event', event => {
// this will only be called once the first time the event is received // this will only be called once the first time the event is received
// ... // ...
}) })
)
let pubs = pool.publish(relays, newEvent) let pubs = pool.publish(relays, newEvent)
pubs.on('ok', () => { pubs.forEach(pub =>
// this may be called multiple times, once for every relay that accepts the event pub.on('ok', () => {
// ... // ...
}) })
)
let events = await pool.list(relays, [{kinds: [0, 1]}]) let events = await pool.list(relays, [{kinds: [0, 1]}])
let event = await pool.get(relays, { let event = await pool.get(relays, {
@@ -166,26 +165,6 @@ let relaysForEvent = pool.seenOn(
// relaysForEvent will be an array of URLs from relays a given event was seen on // relaysForEvent will be an array of URLs from relays a given event was seen on
``` ```
### Parsing references (mentions) from a content using NIP-10 and NIP-27
```js
import {parseReferences} from 'nostr-tools'
let references = parseReferences(event)
let simpleAugmentedContent = event.content
for (let i = 0; i < references.length; i++) {
let {text, profile, event, address} = references[i]
let augmentedReference = profile
? `<strong>@${profilesCache[profile.pubkey].name}</strong>`
: event
? `<em>${eventsCache[event.id].content.slice(0, 5)}</em>`
: address
? `<a href="${text}">[link]</a>`
: text
simpleAugmentedContent.replaceAll(text, augmentedReference)
}
```
### Querying profile data from a NIP-05 address ### Querying profile data from a NIP-05 address
```js ```js
@@ -262,7 +241,7 @@ sendEvent(event)
// on the receiver side // on the receiver side
sub.on('event', event => { sub.on('event', event => {
let sender = event.pubkey let sender = event.tags.find(([k, v]) => k === 'p' && v && v !== '')[1]
pk1 === sender pk1 === sender
let plaintext = await nip04.decrypt(sk2, pk1, event.content) let plaintext = await nip04.decrypt(sk2, pk1, event.content)
}) })
@@ -314,11 +293,6 @@ Please consult the tests or [the source code](https://github.com/fiatjaf/nostr-t
</script> </script>
``` ```
## Plumbing
1. Install [`just`](https://just.systems/)
2. `just -l`
## License ## License
This is free and unencumbered software released into the public domain. By submitting patches to this project, you agree to dedicate any and all copyright interest in this software to the public domain. Public domain.

View File

@@ -1,6 +1,5 @@
#!/usr/bin/env node #!/usr/bin/env node
const fs = require('fs')
const esbuild = require('esbuild') const esbuild = require('esbuild')
let common = { let common = {
@@ -12,16 +11,11 @@ let common = {
esbuild esbuild
.build({ .build({
...common, ...common,
outfile: 'lib/esm/nostr.mjs', outfile: 'lib/nostr.esm.js',
format: 'esm', format: 'esm',
packages: 'external' packages: 'external'
}) })
.then(() => { .then(() => console.log('esm build success.'))
const packageJson = JSON.stringify({type: 'module'})
fs.writeFileSync(`${__dirname}/lib/esm/package.json`, packageJson, 'utf8')
console.log('esm build success.')
})
esbuild esbuild
.build({ .build({

48
event.test.js Normal file
View File

@@ -0,0 +1,48 @@
/* eslint-env jest */
const {
validateEvent,
verifySignature,
signEvent,
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(verifySignature(event)).toBeTruthy()
})
test('sign event', async () => {
let pubkey = getPublicKey(privateKey)
let authored = {...unsigned, pubkey}
let sig = signEvent(authored, privateKey)
let signed = {...authored, sig}
expect(verifySignature(signed)).toBeTruthy()
})

View File

@@ -1,352 +0,0 @@
import {
getBlankEvent,
finishEvent,
serializeEvent,
getEventHash,
validateEvent,
verifySignature,
getSignature,
Kind,
} from './event.ts'
import {getPublicKey} from './keys.ts'
describe('Event', () => {
describe('getBlankEvent', () => {
it('should return a blank event object', () => {
expect(getBlankEvent()).toEqual({
kind: 255,
content: '',
tags: [],
created_at: 0
})
})
it('should return a blank event object with defined kind', () => {
expect(getBlankEvent(Kind.Text)).toEqual({
kind: 1,
content: '',
tags: [],
created_at: 0
})
})
})
describe('finishEvent', () => {
it('should create a signed event from a template', () => {
const privateKey =
'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
const publicKey = getPublicKey(privateKey)
const template = {
kind: Kind.Text,
tags: [],
content: 'Hello, world!',
created_at: 1617932115
}
const event = finishEvent(template, privateKey)
expect(event.kind).toEqual(template.kind)
expect(event.tags).toEqual(template.tags)
expect(event.content).toEqual(template.content)
expect(event.created_at).toEqual(template.created_at)
expect(event.pubkey).toEqual(publicKey)
expect(typeof event.id).toEqual('string')
expect(typeof event.sig).toEqual('string')
})
})
describe('serializeEvent', () => {
it('should serialize a valid event object', () => {
const privateKey =
'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
const publicKey = getPublicKey(privateKey)
const unsignedEvent = {
pubkey: publicKey,
created_at: 1617932115,
kind: Kind.Text,
tags: [],
content: 'Hello, world!'
}
const serializedEvent = serializeEvent(unsignedEvent)
expect(serializedEvent).toEqual(
JSON.stringify([
0,
publicKey,
unsignedEvent.created_at,
unsignedEvent.kind,
unsignedEvent.tags,
unsignedEvent.content
])
)
})
it('should throw an error for an invalid event object', () => {
const privateKey =
'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
const publicKey = getPublicKey(privateKey)
const invalidEvent = {
kind: Kind.Text,
tags: [],
created_at: 1617932115,
pubkey: publicKey // missing content
}
expect(() => {
// @ts-expect-error
serializeEvent(invalidEvent)
}).toThrow("can't serialize event with wrong or missing properties")
})
})
describe('getEventHash', () => {
it('should return the correct event hash', () => {
const privateKey =
'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
const publicKey = getPublicKey(privateKey)
const unsignedEvent = {
kind: Kind.Text,
tags: [],
content: 'Hello, world!',
created_at: 1617932115,
pubkey: publicKey
}
const eventHash = getEventHash(unsignedEvent)
expect(typeof eventHash).toEqual('string')
expect(eventHash.length).toEqual(64)
})
})
describe('validateEvent', () => {
it('should return true for a valid event object', () => {
const privateKey =
'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
const publicKey = getPublicKey(privateKey)
const unsignedEvent = {
kind: Kind.Text,
tags: [],
content: 'Hello, world!',
created_at: 1617932115,
pubkey: publicKey
}
const isValid = validateEvent(unsignedEvent)
expect(isValid).toEqual(true)
})
it('should return false for a non object event', () => {
const nonObjectEvent = ''
const isValid = validateEvent(nonObjectEvent)
expect(isValid).toEqual(false)
})
it('should return false for an event object with missing properties', () => {
const invalidEvent = {
kind: Kind.Text,
tags: [],
created_at: 1617932115 // missing content and pubkey
}
const isValid = validateEvent(invalidEvent)
expect(isValid).toEqual(false)
})
it('should return false for an empty object', () => {
const emptyObj = {}
const isValid = validateEvent(emptyObj)
expect(isValid).toEqual(false)
})
it('should return false for an object with invalid properties', () => {
const privateKey =
'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
const publicKey = getPublicKey(privateKey)
const invalidEvent = {
kind: 1,
tags: [],
created_at: '1617932115', // should be a number
pubkey: publicKey
}
const isValid = validateEvent(invalidEvent)
expect(isValid).toEqual(false)
})
it('should return false for an object with an invalid public key', () => {
const invalidEvent = {
kind: 1,
tags: [],
content: 'Hello, world!',
created_at: 1617932115,
pubkey: 'invalid_pubkey'
}
const isValid = validateEvent(invalidEvent)
expect(isValid).toEqual(false)
})
it('should return false for an object with invalid tags', () => {
const privateKey =
'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
const publicKey = getPublicKey(privateKey)
const invalidEvent = {
kind: 1,
tags: {}, // should be an array
content: 'Hello, world!',
created_at: 1617932115,
pubkey: publicKey
}
const isValid = validateEvent(invalidEvent)
expect(isValid).toEqual(false)
})
})
describe('verifySignature', () => {
it('should return true for a valid event signature', () => {
const privateKey =
'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
const event = finishEvent(
{
kind: Kind.Text,
tags: [],
content: 'Hello, world!',
created_at: 1617932115
},
privateKey
)
const isValid = verifySignature(event)
expect(isValid).toEqual(true)
})
it('should return false for an invalid event signature', () => {
const privateKey =
'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
const event = finishEvent(
{
kind: Kind.Text,
tags: [],
content: 'Hello, world!',
created_at: 1617932115
},
privateKey
)
// tamper with the signature
event.sig = event.sig.replace(/0/g, '1')
const isValid = verifySignature(event)
expect(isValid).toEqual(false)
})
it('should return false when verifying an event with a different private key', () => {
const privateKey1 =
'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
const privateKey2 =
'5b4a34f4e4b23c63ad55a35e3f84a3b53d96dbf266edf521a8358f71d19cbf67'
const publicKey2 = getPublicKey(privateKey2)
const event = finishEvent(
{
kind: Kind.Text,
tags: [],
content: 'Hello, world!',
created_at: 1617932115
},
privateKey1
)
// verify with different private key
const isValid = verifySignature({
...event,
pubkey: publicKey2
})
expect(isValid).toEqual(false)
})
})
describe('getSignature', () => {
it('should produce the correct signature for an event object', () => {
const privateKey =
'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
const publicKey = getPublicKey(privateKey)
const unsignedEvent = {
kind: Kind.Text,
tags: [],
content: 'Hello, world!',
created_at: 1617932115,
pubkey: publicKey
}
const sig = getSignature(unsignedEvent, privateKey)
// verify the signature
// @ts-expect-error
const isValid = verifySignature({
...unsignedEvent,
sig
})
expect(typeof sig).toEqual('string')
expect(sig.length).toEqual(128)
expect(isValid).toEqual(true)
})
it('should not sign an event with different private key', () => {
const privateKey =
'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
const publicKey = getPublicKey(privateKey)
const wrongPrivateKey =
'a91e2a9d9e0f70f0877bea0dbf034e8f95d7392a27a7f07da0d14b9e9d456be7'
const unsignedEvent = {
kind: Kind.Text,
tags: [],
content: 'Hello, world!',
created_at: 1617932115,
pubkey: publicKey
}
const sig = getSignature(unsignedEvent, wrongPrivateKey)
// verify the signature
// @ts-expect-error
const isValid = verifySignature({
...unsignedEvent,
sig
})
expect(typeof sig).toEqual('string')
expect(sig.length).toEqual(128)
expect(isValid).toEqual(false)
})
})
})

View File

@@ -1,11 +1,8 @@
import {schnorr} from '@noble/curves/secp256k1' import * as secp256k1 from '@noble/secp256k1'
import {sha256} from '@noble/hashes/sha256' import {sha256} from '@noble/hashes/sha256'
import {bytesToHex} from '@noble/hashes/utils'
import {getPublicKey} from './keys.ts' import {utf8Encoder} from './utils'
import {utf8Encoder} from './utils.ts'
/** @deprecated Use numbers instead. */
/* eslint-disable no-unused-vars */ /* eslint-disable no-unused-vars */
export enum Kind { export enum Kind {
Metadata = 0, Metadata = 0,
@@ -14,66 +11,35 @@ export enum Kind {
Contacts = 3, Contacts = 3,
EncryptedDirectMessage = 4, EncryptedDirectMessage = 4,
EventDeletion = 5, EventDeletion = 5,
Repost = 6,
Reaction = 7, Reaction = 7,
BadgeAward = 8,
ChannelCreation = 40, ChannelCreation = 40,
ChannelMetadata = 41, ChannelMetadata = 41,
ChannelMessage = 42, ChannelMessage = 42,
ChannelHideMessage = 43, ChannelHideMessage = 43,
ChannelMuteUser = 44, ChannelMuteUser = 44
Blank = 255,
Report = 1984,
ZapRequest = 9734,
Zap = 9735,
RelayList = 10002,
ClientAuth = 22242,
HttpAuth = 27235,
ProfileBadge = 30008,
BadgeDefinition = 30009,
Article = 30023,
FileMetadata = 1063
} }
export type EventTemplate<K extends number = number> = { export type Event = {
kind: K id?: string
sig?: string
kind: Kind
tags: string[][] tags: string[][]
pubkey: string
content: string content: string
created_at: number created_at: number
} }
export type UnsignedEvent<K extends number = number> = EventTemplate<K> & { export function getBlankEvent(): Event {
pubkey: string
}
export type Event<K extends number = number> = UnsignedEvent<K> & {
id: string
sig: string
}
export function getBlankEvent(): EventTemplate<Kind.Blank>
export function getBlankEvent<K extends number>(kind: K): EventTemplate<K>
export function getBlankEvent<K>(kind: K | Kind.Blank = Kind.Blank) {
return { return {
kind, kind: 255,
pubkey: '',
content: '', content: '',
tags: [], tags: [],
created_at: 0 created_at: 0
} }
} }
export function finishEvent<K extends number = number>( export function serializeEvent(evt: Event): string {
t: EventTemplate<K>,
privateKey: string
): Event<K> {
let event = t as Event<K>
event.pubkey = getPublicKey(privateKey)
event.id = getEventHash(event)
event.sig = getSignature(event, privateKey)
return event
}
export function serializeEvent(evt: UnsignedEvent<number>): string {
if (!validateEvent(evt)) if (!validateEvent(evt))
throw new Error("can't serialize event with wrong or missing properties") throw new Error("can't serialize event with wrong or missing properties")
@@ -87,17 +53,12 @@ export function serializeEvent(evt: UnsignedEvent<number>): string {
]) ])
} }
export function getEventHash(event: UnsignedEvent<number>): string { export function getEventHash(event: Event): string {
let eventHash = sha256(utf8Encoder.encode(serializeEvent(event))) let eventHash = sha256(utf8Encoder.encode(serializeEvent(event)))
return bytesToHex(eventHash) return secp256k1.utils.bytesToHex(eventHash)
} }
const isRecord = (obj: unknown): obj is Record<string, unknown> => export function validateEvent(event: Event): boolean {
obj instanceof Object
export function validateEvent<T>(event: T): event is T & UnsignedEvent<number> {
if (!isRecord(event)) return false
if (typeof event.kind !== 'number') return false
if (typeof event.content !== 'string') return false if (typeof event.content !== 'string') return false
if (typeof event.created_at !== 'number') return false if (typeof event.created_at !== 'number') return false
if (typeof event.pubkey !== 'string') return false if (typeof event.pubkey !== 'string') return false
@@ -115,26 +76,16 @@ export function validateEvent<T>(event: T): event is T & UnsignedEvent<number> {
return true return true
} }
export function verifySignature(event: Event<number>): boolean { export function verifySignature(event: Event & {sig: string}): boolean {
try { return secp256k1.schnorr.verifySync(
return schnorr.verify(event.sig, getEventHash(event), event.pubkey) event.sig,
} catch (err) { getEventHash(event),
return false event.pubkey
}
}
/** @deprecated Use `getSignature` instead. */
export function signEvent(event: UnsignedEvent<number>, key: string): string {
console.warn(
'nostr-tools: `signEvent` is deprecated and will be removed or changed in the future. Please use `getSignature` instead.'
) )
return getSignature(event, key)
} }
/** Calculate the signature for an event. */ export function signEvent(event: Event, key: string): string {
export function getSignature( return secp256k1.utils.bytesToHex(
event: UnsignedEvent<number>, secp256k1.schnorr.signSync(getEventHash(event), key)
key: string )
): string {
return bytesToHex(schnorr.sign(getEventHash(event), key))
} }

View File

@@ -1,15 +1,17 @@
import {matchEventId, matchEventKind, getSubscriptionId} from './fakejson.ts' /* eslint-env jest */
const {fj} = require('./lib/nostr.cjs')
test('match id', () => { test('match id', () => {
expect( expect(
matchEventId( fj.matchEventId(
`["EVENT","nostril-query",{"tags":[],"content":"so did we cut all corners and p2p stuff in order to make a decentralized social network that was fast and worked, but in the end what we got was a lot of very slow clients that can't handle the traffic of one jack dorsey tweet?","sig":"ca62629d189edebb8f0811cfa0ac53015013df5f305dcba3f411ba15cfc4074d8c2d517ee7d9e81c9eb72a7328bfbe31c9122156397565ac55e740404e2b1fe7","id":"fef2a50f7d9d3d5a5f38ee761bc087ec16198d3f0140df6d1e8193abf7c2b146","kind":1,"pubkey":"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d","created_at":1671150419}]`, `["EVENT","nostril-query",{"tags":[],"content":"so did we cut all corners and p2p stuff in order to make a decentralized social network that was fast and worked, but in the end what we got was a lot of very slow clients that can't handle the traffic of one jack dorsey tweet?","sig":"ca62629d189edebb8f0811cfa0ac53015013df5f305dcba3f411ba15cfc4074d8c2d517ee7d9e81c9eb72a7328bfbe31c9122156397565ac55e740404e2b1fe7","id":"fef2a50f7d9d3d5a5f38ee761bc087ec16198d3f0140df6d1e8193abf7c2b146","kind":1,"pubkey":"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d","created_at":1671150419}]`,
'fef2a50f7d9d3d5a5f38ee761bc087ec16198d3f0140df6d1e8193abf7c2b146' 'fef2a50f7d9d3d5a5f38ee761bc087ec16198d3f0140df6d1e8193abf7c2b146'
) )
).toBeTruthy() ).toBeTruthy()
expect( expect(
matchEventId( fj.matchEventId(
`["EVENT","nostril-query",{"content":"a bunch of mfs interacted with my post using what I assume were \"likes\": https://nostr.build/i/964.png","created_at":1672506879,"id":"f40bdd0905137ad60482537e260890ab50b0863bf16e67cf9383f203bd26c96f","kind":1,"pubkey":"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d","sig":"8b825d2d4096f0643b18ca39da59ec07a682cd8a3e717f119c845037573d98099f5bea94ec7ddedd5600c8020144a255ed52882a911f7f7ada6d6abb3c0a1eb4","tags":[]}]`, `["EVENT","nostril-query",{"content":"a bunch of mfs interacted with my post using what I assume were \"likes\": https://nostr.build/i/964.png","created_at":1672506879,"id":"f40bdd0905137ad60482537e260890ab50b0863bf16e67cf9383f203bd26c96f","kind":1,"pubkey":"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d","sig":"8b825d2d4096f0643b18ca39da59ec07a682cd8a3e717f119c845037573d98099f5bea94ec7ddedd5600c8020144a255ed52882a911f7f7ada6d6abb3c0a1eb4","tags":[]}]`,
'fef2a50f7d9d3d5a5f38ee761bc087ec16198d3f0140df6d1e8193abf7c2b146' 'fef2a50f7d9d3d5a5f38ee761bc087ec16198d3f0140df6d1e8193abf7c2b146'
) )
@@ -18,14 +20,14 @@ test('match id', () => {
test('match kind', () => { test('match kind', () => {
expect( expect(
matchEventKind( fj.matchEventKind(
`["EVENT","nostril-query",{"tags":[],"content":"so did we cut all corners and p2p stuff in order to make a decentralized social network that was fast and worked, but in the end what we got was a lot of very slow clients that can't handle the traffic of one jack dorsey tweet?","sig":"ca62629d189edebb8f0811cfa0ac53015013df5f305dcba3f411ba15cfc4074d8c2d517ee7d9e81c9eb72a7328bfbe31c9122156397565ac55e740404e2b1fe7","id":"fef2a50f7d9d3d5a5f38ee761bc087ec16198d3f0140df6d1e8193abf7c2b146","kind":1,"pubkey":"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d","created_at":1671150419}]`, `["EVENT","nostril-query",{"tags":[],"content":"so did we cut all corners and p2p stuff in order to make a decentralized social network that was fast and worked, but in the end what we got was a lot of very slow clients that can't handle the traffic of one jack dorsey tweet?","sig":"ca62629d189edebb8f0811cfa0ac53015013df5f305dcba3f411ba15cfc4074d8c2d517ee7d9e81c9eb72a7328bfbe31c9122156397565ac55e740404e2b1fe7","id":"fef2a50f7d9d3d5a5f38ee761bc087ec16198d3f0140df6d1e8193abf7c2b146","kind":1,"pubkey":"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d","created_at":1671150419}]`,
1 1
) )
).toBeTruthy() ).toBeTruthy()
expect( expect(
matchEventKind( fj.matchEventKind(
`["EVENT","nostril-query",{"content":"{\"name\":\"fiatjaf\",\"about\":\"buy my merch at fiatjaf store\",\"picture\":\"https://fiatjaf.com/static/favicon.jpg\",\"nip05\":\"_@fiatjaf.com\"}","created_at":1671217411,"id":"b52f93f6dfecf9d81f59062827cd941412a0e8398dda60baf960b17499b88900","kind":12720,"pubkey":"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d","sig":"fc1ea5d45fa5ed0526faed06e8fc7a558e60d1b213e9714f440828584ee999b93407092f9b04deea7e504fa034fc0428f31f7f0f95417b3280ebe6004b80b470","tags":[]}]`, `["EVENT","nostril-query",{"content":"{\"name\":\"fiatjaf\",\"about\":\"buy my merch at fiatjaf store\",\"picture\":\"https://fiatjaf.com/static/favicon.jpg\",\"nip05\":\"_@fiatjaf.com\"}","created_at":1671217411,"id":"b52f93f6dfecf9d81f59062827cd941412a0e8398dda60baf960b17499b88900","kind":12720,"pubkey":"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d","sig":"fc1ea5d45fa5ed0526faed06e8fc7a558e60d1b213e9714f440828584ee999b93407092f9b04deea7e504fa034fc0428f31f7f0f95417b3280ebe6004b80b470","tags":[]}]`,
12720 12720
) )
@@ -33,14 +35,14 @@ test('match kind', () => {
}) })
test('match subscription id', () => { test('match subscription id', () => {
expect(getSubscriptionId('["EVENT","",{}]')).toEqual('') expect(fj.getSubscriptionId('["EVENT","",{}]')).toEqual('')
expect(getSubscriptionId('["EVENT","_",{}]')).toEqual('_') expect(fj.getSubscriptionId('["EVENT","_",{}]')).toEqual('_')
expect(getSubscriptionId('["EVENT","subname",{}]')).toEqual('subname') expect(fj.getSubscriptionId('["EVENT","subname",{}]')).toEqual('subname')
expect(getSubscriptionId('["EVENT", "kasjbdjkav", {}]')).toEqual( expect(fj.getSubscriptionId('["EVENT", "kasjbdjkav", {}]')).toEqual(
'kasjbdjkav' 'kasjbdjkav'
) )
expect( expect(
getSubscriptionId( fj.getSubscriptionId(
' [ \n\n "EVENT" , \n\n "y4d5ow45gfwoiudfÇA VSADLKAN KLDASB[12312535]SFMZSNJKLH" , {}]' ' [ \n\n "EVENT" , \n\n "y4d5ow45gfwoiudfÇA VSADLKAN KLDASB[12312535]SFMZSNJKLH" , {}]'
) )
).toEqual('y4d5ow45gfwoiudfÇA VSADLKAN KLDASB[12312535]SFMZSNJKLH') ).toEqual('y4d5ow45gfwoiudfÇA VSADLKAN KLDASB[12312535]SFMZSNJKLH')

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()
})
})
})

View File

@@ -1,248 +0,0 @@
import {matchFilter, matchFilters, mergeFilters} from './filter.ts'
import {buildEvent} from './test-helpers.ts'
describe('Filter', () => {
describe('matchFilter', () => {
it('should return true when all filter conditions are met', () => {
const filter = {
ids: ['123', '456'],
kinds: [1, 2, 3],
authors: ['abc'],
since: 100,
until: 200,
'#tag': ['value']
}
const event = buildEvent({
id: '123',
kind: 1,
pubkey: 'abc',
created_at: 150,
tags: [['tag', 'value']]
})
const result = matchFilter(filter, event)
expect(result).toEqual(true)
})
it('should return false when the event id is not in the filter', () => {
const filter = {ids: ['123', '456']}
const event = buildEvent({id: '789'})
const result = matchFilter(filter, event)
expect(result).toEqual(false)
})
it('should return true when the event id starts with a prefix', () => {
const filter = {ids: ['22', '00']}
const event = buildEvent({id: '001'})
const result = matchFilter(filter, event)
expect(result).toEqual(true)
})
it('should return false when the event kind is not in the filter', () => {
const filter = {kinds: [1, 2, 3]}
const event = buildEvent({kind: 4})
const result = matchFilter(filter, event)
expect(result).toEqual(false)
})
it('should return false when the event author is not in the filter', () => {
const filter = {authors: ['abc', 'def']}
const event = buildEvent({pubkey: 'ghi'})
const result = matchFilter(filter, event)
expect(result).toEqual(false)
})
it('should return false when a tag is not present in the event', () => {
const filter = {'#tag': ['value1', 'value2']}
const event = buildEvent({tags: [['not_tag', 'value1']]})
const result = matchFilter(filter, event)
expect(result).toEqual(false)
})
it('should return false when a tag value is not present in the event', () => {
const filter = {'#tag': ['value1', 'value2']}
const event = buildEvent({tags: [['tag', 'value3']]})
const result = matchFilter(filter, event)
expect(result).toEqual(false)
})
it('should return true when filter has tags that is present in the event', () => {
const filter = {'#tag1': ['foo']}
const event = buildEvent({
id: '123',
kind: 1,
pubkey: 'abc',
created_at: 150,
tags: [
['tag1', 'foo'],
['tag2', 'bar']
]
})
const result = matchFilter(filter, event)
expect(result).toEqual(true)
})
it('should return false when the event is before the filter since value', () => {
const filter = {since: 100}
const event = buildEvent({created_at: 50})
const result = matchFilter(filter, event)
expect(result).toEqual(false)
})
it('should return true when the timestamp of event is equal to the filter since value', () => {
const filter = {since: 100}
const event = buildEvent({created_at: 100})
const result = matchFilter(filter, event)
expect(result).toEqual(true)
})
it('should return false when the event is after the filter until value', () => {
const filter = {until: 100}
const event = buildEvent({created_at: 150})
const result = matchFilter(filter, event)
expect(result).toEqual(false)
})
it('should return true when the timestamp of event is equal to the filter until value', () => {
const filter = {until: 100}
const event = buildEvent({created_at: 100})
const result = matchFilter(filter, event)
expect(result).toEqual(true)
})
})
describe('matchFilters', () => {
it('should return true when at least one filter matches the event', () => {
const filters = [
{ids: ['123'], kinds: [1], authors: ['abc']},
{ids: ['456'], kinds: [2], authors: ['def']},
{ids: ['789'], kinds: [3], authors: ['ghi']}
]
const event = buildEvent({id: '789', kind: 3, pubkey: 'ghi'})
const result = matchFilters(filters, event)
expect(result).toEqual(true)
})
it('should return true when at least one prefix matches the event', () => {
const filters = [
{ids: ['1'], kinds: [1], authors: ['a']},
{ids: ['4'], kinds: [2], authors: ['d']},
{ids: ['9'], kinds: [3], authors: ['g']}
]
const event = buildEvent({id: '987', kind: 3, pubkey: 'ghi'})
const result = matchFilters(filters, event)
expect(result).toEqual(true)
})
it('should return true when event matches one or more filters and some have limit set', () => {
const filters = [
{ids: ['123'], limit: 1},
{kinds: [1], limit: 2},
{authors: ['abc'], limit: 3}
]
const event = buildEvent({
id: '123',
kind: 1,
pubkey: 'abc',
created_at: 150
})
const result = matchFilters(filters, event)
expect(result).toEqual(true)
})
it('should return false when no filters match the event', () => {
const filters = [
{ids: ['123'], kinds: [1], authors: ['abc']},
{ids: ['456'], kinds: [2], authors: ['def']},
{ids: ['789'], kinds: [3], authors: ['ghi']}
]
const event = buildEvent({id: '100', kind: 4, pubkey: 'jkl'})
const result = matchFilters(filters, event)
expect(result).toEqual(false)
})
it('should return false when event matches none of the filters and some have limit set', () => {
const filters = [
{ids: ['123'], limit: 1},
{kinds: [1], limit: 2},
{authors: ['abc'], limit: 3}
]
const event = buildEvent({
id: '456',
kind: 2,
pubkey: 'def',
created_at: 200
})
const result = matchFilters(filters, event)
expect(result).toEqual(false)
})
})
describe('mergeFilters', () => {
it('should merge filters', () => {
expect(
mergeFilters(
{ids: ['a', 'b'], limit: 3},
{authors: ['x'], ids: ['b', 'c']}
)
).toEqual({ids: ['a', 'b', 'c'], limit: 3, authors: ['x']})
expect(
mergeFilters(
{kinds: [1], since: 15, until: 30},
{since: 10, kinds: [7], until: 15},
{kinds: [9, 10]}
)
).toEqual({kinds: [1, 7, 9, 10], since: 10, until: 30})
})
})
})

View File

@@ -1,31 +1,23 @@
import {Event, type Kind} from './event.ts' import {Event} from './event'
export type Filter<K extends number = number> = { export type Filter = {
ids?: string[] ids?: string[]
kinds?: K[] kinds?: number[]
authors?: string[] authors?: string[]
since?: number since?: number
until?: number until?: number
limit?: number limit?: number
search?: string [key: `#${string}`]: string[]
[key: `#${string}`]: string[] | undefined
} }
export function matchFilter( export function matchFilter(
filter: Filter<number>, filter: Filter,
event: Event<number> event: Event & {id: string}
): boolean { ): boolean {
if (filter.ids && filter.ids.indexOf(event.id) === -1) { if (filter.ids && filter.ids.indexOf(event.id) === -1) return false
if (!filter.ids.some(prefix => event.id.startsWith(prefix))) {
return false
}
}
if (filter.kinds && filter.kinds.indexOf(event.kind) === -1) return false if (filter.kinds && filter.kinds.indexOf(event.kind) === -1) return false
if (filter.authors && filter.authors.indexOf(event.pubkey) === -1) { if (filter.authors && filter.authors.indexOf(event.pubkey) === -1)
if (!filter.authors.some(prefix => event.pubkey.startsWith(prefix))) {
return false return false
}
}
for (let f in filter) { for (let f in filter) {
if (f[0] === '#') { if (f[0] === '#') {
@@ -34,7 +26,7 @@ export function matchFilter(
if ( if (
values && values &&
!event.tags.find( !event.tags.find(
([t, v]) => t === f.slice(1) && values!.indexOf(v) !== -1 ([t, v]) => t === f.slice(1) && values.indexOf(v) !== -1
) )
) )
return false return false
@@ -42,51 +34,17 @@ export function matchFilter(
} }
if (filter.since && event.created_at < filter.since) return false if (filter.since && event.created_at < filter.since) return false
if (filter.until && event.created_at > filter.until) return false if (filter.until && event.created_at >= filter.until) return false
return true return true
} }
export function matchFilters( export function matchFilters(
filters: Filter<number>[], filters: Filter[],
event: Event<number> event: Event & {id: string}
): boolean { ): boolean {
for (let i = 0; i < filters.length; i++) { for (let i = 0; i < filters.length; i++) {
if (matchFilter(filters[i], event)) return true if (matchFilter(filters[i], event)) return true
} }
return false return false
} }
export function mergeFilters(...filters: Filter<number>[]): Filter<number> {
let result: Filter<number> = {}
for (let i = 0; i < filters.length; i++) {
let filter = filters[i]
Object.entries(filter).forEach(([property, values]) => {
if (
property === 'kinds' ||
property === 'ids' ||
property === 'authors' ||
property[0] === '#'
) {
// @ts-ignore
result[property] = result[property] || []
// @ts-ignore
for (let v = 0; v < values.length; v++) {
// @ts-ignore
let value = values[v]
// @ts-ignore
if (!result[property].includes(value)) result[property].push(value)
}
}
})
if (filter.limit && (!result.limit || filter.limit > result.limit))
result.limit = filter.limit
if (filter.until && (!result.until || filter.until > result.until))
result.until = filter.until
if (filter.since && (!result.since || filter.since < result.since))
result.since = filter.since
}
return result
}

View File

@@ -1,27 +1,23 @@
export * from './keys.ts' export * from './keys'
export * from './relay.ts' export * from './relay'
export * from './event.ts' export * from './event'
export * from './filter.ts' export * from './filter'
export * from './pool.ts' export * from './pool'
export * from './references.ts'
export * as nip04 from './nip04.ts' export * as nip04 from './nip04'
export * as nip05 from './nip05.ts' export * as nip05 from './nip05'
export * as nip06 from './nip06.ts' export * as nip06 from './nip06'
export * as nip10 from './nip10.ts' export * as nip19 from './nip19'
export * as nip13 from './nip13.ts' export * as nip26 from './nip26'
export * as nip18 from './nip18.ts'
export * as nip19 from './nip19.ts'
export * as nip21 from './nip21.ts'
export * as nip25 from './nip25.ts'
export * as nip26 from './nip26.ts'
export * as nip27 from './nip27.ts'
export * as nip28 from './nip28.ts'
export * as nip39 from './nip39.ts'
export * as nip42 from './nip42.ts'
export * as nip44 from './nip44.ts'
export * as nip57 from './nip57.ts'
export * as nip98 from './nip98.ts'
export * as fj from './fakejson.ts' export * as fj from './fakejson'
export * as utils from './utils.ts' export * as utils from './utils'
// monkey patch secp256k1
import * as secp256k1 from '@noble/secp256k1'
import {hmac} from '@noble/hashes/hmac'
import {sha256} from '@noble/hashes/sha256'
secp256k1.utils.hmacSha256Sync = (key, ...msgs) =>
hmac(sha256, key, secp256k1.utils.concatBytes(...msgs))
secp256k1.utils.sha256Sync = (...msgs) =>
sha256(secp256k1.utils.concatBytes(...msgs))

View File

@@ -1,5 +0,0 @@
/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
}

View File

@@ -4,20 +4,13 @@ install-dependencies:
yarn --ignore-engines yarn --ignore-engines
build: build:
rm -rf lib
node build.js node build.js
test: test: build
jest jest
test-only file: testOnly file: build
jest {{file}} jest {{file}}
emit-types: publish: build
tsc # see tsconfig.json
publish: build emit-types
npm publish npm publish
format:
prettier --plugin-search-dir . --write .

View File

@@ -1,14 +1,16 @@
import {generatePrivateKey, getPublicKey} from './keys.ts' /* eslint-env jest */
test('private key generation', () => { const {generatePrivateKey, getPublicKey} = require('./lib/nostr.cjs')
test('test private key generation', () => {
expect(generatePrivateKey()).toMatch(/[a-f0-9]{64}/) expect(generatePrivateKey()).toMatch(/[a-f0-9]{64}/)
}) })
test('public key generation', () => { test('test public key generation', () => {
expect(getPublicKey(generatePrivateKey())).toMatch(/[a-f0-9]{64}/) expect(getPublicKey(generatePrivateKey())).toMatch(/[a-f0-9]{64}/)
}) })
test('public key from private key deterministic', () => { test('test public key from private key deterministic', () => {
let sk = generatePrivateKey() let sk = generatePrivateKey()
let pk = getPublicKey(sk) let pk = getPublicKey(sk)

View File

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

140
magic.ts Normal file
View File

@@ -0,0 +1,140 @@
import {Relay, relayInit} from './relay'
import {Event} from './event'
import {normalizeURL} from './utils'
export default function (
writeableRelays: string[],
fallbackRelays: string[],
safeRelays: string[]
) {
return new MagicPool(fallbackRelays, writeableRelays, safeRelays)
}
class MagicPool {
private _conn: {[url: string]: Relay}
private _fallback: {[url: string]: Relay}
private _write: {[url: string]: Relay}
private _safe: {[url: string]: Relay}
private _profileRelays: {[pubkey: string]: RelayTableScore}
private _tempCache: {[id: string]: Event}
constructor(
fallbackRelays: string[],
writeableRelays: string[],
safeRelays: string[] = [
'wss://eden.nostr.land',
'wss://nostr.milou.lol',
'wss://relay.minds.com/nostr/v1/ws'
]
) {
this._conn = {}
this._write = {}
this._fallback = {}
this._profileRelays = {}
this._tempCache = {}
const hasEventId = (id: string): boolean => id in this._tempCache
const init = (url: string) => {
this._conn[normalizeURL(url)] = relayInit(normalizeURL(url), hasEventId)
}
fallbackRelays.forEach(init)
writeableRelays.forEach(init)
safeRelays.forEach(init)
this._write = Object.fromEntries(
writeableRelays.map(url => [
normalizeURL(url),
this._conn[normalizeURL(url)]
])
)
this._fallback = Object.fromEntries(
fallbackRelays.map(url => [
normalizeURL(url),
this._conn[normalizeURL(url)]
])
)
this._safe = Object.fromEntries(
safeRelays.map(url => [normalizeURL(url), this._conn[normalizeURL(url)]])
)
}
publish(event: Event) {
return Promise.all(
Object.entries(this._write).map(
([url, relay]) =>
new Promise(async resolve => {
await relay.connect()
let pub = relay.publish(event)
let to = setTimeout(() => {
let end = setTimeout(() => {
resolve({url, success: false, reason: 'timeout'})
}, 2500)
pub.on('seen', () => {
clearTimeout(end)
resolve({url, success: true, reason: 'seen'})
})
}, 2500)
pub.on('ok', () => {
clearTimeout(to)
resolve({url, success: true, reason: 'ok'})
})
pub.on('failed', (reason: string) => {
clearTimeout(to)
resolve({url, success: false, reason})
})
})
)
)
}
profile(
pubkey: string,
onUpdate: (events: Event[]) => void
): {
page(n: number): void
} {
var relays = new Set()
let rts = this._profileRelays[pubkey]
if (rts) {
relays = rts.get(3)
}
let fallback = Object.values(this._fallback)
for (let i = 0; i < fallback.length; i++) {
if (relays.size < 3) {
relays.add(fallback[Math.floor(Math.random() * fallback.length)])
} else break
}
// start subscription
for (let r in relays) {
r.
}
return {
page(n: number) {}
}
}
}
class RelayTableScore {
seen: string[] = []
hinted: string[] = []
explicit: string[] = []
get(n: number): Set<string> {
let relays = new Set<string>()
for (let i = 0; i < n; i++) {
for (let j = 0; j < 3; j++) {
let v = [this.seen, this.explicit, this.hinted][j][i]
if (v) {
relays.add(v)
if (relays.size >= n) return relays
}
}
}
return relays
}
}

15
nip04.test.js Normal file
View File

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

View File

@@ -1,19 +0,0 @@
import crypto from 'node:crypto'
import {encrypt, decrypt} from './nip04.ts'
import {getPublicKey, generatePrivateKey} from './keys.ts'
// @ts-ignore
// eslint-disable-next-line no-undef
globalThis.crypto = crypto
test('encrypt and decrypt message', async () => {
let sk1 = generatePrivateKey()
let sk2 = generatePrivateKey()
let pk1 = getPublicKey(sk1)
let pk2 = getPublicKey(sk2)
expect(
await decrypt(sk2, pk1, await encrypt(sk1, pk2, 'hello'))
).toEqual('hello')
})

View File

@@ -1,14 +1,8 @@
import {randomBytes} from '@noble/hashes/utils' import {randomBytes} from '@noble/hashes/utils'
import {secp256k1} from '@noble/curves/secp256k1' import * as secp256k1 from '@noble/secp256k1'
import {base64} from '@scure/base' import {base64} from '@scure/base'
import {utf8Decoder, utf8Encoder} from './utils.ts' import {utf8Decoder, utf8Encoder} from './utils'
// @ts-ignore
if (typeof crypto !== 'undefined' && !crypto.subtle && crypto.webcrypto) {
// @ts-ignore
crypto.subtle = crypto.webcrypto.subtle
}
export async function encrypt( export async function encrypt(
privkey: string, privkey: string,

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,32 +0,0 @@
import fetch from 'node-fetch'
import {useFetchImplementation, queryProfile} from './nip05.ts'
test('fetch nip05 profiles', async () => {
useFetchImplementation(fetch)
let p1 = await queryProfile('jb55.com')
expect(p1!.pubkey).toEqual(
'32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245'
)
expect(p1!.relays).toEqual(['wss://relay.damus.io'])
let p2 = await queryProfile('jb55@jb55.com')
expect(p2!.pubkey).toEqual(
'32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245'
)
expect(p2!.relays).toEqual(['wss://relay.damus.io'])
let p3 = await queryProfile('_@fiatjaf.com')
expect(p3!.pubkey).toEqual(
'3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d'
)
expect(p3!.relays).toEqual([
'wss://relay.nostr.bg',
'wss://nos.lol',
'wss://nostr-verified.wellorder.net',
'wss://nostr.zebedee.cloud',
'wss://eden.nostr.land',
'wss://nostr.milou.lol',
])
})

View File

@@ -1,13 +1,4 @@
import {ProfilePointer} from './nip19.ts' import {ProfilePointer} from './nip19'
/**
* NIP-05 regex. The localpart is optional, and should be assumed to be `_` otherwise.
*
* - 0: full match
* - 1: name (optional)
* - 2: domain
*/
export const NIP05_REGEX = /^(?:([\w.+-]+)@)?([\w.-]+)$/
var _fetch: any var _fetch: any
@@ -34,53 +25,30 @@ export async function searchDomain(
} }
} }
export async function queryProfile(fullname: string): Promise<ProfilePointer | null> { export async function queryProfile(
const match = fullname.match(NIP05_REGEX) fullname: string
if (!match) return null ): Promise<ProfilePointer | null> {
let [name, domain] = fullname.split('@')
const [_, name = '_', domain] = match if (!domain) {
// if there is no @, it is because it is just a domain, so assume the name is "_"
try { domain = name
const res = await _fetch(`https://${domain}/.well-known/nostr.json?name=${name}`) name = '_'
const { names, relays } = parseNIP05Result(await res.json())
const pubkey = names[name]
return pubkey ? { pubkey, relays: relays?.[pubkey] } : null
} catch (_e) {
return null
}
} }
/** nostr.json result. */ if (!name.match(/^[A-Za-z0-9-_]+$/)) return null
export interface NIP05Result {
names: {
[name: string]: string
}
relays?: {
[pubkey: string]: string[]
}
}
/** Parse the nostr.json and throw if it's not valid. */ let res = await (
function parseNIP05Result(json: any): NIP05Result { await _fetch(`https://${domain}/.well-known/nostr.json?name=${name}`)
const result: NIP05Result = { ).json()
names: {},
}
for (const [name, pubkey] of Object.entries(json.names)) { if (!res?.names?.[name]) return null
if (typeof name === 'string' && typeof pubkey === 'string') {
result.names[name] = pubkey
}
}
if (json.relays) { let pubkey = res.names[name] as string
result.relays = {} let relays = (res.relays?.[pubkey] || []) as string[] // nip35
for (const [pubkey, relays] of Object.entries(json.relays)) {
if (typeof pubkey === 'string' && Array.isArray(relays)) {
result.relays[pubkey] = relays.filter((relay: unknown) => typeof relay === 'string')
}
}
}
return result return {
pubkey,
relays
}
} }

15
nip06.test.js Normal file
View File

@@ -0,0 +1,15 @@
/* eslint-env jest */
const {nip06} = require('./lib/nostr.cjs')
test('generate private key from a mnemonic', async () => {
const mnemonic = 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong'
const privateKey = nip06.privateKeyFromSeedWords(mnemonic)
expect(privateKey).toEqual('c26cf31d8ba425b555ca27d00ca71b5008004f2f662470f8c8131822ec129fe2')
})
test('generate private key from a mnemonic and passphrase', async () => {
const mnemonic = 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong'
const passphrase = '123'
const privateKey = nip06.privateKeyFromSeedWords(mnemonic, passphrase)
expect(privateKey).toEqual('55a22b8203273d0aaf24c22c8fbe99608e70c524b17265641074281c8b978ae4')
})

View File

@@ -1,18 +0,0 @@
import {privateKeyFromSeedWords} from './nip06.ts'
test('generate private key from a mnemonic', async () => {
const mnemonic = 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong'
const privateKey = privateKeyFromSeedWords(mnemonic)
expect(privateKey).toEqual(
'c26cf31d8ba425b555ca27d00ca71b5008004f2f662470f8c8131822ec129fe2'
)
})
test('generate private key from a mnemonic and passphrase', async () => {
const mnemonic = 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong'
const passphrase = '123'
const privateKey = privateKeyFromSeedWords(mnemonic, passphrase)
expect(privateKey).toEqual(
'55a22b8203273d0aaf24c22c8fbe99608e70c524b17265641074281c8b978ae4'
)
})

View File

@@ -1,5 +1,5 @@
import {bytesToHex} from '@noble/hashes/utils' import * as secp256k1 from '@noble/secp256k1'
import {wordlist} from '@scure/bip39/wordlists/english' import {wordlist} from '@scure/bip39/wordlists/english.js'
import { import {
generateMnemonic, generateMnemonic,
mnemonicToSeedSync, mnemonicToSeedSync,
@@ -7,14 +7,11 @@ import {
} from '@scure/bip39' } from '@scure/bip39'
import {HDKey} from '@scure/bip32' import {HDKey} from '@scure/bip32'
export function privateKeyFromSeedWords( export function privateKeyFromSeedWords(mnemonic: string, passphrase?: string): string {
mnemonic: string,
passphrase?: string
): string {
let root = HDKey.fromMasterSeed(mnemonicToSeedSync(mnemonic, passphrase)) let root = HDKey.fromMasterSeed(mnemonicToSeedSync(mnemonic, passphrase))
let privateKey = root.derive(`m/44'/1237'/0'/0/0`).privateKey let privateKey = root.derive(`m/44'/1237'/0'/0/0`).privateKey
if (!privateKey) throw new Error('could not derive private key') if (!privateKey) throw new Error('could not derive private key')
return bytesToHex(privateKey) return secp256k1.utils.bytesToHex(privateKey)
} }
export function generateSeedWords(): string { export function generateSeedWords(): string {

View File

@@ -1,351 +0,0 @@
import {parse} from './nip10.ts'
describe('parse NIP10-referenced events', () => {
test('legacy + a lot of events', () => {
let event = {
tags: [
[
'e',
'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c'
],
[
'e',
'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631'
],
[
'e',
'5e081ebb19153357d7c31e8a10b9ceeef29313f58dc8d701f66727fab02aef64'
],
[
'e',
'49aff7ae6daeaaa2777931b90f9bb29f6cb01c5a3d7d88c8ba82d890f264afb4'
],
[
'e',
'567b7c11f0fe582361e3cea6fcc7609a8942dfe196ee1b98d5604c93fbeea976'
],
[
'e',
'090c037b2e399ee74d9f134758928948dd9154413ca1a1acb37155046e03a051'
],
[
'e',
'89f220b63465c93542b1a78caa3a952cf4f196e91a50596493c8093c533ebc4d'
],
[
'p',
'77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7'
],
[
'p',
'534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec'
],
[
'p',
'4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0'
]
]
}
expect(parse(event)).toEqual({
mentions: [
{
id: 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631',
relays: []
},
{
id: '5e081ebb19153357d7c31e8a10b9ceeef29313f58dc8d701f66727fab02aef64',
relays: []
},
{
id: '49aff7ae6daeaaa2777931b90f9bb29f6cb01c5a3d7d88c8ba82d890f264afb4',
relays: []
},
{
id: '567b7c11f0fe582361e3cea6fcc7609a8942dfe196ee1b98d5604c93fbeea976',
relays: []
},
{
id: '090c037b2e399ee74d9f134758928948dd9154413ca1a1acb37155046e03a051',
relays: []
}
],
profiles: [
{
pubkey:
'77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7',
relays: []
},
{
pubkey:
'534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec',
relays: []
},
{
pubkey:
'4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0',
relays: []
}
],
reply: {
id: '89f220b63465c93542b1a78caa3a952cf4f196e91a50596493c8093c533ebc4d',
relays: []
},
root: {
id: 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c',
relays: []
}
})
})
test('legacy + 3 events', () => {
let event = {
tags: [
[
'e',
'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c'
],
[
'e',
'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631'
],
[
'e',
'5e081ebb19153357d7c31e8a10b9ceeef29313f58dc8d701f66727fab02aef64'
],
[
'p',
'77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7'
],
[
'p',
'534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec'
],
[
'p',
'4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0'
]
]
}
expect(parse(event)).toEqual({
mentions: [
{
id: 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631',
relays: []
}
],
profiles: [
{
pubkey:
'77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7',
relays: []
},
{
pubkey:
'534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec',
relays: []
},
{
pubkey:
'4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0',
relays: []
}
],
reply: {
id: '5e081ebb19153357d7c31e8a10b9ceeef29313f58dc8d701f66727fab02aef64',
relays: []
},
root: {
id: 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c',
relays: []
}
})
})
test('legacy + 2 events', () => {
let event = {
tags: [
[
'e',
'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c'
],
[
'e',
'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631'
],
[
'p',
'77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7'
],
[
'p',
'534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec'
],
[
'p',
'4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0'
]
]
}
expect(parse(event)).toEqual({
mentions: [],
profiles: [
{
pubkey:
'77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7',
relays: []
},
{
pubkey:
'534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec',
relays: []
},
{
pubkey:
'4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0',
relays: []
}
],
reply: {
id: 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631',
relays: []
},
root: {
id: 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c',
relays: []
}
})
})
test('legacy + 1 event', () => {
let event = {
tags: [
[
'e',
'9abbfd9b9ac5ecdab45d14b8bf8d746139ea039e931a1b376d19a239f1946590'
],
[
'p',
'534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec'
]
]
}
expect(parse(event)).toEqual({
mentions: [],
profiles: [
{
pubkey:
'534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec',
relays: []
}
],
reply: undefined,
root: {
id: '9abbfd9b9ac5ecdab45d14b8bf8d746139ea039e931a1b376d19a239f1946590',
relays: []
}
})
})
test.todo('recommended + a lot of events')
test.todo('recommended + 3 events')
test.todo('recommended + 2 events')
test('recommended + 1 event', () => {
let event = {
tags: [
[
'p',
'a8c21fcd8aa1f4befba14d72fc7a012397732d30d8b3131af912642f3c726f52',
'wss://relay.mostr.pub'
],
[
'p',
'003d7fd21fd09ff7f6f63a75daf194dd99feefbe6919cc376b7359d5090aa9a6',
'wss://relay.mostr.pub'
],
[
'p',
'2f6fbe452edd3987d3c67f3b034c03ec5bcf4d054c521c3a954686f89f03212e',
'wss://relay.mostr.pub'
],
[
'p',
'44c7c74668ff222b0e0b30579c49fc6e22dafcdeaad091036c947f9856590f1e',
'wss://relay.mostr.pub'
],
[
'p',
'c5cf39149caebda4cdd61771c51f6ba91ef5645919004e5c4998a4ea69f00512',
'wss://relay.mostr.pub'
],
[
'p',
'094d44bb1e812696c57f57ad1c0c707812dedbe72c07e538b80639032c236a9e',
'wss://relay.mostr.pub'
],
[
'p',
'a1ba0ac9b6ec098f726a3c11ec654df4a32cbb84b5377e8788395e9c27d9ecda',
'wss://relay.mostr.pub'
],
[
'e',
'f9472913904ab7e9da008dcb2d85fd4af2d2993ada483d00c646d0c4481d031d',
'wss://relay.mostr.pub',
'reply'
],
['mostr', 'https://poa.st/objects/dc50684b-6364-4264-ab16-49f4622f05ea']
]
}
expect(parse(event)).toEqual({
mentions: [],
profiles: [
{
pubkey:
'a8c21fcd8aa1f4befba14d72fc7a012397732d30d8b3131af912642f3c726f52',
relays: ['wss://relay.mostr.pub']
},
{
pubkey:
'003d7fd21fd09ff7f6f63a75daf194dd99feefbe6919cc376b7359d5090aa9a6',
relays: ['wss://relay.mostr.pub']
},
{
pubkey:
'2f6fbe452edd3987d3c67f3b034c03ec5bcf4d054c521c3a954686f89f03212e',
relays: ['wss://relay.mostr.pub']
},
{
pubkey:
'44c7c74668ff222b0e0b30579c49fc6e22dafcdeaad091036c947f9856590f1e',
relays: ['wss://relay.mostr.pub']
},
{
pubkey:
'c5cf39149caebda4cdd61771c51f6ba91ef5645919004e5c4998a4ea69f00512',
relays: ['wss://relay.mostr.pub']
},
{
pubkey:
'094d44bb1e812696c57f57ad1c0c707812dedbe72c07e538b80639032c236a9e',
relays: ['wss://relay.mostr.pub']
},
{
pubkey:
'a1ba0ac9b6ec098f726a3c11ec654df4a32cbb84b5377e8788395e9c27d9ecda',
relays: ['wss://relay.mostr.pub']
}
],
reply: {
id: 'f9472913904ab7e9da008dcb2d85fd4af2d2993ada483d00c646d0c4481d031d',
relays: ['wss://relay.mostr.pub']
},
root: undefined
})
})
})

View File

@@ -1,96 +0,0 @@
import type {Event} from './event.ts'
import type {EventPointer, ProfilePointer} from './nip19.ts'
export type NIP10Result = {
/**
* Pointer to the root of the thread.
*/
root: EventPointer | undefined
/**
* Pointer to a "parent" event that parsed event replies to (responded to).
*/
reply: EventPointer | undefined
/**
* Pointers to events which may or may not be in the reply chain.
*/
mentions: EventPointer[]
/**
* List of pubkeys that are involved in the thread in no particular order.
*/
profiles: ProfilePointer[]
}
export function parse(event: Pick<Event, 'tags'>): NIP10Result {
const result: NIP10Result = {
reply: undefined,
root: undefined,
mentions: [],
profiles: []
}
const eTags: string[][] = []
for (const tag of event.tags) {
if (tag[0] === 'e' && tag[1]) {
eTags.push(tag)
}
if (tag[0] === 'p' && tag[1]) {
result.profiles.push({
pubkey: tag[1],
relays: tag[2] ? [tag[2]] : []
})
}
}
for (let eTagIndex = 0; eTagIndex < eTags.length; eTagIndex++) {
const eTag = eTags[eTagIndex]
const [_, eTagEventId, eTagRelayUrl, eTagMarker] = eTag as [
string,
string,
undefined | string,
undefined | string
]
const eventPointer: EventPointer = {
id: eTagEventId,
relays: eTagRelayUrl ? [eTagRelayUrl] : []
}
const isFirstETag = eTagIndex === 0
const isLastETag = eTagIndex === eTags.length - 1
if (eTagMarker === 'root') {
result.root = eventPointer
continue
}
if (eTagMarker === 'reply') {
result.reply = eventPointer
continue
}
if (eTagMarker === 'mention') {
result.mentions.push(eventPointer)
continue
}
if (isFirstETag) {
result.root = eventPointer
continue
}
if (isLastETag) {
result.reply = eventPointer
continue
}
result.mentions.push(eventPointer)
}
return result
}

View File

@@ -1,7 +0,0 @@
import {getPow} from './nip13.ts'
test('identifies proof-of-work difficulty', async () => {
const id = '000006d8c378af1779d2feebc7603a125d99eca0ccf1085959b307f64e5dd358'
const difficulty = getPow(id)
expect(difficulty).toEqual(21)
})

View File

@@ -1,42 +0,0 @@
import {hexToBytes} from '@noble/hashes/utils'
/** Get POW difficulty from a Nostr hex ID. */
export function getPow(id: string): number {
return getLeadingZeroBits(hexToBytes(id))
}
/**
* Get number of leading 0 bits. Adapted from nostream.
* https://github.com/Cameri/nostream/blob/fb6948fd83ca87ce552f39f9b5eb780ea07e272e/src/utils/proof-of-work.ts
*/
function getLeadingZeroBits(hash: Uint8Array): number {
let total: number, i: number, bits: number
for (i = 0, total = 0; i < hash.length; i++) {
bits = msb(hash[i])
total += bits
if (bits !== 8) {
break
}
}
return total
}
/**
* Adapted from nostream.
* https://github.com/Cameri/nostream/blob/fb6948fd83ca87ce552f39f9b5eb780ea07e272e/src/utils/proof-of-work.ts
*/
function msb(b: number) {
let n = 0
if (b === 0) {
return 8
}
// eslint-disable-next-line no-cond-assign
while (b >>= 1) {
n++
}
return 7 - n
}

View File

@@ -1,112 +0,0 @@
import {finishEvent, Kind} from './event.ts'
import {getPublicKey} from './keys.ts'
import {finishRepostEvent, getRepostedEventPointer, getRepostedEvent} from './nip18.ts'
import {buildEvent} from './test-helpers.ts'
const relayUrl = 'https://relay.example.com'
describe('finishRepostEvent + getRepostedEventPointer + getRepostedEvent', () => {
const privateKey =
'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
const publicKey = getPublicKey(privateKey)
const repostedEvent = finishEvent(
{
kind: Kind.Text,
tags: [
['e', 'replied event id'],
['p', 'replied event pubkey']
],
content: 'Replied to a post',
created_at: 1617932115
},
privateKey
)
it('should create a signed event from a minimal template', () => {
const template = {
created_at: 1617932115
}
const event = finishRepostEvent(
template,
repostedEvent,
relayUrl,
privateKey
)
expect(event.kind).toEqual(Kind.Repost)
expect(event.tags).toEqual([
['e', repostedEvent.id, relayUrl],
['p', repostedEvent.pubkey]
])
expect(event.content).toEqual(JSON.stringify(repostedEvent))
expect(event.created_at).toEqual(template.created_at)
expect(event.pubkey).toEqual(publicKey)
expect(typeof event.id).toEqual('string')
expect(typeof event.sig).toEqual('string')
const repostedEventPointer = getRepostedEventPointer(event)
expect(repostedEventPointer!.id).toEqual(repostedEvent.id)
expect(repostedEventPointer!.author).toEqual(repostedEvent.pubkey)
expect(repostedEventPointer!.relays).toEqual([relayUrl])
const repostedEventFromContent = getRepostedEvent(event)
expect(repostedEventFromContent).toEqual(repostedEvent)
})
it('should create a signed event from a filled template', () => {
const template = {
tags: [['nonstandard', 'tag']],
content: '' as const,
created_at: 1617932115
}
const event = finishRepostEvent(
template,
repostedEvent,
relayUrl,
privateKey
)
expect(event.kind).toEqual(Kind.Repost)
expect(event.tags).toEqual([
['nonstandard', 'tag'],
['e', repostedEvent.id, relayUrl],
['p', repostedEvent.pubkey]
])
expect(event.content).toEqual('')
expect(event.created_at).toEqual(template.created_at)
expect(event.pubkey).toEqual(publicKey)
expect(typeof event.id).toEqual('string')
expect(typeof event.sig).toEqual('string')
const repostedEventPointer = getRepostedEventPointer(event)
expect(repostedEventPointer!.id).toEqual(repostedEvent.id)
expect(repostedEventPointer!.author).toEqual(repostedEvent.pubkey)
expect(repostedEventPointer!.relays).toEqual([relayUrl])
const repostedEventFromContent = getRepostedEvent(event)
expect(repostedEventFromContent).toEqual(undefined)
})
})
describe('getRepostedEventPointer', () => {
it('should parse an event with only an `e` tag', () => {
const event = buildEvent({
kind: Kind.Repost,
tags: [['e', 'reposted event id', relayUrl]],
})
const repostedEventPointer = getRepostedEventPointer(event)
expect(repostedEventPointer!.id).toEqual('reposted event id')
expect(repostedEventPointer!.author).toEqual(undefined)
expect(repostedEventPointer!.relays).toEqual([relayUrl])
})
})

View File

@@ -1,97 +0,0 @@
import {Event, finishEvent, Kind, verifySignature} from './event.ts'
import {EventPointer} from './nip19.ts'
export type RepostEventTemplate = {
/**
* Pass only non-nip18 tags if you have to.
* Nip18 tags ('e' and 'p' tags pointing to the reposted event) will be added automatically.
*/
tags?: string[][]
/**
* Pass an empty string to NOT include the stringified JSON of the reposted event.
* Any other content will be ignored and replaced with the stringified JSON of the reposted event.
* @default Stringified JSON of the reposted event
*/
content?: '';
created_at: number
}
export function finishRepostEvent(
t: RepostEventTemplate,
reposted: Event<number>,
relayUrl: string,
privateKey: string,
): Event<Kind.Repost> {
return finishEvent({
kind: Kind.Repost,
tags: [
...(t.tags ?? []),
[ 'e', reposted.id, relayUrl ],
[ 'p', reposted.pubkey ],
],
content: t.content === '' ? '' : JSON.stringify(reposted),
created_at: t.created_at,
}, privateKey)
}
export function getRepostedEventPointer(event: Event<number>): undefined | EventPointer {
if (event.kind !== Kind.Repost) {
return undefined
}
let lastETag: undefined | string[]
let lastPTag: undefined | string[]
for (let i = event.tags.length - 1; i >= 0 && (lastETag === undefined || lastPTag === undefined); i--) {
const tag = event.tags[i]
if (tag.length >= 2) {
if (tag[0] === 'e' && lastETag === undefined) {
lastETag = tag
} else if (tag[0] === 'p' && lastPTag === undefined) {
lastPTag = tag
}
}
}
if (lastETag === undefined) {
return undefined
}
return {
id: lastETag[1],
relays: [ lastETag[2], lastPTag?.[2] ].filter((x): x is string => typeof x === 'string'),
author: lastPTag?.[1],
}
}
export type GetRepostedEventOptions = {
skipVerification?: boolean,
};
export function getRepostedEvent(event: Event<number>, { skipVerification }: GetRepostedEventOptions = {}): undefined | Event<number> {
const pointer = getRepostedEventPointer(event)
if (pointer === undefined || event.content === '') {
return undefined
}
let repostedEvent: undefined | Event<number>
try {
repostedEvent = JSON.parse(event.content) as Event<number>
} catch (error) {
return undefined
}
if (repostedEvent.id !== pointer.id) {
return undefined
}
if (!skipVerification && !verifySignature(repostedEvent)) {
return undefined
}
return repostedEvent
}

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])
})

View File

@@ -1,123 +0,0 @@
import {generatePrivateKey, getPublicKey} from './keys.ts'
import {
decode,
naddrEncode,
nprofileEncode,
npubEncode,
nrelayEncode,
nsecEncode,
type AddressPointer,
type ProfilePointer,
} from './nip19.ts'
test('encode and decode nsec', () => {
let sk = generatePrivateKey()
let nsec = nsecEncode(sk)
expect(nsec).toMatch(/nsec1\w+/)
let {type, data} = decode(nsec)
expect(type).toEqual('nsec')
expect(data).toEqual(sk)
})
test('encode and decode npub', () => {
let pk = getPublicKey(generatePrivateKey())
let npub = npubEncode(pk)
expect(npub).toMatch(/npub1\w+/)
let {type, data} = 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 = nprofileEncode({pubkey: pk, relays})
expect(nprofile).toMatch(/nprofile1\w+/)
let {type, data} = decode(nprofile)
expect(type).toEqual('nprofile')
const pointer = data as ProfilePointer
expect(pointer.pubkey).toEqual(pk)
expect(pointer.relays).toContain(relays[0])
expect(pointer.relays).toContain(relays[1])
})
test('decode nprofile without relays', () => {
expect(
decode(
nprofileEncode({
pubkey:
'97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322',
relays: []
})
).data
).toHaveProperty(
'pubkey',
'97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322'
)
})
test('encode and decode naddr', () => {
let pk = getPublicKey(generatePrivateKey())
let relays = [
'wss://relay.nostr.example.mydomain.example.com',
'wss://nostr.banana.com'
]
let naddr = naddrEncode({
pubkey: pk,
relays,
kind: 30023,
identifier: 'banana'
})
expect(naddr).toMatch(/naddr1\w+/)
let {type, data} = decode(naddr)
expect(type).toEqual('naddr')
const pointer = data as AddressPointer
expect(pointer.pubkey).toEqual(pk)
expect(pointer.relays).toContain(relays[0])
expect(pointer.relays).toContain(relays[1])
expect(pointer.kind).toEqual(30023)
expect(pointer.identifier).toEqual('banana')
})
test('decode naddr from habla.news', () => {
let {type, data} = decode(
'naddr1qq98yetxv4ex2mnrv4esygrl54h466tz4v0re4pyuavvxqptsejl0vxcmnhfl60z3rth2xkpjspsgqqqw4rsf34vl5'
)
expect(type).toEqual('naddr')
const pointer = data as AddressPointer
expect(pointer.pubkey).toEqual(
'7fa56f5d6962ab1e3cd424e758c3002b8665f7b0d8dcee9fe9e288d7751ac194'
)
expect(pointer.kind).toEqual(30023)
expect(pointer.identifier).toEqual('references')
})
test('decode naddr from go-nostr with different TLV ordering', () => {
let {type, data} = decode(
'naddr1qqrxyctwv9hxzq3q80cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsxpqqqp65wqfwwaehxw309aex2mrp0yhxummnw3ezuetcv9khqmr99ekhjer0d4skjm3wv4uxzmtsd3jjucm0d5q3vamnwvaz7tmwdaehgu3wvfskuctwvyhxxmmd0zfmwx'
)
expect(type).toEqual('naddr')
const pointer = data as AddressPointer
expect(pointer.pubkey).toEqual(
'3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d'
)
expect(pointer.relays).toContain(
'wss://relay.nostr.example.mydomain.example.com'
)
expect(pointer.relays).toContain('wss://nostr.banana.com')
expect(pointer.kind).toEqual(30023)
expect(pointer.identifier).toEqual('banana')
})
test('encode and decode nrelay', () => {
let url = 'wss://relay.nostr.example'
let nrelay = nrelayEncode(url)
expect(nrelay).toMatch(/nrelay1\w+/)
let {type, data} = decode(nrelay)
expect(type).toEqual('nrelay')
expect(data).toEqual(url)
})

156
nip19.ts
View File

@@ -1,17 +1,10 @@
import {bytesToHex, concatBytes, hexToBytes} from '@noble/hashes/utils' import * as secp256k1 from '@noble/secp256k1'
import {bech32} from '@scure/base' import {bech32} from '@scure/base'
import {utf8Decoder, utf8Encoder} from './utils.ts' import {utf8Decoder, utf8Encoder} from './utils'
const Bech32MaxSize = 5000 const Bech32MaxSize = 5000
/**
* Bech32 regex.
* @see https://github.com/bitcoin/bips/blob/master/bip-0173.mediawiki#bech32
*/
export const BECH32_REGEX =
/[\x21-\x7E]{1,83}1[023456789acdefghjklmnpqrstuvwxyz]{6,}/
export type ProfilePointer = { export type ProfilePointer = {
pubkey: string // hex pubkey: string // hex
relays?: string[] relays?: string[]
@@ -20,43 +13,16 @@ export type ProfilePointer = {
export type EventPointer = { export type EventPointer = {
id: string // hex id: string // hex
relays?: string[] relays?: string[]
author?: string
} }
export type AddressPointer = { export function decode(nip19: string): {
identifier: string type: string
pubkey: string data: ProfilePointer | EventPointer | string
kind: number } {
relays?: string[]
}
type Prefixes = {
nprofile: ProfilePointer
nrelay: string
nevent: EventPointer
naddr: AddressPointer
nsec: string
npub: string
note: string
}
type DecodeValue<Prefix extends keyof Prefixes> = {
type: Prefix
data: Prefixes[Prefix]
}
export type DecodeResult = {
[P in keyof Prefixes]: DecodeValue<P>
}[keyof Prefixes]
export function decode<Prefix extends keyof Prefixes>(nip19: `${Prefix}1${string}`): DecodeValue<Prefix>
export function decode(nip19: string): DecodeResult
export function decode(nip19: string): DecodeResult {
let {prefix, words} = bech32.decode(nip19, Bech32MaxSize) let {prefix, words} = bech32.decode(nip19, Bech32MaxSize)
let data = new Uint8Array(bech32.fromWords(words)) let data = new Uint8Array(bech32.fromWords(words))
switch (prefix) { if (prefix === 'nprofile') {
case 'nprofile': {
let tlv = parseTLV(data) let tlv = parseTLV(data)
if (!tlv[0]?.[0]) throw new Error('missing TLV 0 for nprofile') 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') if (tlv[0][0].length !== 32) throw new Error('TLV 0 should be 32 bytes')
@@ -64,66 +30,32 @@ export function decode(nip19: string): DecodeResult {
return { return {
type: 'nprofile', type: 'nprofile',
data: { data: {
pubkey: bytesToHex(tlv[0][0]), pubkey: secp256k1.utils.bytesToHex(tlv[0][0]),
relays: tlv[1] ? tlv[1].map(d => utf8Decoder.decode(d)) : [] relays: tlv[1].map(d => utf8Decoder.decode(d))
} }
} }
} }
case 'nevent': {
if (prefix === 'nevent') {
let tlv = parseTLV(data) let tlv = parseTLV(data)
if (!tlv[0]?.[0]) throw new Error('missing TLV 0 for nevent') 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') if (tlv[0][0].length !== 32) throw new Error('TLV 0 should be 32 bytes')
if (tlv[2] && tlv[2][0].length !== 32)
throw new Error('TLV 2 should be 32 bytes')
return { return {
type: 'nevent', type: 'nevent',
data: { data: {
id: bytesToHex(tlv[0][0]), id: secp256k1.utils.bytesToHex(tlv[0][0]),
relays: tlv[1] ? tlv[1].map(d => utf8Decoder.decode(d)) : [], relays: tlv[1].map(d => utf8Decoder.decode(d))
author: tlv[2]?.[0] ? bytesToHex(tlv[2][0]) : undefined
} }
} }
} }
case 'naddr': { if (prefix === 'nsec' || prefix === 'npub' || prefix === 'note') {
let tlv = parseTLV(data) return {type: prefix, data: secp256k1.utils.bytesToHex(data)}
if (!tlv[0]?.[0]) throw new Error('missing TLV 0 for naddr')
if (!tlv[2]?.[0]) throw new Error('missing TLV 2 for naddr')
if (tlv[2][0].length !== 32) throw new Error('TLV 2 should be 32 bytes')
if (!tlv[3]?.[0]) throw new Error('missing TLV 3 for naddr')
if (tlv[3][0].length !== 4) throw new Error('TLV 3 should be 4 bytes')
return {
type: 'naddr',
data: {
identifier: utf8Decoder.decode(tlv[0][0]),
pubkey: bytesToHex(tlv[2][0]),
kind: parseInt(bytesToHex(tlv[3][0]), 16),
relays: tlv[1] ? tlv[1].map(d => utf8Decoder.decode(d)) : []
}
}
} }
case 'nrelay': {
let tlv = parseTLV(data)
if (!tlv[0]?.[0]) throw new Error('missing TLV 0 for nrelay')
return {
type: 'nrelay',
data: utf8Decoder.decode(tlv[0][0])
}
}
case 'nsec':
case 'npub':
case 'note':
return {type: prefix, data: bytesToHex(data)}
default:
throw new Error(`unknown prefix ${prefix}`) throw new Error(`unknown prefix ${prefix}`)
} }
}
type TLV = {[t: number]: Uint8Array[]} type TLV = {[t: number]: Uint8Array[]}
@@ -133,73 +65,49 @@ function parseTLV(data: Uint8Array): TLV {
while (rest.length > 0) { while (rest.length > 0) {
let t = rest[0] let t = rest[0]
let l = rest[1] let l = rest[1]
if (!l) throw new Error(`malformed TLV ${t}`)
let v = rest.slice(2, 2 + l) let v = rest.slice(2, 2 + l)
rest = rest.slice(2 + l) rest = rest.slice(2 + l)
if (v.length < l) throw new Error(`not enough data to read on TLV ${t}`) if (v.length < l) continue
result[t] = result[t] || [] result[t] = result[t] || []
result[t].push(v) result[t].push(v)
} }
return result return result
} }
export function nsecEncode(hex: string): `nsec1${string}` { export function nsecEncode(hex: string): string {
return encodeBytes('nsec', hex) return encodeBytes('nsec', hex)
} }
export function npubEncode(hex: string): `npub1${string}` { export function npubEncode(hex: string): string {
return encodeBytes('npub', hex) return encodeBytes('npub', hex)
} }
export function noteEncode(hex: string): `note1${string}` { export function noteEncode(hex: string): string {
return encodeBytes('note', hex) return encodeBytes('note', hex)
} }
function encodeBech32<Prefix extends string>(prefix: Prefix, data: Uint8Array): `${Prefix}1${string}` { function encodeBytes(prefix: string, hex: string): string {
let data = secp256k1.utils.hexToBytes(hex)
let words = bech32.toWords(data) let words = bech32.toWords(data)
return bech32.encode(prefix, words, Bech32MaxSize) as `${Prefix}1${string}` return bech32.encode(prefix, words, Bech32MaxSize)
} }
function encodeBytes<Prefix extends string>(prefix: Prefix, hex: string): `${Prefix}1${string}` { export function nprofileEncode(profile: ProfilePointer): string {
let data = hexToBytes(hex)
return encodeBech32(prefix, data)
}
export function nprofileEncode(profile: ProfilePointer): `nprofile1${string}` {
let data = encodeTLV({ let data = encodeTLV({
0: [hexToBytes(profile.pubkey)], 0: [secp256k1.utils.hexToBytes(profile.pubkey)],
1: (profile.relays || []).map(url => utf8Encoder.encode(url)) 1: (profile.relays || []).map(url => utf8Encoder.encode(url))
}) })
return encodeBech32('nprofile', data) let words = bech32.toWords(data)
return bech32.encode('nprofile', words, Bech32MaxSize)
} }
export function neventEncode(event: EventPointer): `nevent1${string}` { export function neventEncode(event: EventPointer): string {
let data = encodeTLV({ let data = encodeTLV({
0: [hexToBytes(event.id)], 0: [secp256k1.utils.hexToBytes(event.id)],
1: (event.relays || []).map(url => utf8Encoder.encode(url)), 1: (event.relays || []).map(url => utf8Encoder.encode(url))
2: event.author ? [hexToBytes(event.author)] : []
}) })
return encodeBech32('nevent', data) let words = bech32.toWords(data)
} return bech32.encode('nevent', words, Bech32MaxSize)
export function naddrEncode(addr: AddressPointer): `naddr1${string}` {
let kind = new ArrayBuffer(4)
new DataView(kind).setUint32(0, addr.kind, false)
let data = encodeTLV({
0: [utf8Encoder.encode(addr.identifier)],
1: (addr.relays || []).map(url => utf8Encoder.encode(url)),
2: [hexToBytes(addr.pubkey)],
3: [new Uint8Array(kind)]
})
return encodeBech32('naddr', data)
}
export function nrelayEncode(url: string): `nrelay1${string}` {
let data = encodeTLV({
0: [utf8Encoder.encode(url)]
})
return encodeBech32('nrelay', data)
} }
function encodeTLV(tlv: TLV): Uint8Array { function encodeTLV(tlv: TLV): Uint8Array {
@@ -215,5 +123,5 @@ function encodeTLV(tlv: TLV): Uint8Array {
}) })
}) })
return concatBytes(...entries) return secp256k1.utils.concatBytes(...entries)
} }

View File

@@ -1,41 +0,0 @@
import {test as testRegex, parse} from './nip21.ts'
test('test()', () => {
expect(
testRegex(
'nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6'
)
).toBe(true)
expect(
testRegex(
'nostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky'
)
).toBe(true)
expect(
testRegex(
' nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6'
)
).toBe(false)
expect(testRegex('nostr:')).toBe(false)
expect(
testRegex(
'nostr:npub108pv4cg5ag52nQq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6'
)
).toBe(false)
expect(testRegex('gggggg')).toBe(false)
})
test('parse', () => {
const result = parse(
'nostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky'
)
expect(result).toEqual({
uri: 'nostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
value: 'note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
decoded: {
type: 'note',
data: '46d731680add2990efe1cc619dc9b8014feeb23261ab9dee50e9d11814de5a2b'
}
})
})

View File

@@ -1,33 +0,0 @@
import {BECH32_REGEX, decode, type DecodeResult} from './nip19.ts'
/** Nostr URI regex, eg `nostr:npub1...` */
export const NOSTR_URI_REGEX = new RegExp(`nostr:(${BECH32_REGEX.source})`)
/** Test whether the value is a Nostr URI. */
export function test(value: unknown): value is `nostr:${string}` {
return (
typeof value === 'string' &&
new RegExp(`^${NOSTR_URI_REGEX.source}$`).test(value)
)
}
/** Parsed Nostr URI data. */
export interface NostrURI {
/** Full URI including the `nostr:` protocol. */
uri: `nostr:${string}`
/** The bech32-encoded data (eg `npub1...`). */
value: string
/** Decoded bech32 string, according to NIP-19. */
decoded: DecodeResult
}
/** Parse and decode a Nostr URI. */
export function parse(uri: string): NostrURI {
const match = uri.match(new RegExp(`^${NOSTR_URI_REGEX.source}$`))
if (!match) throw new Error(`Invalid Nostr URI: ${uri}`)
return {
uri: match[0] as `nostr:${string}`,
value: match[1],
decoded: decode(match[1])
}
}

View File

@@ -1,78 +0,0 @@
import {finishEvent, Kind} from './event.ts'
import {getPublicKey} from './keys.ts'
import {finishReactionEvent, getReactedEventPointer} from './nip25.ts'
describe('finishReactionEvent + getReactedEventPointer', () => {
const privateKey =
'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
const publicKey = getPublicKey(privateKey)
const reactedEvent = finishEvent(
{
kind: Kind.Text,
tags: [
['e', 'replied event id'],
['p', 'replied event pubkey']
],
content: 'Replied to a post',
created_at: 1617932115
},
privateKey
)
it('should create a signed event from a minimal template', () => {
const template = {
created_at: 1617932115
}
const event = finishReactionEvent(template, reactedEvent, privateKey)
expect(event.kind).toEqual(Kind.Reaction)
expect(event.tags).toEqual([
['e', 'replied event id'],
['p', 'replied event pubkey'],
['e', '0ecdbd4dba0652afb19e5f638257a41552a37995a4438ef63de658443f8d16b1'],
['p', '6af0f9de588f2c53cedcba26c5e2402e0d0aa64ec7b47c9f8d97b5bc562bab5f']
])
expect(event.content).toEqual('+')
expect(event.created_at).toEqual(template.created_at)
expect(event.pubkey).toEqual(publicKey)
expect(typeof event.id).toEqual('string')
expect(typeof event.sig).toEqual('string')
const reactedEventPointer = getReactedEventPointer(event)
expect(reactedEventPointer!.id).toEqual(reactedEvent.id)
expect(reactedEventPointer!.author).toEqual(reactedEvent.pubkey)
})
it('should create a signed event from a filled template', () => {
const template = {
tags: [['nonstandard', 'tag']],
content: '👍',
created_at: 1617932115
}
const event = finishReactionEvent(template, reactedEvent, privateKey)
expect(event.kind).toEqual(Kind.Reaction)
expect(event.tags).toEqual([
['nonstandard', 'tag'],
['e', 'replied event id'],
['p', 'replied event pubkey'],
['e', '0ecdbd4dba0652afb19e5f638257a41552a37995a4438ef63de658443f8d16b1'],
['p', '6af0f9de588f2c53cedcba26c5e2402e0d0aa64ec7b47c9f8d97b5bc562bab5f']
])
expect(event.content).toEqual('👍')
expect(event.created_at).toEqual(template.created_at)
expect(event.pubkey).toEqual(publicKey)
expect(typeof event.id).toEqual('string')
expect(typeof event.sig).toEqual('string')
const reactedEventPointer = getReactedEventPointer(event)
expect(reactedEventPointer!.id).toEqual(reactedEvent.id)
expect(reactedEventPointer!.author).toEqual(reactedEvent.pubkey)
})
})

View File

@@ -1,69 +0,0 @@
import {Event, finishEvent, Kind} from './event.ts'
import type {EventPointer} from './nip19.ts'
export type ReactionEventTemplate = {
/**
* Pass only non-nip25 tags if you have to. Nip25 tags ('e' and 'p' tags from reacted event) will be added automatically.
*/
tags?: string[][]
/**
* @default '+'
*/
content?: string
created_at: number
}
export function finishReactionEvent(
t: ReactionEventTemplate,
reacted: Event<number>,
privateKey: string,
): Event<Kind.Reaction> {
const inheritedTags = reacted.tags.filter(
(tag) => tag.length >= 2 && (tag[0] === 'e' || tag[0] === 'p'),
)
return finishEvent({
...t,
kind: Kind.Reaction,
tags: [
...(t.tags ?? []),
...inheritedTags,
['e', reacted.id],
['p', reacted.pubkey],
],
content: t.content ?? '+',
}, privateKey)
}
export function getReactedEventPointer(event: Event<number>): undefined | EventPointer {
if (event.kind !== Kind.Reaction) {
return undefined
}
let lastETag: undefined | string[]
let lastPTag: undefined | string[]
for (let i = event.tags.length - 1; i >= 0 && (lastETag === undefined || lastPTag === undefined); i--) {
const tag = event.tags[i]
if (tag.length >= 2) {
if (tag[0] === 'e' && lastETag === undefined) {
lastETag = tag
} else if (tag[0] === 'p' && lastPTag === undefined) {
lastPTag = tag
}
}
}
if (lastETag === undefined || lastPTag === undefined) {
return undefined
}
return {
id: lastETag[1],
relays: [ lastETag[2], lastPTag[2] ].filter((x) => x !== undefined),
author: lastPTag[1],
}
}

View File

@@ -1,10 +1,10 @@
import {getPublicKey, generatePrivateKey} from './keys.ts' /* eslint-env jest */
import {getDelegator, createDelegation} from './nip26.ts'
import {buildEvent} from './test-helpers.ts' const {nip26, getPublicKey, generatePrivateKey} = require('./lib/nostr.cjs')
test('parse good delegation from NIP', async () => { test('parse good delegation from NIP', async () => {
expect( expect(
getDelegator({ nip26.getDelegator({
id: 'a080fd288b60ac2225ff2e2d815291bd730911e583e177302cc949a15dc2b2dc', id: 'a080fd288b60ac2225ff2e2d815291bd730911e583e177302cc949a15dc2b2dc',
pubkey: pubkey:
'62903b1ff41559daf9ee98ef1ae67cc52f301bb5ce26d14baba3052f649c3f49', '62903b1ff41559daf9ee98ef1ae67cc52f301bb5ce26d14baba3052f649c3f49',
@@ -26,7 +26,7 @@ test('parse good delegation from NIP', async () => {
test('parse bad delegations', async () => { test('parse bad delegations', async () => {
expect( expect(
getDelegator({ nip26.getDelegator({
id: 'a080fd288b60ac2225ff2e2d815291bd730911e583e177302cc949a15dc2b2dc', id: 'a080fd288b60ac2225ff2e2d815291bd730911e583e177302cc949a15dc2b2dc',
pubkey: pubkey:
'62903b1ff41559daf9ee98ef1ae67cc52f301bb5ce26d14baba3052f649c3f49', '62903b1ff41559daf9ee98ef1ae67cc52f301bb5ce26d14baba3052f649c3f49',
@@ -46,7 +46,7 @@ test('parse bad delegations', async () => {
).toEqual(null) ).toEqual(null)
expect( expect(
getDelegator({ nip26.getDelegator({
id: 'a080fd288b60ac2225ff2e2d815291bd730911e583e177302cc949a15dc2b2dc', id: 'a080fd288b60ac2225ff2e2d815291bd730911e583e177302cc949a15dc2b2dc',
pubkey: pubkey:
'62903b1ff41559daf9ee98ef1ae67cc52f301bb5ce26d14baba3052f649c3f49', '62903b1ff41559daf9ee98ef1ae67cc52f301bb5ce26d14baba3052f649c3f49',
@@ -66,7 +66,7 @@ test('parse bad delegations', async () => {
).toEqual(null) ).toEqual(null)
expect( expect(
getDelegator({ nip26.getDelegator({
id: 'a080fd288b60ac2225ff2e2d815291bd730911e583e177302cc949a15dc2b2dc', id: 'a080fd288b60ac2225ff2e2d815291bd730911e583e177302cc949a15dc2b2dc',
pubkey: pubkey:
'62903b1ff41559daf9ee98ef1ae67c152f301bb5ce26d14baba3052f649c3f49', '62903b1ff41559daf9ee98ef1ae67c152f301bb5ce26d14baba3052f649c3f49',
@@ -91,15 +91,15 @@ test('create and verify delegation', async () => {
let pk1 = getPublicKey(sk1) let pk1 = getPublicKey(sk1)
let sk2 = generatePrivateKey() let sk2 = generatePrivateKey()
let pk2 = getPublicKey(sk2) let pk2 = getPublicKey(sk2)
let delegation = createDelegation(sk1, {pubkey: pk2, kind: 1}) let delegation = nip26.createDelegation(sk1, {pubkey: pk2, kind: 1})
expect(delegation).toHaveProperty('from', pk1) expect(delegation).toHaveProperty('from', pk1)
expect(delegation).toHaveProperty('to', pk2) expect(delegation).toHaveProperty('to', pk2)
expect(delegation).toHaveProperty('cond', 'kind=1') expect(delegation).toHaveProperty('cond', 'kind=1')
let event = buildEvent({ let event = {
kind: 1, kind: 1,
tags: [['delegation', delegation.from, delegation.cond, delegation.sig]], tags: [['delegation', delegation.from, delegation.cond, delegation.sig]],
pubkey: pk2, pubkey: pk2
}) }
expect(getDelegator(event)).toEqual(pk1) expect(nip26.getDelegator(event)).toEqual(pk1)
}) })

View File

@@ -1,17 +1,15 @@
import {schnorr} from '@noble/curves/secp256k1' import * as secp256k1 from '@noble/secp256k1'
import {bytesToHex} from '@noble/hashes/utils'
import {sha256} from '@noble/hashes/sha256' import {sha256} from '@noble/hashes/sha256'
import {utf8Encoder} from './utils.ts' import {Event} from './event'
import {getPublicKey} from './keys.ts' import {utf8Encoder} from './utils'
import {getPublicKey} from './keys'
import type {Event} from './event.ts'
export type Parameters = { export type Parameters = {
pubkey: string // the key to whom the delegation will be given pubkey: string // the key to whom the delegation will be given
kind?: number kind: number | undefined
until?: number // delegation will only be valid until this date until: number | undefined // delegation will only be valid until this date
since?: number // delegation will be valid from this date on since: number | undefined // delegation will be valid from this date on
} }
export type Delegation = { export type Delegation = {
@@ -38,8 +36,8 @@ export function createDelegation(
utf8Encoder.encode(`nostr:delegation:${parameters.pubkey}:${cond}`) utf8Encoder.encode(`nostr:delegation:${parameters.pubkey}:${cond}`)
) )
let sig = bytesToHex( let sig = secp256k1.utils.bytesToHex(
schnorr.sign(sighash, privateKey) secp256k1.schnorr.signSync(sighash, privateKey)
) )
return { return {
@@ -50,7 +48,7 @@ export function createDelegation(
} }
} }
export function getDelegator(event: Event<number>): string | null { export function getDelegator(event: Event): string | null {
// find delegation tag // find delegation tag
let tag = event.tags.find(tag => tag[0] === 'delegation' && tag.length >= 4) let tag = event.tags.find(tag => tag[0] === 'delegation' && tag.length >= 4)
if (!tag) return null if (!tag) return null
@@ -86,7 +84,7 @@ export function getDelegator(event: Event<number>): string | null {
let sighash = sha256( let sighash = sha256(
utf8Encoder.encode(`nostr:delegation:${event.pubkey}:${cond}`) utf8Encoder.encode(`nostr:delegation:${event.pubkey}:${cond}`)
) )
if (!schnorr.verify(sig, sighash, pubkey)) return null if (!secp256k1.schnorr.verifySync(sig, sighash, pubkey)) return null
return pubkey return pubkey
} }

View File

@@ -1,67 +0,0 @@
import {matchAll, replaceAll} from './nip27.ts'
test('matchAll', () => {
const result = matchAll(
'Hello nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6!\n\nnostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky'
)
expect([...result]).toEqual([
{
uri: 'nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6',
value: 'npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6',
decoded: {
type: 'npub',
data: '79c2cae114ea28a981e7559b4fe7854a473521a8d22a66bbab9fa248eb820ff6'
},
start: 6,
end: 75
},
{
uri: 'nostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
value: 'note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
decoded: {
type: 'note',
data: '46d731680add2990efe1cc619dc9b8014feeb23261ab9dee50e9d11814de5a2b'
},
start: 78,
end: 147
}
])
})
test('matchAll with an invalid nip19', () => {
const result = matchAll(
'Hello nostr:npub129tvj896hqqkljerxkccpj9flshwnw999v9uwn9lfmwlj8vnzwgq9y5llnpub1rujdpkd8mwezrvpqd2rx2zphfaztqrtsfg6w3vdnlj!\n\nnostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky'
)
expect([...result]).toEqual([
{
decoded: {
data: '46d731680add2990efe1cc619dc9b8014feeb23261ab9dee50e9d11814de5a2b',
type: 'note'
},
end: 193,
start: 124,
uri: 'nostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
value: 'note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky'
}
])
})
test('replaceAll', () => {
const content =
'Hello nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6!\n\nnostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky'
const result = replaceAll(content, ({decoded, value}) => {
switch (decoded.type) {
case 'npub':
return '@alex'
case 'note':
return '!1234'
default:
return value
}
})
expect(result).toEqual('Hello @alex!\n\n!1234')
})

View File

@@ -1,66 +0,0 @@
import {decode} from './nip19.ts'
import {NOSTR_URI_REGEX, type NostrURI} from './nip21.ts'
/** Regex to find NIP-21 URIs inside event content. */
export const regex = () => new RegExp(`\\b${NOSTR_URI_REGEX.source}\\b`, 'g')
/** Match result for a Nostr URI in event content. */
export interface NostrURIMatch extends NostrURI {
/** Index where the URI begins in the event content. */
start: number
/** Index where the URI ends in the event content. */
end: number
}
/** Find and decode all NIP-21 URIs. */
export function * matchAll(content: string): Iterable<NostrURIMatch> {
const matches = content.matchAll(regex())
for (const match of matches) {
try {
const [uri, value] = match
yield {
uri: uri as `nostr:${string}`,
value,
decoded: decode(value),
start: match.index!,
end: match.index! + uri.length
}
} catch (_e) {
// do nothing
}
}
}
/**
* Replace all occurrences of Nostr URIs in the text.
*
* WARNING: using this on an HTML string is potentially unsafe!
*
* @example
* ```ts
* nip27.replaceAll(event.content, ({ decoded, value }) => {
* switch(decoded.type) {
* case 'npub':
* return renderMention(decoded)
* case 'note':
* return renderNote(decoded)
* default:
* return value
* }
* })
* ```
*/
export function replaceAll(
content: string,
replacer: (match: NostrURI) => string
): string {
return content.replaceAll(regex(), (uri, value: string) => {
return replacer({
uri: uri as `nostr:${string}`,
value,
decoded: decode(value)
})
})
}

View File

@@ -1,134 +0,0 @@
import {Kind} from './event.ts'
import {getPublicKey} from './keys.ts'
import {
channelCreateEvent,
channelMetadataEvent,
channelMessageEvent,
channelHideMessageEvent,
channelMuteUserEvent,
ChannelMetadata,
ChannelMessageEventTemplate
} from './nip28.ts'
const privateKey =
'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
const publicKey = getPublicKey(privateKey)
describe('NIP-28 Functions', () => {
const channelMetadata: ChannelMetadata = {
name: 'Test Channel',
about: 'This is a test channel',
picture: 'https://example.com/picture.jpg'
}
it('channelCreateEvent should create an event with given template', () => {
const template = {
content: channelMetadata,
created_at: 1617932115
}
const event = channelCreateEvent(template, privateKey)
expect(event!.kind).toEqual(Kind.ChannelCreation)
expect(event!.content).toEqual(JSON.stringify(template.content))
expect(event!.pubkey).toEqual(publicKey)
})
it('channelMetadataEvent should create a signed event with given template', () => {
const template = {
channel_create_event_id: 'channel creation event id',
content: channelMetadata,
created_at: 1617932115
}
const event = channelMetadataEvent(template, privateKey)
expect(event!.kind).toEqual(Kind.ChannelMetadata)
expect(event!.tags).toEqual([['e', template.channel_create_event_id]])
expect(event!.content).toEqual(JSON.stringify(template.content))
expect(event!.pubkey).toEqual(publicKey)
expect(typeof event!.id).toEqual('string')
expect(typeof event!.sig).toEqual('string')
})
it('channelMessageEvent should create a signed message event with given template', () => {
const template = {
channel_create_event_id: 'channel creation event id',
relay_url: 'https://relay.example.com',
content: 'Hello, world!',
created_at: 1617932115
}
const event = channelMessageEvent(template, privateKey)
expect(event.kind).toEqual(Kind.ChannelMessage)
expect(event.tags[0]).toEqual([
'e',
template.channel_create_event_id,
template.relay_url,
'root'
])
expect(event.content).toEqual(template.content)
expect(event.pubkey).toEqual(publicKey)
expect(typeof event.id).toEqual('string')
expect(typeof event.sig).toEqual('string')
})
it('channelMessageEvent should create a signed message reply event with given template', () => {
const template: ChannelMessageEventTemplate = {
channel_create_event_id: 'channel creation event id',
reply_to_channel_message_event_id: 'channel message event id',
relay_url: 'https://relay.example.com',
content: 'Hello, world!',
created_at: 1617932115
}
const event = channelMessageEvent(template, privateKey)
expect(event.kind).toEqual(Kind.ChannelMessage)
expect(event.tags).toContainEqual([
'e',
template.channel_create_event_id,
template.relay_url,
'root'
])
expect(event.tags).toContainEqual([
'e',
template.reply_to_channel_message_event_id,
template.relay_url,
'reply'
])
expect(event.content).toEqual(template.content)
expect(event.pubkey).toEqual(publicKey)
expect(typeof event.id).toEqual('string')
expect(typeof event.sig).toEqual('string')
})
it('channelHideMessageEvent should create a signed event with given template', () => {
const template = {
channel_message_event_id: 'channel message event id',
content: {reason: 'Inappropriate content'},
created_at: 1617932115
}
const event = channelHideMessageEvent(template, privateKey)
expect(event!.kind).toEqual(Kind.ChannelHideMessage)
expect(event!.tags).toEqual([['e', template.channel_message_event_id]])
expect(event!.content).toEqual(JSON.stringify(template.content))
expect(event!.pubkey).toEqual(publicKey)
expect(typeof event!.id).toEqual('string')
expect(typeof event!.sig).toEqual('string')
})
it('channelMuteUserEvent should create a signed event with given template', () => {
const template = {
content: {reason: 'Spamming'},
created_at: 1617932115,
pubkey_to_mute: 'pubkey to mute'
}
const event = channelMuteUserEvent(template, privateKey)
expect(event!.kind).toEqual(Kind.ChannelMuteUser)
expect(event!.tags).toEqual([['p', template.pubkey_to_mute]])
expect(event!.content).toEqual(JSON.stringify(template.content))
expect(event!.pubkey).toEqual(publicKey)
expect(typeof event!.id).toEqual('string')
expect(typeof event!.sig).toEqual('string')
})
})

163
nip28.ts
View File

@@ -1,163 +0,0 @@
import {Event, finishEvent, Kind} from './event.ts'
export interface ChannelMetadata {
name: string
about: string
picture: string
}
export interface ChannelCreateEventTemplate {
/* JSON string containing ChannelMetadata as defined for Kind 40 and 41 in nip-28. */
content: string | ChannelMetadata
created_at: number
tags?: string[][]
}
export interface ChannelMetadataEventTemplate {
channel_create_event_id: string
/* JSON string containing ChannelMetadata as defined for Kind 40 and 41 in nip-28. */
content: string | ChannelMetadata
created_at: number
tags?: string[][]
}
export interface ChannelMessageEventTemplate {
channel_create_event_id: string
reply_to_channel_message_event_id?: string
relay_url: string
content: string
created_at: number
tags?: string[][]
}
export interface ChannelHideMessageEventTemplate {
channel_message_event_id: string
content: string | {reason: string}
created_at: number
tags?: string[][]
}
export interface ChannelMuteUserEventTemplate {
content: string | {reason: string}
created_at: number
pubkey_to_mute: string
tags?: string[][]
}
export const channelCreateEvent = (
t: ChannelCreateEventTemplate,
privateKey: string
): Event<Kind.ChannelCreation> | undefined => {
let content: string
if (typeof t.content === 'object') {
content = JSON.stringify(t.content)
} else if (typeof t.content === 'string') {
content = t.content
} else {
return undefined
}
return finishEvent(
{
kind: Kind.ChannelCreation,
tags: [...(t.tags ?? [])],
content: content,
created_at: t.created_at
},
privateKey
)
}
export const channelMetadataEvent = (
t: ChannelMetadataEventTemplate,
privateKey: string
): Event<Kind.ChannelMetadata> | undefined => {
let content: string
if (typeof t.content === 'object') {
content = JSON.stringify(t.content)
} else if (typeof t.content === 'string') {
content = t.content
} else {
return undefined
}
return finishEvent(
{
kind: Kind.ChannelMetadata,
tags: [['e', t.channel_create_event_id], ...(t.tags ?? [])],
content: content,
created_at: t.created_at
},
privateKey
)
}
export const channelMessageEvent = (
t: ChannelMessageEventTemplate,
privateKey: string
): Event<Kind.ChannelMessage> => {
const tags = [['e', t.channel_create_event_id, t.relay_url, 'root']]
if (t.reply_to_channel_message_event_id) {
tags.push(['e', t.reply_to_channel_message_event_id, t.relay_url, 'reply'])
}
return finishEvent(
{
kind: Kind.ChannelMessage,
tags: [...tags, ...(t.tags ?? [])],
content: t.content,
created_at: t.created_at
},
privateKey
)
}
/* "e" tag should be the kind 42 event to hide */
export const channelHideMessageEvent = (
t: ChannelHideMessageEventTemplate,
privateKey: string
): Event<Kind.ChannelHideMessage> | undefined => {
let content: string
if (typeof t.content === 'object') {
content = JSON.stringify(t.content)
} else if (typeof t.content === 'string') {
content = t.content
} else {
return undefined
}
return finishEvent(
{
kind: Kind.ChannelHideMessage,
tags: [['e', t.channel_message_event_id], ...(t.tags ?? [])],
content: content,
created_at: t.created_at
},
privateKey
)
}
export const channelMuteUserEvent = (
t: ChannelMuteUserEventTemplate,
privateKey: string
): Event<Kind.ChannelMuteUser> | undefined => {
let content: string
if (typeof t.content === 'object') {
content = JSON.stringify(t.content)
} else if (typeof t.content === 'string') {
content = t.content
} else {
return undefined
}
return finishEvent(
{
kind: Kind.ChannelMuteUser,
tags: [['p', t.pubkey_to_mute], ...(t.tags ?? [])],
content: content,
created_at: t.created_at
},
privateKey
)
}

View File

@@ -1,14 +0,0 @@
import fetch from 'node-fetch'
import {useFetchImplementation, validateGithub} from './nip39.ts'
test('validate github claim', async () => {
useFetchImplementation(fetch)
let result = await validateGithub(
'npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z',
'vitorpamplona',
'cf19e2d1d7f8dac6348ad37b35ec8421'
)
expect(result).toBe(true)
})

View File

@@ -1,27 +0,0 @@
var _fetch: any
try {
_fetch = fetch
} catch {}
export function useFetchImplementation(fetchImplementation: any) {
_fetch = fetchImplementation
}
export async function validateGithub(
pubkey: string,
username: string,
proof: string
): Promise<boolean> {
try {
let res = await (
await _fetch(`https://gist.github.com/${username}/${proof}/raw`)
).text()
return (
res ===
`Verifying that I control the following Nostr public key: ${pubkey}`
)
} catch (_) {
return false
}
}

View File

@@ -1,26 +0,0 @@
import 'websocket-polyfill'
import {finishEvent} from './event.ts'
import {generatePrivateKey} from './keys.ts'
import {authenticate} from './nip42.ts'
import {relayInit} from './relay.ts'
test('auth flow', () => {
const relay = relayInit('wss://nostr.kollider.xyz')
relay.connect()
const sk = generatePrivateKey()
return new Promise<void>((resolve) => {
relay.on('auth', async challenge => {
await expect(
authenticate({
challenge,
relay,
sign: (e) => finishEvent(e, sk)
})
).rejects.toBeTruthy()
relay.close()
resolve()
})
})
})

View File

@@ -1,34 +0,0 @@
import {Kind, type EventTemplate, type Event} from './event.ts'
import {Relay} from './relay.ts'
/**
* Authenticate via NIP-42 flow.
*
* @example
* const sign = window.nostr.signEvent
* relay.on('auth', challenge =>
* authenticate({ relay, sign, challenge })
* )
*/
export const authenticate = async ({
challenge,
relay,
sign
}: {
challenge: string
relay: Relay
sign: <K extends number = number>(
e: EventTemplate<K>
) => Promise<Event<K>> | Event<K>
}): Promise<void> => {
const e: EventTemplate = {
kind: Kind.ClientAuth,
created_at: Math.floor(Date.now() / 1000),
tags: [
['relay', relay.url],
['challenge', challenge]
],
content: ''
}
return relay.auth(await sign(e))
}

View File

@@ -1,21 +0,0 @@
import crypto from 'node:crypto'
import {hexToBytes} from '@noble/hashes/utils'
import {encrypt, decrypt, getSharedSecret} from './nip44.ts'
import {getPublicKey, generatePrivateKey} from './keys.ts'
// @ts-ignore
// eslint-disable-next-line no-undef
globalThis.crypto = crypto
test('encrypt and decrypt message', async () => {
let sk1 = generatePrivateKey()
let sk2 = generatePrivateKey()
let pk1 = getPublicKey(sk1)
let pk2 = getPublicKey(sk2)
let sharedKey1 = getSharedSecret(sk1, pk2)
let sharedKey2 = getSharedSecret(sk2, pk1)
expect(decrypt(hexToBytes(sk1), encrypt(hexToBytes(sk1), 'hello'))).toEqual('hello')
expect(decrypt(sharedKey2, encrypt(sharedKey1, 'hello'))).toEqual('hello')
})

View File

@@ -1,40 +0,0 @@
import {base64} from '@scure/base'
import {randomBytes} from '@noble/hashes/utils'
import {secp256k1} from '@noble/curves/secp256k1'
import {sha256} from '@noble/hashes/sha256'
import {xchacha20} from '@noble/ciphers/chacha'
import {utf8Decoder, utf8Encoder} from './utils.ts'
export const getSharedSecret = (privkey: string, pubkey: string): Uint8Array =>
sha256(secp256k1.getSharedSecret(privkey, '02' + pubkey).subarray(1, 33))
export function encrypt(key: Uint8Array, text: string, v = 1) {
if (v !== 1) {
throw new Error('NIP44: unknown encryption version')
}
const nonce = randomBytes(24)
const plaintext = utf8Encoder.encode(text)
const ciphertext = xchacha20(key, nonce, plaintext)
const payload = new Uint8Array(25 + ciphertext.length)
payload.set([v], 0)
payload.set(nonce, 1)
payload.set(ciphertext, 25)
return base64.encode(payload)
}
export function decrypt(key: Uint8Array, payload: string) {
let data = base64.decode(payload)
if (data[0] !== 1) {
throw new Error(`NIP44: unknown encryption version: ${data[0]}`)
}
const nonce = data.slice(1, 25)
const ciphertext = data.slice(25)
const plaintext = xchacha20(key, nonce, ciphertext)
return utf8Decoder.decode(plaintext)
}

View File

@@ -1,340 +0,0 @@
import {finishEvent} from './event.ts'
import {getPublicKey, generatePrivateKey} from './keys.ts'
import {
getZapEndpoint,
makeZapReceipt,
makeZapRequest,
useFetchImplementation,
validateZapRequest,
} from './nip57.ts'
import {buildEvent} from './test-helpers.ts'
describe('getZapEndpoint', () => {
test('returns null if neither lud06 nor lud16 is present', async () => {
const metadata = buildEvent({kind: 0, content: '{}'})
const result = await getZapEndpoint(metadata)
expect(result).toBeNull()
})
test('returns null if fetch fails', async () => {
const fetchImplementation = jest.fn(() => Promise.reject(new Error()))
useFetchImplementation(fetchImplementation)
const metadata = buildEvent({kind: 0, content: '{"lud16": "name@domain"}'})
const result = await getZapEndpoint(metadata)
expect(result).toBeNull()
expect(fetchImplementation).toHaveBeenCalledWith(
'https://domain/.well-known/lnurlp/name'
)
})
test('returns null if the response does not allow Nostr payments', async () => {
const fetchImplementation = jest.fn(() =>
Promise.resolve({json: () => ({allowsNostr: false})})
)
useFetchImplementation(fetchImplementation)
const metadata = buildEvent({kind: 0, content: '{"lud16": "name@domain"}'})
const result = await getZapEndpoint(metadata)
expect(result).toBeNull()
expect(fetchImplementation).toHaveBeenCalledWith(
'https://domain/.well-known/lnurlp/name'
)
})
test('returns the callback URL if the response allows Nostr payments', async () => {
const fetchImplementation = jest.fn(() =>
Promise.resolve({
json: () => ({
allowsNostr: true,
nostrPubkey: 'pubkey',
callback: 'callback'
})
})
)
useFetchImplementation(fetchImplementation)
const metadata = buildEvent({kind: 0, content: '{"lud16": "name@domain"}'})
const result = await getZapEndpoint(metadata)
expect(result).toBe('callback')
expect(fetchImplementation).toHaveBeenCalledWith(
'https://domain/.well-known/lnurlp/name'
)
})
})
describe('makeZapRequest', () => {
test('throws an error if amount is not given', () => {
expect(() =>
// @ts-expect-error
makeZapRequest({
profile: 'profile',
event: null,
relays: [],
comment: ''
})
).toThrow()
})
test('throws an error if profile is not given', () => {
expect(() =>
// @ts-expect-error
makeZapRequest({
event: null,
amount: 100,
relays: [],
comment: ''
})
).toThrow()
})
test('returns a valid Zap request', () => {
const result = makeZapRequest({
profile: 'profile',
event: 'event',
amount: 100,
relays: ['relay1', 'relay2'],
comment: 'comment'
})
expect(result.kind).toBe(9734)
expect(result.created_at).toBeCloseTo(Date.now() / 1000, 0)
expect(result.content).toBe('comment')
expect(result.tags).toEqual(
expect.arrayContaining([
['p', 'profile'],
['amount', '100'],
['relays', 'relay1', 'relay2']
])
)
expect(result.tags).toContainEqual(['e', 'event'])
})
})
describe('validateZapRequest', () => {
test('returns an error message for invalid JSON', () => {
expect(validateZapRequest('invalid JSON')).toBe(
'Invalid zap request JSON.'
)
})
test('returns an error message if the Zap request is not a valid Nostr event', () => {
const zapRequest = {
kind: 1234,
created_at: Date.now() / 1000,
content: 'content',
tags: [
['p', 'profile'],
['amount', '100'],
['relays', 'relay1', 'relay2']
]
}
expect(validateZapRequest(JSON.stringify(zapRequest))).toBe(
'Zap request is not a valid Nostr event.'
)
})
test('returns an error message if the signature on the Zap request is invalid', () => {
const privateKey = generatePrivateKey()
const publicKey = getPublicKey(privateKey)
const zapRequest = {
pubkey: publicKey,
kind: 9734,
created_at: Date.now() / 1000,
content: 'content',
tags: [
['p', publicKey],
['amount', '100'],
['relays', 'relay1', 'relay2']
]
}
expect(validateZapRequest(JSON.stringify(zapRequest))).toBe(
'Invalid signature on zap request.'
)
})
test('returns an error message if the Zap request does not have a "p" tag', () => {
const privateKey = generatePrivateKey()
const zapRequest = finishEvent(
{
kind: 9734,
created_at: Date.now() / 1000,
content: 'content',
tags: [
['amount', '100'],
['relays', 'relay1', 'relay2']
]
},
privateKey
)
expect(validateZapRequest(JSON.stringify(zapRequest))).toBe(
"Zap request doesn't have a 'p' tag."
)
})
test('returns an error message if the "p" tag on the Zap request is not valid hex', () => {
const privateKey = generatePrivateKey()
const zapRequest = finishEvent(
{
kind: 9734,
created_at: Date.now() / 1000,
content: 'content',
tags: [
['p', 'invalid hex'],
['amount', '100'],
['relays', 'relay1', 'relay2']
]
},
privateKey
)
expect(validateZapRequest(JSON.stringify(zapRequest))).toBe(
"Zap request 'p' tag is not valid hex."
)
})
test('returns an error message if the "e" tag on the Zap request is not valid hex', () => {
const privateKey = generatePrivateKey()
const publicKey = getPublicKey(privateKey)
const zapRequest = finishEvent(
{
kind: 9734,
created_at: Date.now() / 1000,
content: 'content',
tags: [
['p', publicKey],
['e', 'invalid hex'],
['amount', '100'],
['relays', 'relay1', 'relay2']
]
},
privateKey
)
expect(validateZapRequest(JSON.stringify(zapRequest))).toBe(
"Zap request 'e' tag is not valid hex."
)
})
test('returns an error message if the Zap request does not have a relays tag', () => {
const privateKey = generatePrivateKey()
const publicKey = getPublicKey(privateKey)
const zapRequest = finishEvent(
{
kind: 9734,
created_at: Date.now() / 1000,
content: 'content',
tags: [
['p', publicKey],
['amount', '100']
]
},
privateKey
)
expect(validateZapRequest(JSON.stringify(zapRequest))).toBe(
"Zap request doesn't have a 'relays' tag."
)
})
test('returns null for a valid Zap request', () => {
const privateKey = generatePrivateKey()
const publicKey = getPublicKey(privateKey)
const zapRequest = finishEvent(
{
kind: 9734,
created_at: Date.now() / 1000,
content: 'content',
tags: [
['p', publicKey],
['amount', '100'],
['relays', 'relay1', 'relay2']
]
},
privateKey
)
expect(validateZapRequest(JSON.stringify(zapRequest))).toBeNull()
})
})
describe('makeZapReceipt', () => {
test('returns a valid Zap receipt with a preimage', () => {
const privateKey = generatePrivateKey()
const publicKey = getPublicKey(privateKey)
const zapRequest = JSON.stringify(
finishEvent(
{
kind: 9734,
created_at: Date.now() / 1000,
content: 'content',
tags: [
['p', publicKey],
['amount', '100'],
['relays', 'relay1', 'relay2']
]
},
privateKey
)
)
const preimage = 'preimage'
const bolt11 = 'bolt11'
const paidAt = new Date()
const result = makeZapReceipt({zapRequest, preimage, bolt11, paidAt})
expect(result.kind).toBe(9735)
expect(result.created_at).toBeCloseTo(paidAt.getTime() / 1000, 0)
expect(result.content).toBe('')
expect(result.tags).toContainEqual(['bolt11', bolt11])
expect(result.tags).toContainEqual(['description', zapRequest])
expect(result.tags).toContainEqual(['p', publicKey])
expect(result.tags).toContainEqual(['preimage', preimage])
})
test('returns a valid Zap receipt without a preimage', () => {
const privateKey = generatePrivateKey()
const publicKey = getPublicKey(privateKey)
const zapRequest = JSON.stringify(
finishEvent(
{
kind: 9734,
created_at: Date.now() / 1000,
content: 'content',
tags: [
['p', publicKey],
['amount', '100'],
['relays', 'relay1', 'relay2']
]
},
privateKey
)
)
const bolt11 = 'bolt11'
const paidAt = new Date()
const result = makeZapReceipt({zapRequest, bolt11, paidAt})
expect(result.kind).toBe(9735)
expect(result.created_at).toBeCloseTo(paidAt.getTime() / 1000, 0)
expect(result.content).toBe('')
expect(result.tags).toContainEqual(['bolt11', bolt11])
expect(result.tags).toContainEqual(['description', zapRequest])
expect(result.tags).toContainEqual(['p', publicKey])
expect(result.tags).not.toContain('preimage')
})
})

147
nip57.ts
View File

@@ -1,147 +0,0 @@
import {bech32} from '@scure/base'
import {
Kind,
validateEvent,
verifySignature,
type Event,
type EventTemplate,
} from './event.ts'
import {utf8Decoder} from './utils.ts'
var _fetch: any
try {
_fetch = fetch
} catch {}
export function useFetchImplementation(fetchImplementation: any) {
_fetch = fetchImplementation
}
export async function getZapEndpoint(
metadata: Event<Kind.Metadata>
): Promise<null | string> {
try {
let lnurl: string = ''
let {lud06, lud16} = JSON.parse(metadata.content)
if (lud06) {
let {words} = bech32.decode(lud06, 1000)
let data = bech32.fromWords(words)
lnurl = utf8Decoder.decode(data)
} else if (lud16) {
let [name, domain] = lud16.split('@')
lnurl = `https://${domain}/.well-known/lnurlp/${name}`
} else {
return null
}
let res = await _fetch(lnurl)
let body = await res.json()
if (body.allowsNostr && body.nostrPubkey) {
return body.callback
}
} catch (err) {
/*-*/
}
return null
}
export function makeZapRequest({
profile,
event,
amount,
relays,
comment = ''
}: {
profile: string
event: string | null
amount: number
comment: string
relays: string[]
}): EventTemplate<Kind.ZapRequest> {
if (!amount) throw new Error('amount not given')
if (!profile) throw new Error('profile not given')
let zr: EventTemplate<Kind.ZapRequest> = {
kind: 9734,
created_at: Math.round(Date.now() / 1000),
content: comment,
tags: [
['p', profile],
['amount', amount.toString()],
['relays', ...relays]
]
}
if (event) {
zr.tags.push(['e', event])
}
return zr
}
export function validateZapRequest(zapRequestString: string): string | null {
let zapRequest: Event
try {
zapRequest = JSON.parse(zapRequestString)
} catch (err) {
return 'Invalid zap request JSON.'
}
if (!validateEvent(zapRequest))
return 'Zap request is not a valid Nostr event.'
if (!verifySignature(zapRequest)) return 'Invalid signature on zap request.'
let p = zapRequest.tags.find(([t, v]) => t === 'p' && v)
if (!p) return "Zap request doesn't have a 'p' tag."
if (!p[1].match(/^[a-f0-9]{64}$/))
return "Zap request 'p' tag is not valid hex."
let e = zapRequest.tags.find(([t, v]) => t === 'e' && v)
if (e && !e[1].match(/^[a-f0-9]{64}$/))
return "Zap request 'e' tag is not valid hex."
let relays = zapRequest.tags.find(([t, v]) => t === 'relays' && v)
if (!relays) return "Zap request doesn't have a 'relays' tag."
return null
}
export function makeZapReceipt({
zapRequest,
preimage,
bolt11,
paidAt
}: {
zapRequest: string
preimage?: string
bolt11: string
paidAt: Date
}): EventTemplate<Kind.Zap> {
let zr: Event<Kind.ZapRequest> = JSON.parse(zapRequest)
let tagsFromZapRequest = zr.tags.filter(
([t]) => t === 'e' || t === 'p' || t === 'a'
)
let zap: EventTemplate<Kind.Zap> = {
kind: 9735,
created_at: Math.round(paidAt.getTime() / 1000),
content: '',
tags: [
...tagsFromZapRequest,
['bolt11', bolt11],
['description', zapRequest]
]
}
if (preimage) {
zap.tags.push(['preimage', preimage])
}
return zap
}

View File

@@ -1,170 +0,0 @@
import {getToken, unpackEventFromToken, validateEvent, validateToken} from './nip98.ts'
import {Event, Kind, finishEvent} from './event.ts'
import {generatePrivateKey, getPublicKey} from './keys.ts'
const sk = generatePrivateKey()
describe('getToken', () => {
test('getToken GET returns without authorization scheme', async () => {
let result = await getToken('http://test.com', 'get', e =>
finishEvent(e, sk)
)
const decodedResult: Event = await unpackEventFromToken(result)
expect(decodedResult.created_at).toBeGreaterThan(0)
expect(decodedResult.content).toBe('')
expect(decodedResult.kind).toBe(Kind.HttpAuth)
expect(decodedResult.pubkey).toBe(getPublicKey(sk))
expect(decodedResult.tags).toStrictEqual([
['u', 'http://test.com'],
['method', 'get']
])
})
test('getToken POST returns token without authorization scheme', async () => {
let result = await getToken('http://test.com', 'post', e =>
finishEvent(e, sk)
)
const decodedResult: Event = await unpackEventFromToken(result)
expect(decodedResult.created_at).toBeGreaterThan(0)
expect(decodedResult.content).toBe('')
expect(decodedResult.kind).toBe(Kind.HttpAuth)
expect(decodedResult.pubkey).toBe(getPublicKey(sk))
expect(decodedResult.tags).toStrictEqual([
['u', 'http://test.com'],
['method', 'post']
])
})
test('getToken GET returns token WITH authorization scheme', async () => {
const authorizationScheme = 'Nostr '
let result = await getToken(
'http://test.com',
'post',
e => finishEvent(e, sk),
true
)
expect(result.startsWith(authorizationScheme)).toBe(true)
const decodedResult: Event = await unpackEventFromToken(result)
expect(decodedResult.created_at).toBeGreaterThan(0)
expect(decodedResult.content).toBe('')
expect(decodedResult.kind).toBe(Kind.HttpAuth)
expect(decodedResult.pubkey).toBe(getPublicKey(sk))
expect(decodedResult.tags).toStrictEqual([
['u', 'http://test.com'],
['method', 'post']
])
})
test('getToken unknown method throws an error', async () => {
const result = getToken('http://test.com', 'fake', e => finishEvent(e, sk))
await expect(result).rejects.toThrow(Error)
})
test('getToken missing loginUrl throws an error', async () => {
const result = getToken('', 'get', e => finishEvent(e, sk))
await expect(result).rejects.toThrow(Error)
})
test('getToken missing httpMethod throws an error', async () => {
const result = getToken('http://test.com', '', e => finishEvent(e, sk))
await expect(result).rejects.toThrow(Error)
})
})
describe('validateToken', () => {
test('validateToken returns true for valid token without authorization scheme', async () => {
const validToken = await getToken('http://test.com', 'get', e =>
finishEvent(e, sk)
)
const result = await validateToken(validToken, 'http://test.com', 'get')
expect(result).toBe(true)
})
test('validateToken returns true for valid token with authorization scheme', async () => {
const validToken = await getToken(
'http://test.com',
'get',
e => finishEvent(e, sk),
true
)
const result = await validateToken(validToken, 'http://test.com', 'get')
expect(result).toBe(true)
})
test('validateToken throws an error for invalid token', async () => {
const result = validateToken('fake', 'http://test.com', 'get')
await expect(result).rejects.toThrow(Error)
})
test('validateToken throws an error for missing token', async () => {
const result = validateToken('', 'http://test.com', 'get')
await expect(result).rejects.toThrow(Error)
})
test('validateToken throws an error for a wrong url', async () => {
const validToken = await getToken('http://test.com', 'get', e =>
finishEvent(e, sk)
)
const result = validateToken(validToken, 'http://wrong-test.com', 'get')
await expect(result).rejects.toThrow(Error)
})
test('validateToken throws an error for a wrong method', async () => {
const validToken = await getToken('http://test.com', 'get', e =>
finishEvent(e, sk)
)
const result = validateToken(validToken, 'http://test.com', 'post')
await expect(result).rejects.toThrow(Error)
})
test('validateEvent returns true for valid decoded token with authorization scheme', async () => {
const validToken = await getToken(
'http://test.com',
'get',
e => finishEvent(e, sk),
true
)
const decodedResult: Event = await unpackEventFromToken(validToken)
const result = await validateEvent(decodedResult, 'http://test.com', 'get')
expect(result).toBe(true)
})
test('validateEvent throws an error for a wrong url', async () => {
const validToken = await getToken(
'http://test.com',
'get',
e => finishEvent(e, sk),
true
)
const decodedResult: Event = await unpackEventFromToken(validToken)
const result = validateEvent(decodedResult, 'http://wrong-test.com', 'get')
await expect(result).rejects.toThrow(Error)
})
test('validateEvent throws an error for a wrong method', async () => {
const validToken = await getToken(
'http://test.com',
'get',
e => finishEvent(e, sk),
true
)
const decodedResult: Event = await unpackEventFromToken(validToken)
const result = validateEvent(decodedResult, 'http://test.com', 'post')
await expect(result).rejects.toThrow(Error)
})
})

121
nip98.ts
View File

@@ -1,121 +0,0 @@
import {base64} from '@scure/base'
import {
Event,
EventTemplate,
Kind,
getBlankEvent,
verifySignature
} from './event'
import {utf8Decoder, utf8Encoder} from './utils'
const _authorizationScheme = 'Nostr '
/**
* Generate token for NIP-98 flow.
*
* @example
* const sign = window.nostr.signEvent
* await nip98.getToken('https://example.com/login', 'post', (e) => sign(e), true)
*/
export async function getToken(
loginUrl: string,
httpMethod: string,
sign: <K extends number = number>(
e: EventTemplate<K>
) => Promise<Event<K>> | Event<K>,
includeAuthorizationScheme: boolean = false
): Promise<string> {
if (!loginUrl || !httpMethod)
throw new Error('Missing loginUrl or httpMethod')
const event = getBlankEvent(Kind.HttpAuth)
event.tags = [
['u', loginUrl],
['method', httpMethod]
]
event.created_at = Math.round(new Date().getTime() / 1000)
const signedEvent = await sign(event)
const authorizationScheme = includeAuthorizationScheme
? _authorizationScheme
: ''
return (
authorizationScheme +
base64.encode(utf8Encoder.encode(JSON.stringify(signedEvent)))
)
}
/**
* Validate token for NIP-98 flow.
*
* @example
* await nip98.validateToken('Nostr base64token', 'https://example.com/login', 'post')
*/
export async function validateToken(
token: string,
url: string,
method: string
): Promise<boolean> {
const event = await unpackEventFromToken(token).catch((error) => { throw(error) })
const valid = await validateEvent(event, url, method).catch((error) => { throw(error) })
return valid
}
export async function unpackEventFromToken(token: string): Promise<Event> {
if (!token) {
throw new Error('Missing token')
}
token = token.replace(_authorizationScheme, '')
const eventB64 = utf8Decoder.decode(base64.decode(token))
if (!eventB64 || eventB64.length === 0 || !eventB64.startsWith('{')) {
throw new Error('Invalid token')
}
const event = JSON.parse(eventB64) as Event
return event
}
export async function validateEvent(
event: Event,
url: string,
method: string
): Promise<boolean> {
if (!event) {
throw new Error('Invalid nostr event')
}
if (!verifySignature(event)) {
throw new Error('Invalid nostr event, signature invalid')
}
if (event.kind !== Kind.HttpAuth) {
throw new Error('Invalid nostr event, kind invalid')
}
if (!event.created_at) {
throw new Error('Invalid nostr event, created_at invalid')
}
// Event must be less than 60 seconds old
if (Math.round(new Date().getTime() / 1000) - event.created_at > 60) {
throw new Error('Invalid nostr event, expired')
}
const urlTag = event.tags.find(t => t[0] === 'u')
if (urlTag?.length !== 1 && urlTag?.[1] !== url) {
throw new Error('Invalid nostr event, url tag invalid')
}
const methodTag = event.tags.find(t => t[0] === 'method')
if (
methodTag?.length !== 1 &&
methodTag?.[1].toLowerCase() !== method.toLowerCase()
) {
throw new Error('Invalid nostr event, method tag invalid')
}
return true
}

View File

@@ -1,30 +1,19 @@
{ {
"name": "nostr-tools", "name": "nostr-tools",
"version": "1.14.2", "version": "1.3.2",
"description": "Tools for making a Nostr client.", "description": "Tools for making a Nostr client.",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://github.com/nbd-wtf/nostr-tools.git" "url": "https://github.com/fiatjaf/nostr-tools.git"
}, },
"files": [
"./lib/**/*"
],
"types": "./lib/index.d.ts",
"main": "lib/nostr.cjs.js", "main": "lib/nostr.cjs.js",
"module": "lib/esm/nostr.mjs", "module": "lib/nostr.esm.js",
"exports": {
"import": "./lib/esm/nostr.mjs",
"require": "./lib/nostr.cjs.js",
"types": "./lib/index.d.ts"
},
"license": "Unlicense",
"dependencies": { "dependencies": {
"@noble/curves": "1.1.0", "@noble/hashes": "^0.5.7",
"@noble/hashes": "1.3.1", "@noble/secp256k1": "^1.7.0",
"@noble/ciphers": "^0.2.0", "@scure/base": "^1.1.1",
"@scure/base": "1.1.1", "@scure/bip32": "^1.1.1",
"@scure/bip32": "1.3.1", "@scure/bip39": "^1.1.0"
"@scure/bip39": "1.2.1"
}, },
"keywords": [ "keywords": [
"decentralization", "decentralization",
@@ -33,30 +22,21 @@
"client", "client",
"nostr" "nostr"
], ],
"scripts": {
"build": "node build",
"format": "prettier --plugin-search-dir . --write .",
"test": "jest"
},
"devDependencies": { "devDependencies": {
"@types/jest": "^29.5.1", "@types/node": "^18.0.3",
"@types/node": "^18.13.0", "@typescript-eslint/eslint-plugin": "^5.46.1",
"@types/node-fetch": "^2.6.3", "@typescript-eslint/parser": "^5.46.1",
"@typescript-eslint/eslint-plugin": "^5.51.0",
"@typescript-eslint/parser": "^5.51.0",
"esbuild": "0.16.9", "esbuild": "0.16.9",
"esbuild-plugin-alias": "^0.2.1", "esbuild-plugin-alias": "^0.2.1",
"eslint": "^8.40.0", "eslint": "^8.30.0",
"eslint-plugin-babel": "^5.3.1", "eslint-plugin-babel": "^5.3.1",
"eslint-plugin-jest": "^27.2.1", "esm-loader-typescript": "^1.0.1",
"esm-loader-typescript": "^1.0.3",
"events": "^3.3.0", "events": "^3.3.0",
"jest": "^29.5.0", "jest": "^29.3.1",
"node-fetch": "^2.6.9", "node-fetch": "2",
"prettier": "^2.8.4", "ts-jest": "^29.0.3",
"ts-jest": "^29.1.0",
"tsd": "^0.22.0", "tsd": "^0.22.0",
"typescript": "^5.0.4", "typescript": "^4.9.4",
"websocket-polyfill": "^0.0.3" "websocket-polyfill": "^0.0.3"
} }
} }

View File

@@ -1,23 +1,28 @@
import 'websocket-polyfill' /* eslint-env jest */
import {finishEvent, type Event} from './event.ts' require('websocket-polyfill')
import {generatePrivateKey, getPublicKey} from './keys.ts' const {
import {SimplePool} from './pool.ts' SimplePool,
generatePrivateKey,
getPublicKey,
getEventHash,
signEvent
} = require('./lib/nostr.cjs')
let pool = new SimplePool() let pool = new SimplePool()
let relays = [ let relays = [
'wss://relay.damus.io/', 'wss://nostr-dev.wellorder.net/',
'wss://relay.nostr.bg/', 'wss://relay.nostr.bg/',
'wss://nostr.fmt.wiz.biz/', 'wss://nostr.fmt.wiz.biz/',
'wss://relay.nostr.band/', 'wss://relay.nostr.band/',
'wss://nos.lol/' 'wss://nostr.zebedee.cloud/'
] ]
afterAll(() => { afterAll(async () => {
pool.close([ await pool.close([
...relays, ...relays,
'wss://nostr.wine', 'wss://nostr-relay.untethr.me',
'wss://offchain.pub', 'wss://offchain.pub',
'wss://eden.nostr.land' 'wss://eden.nostr.land'
]) ])
@@ -28,7 +33,7 @@ test('removing duplicates when querying', async () => {
let pub = getPublicKey(priv) let pub = getPublicKey(priv)
let sub = pool.sub(relays, [{authors: [pub]}]) let sub = pool.sub(relays, [{authors: [pub]}])
let received: Event[] = [] let received = []
sub.on('event', event => { sub.on('event', event => {
// this should be called only once even though we're listening // this should be called only once even though we're listening
@@ -37,12 +42,15 @@ test('removing duplicates when querying', async () => {
received.push(event) received.push(event)
}) })
let event = finishEvent({ let event = {
pubkey: pub,
created_at: Math.round(Date.now() / 1000), created_at: Math.round(Date.now() / 1000),
content: 'test', content: 'test',
kind: 22345, kind: 22345,
tags: [] tags: []
}, priv) }
event.id = getEventHash(event)
event.sig = signEvent(event, priv)
pool.publish(relays, event) pool.publish(relays, event)
@@ -58,7 +66,7 @@ test('same with double querying', async () => {
let sub1 = pool.sub(relays, [{authors: [pub]}]) let sub1 = pool.sub(relays, [{authors: [pub]}])
let sub2 = pool.sub(relays, [{authors: [pub]}]) let sub2 = pool.sub(relays, [{authors: [pub]}])
let received: Event[] = [] let received = []
sub1.on('event', event => { sub1.on('event', event => {
received.push(event) received.push(event)
@@ -68,12 +76,15 @@ test('same with double querying', async () => {
received.push(event) received.push(event)
}) })
let event = finishEvent({ let event = {
pubkey: pub,
created_at: Math.round(Date.now() / 1000), created_at: Math.round(Date.now() / 1000),
content: 'test2', content: 'test2',
kind: 22346, kind: 22346,
tags: [] tags: []
}, priv) }
event.id = getEventHash(event)
event.sig = signEvent(event, priv)
pool.publish(relays, event) pool.publish(relays, event)
@@ -111,7 +122,6 @@ test('list()', async () => {
expect(events.length).toEqual( expect(events.length).toEqual(
events events
.map(evt => evt.id) .map(evt => evt.id)
// @ts-ignore ???
.reduce((acc, n) => (acc.indexOf(n) !== -1 ? acc : [...acc, n]), []) .reduce((acc, n) => (acc.indexOf(n) !== -1 ? acc : [...acc, n]), [])
.length .length
) )
@@ -121,20 +131,3 @@ test('list()', async () => {
.reduce((acc, n) => acc.concat(n), []) .reduce((acc, n) => acc.concat(n), [])
expect(relaysForAllEvents.length).toBeGreaterThanOrEqual(events.length) expect(relaysForAllEvents.length).toBeGreaterThanOrEqual(events.length)
}) })
test('seenOnEnabled: false', async () => {
const poolWithoutSeenOn = new SimplePool({seenOnEnabled: false})
const event = await poolWithoutSeenOn.get(relays, {
ids: ['d7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027']
})
expect(event).toHaveProperty(
'id',
'd7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027'
)
const relaysForEvent = poolWithoutSeenOn.seenOn(event!.id)
expect(relaysForEvent).toHaveLength(0)
})

202
pool.ts
View File

@@ -1,89 +1,51 @@
import { import {Relay, relayInit} from './relay'
relayInit, import {normalizeURL} from './utils'
type Relay, import {Filter} from './filter'
type Sub, import {Event} from './event'
type SubscriptionOptions import {SubscriptionOptions, Sub, Pub} from './relay'
} from './relay.ts'
import {normalizeURL} from './utils.ts'
import type {Event} from './event.ts'
import {matchFilters, type Filter} from './filter.ts'
type BatchedRequest = {
filters: Filter<any>[]
relays: string[]
resolve: (events: Event<any>[]) => void
events: Event<any>[]
}
export class SimplePool { export class SimplePool {
private _conn: {[url: string]: Relay} private _conn: {[url: string]: Relay}
private _seenOn: {[id: string]: Set<string>} = {} // a map of all events we've seen in each relay private _seenOn: {[id: string]: Set<string>} = {} // a map of all events we've seen in each relay
private batchedByKey: {[batchKey: string]: BatchedRequest[]} = {}
private eoseSubTimeout: number constructor() {
private getTimeout: number
private seenOnEnabled: boolean = true
private batchInterval: number = 100
constructor(
options: {
eoseSubTimeout?: number
getTimeout?: number
seenOnEnabled?: boolean
batchInterval?: number
} = {}
) {
this._conn = {} this._conn = {}
this.eoseSubTimeout = options.eoseSubTimeout || 3400
this.getTimeout = options.getTimeout || 3400
this.seenOnEnabled = options.seenOnEnabled !== false
this.batchInterval = options.batchInterval || 100
} }
close(relays: string[]): void { async close(relays: string[]): Promise<void> {
relays.forEach(url => { await Promise.all(
relays.map(async url => {
let relay = this._conn[normalizeURL(url)] let relay = this._conn[normalizeURL(url)]
if (relay) relay.close() if (relay) await relay.close()
}) })
)
} }
async ensureRelay(url: string): Promise<Relay> { async ensureRelay(url: string): Promise<Relay> {
const nm = normalizeURL(url) const nm = normalizeURL(url)
const existing = this._conn[nm]
if (existing) return existing
if (!this._conn[nm]) { const relay = relayInit(nm)
this._conn[nm] = relayInit(nm, { this._conn[nm] = relay
getTimeout: this.getTimeout * 0.9,
listTimeout: this.getTimeout * 0.9
})
}
const relay = this._conn[nm]
await relay.connect() await relay.connect()
return relay return relay
} }
sub<K extends number = number>( sub(relays: string[], filters: Filter[], opts?: SubscriptionOptions): Sub {
relays: string[],
filters: Filter<K>[],
opts?: SubscriptionOptions
): Sub<K> {
let _knownIds: Set<string> = new Set() let _knownIds: Set<string> = new Set()
let modifiedOpts = {...(opts || {})} let modifiedOpts = opts || {}
modifiedOpts.alreadyHaveEvent = (id, url) => { modifiedOpts.alreadyHaveEvent = (id, url) => {
if (opts?.alreadyHaveEvent?.(id, url)) {
return true
}
if (this.seenOnEnabled) {
let set = this._seenOn[id] || new Set() let set = this._seenOn[id] || new Set()
set.add(url) set.add(url)
this._seenOn[id] = set this._seenOn[id] = set
}
return _knownIds.has(id) return _knownIds.has(id)
} }
let subs: Sub[] = [] let subs: Sub[] = []
let eventListeners: Set<any> = new Set() let eventListeners: Set<(event: Event) => void> = new Set()
let eoseListeners: Set<() => void> = new Set() let eoseListeners: Set<() => void> = new Set()
let eosesMissing = relays.length let eosesMissing = relays.length
@@ -91,37 +53,26 @@ export class SimplePool {
let eoseTimeout = setTimeout(() => { let eoseTimeout = setTimeout(() => {
eoseSent = true eoseSent = true
for (let cb of eoseListeners.values()) cb() for (let cb of eoseListeners.values()) cb()
}, this.eoseSubTimeout) }, 2400)
relays relays.forEach(async relay => {
.filter((r, i, a) => a.indexOf(r) == i) let r = await this.ensureRelay(relay)
.forEach(async relay => {
let r
try {
r = await this.ensureRelay(relay)
} catch (err) {
handleEose()
return
}
if (!r) return if (!r) return
let s = r.sub(filters, modifiedOpts) let s = r.sub(filters, modifiedOpts)
s.on('event', event => { s.on('event', (event: Event) => {
_knownIds.add(event.id as string) _knownIds.add(event.id as string)
for (let cb of eventListeners.values()) cb(event) for (let cb of eventListeners.values()) cb(event)
}) })
s.on('eose', () => { s.on('eose', () => {
if (eoseSent) return if (eoseSent) return
handleEose()
})
subs.push(s)
function handleEose() {
eosesMissing-- eosesMissing--
if (eosesMissing === 0) { if (eosesMissing === 0) {
clearTimeout(eoseTimeout) clearTimeout(eoseTimeout)
for (let cb of eoseListeners.values()) cb() for (let cb of eoseListeners.values()) cb()
} }
} })
subs.push(s)
}) })
let greaterSub: Sub = { let greaterSub: Sub = {
@@ -133,35 +84,37 @@ export class SimplePool {
subs.forEach(sub => sub.unsub()) subs.forEach(sub => sub.unsub())
}, },
on(type, cb) { on(type, cb) {
if (type === 'event') { switch (type) {
case 'event':
eventListeners.add(cb) eventListeners.add(cb)
} else if (type === 'eose') { break
eoseListeners.add(cb as () => void | Promise<void>) case 'eose':
eoseListeners.add(cb)
break
} }
}, },
off(type, cb) { off(type, cb) {
if (type === 'event') { if (type === 'event') {
eventListeners.delete(cb) eventListeners.delete(cb)
} else if (type === 'eose') } else if (type === 'eose') eoseListeners.delete(cb)
eoseListeners.delete(cb as () => void | Promise<void>)
} }
} }
return greaterSub return greaterSub
} }
get<K extends number = number>( get(
relays: string[], relays: string[],
filter: Filter<K>, filter: Filter,
opts?: SubscriptionOptions opts?: SubscriptionOptions
): Promise<Event<K> | null> { ): Promise<Event | null> {
return new Promise(resolve => { return new Promise(resolve => {
let sub = this.sub(relays, [filter], opts) let sub = this.sub(relays, [filter], opts)
let timeout = setTimeout(() => { let timeout = setTimeout(() => {
sub.unsub() sub.unsub()
resolve(null) resolve(null)
}, this.getTimeout) }, 1500)
sub.on('event', event => { sub.on('event', (event: Event) => {
resolve(event) resolve(event)
clearTimeout(timeout) clearTimeout(timeout)
sub.unsub() sub.unsub()
@@ -169,16 +122,16 @@ export class SimplePool {
}) })
} }
list<K extends number = number>( list(
relays: string[], relays: string[],
filters: Filter<K>[], filters: Filter[],
opts?: SubscriptionOptions opts?: SubscriptionOptions
): Promise<Event<K>[]> { ): Promise<Event[]> {
return new Promise(resolve => { return new Promise(resolve => {
let events: Event<K>[] = [] let events: Event[] = []
let sub = this.sub(relays, filters, opts) let sub = this.sub(relays, filters, opts)
sub.on('event', event => { sub.on('event', (event: Event) => {
events.push(event) events.push(event)
}) })
@@ -190,62 +143,12 @@ export class SimplePool {
}) })
} }
batchedList<K extends number = number>( publish(relays: string[], event: Event): Pub[] {
batchKey: string, return relays.map(relay => {
relays: string[], let r = this._conn[normalizeURL(relay)]
filters: Filter<K>[] if (!r) return badPub(relay)
): Promise<Event<K>[]> { let s = r.publish(event)
return new Promise(resolve => { return s
if (!this.batchedByKey[batchKey]) {
this.batchedByKey[batchKey] = [
{
filters,
relays,
resolve,
events: []
}
]
setTimeout(() => {
Object.keys(this.batchedByKey).forEach(async batchKey => {
const batchedRequests = this.batchedByKey[batchKey]
const filters = [] as Filter[]
const relays = [] as string[]
batchedRequests.forEach(br => {
filters.push(...br.filters)
relays.push(...br.relays)
})
const sub = this.sub(relays, filters)
sub.on('event', event => {
batchedRequests.forEach(
br => matchFilters(br.filters, event) && br.events.push(event)
)
})
sub.on('eose', () => {
sub.unsub()
batchedRequests.forEach(br => br.resolve(br.events))
})
delete this.batchedByKey[batchKey]
})
}, this.batchInterval)
} else {
this.batchedByKey[batchKey].push({
filters,
relays,
resolve,
events: []
})
}
})
}
publish(relays: string[], event: Event<number>): Promise<void>[] {
return relays.map(async relay => {
let r = await this.ensureRelay(relay)
return r.publish(event)
}) })
} }
@@ -253,3 +156,12 @@ export class SimplePool {
return Array.from(this._seenOn[id]?.values?.() || []) return Array.from(this._seenOn[id]?.values?.() || [])
} }
} }
function badPub(relay: string): Pub {
return {
on(typ, cb) {
if (typ === 'failed') cb(`relay ${relay} not connected`)
},
off() {}
}
}

View File

@@ -1,61 +0,0 @@
import {parseReferences} from './references.ts'
import {buildEvent} from './test-helpers.ts'
test('parse mentions', () => {
let evt = buildEvent({
tags: [
[
'p',
'c9d556c6d3978d112d30616d0d20aaa81410e3653911dd67787b5aaf9b36ade8',
'wss://nostr.com'
],
[
'e',
'a84c5de86efc2ec2cff7bad077c4171e09146b633b7ad117fffe088d9579ac33',
'wss://other.com',
'reply'
],
[
'e',
'31d7c2875b5fc8e6f9c8f9dc1f84de1b6b91d1947ea4c59225e55c325d330fa8',
''
]
],
content:
'hello #[0], have you seen #[2]? it was made by nostr:nprofile1qqsvc6ulagpn7kwrcwdqgp797xl7usumqa6s3kgcelwq6m75x8fe8yc5usxdg on nostr:nevent1qqsvc6ulagpn7kwrcwdqgp797xl7usumqa6s3kgcelwq6m75x8fe8ychxp5v4! broken #[3]',
})
expect(parseReferences(evt)).toEqual([
{
text: '#[0]',
profile: {
pubkey:
'c9d556c6d3978d112d30616d0d20aaa81410e3653911dd67787b5aaf9b36ade8',
relays: ['wss://nostr.com']
}
},
{
text: '#[2]',
event: {
id: '31d7c2875b5fc8e6f9c8f9dc1f84de1b6b91d1947ea4c59225e55c325d330fa8',
relays: []
}
},
{
text: 'nostr:nprofile1qqsvc6ulagpn7kwrcwdqgp797xl7usumqa6s3kgcelwq6m75x8fe8yc5usxdg',
profile: {
pubkey:
'cc6b9fea033f59c3c39a0407c5f1bfee439b077508d918cfdc0d6fd431d39393',
relays: []
}
},
{
text: 'nostr:nevent1qqsvc6ulagpn7kwrcwdqgp797xl7usumqa6s3kgcelwq6m75x8fe8ychxp5v4',
event: {
id: 'cc6b9fea033f59c3c39a0407c5f1bfee439b077508d918cfdc0d6fd431d39393',
relays: [],
author: undefined
}
}
])
})

View File

@@ -1,110 +0,0 @@
import {
decode,
type AddressPointer,
type ProfilePointer,
type EventPointer,
} from './nip19.ts'
import type {Event} from './event.ts'
type Reference = {
text: string
profile?: ProfilePointer
event?: EventPointer
address?: AddressPointer
}
const mentionRegex =
/\bnostr:((note|npub|naddr|nevent|nprofile)1\w+)\b|#\[(\d+)\]/g
export function parseReferences(evt: Event): Reference[] {
let references: Reference[] = []
for (let ref of evt.content.matchAll(mentionRegex)) {
if (ref[2]) {
// it's a NIP-27 mention
try {
let {type, data} = decode(ref[1])
switch (type) {
case 'npub': {
references.push({
text: ref[0],
profile: {pubkey: data as string, relays: []}
})
break
}
case 'nprofile': {
references.push({
text: ref[0],
profile: data as ProfilePointer
})
break
}
case 'note': {
references.push({
text: ref[0],
event: {id: data as string, relays: []}
})
break
}
case 'nevent': {
references.push({
text: ref[0],
event: data as EventPointer
})
break
}
case 'naddr': {
references.push({
text: ref[0],
address: data as AddressPointer
})
break
}
}
} catch (err) {
/***/
}
} else if (ref[3]) {
// it's a NIP-10 mention
let idx = parseInt(ref[3], 10)
let tag = evt.tags[idx]
if (!tag) continue
switch (tag[0]) {
case 'p': {
references.push({
text: ref[0],
profile: {pubkey: tag[1], relays: tag[2] ? [tag[2]] : []}
})
break
}
case 'e': {
references.push({
text: ref[0],
event: {id: tag[1], relays: tag[2] ? [tag[2]] : []}
})
break
}
case 'a': {
try {
let [kind, pubkey, identifier] = tag[1].split(':')
references.push({
text: ref[0],
address: {
identifier,
pubkey,
kind: parseInt(kind, 10),
relays: tag[2] ? [tag[2]] : []
}
})
} catch (err) {
/***/
}
break
}
}
}
}
return references
}

View File

@@ -1,17 +1,22 @@
import 'websocket-polyfill' /* eslint-env jest */
import {finishEvent} from './event.ts' require('websocket-polyfill')
import {generatePrivateKey, getPublicKey} from './keys.ts' const {
import {relayInit} from './relay.ts' relayInit,
generatePrivateKey,
getPublicKey,
getEventHash,
signEvent
} = require('./lib/nostr.cjs')
let relay = relayInit('wss://relay.damus.io/') let relay = relayInit('wss://nostr-dev.wellorder.net/')
beforeAll(() => { beforeAll(() => {
relay.connect() relay.connect()
}) })
afterAll(() => { afterAll(async () => {
relay.close() await relay.close()
}) })
test('connectivity', () => { test('connectivity', () => {
@@ -28,8 +33,8 @@ test('connectivity', () => {
}) })
test('querying', async () => { test('querying', async () => {
var resolve1: (value: boolean) => void var resolve1
var resolve2: (value: boolean) => void var resolve2
let sub = relay.sub([ let sub = relay.sub([
{ {
@@ -48,10 +53,10 @@ test('querying', async () => {
}) })
let [t1, t2] = await Promise.all([ let [t1, t2] = await Promise.all([
new Promise<boolean>(resolve => { new Promise(resolve => {
resolve1 = resolve resolve1 = resolve
}), }),
new Promise<boolean>(resolve => { new Promise(resolve => {
resolve2 = resolve resolve2 = resolve
}) })
]) ])
@@ -88,8 +93,8 @@ test('list()', async () => {
test('listening (twice) and publishing', async () => { test('listening (twice) and publishing', async () => {
let sk = generatePrivateKey() let sk = generatePrivateKey()
let pk = getPublicKey(sk) let pk = getPublicKey(sk)
var resolve1: (value: boolean) => void var resolve1
var resolve2: (value: boolean) => void var resolve2
let sub = relay.sub([ let sub = relay.sub([
{ {
@@ -111,12 +116,15 @@ test('listening (twice) and publishing', async () => {
resolve2(true) resolve2(true)
}) })
let event = finishEvent({ let event = {
kind: 27572, kind: 27572,
pubkey: pk,
created_at: Math.floor(Date.now() / 1000), created_at: Math.floor(Date.now() / 1000),
tags: [], tags: [],
content: 'nostr-tools test suite' content: 'nostr-tools test suite'
}, sk) }
event.id = getEventHash(event)
event.sig = signEvent(event, sk)
relay.publish(event) relay.publish(event)
return expect( return expect(

320
relay.ts
View File

@@ -1,153 +1,109 @@
/* global WebSocket */ /* global WebSocket */
import {verifySignature, validateEvent, type Event} from './event.ts' import {Event, verifySignature, validateEvent} from './event'
import {matchFilters, type Filter} from './filter.ts' import {Filter, matchFilters} from './filter'
import {getHex64, getSubscriptionId} from './fakejson.ts' import {getHex64, getSubscriptionId} from './fakejson'
import {MessageQueue} from './utils.ts'
type RelayEvent = 'connect' | 'disconnect' | 'error' | 'notice'
type RelayEvent = {
connect: () => void | Promise<void>
disconnect: () => void | Promise<void>
error: () => void | Promise<void>
notice: (msg: string) => void | Promise<void>
auth: (challenge: string) => void | Promise<void>
}
export type CountPayload = {
count: number
}
type SubEvent<K extends number> = {
event: (event: Event<K>) => void | Promise<void>
count: (payload: CountPayload) => void | Promise<void>
eose: () => void | Promise<void>
}
export type Relay = { export type Relay = {
url: string url: string
status: number status: number
connect: () => Promise<void> connect: () => Promise<void>
close: () => void close: () => Promise<void>
sub: <K extends number = number>( sub: (filters: Filter[], opts?: SubscriptionOptions) => Sub
filters: Filter<K>[], list: (filters: Filter[], opts?: SubscriptionOptions) => Promise<Event[]>
opts?: SubscriptionOptions get: (filter: Filter, opts?: SubscriptionOptions) => Promise<Event | null>
) => Sub<K> publish: (event: Event) => Pub
list: <K extends number = number>( on: (type: RelayEvent, cb: any) => void
filters: Filter<K>[], off: (type: RelayEvent, cb: any) => void
opts?: SubscriptionOptions
) => Promise<Event<K>[]>
get: <K extends number = number>(
filter: Filter<K>,
opts?: SubscriptionOptions
) => Promise<Event<K> | null>
count: (
filters: Filter[],
opts?: SubscriptionOptions
) => Promise<CountPayload | null>
publish: (event: Event<number>) => Promise<void>
auth: (event: Event<number>) => Promise<void>
off: <T extends keyof RelayEvent, U extends RelayEvent[T]>(
event: T,
listener: U
) => void
on: <T extends keyof RelayEvent, U extends RelayEvent[T]>(
event: T,
listener: U
) => void
} }
export type Sub<K extends number = number> = { export type Pub = {
sub: <K extends number = number>( on: (type: 'ok' | 'seen' | 'failed', cb: any) => void
filters: Filter<K>[], off: (type: 'ok' | 'seen' | 'failed', cb: any) => void
opts: SubscriptionOptions }
) => Sub<K> export type Sub = {
sub: (filters: Filter[], opts: SubscriptionOptions) => Sub
unsub: () => void unsub: () => void
on: <T extends keyof SubEvent<K>, U extends SubEvent<K>[T]>( on: (type: 'event' | 'eose', cb: any) => void
event: T, off: (type: 'event' | 'eose', cb: any) => void
listener: U
) => void
off: <T extends keyof SubEvent<K>, U extends SubEvent<K>[T]>(
event: T,
listener: U
) => void
} }
export type SubscriptionOptions = { export type SubscriptionOptions = {
id?: string id?: string
verb?: 'REQ' | 'COUNT'
skipVerification?: boolean skipVerification?: boolean
alreadyHaveEvent?: null | ((id: string, relay: string) => boolean) alreadyHaveEvent?: null | ((id: string, relay: string) => boolean)
} }
const newListeners = (): {[TK in keyof RelayEvent]: RelayEvent[TK][]} => ({ export function relayInit(url: string): Relay {
var ws: WebSocket
var resolveClose: () => void
var setOpen: (value: PromiseLike<void> | void) => void
var untilOpen = new Promise<void>(resolve => {
setOpen = resolve
})
var openSubs: {[id: string]: {filters: Filter[]} & SubscriptionOptions} = {}
var listeners: {
connect: Array<() => void>
disconnect: Array<() => void>
error: Array<() => void>
notice: Array<(msg: string) => void>
} = {
connect: [], connect: [],
disconnect: [], disconnect: [],
error: [], error: [],
notice: [], notice: []
auth: [] }
})
export function relayInit(
url: string,
options: {
getTimeout?: number
listTimeout?: number
countTimeout?: number
} = {}
): Relay {
let {listTimeout = 3000, getTimeout = 3000, countTimeout = 3000} = options
var ws: WebSocket
var openSubs: {[id: string]: {filters: Filter[]} & SubscriptionOptions} = {}
var listeners = newListeners()
var subListeners: { var subListeners: {
[subid: string]: {[TK in keyof SubEvent<any>]: SubEvent<any>[TK][]} [subid: string]: {
event: Array<(event: Event) => void>
eose: Array<() => void>
}
} = {} } = {}
var pubListeners: { var pubListeners: {
[eventid: string]: { [eventid: string]: {
resolve: (_: unknown) => void ok: Array<() => void>
reject: (err: Error) => void seen: Array<() => void>
failed: Array<(reason: string) => void>
} }
} = {} } = {}
var connectionPromise: Promise<void> | undefined
async function connectRelay(): Promise<void> { async function connectRelay(): Promise<void> {
if (connectionPromise) return connectionPromise return new Promise((resolve, reject) => {
connectionPromise = new Promise((resolve, reject) => {
try {
ws = new WebSocket(url) ws = new WebSocket(url)
} catch (err) {
reject(err)
}
ws.onopen = () => { ws.onopen = () => {
listeners.connect.forEach(cb => cb()) listeners.connect.forEach(cb => cb())
setOpen()
resolve() resolve()
} }
ws.onerror = () => { ws.onerror = () => {
connectionPromise = undefined
listeners.error.forEach(cb => cb()) listeners.error.forEach(cb => cb())
reject() reject()
} }
ws.onclose = async () => { ws.onclose = async () => {
connectionPromise = undefined
listeners.disconnect.forEach(cb => cb()) listeners.disconnect.forEach(cb => cb())
resolveClose && resolveClose()
} }
let incomingMessageQueue: MessageQueue = new MessageQueue() let incomingMessageQueue: string[] = []
let handleNextInterval: any let handleNextInterval: any
ws.onmessage = e => { ws.onmessage = e => {
incomingMessageQueue.enqueue(e.data) incomingMessageQueue.push(e.data)
if (!handleNextInterval) { if (!handleNextInterval) {
handleNextInterval = setInterval(handleNext, 0) handleNextInterval = setInterval(handleNext, 0)
} }
} }
function handleNext() { function handleNext() {
if (incomingMessageQueue.size === 0) { if (incomingMessageQueue.length === 0) {
clearInterval(handleNextInterval) clearInterval(handleNextInterval)
handleNextInterval = null handleNextInterval = null
return return
} }
var json = incomingMessageQueue.dequeue() var json = incomingMessageQueue.shift()
if (!json) return if (!json) return
let subid = getSubscriptionId(json) let subid = getSubscriptionId(json)
@@ -169,7 +125,7 @@ export function relayInit(
// will naturally be caught by the encompassing try..catch block // will naturally be caught by the encompassing try..catch block
switch (data[0]) { switch (data[0]) {
case 'EVENT': { case 'EVENT':
let id = data[1] let id = data[1]
let event = data[2] let event = data[2]
if ( if (
@@ -182,69 +138,40 @@ export function relayInit(
;(subListeners[id]?.event || []).forEach(cb => cb(event)) ;(subListeners[id]?.event || []).forEach(cb => cb(event))
} }
return return
}
case 'COUNT':
let id = data[1]
let payload = data[2]
if (openSubs[id]) {
;(subListeners[id]?.count || []).forEach(cb => cb(payload))
}
return
case 'EOSE': { case 'EOSE': {
let id = data[1] let id = data[1]
if (id in subListeners) { ;(subListeners[id]?.eose || []).forEach(cb => cb())
subListeners[id].eose.forEach(cb => cb())
subListeners[id].eose = [] // 'eose' only happens once per sub, so stop listeners here
}
return return
} }
case 'OK': { case 'OK': {
let id: string = data[1] let id: string = data[1]
let ok: boolean = data[2] let ok: boolean = data[2]
let reason: string = data[3] || '' let reason: string = data[3] || ''
if (id in pubListeners) { if (ok) pubListeners[id]?.ok.forEach(cb => cb())
let {resolve, reject} = pubListeners[id] else pubListeners[id]?.failed.forEach(cb => cb(reason))
if (ok) resolve(null)
else reject(new Error(reason))
}
return return
} }
case 'NOTICE': case 'NOTICE':
let notice = data[1] let notice = data[1]
listeners.notice.forEach(cb => cb(notice)) listeners.notice.forEach(cb => cb(notice))
return return
case 'AUTH': {
let challenge = data[1]
listeners.auth?.forEach(cb => cb(challenge))
return
}
} }
} catch (err) { } catch (err) {
return return
} }
} }
}) })
return connectionPromise
}
function connected() {
return ws?.readyState === 1
} }
async function connect(): Promise<void> { async function connect(): Promise<void> {
if (connected()) return // ws already open if (ws?.readyState && ws.readyState === 1) return // ws already open
await connectRelay() await connectRelay()
} }
async function trySend(params: [string, ...any]) { async function trySend(params: [string, ...any]) {
let msg = JSON.stringify(params) let msg = JSON.stringify(params)
if (!connected()) {
await new Promise(resolve => setTimeout(resolve, 1000)) await untilOpen
if (!connected()) {
return
}
}
try { try {
ws.send(msg) ws.send(msg)
} catch (err) { } catch (err) {
@@ -252,15 +179,14 @@ export function relayInit(
} }
} }
const sub = <K extends number = number>( const sub = (
filters: Filter<K>[], filters: Filter[],
{ {
verb = 'REQ',
skipVerification = false, skipVerification = false,
alreadyHaveEvent = null, alreadyHaveEvent = null,
id = Math.random().toString().slice(2) id = Math.random().toString().slice(2)
}: SubscriptionOptions = {} }: SubscriptionOptions = {}
): Sub<K> => { ): Sub => {
let subid = id let subid = id
openSubs[subid] = { openSubs[subid] = {
@@ -269,7 +195,7 @@ export function relayInit(
skipVerification, skipVerification,
alreadyHaveEvent alreadyHaveEvent
} }
trySend([verb, subid, ...filters]) trySend(['REQ', subid, ...filters])
return { return {
sub: (newFilters, newOpts = {}) => sub: (newFilters, newOpts = {}) =>
@@ -283,15 +209,14 @@ export function relayInit(
delete subListeners[subid] delete subListeners[subid]
trySend(['CLOSE', subid]) trySend(['CLOSE', subid])
}, },
on: (type, cb) => { on: (type: 'event' | 'eose', cb: any): void => {
subListeners[subid] = subListeners[subid] || { subListeners[subid] = subListeners[subid] || {
event: [], event: [],
count: [],
eose: [] eose: []
} }
subListeners[subid][type].push(cb) subListeners[subid][type].push(cb)
}, },
off: (type, cb): void => { off: (type: 'event' | 'eose', cb: any): void => {
let listeners = subListeners[subid] let listeners = subListeners[subid]
let idx = listeners[type].indexOf(cb) let idx = listeners[type].indexOf(cb)
if (idx >= 0) listeners[type].splice(idx, 1) if (idx >= 0) listeners[type].splice(idx, 1)
@@ -299,96 +224,111 @@ export function relayInit(
} }
} }
function _publishEvent(event: Event<number>, type: string) {
return new Promise((resolve, reject) => {
if (!event.id) {
reject(new Error(`event ${event} has no id`))
return
}
let id = event.id
trySend([type, event])
pubListeners[id] = {resolve, reject}
})
}
return { return {
url, url,
sub, sub,
on: <T extends keyof RelayEvent, U extends RelayEvent[T]>( on: (type: RelayEvent, cb: any): void => {
type: T,
cb: U
): void => {
listeners[type].push(cb) listeners[type].push(cb)
if (type === 'connect' && ws?.readyState === 1) { if (type === 'connect' && ws?.readyState === 1) {
// i would love to know why we need this cb()
;(cb as () => void)()
} }
}, },
off: <T extends keyof RelayEvent, U extends RelayEvent[T]>( off: (type: RelayEvent, cb: any): void => {
type: T,
cb: U
): void => {
let index = listeners[type].indexOf(cb) let index = listeners[type].indexOf(cb)
if (index !== -1) listeners[type].splice(index, 1) if (index !== -1) listeners[type].splice(index, 1)
}, },
list: (filters, opts?: SubscriptionOptions) => list: (filters: Filter[], opts?: SubscriptionOptions): Promise<Event[]> =>
new Promise(resolve => { new Promise(resolve => {
let s = sub(filters, opts) let s = sub(filters, opts)
let events: Event<any>[] = [] let events: Event[] = []
let timeout = setTimeout(() => { let timeout = setTimeout(() => {
s.unsub() s.unsub()
resolve(events) resolve(events)
}, listTimeout) }, 1500)
s.on('eose', () => { s.on('eose', () => {
s.unsub() s.unsub()
clearTimeout(timeout) clearTimeout(timeout)
resolve(events) resolve(events)
}) })
s.on('event', event => { s.on('event', (event: Event) => {
events.push(event) events.push(event)
}) })
}), }),
get: (filter, opts?: SubscriptionOptions) => get: (filter: Filter, opts?: SubscriptionOptions): Promise<Event | null> =>
new Promise(resolve => { new Promise(resolve => {
let s = sub([filter], opts) let s = sub([filter], opts)
let timeout = setTimeout(() => { let timeout = setTimeout(() => {
s.unsub() s.unsub()
resolve(null) resolve(null)
}, getTimeout) }, 1500)
s.on('event', event => { s.on('event', (event: Event) => {
s.unsub() s.unsub()
clearTimeout(timeout) clearTimeout(timeout)
resolve(event) resolve(event)
}) })
}), }),
count: (filters: Filter[]): Promise<CountPayload | null> => publish(event: Event): Pub {
new Promise(resolve => { if (!event.id) throw new Error(`event ${event} has no id`)
let s = sub(filters, {...sub, verb: 'COUNT'}) let id = event.id
let timeout = setTimeout(() => {
s.unsub() var sent = false
resolve(null) var mustMonitor = false
}, countTimeout)
s.on('count', (event: CountPayload) => { trySend(['EVENT', event])
s.unsub() .then(() => {
clearTimeout(timeout) sent = true
resolve(event) if (mustMonitor) {
startMonitoring()
mustMonitor = false
}
}) })
}), .catch(() => {})
async publish(event): Promise<void> {
await _publishEvent(event, 'EVENT') 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
}
}, },
async auth(event): Promise<void> { off: (type: 'ok' | 'seen' | 'failed', cb: any) => {
await _publishEvent(event, 'AUTH') let listeners = pubListeners[id]
if (!listeners) return
let idx = listeners[type].indexOf(cb)
if (idx >= 0) listeners[type].splice(idx, 1)
}
}
}, },
connect, connect,
close(): void { close(): Promise<void> {
listeners = newListeners() if (ws.readyState > 1) return Promise.resolve()
subListeners = {}
pubListeners = {}
if (ws?.readyState === WebSocket.OPEN) {
ws.close() ws.close()
} return new Promise<void>(resolve => {
resolveClose = resolve
})
}, },
get status() { get status() {
return ws?.readyState ?? 3 return ws?.readyState ?? 3

View File

@@ -1,17 +0,0 @@
import type {Event} from './event.ts'
type EventParams<K extends number> = Partial<Event<K>>
/** Build an event for testing purposes. */
export function buildEvent<K extends number = 1>(params: EventParams<K>): Event<K> {
return {
id: '',
kind: 1 as K,
pubkey: '',
created_at: 0,
content: '',
tags: [],
sig: '',
...params
}
}

View File

@@ -9,8 +9,7 @@
"skipLibCheck": true, "skipLibCheck": true,
"esModuleInterop": true, "esModuleInterop": true,
"emitDeclarationOnly": true, "emitDeclarationOnly": true,
"outDir": "lib", "outDir": "dist",
"rootDir": ".", "rootDir": "."
"allowImportingTsExtensions": true
} }
} }

183
utils.test.js Normal file
View File

@@ -0,0 +1,183 @@
/* eslint-env jest */
const {utils} = require('./lib/nostr.cjs')
const {insertEventIntoAscendingList, insertEventIntoDescendingList} = utils
describe('inserting into a desc sorted list of events', () => {
test('insert into an empty list', async () => {
const list0 = []
expect(
insertEventIntoDescendingList(list0, {id: 'abc', created_at: 10})
).toHaveLength(1)
})
test('insert in the beginning of a list', async () => {
const list0 = [{created_at: 20}, {created_at: 10}]
const list1 = insertEventIntoDescendingList(list0, {
id: 'abc',
created_at: 30
})
expect(list1).toHaveLength(3)
expect(list1[0].id).toBe('abc')
})
test('insert in the beginning of a list with same created_at', async () => {
const list0 = [{created_at: 30}, {created_at: 20}, {created_at: 10}]
const list1 = insertEventIntoDescendingList(list0, {
id: 'abc',
created_at: 30
})
expect(list1).toHaveLength(4)
expect(list1[0].id).toBe('abc')
})
test('insert in the middle of a list', async () => {
const list0 = [
{created_at: 30},
{created_at: 20},
{created_at: 10},
{created_at: 1}
]
const list1 = insertEventIntoDescendingList(list0, {
id: 'abc',
created_at: 15
})
expect(list1).toHaveLength(5)
expect(list1[2].id).toBe('abc')
})
test('insert in the end of a list', async () => {
const list0 = [
{created_at: 20},
{created_at: 20},
{created_at: 20},
{created_at: 20},
{created_at: 10}
]
const list1 = insertEventIntoDescendingList(list0, {
id: 'abc',
created_at: 5
})
expect(list1).toHaveLength(6)
expect(list1.slice(-1)[0].id).toBe('abc')
})
test('insert in the last-to-end of a list with same created_at', async () => {
const list0 = [
{created_at: 20},
{created_at: 20},
{created_at: 20},
{created_at: 20},
{created_at: 10}
]
const list1 = insertEventIntoDescendingList(list0, {
id: 'abc',
created_at: 10
})
expect(list1).toHaveLength(6)
expect(list1.slice(-2)[0].id).toBe('abc')
})
test('do not insert duplicates', async () => {
const list0 = [
{created_at: 20},
{created_at: 20},
{created_at: 10, id: 'abc'}
]
const list1 = insertEventIntoDescendingList(list0, {
id: 'abc',
created_at: 10
})
expect(list1).toHaveLength(3)
})
})
describe('inserting into a asc sorted list of events', () => {
test('insert into an empty list', async () => {
const list0 = []
expect(
insertEventIntoAscendingList(list0, {id: 'abc', created_at: 10})
).toHaveLength(1)
})
test('insert in the beginning of a list', async () => {
const list0 = [{created_at: 10}, {created_at: 20}]
const list1 = insertEventIntoAscendingList(list0, {
id: 'abc',
created_at: 1
})
expect(list1).toHaveLength(3)
expect(list1[0].id).toBe('abc')
})
test('insert in the beginning of a list with same created_at', async () => {
const list0 = [{created_at: 10}, {created_at: 20}, {created_at: 30}]
const list1 = insertEventIntoAscendingList(list0, {
id: 'abc',
created_at: 10
})
expect(list1).toHaveLength(4)
expect(list1[0].id).toBe('abc')
})
test('insert in the middle of a list', async () => {
const list0 = [
{created_at: 10},
{created_at: 20},
{created_at: 30},
{created_at: 40}
]
const list1 = insertEventIntoAscendingList(list0, {
id: 'abc',
created_at: 25
})
expect(list1).toHaveLength(5)
expect(list1[2].id).toBe('abc')
})
test('insert in the end of a list', async () => {
const list0 = [
{created_at: 20},
{created_at: 20},
{created_at: 20},
{created_at: 20},
{created_at: 40}
]
const list1 = insertEventIntoAscendingList(list0, {
id: 'abc',
created_at: 50
})
expect(list1).toHaveLength(6)
expect(list1.slice(-1)[0].id).toBe('abc')
})
test('insert in the last-to-end of a list with same created_at', async () => {
const list0 = [
{created_at: 20},
{created_at: 20},
{created_at: 20},
{created_at: 20},
{created_at: 30}
]
const list1 = insertEventIntoAscendingList(list0, {
id: 'abc',
created_at: 30
})
expect(list1).toHaveLength(6)
expect(list1.slice(-2)[0].id).toBe('abc')
})
test('do not insert duplicates', async () => {
const list0 = [
{created_at: 20},
{created_at: 20},
{created_at: 30, id: 'abc'}
]
const list1 = insertEventIntoAscendingList(list0, {
id: 'abc',
created_at: 30
})
expect(list1).toHaveLength(3)
})
})

View File

@@ -1,239 +0,0 @@
import {buildEvent} from './test-helpers.ts'
import {
MessageQueue,
insertEventIntoAscendingList,
insertEventIntoDescendingList,
} from './utils.ts'
import type {Event} from './event.ts'
describe('inserting into a desc sorted list of events', () => {
test('insert into an empty list', async () => {
const list0: Event[] = []
expect(
insertEventIntoDescendingList(list0, buildEvent({id: 'abc', created_at: 10}))
).toHaveLength(1)
})
test('insert in the beginning of a list', async () => {
const list0 = [buildEvent({created_at: 20}), buildEvent({created_at: 10})]
const list1 = insertEventIntoDescendingList(list0, buildEvent({
id: 'abc',
created_at: 30
}))
expect(list1).toHaveLength(3)
expect(list1[0].id).toBe('abc')
})
test('insert in the beginning of a list with same created_at', async () => {
const list0 = [
buildEvent({created_at: 30}),
buildEvent({created_at: 20}),
buildEvent({created_at: 10}),
]
const list1 = insertEventIntoDescendingList(list0, buildEvent({
id: 'abc',
created_at: 30
}))
expect(list1).toHaveLength(4)
expect(list1[0].id).toBe('abc')
})
test('insert in the middle of a list', async () => {
const list0 = [
buildEvent({created_at: 30}),
buildEvent({created_at: 20}),
buildEvent({created_at: 10}),
buildEvent({created_at: 1}),
]
const list1 = insertEventIntoDescendingList(list0, buildEvent({
id: 'abc',
created_at: 15
}))
expect(list1).toHaveLength(5)
expect(list1[2].id).toBe('abc')
})
test('insert in the end of a list', async () => {
const list0 = [
buildEvent({created_at: 20}),
buildEvent({created_at: 20}),
buildEvent({created_at: 20}),
buildEvent({created_at: 20}),
buildEvent({created_at: 10}),
]
const list1 = insertEventIntoDescendingList(list0, buildEvent({
id: 'abc',
created_at: 5
}))
expect(list1).toHaveLength(6)
expect(list1.slice(-1)[0].id).toBe('abc')
})
test('insert in the last-to-end of a list with same created_at', async () => {
const list0: Event[] = [
buildEvent({created_at: 20}),
buildEvent({created_at: 20}),
buildEvent({created_at: 20}),
buildEvent({created_at: 20}),
buildEvent({created_at: 10}),
]
const list1 = insertEventIntoDescendingList(list0, buildEvent({
id: 'abc',
created_at: 10
}))
expect(list1).toHaveLength(6)
expect(list1.slice(-2)[0].id).toBe('abc')
})
test('do not insert duplicates', async () => {
const list0 = [
buildEvent({created_at: 20}),
buildEvent({created_at: 20}),
buildEvent({created_at: 10, id: 'abc'}),
]
const list1 = insertEventIntoDescendingList(list0, buildEvent({
id: 'abc',
created_at: 10
}))
expect(list1).toHaveLength(3)
})
})
describe('inserting into a asc sorted list of events', () => {
test('insert into an empty list', async () => {
const list0: Event[] = []
expect(
insertEventIntoAscendingList(list0, buildEvent({id: 'abc', created_at: 10}))
).toHaveLength(1)
})
test('insert in the beginning of a list', async () => {
const list0 = [buildEvent({created_at: 10}), buildEvent({created_at: 20})]
const list1 = insertEventIntoAscendingList(list0, buildEvent({
id: 'abc',
created_at: 1
}))
expect(list1).toHaveLength(3)
expect(list1[0].id).toBe('abc')
})
test('insert in the beginning of a list with same created_at', async () => {
const list0 = [
buildEvent({created_at: 10}),
buildEvent({created_at: 20}),
buildEvent({created_at: 30}),
]
const list1 = insertEventIntoAscendingList(list0, buildEvent({
id: 'abc',
created_at: 10
}))
expect(list1).toHaveLength(4)
expect(list1[0].id).toBe('abc')
})
test('insert in the middle of a list', async () => {
const list0 = [
buildEvent({created_at: 10}),
buildEvent({created_at: 20}),
buildEvent({created_at: 30}),
buildEvent({created_at: 40}),
]
const list1 = insertEventIntoAscendingList(list0, buildEvent({
id: 'abc',
created_at: 25
}))
expect(list1).toHaveLength(5)
expect(list1[2].id).toBe('abc')
})
test('insert in the end of a list', async () => {
const list0 = [
buildEvent({created_at: 20}),
buildEvent({created_at: 20}),
buildEvent({created_at: 20}),
buildEvent({created_at: 20}),
buildEvent({created_at: 40}),
]
const list1 = insertEventIntoAscendingList(list0, buildEvent({
id: 'abc',
created_at: 50
}))
expect(list1).toHaveLength(6)
expect(list1.slice(-1)[0].id).toBe('abc')
})
test('insert in the last-to-end of a list with same created_at', async () => {
const list0 = [
buildEvent({created_at: 20}),
buildEvent({created_at: 20}),
buildEvent({created_at: 20}),
buildEvent({created_at: 20}),
buildEvent({created_at: 30}),
]
const list1 = insertEventIntoAscendingList(list0, buildEvent({
id: 'abc',
created_at: 30
}))
expect(list1).toHaveLength(6)
expect(list1.slice(-2)[0].id).toBe('abc')
})
test('do not insert duplicates', async () => {
const list0 = [
buildEvent({created_at: 20}),
buildEvent({created_at: 20}),
buildEvent({created_at: 30, id: 'abc'}),
]
const list1 = insertEventIntoAscendingList(list0, buildEvent({
id: 'abc',
created_at: 30
}))
expect(list1).toHaveLength(3)
})
})
describe('enque a message into MessageQueue', () => {
test('enque into an empty queue', () => {
const queue = new MessageQueue()
queue.enqueue('node1')
expect(queue.first!.value).toBe('node1')
})
test('enque into a non-empty queue', () => {
const queue = new MessageQueue()
queue.enqueue('node1')
queue.enqueue('node3')
queue.enqueue('node2')
expect(queue.first!.value).toBe('node1')
expect(queue.last!.value).toBe('node2')
expect(queue.size).toBe(3)
})
test('dequeue from an empty queue', () => {
const queue = new MessageQueue()
const item1 = queue.dequeue()
expect(item1).toBe(null)
expect(queue.size).toBe(0)
})
test('dequeue from a non-empty queue', () => {
const queue = new MessageQueue()
queue.enqueue('node1')
queue.enqueue('node3')
queue.enqueue('node2')
const item1 = queue.dequeue()
expect(item1).toBe('node1')
const item2 = queue.dequeue()
expect(item2).toBe('node3')
})
test('dequeue more than in queue', () => {
const queue = new MessageQueue()
queue.enqueue('node1')
queue.enqueue('node3')
const item1 = queue.dequeue()
expect(item1).toBe('node1')
const item2 = queue.dequeue()
expect(item2).toBe('node3')
expect(queue.size).toBe(0)
const item3 = queue.dequeue()
expect(item3).toBe(null)
})
})

View File

@@ -1,4 +1,4 @@
import type {Event} from './event.ts' import {Event} from './event'
export const utf8Decoder = new TextDecoder('utf-8') export const utf8Decoder = new TextDecoder('utf-8')
export const utf8Encoder = new TextEncoder() export const utf8Encoder = new TextEncoder()
@@ -21,8 +21,8 @@ export function normalizeURL(url: string): string {
// fast insert-into-sorted-array functions adapted from https://github.com/terrymorse58/fast-sorted-array // fast insert-into-sorted-array functions adapted from https://github.com/terrymorse58/fast-sorted-array
// //
export function insertEventIntoDescendingList( export function insertEventIntoDescendingList(
sortedArray: Event<number>[], sortedArray: Event[],
event: Event<number> event: Event
) { ) {
let start = 0 let start = 0
let end = sortedArray.length - 1 let end = sortedArray.length - 1
@@ -66,8 +66,8 @@ export function insertEventIntoDescendingList(
} }
export function insertEventIntoAscendingList( export function insertEventIntoAscendingList(
sortedArray: Event<number>[], sortedArray: Event[],
event: Event<number> event: Event
) { ) {
let start = 0 let start = 0
let end = sortedArray.length - 1 let end = sortedArray.length - 1
@@ -109,79 +109,3 @@ export function insertEventIntoAscendingList(
return sortedArray return sortedArray
} }
export class MessageNode {
private _value: string
private _next: MessageNode | null
public get value(): string {
return this._value
}
public set value(message: string) {
this._value = message
}
public get next(): MessageNode | null {
return this._next
}
public set next(node: MessageNode | null) {
this._next = node
}
constructor(message: string) {
this._value = message
this._next = null
}
}
export class MessageQueue {
private _first: MessageNode | null
private _last: MessageNode | null
public get first(): MessageNode | null {
return this._first
}
public set first(messageNode: MessageNode | null) {
this._first = messageNode
}
public get last(): MessageNode | null {
return this._last
}
public set last(messageNode: MessageNode | null) {
this._last = messageNode
}
private _size: number
public get size(): number {
return this._size
}
public set size(v: number) {
this._size = v
}
constructor() {
this._first = null
this._last = null
this._size = 0
}
enqueue(message: string): boolean {
const newNode = new MessageNode(message)
if (this._size === 0 || !this._last) {
this._first = newNode
this._last = newNode
} else {
this._last.next = newNode
this._last = newNode
}
this._size++
return true
}
dequeue(): string | null {
if (this._size === 0 || !this._first) return null
let prev = this._first
this._first = prev.next
prev.next = null
this._size--
return prev.value
}
}

3984
yarn.lock

File diff suppressed because it is too large Load Diff