@toon-protocol/relay 2.0.1 → 2.1.0

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.
@@ -0,0 +1,1799 @@
1
+ import {
2
+ verifyEventId,
3
+ verifyEventSignature,
4
+ verifyImplementation
5
+ } from "./chunk-SMT6G3XD.js";
6
+
7
+ // src/version.ts
8
+ var VERSION = "2.1.0";
9
+
10
+ // src/types.ts
11
+ var DEFAULT_RELAY_CONFIG = {
12
+ port: 7100,
13
+ host: "0.0.0.0",
14
+ maxConnections: 4096,
15
+ maxSubscriptionsPerConnection: 20,
16
+ maxFiltersPerSubscription: 10,
17
+ databasePath: ":memory:",
18
+ enforceExpiration: true
19
+ };
20
+
21
+ // src/filters/matchFilter.ts
22
+ function matchFilter(event, filter) {
23
+ if (Object.keys(filter).length === 0) {
24
+ return true;
25
+ }
26
+ if (filter.ids !== void 0 && filter.ids.length > 0) {
27
+ const matches = filter.ids.some((id) => event.id.startsWith(id));
28
+ if (!matches) return false;
29
+ }
30
+ if (filter.authors !== void 0 && filter.authors.length > 0) {
31
+ const matches = filter.authors.some(
32
+ (author) => event.pubkey.startsWith(author)
33
+ );
34
+ if (!matches) return false;
35
+ }
36
+ if (filter.kinds !== void 0 && filter.kinds.length > 0) {
37
+ if (!filter.kinds.includes(event.kind)) return false;
38
+ }
39
+ if (filter.since !== void 0) {
40
+ if (event.created_at < filter.since) return false;
41
+ }
42
+ if (filter.until !== void 0) {
43
+ if (event.created_at > filter.until) return false;
44
+ }
45
+ for (const key of Object.keys(filter)) {
46
+ if (key.startsWith("#") && key.length === 2) {
47
+ const tagName = key.slice(1);
48
+ const filterValues = filter[key];
49
+ if (filterValues !== void 0 && filterValues.length > 0) {
50
+ const eventTagValues = event.tags.filter((tag) => tag[0] === tagName).map((tag) => tag[1]);
51
+ const hasMatch = filterValues.some((v) => eventTagValues.includes(v));
52
+ if (!hasMatch) return false;
53
+ }
54
+ }
55
+ }
56
+ return true;
57
+ }
58
+
59
+ // src/nips/expiration.ts
60
+ var EXPIRATION_TAG = "expiration";
61
+ function getExpiration(event) {
62
+ for (const tag of event.tags) {
63
+ if (tag[0] !== EXPIRATION_TAG) continue;
64
+ const raw = tag[1];
65
+ if (raw === void 0) continue;
66
+ if (!/^\d+$/.test(raw)) continue;
67
+ const seconds = Number(raw);
68
+ if (!Number.isSafeInteger(seconds)) continue;
69
+ return seconds;
70
+ }
71
+ return void 0;
72
+ }
73
+ function isExpired(event, nowSeconds2) {
74
+ const expiration = getExpiration(event);
75
+ return expiration !== void 0 && expiration <= nowSeconds2;
76
+ }
77
+
78
+ // src/nips/deletion.ts
79
+ var DELETION_KIND = 5;
80
+ function isDeletionKind(kind) {
81
+ return kind === DELETION_KIND;
82
+ }
83
+ var HEX_64 = /^[0-9a-f]{64}$/;
84
+ function parseAddressCoordinate(value) {
85
+ const firstSep = value.indexOf(":");
86
+ if (firstSep < 0) return void 0;
87
+ const secondSep = value.indexOf(":", firstSep + 1);
88
+ if (secondSep < 0) return void 0;
89
+ const kindPart = value.slice(0, firstSep);
90
+ const pubkey = value.slice(firstSep + 1, secondSep);
91
+ const identifier = value.slice(secondSep + 1);
92
+ if (!/^\d+$/.test(kindPart)) return void 0;
93
+ const kind = Number(kindPart);
94
+ if (!Number.isSafeInteger(kind)) return void 0;
95
+ if (!HEX_64.test(pubkey)) return void 0;
96
+ return { kind, pubkey, identifier };
97
+ }
98
+ function parseDeletionTargets(event) {
99
+ const ids = /* @__PURE__ */ new Set();
100
+ const addresses = /* @__PURE__ */ new Map();
101
+ for (const tag of event.tags) {
102
+ const value = tag[1];
103
+ if (value === void 0) continue;
104
+ if (tag[0] === "e") {
105
+ if (HEX_64.test(value)) ids.add(value);
106
+ } else if (tag[0] === "a") {
107
+ const coordinate = parseAddressCoordinate(value);
108
+ if (coordinate) addresses.set(value, coordinate);
109
+ }
110
+ }
111
+ return { ids: [...ids], addresses: [...addresses.values()] };
112
+ }
113
+ function isDeletableBy(target, deletion) {
114
+ return target.pubkey === deletion.pubkey && target.created_at <= deletion.created_at;
115
+ }
116
+
117
+ // src/storage/InMemoryEventStore.ts
118
+ var InMemoryEventStore = class _InMemoryEventStore {
119
+ events = /* @__PURE__ */ new Map();
120
+ /** NIP-09 id tombstones: event id -> the pubkey that requested deletion. */
121
+ deletedIds = /* @__PURE__ */ new Map();
122
+ /** NIP-09 address tombstones: `<kind>:<pubkey>:<d>` -> deletion created_at. */
123
+ deletedAddresses = /* @__PURE__ */ new Map();
124
+ enforceExpiration;
125
+ blockedEventIds;
126
+ constructor(options = {}) {
127
+ this.enforceExpiration = options.enforceExpiration ?? true;
128
+ this.blockedEventIds = new Set(options.blockedEventIds ?? []);
129
+ }
130
+ store(event) {
131
+ if (this.blockedEventIds.has(event.id)) return;
132
+ if (this.isRetracted(event)) return;
133
+ if (isDeletionKind(event.kind)) {
134
+ this.applyDeletion(event);
135
+ }
136
+ this.events.set(event.id, event);
137
+ }
138
+ get(id) {
139
+ const event = this.events.get(id);
140
+ if (!event) return void 0;
141
+ if (this.enforceExpiration && isExpired(event, nowSeconds())) {
142
+ return void 0;
143
+ }
144
+ return event;
145
+ }
146
+ query(filters) {
147
+ const now = nowSeconds();
148
+ const allEvents = Array.from(this.events.values()).filter(
149
+ (event) => !this.enforceExpiration || !isExpired(event, now)
150
+ );
151
+ if (filters.length === 0) {
152
+ return allEvents.sort((a, b) => b.created_at - a.created_at);
153
+ }
154
+ const matchingEvents = [];
155
+ for (const event of allEvents) {
156
+ for (const filter of filters) {
157
+ if (matchFilter(event, filter)) {
158
+ matchingEvents.push(event);
159
+ break;
160
+ }
161
+ }
162
+ }
163
+ matchingEvents.sort((a, b) => b.created_at - a.created_at);
164
+ const limitFilter = filters.find((f) => f.limit !== void 0);
165
+ if (limitFilter?.limit !== void 0) {
166
+ return matchingEvents.slice(0, limitFilter.limit);
167
+ }
168
+ return matchingEvents;
169
+ }
170
+ /**
171
+ * Drop events expired for longer than `graceSeconds` (NIP-40).
172
+ *
173
+ * @param now - Current unix time in seconds.
174
+ * @param graceSeconds - Extra time an expired event is kept.
175
+ * @returns The number of events removed.
176
+ */
177
+ reapExpired(now, graceSeconds = 0) {
178
+ let removed = 0;
179
+ for (const [id, event] of this.events) {
180
+ if (isExpired(event, now - graceSeconds)) {
181
+ this.events.delete(id);
182
+ removed++;
183
+ }
184
+ }
185
+ return removed;
186
+ }
187
+ /** The NIP-01 addressable coordinate of an event, `<kind>:<pubkey>:<d>`. */
188
+ static coordinateOf(event) {
189
+ const identifier = event.tags.find((tag) => tag[0] === "d")?.[1] ?? "";
190
+ return `${event.kind}:${event.pubkey}:${identifier}`;
191
+ }
192
+ /** Whether a NIP-09 request already retracted this event (same author). */
193
+ isRetracted(event) {
194
+ if (this.deletedIds.get(event.id) === event.pubkey) return true;
195
+ const deletedAt = this.deletedAddresses.get(
196
+ _InMemoryEventStore.coordinateOf(event)
197
+ );
198
+ return deletedAt !== void 0 && event.created_at <= deletedAt;
199
+ }
200
+ /** Apply a kind:5 request to the author's OWN events only. */
201
+ applyDeletion(deletion) {
202
+ const targets = parseDeletionTargets(deletion);
203
+ for (const id of targets.ids) {
204
+ this.deletedIds.set(id, deletion.pubkey);
205
+ const target = this.events.get(id);
206
+ if (target && isDeletableBy(target, deletion)) {
207
+ this.events.delete(id);
208
+ }
209
+ }
210
+ for (const address of targets.addresses) {
211
+ if (address.pubkey !== deletion.pubkey) continue;
212
+ const coordinate = `${address.kind}:${address.pubkey}:${address.identifier}`;
213
+ this.deletedAddresses.set(
214
+ coordinate,
215
+ Math.max(
216
+ this.deletedAddresses.get(coordinate) ?? deletion.created_at,
217
+ deletion.created_at
218
+ )
219
+ );
220
+ for (const [id, target] of this.events) {
221
+ if (_InMemoryEventStore.coordinateOf(target) === coordinate && isDeletableBy(target, deletion)) {
222
+ this.events.delete(id);
223
+ }
224
+ }
225
+ }
226
+ }
227
+ /**
228
+ * Close the storage backend (no-op for in-memory store).
229
+ */
230
+ close() {
231
+ }
232
+ };
233
+ function nowSeconds() {
234
+ return Math.floor(Date.now() / 1e3);
235
+ }
236
+
237
+ // src/storage/SqliteEventStore.ts
238
+ import Database from "better-sqlite3";
239
+ var SCHEMA_SQL = `
240
+ CREATE TABLE IF NOT EXISTS events (
241
+ id TEXT PRIMARY KEY,
242
+ pubkey TEXT NOT NULL,
243
+ kind INTEGER NOT NULL,
244
+ content TEXT NOT NULL,
245
+ tags TEXT NOT NULL,
246
+ created_at INTEGER NOT NULL,
247
+ sig TEXT NOT NULL,
248
+ received_at INTEGER NOT NULL,
249
+ expires_at INTEGER
250
+ )
251
+ `;
252
+ var DELETED_EVENTS_SCHEMA_SQL = `
253
+ CREATE TABLE IF NOT EXISTS deleted_events (
254
+ event_id TEXT PRIMARY KEY,
255
+ pubkey TEXT NOT NULL,
256
+ deleted_at INTEGER NOT NULL
257
+ )
258
+ `;
259
+ var DELETED_ADDRESSES_SCHEMA_SQL = `
260
+ CREATE TABLE IF NOT EXISTS deleted_addresses (
261
+ coordinate TEXT PRIMARY KEY,
262
+ deleted_at INTEGER NOT NULL
263
+ )
264
+ `;
265
+ var INDEX_SQL = [
266
+ "CREATE INDEX IF NOT EXISTS idx_events_pubkey ON events(pubkey)",
267
+ "CREATE INDEX IF NOT EXISTS idx_events_kind ON events(kind)",
268
+ "CREATE INDEX IF NOT EXISTS idx_events_created_at ON events(created_at)",
269
+ "CREATE INDEX IF NOT EXISTS idx_events_pubkey_kind ON events(pubkey, kind)",
270
+ // Partial index: only the small minority of events that expire at all.
271
+ "CREATE INDEX IF NOT EXISTS idx_events_expires_at ON events(expires_at) WHERE expires_at IS NOT NULL"
272
+ ];
273
+ function migrateExpiresAtColumn(db) {
274
+ const columns = db.prepare("PRAGMA table_info(events)").all();
275
+ if (columns.some((column) => column.name === "expires_at")) return;
276
+ db.exec("ALTER TABLE events ADD COLUMN expires_at INTEGER");
277
+ const candidates = db.prepare(`SELECT id, tags FROM events WHERE tags LIKE '%"expiration"%'`).all();
278
+ const update = db.prepare("UPDATE events SET expires_at = ? WHERE id = ?");
279
+ const backfill = db.transaction(() => {
280
+ for (const row of candidates) {
281
+ let tags;
282
+ try {
283
+ tags = JSON.parse(row.tags);
284
+ } catch {
285
+ continue;
286
+ }
287
+ const expiresAt = getExpiration({ tags });
288
+ if (expiresAt !== void 0) update.run(expiresAt, row.id);
289
+ }
290
+ });
291
+ backfill();
292
+ }
293
+ function initializeSchema(db) {
294
+ db.exec(SCHEMA_SQL);
295
+ db.exec(DELETED_EVENTS_SCHEMA_SQL);
296
+ db.exec(DELETED_ADDRESSES_SCHEMA_SQL);
297
+ migrateExpiresAtColumn(db);
298
+ for (const indexSql of INDEX_SQL) {
299
+ db.exec(indexSql);
300
+ }
301
+ }
302
+ var RelayError = class extends Error {
303
+ constructor(message, code) {
304
+ super(message);
305
+ this.code = code;
306
+ this.name = "RelayError";
307
+ }
308
+ };
309
+ function isReplaceableKind(kind) {
310
+ return kind >= 1e4 && kind <= 19999 && !(kind >= 10032 && kind <= 10099);
311
+ }
312
+ function isParameterizedReplaceableKind(kind) {
313
+ return kind >= 3e4 && kind <= 39999 || kind >= 10032 && kind <= 10099;
314
+ }
315
+ function getDTagValue(tags) {
316
+ const dTag = tags.find((tag) => tag[0] === "d");
317
+ return dTag?.[1] ?? "";
318
+ }
319
+ var SqliteEventStore = class _SqliteEventStore {
320
+ db;
321
+ insertStmt;
322
+ insertOrIgnoreStmt;
323
+ getStmt;
324
+ deleteByPubkeyKindStmt;
325
+ deleteByPubkeyKindDTagStmt;
326
+ getByPubkeyKindStmt;
327
+ getByPubkeyKindDTagStmt;
328
+ tombstoneIdStmt;
329
+ getTombstoneStmt;
330
+ tombstoneAddressStmt;
331
+ getAddressTombstoneStmt;
332
+ deleteExpiredStmt;
333
+ enforceExpiration;
334
+ blockedEventIds;
335
+ /**
336
+ * Create a new SqliteEventStore.
337
+ * @param dbPath - Path to the database file. Use ':memory:' for in-memory database.
338
+ * @param options - Expiry-enforcement and operator-blocklist settings.
339
+ */
340
+ constructor(dbPath = ":memory:", options = {}) {
341
+ this.enforceExpiration = options.enforceExpiration ?? true;
342
+ this.blockedEventIds = new Set(options.blockedEventIds ?? []);
343
+ try {
344
+ this.db = new Database(dbPath);
345
+ this.db.pragma("journal_mode = WAL");
346
+ this.db.pragma("synchronous = NORMAL");
347
+ initializeSchema(this.db);
348
+ this.insertStmt = this.db.prepare(`
349
+ INSERT OR REPLACE INTO events (id, pubkey, kind, content, tags, created_at, sig, received_at, expires_at)
350
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
351
+ `);
352
+ this.insertOrIgnoreStmt = this.db.prepare(`
353
+ INSERT OR IGNORE INTO events (id, pubkey, kind, content, tags, created_at, sig, received_at, expires_at)
354
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
355
+ `);
356
+ this.getStmt = this.db.prepare("SELECT * FROM events WHERE id = ?");
357
+ this.tombstoneIdStmt = this.db.prepare(
358
+ "INSERT OR REPLACE INTO deleted_events (event_id, pubkey, deleted_at) VALUES (?, ?, ?)"
359
+ );
360
+ this.getTombstoneStmt = this.db.prepare(
361
+ "SELECT pubkey FROM deleted_events WHERE event_id = ?"
362
+ );
363
+ this.tombstoneAddressStmt = this.db.prepare(
364
+ `INSERT INTO deleted_addresses (coordinate, deleted_at) VALUES (?, ?)
365
+ ON CONFLICT(coordinate) DO UPDATE SET deleted_at = MAX(deleted_at, excluded.deleted_at)`
366
+ );
367
+ this.getAddressTombstoneStmt = this.db.prepare(
368
+ "SELECT deleted_at FROM deleted_addresses WHERE coordinate = ?"
369
+ );
370
+ this.deleteExpiredStmt = this.db.prepare(
371
+ "DELETE FROM events WHERE expires_at IS NOT NULL AND expires_at <= ?"
372
+ );
373
+ if (this.blockedEventIds.size > 0) {
374
+ const purge = this.db.prepare("DELETE FROM events WHERE id = ?");
375
+ const purgeAll = this.db.transaction(() => {
376
+ for (const id of this.blockedEventIds) purge.run(id);
377
+ });
378
+ purgeAll();
379
+ }
380
+ this.deleteByPubkeyKindStmt = this.db.prepare(
381
+ "DELETE FROM events WHERE pubkey = ? AND kind = ?"
382
+ );
383
+ this.deleteByPubkeyKindDTagStmt = this.db.prepare(
384
+ "DELETE FROM events WHERE pubkey = ? AND kind = ? AND json_extract(tags, '$') LIKE ?"
385
+ );
386
+ this.getByPubkeyKindStmt = this.db.prepare(
387
+ "SELECT id, created_at FROM events WHERE pubkey = ? AND kind = ?"
388
+ );
389
+ this.getByPubkeyKindDTagStmt = this.db.prepare(
390
+ "SELECT id, created_at FROM events WHERE pubkey = ? AND kind = ? AND tags LIKE ?"
391
+ );
392
+ } catch (error) {
393
+ throw new RelayError(
394
+ `Failed to initialize database: ${error instanceof Error ? error.message : String(error)}`,
395
+ "STORAGE_ERROR"
396
+ );
397
+ }
398
+ }
399
+ /**
400
+ * Store an event in the database.
401
+ *
402
+ * Handles replaceable and parameterized replaceable events according to
403
+ * NIP-01, applies NIP-09 deletion requests, and refuses events the operator
404
+ * has blocked or that a previous NIP-09 request already retracted.
405
+ */
406
+ store(event) {
407
+ try {
408
+ if (this.blockedEventIds.has(event.id)) return;
409
+ if (this.isRetracted(event)) return;
410
+ const tagsJson = JSON.stringify(event.tags);
411
+ const receivedAt = Math.floor(Date.now() / 1e3);
412
+ if (isDeletionKind(event.kind)) {
413
+ this.applyDeletion(event);
414
+ }
415
+ if (isReplaceableKind(event.kind)) {
416
+ this.storeReplaceableEvent(event, tagsJson, receivedAt);
417
+ } else if (isParameterizedReplaceableKind(event.kind)) {
418
+ this.storeParameterizedReplaceableEvent(event, tagsJson, receivedAt);
419
+ } else {
420
+ this.runInsert(this.insertOrIgnoreStmt, event, tagsJson, receivedAt);
421
+ }
422
+ } catch (error) {
423
+ if (error instanceof RelayError) {
424
+ throw error;
425
+ }
426
+ throw new RelayError(
427
+ `Failed to store event: ${error instanceof Error ? error.message : String(error)}`,
428
+ "STORAGE_ERROR"
429
+ );
430
+ }
431
+ }
432
+ /**
433
+ * Store a replaceable event (kinds 10000-19999).
434
+ * Only keeps the latest event per pubkey+kind.
435
+ */
436
+ storeReplaceableEvent(event, tagsJson, receivedAt) {
437
+ const existing = this.getByPubkeyKindStmt.get(event.pubkey, event.kind);
438
+ if (existing) {
439
+ if (event.created_at > existing.created_at || event.created_at === existing.created_at && event.id < existing.id) {
440
+ const transaction = this.db.transaction(() => {
441
+ this.deleteByPubkeyKindStmt.run(event.pubkey, event.kind);
442
+ this.runInsert(this.insertStmt, event, tagsJson, receivedAt);
443
+ });
444
+ transaction();
445
+ }
446
+ } else {
447
+ this.runInsert(this.insertStmt, event, tagsJson, receivedAt);
448
+ }
449
+ }
450
+ /**
451
+ * Store a parameterized replaceable event (kinds 30000-39999).
452
+ * Only keeps the latest event per pubkey+kind+d-tag.
453
+ */
454
+ storeParameterizedReplaceableEvent(event, tagsJson, receivedAt) {
455
+ const dTagValue = getDTagValue(event.tags);
456
+ let existing;
457
+ if (dTagValue === "") {
458
+ const candidates = this.db.prepare(
459
+ "SELECT id, created_at, tags FROM events WHERE pubkey = ? AND kind = ?"
460
+ ).all(event.pubkey, event.kind);
461
+ for (const candidate of candidates) {
462
+ const candidateTags = JSON.parse(candidate.tags);
463
+ const candidateDTagValue = getDTagValue(candidateTags);
464
+ if (candidateDTagValue === "") {
465
+ existing = { id: candidate.id, created_at: candidate.created_at };
466
+ break;
467
+ }
468
+ }
469
+ } else {
470
+ const dTagPattern = `%["d","${dTagValue}"%`;
471
+ existing = this.getByPubkeyKindDTagStmt.get(
472
+ event.pubkey,
473
+ event.kind,
474
+ dTagPattern
475
+ );
476
+ }
477
+ if (existing) {
478
+ if (event.created_at > existing.created_at || event.created_at === existing.created_at && event.id < existing.id) {
479
+ const transaction = this.db.transaction(() => {
480
+ this.db.prepare("DELETE FROM events WHERE id = ?").run(existing.id);
481
+ this.runInsert(this.insertStmt, event, tagsJson, receivedAt);
482
+ });
483
+ transaction();
484
+ }
485
+ } else {
486
+ this.runInsert(this.insertStmt, event, tagsJson, receivedAt);
487
+ }
488
+ }
489
+ /**
490
+ * Bind an event to one of the prepared INSERT statements.
491
+ *
492
+ * The `expires_at` column is derived here, at the single point every write
493
+ * funnels through, so no insert path can forget it.
494
+ */
495
+ runInsert(stmt, event, tagsJson, receivedAt) {
496
+ stmt.run(
497
+ event.id,
498
+ event.pubkey,
499
+ event.kind,
500
+ event.content,
501
+ tagsJson,
502
+ event.created_at,
503
+ event.sig,
504
+ receivedAt,
505
+ getExpiration(event) ?? null
506
+ );
507
+ }
508
+ /**
509
+ * The NIP-01 addressable coordinate of an event, `<kind>:<pubkey>:<d>`.
510
+ * The `d` value is the empty string for events that carry no `d` tag —
511
+ * which is every kind:10032 announce on the network today.
512
+ */
513
+ static coordinateOf(event) {
514
+ return `${event.kind}:${event.pubkey}:${getDTagValue(event.tags)}`;
515
+ }
516
+ /**
517
+ * Whether a NIP-09 deletion request already retracted this event, so it
518
+ * must not be re-admitted.
519
+ *
520
+ * The id tombstone only bites when the arriving event's OWN pubkey matches
521
+ * the pubkey that asked for the deletion; otherwise anyone could pre-block
522
+ * an id they merely predicted. Address tombstones already carry the
523
+ * author's pubkey inside the coordinate.
524
+ */
525
+ isRetracted(event) {
526
+ const tombstone = this.getTombstoneStmt.get(event.id);
527
+ if (tombstone && tombstone.pubkey === event.pubkey) return true;
528
+ const address = this.getAddressTombstoneStmt.get(
529
+ _SqliteEventStore.coordinateOf(event)
530
+ );
531
+ return address !== void 0 && event.created_at <= address.deleted_at;
532
+ }
533
+ /**
534
+ * Apply a NIP-09 deletion request: remove the author's own targeted events
535
+ * and record tombstones so a re-publish cannot resurrect them.
536
+ *
537
+ * Every statement here is scoped by `pubkey = <the requester>`, which is
538
+ * what makes a cross-author deletion a no-op rather than a privilege.
539
+ */
540
+ applyDeletion(deletion) {
541
+ const targets = parseDeletionTargets(deletion);
542
+ if (targets.ids.length === 0 && targets.addresses.length === 0) return;
543
+ const deleteById = this.db.prepare(
544
+ "DELETE FROM events WHERE id = ? AND pubkey = ? AND created_at <= ?"
545
+ );
546
+ const apply = this.db.transaction(() => {
547
+ for (const id of targets.ids) {
548
+ this.tombstoneIdStmt.run(id, deletion.pubkey, deletion.created_at);
549
+ deleteById.run(id, deletion.pubkey, deletion.created_at);
550
+ }
551
+ for (const address of targets.addresses) {
552
+ if (address.pubkey !== deletion.pubkey) continue;
553
+ const coordinate = `${address.kind}:${address.pubkey}:${address.identifier}`;
554
+ this.tombstoneAddressStmt.run(coordinate, deletion.created_at);
555
+ for (const row of this.findByCoordinate(address.kind, address.pubkey)) {
556
+ if (getDTagValue(row.tags) === address.identifier && isDeletableBy(row, deletion)) {
557
+ this.db.prepare("DELETE FROM events WHERE id = ?").run(row.id);
558
+ }
559
+ }
560
+ }
561
+ });
562
+ apply();
563
+ }
564
+ /**
565
+ * Rows for a (kind, pubkey) pair with their parsed tags, so the caller can
566
+ * compare `d` values in code. SQL cannot distinguish `["d",""]` from a
567
+ * missing `d` tag, and both mean "the empty identifier".
568
+ */
569
+ findByCoordinate(kind, pubkey) {
570
+ const rows = this.db.prepare(
571
+ "SELECT id, pubkey, created_at, tags FROM events WHERE pubkey = ? AND kind = ?"
572
+ ).all(pubkey, kind);
573
+ return rows.map((row) => ({
574
+ id: row.id,
575
+ pubkey: row.pubkey,
576
+ created_at: row.created_at,
577
+ tags: JSON.parse(row.tags)
578
+ }));
579
+ }
580
+ /**
581
+ * NIP-40 reaper: permanently delete events whose expiration is further than
582
+ * `graceSeconds` in the past.
583
+ *
584
+ * The grace window is the safety net for enforcement itself. Serve-time
585
+ * filtering is instantly reversible (flip `enforceExpiration` off and every
586
+ * still-present event is served again); a DELETE is not. Keeping recently
587
+ * expired events on disk for a while means an operator who discovers that
588
+ * enforcement broke discovery can undo it without having lost the data.
589
+ *
590
+ * @param nowSeconds - Current unix time in seconds.
591
+ * @param graceSeconds - Extra time to keep an expired event on disk.
592
+ * @returns The number of rows deleted.
593
+ */
594
+ reapExpired(nowSeconds2, graceSeconds = 0) {
595
+ try {
596
+ const result = this.deleteExpiredStmt.run(nowSeconds2 - graceSeconds);
597
+ return result.changes;
598
+ } catch (error) {
599
+ throw new RelayError(
600
+ `Failed to reap expired events: ${error instanceof Error ? error.message : String(error)}`,
601
+ "STORAGE_ERROR"
602
+ );
603
+ }
604
+ }
605
+ /**
606
+ * Retrieve an event by its ID.
607
+ *
608
+ * Returns undefined for an event that is past its NIP-40 expiration while
609
+ * enforcement is on, even though the row may still be on disk inside the
610
+ * reaper's grace window.
611
+ */
612
+ get(id) {
613
+ try {
614
+ const row = this.getStmt.get(id);
615
+ if (!row) {
616
+ return void 0;
617
+ }
618
+ if (this.enforceExpiration && row.expires_at !== null && row.expires_at <= Math.floor(Date.now() / 1e3)) {
619
+ return void 0;
620
+ }
621
+ return {
622
+ id: row.id,
623
+ pubkey: row.pubkey,
624
+ kind: row.kind,
625
+ content: row.content,
626
+ tags: JSON.parse(row.tags),
627
+ created_at: row.created_at,
628
+ sig: row.sig
629
+ };
630
+ } catch (error) {
631
+ throw new RelayError(
632
+ `Failed to get event: ${error instanceof Error ? error.message : String(error)}`,
633
+ "STORAGE_ERROR"
634
+ );
635
+ }
636
+ }
637
+ /**
638
+ * Query events matching any of the provided filters.
639
+ */
640
+ query(filters) {
641
+ try {
642
+ const { sql, params } = this.buildQuerySql(filters);
643
+ const stmt = this.db.prepare(sql);
644
+ const rows = stmt.all(...params);
645
+ return rows.map((row) => ({
646
+ id: row.id,
647
+ pubkey: row.pubkey,
648
+ kind: row.kind,
649
+ content: row.content,
650
+ tags: JSON.parse(row.tags),
651
+ created_at: row.created_at,
652
+ sig: row.sig
653
+ }));
654
+ } catch (error) {
655
+ throw new RelayError(
656
+ `Failed to query events: ${error instanceof Error ? error.message : String(error)}`,
657
+ "STORAGE_ERROR"
658
+ );
659
+ }
660
+ }
661
+ /**
662
+ * Build SQL query from filters.
663
+ */
664
+ buildQuerySql(filters) {
665
+ const params = [];
666
+ let liveClause = "";
667
+ if (this.enforceExpiration) {
668
+ liveClause = "(expires_at IS NULL OR expires_at > ?)";
669
+ params.push(Math.floor(Date.now() / 1e3));
670
+ }
671
+ if (filters.length === 0) {
672
+ return {
673
+ sql: `SELECT * FROM events${liveClause ? ` WHERE ${liveClause}` : ""} ORDER BY created_at DESC`,
674
+ params
675
+ };
676
+ }
677
+ const conditions = [];
678
+ for (const filter of filters) {
679
+ const filterConditions = [];
680
+ if (filter.ids?.length) {
681
+ const idConditions = filter.ids.map(() => "id LIKE ?");
682
+ filterConditions.push(`(${idConditions.join(" OR ")})`);
683
+ params.push(...filter.ids.map((id) => `${id}%`));
684
+ }
685
+ if (filter.authors?.length) {
686
+ const authorConditions = filter.authors.map(() => "pubkey LIKE ?");
687
+ filterConditions.push(`(${authorConditions.join(" OR ")})`);
688
+ params.push(...filter.authors.map((a) => `${a}%`));
689
+ }
690
+ if (filter.kinds?.length) {
691
+ filterConditions.push(
692
+ `kind IN (${filter.kinds.map(() => "?").join(", ")})`
693
+ );
694
+ params.push(...filter.kinds);
695
+ }
696
+ if (filter.since !== void 0) {
697
+ filterConditions.push("created_at >= ?");
698
+ params.push(filter.since);
699
+ }
700
+ if (filter.until !== void 0) {
701
+ filterConditions.push("created_at <= ?");
702
+ params.push(filter.until);
703
+ }
704
+ for (const [key, values] of Object.entries(filter)) {
705
+ if (key.startsWith("#") && Array.isArray(values) && values.length > 0) {
706
+ const tagName = key.slice(1);
707
+ const tagConditions = values.map(() => `tags LIKE ?`);
708
+ filterConditions.push(`(${tagConditions.join(" OR ")})`);
709
+ params.push(...values.map((v) => `%["${tagName}","${v}"%`));
710
+ }
711
+ }
712
+ if (filterConditions.length > 0) {
713
+ conditions.push(`(${filterConditions.join(" AND ")})`);
714
+ }
715
+ }
716
+ const whereParts = [];
717
+ if (liveClause) whereParts.push(liveClause);
718
+ if (conditions.length > 0) whereParts.push(`(${conditions.join(" OR ")})`);
719
+ let sql = "SELECT * FROM events";
720
+ if (whereParts.length > 0) {
721
+ sql += ` WHERE ${whereParts.join(" AND ")}`;
722
+ }
723
+ sql += " ORDER BY created_at DESC";
724
+ const limitFilter = filters.find((f) => f.limit !== void 0);
725
+ if (limitFilter?.limit !== void 0) {
726
+ sql += " LIMIT ?";
727
+ params.push(limitFilter.limit);
728
+ }
729
+ return { sql, params };
730
+ }
731
+ /**
732
+ * Close the database connection.
733
+ */
734
+ close() {
735
+ this.db.close();
736
+ }
737
+ };
738
+
739
+ // src/nips/blocklist.ts
740
+ var HEX_642 = /^[0-9a-f]{64}$/;
741
+ function parseBlockedEventIds(raw) {
742
+ const ids = /* @__PURE__ */ new Set();
743
+ const invalid = [];
744
+ for (const entry of (raw ?? "").split(/[\s,]+/)) {
745
+ if (entry === "") continue;
746
+ const normalized = entry.toLowerCase();
747
+ if (HEX_642.test(normalized)) {
748
+ ids.add(normalized);
749
+ } else {
750
+ invalid.push(entry);
751
+ }
752
+ }
753
+ return { ids: [...ids], invalid };
754
+ }
755
+
756
+ // src/crypto/verify-pool.ts
757
+ import { existsSync } from "fs";
758
+ import { cpus } from "os";
759
+ import { fileURLToPath } from "url";
760
+ import { Worker } from "worker_threads";
761
+ import { performance } from "perf_hooks";
762
+ import { verifiedSymbol } from "nostr-tools/pure";
763
+ function defaultVerifyWorkers() {
764
+ return Math.max(0, cpus().length - 1);
765
+ }
766
+ function resolveWorkerUrl() {
767
+ for (const candidate of [
768
+ new URL("./verify-worker.js", import.meta.url),
769
+ new URL("../../dist/verify-worker.js", import.meta.url)
770
+ ]) {
771
+ try {
772
+ if (existsSync(fileURLToPath(candidate))) return candidate;
773
+ } catch {
774
+ }
775
+ }
776
+ return null;
777
+ }
778
+ function createVerifyPool(options = {}) {
779
+ const requestedSize = options.size ?? defaultVerifyWorkers();
780
+ const onMeasure = options.onMeasure;
781
+ const measured = (fn) => {
782
+ if (!onMeasure) return fn();
783
+ const start = performance.now();
784
+ const result = fn();
785
+ onMeasure(performance.now() - start);
786
+ return result;
787
+ };
788
+ const inlineVerify = (event) => Promise.resolve(measured(() => verifyEventSignature(event)));
789
+ const workerUrl = requestedSize > 0 ? resolveWorkerUrl() : null;
790
+ if (requestedSize > 0 && !workerUrl) {
791
+ console.warn(
792
+ "[relay] verify pool: compiled worker (dist/verify-worker.js) not found -- falling back to inline verification (build the package to enable workers)"
793
+ );
794
+ }
795
+ const workers = [];
796
+ let seq = 0;
797
+ let destroyed = false;
798
+ const retireWorker = (pw, reason) => {
799
+ const index = workers.indexOf(pw);
800
+ if (index === -1) return;
801
+ workers.splice(index, 1);
802
+ if (!destroyed) {
803
+ console.warn(
804
+ `[relay] verify pool: worker retired (${reason}); ` + (workers.length > 0 ? `${workers.length} worker(s) remain` : "falling back to inline verification")
805
+ );
806
+ }
807
+ for (const { resolve, event } of pw.pending.values()) {
808
+ resolve(verifyEventSignature(event));
809
+ }
810
+ pw.pending.clear();
811
+ };
812
+ if (workerUrl) {
813
+ for (let i = 0; i < requestedSize; i++) {
814
+ const worker = new Worker(workerUrl);
815
+ const pw = { worker, pending: /* @__PURE__ */ new Map() };
816
+ worker.on("message", (reply) => {
817
+ const entry = pw.pending.get(reply.seq);
818
+ if (!entry) return;
819
+ pw.pending.delete(reply.seq);
820
+ entry.event[verifiedSymbol] = reply.ok;
821
+ entry.resolve(reply.ok);
822
+ });
823
+ worker.on(
824
+ "error",
825
+ (error) => retireWorker(pw, `error: ${error.message}`)
826
+ );
827
+ worker.on("exit", () => retireWorker(pw, "exit"));
828
+ workers.push(pw);
829
+ }
830
+ }
831
+ const poolVerify = (event) => {
832
+ const cached = event[verifiedSymbol];
833
+ if (typeof cached === "boolean") return Promise.resolve(cached);
834
+ let target = workers[0];
835
+ if (!target) return inlineVerify(event);
836
+ for (const pw of workers) {
837
+ if (pw.pending.size < target.pending.size) target = pw;
838
+ }
839
+ const start = performance.now();
840
+ return new Promise((resolve) => {
841
+ const id = ++seq;
842
+ target.pending.set(id, {
843
+ event,
844
+ resolve: (ok) => {
845
+ onMeasure?.(performance.now() - start);
846
+ resolve(ok);
847
+ }
848
+ });
849
+ target.worker.postMessage({ seq: id, event });
850
+ });
851
+ };
852
+ return {
853
+ verify(event) {
854
+ return workers.length > 0 ? poolVerify(event) : inlineVerify(event);
855
+ },
856
+ get size() {
857
+ return workers.length;
858
+ },
859
+ async destroy() {
860
+ destroyed = true;
861
+ const toTerminate = [...workers];
862
+ for (const pw of toTerminate) {
863
+ retireWorker(pw, "destroy");
864
+ }
865
+ await Promise.all(toTerminate.map((pw) => pw.worker.terminate()));
866
+ }
867
+ };
868
+ }
869
+
870
+ // src/websocket/ConnectionHandler.ts
871
+ function serializeEventFrame(subscriptionId, eventJson) {
872
+ return `["EVENT",${JSON.stringify(subscriptionId)},${eventJson}]`;
873
+ }
874
+ var ConnectionHandler = class {
875
+ constructor(ws, eventStore, config = {}) {
876
+ this.ws = ws;
877
+ this.eventStore = eventStore;
878
+ this.config = { ...DEFAULT_RELAY_CONFIG, ...config };
879
+ }
880
+ subscriptions = /* @__PURE__ */ new Map();
881
+ config;
882
+ /**
883
+ * Handle an incoming message from the WebSocket.
884
+ */
885
+ handleMessage(data) {
886
+ console.log(`[ConnectionHandler] Received message:`, data.slice(0, 150));
887
+ let message;
888
+ try {
889
+ const parsed = JSON.parse(data);
890
+ if (!Array.isArray(parsed)) {
891
+ this.sendNotice("error: invalid message format, expected JSON array");
892
+ return;
893
+ }
894
+ message = parsed;
895
+ } catch {
896
+ this.sendNotice("error: invalid JSON");
897
+ return;
898
+ }
899
+ const messageType = message[0];
900
+ console.log(`[ConnectionHandler] Message type: ${messageType}`);
901
+ if (messageType === "REQ") {
902
+ const subscriptionId = message[1];
903
+ const filters = message.slice(2);
904
+ this.handleReq(subscriptionId, filters);
905
+ } else if (messageType === "EVENT") {
906
+ const event = message[1];
907
+ this.handleEvent(event);
908
+ } else if (messageType === "CLOSE") {
909
+ const subscriptionId = message[1];
910
+ this.handleClose(subscriptionId);
911
+ } else {
912
+ this.sendNotice(`error: unknown message type: ${messageType}`);
913
+ }
914
+ }
915
+ /**
916
+ * Handle a REQ message to create/update a subscription.
917
+ */
918
+ handleReq(subscriptionId, filters) {
919
+ if (typeof subscriptionId !== "string" || subscriptionId.length === 0) {
920
+ this.sendNotice("error: invalid subscription id");
921
+ return;
922
+ }
923
+ if (!this.subscriptions.has(subscriptionId)) {
924
+ if (this.subscriptions.size >= this.config.maxSubscriptionsPerConnection) {
925
+ this.sendNotice("error: too many subscriptions");
926
+ return;
927
+ }
928
+ }
929
+ if (filters.length > this.config.maxFiltersPerSubscription) {
930
+ this.sendNotice("error: too many filters");
931
+ return;
932
+ }
933
+ this.subscriptions.set(subscriptionId, {
934
+ id: subscriptionId,
935
+ filters
936
+ });
937
+ console.log(
938
+ `[ConnectionHandler] REQ: ${subscriptionId}, filters:`,
939
+ JSON.stringify(filters).slice(0, 100)
940
+ );
941
+ const events = this.eventStore.query(filters);
942
+ console.log(
943
+ `[ConnectionHandler] Query returned ${events.length} events for ${subscriptionId}`
944
+ );
945
+ for (const event of events) {
946
+ console.log(
947
+ `[ConnectionHandler] Sending event ${event.id.slice(0, 16)}... to ${subscriptionId}`
948
+ );
949
+ this.sendEvent(subscriptionId, event);
950
+ }
951
+ console.log(`[ConnectionHandler] Sending EOSE for ${subscriptionId}`);
952
+ this.sendEose(subscriptionId);
953
+ }
954
+ /**
955
+ * Handle an EVENT message from a WebSocket client.
956
+ *
957
+ * Rejects all external writes — the relay is ILP-gated (pay to write).
958
+ * Events are only stored through the ILP packet handler which calls
959
+ * eventStore.store() directly and then broadcastEvent() to notify subscribers.
960
+ */
961
+ handleEvent(event) {
962
+ this.sendOk(event.id, false, "restricted: writes require ILP payment");
963
+ }
964
+ /**
965
+ * Handle a CLOSE message to terminate a subscription.
966
+ */
967
+ handleClose(subscriptionId) {
968
+ this.subscriptions.delete(subscriptionId);
969
+ }
970
+ /**
971
+ * Push a new event to all matching subscriptions on this connection.
972
+ * Used when events are stored outside the WebSocket flow (e.g., via ILP).
973
+ *
974
+ * @param event - The event to fan out (used for filter matching).
975
+ * @param eventJson - Optional pre-serialized `JSON.stringify(event)`.
976
+ * `NostrRelayServer.broadcastEvent` serializes the event ONCE and passes
977
+ * it here so a 500-subscriber fan-out costs one serialization, not 500
978
+ * (relay#91). When omitted (direct callers), the event is serialized
979
+ * on first matching send.
980
+ */
981
+ notifyNewEvent(event, eventJson) {
982
+ if (this.config.enforceExpiration && isExpired(event, Math.floor(Date.now() / 1e3))) {
983
+ return;
984
+ }
985
+ let json = eventJson;
986
+ for (const sub of this.subscriptions.values()) {
987
+ const matches = sub.filters.some((f) => matchFilter(event, f));
988
+ if (matches) {
989
+ json ??= JSON.stringify(event);
990
+ this.send(serializeEventFrame(sub.id, json));
991
+ }
992
+ }
993
+ }
994
+ /**
995
+ * Clean up all subscriptions for this connection.
996
+ */
997
+ cleanup() {
998
+ this.subscriptions.clear();
999
+ }
1000
+ /**
1001
+ * Get the number of active subscriptions.
1002
+ */
1003
+ getSubscriptionCount() {
1004
+ return this.subscriptions.size;
1005
+ }
1006
+ /**
1007
+ * Emit an outbound NIP-01 EVENT frame.
1008
+ *
1009
+ * The event MUST go on the wire as canonical NIP-01 JSON —
1010
+ * `["EVENT", <subId>, {id, pubkey, created_at, kind, tags, content, sig}]`
1011
+ * with the event as a plain JSON object — so any standard nostr client can
1012
+ * parse it and verify `id`/`sig` from the wire bytes (#46). Never re-encode
1013
+ * the event (TOON text, double-JSON-stringify, etc.) at this boundary.
1014
+ * serializeEventFrame is byte-identical to the full JSON.stringify
1015
+ * envelope (pinned by tests).
1016
+ */
1017
+ sendEvent(subscriptionId, event) {
1018
+ this.send(serializeEventFrame(subscriptionId, JSON.stringify(event)));
1019
+ }
1020
+ sendEose(subscriptionId) {
1021
+ this.send(["EOSE", subscriptionId]);
1022
+ }
1023
+ sendOk(eventId, success, message) {
1024
+ this.send(["OK", eventId, success, message]);
1025
+ }
1026
+ sendNotice(message) {
1027
+ this.send(["NOTICE", message]);
1028
+ }
1029
+ /** Send a message: pre-serialized frames go out as-is (relay#91). */
1030
+ send(message) {
1031
+ if (this.ws.readyState === 1) {
1032
+ this.ws.send(
1033
+ typeof message === "string" ? message : JSON.stringify(message)
1034
+ );
1035
+ }
1036
+ }
1037
+ };
1038
+
1039
+ // src/websocket/NostrRelayServer.ts
1040
+ import { readFileSync } from "fs";
1041
+ import { WebSocketServer } from "ws";
1042
+ var FD_HEADROOM = 128;
1043
+ function readOpenFilesSoftLimit(read = (p) => readFileSync(p, "utf8")) {
1044
+ try {
1045
+ const line = read("/proc/self/limits").split("\n").find((l) => l.startsWith("Max open files"));
1046
+ const match = line?.match(/Max open files\s+(\S+)/);
1047
+ if (!match?.[1]) return null;
1048
+ if (match[1] === "unlimited") return Infinity;
1049
+ const limit = parseInt(match[1], 10);
1050
+ return Number.isNaN(limit) ? null : limit;
1051
+ } catch {
1052
+ return null;
1053
+ }
1054
+ }
1055
+ var NostrRelayServer = class {
1056
+ constructor(config = {}, eventStore) {
1057
+ this.eventStore = eventStore;
1058
+ this.config = { ...DEFAULT_RELAY_CONFIG, ...config };
1059
+ }
1060
+ wss = null;
1061
+ handlers = /* @__PURE__ */ new Map();
1062
+ config;
1063
+ /**
1064
+ * Start the WebSocket server.
1065
+ */
1066
+ async start() {
1067
+ return new Promise((resolve, reject) => {
1068
+ try {
1069
+ this.wss = new WebSocketServer({
1070
+ port: this.config.port,
1071
+ host: this.config.host
1072
+ });
1073
+ this.wss.on("connection", (ws) => {
1074
+ this.handleConnection(ws);
1075
+ });
1076
+ this.wss.on("error", (error) => {
1077
+ console.error("[NostrRelayServer] Server error:", error.message);
1078
+ });
1079
+ this.wss.on("listening", () => {
1080
+ const address = this.wss?.address();
1081
+ if (address && typeof address === "object") {
1082
+ console.log(`[NostrRelayServer] Listening on port ${address.port}`);
1083
+ }
1084
+ const fdLimit = readOpenFilesSoftLimit();
1085
+ if (fdLimit !== null && Number.isFinite(fdLimit) && this.config.maxConnections > fdLimit - FD_HEADROOM) {
1086
+ console.warn(
1087
+ `[NostrRelayServer] maxConnections (${this.config.maxConnections}) exceeds the process fd soft limit (${fdLimit}) minus ${FD_HEADROOM} headroom -- connections will fail with EMFILE before the cap. Raise \`ulimit -n\` or lower maxConnections.`
1088
+ );
1089
+ }
1090
+ resolve();
1091
+ });
1092
+ } catch (error) {
1093
+ reject(error);
1094
+ }
1095
+ });
1096
+ }
1097
+ /**
1098
+ * Stop the WebSocket server and close all connections.
1099
+ */
1100
+ async stop() {
1101
+ return new Promise((resolve) => {
1102
+ if (!this.wss) {
1103
+ resolve();
1104
+ return;
1105
+ }
1106
+ for (const [ws, handler] of this.handlers) {
1107
+ handler.cleanup();
1108
+ ws.close();
1109
+ }
1110
+ this.handlers.clear();
1111
+ this.wss.close(() => {
1112
+ this.wss = null;
1113
+ resolve();
1114
+ });
1115
+ });
1116
+ }
1117
+ /**
1118
+ * Get the port the server is listening on.
1119
+ * Returns 0 if the server is not started.
1120
+ */
1121
+ getPort() {
1122
+ if (!this.wss) return 0;
1123
+ const address = this.wss.address();
1124
+ if (address && typeof address === "object") {
1125
+ return address.port;
1126
+ }
1127
+ return 0;
1128
+ }
1129
+ /**
1130
+ * Get the number of connected clients.
1131
+ */
1132
+ getClientCount() {
1133
+ return this.handlers.size;
1134
+ }
1135
+ /**
1136
+ * Broadcast an event to all connected clients with matching subscriptions.
1137
+ * Call this after storing an event outside the WebSocket flow (e.g., via ILP)
1138
+ * so that discovery subscribers are notified.
1139
+ *
1140
+ * Serialize-once fan-out (relay#91): the event payload is stringified ONE
1141
+ * time here and reused for every matching subscriber -- only the small
1142
+ * per-subscription `["EVENT",<subId>,...]` envelope is spliced per send.
1143
+ * Previously each of N subscribers re-serialized the identical event
1144
+ * (N=500 pinned a core doing 500 identical stringifies per frame).
1145
+ */
1146
+ broadcastEvent(event) {
1147
+ const eventJson = JSON.stringify(event);
1148
+ for (const handler of this.handlers.values()) {
1149
+ handler.notifyNewEvent(event, eventJson);
1150
+ }
1151
+ }
1152
+ handleConnection(ws) {
1153
+ if (this.handlers.size >= this.config.maxConnections) {
1154
+ console.warn(
1155
+ `[NostrRelayServer] connection rejected: maxConnections (${this.config.maxConnections}) reached -- raise TOON_MAX_CONNECTIONS if this box has headroom (relay#90)`
1156
+ );
1157
+ ws.close(1013, "max connections reached");
1158
+ return;
1159
+ }
1160
+ console.log("[NostrRelayServer] Client connected");
1161
+ const handler = new ConnectionHandler(ws, this.eventStore, this.config);
1162
+ this.handlers.set(ws, handler);
1163
+ ws.on("message", (data) => {
1164
+ const message = typeof data === "string" ? data : data.toString();
1165
+ handler.handleMessage(message);
1166
+ });
1167
+ ws.on("close", () => {
1168
+ console.log("[NostrRelayServer] Client disconnected");
1169
+ handler.cleanup();
1170
+ this.handlers.delete(ws);
1171
+ });
1172
+ ws.on("error", (error) => {
1173
+ console.error("[NostrRelayServer] Client error:", error.message);
1174
+ handler.cleanup();
1175
+ this.handlers.delete(ws);
1176
+ });
1177
+ }
1178
+ };
1179
+
1180
+ // src/subscriber/RelaySubscriber.ts
1181
+ import { SimplePool } from "nostr-tools/pool";
1182
+ import { verifyEvent } from "nostr-tools/pure";
1183
+ var RelaySubscriber = class {
1184
+ config;
1185
+ eventStore;
1186
+ pool;
1187
+ started = false;
1188
+ /**
1189
+ * @param config - Subscriber configuration
1190
+ * @param eventStore - Storage backend to write events into
1191
+ * @param pool - Optional SimplePool instance (creates new one if not provided)
1192
+ */
1193
+ constructor(config, eventStore, pool) {
1194
+ this.config = config;
1195
+ this.eventStore = eventStore;
1196
+ this.pool = pool ?? new SimplePool();
1197
+ }
1198
+ /**
1199
+ * Start subscribing to the configured upstream relays.
1200
+ *
1201
+ * @returns Handle with unsubscribe() to stop the subscription
1202
+ * @throws Error if already started
1203
+ */
1204
+ start() {
1205
+ if (this.started) {
1206
+ throw new Error("RelaySubscriber already started");
1207
+ }
1208
+ this.started = true;
1209
+ const shouldVerify = this.config.verifySignatures !== false;
1210
+ let isUnsubscribed = false;
1211
+ const subCloser = this.pool.subscribeMany(
1212
+ this.config.relayUrls,
1213
+ this.config.filter,
1214
+ {
1215
+ onevent: (event) => {
1216
+ if (isUnsubscribed) return;
1217
+ if (shouldVerify && !verifyEvent(event)) {
1218
+ return;
1219
+ }
1220
+ try {
1221
+ this.eventStore.store(event);
1222
+ } catch (error) {
1223
+ console.warn(
1224
+ "[RelaySubscriber] Failed to store event:",
1225
+ error instanceof Error ? error.message : "Unknown error"
1226
+ );
1227
+ }
1228
+ }
1229
+ }
1230
+ );
1231
+ return {
1232
+ unsubscribe: () => {
1233
+ if (!isUnsubscribed) {
1234
+ isUnsubscribed = true;
1235
+ subCloser.close();
1236
+ this.started = false;
1237
+ }
1238
+ }
1239
+ };
1240
+ }
1241
+ };
1242
+
1243
+ // src/launcher/metrics.ts
1244
+ import { monitorEventLoopDelay } from "perf_hooks";
1245
+ var VERIFY_WINDOW = 2048;
1246
+ var NS_PER_MS = 1e6;
1247
+ function percentileOf(sorted, fraction) {
1248
+ if (sorted.length === 0) return 0;
1249
+ const index = Math.min(
1250
+ sorted.length - 1,
1251
+ Math.ceil(fraction * sorted.length) - 1
1252
+ );
1253
+ return sorted[Math.max(0, index)] ?? 0;
1254
+ }
1255
+ function round(value) {
1256
+ if (!Number.isFinite(value)) return 0;
1257
+ return Math.round(value * 1e3) / 1e3;
1258
+ }
1259
+ function createMetricsRegistry(info) {
1260
+ const loopDelay = monitorEventLoopDelay({
1261
+ resolution: 20
1262
+ });
1263
+ loopDelay.enable();
1264
+ let verifyWorkers = info.verifyWorkers;
1265
+ let count = 0;
1266
+ let totalMs = 0;
1267
+ let maxMs = 0;
1268
+ const window = new Array(VERIFY_WINDOW);
1269
+ let windowFill = 0;
1270
+ let windowCursor = 0;
1271
+ return {
1272
+ recordVerify(ms) {
1273
+ count += 1;
1274
+ totalMs += ms;
1275
+ if (ms > maxMs) maxMs = ms;
1276
+ window[windowCursor] = ms;
1277
+ windowCursor = (windowCursor + 1) % VERIFY_WINDOW;
1278
+ if (windowFill < VERIFY_WINDOW) windowFill += 1;
1279
+ },
1280
+ snapshot() {
1281
+ const recent = window.slice(0, windowFill).sort((a, b) => a - b);
1282
+ return {
1283
+ timestamp: Date.now(),
1284
+ eventLoopDelayMs: {
1285
+ mean: round(loopDelay.mean / NS_PER_MS),
1286
+ p50: round(loopDelay.percentile(50) / NS_PER_MS),
1287
+ p99: round(loopDelay.percentile(99) / NS_PER_MS),
1288
+ max: round(loopDelay.max / NS_PER_MS)
1289
+ },
1290
+ verify: {
1291
+ implementation: info.verifyImplementation,
1292
+ workers: verifyWorkers,
1293
+ count,
1294
+ meanMs: round(count > 0 ? totalMs / count : 0),
1295
+ maxMs: round(maxMs),
1296
+ p50Ms: round(percentileOf(recent, 0.5)),
1297
+ p99Ms: round(percentileOf(recent, 0.99))
1298
+ },
1299
+ ephemeralWriteLane: {
1300
+ enabled: true,
1301
+ rateLimit: info.ephemeralRateLimit,
1302
+ maxBodyBytes: info.ephemeralMaxBodyBytes
1303
+ }
1304
+ };
1305
+ },
1306
+ setVerifyWorkers(workers) {
1307
+ verifyWorkers = workers;
1308
+ },
1309
+ stop() {
1310
+ loopDelay.disable();
1311
+ }
1312
+ };
1313
+ }
1314
+
1315
+ // src/launcher/handlers/payment-attribution.ts
1316
+ var EVM_PAYER = /^evm:0x[0-9a-f]{64}$/;
1317
+ var SOLANA_PAYER = /^solana:[1-9A-HJ-NP-Za-km-z]{32,44}$/;
1318
+ var AMOUNT = /^[0-9]+$/;
1319
+ function readPaymentAttribution(c) {
1320
+ const payer = c.req.header("X-TOON-Payer");
1321
+ const amount = c.req.header("X-TOON-Amount");
1322
+ const chain = c.req.header("X-TOON-Chain");
1323
+ if (!payer || !amount || !chain) return void 0;
1324
+ if (chain !== "evm" && chain !== "solana") return void 0;
1325
+ if (!AMOUNT.test(amount)) return void 0;
1326
+ const payerMatchesChain = chain === "evm" ? EVM_PAYER.test(payer) : SOLANA_PAYER.test(payer);
1327
+ if (!payerMatchesChain) return void 0;
1328
+ return { payer, amount, chain };
1329
+ }
1330
+
1331
+ // src/launcher/handlers/write-handler.ts
1332
+ function isEphemeralKind(kind) {
1333
+ return kind >= 2e4 && kind < 3e4;
1334
+ }
1335
+ function createWriteHandler(config) {
1336
+ const logWrites = config.logWrites ?? false;
1337
+ const verifyEphemeral = config.verifyEphemeral ?? false;
1338
+ const verifyEvent2 = config.verifyEvent ?? verifyEventSignature;
1339
+ return {
1340
+ async handleWrite(c) {
1341
+ let body;
1342
+ try {
1343
+ body = await c.req.json();
1344
+ } catch {
1345
+ return c.json({ error: "Invalid request body" }, 400);
1346
+ }
1347
+ if (!body.event) {
1348
+ return c.json({ error: "Missing required field: event" }, 400);
1349
+ }
1350
+ const event = body.event;
1351
+ const payment = readPaymentAttribution(c);
1352
+ if (logWrites) {
1353
+ const attribution = payment ? ` payer=${payment.payer} amount=${payment.amount} chain=${payment.chain}` : "";
1354
+ console.log(`[write] event=${event.id} handler=write${attribution}`);
1355
+ }
1356
+ if (!config.devMode) {
1357
+ if (isEphemeralKind(event.kind) && !verifyEphemeral) {
1358
+ if (!verifyEventId(event)) {
1359
+ return c.json({ error: "Invalid event id" }, 422);
1360
+ }
1361
+ } else if (!await verifyEvent2(event)) {
1362
+ return c.json({ error: "Invalid event signature" }, 422);
1363
+ }
1364
+ }
1365
+ if (!isEphemeralKind(event.kind)) {
1366
+ config.eventStore.store(event);
1367
+ }
1368
+ config.onStored?.(event);
1369
+ return c.json(
1370
+ {
1371
+ eventId: event.id,
1372
+ storedAt: Math.floor(Date.now() / 1e3),
1373
+ ...payment ? { payment } : {}
1374
+ },
1375
+ 200
1376
+ );
1377
+ }
1378
+ };
1379
+ }
1380
+
1381
+ // src/launcher/rate-limiter.ts
1382
+ function createRateLimiter(options) {
1383
+ const { maxRequests, windowMs } = options;
1384
+ const now = options.now ?? Date.now;
1385
+ const hits = /* @__PURE__ */ new Map();
1386
+ return {
1387
+ allow(key) {
1388
+ const t = now();
1389
+ const windowStart = t - windowMs;
1390
+ const timestamps = (hits.get(key) ?? []).filter(
1391
+ (ts) => ts >= windowStart
1392
+ );
1393
+ hits.set(key, timestamps);
1394
+ if (timestamps.length >= maxRequests) {
1395
+ return false;
1396
+ }
1397
+ timestamps.push(t);
1398
+ return true;
1399
+ }
1400
+ };
1401
+ }
1402
+
1403
+ // src/launcher/handlers/write-ephemeral-handler.ts
1404
+ import { getConnInfo } from "@hono/node-server/conninfo";
1405
+ function isEphemeralKind2(kind) {
1406
+ return kind >= 2e4 && kind < 3e4;
1407
+ }
1408
+ var DEFAULT_EPHEMERAL_RATE_LIMIT = {
1409
+ maxRequests: 200,
1410
+ windowMs: 1e4
1411
+ };
1412
+ var DEFAULT_EPHEMERAL_MAX_BODY_BYTES = 8 * 1024;
1413
+ function defaultClientKey(c) {
1414
+ try {
1415
+ return getConnInfo(c).remote.address ?? "unknown";
1416
+ } catch {
1417
+ return "unknown";
1418
+ }
1419
+ }
1420
+ function createEphemeralWriteHandler(config = {}) {
1421
+ const logWrites = config.logWrites ?? false;
1422
+ const verifyEvent2 = config.verifyEvent ?? verifyEventSignature;
1423
+ const maxBodyBytes = config.maxBodyBytes ?? DEFAULT_EPHEMERAL_MAX_BODY_BYTES;
1424
+ const rateLimiter = config.rateLimiter ?? createRateLimiter(config.rateLimit ?? DEFAULT_EPHEMERAL_RATE_LIMIT);
1425
+ const getClientKey = config.getClientKey ?? defaultClientKey;
1426
+ return {
1427
+ async handleWrite(c) {
1428
+ if (!rateLimiter.allow(getClientKey(c))) {
1429
+ return c.json({ error: "Rate limit exceeded" }, 429);
1430
+ }
1431
+ const contentLengthHeader = c.req.header("content-length");
1432
+ if (contentLengthHeader !== void 0 && Number(contentLengthHeader) > maxBodyBytes) {
1433
+ return c.json({ error: "Request body too large" }, 413);
1434
+ }
1435
+ let rawBody;
1436
+ try {
1437
+ rawBody = await c.req.text();
1438
+ } catch {
1439
+ return c.json({ error: "Invalid request body" }, 400);
1440
+ }
1441
+ if (Buffer.byteLength(rawBody, "utf8") > maxBodyBytes) {
1442
+ return c.json({ error: "Request body too large" }, 413);
1443
+ }
1444
+ let body;
1445
+ try {
1446
+ body = JSON.parse(rawBody);
1447
+ } catch {
1448
+ return c.json({ error: "Invalid request body" }, 400);
1449
+ }
1450
+ if (!body.event) {
1451
+ return c.json({ error: "Missing required field: event" }, 400);
1452
+ }
1453
+ const event = body.event;
1454
+ if (!isEphemeralKind2(event.kind)) {
1455
+ return c.json(
1456
+ {
1457
+ error: "Only ephemeral kinds (20000-29999) are accepted on this lane"
1458
+ },
1459
+ 400
1460
+ );
1461
+ }
1462
+ if (logWrites) {
1463
+ console.log(`[write] event=${event.id} handler=write-ephemeral`);
1464
+ }
1465
+ if (!await verifyEvent2(event)) {
1466
+ return c.json({ error: "Invalid event signature" }, 422);
1467
+ }
1468
+ config.onBroadcast?.(event);
1469
+ return c.json(
1470
+ {
1471
+ eventId: event.id,
1472
+ broadcastAt: Math.floor(Date.now() / 1e3)
1473
+ },
1474
+ 200
1475
+ );
1476
+ }
1477
+ };
1478
+ }
1479
+
1480
+ // src/launcher/health.ts
1481
+ function createHealthResponse(config) {
1482
+ return {
1483
+ status: "healthy",
1484
+ pubkey: config.pubkey,
1485
+ capabilities: ["relay"],
1486
+ version: VERSION,
1487
+ timestamp: Date.now()
1488
+ };
1489
+ }
1490
+
1491
+ // src/launcher/relay.ts
1492
+ import { mkdirSync } from "fs";
1493
+ import { join } from "path";
1494
+ import { serve } from "@hono/node-server";
1495
+ import { Hono } from "hono";
1496
+ import { getPublicKey } from "nostr-tools/pure";
1497
+ import { privateKeyFromSeedWords } from "nostr-tools/nip06";
1498
+ function deriveIdentity(config) {
1499
+ const hasMnemonic = config.mnemonic !== void 0;
1500
+ const hasSecretKey = config.secretKey !== void 0;
1501
+ if (hasMnemonic && hasSecretKey) {
1502
+ throw new Error(
1503
+ "RelayConfig: provide either mnemonic or secretKey, not both"
1504
+ );
1505
+ }
1506
+ if (!hasMnemonic && !hasSecretKey) {
1507
+ throw new Error("RelayConfig: one of mnemonic or secretKey is required");
1508
+ }
1509
+ const secretKey = hasMnemonic ? privateKeyFromSeedWords(config.mnemonic) : config.secretKey;
1510
+ return { secretKey, pubkey: getPublicKey(secretKey) };
1511
+ }
1512
+ function isInternalBindHost(host) {
1513
+ const h = host.trim().toLowerCase();
1514
+ if (h === "localhost" || h === "::1" || h === "[::1]") return true;
1515
+ if (h.startsWith("127.")) return true;
1516
+ if (h.startsWith("10.")) return true;
1517
+ if (h.startsWith("192.168.")) return true;
1518
+ if (/^172\.(1[6-9]|2\d|3[01])\./.test(h)) return true;
1519
+ if (/^f[cd][0-9a-f]{2}:/.test(h)) return true;
1520
+ if (h.startsWith("fe80:")) return true;
1521
+ return false;
1522
+ }
1523
+ function warnIfWritePortExposed(writeHost, blsPort, options) {
1524
+ const skipActive = options.devMode || !options.verifyEphemeral;
1525
+ if (!skipActive || isInternalBindHost(writeHost)) {
1526
+ return false;
1527
+ }
1528
+ console.warn(
1529
+ [
1530
+ "",
1531
+ "!".repeat(72),
1532
+ `[relay] WARNING: POST /write is binding ${writeHost}:${blsPort} (a`,
1533
+ "[relay] non-loopback/non-internal interface) while event verification",
1534
+ options.devMode ? "[relay] is fully DISABLED (devMode)." : "[relay] is SKIPPED for paid ephemeral kinds (relay#85 default).",
1535
+ "[relay] This is safe ONLY if the write port is reachable exclusively",
1536
+ "[relay] through the payment-gating connector. In docker, do NOT",
1537
+ "[relay] host-publish this port (`expose:`, never `ports:` -- published",
1538
+ "[relay] ports bypass ufw). If the port is directly reachable, either",
1539
+ "[relay] bind it internally (TOON_WRITE_HOST=127.0.0.1) or restore full",
1540
+ "[relay] verification (TOON_VERIFY_EPHEMERAL=true).",
1541
+ "!".repeat(72),
1542
+ ""
1543
+ ].join("\n")
1544
+ );
1545
+ return true;
1546
+ }
1547
+ function createSubscription(relayUrl, filter, eventStore, activeSubscriptions) {
1548
+ if (!relayUrl.startsWith("ws://") && !relayUrl.startsWith("wss://")) {
1549
+ throw new Error(
1550
+ "Invalid relay URL -- must use WebSocket scheme (ws or wss)"
1551
+ );
1552
+ }
1553
+ const subscriber = new RelaySubscriber(
1554
+ { relayUrls: [relayUrl], filter },
1555
+ eventStore
1556
+ );
1557
+ const handle = subscriber.start();
1558
+ let active = true;
1559
+ const subscription = {
1560
+ close() {
1561
+ if (!active) return;
1562
+ active = false;
1563
+ handle.unsubscribe();
1564
+ activeSubscriptions.delete(subscription);
1565
+ },
1566
+ relayUrl,
1567
+ isActive() {
1568
+ return active;
1569
+ }
1570
+ };
1571
+ activeSubscriptions.add(subscription);
1572
+ return subscription;
1573
+ }
1574
+ async function startRelay(config) {
1575
+ const identity = deriveIdentity(config);
1576
+ const relayPort = config.relayPort ?? 7100;
1577
+ const blsPort = config.blsPort ?? 3100;
1578
+ const host = config.host ?? "0.0.0.0";
1579
+ const writeHost = config.writeHost ?? "0.0.0.0";
1580
+ const maxConnections = config.maxConnections ?? DEFAULT_RELAY_CONFIG.maxConnections;
1581
+ const dataDir = config.dataDir ?? "./data";
1582
+ const devMode = config.devMode ?? false;
1583
+ const verifyEphemeral = config.verifyEphemeral ?? false;
1584
+ const verifyWorkers = config.verifyWorkers ?? defaultVerifyWorkers();
1585
+ const ephemeralRateLimit = config.ephemeralRateLimit ?? DEFAULT_EPHEMERAL_RATE_LIMIT;
1586
+ const ephemeralMaxBodyBytes = config.ephemeralMaxBodyBytes ?? DEFAULT_EPHEMERAL_MAX_BODY_BYTES;
1587
+ const logWrites = config.logWrites ?? false;
1588
+ const enforceExpiration = config.enforceExpiration ?? true;
1589
+ const expirationReapGraceSeconds = config.expirationReapGraceSeconds ?? 86400;
1590
+ const expirationReapIntervalSeconds = config.expirationReapIntervalSeconds ?? 3600;
1591
+ const blockedEventIds = config.blockedEventIds ?? [];
1592
+ const resolvedConfig = {
1593
+ relayPort,
1594
+ blsPort,
1595
+ host,
1596
+ writeHost,
1597
+ maxConnections,
1598
+ dataDir,
1599
+ devMode,
1600
+ verifyEphemeral,
1601
+ verifyWorkers,
1602
+ ephemeralRateLimit,
1603
+ ephemeralMaxBodyBytes,
1604
+ logWrites,
1605
+ enforceExpiration,
1606
+ expirationReapGraceSeconds,
1607
+ expirationReapIntervalSeconds,
1608
+ blockedEventIds
1609
+ };
1610
+ let eventStore;
1611
+ if (config.eventStore) {
1612
+ eventStore = config.eventStore;
1613
+ } else {
1614
+ mkdirSync(dataDir, { recursive: true });
1615
+ eventStore = new SqliteEventStore(join(dataDir, "events.db"), {
1616
+ enforceExpiration,
1617
+ blockedEventIds
1618
+ });
1619
+ }
1620
+ console.log(
1621
+ `[relay] NIP-40 expiration: ${enforceExpiration ? `enforced (reap grace ${expirationReapGraceSeconds}s, sweep ${expirationReapIntervalSeconds > 0 ? `every ${expirationReapIntervalSeconds}s` : "disabled"})` : "NOT enforced -- expired events are still served (TOON_ENFORCE_EXPIRATION=false)"}`
1622
+ );
1623
+ console.log("[relay] NIP-09 deletion: enabled (author-signed kind:5 only)");
1624
+ if (blockedEventIds.length > 0) {
1625
+ console.warn(
1626
+ `[relay] OPERATOR BLOCKLIST ACTIVE -- ${blockedEventIds.length} event id(s) refused on write and swept from storage:`
1627
+ );
1628
+ for (const id of blockedEventIds) {
1629
+ console.warn(`[relay] blocked ${id}`);
1630
+ }
1631
+ }
1632
+ const wsRelay = new NostrRelayServer(
1633
+ { port: relayPort, host, maxConnections, enforceExpiration },
1634
+ eventStore
1635
+ );
1636
+ let reapTimer;
1637
+ if (enforceExpiration && expirationReapIntervalSeconds > 0 && eventStore.reapExpired) {
1638
+ const reap = () => {
1639
+ try {
1640
+ const removed = eventStore.reapExpired?.(
1641
+ Math.floor(Date.now() / 1e3),
1642
+ expirationReapGraceSeconds
1643
+ );
1644
+ if (removed) {
1645
+ console.log(
1646
+ `[relay] NIP-40 reaper removed ${removed} expired event(s)`
1647
+ );
1648
+ }
1649
+ } catch (error) {
1650
+ console.warn(
1651
+ `[relay] NIP-40 reaper failed: ${error instanceof Error ? error.message : String(error)}`
1652
+ );
1653
+ }
1654
+ };
1655
+ reap();
1656
+ reapTimer = setInterval(reap, expirationReapIntervalSeconds * 1e3);
1657
+ reapTimer.unref();
1658
+ }
1659
+ const app = new Hono();
1660
+ app.get(
1661
+ "/health",
1662
+ (c) => c.json(createHealthResponse({ pubkey: identity.pubkey }))
1663
+ );
1664
+ const metrics = createMetricsRegistry({
1665
+ verifyImplementation,
1666
+ verifyWorkers: 0,
1667
+ // updated once the pool reports its live size below
1668
+ ephemeralRateLimit,
1669
+ ephemeralMaxBodyBytes
1670
+ });
1671
+ const verifyPool = createVerifyPool({
1672
+ size: verifyWorkers,
1673
+ onMeasure: (ms) => metrics.recordVerify(ms)
1674
+ });
1675
+ metrics.setVerifyWorkers(verifyPool.size);
1676
+ app.get("/metrics", (c) => c.json(metrics.snapshot()));
1677
+ console.log(`[relay] event signature verify: ${verifyImplementation}`);
1678
+ console.log(
1679
+ `[relay] ephemeral-kind schnorr verify: ${devMode ? "skipped (devMode)" : verifyEphemeral ? "full (TOON_VERIFY_EPHEMERAL)" : "skipped -- payment-gated write path, id check kept (relay#85)"}`
1680
+ );
1681
+ warnIfWritePortExposed(writeHost, blsPort, { verifyEphemeral, devMode });
1682
+ console.log(
1683
+ `[relay] verify pool: ${verifyPool.size > 0 ? `${verifyPool.size} worker thread(s)` : "inline (0 workers -- verification on the event loop)"}`
1684
+ );
1685
+ const verifyViaPool = (event) => {
1686
+ metrics.setVerifyWorkers(verifyPool.size);
1687
+ return verifyPool.verify(event);
1688
+ };
1689
+ const broadcastToReaders = (event) => {
1690
+ try {
1691
+ wsRelay.broadcastEvent(event);
1692
+ } catch {
1693
+ }
1694
+ };
1695
+ const writeHandler = createWriteHandler({
1696
+ eventStore,
1697
+ devMode,
1698
+ verifyEphemeral,
1699
+ verifyEvent: verifyViaPool,
1700
+ logWrites,
1701
+ onStored: broadcastToReaders
1702
+ });
1703
+ app.post("/write", (c) => writeHandler.handleWrite(c));
1704
+ console.log(
1705
+ `[relay] ephemeral free write lane: enabled on POST /write-ephemeral (full schnorr verify always; rate limit ${ephemeralRateLimit.maxRequests} req / ${ephemeralRateLimit.windowMs}ms per key; max body ${ephemeralMaxBodyBytes} bytes)`
1706
+ );
1707
+ const ephemeralWriteHandler = createEphemeralWriteHandler({
1708
+ rateLimit: ephemeralRateLimit,
1709
+ maxBodyBytes: ephemeralMaxBodyBytes,
1710
+ verifyEvent: verifyViaPool,
1711
+ logWrites,
1712
+ onBroadcast: broadcastToReaders
1713
+ });
1714
+ app.post(
1715
+ "/write-ephemeral",
1716
+ (c) => ephemeralWriteHandler.handleWrite(c)
1717
+ );
1718
+ const blsServer = await new Promise((resolve) => {
1719
+ const server = serve(
1720
+ { fetch: app.fetch, port: blsPort, hostname: writeHost },
1721
+ () => resolve(server)
1722
+ );
1723
+ });
1724
+ await wsRelay.start();
1725
+ let running = true;
1726
+ const activeSubscriptions = /* @__PURE__ */ new Set();
1727
+ const instance = {
1728
+ isRunning() {
1729
+ return running;
1730
+ },
1731
+ subscribe(subscribeRelayUrl, filter) {
1732
+ if (!running) {
1733
+ throw new Error("Cannot subscribe: relay is not running");
1734
+ }
1735
+ return createSubscription(
1736
+ subscribeRelayUrl,
1737
+ filter,
1738
+ eventStore,
1739
+ activeSubscriptions
1740
+ );
1741
+ },
1742
+ async stop() {
1743
+ if (!running) return;
1744
+ running = false;
1745
+ for (const sub of activeSubscriptions) {
1746
+ sub.close();
1747
+ }
1748
+ activeSubscriptions.clear();
1749
+ if (reapTimer) clearInterval(reapTimer);
1750
+ await wsRelay.stop();
1751
+ blsServer.close();
1752
+ metrics.stop();
1753
+ await verifyPool.destroy();
1754
+ if (!config.eventStore) {
1755
+ eventStore.close?.();
1756
+ }
1757
+ },
1758
+ pubkey: identity.pubkey,
1759
+ config: resolvedConfig
1760
+ };
1761
+ return instance;
1762
+ }
1763
+
1764
+ export {
1765
+ VERSION,
1766
+ DEFAULT_RELAY_CONFIG,
1767
+ matchFilter,
1768
+ EXPIRATION_TAG,
1769
+ getExpiration,
1770
+ isExpired,
1771
+ DELETION_KIND,
1772
+ isDeletionKind,
1773
+ parseAddressCoordinate,
1774
+ parseDeletionTargets,
1775
+ isDeletableBy,
1776
+ InMemoryEventStore,
1777
+ RelayError,
1778
+ SqliteEventStore,
1779
+ parseBlockedEventIds,
1780
+ defaultVerifyWorkers,
1781
+ createVerifyPool,
1782
+ serializeEventFrame,
1783
+ ConnectionHandler,
1784
+ readOpenFilesSoftLimit,
1785
+ NostrRelayServer,
1786
+ RelaySubscriber,
1787
+ createMetricsRegistry,
1788
+ readPaymentAttribution,
1789
+ createWriteHandler,
1790
+ createRateLimiter,
1791
+ DEFAULT_EPHEMERAL_RATE_LIMIT,
1792
+ DEFAULT_EPHEMERAL_MAX_BODY_BYTES,
1793
+ createEphemeralWriteHandler,
1794
+ createHealthResponse,
1795
+ isInternalBindHost,
1796
+ warnIfWritePortExposed,
1797
+ startRelay
1798
+ };
1799
+ //# sourceMappingURL=chunk-IZOSPMWV.js.map