mirror of
https://github.com/nbd-wtf/nostr-tools.git
synced 2025-12-09 00:28:51 +00:00
Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3368e8c00e | ||
|
|
e5a3ad9855 | ||
|
|
03185c654b | ||
|
|
9d690814ca | ||
|
|
17590cce91 | ||
|
|
ee9f37e192 | ||
|
|
c1848d78a0 | ||
|
|
81776ba811 | ||
|
|
915d6d729b | ||
|
|
1a23f5ee01 | ||
|
|
fec40490a2 | ||
|
|
bb3e41bb89 | ||
|
|
27b971eef3 | ||
|
|
0041008b22 | ||
|
|
ae5bf4c72c | ||
|
|
75fc836cf6 | ||
|
|
70b025b8da | ||
|
|
c9bc702d90 | ||
|
|
7652318185 | ||
|
|
d81a2444b3 | ||
|
|
7507943253 | ||
|
|
b9a7f814aa | ||
|
|
0e364701da | ||
|
|
a55fb8465f | ||
|
|
472a01af6a | ||
|
|
bb5acfc197 | ||
|
|
5b15237b95 | ||
|
|
4184609a00 | ||
|
|
97287cad74 | ||
|
|
fa21f71ab5 |
10
README.md
10
README.md
@@ -4,6 +4,8 @@ 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
|
## Installation
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -106,13 +108,7 @@ let event = {
|
|||||||
event.id = getEventHash(event)
|
event.id = getEventHash(event)
|
||||||
event.sig = getSignature(event, sk)
|
event.sig = getSignature(event, sk)
|
||||||
|
|
||||||
let pub = relay.publish(event)
|
await relay.publish(event)
|
||||||
pub.on('ok', () => {
|
|
||||||
console.log(`${relay.url} has accepted our event`)
|
|
||||||
})
|
|
||||||
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({
|
||||||
|
|||||||
5
event.ts
5
event.ts
@@ -27,8 +27,9 @@ export enum Kind {
|
|||||||
Zap = 9735,
|
Zap = 9735,
|
||||||
RelayList = 10002,
|
RelayList = 10002,
|
||||||
ClientAuth = 22242,
|
ClientAuth = 22242,
|
||||||
BadgeDefinition = 30008,
|
HttpAuth = 27235,
|
||||||
ProfileBadge = 30009,
|
ProfileBadge = 30008,
|
||||||
|
BadgeDefinition = 30009,
|
||||||
Article = 30023
|
Article = 30023
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {matchFilter, matchFilters} from './filter.ts'
|
import {matchFilter, matchFilters, mergeFilters} from './filter.ts'
|
||||||
import {buildEvent} from './test-helpers.ts'
|
import {buildEvent} from './test-helpers.ts'
|
||||||
|
|
||||||
describe('Filter', () => {
|
describe('Filter', () => {
|
||||||
@@ -18,7 +18,7 @@ describe('Filter', () => {
|
|||||||
kind: 1,
|
kind: 1,
|
||||||
pubkey: 'abc',
|
pubkey: 'abc',
|
||||||
created_at: 150,
|
created_at: 150,
|
||||||
tags: [['tag', 'value']],
|
tags: [['tag', 'value']]
|
||||||
})
|
})
|
||||||
|
|
||||||
const result = matchFilter(filter, event)
|
const result = matchFilter(filter, event)
|
||||||
@@ -115,6 +115,16 @@ describe('Filter', () => {
|
|||||||
expect(result).toEqual(false)
|
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', () => {
|
it('should return false when the event is after the filter until value', () => {
|
||||||
const filter = {until: 100}
|
const filter = {until: 100}
|
||||||
|
|
||||||
@@ -124,6 +134,16 @@ describe('Filter', () => {
|
|||||||
|
|
||||||
expect(result).toEqual(false)
|
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', () => {
|
describe('matchFilters', () => {
|
||||||
@@ -162,7 +182,12 @@ describe('Filter', () => {
|
|||||||
{authors: ['abc'], limit: 3}
|
{authors: ['abc'], limit: 3}
|
||||||
]
|
]
|
||||||
|
|
||||||
const event = buildEvent({id: '123', kind: 1, pubkey: 'abc', created_at: 150})
|
const event = buildEvent({
|
||||||
|
id: '123',
|
||||||
|
kind: 1,
|
||||||
|
pubkey: 'abc',
|
||||||
|
created_at: 150
|
||||||
|
})
|
||||||
|
|
||||||
const result = matchFilters(filters, event)
|
const result = matchFilters(filters, event)
|
||||||
|
|
||||||
@@ -189,11 +214,35 @@ describe('Filter', () => {
|
|||||||
{kinds: [1], limit: 2},
|
{kinds: [1], limit: 2},
|
||||||
{authors: ['abc'], limit: 3}
|
{authors: ['abc'], limit: 3}
|
||||||
]
|
]
|
||||||
const event = buildEvent({id: '456', kind: 2, pubkey: 'def', created_at: 200})
|
const event = buildEvent({
|
||||||
|
id: '456',
|
||||||
|
kind: 2,
|
||||||
|
pubkey: 'def',
|
||||||
|
created_at: 200
|
||||||
|
})
|
||||||
|
|
||||||
const result = matchFilters(filters, event)
|
const result = matchFilters(filters, event)
|
||||||
|
|
||||||
expect(result).toEqual(false)
|
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})
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
36
filter.ts
36
filter.ts
@@ -42,7 +42,7 @@ 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
|
||||||
}
|
}
|
||||||
@@ -56,3 +56,37 @@ export function matchFilters(
|
|||||||
}
|
}
|
||||||
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
|
||||||
|
}
|
||||||
|
|||||||
2
index.ts
2
index.ts
@@ -16,9 +16,11 @@ export * as nip21 from './nip21.ts'
|
|||||||
export * as nip25 from './nip25.ts'
|
export * as nip25 from './nip25.ts'
|
||||||
export * as nip26 from './nip26.ts'
|
export * as nip26 from './nip26.ts'
|
||||||
export * as nip27 from './nip27.ts'
|
export * as nip27 from './nip27.ts'
|
||||||
|
export * as nip28 from './nip28.ts'
|
||||||
export * as nip39 from './nip39.ts'
|
export * as nip39 from './nip39.ts'
|
||||||
export * as nip42 from './nip42.ts'
|
export * as nip42 from './nip42.ts'
|
||||||
export * as nip57 from './nip57.ts'
|
export * as nip57 from './nip57.ts'
|
||||||
|
export * as nip98 from './nip98.ts'
|
||||||
|
|
||||||
export * as fj from './fakejson.ts'
|
export * as fj from './fakejson.ts'
|
||||||
export * as utils from './utils.ts'
|
export * as utils from './utils.ts'
|
||||||
|
|||||||
@@ -17,17 +17,11 @@ test('fetch nip05 profiles', async () => {
|
|||||||
)
|
)
|
||||||
expect(p2!.relays).toEqual(['wss://relay.damus.io'])
|
expect(p2!.relays).toEqual(['wss://relay.damus.io'])
|
||||||
|
|
||||||
let p3 = await queryProfile('channel.ninja@channel.ninja')
|
let p3 = await queryProfile('_@fiatjaf.com')
|
||||||
expect(p3!.pubkey).toEqual(
|
expect(p3!.pubkey).toEqual(
|
||||||
'36e65b503eba8a6b698e724a59137603101166a1cddb45ddc704247fc8aa0fce'
|
|
||||||
)
|
|
||||||
expect(p3!.relays).toEqual(undefined)
|
|
||||||
|
|
||||||
let p4 = await queryProfile('_@fiatjaf.com')
|
|
||||||
expect(p4!.pubkey).toEqual(
|
|
||||||
'3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d'
|
'3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d'
|
||||||
)
|
)
|
||||||
expect(p4!.relays).toEqual([
|
expect(p3!.relays).toEqual([
|
||||||
'wss://relay.nostr.bg',
|
'wss://relay.nostr.bg',
|
||||||
'wss://nos.lol',
|
'wss://nos.lol',
|
||||||
'wss://nostr-verified.wellorder.net',
|
'wss://nostr-verified.wellorder.net',
|
||||||
|
|||||||
2
nip06.ts
2
nip06.ts
@@ -1,5 +1,5 @@
|
|||||||
import {bytesToHex} from '@noble/hashes/utils'
|
import {bytesToHex} from '@noble/hashes/utils'
|
||||||
import {wordlist} from '@scure/bip39/wordlists/english.js'
|
import {wordlist} from '@scure/bip39/wordlists/english'
|
||||||
import {
|
import {
|
||||||
generateMnemonic,
|
generateMnemonic,
|
||||||
mnemonicToSeedSync,
|
mnemonicToSeedSync,
|
||||||
|
|||||||
71
nip19.ts
71
nip19.ts
@@ -30,15 +30,27 @@ export type AddressPointer = {
|
|||||||
relays?: string[]
|
relays?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DecodeResult =
|
type Prefixes = {
|
||||||
| {type: 'nprofile'; data: ProfilePointer}
|
nprofile: ProfilePointer
|
||||||
| {type: 'nrelay'; data: string}
|
nrelay: string
|
||||||
| {type: 'nevent'; data: EventPointer}
|
nevent: EventPointer
|
||||||
| {type: 'naddr'; data: AddressPointer}
|
naddr: AddressPointer
|
||||||
| {type: 'nsec'; data: string}
|
nsec: string
|
||||||
| {type: 'npub'; data: string}
|
npub: string
|
||||||
| {type: 'note'; data: 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 {
|
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))
|
||||||
@@ -69,9 +81,7 @@ export function decode(nip19: string): DecodeResult {
|
|||||||
data: {
|
data: {
|
||||||
id: bytesToHex(tlv[0][0]),
|
id: bytesToHex(tlv[0][0]),
|
||||||
relays: tlv[1] ? tlv[1].map(d => utf8Decoder.decode(d)) : [],
|
relays: tlv[1] ? tlv[1].map(d => utf8Decoder.decode(d)) : [],
|
||||||
author: tlv[2]?.[0]
|
author: tlv[2]?.[0] ? bytesToHex(tlv[2][0]) : undefined
|
||||||
? bytesToHex(tlv[2][0])
|
|
||||||
: undefined
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -123,53 +133,56 @@ 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) continue
|
if (v.length < l) throw new Error(`not enough data to read on TLV ${t}`)
|
||||||
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): string {
|
export function nsecEncode(hex: string): `nsec1${string}` {
|
||||||
return encodeBytes('nsec', hex)
|
return encodeBytes('nsec', hex)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function npubEncode(hex: string): string {
|
export function npubEncode(hex: string): `npub1${string}` {
|
||||||
return encodeBytes('npub', hex)
|
return encodeBytes('npub', hex)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function noteEncode(hex: string): string {
|
export function noteEncode(hex: string): `note1${string}` {
|
||||||
return encodeBytes('note', hex)
|
return encodeBytes('note', hex)
|
||||||
}
|
}
|
||||||
|
|
||||||
function encodeBytes(prefix: string, hex: string): string {
|
function encodeBech32<Prefix extends string>(prefix: Prefix, data: Uint8Array): `${Prefix}1${string}` {
|
||||||
let data = hexToBytes(hex)
|
|
||||||
let words = bech32.toWords(data)
|
let words = bech32.toWords(data)
|
||||||
return bech32.encode(prefix, words, Bech32MaxSize)
|
return bech32.encode(prefix, words, Bech32MaxSize) as `${Prefix}1${string}`
|
||||||
}
|
}
|
||||||
|
|
||||||
export function nprofileEncode(profile: ProfilePointer): string {
|
function encodeBytes<Prefix extends string>(prefix: Prefix, hex: string): `${Prefix}1${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: [hexToBytes(profile.pubkey)],
|
||||||
1: (profile.relays || []).map(url => utf8Encoder.encode(url))
|
1: (profile.relays || []).map(url => utf8Encoder.encode(url))
|
||||||
})
|
})
|
||||||
let words = bech32.toWords(data)
|
return encodeBech32('nprofile', data)
|
||||||
return bech32.encode('nprofile', words, Bech32MaxSize)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function neventEncode(event: EventPointer): string {
|
export function neventEncode(event: EventPointer): `nevent1${string}` {
|
||||||
let data = encodeTLV({
|
let data = encodeTLV({
|
||||||
0: [hexToBytes(event.id)],
|
0: [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)] : []
|
2: event.author ? [hexToBytes(event.author)] : []
|
||||||
})
|
})
|
||||||
let words = bech32.toWords(data)
|
return encodeBech32('nevent', data)
|
||||||
return bech32.encode('nevent', words, Bech32MaxSize)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function naddrEncode(addr: AddressPointer): string {
|
export function naddrEncode(addr: AddressPointer): `naddr1${string}` {
|
||||||
let kind = new ArrayBuffer(4)
|
let kind = new ArrayBuffer(4)
|
||||||
new DataView(kind).setUint32(0, addr.kind, false)
|
new DataView(kind).setUint32(0, addr.kind, false)
|
||||||
|
|
||||||
@@ -179,16 +192,14 @@ export function naddrEncode(addr: AddressPointer): string {
|
|||||||
2: [hexToBytes(addr.pubkey)],
|
2: [hexToBytes(addr.pubkey)],
|
||||||
3: [new Uint8Array(kind)]
|
3: [new Uint8Array(kind)]
|
||||||
})
|
})
|
||||||
let words = bech32.toWords(data)
|
return encodeBech32('naddr', data)
|
||||||
return bech32.encode('naddr', words, Bech32MaxSize)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function nrelayEncode(url: string): string {
|
export function nrelayEncode(url: string): `nrelay1${string}` {
|
||||||
let data = encodeTLV({
|
let data = encodeTLV({
|
||||||
0: [utf8Encoder.encode(url)]
|
0: [utf8Encoder.encode(url)]
|
||||||
})
|
})
|
||||||
let words = bech32.toWords(data)
|
return encodeBech32('nrelay', data)
|
||||||
return bech32.encode('nrelay', words, Bech32MaxSize)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function encodeTLV(tlv: TLV): Uint8Array {
|
function encodeTLV(tlv: TLV): Uint8Array {
|
||||||
|
|||||||
@@ -29,6 +29,25 @@ test('matchAll', () => {
|
|||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
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', () => {
|
test('replaceAll', () => {
|
||||||
const content =
|
const content =
|
||||||
'Hello nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6!\n\nnostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky'
|
'Hello nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6!\n\nnostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky'
|
||||||
|
|||||||
9
nip27.ts
9
nip27.ts
@@ -2,8 +2,7 @@ import {decode} from './nip19.ts'
|
|||||||
import {NOSTR_URI_REGEX, type NostrURI} from './nip21.ts'
|
import {NOSTR_URI_REGEX, type NostrURI} from './nip21.ts'
|
||||||
|
|
||||||
/** Regex to find NIP-21 URIs inside event content. */
|
/** Regex to find NIP-21 URIs inside event content. */
|
||||||
export const regex = () =>
|
export const regex = () => new RegExp(`\\b${NOSTR_URI_REGEX.source}\\b`, 'g')
|
||||||
new RegExp(`\\b${NOSTR_URI_REGEX.source}\\b`, 'g')
|
|
||||||
|
|
||||||
/** Match result for a Nostr URI in event content. */
|
/** Match result for a Nostr URI in event content. */
|
||||||
export interface NostrURIMatch extends NostrURI {
|
export interface NostrURIMatch extends NostrURI {
|
||||||
@@ -18,6 +17,7 @@ export function * matchAll(content: string): Iterable<NostrURIMatch> {
|
|||||||
const matches = content.matchAll(regex())
|
const matches = content.matchAll(regex())
|
||||||
|
|
||||||
for (const match of matches) {
|
for (const match of matches) {
|
||||||
|
try {
|
||||||
const [uri, value] = match
|
const [uri, value] = match
|
||||||
|
|
||||||
yield {
|
yield {
|
||||||
@@ -27,6 +27,9 @@ export function * matchAll(content: string): Iterable<NostrURIMatch> {
|
|||||||
start: match.index!,
|
start: match.index!,
|
||||||
end: match.index! + uri.length
|
end: match.index! + uri.length
|
||||||
}
|
}
|
||||||
|
} catch (_e) {
|
||||||
|
// do nothing
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,7 +56,7 @@ export function replaceAll(
|
|||||||
content: string,
|
content: string,
|
||||||
replacer: (match: NostrURI) => string
|
replacer: (match: NostrURI) => string
|
||||||
): string {
|
): string {
|
||||||
return content.replaceAll(regex(), (uri, value) => {
|
return content.replaceAll(regex(), (uri, value: string) => {
|
||||||
return replacer({
|
return replacer({
|
||||||
uri: uri as `nostr:${string}`,
|
uri: uri as `nostr:${string}`,
|
||||||
value,
|
value,
|
||||||
|
|||||||
134
nip28.test.ts
Normal file
134
nip28.test.ts
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
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
Normal file
163
nip28.ts
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
16
nip42.ts
16
nip42.ts
@@ -17,7 +17,9 @@ export const authenticate = async ({
|
|||||||
}: {
|
}: {
|
||||||
challenge: string
|
challenge: string
|
||||||
relay: Relay
|
relay: Relay
|
||||||
sign: <K extends number = number>(e: EventTemplate<K>) => Promise<Event<K>> | Event<K>
|
sign: <K extends number = number>(
|
||||||
|
e: EventTemplate<K>
|
||||||
|
) => Promise<Event<K>> | Event<K>
|
||||||
}): Promise<void> => {
|
}): Promise<void> => {
|
||||||
const e: EventTemplate = {
|
const e: EventTemplate = {
|
||||||
kind: Kind.ClientAuth,
|
kind: Kind.ClientAuth,
|
||||||
@@ -28,15 +30,5 @@ export const authenticate = async ({
|
|||||||
],
|
],
|
||||||
content: ''
|
content: ''
|
||||||
}
|
}
|
||||||
const pub = relay.auth(await sign(e))
|
return relay.auth(await sign(e))
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
pub.on('ok', function ok() {
|
|
||||||
pub.off('ok', ok)
|
|
||||||
resolve()
|
|
||||||
})
|
|
||||||
pub.on('failed', function fail(reason: string) {
|
|
||||||
pub.off('failed', fail)
|
|
||||||
reject(reason)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|||||||
139
nip98.test.ts
Normal file
139
nip98.test.ts
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import {base64} from '@scure/base'
|
||||||
|
import {getToken, validateToken} from './nip98.ts'
|
||||||
|
import {Event, Kind, finishEvent} from './event.ts'
|
||||||
|
import {utf8Decoder} from './utils.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 = JSON.parse(
|
||||||
|
utf8Decoder.decode(base64.decode(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 = JSON.parse(
|
||||||
|
utf8Decoder.decode(base64.decode(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 = JSON.parse(
|
||||||
|
utf8Decoder.decode(base64.decode(result.replace(authorizationScheme, '')))
|
||||||
|
)
|
||||||
|
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
112
nip98.ts
Normal file
112
nip98.ts
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import {base64} from '@scure/base'
|
||||||
|
import {
|
||||||
|
Event,
|
||||||
|
EventTemplate,
|
||||||
|
Kind,
|
||||||
|
getBlankEvent,
|
||||||
|
verifySignature
|
||||||
|
} from './event'
|
||||||
|
import {utf8Decoder, utf8Encoder} from './utils'
|
||||||
|
|
||||||
|
enum HttpMethod {
|
||||||
|
Get = 'get',
|
||||||
|
Post = 'post'
|
||||||
|
}
|
||||||
|
|
||||||
|
const _authorizationScheme = 'Nostr '
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate token for NIP-98 flow.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const sign = window.nostr.signEvent
|
||||||
|
* await getToken('https://example.com/login', 'post', sign, true)
|
||||||
|
*/
|
||||||
|
export async function getToken(
|
||||||
|
loginUrl: string,
|
||||||
|
httpMethod: 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')
|
||||||
|
if (httpMethod !== HttpMethod.Get && httpMethod !== HttpMethod.Post)
|
||||||
|
throw new Error('Unknown 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 validateToken('Nostr base64token', 'https://example.com/login', 'post')
|
||||||
|
*/
|
||||||
|
export async function validateToken(
|
||||||
|
token: string,
|
||||||
|
url: string,
|
||||||
|
method: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
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
|
||||||
|
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
|
||||||
|
}
|
||||||
10
package.json
10
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "nostr-tools",
|
"name": "nostr-tools",
|
||||||
"version": "1.11.1",
|
"version": "1.14.0",
|
||||||
"description": "Tools for making a Nostr client.",
|
"description": "Tools for making a Nostr client.",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
@@ -19,11 +19,11 @@
|
|||||||
},
|
},
|
||||||
"license": "Unlicense",
|
"license": "Unlicense",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@noble/curves": "1.0.0",
|
"@noble/curves": "1.1.0",
|
||||||
"@noble/hashes": "1.3.0",
|
"@noble/hashes": "1.3.1",
|
||||||
"@scure/base": "1.1.1",
|
"@scure/base": "1.1.1",
|
||||||
"@scure/bip32": "1.3.0",
|
"@scure/bip32": "1.3.1",
|
||||||
"@scure/bip39": "1.2.0"
|
"@scure/bip39": "1.2.1"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"decentralization",
|
"decentralization",
|
||||||
|
|||||||
17
pool.test.ts
17
pool.test.ts
@@ -121,3 +121,20 @@ 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)
|
||||||
|
})
|
||||||
|
|||||||
61
pool.ts
61
pool.ts
@@ -1,9 +1,8 @@
|
|||||||
import {
|
import {
|
||||||
relayInit,
|
relayInit,
|
||||||
type Pub,
|
|
||||||
type Relay,
|
type Relay,
|
||||||
type Sub,
|
type Sub,
|
||||||
type SubscriptionOptions,
|
type SubscriptionOptions
|
||||||
} from './relay.ts'
|
} from './relay.ts'
|
||||||
import {normalizeURL} from './utils.ts'
|
import {normalizeURL} from './utils.ts'
|
||||||
|
|
||||||
@@ -15,11 +14,19 @@ export class SimplePool {
|
|||||||
|
|
||||||
private eoseSubTimeout: number
|
private eoseSubTimeout: number
|
||||||
private getTimeout: number
|
private getTimeout: number
|
||||||
|
private seenOnEnabled: boolean = true
|
||||||
|
|
||||||
constructor(options: {eoseSubTimeout?: number; getTimeout?: number} = {}) {
|
constructor(
|
||||||
|
options: {
|
||||||
|
eoseSubTimeout?: number
|
||||||
|
getTimeout?: number
|
||||||
|
seenOnEnabled?: boolean
|
||||||
|
} = {}
|
||||||
|
) {
|
||||||
this._conn = {}
|
this._conn = {}
|
||||||
this.eoseSubTimeout = options.eoseSubTimeout || 3400
|
this.eoseSubTimeout = options.eoseSubTimeout || 3400
|
||||||
this.getTimeout = options.getTimeout || 3400
|
this.getTimeout = options.getTimeout || 3400
|
||||||
|
this.seenOnEnabled = options.seenOnEnabled !== false
|
||||||
}
|
}
|
||||||
|
|
||||||
close(relays: string[]): void {
|
close(relays: string[]): void {
|
||||||
@@ -44,16 +51,22 @@ export class SimplePool {
|
|||||||
return relay
|
return relay
|
||||||
}
|
}
|
||||||
|
|
||||||
sub<K extends number = number>(relays: string[], filters: Filter<K>[], opts?: SubscriptionOptions): Sub<K> {
|
sub<K extends number = number>(
|
||||||
|
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)) {
|
if (opts?.alreadyHaveEvent?.(id, url)) {
|
||||||
return true
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,7 +91,7 @@ export class SimplePool {
|
|||||||
}
|
}
|
||||||
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 => {
|
||||||
_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)
|
||||||
})
|
})
|
||||||
@@ -134,7 +147,7 @@ export class SimplePool {
|
|||||||
sub.unsub()
|
sub.unsub()
|
||||||
resolve(null)
|
resolve(null)
|
||||||
}, this.getTimeout)
|
}, this.getTimeout)
|
||||||
sub.on('event', (event) => {
|
sub.on('event', event => {
|
||||||
resolve(event)
|
resolve(event)
|
||||||
clearTimeout(timeout)
|
clearTimeout(timeout)
|
||||||
sub.unsub()
|
sub.unsub()
|
||||||
@@ -151,7 +164,7 @@ export class SimplePool {
|
|||||||
let events: Event<K>[] = []
|
let events: Event<K>[] = []
|
||||||
let sub = this.sub(relays, filters, opts)
|
let sub = this.sub(relays, filters, opts)
|
||||||
|
|
||||||
sub.on('event', (event) => {
|
sub.on('event', event => {
|
||||||
events.push(event)
|
events.push(event)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -163,39 +176,11 @@ export class SimplePool {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
publish(relays: string[], event: Event<number>): Pub {
|
publish(relays: string[], event: Event<number>): Promise<void>[] {
|
||||||
const pubPromises: Promise<Pub>[] = relays.map(async relay => {
|
return relays.map(async relay => {
|
||||||
let r
|
let r = await this.ensureRelay(relay)
|
||||||
try {
|
|
||||||
r = await this.ensureRelay(relay)
|
|
||||||
return r.publish(event)
|
return r.publish(event)
|
||||||
} catch (_) {
|
|
||||||
return {on() {}, off() {}}
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const callbackMap = new Map()
|
|
||||||
|
|
||||||
return {
|
|
||||||
on(type, cb) {
|
|
||||||
relays.forEach(async (relay, i) => {
|
|
||||||
let pub = await pubPromises[i]
|
|
||||||
let callback = () => cb(relay)
|
|
||||||
callbackMap.set(cb, callback)
|
|
||||||
pub.on(type, callback)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
|
|
||||||
off(type, cb) {
|
|
||||||
relays.forEach(async (_, i) => {
|
|
||||||
let callback = callbackMap.get(cb)
|
|
||||||
if (callback) {
|
|
||||||
let pub = await pubPromises[i]
|
|
||||||
pub.off(type, callback)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
seenOn(id: string): string[] {
|
seenOn(id: string): string[] {
|
||||||
|
|||||||
87
relay.ts
87
relay.ts
@@ -3,6 +3,7 @@
|
|||||||
import {verifySignature, validateEvent, type Event} from './event.ts'
|
import {verifySignature, validateEvent, type Event} from './event.ts'
|
||||||
import {matchFilters, type Filter} from './filter.ts'
|
import {matchFilters, type Filter} from './filter.ts'
|
||||||
import {getHex64, getSubscriptionId} from './fakejson.ts'
|
import {getHex64, getSubscriptionId} from './fakejson.ts'
|
||||||
|
import {MessageQueue} from './utils.ts'
|
||||||
|
|
||||||
type RelayEvent = {
|
type RelayEvent = {
|
||||||
connect: () => void | Promise<void>
|
connect: () => void | Promise<void>
|
||||||
@@ -24,15 +25,24 @@ export type Relay = {
|
|||||||
status: number
|
status: number
|
||||||
connect: () => Promise<void>
|
connect: () => Promise<void>
|
||||||
close: () => void
|
close: () => void
|
||||||
sub: <K extends number = number>(filters: Filter<K>[], opts?: SubscriptionOptions) => Sub<K>
|
sub: <K extends number = number>(
|
||||||
list: <K extends number = number>(filters: Filter<K>[], opts?: SubscriptionOptions) => Promise<Event<K>[]>
|
filters: Filter<K>[],
|
||||||
get: <K extends number = number>(filter: Filter<K>, opts?: SubscriptionOptions) => Promise<Event<K> | null>
|
opts?: SubscriptionOptions
|
||||||
|
) => Sub<K>
|
||||||
|
list: <K extends number = number>(
|
||||||
|
filters: Filter<K>[],
|
||||||
|
opts?: SubscriptionOptions
|
||||||
|
) => Promise<Event<K>[]>
|
||||||
|
get: <K extends number = number>(
|
||||||
|
filter: Filter<K>,
|
||||||
|
opts?: SubscriptionOptions
|
||||||
|
) => Promise<Event<K> | null>
|
||||||
count: (
|
count: (
|
||||||
filters: Filter[],
|
filters: Filter[],
|
||||||
opts?: SubscriptionOptions
|
opts?: SubscriptionOptions
|
||||||
) => Promise<CountPayload | null>
|
) => Promise<CountPayload | null>
|
||||||
publish: (event: Event<number>) => Pub
|
publish: (event: Event<number>) => Promise<void>
|
||||||
auth: (event: Event<number>) => Pub
|
auth: (event: Event<number>) => Promise<void>
|
||||||
off: <T extends keyof RelayEvent, U extends RelayEvent[T]>(
|
off: <T extends keyof RelayEvent, U extends RelayEvent[T]>(
|
||||||
event: T,
|
event: T,
|
||||||
listener: U
|
listener: U
|
||||||
@@ -42,12 +52,11 @@ export type Relay = {
|
|||||||
listener: U
|
listener: U
|
||||||
) => void
|
) => void
|
||||||
}
|
}
|
||||||
export type Pub = {
|
|
||||||
on: (type: 'ok' | 'failed', cb: any) => void
|
|
||||||
off: (type: 'ok' | 'failed', cb: any) => void
|
|
||||||
}
|
|
||||||
export type Sub<K extends number = number> = {
|
export type Sub<K extends number = number> = {
|
||||||
sub: <K extends number = number>(filters: Filter<K>[], opts: SubscriptionOptions) => Sub<K>
|
sub: <K extends number = number>(
|
||||||
|
filters: Filter<K>[],
|
||||||
|
opts: SubscriptionOptions
|
||||||
|
) => Sub<K>
|
||||||
unsub: () => void
|
unsub: () => void
|
||||||
on: <T extends keyof SubEvent<K>, U extends SubEvent<K>[T]>(
|
on: <T extends keyof SubEvent<K>, U extends SubEvent<K>[T]>(
|
||||||
event: T,
|
event: T,
|
||||||
@@ -92,9 +101,8 @@ export function relayInit(
|
|||||||
} = {}
|
} = {}
|
||||||
var pubListeners: {
|
var pubListeners: {
|
||||||
[eventid: string]: {
|
[eventid: string]: {
|
||||||
ok: Array<() => void>
|
resolve: (_: unknown) => void
|
||||||
seen: Array<() => void>
|
reject: (err: Error) => void
|
||||||
failed: Array<(reason: string) => void>
|
|
||||||
}
|
}
|
||||||
} = {}
|
} = {}
|
||||||
|
|
||||||
@@ -122,24 +130,24 @@ export function relayInit(
|
|||||||
listeners.disconnect.forEach(cb => cb())
|
listeners.disconnect.forEach(cb => cb())
|
||||||
}
|
}
|
||||||
|
|
||||||
let incomingMessageQueue: string[] = []
|
let incomingMessageQueue: MessageQueue = new MessageQueue()
|
||||||
let handleNextInterval: any
|
let handleNextInterval: any
|
||||||
|
|
||||||
ws.onmessage = e => {
|
ws.onmessage = e => {
|
||||||
incomingMessageQueue.push(e.data)
|
incomingMessageQueue.enqueue(e.data)
|
||||||
if (!handleNextInterval) {
|
if (!handleNextInterval) {
|
||||||
handleNextInterval = setInterval(handleNext, 0)
|
handleNextInterval = setInterval(handleNext, 0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleNext() {
|
function handleNext() {
|
||||||
if (incomingMessageQueue.length === 0) {
|
if (incomingMessageQueue.size === 0) {
|
||||||
clearInterval(handleNextInterval)
|
clearInterval(handleNextInterval)
|
||||||
handleNextInterval = null
|
handleNextInterval = null
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var json = incomingMessageQueue.shift()
|
var json = incomingMessageQueue.dequeue()
|
||||||
if (!json) return
|
if (!json) return
|
||||||
|
|
||||||
let subid = getSubscriptionId(json)
|
let subid = getSubscriptionId(json)
|
||||||
@@ -195,10 +203,9 @@ export function relayInit(
|
|||||||
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 (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)
|
||||||
pubListeners[id].ok = [] // 'ok' only happens once per pub, so stop listeners here
|
else reject(new Error(reason))
|
||||||
pubListeners[id].failed = []
|
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -293,26 +300,16 @@ export function relayInit(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function _publishEvent(event: Event<number>, type: string) {
|
function _publishEvent(event: Event<number>, type: string) {
|
||||||
if (!event.id) throw new Error(`event ${event} has no id`)
|
return new Promise((resolve, reject) => {
|
||||||
|
if (!event.id) {
|
||||||
|
reject(new Error(`event ${event} has no id`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
let id = event.id
|
let id = event.id
|
||||||
|
|
||||||
trySend([type, event])
|
trySend([type, event])
|
||||||
|
pubListeners[id] = {resolve, reject}
|
||||||
return {
|
})
|
||||||
on: (type: 'ok' | 'failed', cb: any) => {
|
|
||||||
pubListeners[id] = pubListeners[id] || {
|
|
||||||
ok: [],
|
|
||||||
failed: []
|
|
||||||
}
|
|
||||||
pubListeners[id][type].push(cb)
|
|
||||||
},
|
|
||||||
off: (type: 'ok' | 'failed', cb: any) => {
|
|
||||||
let listeners = pubListeners[id]
|
|
||||||
if (!listeners) return
|
|
||||||
let idx = listeners[type].indexOf(cb)
|
|
||||||
if (idx >= 0) listeners[type].splice(idx, 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -348,7 +345,7 @@ export function relayInit(
|
|||||||
clearTimeout(timeout)
|
clearTimeout(timeout)
|
||||||
resolve(events)
|
resolve(events)
|
||||||
})
|
})
|
||||||
s.on('event', (event) => {
|
s.on('event', event => {
|
||||||
events.push(event)
|
events.push(event)
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
@@ -359,7 +356,7 @@ export function relayInit(
|
|||||||
s.unsub()
|
s.unsub()
|
||||||
resolve(null)
|
resolve(null)
|
||||||
}, getTimeout)
|
}, getTimeout)
|
||||||
s.on('event', (event) => {
|
s.on('event', event => {
|
||||||
s.unsub()
|
s.unsub()
|
||||||
clearTimeout(timeout)
|
clearTimeout(timeout)
|
||||||
resolve(event)
|
resolve(event)
|
||||||
@@ -378,11 +375,11 @@ export function relayInit(
|
|||||||
resolve(event)
|
resolve(event)
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
publish(event): Pub {
|
async publish(event): Promise<void> {
|
||||||
return _publishEvent(event, 'EVENT')
|
await _publishEvent(event, 'EVENT')
|
||||||
},
|
},
|
||||||
auth(event): Pub {
|
async auth(event): Promise<void> {
|
||||||
return _publishEvent(event, 'AUTH')
|
await _publishEvent(event, 'AUTH')
|
||||||
},
|
},
|
||||||
connect,
|
connect,
|
||||||
close(): void {
|
close(): void {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {buildEvent} from './test-helpers.ts'
|
import {buildEvent} from './test-helpers.ts'
|
||||||
import {
|
import {
|
||||||
|
MessageQueue,
|
||||||
insertEventIntoAscendingList,
|
insertEventIntoAscendingList,
|
||||||
insertEventIntoDescendingList,
|
insertEventIntoDescendingList,
|
||||||
} from './utils.ts'
|
} from './utils.ts'
|
||||||
@@ -191,3 +192,48 @@ describe('inserting into a asc sorted list of events', () => {
|
|||||||
expect(list1).toHaveLength(3)
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
76
utils.ts
76
utils.ts
@@ -109,3 +109,79 @@ 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user