@toon-protocol/relay 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,1196 @@
1
+ // src/types.ts
2
+ var DEFAULT_RELAY_CONFIG = {
3
+ port: 7e3,
4
+ maxConnections: 100,
5
+ maxSubscriptionsPerConnection: 20,
6
+ maxFiltersPerSubscription: 10,
7
+ databasePath: ":memory:"
8
+ };
9
+
10
+ // src/filters/matchFilter.ts
11
+ function matchFilter(event, filter) {
12
+ if (Object.keys(filter).length === 0) {
13
+ return true;
14
+ }
15
+ if (filter.ids !== void 0 && filter.ids.length > 0) {
16
+ const matches = filter.ids.some((id) => event.id.startsWith(id));
17
+ if (!matches) return false;
18
+ }
19
+ if (filter.authors !== void 0 && filter.authors.length > 0) {
20
+ const matches = filter.authors.some(
21
+ (author) => event.pubkey.startsWith(author)
22
+ );
23
+ if (!matches) return false;
24
+ }
25
+ if (filter.kinds !== void 0 && filter.kinds.length > 0) {
26
+ if (!filter.kinds.includes(event.kind)) return false;
27
+ }
28
+ if (filter.since !== void 0) {
29
+ if (event.created_at < filter.since) return false;
30
+ }
31
+ if (filter.until !== void 0) {
32
+ if (event.created_at > filter.until) return false;
33
+ }
34
+ for (const key of Object.keys(filter)) {
35
+ if (key.startsWith("#") && key.length === 2) {
36
+ const tagName = key.slice(1);
37
+ const filterValues = filter[key];
38
+ if (filterValues !== void 0 && filterValues.length > 0) {
39
+ const eventTagValues = event.tags.filter((tag) => tag[0] === tagName).map((tag) => tag[1]);
40
+ const hasMatch = filterValues.some((v) => eventTagValues.includes(v));
41
+ if (!hasMatch) return false;
42
+ }
43
+ }
44
+ }
45
+ return true;
46
+ }
47
+
48
+ // src/storage/InMemoryEventStore.ts
49
+ var InMemoryEventStore = class {
50
+ events = /* @__PURE__ */ new Map();
51
+ store(event) {
52
+ this.events.set(event.id, event);
53
+ }
54
+ get(id) {
55
+ return this.events.get(id);
56
+ }
57
+ query(filters) {
58
+ const allEvents = Array.from(this.events.values());
59
+ if (filters.length === 0) {
60
+ return allEvents.sort((a, b) => b.created_at - a.created_at);
61
+ }
62
+ const matchingEvents = [];
63
+ for (const event of allEvents) {
64
+ for (const filter of filters) {
65
+ if (matchFilter(event, filter)) {
66
+ matchingEvents.push(event);
67
+ break;
68
+ }
69
+ }
70
+ }
71
+ matchingEvents.sort((a, b) => b.created_at - a.created_at);
72
+ const limitFilter = filters.find((f) => f.limit !== void 0);
73
+ if (limitFilter?.limit !== void 0) {
74
+ return matchingEvents.slice(0, limitFilter.limit);
75
+ }
76
+ return matchingEvents;
77
+ }
78
+ /**
79
+ * Close the storage backend (no-op for in-memory store).
80
+ */
81
+ close() {
82
+ }
83
+ };
84
+
85
+ // src/storage/SqliteEventStore.ts
86
+ import Database from "better-sqlite3";
87
+ var SCHEMA_SQL = `
88
+ CREATE TABLE IF NOT EXISTS events (
89
+ id TEXT PRIMARY KEY,
90
+ pubkey TEXT NOT NULL,
91
+ kind INTEGER NOT NULL,
92
+ content TEXT NOT NULL,
93
+ tags TEXT NOT NULL,
94
+ created_at INTEGER NOT NULL,
95
+ sig TEXT NOT NULL,
96
+ received_at INTEGER NOT NULL
97
+ )
98
+ `;
99
+ var INDEX_SQL = [
100
+ "CREATE INDEX IF NOT EXISTS idx_events_pubkey ON events(pubkey)",
101
+ "CREATE INDEX IF NOT EXISTS idx_events_kind ON events(kind)",
102
+ "CREATE INDEX IF NOT EXISTS idx_events_created_at ON events(created_at)",
103
+ "CREATE INDEX IF NOT EXISTS idx_events_pubkey_kind ON events(pubkey, kind)"
104
+ ];
105
+ function initializeSchema(db) {
106
+ db.exec(SCHEMA_SQL);
107
+ for (const indexSql of INDEX_SQL) {
108
+ db.exec(indexSql);
109
+ }
110
+ }
111
+ var RelayError = class extends Error {
112
+ constructor(message, code) {
113
+ super(message);
114
+ this.code = code;
115
+ this.name = "RelayError";
116
+ }
117
+ };
118
+ function isReplaceableKind(kind) {
119
+ return kind >= 1e4 && kind <= 19999;
120
+ }
121
+ function isParameterizedReplaceableKind(kind) {
122
+ return kind >= 3e4 && kind <= 39999;
123
+ }
124
+ function getDTagValue(tags) {
125
+ const dTag = tags.find((tag) => tag[0] === "d");
126
+ return dTag?.[1] ?? "";
127
+ }
128
+ var SqliteEventStore = class {
129
+ db;
130
+ insertStmt;
131
+ getStmt;
132
+ deleteByPubkeyKindStmt;
133
+ deleteByPubkeyKindDTagStmt;
134
+ getByPubkeyKindStmt;
135
+ getByPubkeyKindDTagStmt;
136
+ /**
137
+ * Create a new SqliteEventStore.
138
+ * @param dbPath - Path to the database file. Use ':memory:' for in-memory database.
139
+ */
140
+ constructor(dbPath = ":memory:") {
141
+ try {
142
+ this.db = new Database(dbPath);
143
+ initializeSchema(this.db);
144
+ this.insertStmt = this.db.prepare(`
145
+ INSERT OR REPLACE INTO events (id, pubkey, kind, content, tags, created_at, sig, received_at)
146
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
147
+ `);
148
+ this.getStmt = this.db.prepare("SELECT * FROM events WHERE id = ?");
149
+ this.deleteByPubkeyKindStmt = this.db.prepare(
150
+ "DELETE FROM events WHERE pubkey = ? AND kind = ?"
151
+ );
152
+ this.deleteByPubkeyKindDTagStmt = this.db.prepare(
153
+ "DELETE FROM events WHERE pubkey = ? AND kind = ? AND json_extract(tags, '$') LIKE ?"
154
+ );
155
+ this.getByPubkeyKindStmt = this.db.prepare(
156
+ "SELECT id, created_at FROM events WHERE pubkey = ? AND kind = ?"
157
+ );
158
+ this.getByPubkeyKindDTagStmt = this.db.prepare(
159
+ "SELECT id, created_at FROM events WHERE pubkey = ? AND kind = ? AND tags LIKE ?"
160
+ );
161
+ } catch (error) {
162
+ throw new RelayError(
163
+ `Failed to initialize database: ${error instanceof Error ? error.message : String(error)}`,
164
+ "STORAGE_ERROR"
165
+ );
166
+ }
167
+ }
168
+ /**
169
+ * Store an event in the database.
170
+ * Handles replaceable and parameterized replaceable events according to NIP-01.
171
+ */
172
+ store(event) {
173
+ try {
174
+ const tagsJson = JSON.stringify(event.tags);
175
+ const receivedAt = Math.floor(Date.now() / 1e3);
176
+ if (isReplaceableKind(event.kind)) {
177
+ this.storeReplaceableEvent(event, tagsJson, receivedAt);
178
+ } else if (isParameterizedReplaceableKind(event.kind)) {
179
+ this.storeParameterizedReplaceableEvent(event, tagsJson, receivedAt);
180
+ } else {
181
+ const insertOrIgnore = this.db.prepare(`
182
+ INSERT OR IGNORE INTO events (id, pubkey, kind, content, tags, created_at, sig, received_at)
183
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
184
+ `);
185
+ insertOrIgnore.run(
186
+ event.id,
187
+ event.pubkey,
188
+ event.kind,
189
+ event.content,
190
+ tagsJson,
191
+ event.created_at,
192
+ event.sig,
193
+ receivedAt
194
+ );
195
+ }
196
+ } catch (error) {
197
+ if (error instanceof RelayError) {
198
+ throw error;
199
+ }
200
+ throw new RelayError(
201
+ `Failed to store event: ${error instanceof Error ? error.message : String(error)}`,
202
+ "STORAGE_ERROR"
203
+ );
204
+ }
205
+ }
206
+ /**
207
+ * Store a replaceable event (kinds 10000-19999).
208
+ * Only keeps the latest event per pubkey+kind.
209
+ */
210
+ storeReplaceableEvent(event, tagsJson, receivedAt) {
211
+ const existing = this.getByPubkeyKindStmt.get(event.pubkey, event.kind);
212
+ if (existing) {
213
+ if (event.created_at > existing.created_at || event.created_at === existing.created_at && event.id < existing.id) {
214
+ const transaction = this.db.transaction(() => {
215
+ this.deleteByPubkeyKindStmt.run(event.pubkey, event.kind);
216
+ this.insertStmt.run(
217
+ event.id,
218
+ event.pubkey,
219
+ event.kind,
220
+ event.content,
221
+ tagsJson,
222
+ event.created_at,
223
+ event.sig,
224
+ receivedAt
225
+ );
226
+ });
227
+ transaction();
228
+ }
229
+ } else {
230
+ this.insertStmt.run(
231
+ event.id,
232
+ event.pubkey,
233
+ event.kind,
234
+ event.content,
235
+ tagsJson,
236
+ event.created_at,
237
+ event.sig,
238
+ receivedAt
239
+ );
240
+ }
241
+ }
242
+ /**
243
+ * Store a parameterized replaceable event (kinds 30000-39999).
244
+ * Only keeps the latest event per pubkey+kind+d-tag.
245
+ */
246
+ storeParameterizedReplaceableEvent(event, tagsJson, receivedAt) {
247
+ const dTagValue = getDTagValue(event.tags);
248
+ let existing;
249
+ if (dTagValue === "") {
250
+ const candidates = this.db.prepare(
251
+ "SELECT id, created_at, tags FROM events WHERE pubkey = ? AND kind = ?"
252
+ ).all(event.pubkey, event.kind);
253
+ for (const candidate of candidates) {
254
+ const candidateTags = JSON.parse(candidate.tags);
255
+ const candidateDTagValue = getDTagValue(candidateTags);
256
+ if (candidateDTagValue === "") {
257
+ existing = { id: candidate.id, created_at: candidate.created_at };
258
+ break;
259
+ }
260
+ }
261
+ } else {
262
+ const dTagPattern = `%["d","${dTagValue}"%`;
263
+ existing = this.getByPubkeyKindDTagStmt.get(
264
+ event.pubkey,
265
+ event.kind,
266
+ dTagPattern
267
+ );
268
+ }
269
+ if (existing) {
270
+ if (event.created_at > existing.created_at || event.created_at === existing.created_at && event.id < existing.id) {
271
+ const transaction = this.db.transaction(() => {
272
+ this.db.prepare("DELETE FROM events WHERE id = ?").run(existing.id);
273
+ this.insertStmt.run(
274
+ event.id,
275
+ event.pubkey,
276
+ event.kind,
277
+ event.content,
278
+ tagsJson,
279
+ event.created_at,
280
+ event.sig,
281
+ receivedAt
282
+ );
283
+ });
284
+ transaction();
285
+ }
286
+ } else {
287
+ this.insertStmt.run(
288
+ event.id,
289
+ event.pubkey,
290
+ event.kind,
291
+ event.content,
292
+ tagsJson,
293
+ event.created_at,
294
+ event.sig,
295
+ receivedAt
296
+ );
297
+ }
298
+ }
299
+ /**
300
+ * Retrieve an event by its ID.
301
+ */
302
+ get(id) {
303
+ try {
304
+ const row = this.getStmt.get(id);
305
+ if (!row) {
306
+ return void 0;
307
+ }
308
+ return {
309
+ id: row.id,
310
+ pubkey: row.pubkey,
311
+ kind: row.kind,
312
+ content: row.content,
313
+ tags: JSON.parse(row.tags),
314
+ created_at: row.created_at,
315
+ sig: row.sig
316
+ };
317
+ } catch (error) {
318
+ throw new RelayError(
319
+ `Failed to get event: ${error instanceof Error ? error.message : String(error)}`,
320
+ "STORAGE_ERROR"
321
+ );
322
+ }
323
+ }
324
+ /**
325
+ * Query events matching any of the provided filters.
326
+ */
327
+ query(filters) {
328
+ try {
329
+ const { sql, params } = this.buildQuerySql(filters);
330
+ const stmt = this.db.prepare(sql);
331
+ const rows = stmt.all(...params);
332
+ return rows.map((row) => ({
333
+ id: row.id,
334
+ pubkey: row.pubkey,
335
+ kind: row.kind,
336
+ content: row.content,
337
+ tags: JSON.parse(row.tags),
338
+ created_at: row.created_at,
339
+ sig: row.sig
340
+ }));
341
+ } catch (error) {
342
+ throw new RelayError(
343
+ `Failed to query events: ${error instanceof Error ? error.message : String(error)}`,
344
+ "STORAGE_ERROR"
345
+ );
346
+ }
347
+ }
348
+ /**
349
+ * Build SQL query from filters.
350
+ */
351
+ buildQuerySql(filters) {
352
+ if (filters.length === 0) {
353
+ return {
354
+ sql: "SELECT * FROM events ORDER BY created_at DESC",
355
+ params: []
356
+ };
357
+ }
358
+ const conditions = [];
359
+ const params = [];
360
+ for (const filter of filters) {
361
+ const filterConditions = [];
362
+ if (filter.ids?.length) {
363
+ const idConditions = filter.ids.map(() => "id LIKE ?");
364
+ filterConditions.push(`(${idConditions.join(" OR ")})`);
365
+ params.push(...filter.ids.map((id) => `${id}%`));
366
+ }
367
+ if (filter.authors?.length) {
368
+ const authorConditions = filter.authors.map(() => "pubkey LIKE ?");
369
+ filterConditions.push(`(${authorConditions.join(" OR ")})`);
370
+ params.push(...filter.authors.map((a) => `${a}%`));
371
+ }
372
+ if (filter.kinds?.length) {
373
+ filterConditions.push(
374
+ `kind IN (${filter.kinds.map(() => "?").join(", ")})`
375
+ );
376
+ params.push(...filter.kinds);
377
+ }
378
+ if (filter.since !== void 0) {
379
+ filterConditions.push("created_at >= ?");
380
+ params.push(filter.since);
381
+ }
382
+ if (filter.until !== void 0) {
383
+ filterConditions.push("created_at <= ?");
384
+ params.push(filter.until);
385
+ }
386
+ for (const [key, values] of Object.entries(filter)) {
387
+ if (key.startsWith("#") && Array.isArray(values) && values.length > 0) {
388
+ const tagName = key.slice(1);
389
+ const tagConditions = values.map(() => `tags LIKE ?`);
390
+ filterConditions.push(`(${tagConditions.join(" OR ")})`);
391
+ params.push(...values.map((v) => `%["${tagName}","${v}"%`));
392
+ }
393
+ }
394
+ if (filterConditions.length > 0) {
395
+ conditions.push(`(${filterConditions.join(" AND ")})`);
396
+ }
397
+ }
398
+ let sql = "SELECT * FROM events";
399
+ if (conditions.length > 0) {
400
+ sql += ` WHERE ${conditions.join(" OR ")}`;
401
+ }
402
+ sql += " ORDER BY created_at DESC";
403
+ const limitFilter = filters.find((f) => f.limit !== void 0);
404
+ if (limitFilter?.limit !== void 0) {
405
+ sql += " LIMIT ?";
406
+ params.push(limitFilter.limit);
407
+ }
408
+ return { sql, params };
409
+ }
410
+ /**
411
+ * Close the database connection.
412
+ */
413
+ close() {
414
+ this.db.close();
415
+ }
416
+ };
417
+
418
+ // src/toon/index.ts
419
+ import {
420
+ encodeEventToToon,
421
+ encodeEventToToonString,
422
+ ToonEncodeError
423
+ } from "@toon-protocol/core";
424
+ import { decodeEventFromToon, ToonDecodeError } from "@toon-protocol/core";
425
+
426
+ // src/websocket/ConnectionHandler.ts
427
+ var ConnectionHandler = class {
428
+ constructor(ws, eventStore, config = {}) {
429
+ this.ws = ws;
430
+ this.eventStore = eventStore;
431
+ this.config = { ...DEFAULT_RELAY_CONFIG, ...config };
432
+ }
433
+ subscriptions = /* @__PURE__ */ new Map();
434
+ config;
435
+ /**
436
+ * Handle an incoming message from the WebSocket.
437
+ */
438
+ handleMessage(data) {
439
+ console.log(`[ConnectionHandler] Received message:`, data.slice(0, 150));
440
+ let message;
441
+ try {
442
+ const parsed = JSON.parse(data);
443
+ if (!Array.isArray(parsed)) {
444
+ this.sendNotice("error: invalid message format, expected JSON array");
445
+ return;
446
+ }
447
+ message = parsed;
448
+ } catch {
449
+ this.sendNotice("error: invalid JSON");
450
+ return;
451
+ }
452
+ const messageType = message[0];
453
+ console.log(`[ConnectionHandler] Message type: ${messageType}`);
454
+ if (messageType === "REQ") {
455
+ const subscriptionId = message[1];
456
+ const filters = message.slice(2);
457
+ this.handleReq(subscriptionId, filters);
458
+ } else if (messageType === "EVENT") {
459
+ const event = message[1];
460
+ this.handleEvent(event);
461
+ } else if (messageType === "CLOSE") {
462
+ const subscriptionId = message[1];
463
+ this.handleClose(subscriptionId);
464
+ } else {
465
+ this.sendNotice(`error: unknown message type: ${messageType}`);
466
+ }
467
+ }
468
+ /**
469
+ * Handle a REQ message to create/update a subscription.
470
+ */
471
+ handleReq(subscriptionId, filters) {
472
+ if (typeof subscriptionId !== "string" || subscriptionId.length === 0) {
473
+ this.sendNotice("error: invalid subscription id");
474
+ return;
475
+ }
476
+ if (!this.subscriptions.has(subscriptionId)) {
477
+ if (this.subscriptions.size >= this.config.maxSubscriptionsPerConnection) {
478
+ this.sendNotice("error: too many subscriptions");
479
+ return;
480
+ }
481
+ }
482
+ if (filters.length > this.config.maxFiltersPerSubscription) {
483
+ this.sendNotice("error: too many filters");
484
+ return;
485
+ }
486
+ this.subscriptions.set(subscriptionId, {
487
+ id: subscriptionId,
488
+ filters
489
+ });
490
+ console.log(
491
+ `[ConnectionHandler] REQ: ${subscriptionId}, filters:`,
492
+ JSON.stringify(filters).slice(0, 100)
493
+ );
494
+ const events = this.eventStore.query(filters);
495
+ console.log(
496
+ `[ConnectionHandler] Query returned ${events.length} events for ${subscriptionId}`
497
+ );
498
+ for (const event of events) {
499
+ console.log(
500
+ `[ConnectionHandler] Sending event ${event.id.slice(0, 16)}... to ${subscriptionId}`
501
+ );
502
+ this.sendEvent(subscriptionId, event);
503
+ }
504
+ console.log(`[ConnectionHandler] Sending EOSE for ${subscriptionId}`);
505
+ this.sendEose(subscriptionId);
506
+ }
507
+ /**
508
+ * Handle an EVENT message to store an event.
509
+ * Accepts events for free (no payment required).
510
+ */
511
+ handleEvent(event) {
512
+ console.log(
513
+ `[ConnectionHandler] Received EVENT: ${event.id.slice(0, 16)}... kind:${event.kind}`
514
+ );
515
+ try {
516
+ this.eventStore.store(event);
517
+ console.log(`[ConnectionHandler] Event stored successfully`);
518
+ this.sendOk(event.id, true, "");
519
+ } catch (error) {
520
+ console.error(`[ConnectionHandler] Failed to store event:`, error);
521
+ this.sendOk(
522
+ event.id,
523
+ false,
524
+ error instanceof Error ? error.message : "storage failed"
525
+ );
526
+ }
527
+ }
528
+ /**
529
+ * Handle a CLOSE message to terminate a subscription.
530
+ */
531
+ handleClose(subscriptionId) {
532
+ this.subscriptions.delete(subscriptionId);
533
+ }
534
+ /**
535
+ * Push a new event to all matching subscriptions on this connection.
536
+ * Used when events are stored outside the WebSocket flow (e.g., via ILP).
537
+ */
538
+ notifyNewEvent(event) {
539
+ for (const sub of this.subscriptions.values()) {
540
+ const matches = sub.filters.some((f) => matchFilter(event, f));
541
+ if (matches) {
542
+ this.sendEvent(sub.id, event);
543
+ }
544
+ }
545
+ }
546
+ /**
547
+ * Clean up all subscriptions for this connection.
548
+ */
549
+ cleanup() {
550
+ this.subscriptions.clear();
551
+ }
552
+ /**
553
+ * Get the number of active subscriptions.
554
+ */
555
+ getSubscriptionCount() {
556
+ return this.subscriptions.size;
557
+ }
558
+ sendEvent(subscriptionId, event) {
559
+ this.send(["EVENT", subscriptionId, encodeEventToToonString(event)]);
560
+ }
561
+ sendEose(subscriptionId) {
562
+ this.send(["EOSE", subscriptionId]);
563
+ }
564
+ sendOk(eventId, success, message) {
565
+ this.send(["OK", eventId, success, message]);
566
+ }
567
+ sendNotice(message) {
568
+ this.send(["NOTICE", message]);
569
+ }
570
+ send(message) {
571
+ if (this.ws.readyState === 1) {
572
+ this.ws.send(JSON.stringify(message));
573
+ }
574
+ }
575
+ };
576
+
577
+ // src/websocket/NostrRelayServer.ts
578
+ import { WebSocketServer } from "ws";
579
+ var NostrRelayServer = class {
580
+ constructor(config = {}, eventStore) {
581
+ this.eventStore = eventStore;
582
+ this.config = { ...DEFAULT_RELAY_CONFIG, ...config };
583
+ }
584
+ wss = null;
585
+ handlers = /* @__PURE__ */ new Map();
586
+ config;
587
+ /**
588
+ * Start the WebSocket server.
589
+ */
590
+ async start() {
591
+ return new Promise((resolve, reject) => {
592
+ try {
593
+ this.wss = new WebSocketServer({ port: this.config.port });
594
+ this.wss.on("connection", (ws) => {
595
+ this.handleConnection(ws);
596
+ });
597
+ this.wss.on("error", (error) => {
598
+ console.error("[NostrRelayServer] Server error:", error.message);
599
+ });
600
+ this.wss.on("listening", () => {
601
+ const address = this.wss?.address();
602
+ if (address && typeof address === "object") {
603
+ console.log(`[NostrRelayServer] Listening on port ${address.port}`);
604
+ }
605
+ resolve();
606
+ });
607
+ } catch (error) {
608
+ reject(error);
609
+ }
610
+ });
611
+ }
612
+ /**
613
+ * Stop the WebSocket server and close all connections.
614
+ */
615
+ async stop() {
616
+ return new Promise((resolve) => {
617
+ if (!this.wss) {
618
+ resolve();
619
+ return;
620
+ }
621
+ for (const [ws, handler] of this.handlers) {
622
+ handler.cleanup();
623
+ ws.close();
624
+ }
625
+ this.handlers.clear();
626
+ this.wss.close(() => {
627
+ this.wss = null;
628
+ resolve();
629
+ });
630
+ });
631
+ }
632
+ /**
633
+ * Get the port the server is listening on.
634
+ * Returns 0 if the server is not started.
635
+ */
636
+ getPort() {
637
+ if (!this.wss) return 0;
638
+ const address = this.wss.address();
639
+ if (address && typeof address === "object") {
640
+ return address.port;
641
+ }
642
+ return 0;
643
+ }
644
+ /**
645
+ * Get the number of connected clients.
646
+ */
647
+ getClientCount() {
648
+ return this.handlers.size;
649
+ }
650
+ /**
651
+ * Broadcast an event to all connected clients with matching subscriptions.
652
+ * Call this after storing an event outside the WebSocket flow (e.g., via ILP)
653
+ * so that discovery subscribers are notified.
654
+ */
655
+ broadcastEvent(event) {
656
+ for (const handler of this.handlers.values()) {
657
+ handler.notifyNewEvent(event);
658
+ }
659
+ }
660
+ handleConnection(ws) {
661
+ if (this.handlers.size >= this.config.maxConnections) {
662
+ ws.close(1013, "max connections reached");
663
+ return;
664
+ }
665
+ console.log("[NostrRelayServer] Client connected");
666
+ const handler = new ConnectionHandler(ws, this.eventStore, this.config);
667
+ this.handlers.set(ws, handler);
668
+ ws.on("message", (data) => {
669
+ const message = typeof data === "string" ? data : data.toString();
670
+ handler.handleMessage(message);
671
+ });
672
+ ws.on("close", () => {
673
+ console.log("[NostrRelayServer] Client disconnected");
674
+ handler.cleanup();
675
+ this.handlers.delete(ws);
676
+ });
677
+ ws.on("error", (error) => {
678
+ console.error("[NostrRelayServer] Client error:", error.message);
679
+ handler.cleanup();
680
+ this.handlers.delete(ws);
681
+ });
682
+ }
683
+ };
684
+
685
+ // src/bls/types.ts
686
+ var PUBKEY_REGEX = /^[0-9a-f]{64}$/;
687
+ function isValidPubkey(pubkey) {
688
+ return PUBKEY_REGEX.test(pubkey);
689
+ }
690
+ var ILP_ERROR_CODES = {
691
+ BAD_REQUEST: "F00",
692
+ INSUFFICIENT_AMOUNT: "F06",
693
+ INTERNAL_ERROR: "T00"
694
+ };
695
+ var BlsError = class extends RelayError {
696
+ constructor(message, code = "BLS_ERROR") {
697
+ super(message, code);
698
+ this.name = "BlsError";
699
+ }
700
+ };
701
+
702
+ // src/bls/BusinessLogicServer.ts
703
+ import { createHash } from "crypto";
704
+ import { Hono } from "hono";
705
+ import { verifyEvent } from "nostr-tools/pure";
706
+ function generateFulfillment(eventId) {
707
+ const hash = createHash("sha256").update(eventId).digest();
708
+ return hash.toString("base64");
709
+ }
710
+ var BusinessLogicServer = class {
711
+ constructor(config, eventStore) {
712
+ this.config = config;
713
+ this.eventStore = eventStore;
714
+ if (config.ownerPubkey !== void 0 && !isValidPubkey(config.ownerPubkey)) {
715
+ throw new BlsError(
716
+ "Invalid ownerPubkey format: must be 64 lowercase hex characters",
717
+ "INVALID_CONFIG"
718
+ );
719
+ }
720
+ this.app = new Hono();
721
+ this.setupRoutes();
722
+ }
723
+ app;
724
+ /**
725
+ * Set up HTTP routes.
726
+ */
727
+ setupRoutes() {
728
+ this.app.post("/handle-packet", async (c) => {
729
+ try {
730
+ const body = await c.req.json();
731
+ const response = this.handlePacket(body);
732
+ return c.json(response, response.accept ? 200 : 400);
733
+ } catch (error) {
734
+ const response = {
735
+ accept: false,
736
+ code: ILP_ERROR_CODES.INTERNAL_ERROR,
737
+ message: error instanceof Error ? error.message : "Internal server error"
738
+ };
739
+ return c.json(response, 500);
740
+ }
741
+ });
742
+ this.app.get("/health", (c) => {
743
+ return c.json({
744
+ status: "healthy",
745
+ timestamp: Date.now()
746
+ });
747
+ });
748
+ }
749
+ /**
750
+ * Process a packet request.
751
+ *
752
+ * This method is public to support direct connector integration in embedded mode,
753
+ * where the connector calls this method directly via setPacketHandler() instead
754
+ * of making HTTP requests.
755
+ */
756
+ handlePacket(request) {
757
+ if (!request.amount || !request.destination || !request.data) {
758
+ return {
759
+ accept: false,
760
+ code: ILP_ERROR_CODES.BAD_REQUEST,
761
+ message: "Missing required fields: amount, destination, data"
762
+ };
763
+ }
764
+ let toonBytes;
765
+ try {
766
+ toonBytes = Uint8Array.from(Buffer.from(request.data, "base64"));
767
+ } catch {
768
+ return {
769
+ accept: false,
770
+ code: ILP_ERROR_CODES.BAD_REQUEST,
771
+ message: "Invalid base64 encoding in data field"
772
+ };
773
+ }
774
+ let event;
775
+ try {
776
+ event = decodeEventFromToon(toonBytes);
777
+ } catch (error) {
778
+ return {
779
+ accept: false,
780
+ code: ILP_ERROR_CODES.BAD_REQUEST,
781
+ message: `Invalid TOON data: ${error instanceof Error ? error.message : "Unknown error"}`
782
+ };
783
+ }
784
+ if (!verifyEvent(event)) {
785
+ return {
786
+ accept: false,
787
+ code: ILP_ERROR_CODES.BAD_REQUEST,
788
+ message: "Invalid event signature"
789
+ };
790
+ }
791
+ if (this.config.ownerPubkey && event.pubkey === this.config.ownerPubkey) {
792
+ try {
793
+ this.eventStore.store(event);
794
+ } catch (error) {
795
+ throw new BlsError(
796
+ `Failed to store event: ${error instanceof Error ? error.message : "Unknown error"}`,
797
+ "STORAGE_ERROR"
798
+ );
799
+ }
800
+ return {
801
+ accept: true,
802
+ fulfillment: generateFulfillment(event.id),
803
+ metadata: {
804
+ eventId: event.id,
805
+ storedAt: Date.now()
806
+ }
807
+ };
808
+ }
809
+ const price = this.config.pricingService ? this.config.pricingService.calculatePriceFromBytes(
810
+ toonBytes,
811
+ event.kind
812
+ ) : BigInt(toonBytes.length) * this.config.basePricePerByte;
813
+ let amount;
814
+ try {
815
+ amount = BigInt(request.amount);
816
+ } catch {
817
+ return {
818
+ accept: false,
819
+ code: ILP_ERROR_CODES.BAD_REQUEST,
820
+ message: "Invalid amount format"
821
+ };
822
+ }
823
+ if (amount < price) {
824
+ return {
825
+ accept: false,
826
+ code: ILP_ERROR_CODES.INSUFFICIENT_AMOUNT,
827
+ message: "Insufficient payment amount",
828
+ metadata: {
829
+ required: price.toString(),
830
+ received: amount.toString()
831
+ }
832
+ };
833
+ }
834
+ try {
835
+ this.eventStore.store(event);
836
+ } catch (error) {
837
+ throw new BlsError(
838
+ `Failed to store event: ${error instanceof Error ? error.message : "Unknown error"}`,
839
+ "STORAGE_ERROR"
840
+ );
841
+ }
842
+ const storedAt = Date.now();
843
+ return {
844
+ accept: true,
845
+ fulfillment: generateFulfillment(event.id),
846
+ metadata: {
847
+ eventId: event.id,
848
+ storedAt
849
+ }
850
+ };
851
+ }
852
+ /**
853
+ * Get the Hono app instance for testing or composition.
854
+ */
855
+ getApp() {
856
+ return this.app;
857
+ }
858
+ /**
859
+ * Start the HTTP server on the specified port.
860
+ */
861
+ start(port) {
862
+ console.log(`BLS would start on port ${port}`);
863
+ }
864
+ };
865
+
866
+ // src/pricing/types.ts
867
+ var PricingError = class extends RelayError {
868
+ constructor(message, code = "PRICING_ERROR") {
869
+ super(message, code);
870
+ this.name = "PricingError";
871
+ }
872
+ };
873
+
874
+ // src/pricing/PricingService.ts
875
+ var PricingService = class {
876
+ basePricePerByte;
877
+ kindOverrides;
878
+ constructor(config) {
879
+ if (config.basePricePerByte < 0n) {
880
+ throw new PricingError(
881
+ "basePricePerByte must be non-negative",
882
+ "INVALID_CONFIG"
883
+ );
884
+ }
885
+ if (config.kindOverrides) {
886
+ for (const [kind, price] of config.kindOverrides.entries()) {
887
+ if (price < 0n) {
888
+ throw new PricingError(
889
+ `kindOverride for kind ${kind} must be non-negative`,
890
+ "INVALID_CONFIG"
891
+ );
892
+ }
893
+ }
894
+ }
895
+ this.basePricePerByte = config.basePricePerByte;
896
+ this.kindOverrides = config.kindOverrides ?? /* @__PURE__ */ new Map();
897
+ }
898
+ /**
899
+ * Calculate price for a Nostr event.
900
+ *
901
+ * @param event - The Nostr event to price
902
+ * @returns The calculated price as bigint
903
+ */
904
+ calculatePrice(event) {
905
+ const toonBytes = encodeEventToToon(event);
906
+ return this.calculatePriceFromBytes(toonBytes, event.kind);
907
+ }
908
+ /**
909
+ * Calculate price from raw TOON bytes and event kind.
910
+ *
911
+ * @param bytes - The TOON-encoded event bytes
912
+ * @param kind - The event kind number
913
+ * @returns The calculated price as bigint
914
+ */
915
+ calculatePriceFromBytes(bytes, kind) {
916
+ const pricePerByte = this.getPricePerByte(kind);
917
+ return BigInt(bytes.length) * pricePerByte;
918
+ }
919
+ /**
920
+ * Get the effective price per byte for a given kind.
921
+ *
922
+ * @param kind - The event kind number
923
+ * @returns The price per byte (kind override if exists, otherwise base price)
924
+ */
925
+ getPricePerByte(kind) {
926
+ return this.kindOverrides.get(kind) ?? this.basePricePerByte;
927
+ }
928
+ };
929
+
930
+ // src/pricing/config.ts
931
+ import { readFileSync } from "fs";
932
+ function loadPricingConfigFromEnv() {
933
+ const basePriceStr = process.env["RELAY_BASE_PRICE_PER_BYTE"] ?? "10";
934
+ let basePricePerByte;
935
+ try {
936
+ basePricePerByte = BigInt(basePriceStr);
937
+ } catch {
938
+ throw new PricingError(
939
+ `Invalid RELAY_BASE_PRICE_PER_BYTE: "${basePriceStr}" is not a valid integer`,
940
+ "INVALID_ENV_CONFIG"
941
+ );
942
+ }
943
+ if (basePricePerByte < 0n) {
944
+ throw new PricingError(
945
+ `Invalid RELAY_BASE_PRICE_PER_BYTE: value must be non-negative`,
946
+ "INVALID_ENV_CONFIG"
947
+ );
948
+ }
949
+ const kindOverridesStr = process.env["RELAY_KIND_OVERRIDES"];
950
+ let kindOverrides;
951
+ if (kindOverridesStr) {
952
+ kindOverrides = parseKindOverridesJson(kindOverridesStr);
953
+ }
954
+ return {
955
+ basePricePerByte,
956
+ kindOverrides
957
+ };
958
+ }
959
+ function loadPricingConfigFromFile(path) {
960
+ let fileContent;
961
+ try {
962
+ fileContent = readFileSync(path, "utf-8");
963
+ } catch (error) {
964
+ throw new PricingError(
965
+ `Failed to read config file: ${error instanceof Error ? error.message : "Unknown error"}`,
966
+ "CONFIG_FILE_ERROR"
967
+ );
968
+ }
969
+ let parsed;
970
+ try {
971
+ parsed = JSON.parse(fileContent);
972
+ } catch {
973
+ throw new PricingError(
974
+ `Invalid JSON in config file: ${path}`,
975
+ "INVALID_FILE_CONFIG"
976
+ );
977
+ }
978
+ if (typeof parsed !== "object" || parsed === null) {
979
+ throw new PricingError(
980
+ `Config file must contain a JSON object`,
981
+ "INVALID_FILE_CONFIG"
982
+ );
983
+ }
984
+ const config = parsed;
985
+ const basePriceStr = config["basePricePerByte"];
986
+ if (typeof basePriceStr !== "string" && typeof basePriceStr !== "number") {
987
+ throw new PricingError(
988
+ `Config file must contain "basePricePerByte" as string or number`,
989
+ "INVALID_FILE_CONFIG"
990
+ );
991
+ }
992
+ let basePricePerByte;
993
+ try {
994
+ basePricePerByte = BigInt(basePriceStr);
995
+ } catch {
996
+ throw new PricingError(
997
+ `Invalid basePricePerByte: "${basePriceStr}" is not a valid integer`,
998
+ "INVALID_FILE_CONFIG"
999
+ );
1000
+ }
1001
+ if (basePricePerByte < 0n) {
1002
+ throw new PricingError(
1003
+ `basePricePerByte must be non-negative`,
1004
+ "INVALID_FILE_CONFIG"
1005
+ );
1006
+ }
1007
+ let kindOverrides;
1008
+ const kindOverridesValue = config["kindOverrides"];
1009
+ if (kindOverridesValue !== void 0) {
1010
+ if (typeof kindOverridesValue !== "object" || kindOverridesValue === null) {
1011
+ throw new PricingError(
1012
+ `kindOverrides must be an object`,
1013
+ "INVALID_FILE_CONFIG"
1014
+ );
1015
+ }
1016
+ kindOverrides = /* @__PURE__ */ new Map();
1017
+ const overridesObj = kindOverridesValue;
1018
+ for (const [kindStr, priceValue] of Object.entries(overridesObj)) {
1019
+ const kind = parseInt(kindStr, 10);
1020
+ if (isNaN(kind)) {
1021
+ throw new PricingError(
1022
+ `Invalid kind in kindOverrides: "${kindStr}" is not a valid integer`,
1023
+ "INVALID_FILE_CONFIG"
1024
+ );
1025
+ }
1026
+ if (typeof priceValue !== "string" && typeof priceValue !== "number") {
1027
+ throw new PricingError(
1028
+ `Invalid price for kind ${kind}: must be string or number`,
1029
+ "INVALID_FILE_CONFIG"
1030
+ );
1031
+ }
1032
+ let price;
1033
+ try {
1034
+ price = BigInt(priceValue);
1035
+ } catch {
1036
+ throw new PricingError(
1037
+ `Invalid price for kind ${kind}: "${priceValue}" is not a valid integer`,
1038
+ "INVALID_FILE_CONFIG"
1039
+ );
1040
+ }
1041
+ if (price < 0n) {
1042
+ throw new PricingError(
1043
+ `Price for kind ${kind} must be non-negative`,
1044
+ "INVALID_FILE_CONFIG"
1045
+ );
1046
+ }
1047
+ kindOverrides.set(kind, price);
1048
+ }
1049
+ }
1050
+ return {
1051
+ basePricePerByte,
1052
+ kindOverrides
1053
+ };
1054
+ }
1055
+ function parseKindOverridesJson(jsonStr) {
1056
+ let parsed;
1057
+ try {
1058
+ parsed = JSON.parse(jsonStr);
1059
+ } catch {
1060
+ throw new PricingError(
1061
+ `Invalid JSON in RELAY_KIND_OVERRIDES: ${jsonStr}`,
1062
+ "INVALID_ENV_CONFIG"
1063
+ );
1064
+ }
1065
+ if (typeof parsed !== "object" || parsed === null) {
1066
+ throw new PricingError(
1067
+ `RELAY_KIND_OVERRIDES must be a JSON object`,
1068
+ "INVALID_ENV_CONFIG"
1069
+ );
1070
+ }
1071
+ const result = /* @__PURE__ */ new Map();
1072
+ const obj = parsed;
1073
+ for (const [kindStr, priceValue] of Object.entries(obj)) {
1074
+ const kind = parseInt(kindStr, 10);
1075
+ if (isNaN(kind)) {
1076
+ throw new PricingError(
1077
+ `Invalid kind in RELAY_KIND_OVERRIDES: "${kindStr}" is not a valid integer`,
1078
+ "INVALID_ENV_CONFIG"
1079
+ );
1080
+ }
1081
+ if (typeof priceValue !== "string" && typeof priceValue !== "number") {
1082
+ throw new PricingError(
1083
+ `Invalid price for kind ${kind} in RELAY_KIND_OVERRIDES: must be string or number`,
1084
+ "INVALID_ENV_CONFIG"
1085
+ );
1086
+ }
1087
+ let price;
1088
+ try {
1089
+ price = BigInt(priceValue);
1090
+ } catch {
1091
+ throw new PricingError(
1092
+ `Invalid price for kind ${kind} in RELAY_KIND_OVERRIDES: "${priceValue}" is not a valid integer`,
1093
+ "INVALID_ENV_CONFIG"
1094
+ );
1095
+ }
1096
+ if (price < 0n) {
1097
+ throw new PricingError(
1098
+ `Price for kind ${kind} in RELAY_KIND_OVERRIDES must be non-negative`,
1099
+ "INVALID_ENV_CONFIG"
1100
+ );
1101
+ }
1102
+ result.set(kind, price);
1103
+ }
1104
+ return result;
1105
+ }
1106
+
1107
+ // src/subscriber/RelaySubscriber.ts
1108
+ import { SimplePool } from "nostr-tools/pool";
1109
+ import { verifyEvent as verifyEvent2 } from "nostr-tools/pure";
1110
+ var RelaySubscriber = class {
1111
+ config;
1112
+ eventStore;
1113
+ pool;
1114
+ started = false;
1115
+ /**
1116
+ * @param config - Subscriber configuration
1117
+ * @param eventStore - Storage backend to write events into
1118
+ * @param pool - Optional SimplePool instance (creates new one if not provided)
1119
+ */
1120
+ constructor(config, eventStore, pool) {
1121
+ this.config = config;
1122
+ this.eventStore = eventStore;
1123
+ this.pool = pool ?? new SimplePool();
1124
+ }
1125
+ /**
1126
+ * Start subscribing to the configured upstream relays.
1127
+ *
1128
+ * @returns Handle with unsubscribe() to stop the subscription
1129
+ * @throws Error if already started
1130
+ */
1131
+ start() {
1132
+ if (this.started) {
1133
+ throw new Error("RelaySubscriber already started");
1134
+ }
1135
+ this.started = true;
1136
+ const shouldVerify = this.config.verifySignatures !== false;
1137
+ let isUnsubscribed = false;
1138
+ const subCloser = this.pool.subscribeMany(
1139
+ this.config.relayUrls,
1140
+ this.config.filter,
1141
+ {
1142
+ onevent: (event) => {
1143
+ if (isUnsubscribed) return;
1144
+ if (shouldVerify && !verifyEvent2(event)) {
1145
+ return;
1146
+ }
1147
+ try {
1148
+ this.eventStore.store(event);
1149
+ } catch (error) {
1150
+ console.warn(
1151
+ "[RelaySubscriber] Failed to store event:",
1152
+ error instanceof Error ? error.message : "Unknown error"
1153
+ );
1154
+ }
1155
+ }
1156
+ }
1157
+ );
1158
+ return {
1159
+ unsubscribe: () => {
1160
+ if (!isUnsubscribed) {
1161
+ isUnsubscribed = true;
1162
+ subCloser.close();
1163
+ this.started = false;
1164
+ }
1165
+ }
1166
+ };
1167
+ }
1168
+ };
1169
+
1170
+ // src/index.ts
1171
+ var VERSION = "0.1.0";
1172
+ export {
1173
+ BlsError,
1174
+ BusinessLogicServer,
1175
+ ConnectionHandler,
1176
+ DEFAULT_RELAY_CONFIG,
1177
+ ILP_ERROR_CODES,
1178
+ InMemoryEventStore,
1179
+ NostrRelayServer,
1180
+ PricingError,
1181
+ PricingService,
1182
+ RelayError,
1183
+ RelaySubscriber,
1184
+ SqliteEventStore,
1185
+ ToonDecodeError,
1186
+ ToonEncodeError,
1187
+ VERSION,
1188
+ decodeEventFromToon,
1189
+ encodeEventToToon,
1190
+ generateFulfillment,
1191
+ isValidPubkey,
1192
+ loadPricingConfigFromEnv,
1193
+ loadPricingConfigFromFile,
1194
+ matchFilter
1195
+ };
1196
+ //# sourceMappingURL=index.js.map