diff --git a/abstract-relay.ts b/abstract-relay.ts index 4579b7f..e8c88de 100644 --- a/abstract-relay.ts +++ b/abstract-relay.ts @@ -331,13 +331,23 @@ export class AbstractRelay { so.close(data[2] as string) return } - case 'NOTICE': + case 'NOTICE': { this.onnotice(data[1] as string) return + } case 'AUTH': { this.challenge = data[1] as string return } + case 'NEG-ERR': + case 'NEG-MSG': { + const msg = data[2] as string + const id = data[1] as string + const so = this.openSubs.get(id) + if (!so || !so.onnegentropy) return + so.onnegentropy(msg, data[0] === 'NEG-ERR') + return + } } } catch (err) { return @@ -404,9 +414,12 @@ export class AbstractRelay { public subscribe( filters: Filter[], params: Partial & { label?: string; id?: string }, + fireImmediately: boolean = true, ): Subscription { const subscription = this.prepareSubscription(filters, params) - subscription.fire() + if (fireImmediately) { + subscription.fire() + } return subscription } @@ -459,6 +472,7 @@ export class Subscription { public alreadyHaveEvent: ((id: string) => boolean) | undefined public receivedEvent: ((relay: AbstractRelay, id: string) => void) | undefined + public onnegentropy: ((msg: string, isError: boolean) => void) | undefined public onevent: (evt: Event) => void public oneose: (() => void) | undefined public onclose: ((reason: string) => void) | undefined @@ -474,6 +488,7 @@ export class Subscription { this.receivedEvent = params.receivedEvent this.eoseTimeout = params.eoseTimeout || relay.baseEoseTimeout + this.onnegentropy = params.onnegentropy this.oneose = params.oneose this.onclose = params.onclose this.onevent = @@ -523,6 +538,7 @@ export class Subscription { export type SubscriptionParams = { onevent?: (evt: Event) => void oneose?: () => void + onnegentropy?: (msg: string, isError: boolean) => void onclose?: (reason: string) => void alreadyHaveEvent?: (id: string) => boolean receivedEvent?: (relay: AbstractRelay, id: string) => void diff --git a/helpers.ts b/helpers.ts index 3457fda..56d5df0 100644 --- a/helpers.ts +++ b/helpers.ts @@ -1,17 +1,34 @@ import { verifiedSymbol, type Event, type Nostr, VerifiedEvent } from './core.ts' export async function yieldThread() { - return new Promise(resolve => { - const ch = new MessageChannel() - const handler = () => { - // @ts-ignore (typescript thinks this property should be called `removeListener`, but in fact it's `removeEventListener`) - ch.port1.removeEventListener('message', handler) - resolve() + return new Promise((resolve, reject) => { + try { + // Check if MessageChannel is available + if (typeof MessageChannel !== 'undefined') { + const ch = new MessageChannel() + const handler = () => { + // @ts-ignore (typescript thinks this property should be called `removeListener`, but in fact it's `removeEventListener`) + ch.port1.removeEventListener('message', handler) + resolve() + } + // @ts-ignore (typescript thinks this property should be called `addListener`, but in fact it's `addEventListener`) + ch.port1.addEventListener('message', handler) + ch.port2.postMessage(0) + ch.port1.start() + } else { + if (typeof setImmediate !== 'undefined') { + setImmediate(resolve) + } else if (typeof setTimeout !== 'undefined') { + setTimeout(resolve, 0) + } else { + // Last resort - resolve immediately + resolve() + } + } + } catch (e) { + console.error('during yield: ', e) + reject(e) } - // @ts-ignore (typescript thinks this property should be called `addListener`, but in fact it's `addEventListener`) - ch.port1.addEventListener('message', handler) - ch.port2.postMessage(0) - ch.port1.start() }) } diff --git a/index.ts b/index.ts index 69aef00..ea77374 100644 --- a/index.ts +++ b/index.ts @@ -24,6 +24,7 @@ export * as nip47 from './nip47.ts' export * as nip54 from './nip54.ts' export * as nip57 from './nip57.ts' export * as nip59 from './nip59.ts' +export * as nip77 from './nip77.ts' export * as nip98 from './nip98.ts' export * as kinds from './kinds.ts' diff --git a/nip77.test.ts b/nip77.test.ts new file mode 100644 index 0000000..13479b5 --- /dev/null +++ b/nip77.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect } from 'bun:test' + +import { sendNegentropyMessage, openNegentropyWithMessage, closeNegentropy } from './nip77.ts' +import type { Filter } from './filter.ts' +import { AbstractRelay } from './abstract-relay.ts' +import type { Event, VerifiedEvent } from './core.ts' + +// A minimal mock relay implementing send(). We don't need websocket behavior here. +class MockRelay extends AbstractRelay { + public sent: string[] = [] + constructor(url: string = 'wss://example.com') { + // pass a dummy verifyEvent function to satisfy constructor (always returns true) + const verifyEvent = (event: Event): event is VerifiedEvent => true + super(url, { verifyEvent }) + // Pretend it's connected so send() doesn't throw. + // @ts-ignore accessing private + this.connectionPromise = Promise.resolve() + } + public override async send(message: string) { + this.sent.push(message) + } +} + +function extractJSON(message: string) { + return JSON.parse(message) +} + +describe('nip77 negentropy message helpers', () => { + it('sendNegentropyMessage should send NEG-MSG with generated subscription id and filters flattened', () => { + const relay = new MockRelay() + const filters: Filter[] = [ + { kinds: [1], authors: ['abc'] }, + { ids: ['deadbeef'] }, + ] + sendNegentropyMessage(relay as any, 'hello', filters) + + expect(relay.sent.length).toBe(1) + const arr = extractJSON(relay.sent[0]) + expect(arr[0]).toBe('NEG-MSG') + expect(typeof arr[1]).toBe('string') // auto sub id + // message should include each filter object fields flattened after the sub id + // Request format built in nip77.ts: ["NEG-MSG","subId",,"msg"] + // So positions 2..n-2 are filter objects; last element is the msg string + expect(arr[arr.length - 1]).toBe('hello') + // Ensure at least one property from each filter is present + const serialized = relay.sent[0] + expect(serialized.includes('"kinds":[1]')).toBe(true) + expect(serialized.includes('"authors":["abc"]')).toBe(true) + expect(serialized.includes('"ids":["deadbeef"]')).toBe(true) + }) + + it('openNegentropyWithMessage should send NEG-OPEN', () => { + const relay = new MockRelay() + const filters: Filter[] = [{ kinds: [3] }] + openNegentropyWithMessage(relay as any, 'init', filters, 'sub123') + + expect(relay.sent.length).toBe(1) + const arr = extractJSON(relay.sent[0]) + expect(arr[0]).toBe('NEG-OPEN') + expect(arr[1]).toBe('sub123') + expect(arr[arr.length - 1]).toBe('init') + }) + + it('closeNegentropy should send NEG-CLOSE with given subscription id', () => { + const relay = new MockRelay() + closeNegentropy(relay as any, 'subXYZ') + + expect(relay.sent.length).toBe(1) + const arr = extractJSON(relay.sent[0]) + expect(arr).toEqual(['NEG-CLOSE', 'subXYZ']) + }) + + it('sendNegentropyMessage should honor provided subscriptionId', () => { + const relay = new MockRelay() + const filters: Filter[] = [{ kinds: [1] }] + sendNegentropyMessage(relay as any, 'custom', filters, 'customSub') + const arr = extractJSON(relay.sent[0]) + expect(arr[1]).toBe('customSub') + }) + + it('should handle empty filters array (request still valid)', () => { + const relay = new MockRelay() + sendNegentropyMessage(relay as any, 'nofilters', []) + const arr = extractJSON(relay.sent[0]) + expect(arr[0]).toBe('NEG-MSG') + expect(typeof arr[1]).toBe('string') + // With empty filters array we expect exactly 3 elements: type, subId, msg + expect(arr.length).toBe(3) + expect(arr[2]).toBe('nofilters') + }) +}) diff --git a/nip77.ts b/nip77.ts new file mode 100644 index 0000000..9c83ec5 --- /dev/null +++ b/nip77.ts @@ -0,0 +1,21 @@ +import { Filter } from './filter.ts' +import { AbstractRelay } from './relay.ts' + +function sendNegentropyToRelay(open: boolean, relay: AbstractRelay, msg: string, filters: Filter[], subscriptionId?: any): void { + const subId = subscriptionId || Math.random().toString(36).slice(2, 10) + const parts: any[] = [open ? 'NEG-OPEN' : 'NEG-MSG', subId, ...filters, msg] + relay.send(JSON.stringify(parts)) +} + +export function sendNegentropyMessage(relay: AbstractRelay, msg: string, filters: Filter[], subscriptionId?: any): void { + sendNegentropyToRelay(false, relay, msg, filters, subscriptionId) +} + +export function openNegentropyWithMessage(relay: AbstractRelay, msg: string, filters: Filter[], subscriptionId?: any): void { + sendNegentropyToRelay(true, relay, msg, filters, subscriptionId) +} + +export function closeNegentropy(relay: AbstractRelay, subscriptionId: string): void { + const request = '["NEG-CLOSE","' + subscriptionId + '"]' + relay.send(request) +}