Skip to content

Commit ab7b7b6

Browse files
feat: in-memory test relay (serve) — NIP-01 events/subscriptions, NIP-11 info, real-time notifications, 314 tests
1 parent 40362e6 commit ab7b7b6

4 files changed

Lines changed: 427 additions & 1 deletion

File tree

src/cli.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,26 @@ const args = process.argv.slice(2)
2727
const command = args[0]
2828

2929
// No command = start MCP server
30-
if (!command || command === 'serve') {
30+
if (!command) {
3131
await import('./index.js')
3232
process.exit(0)
3333
}
3434

35+
// Serve = in-memory test relay
36+
if (command === 'serve' && !args.includes('--help')) {
37+
const { startRelay } = await import('./serve.js')
38+
const hostname = args.includes('--hostname') ? args[args.indexOf('--hostname') + 1] : 'localhost'
39+
const port = args.includes('--port') ? parseInt(args[args.indexOf('--port') + 1], 10) : 10547
40+
const eventsFile = args.includes('--events') ? args[args.indexOf('--events') + 1] : undefined
41+
const relay = startRelay({ hostname, port, eventsFile, quiet: args.includes('--quiet') })
42+
console.error(`nostr-bray test relay running at ${relay.url}`)
43+
console.error('Press Ctrl+C to stop')
44+
process.on('SIGINT', () => { relay.close(); process.exit(0) })
45+
process.on('SIGTERM', () => { relay.close(); process.exit(0) })
46+
// Keep process alive
47+
await new Promise(() => {})
48+
}
49+
3550
// Per-command help: `nostr-bray post --help`
3651
if (args.includes('--help') && command && command !== 'help' && command !== '--help' && command !== '-h') {
3752
const help = getCommandHelp(command)
@@ -132,6 +147,7 @@ Utility:
132147
133148
Modes:
134149
(no command) Start MCP server (stdio)
150+
serve [--port N] [--events file] Start in-memory test relay
135151
shell Interactive REPL (persistent relay connection)
136152
137153
Environment:

src/help.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ export const COMMAND_HELP: Record<string, { usage: string; description: string;
9292
count: { usage: 'count [--kinds N,N] [--authors X,X] [--since N]', description: 'Count events matching a filter on relays.', examples: ['nostr-bray count --kinds 1', 'nostr-bray count --kinds 1,7 --authors abc123...'] },
9393
fetch: { usage: 'fetch <nip19>', description: 'Fetch events by nip19 code (note, nevent, nprofile, npub, naddr). Resolves the entity and queries relays.', examples: ['nostr-bray fetch note1...', 'nostr-bray fetch nevent1...', 'nostr-bray fetch npub1...'] },
9494
shell: { usage: 'shell', description: 'Start an interactive REPL with a persistent relay connection. Supports tab autocomplete. Type "help" for commands, "exit" to quit.', examples: ['nostr-bray shell'] },
95+
serve: { usage: 'serve [--port N] [--hostname H] [--events file.jsonl] [--quiet]', description: 'Start an in-memory Nostr relay for testing. Implements NIP-01 (events, subscriptions, EOSE) and NIP-11 (relay info). Events live in memory only — no persistence. Optionally pre-load events from a JSONL file.', examples: ['nostr-bray serve', 'nostr-bray serve --port 7777', 'nostr-bray serve --events test-data.jsonl', 'nostr-bray serve --port 7777 --quiet'] },
9596
}
9697

9798
/** Get formatted help for a single command */

src/serve.ts

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
/**
2+
* In-memory Nostr relay for testing purposes.
3+
*
4+
* Implements NIP-01 (events, subscriptions, EOSE) and NIP-11 (relay info).
5+
* No persistence — events live in memory until the process exits.
6+
*/
7+
8+
import { WebSocketServer, WebSocket } from 'ws'
9+
import { createServer } from 'node:http'
10+
import { readFileSync } from 'node:fs'
11+
import { verifyEvent } from 'nostr-tools/pure'
12+
import type { Event as NostrEvent, Filter } from 'nostr-tools'
13+
14+
export interface ServeOptions {
15+
hostname?: string
16+
port?: number
17+
eventsFile?: string
18+
quiet?: boolean
19+
}
20+
21+
interface Subscription {
22+
id: string
23+
filters: Filter[]
24+
ws: WebSocket
25+
}
26+
27+
/** Check if an event matches a single filter */
28+
function matchFilter(filter: Filter, event: NostrEvent): boolean {
29+
if (filter.ids && !filter.ids.includes(event.id)) return false
30+
if (filter.kinds && !filter.kinds.includes(event.kind)) return false
31+
if (filter.authors && !filter.authors.includes(event.pubkey)) return false
32+
if (filter.since && event.created_at < filter.since) return false
33+
if (filter.until && event.created_at > filter.until) return false
34+
35+
// Tag filters (#e, #p, #t, #d, etc.)
36+
for (const key of Object.keys(filter)) {
37+
if (key.startsWith('#')) {
38+
const vals = (filter as Record<string, unknown>)[key] as string[] | undefined
39+
if (!vals || !Array.isArray(vals)) continue
40+
const tagName = key.slice(1)
41+
const eventTagValues = event.tags.filter(t => t[0] === tagName).map(t => t[1])
42+
if (!vals.some(v => eventTagValues.includes(v))) return false
43+
}
44+
}
45+
46+
return true
47+
}
48+
49+
/** Check if an event matches any filter in a list */
50+
function matchFilters(filters: Filter[], event: NostrEvent): boolean {
51+
return filters.some(f => matchFilter(f, event))
52+
}
53+
54+
export function startRelay(opts: ServeOptions = {}): { url: string; close: () => void } {
55+
const hostname = opts.hostname ?? 'localhost'
56+
const port = opts.port ?? 10547
57+
const quiet = opts.quiet ?? false
58+
const log = quiet ? () => {} : (...args: unknown[]) => console.error('[relay]', ...args)
59+
60+
const events = new Map<string, NostrEvent>()
61+
const subscriptions = new Map<string, Subscription>()
62+
63+
// Pre-load events from JSONL file
64+
if (opts.eventsFile) {
65+
const lines = readFileSync(opts.eventsFile, 'utf-8').split('\n').filter(Boolean)
66+
for (const line of lines) {
67+
try {
68+
const event = JSON.parse(line) as NostrEvent
69+
events.set(event.id, event)
70+
} catch { /* skip malformed lines */ }
71+
}
72+
log(`Loaded ${events.size} events from ${opts.eventsFile}`)
73+
}
74+
75+
const httpServer = createServer((req, res) => {
76+
// NIP-11 relay info document
77+
if (req.headers.accept?.includes('application/nostr+json')) {
78+
res.writeHead(200, { 'Content-Type': 'application/nostr+json' })
79+
res.end(JSON.stringify({
80+
name: 'nostr-bray test relay',
81+
description: 'In-memory relay for testing',
82+
supported_nips: [1, 11],
83+
software: 'nostr-bray',
84+
version: '0.1.0',
85+
}))
86+
return
87+
}
88+
res.writeHead(200, { 'Content-Type': 'text/plain' })
89+
res.end('nostr-bray test relay — connect via WebSocket')
90+
})
91+
92+
const wss = new WebSocketServer({ server: httpServer })
93+
94+
wss.on('connection', (ws) => {
95+
const clientSubs = new Set<string>()
96+
log('Client connected')
97+
98+
ws.on('message', (raw) => {
99+
let msg: unknown[]
100+
try {
101+
msg = JSON.parse(raw.toString())
102+
} catch {
103+
ws.send(JSON.stringify(['NOTICE', 'Invalid JSON']))
104+
return
105+
}
106+
107+
if (!Array.isArray(msg) || msg.length < 2) {
108+
ws.send(JSON.stringify(['NOTICE', 'Invalid message format']))
109+
return
110+
}
111+
112+
const type = msg[0]
113+
114+
if (type === 'EVENT') {
115+
const event = msg[1] as NostrEvent
116+
if (!event?.id || !event?.sig || !event?.pubkey) {
117+
ws.send(JSON.stringify(['OK', event?.id ?? '', false, 'invalid: missing fields']))
118+
return
119+
}
120+
121+
if (!verifyEvent(event)) {
122+
ws.send(JSON.stringify(['OK', event.id, false, 'invalid: signature verification failed']))
123+
return
124+
}
125+
126+
// Store event
127+
events.set(event.id, event)
128+
ws.send(JSON.stringify(['OK', event.id, true, '']))
129+
log(`Stored event ${event.id.slice(0, 8)}... kind:${event.kind}`)
130+
131+
// Notify matching subscriptions
132+
for (const sub of subscriptions.values()) {
133+
if (matchFilters(sub.filters, event) && sub.ws.readyState === WebSocket.OPEN) {
134+
sub.ws.send(JSON.stringify(['EVENT', sub.id, event]))
135+
}
136+
}
137+
} else if (type === 'REQ') {
138+
const subId = msg[1] as string
139+
const filters = msg.slice(2) as Filter[]
140+
141+
// Register subscription
142+
const sub: Subscription = { id: subId, filters, ws }
143+
const key = `${subId}-${Date.now()}`
144+
subscriptions.set(key, sub)
145+
clientSubs.add(key)
146+
147+
// Send matching stored events
148+
let count = 0
149+
const limit = filters.reduce((min, f) => Math.min(min, f.limit ?? Infinity), Infinity)
150+
const matching = [...events.values()]
151+
.filter(e => matchFilters(filters, e))
152+
.sort((a, b) => b.created_at - a.created_at)
153+
.slice(0, limit === Infinity ? undefined : limit)
154+
155+
for (const event of matching) {
156+
ws.send(JSON.stringify(['EVENT', subId, event]))
157+
count++
158+
}
159+
160+
// EOSE
161+
ws.send(JSON.stringify(['EOSE', subId]))
162+
log(`REQ ${subId}: ${count} events, ${filters.length} filter(s)`)
163+
} else if (type === 'CLOSE') {
164+
const subId = msg[1] as string
165+
for (const key of clientSubs) {
166+
if (subscriptions.get(key)?.id === subId) {
167+
subscriptions.delete(key)
168+
clientSubs.delete(key)
169+
}
170+
}
171+
ws.send(JSON.stringify(['CLOSED', subId, '']))
172+
} else if (type === 'COUNT') {
173+
const subId = msg[1] as string
174+
const filters = msg.slice(2) as Filter[]
175+
const count = [...events.values()].filter(e => matchFilters(filters, e)).length
176+
ws.send(JSON.stringify(['COUNT', subId, { count }]))
177+
} else {
178+
ws.send(JSON.stringify(['NOTICE', `Unknown message type: ${type}`]))
179+
}
180+
})
181+
182+
ws.on('close', () => {
183+
for (const key of clientSubs) {
184+
subscriptions.delete(key)
185+
}
186+
log('Client disconnected')
187+
})
188+
})
189+
190+
httpServer.listen(port, hostname, () => {
191+
log(`Listening on ws://${hostname}:${port}`)
192+
})
193+
194+
return {
195+
url: `ws://${hostname}:${port}`,
196+
close: () => {
197+
wss.close()
198+
httpServer.close()
199+
},
200+
}
201+
}

0 commit comments

Comments
 (0)