@toon-protocol/relay 2.0.0 → 2.0.2

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.
@@ -1,3 +1,9 @@
1
+ import {
2
+ verifyEventId,
3
+ verifyEventSignature,
4
+ verifyImplementation
5
+ } from "./chunk-SMT6G3XD.js";
6
+
1
7
  // src/version.ts
2
8
  var VERSION = "0.1.0";
3
9
 
@@ -5,7 +11,7 @@ var VERSION = "0.1.0";
5
11
  var DEFAULT_RELAY_CONFIG = {
6
12
  port: 7e3,
7
13
  host: "0.0.0.0",
8
- maxConnections: 100,
14
+ maxConnections: 4096,
9
15
  maxSubscriptionsPerConnection: 20,
10
16
  maxFiltersPerSubscription: 10,
11
17
  databasePath: ":memory:"
@@ -132,6 +138,7 @@ function getDTagValue(tags) {
132
138
  var SqliteEventStore = class {
133
139
  db;
134
140
  insertStmt;
141
+ insertOrIgnoreStmt;
135
142
  getStmt;
136
143
  deleteByPubkeyKindStmt;
137
144
  deleteByPubkeyKindDTagStmt;
@@ -144,11 +151,17 @@ var SqliteEventStore = class {
144
151
  constructor(dbPath = ":memory:") {
145
152
  try {
146
153
  this.db = new Database(dbPath);
154
+ this.db.pragma("journal_mode = WAL");
155
+ this.db.pragma("synchronous = NORMAL");
147
156
  initializeSchema(this.db);
148
157
  this.insertStmt = this.db.prepare(`
149
158
  INSERT OR REPLACE INTO events (id, pubkey, kind, content, tags, created_at, sig, received_at)
150
159
  VALUES (?, ?, ?, ?, ?, ?, ?, ?)
151
160
  `);
161
+ this.insertOrIgnoreStmt = this.db.prepare(`
162
+ INSERT OR IGNORE INTO events (id, pubkey, kind, content, tags, created_at, sig, received_at)
163
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
164
+ `);
152
165
  this.getStmt = this.db.prepare("SELECT * FROM events WHERE id = ?");
153
166
  this.deleteByPubkeyKindStmt = this.db.prepare(
154
167
  "DELETE FROM events WHERE pubkey = ? AND kind = ?"
@@ -182,11 +195,7 @@ var SqliteEventStore = class {
182
195
  } else if (isParameterizedReplaceableKind(event.kind)) {
183
196
  this.storeParameterizedReplaceableEvent(event, tagsJson, receivedAt);
184
197
  } else {
185
- const insertOrIgnore = this.db.prepare(`
186
- INSERT OR IGNORE INTO events (id, pubkey, kind, content, tags, created_at, sig, received_at)
187
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)
188
- `);
189
- insertOrIgnore.run(
198
+ this.insertOrIgnoreStmt.run(
190
199
  event.id,
191
200
  event.pubkey,
192
201
  event.kind,
@@ -419,100 +428,124 @@ var SqliteEventStore = class {
419
428
  }
420
429
  };
421
430
 
422
- // src/toon/codec.ts
423
- import { encode, decode } from "@toon-format/toon";
424
- var ToonEncodeError = class extends Error {
425
- code = "TOON_ENCODE_ERROR";
426
- constructor(message, cause) {
427
- super(message, { cause });
428
- this.name = "ToonEncodeError";
429
- }
430
- };
431
- var ToonDecodeError = class extends Error {
432
- code = "TOON_DECODE_ERROR";
433
- constructor(message, cause) {
434
- super(message, { cause });
435
- this.name = "ToonDecodeError";
436
- }
437
- };
438
- function encodeEventToToon(event) {
439
- return new TextEncoder().encode(encodeEventToToonString(event));
431
+ // src/crypto/verify-pool.ts
432
+ import { existsSync } from "fs";
433
+ import { cpus } from "os";
434
+ import { fileURLToPath } from "url";
435
+ import { Worker } from "worker_threads";
436
+ import { performance } from "perf_hooks";
437
+ import { verifiedSymbol } from "nostr-tools/pure";
438
+ function defaultVerifyWorkers() {
439
+ return Math.max(0, cpus().length - 1);
440
440
  }
441
- function encodeEventToToonString(event) {
442
- try {
443
- return encode(event);
444
- } catch (error) {
445
- throw new ToonEncodeError(
446
- `Failed to encode event to TOON: ${error instanceof Error ? error.message : String(error)}`,
447
- error instanceof Error ? error : void 0
448
- );
441
+ function resolveWorkerUrl() {
442
+ for (const candidate of [
443
+ new URL("./verify-worker.js", import.meta.url),
444
+ new URL("../../dist/verify-worker.js", import.meta.url)
445
+ ]) {
446
+ try {
447
+ if (existsSync(fileURLToPath(candidate))) return candidate;
448
+ } catch {
449
+ }
449
450
  }
451
+ return null;
450
452
  }
451
- function isValidHex(value, length) {
452
- return typeof value === "string" && value.length === length && /^[0-9a-f]+$/i.test(value);
453
- }
454
- function validateNostrEvent(obj) {
455
- if (typeof obj !== "object" || obj === null) {
456
- throw new ToonDecodeError("Decoded value is not an object");
457
- }
458
- const event = obj;
459
- if (!isValidHex(event["id"], 64)) {
460
- throw new ToonDecodeError(
461
- "Invalid event id: must be a 64-character hex string"
462
- );
463
- }
464
- if (!isValidHex(event["pubkey"], 64)) {
465
- throw new ToonDecodeError(
466
- "Invalid event pubkey: must be a 64-character hex string"
453
+ function createVerifyPool(options = {}) {
454
+ const requestedSize = options.size ?? defaultVerifyWorkers();
455
+ const onMeasure = options.onMeasure;
456
+ const measured = (fn) => {
457
+ if (!onMeasure) return fn();
458
+ const start = performance.now();
459
+ const result = fn();
460
+ onMeasure(performance.now() - start);
461
+ return result;
462
+ };
463
+ const inlineVerify = (event) => Promise.resolve(measured(() => verifyEventSignature(event)));
464
+ const workerUrl = requestedSize > 0 ? resolveWorkerUrl() : null;
465
+ if (requestedSize > 0 && !workerUrl) {
466
+ console.warn(
467
+ "[relay] verify pool: compiled worker (dist/verify-worker.js) not found -- falling back to inline verification (build the package to enable workers)"
467
468
  );
468
469
  }
469
- if (typeof event["kind"] !== "number" || !Number.isInteger(event["kind"])) {
470
- throw new ToonDecodeError("Invalid event kind: must be an integer");
471
- }
472
- if (typeof event["content"] !== "string") {
473
- throw new ToonDecodeError("Invalid event content: must be a string");
474
- }
475
- const tags = event["tags"];
476
- if (!Array.isArray(tags)) {
477
- throw new ToonDecodeError("Invalid event tags: must be an array");
470
+ const workers = [];
471
+ let seq = 0;
472
+ let destroyed = false;
473
+ const retireWorker = (pw, reason) => {
474
+ const index = workers.indexOf(pw);
475
+ if (index === -1) return;
476
+ workers.splice(index, 1);
477
+ if (!destroyed) {
478
+ console.warn(
479
+ `[relay] verify pool: worker retired (${reason}); ` + (workers.length > 0 ? `${workers.length} worker(s) remain` : "falling back to inline verification")
480
+ );
481
+ }
482
+ for (const { resolve, event } of pw.pending.values()) {
483
+ resolve(verifyEventSignature(event));
484
+ }
485
+ pw.pending.clear();
486
+ };
487
+ if (workerUrl) {
488
+ for (let i = 0; i < requestedSize; i++) {
489
+ const worker = new Worker(workerUrl);
490
+ const pw = { worker, pending: /* @__PURE__ */ new Map() };
491
+ worker.on("message", (reply) => {
492
+ const entry = pw.pending.get(reply.seq);
493
+ if (!entry) return;
494
+ pw.pending.delete(reply.seq);
495
+ entry.event[verifiedSymbol] = reply.ok;
496
+ entry.resolve(reply.ok);
497
+ });
498
+ worker.on(
499
+ "error",
500
+ (error) => retireWorker(pw, `error: ${error.message}`)
501
+ );
502
+ worker.on("exit", () => retireWorker(pw, "exit"));
503
+ workers.push(pw);
504
+ }
478
505
  }
479
- for (let i = 0; i < tags.length; i++) {
480
- const tag = tags[i];
481
- if (!Array.isArray(tag)) {
482
- throw new ToonDecodeError(`Invalid event tags[${i}]: must be an array`);
506
+ const poolVerify = (event) => {
507
+ const cached = event[verifiedSymbol];
508
+ if (typeof cached === "boolean") return Promise.resolve(cached);
509
+ let target = workers[0];
510
+ if (!target) return inlineVerify(event);
511
+ for (const pw of workers) {
512
+ if (pw.pending.size < target.pending.size) target = pw;
483
513
  }
484
- for (let j = 0; j < tag.length; j++) {
485
- if (typeof tag[j] !== "string") {
486
- throw new ToonDecodeError(
487
- `Invalid event tags[${i}][${j}]: must be a string`
488
- );
514
+ const start = performance.now();
515
+ return new Promise((resolve) => {
516
+ const id = ++seq;
517
+ target.pending.set(id, {
518
+ event,
519
+ resolve: (ok) => {
520
+ onMeasure?.(performance.now() - start);
521
+ resolve(ok);
522
+ }
523
+ });
524
+ target.worker.postMessage({ seq: id, event });
525
+ });
526
+ };
527
+ return {
528
+ verify(event) {
529
+ return workers.length > 0 ? poolVerify(event) : inlineVerify(event);
530
+ },
531
+ get size() {
532
+ return workers.length;
533
+ },
534
+ async destroy() {
535
+ destroyed = true;
536
+ const toTerminate = [...workers];
537
+ for (const pw of toTerminate) {
538
+ retireWorker(pw, "destroy");
489
539
  }
540
+ await Promise.all(toTerminate.map((pw) => pw.worker.terminate()));
490
541
  }
491
- }
492
- if (typeof event["created_at"] !== "number" || !Number.isInteger(event["created_at"])) {
493
- throw new ToonDecodeError("Invalid event created_at: must be an integer");
494
- }
495
- if (!isValidHex(event["sig"], 128)) {
496
- throw new ToonDecodeError(
497
- "Invalid event sig: must be a 128-character hex string"
498
- );
499
- }
500
- }
501
- function decodeEventFromToon(data) {
502
- let decoded;
503
- try {
504
- decoded = decode(new TextDecoder().decode(data));
505
- } catch (error) {
506
- throw new ToonDecodeError(
507
- `Failed to decode TOON data: ${error instanceof Error ? error.message : String(error)}`,
508
- error instanceof Error ? error : void 0
509
- );
510
- }
511
- validateNostrEvent(decoded);
512
- return decoded;
542
+ };
513
543
  }
514
544
 
515
545
  // src/websocket/ConnectionHandler.ts
546
+ function serializeEventFrame(subscriptionId, eventJson) {
547
+ return `["EVENT",${JSON.stringify(subscriptionId)},${eventJson}]`;
548
+ }
516
549
  var ConnectionHandler = class {
517
550
  constructor(ws, eventStore, config = {}) {
518
551
  this.ws = ws;
@@ -612,12 +645,21 @@ var ConnectionHandler = class {
612
645
  /**
613
646
  * Push a new event to all matching subscriptions on this connection.
614
647
  * Used when events are stored outside the WebSocket flow (e.g., via ILP).
648
+ *
649
+ * @param event - The event to fan out (used for filter matching).
650
+ * @param eventJson - Optional pre-serialized `JSON.stringify(event)`.
651
+ * `NostrRelayServer.broadcastEvent` serializes the event ONCE and passes
652
+ * it here so a 500-subscriber fan-out costs one serialization, not 500
653
+ * (relay#91). When omitted (direct callers), the event is serialized
654
+ * on first matching send.
615
655
  */
616
- notifyNewEvent(event) {
656
+ notifyNewEvent(event, eventJson) {
657
+ let json = eventJson;
617
658
  for (const sub of this.subscriptions.values()) {
618
659
  const matches = sub.filters.some((f) => matchFilter(event, f));
619
660
  if (matches) {
620
- this.sendEvent(sub.id, event);
661
+ json ??= JSON.stringify(event);
662
+ this.send(serializeEventFrame(sub.id, json));
621
663
  }
622
664
  }
623
665
  }
@@ -633,8 +675,19 @@ var ConnectionHandler = class {
633
675
  getSubscriptionCount() {
634
676
  return this.subscriptions.size;
635
677
  }
678
+ /**
679
+ * Emit an outbound NIP-01 EVENT frame.
680
+ *
681
+ * The event MUST go on the wire as canonical NIP-01 JSON —
682
+ * `["EVENT", <subId>, {id, pubkey, created_at, kind, tags, content, sig}]`
683
+ * with the event as a plain JSON object — so any standard nostr client can
684
+ * parse it and verify `id`/`sig` from the wire bytes (#46). Never re-encode
685
+ * the event (TOON text, double-JSON-stringify, etc.) at this boundary.
686
+ * serializeEventFrame is byte-identical to the full JSON.stringify
687
+ * envelope (pinned by tests).
688
+ */
636
689
  sendEvent(subscriptionId, event) {
637
- this.send(["EVENT", subscriptionId, encodeEventToToonString(event)]);
690
+ this.send(serializeEventFrame(subscriptionId, JSON.stringify(event)));
638
691
  }
639
692
  sendEose(subscriptionId) {
640
693
  this.send(["EOSE", subscriptionId]);
@@ -645,15 +698,32 @@ var ConnectionHandler = class {
645
698
  sendNotice(message) {
646
699
  this.send(["NOTICE", message]);
647
700
  }
701
+ /** Send a message: pre-serialized frames go out as-is (relay#91). */
648
702
  send(message) {
649
703
  if (this.ws.readyState === 1) {
650
- this.ws.send(JSON.stringify(message));
704
+ this.ws.send(
705
+ typeof message === "string" ? message : JSON.stringify(message)
706
+ );
651
707
  }
652
708
  }
653
709
  };
654
710
 
655
711
  // src/websocket/NostrRelayServer.ts
712
+ import { readFileSync } from "fs";
656
713
  import { WebSocketServer } from "ws";
714
+ var FD_HEADROOM = 128;
715
+ function readOpenFilesSoftLimit(read = (p) => readFileSync(p, "utf8")) {
716
+ try {
717
+ const line = read("/proc/self/limits").split("\n").find((l) => l.startsWith("Max open files"));
718
+ const match = line?.match(/Max open files\s+(\S+)/);
719
+ if (!match?.[1]) return null;
720
+ if (match[1] === "unlimited") return Infinity;
721
+ const limit = parseInt(match[1], 10);
722
+ return Number.isNaN(limit) ? null : limit;
723
+ } catch {
724
+ return null;
725
+ }
726
+ }
657
727
  var NostrRelayServer = class {
658
728
  constructor(config = {}, eventStore) {
659
729
  this.eventStore = eventStore;
@@ -683,6 +753,12 @@ var NostrRelayServer = class {
683
753
  if (address && typeof address === "object") {
684
754
  console.log(`[NostrRelayServer] Listening on port ${address.port}`);
685
755
  }
756
+ const fdLimit = readOpenFilesSoftLimit();
757
+ if (fdLimit !== null && Number.isFinite(fdLimit) && this.config.maxConnections > fdLimit - FD_HEADROOM) {
758
+ console.warn(
759
+ `[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.`
760
+ );
761
+ }
686
762
  resolve();
687
763
  });
688
764
  } catch (error) {
@@ -732,14 +808,24 @@ var NostrRelayServer = class {
732
808
  * Broadcast an event to all connected clients with matching subscriptions.
733
809
  * Call this after storing an event outside the WebSocket flow (e.g., via ILP)
734
810
  * so that discovery subscribers are notified.
811
+ *
812
+ * Serialize-once fan-out (relay#91): the event payload is stringified ONE
813
+ * time here and reused for every matching subscriber -- only the small
814
+ * per-subscription `["EVENT",<subId>,...]` envelope is spliced per send.
815
+ * Previously each of N subscribers re-serialized the identical event
816
+ * (N=500 pinned a core doing 500 identical stringifies per frame).
735
817
  */
736
818
  broadcastEvent(event) {
819
+ const eventJson = JSON.stringify(event);
737
820
  for (const handler of this.handlers.values()) {
738
- handler.notifyNewEvent(event);
821
+ handler.notifyNewEvent(event, eventJson);
739
822
  }
740
823
  }
741
824
  handleConnection(ws) {
742
825
  if (this.handlers.size >= this.config.maxConnections) {
826
+ console.warn(
827
+ `[NostrRelayServer] connection rejected: maxConnections (${this.config.maxConnections}) reached -- raise TOON_MAX_CONNECTIONS if this box has headroom (relay#90)`
828
+ );
743
829
  ws.close(1013, "max connections reached");
744
830
  return;
745
831
  }
@@ -826,9 +912,81 @@ var RelaySubscriber = class {
826
912
  }
827
913
  };
828
914
 
915
+ // src/launcher/metrics.ts
916
+ import { monitorEventLoopDelay } from "perf_hooks";
917
+ var VERIFY_WINDOW = 2048;
918
+ var NS_PER_MS = 1e6;
919
+ function percentileOf(sorted, fraction) {
920
+ if (sorted.length === 0) return 0;
921
+ const index = Math.min(
922
+ sorted.length - 1,
923
+ Math.ceil(fraction * sorted.length) - 1
924
+ );
925
+ return sorted[Math.max(0, index)] ?? 0;
926
+ }
927
+ function round(value) {
928
+ if (!Number.isFinite(value)) return 0;
929
+ return Math.round(value * 1e3) / 1e3;
930
+ }
931
+ function createMetricsRegistry(info) {
932
+ const loopDelay = monitorEventLoopDelay({
933
+ resolution: 20
934
+ });
935
+ loopDelay.enable();
936
+ let verifyWorkers = info.verifyWorkers;
937
+ let count = 0;
938
+ let totalMs = 0;
939
+ let maxMs = 0;
940
+ const window = new Array(VERIFY_WINDOW);
941
+ let windowFill = 0;
942
+ let windowCursor = 0;
943
+ return {
944
+ recordVerify(ms) {
945
+ count += 1;
946
+ totalMs += ms;
947
+ if (ms > maxMs) maxMs = ms;
948
+ window[windowCursor] = ms;
949
+ windowCursor = (windowCursor + 1) % VERIFY_WINDOW;
950
+ if (windowFill < VERIFY_WINDOW) windowFill += 1;
951
+ },
952
+ snapshot() {
953
+ const recent = window.slice(0, windowFill).sort((a, b) => a - b);
954
+ return {
955
+ timestamp: Date.now(),
956
+ eventLoopDelayMs: {
957
+ mean: round(loopDelay.mean / NS_PER_MS),
958
+ p50: round(loopDelay.percentile(50) / NS_PER_MS),
959
+ p99: round(loopDelay.percentile(99) / NS_PER_MS),
960
+ max: round(loopDelay.max / NS_PER_MS)
961
+ },
962
+ verify: {
963
+ implementation: info.verifyImplementation,
964
+ workers: verifyWorkers,
965
+ count,
966
+ meanMs: round(count > 0 ? totalMs / count : 0),
967
+ maxMs: round(maxMs),
968
+ p50Ms: round(percentileOf(recent, 0.5)),
969
+ p99Ms: round(percentileOf(recent, 0.99))
970
+ }
971
+ };
972
+ },
973
+ setVerifyWorkers(workers) {
974
+ verifyWorkers = workers;
975
+ },
976
+ stop() {
977
+ loopDelay.disable();
978
+ }
979
+ };
980
+ }
981
+
829
982
  // src/launcher/handlers/write-handler.ts
830
- import { verifyEvent as verifyEvent2 } from "nostr-tools/pure";
983
+ function isEphemeralKind(kind) {
984
+ return kind >= 2e4 && kind < 3e4;
985
+ }
831
986
  function createWriteHandler(config) {
987
+ const logWrites = config.logWrites ?? false;
988
+ const verifyEphemeral = config.verifyEphemeral ?? false;
989
+ const verifyEvent2 = config.verifyEvent ?? verifyEventSignature;
832
990
  return {
833
991
  async handleWrite(c) {
834
992
  let body;
@@ -844,13 +1002,23 @@ function createWriteHandler(config) {
844
1002
  const payer = c.req.header("X-TOON-Payer");
845
1003
  const amount = c.req.header("X-TOON-Amount");
846
1004
  const chain = c.req.header("X-TOON-Chain");
847
- console.log(
848
- `[write] event=${event.id} payer=${payer ?? "-"} amount=${amount ?? "-"} chain=${chain ?? "-"}`
849
- );
850
- if (!config.devMode && !verifyEvent2(event)) {
851
- return c.json({ error: "Invalid event signature" }, 422);
1005
+ if (logWrites) {
1006
+ console.log(
1007
+ `[write] event=${event.id} payer=${payer ?? "-"} amount=${amount ?? "-"} chain=${chain ?? "-"}`
1008
+ );
1009
+ }
1010
+ if (!config.devMode) {
1011
+ if (isEphemeralKind(event.kind) && !verifyEphemeral) {
1012
+ if (!verifyEventId(event)) {
1013
+ return c.json({ error: "Invalid event id" }, 422);
1014
+ }
1015
+ } else if (!await verifyEvent2(event)) {
1016
+ return c.json({ error: "Invalid event signature" }, 422);
1017
+ }
1018
+ }
1019
+ if (!isEphemeralKind(event.kind)) {
1020
+ config.eventStore.store(event);
852
1021
  }
853
- config.eventStore.store(event);
854
1022
  config.onStored?.(event);
855
1023
  return c.json(
856
1024
  {
@@ -898,6 +1066,41 @@ function deriveIdentity(config) {
898
1066
  const secretKey = hasMnemonic ? privateKeyFromSeedWords(config.mnemonic) : config.secretKey;
899
1067
  return { secretKey, pubkey: getPublicKey(secretKey) };
900
1068
  }
1069
+ function isInternalBindHost(host) {
1070
+ const h = host.trim().toLowerCase();
1071
+ if (h === "localhost" || h === "::1" || h === "[::1]") return true;
1072
+ if (h.startsWith("127.")) return true;
1073
+ if (h.startsWith("10.")) return true;
1074
+ if (h.startsWith("192.168.")) return true;
1075
+ if (/^172\.(1[6-9]|2\d|3[01])\./.test(h)) return true;
1076
+ if (/^f[cd][0-9a-f]{2}:/.test(h)) return true;
1077
+ if (h.startsWith("fe80:")) return true;
1078
+ return false;
1079
+ }
1080
+ function warnIfWritePortExposed(writeHost, blsPort, options) {
1081
+ const skipActive = options.devMode || !options.verifyEphemeral;
1082
+ if (!skipActive || isInternalBindHost(writeHost)) {
1083
+ return false;
1084
+ }
1085
+ console.warn(
1086
+ [
1087
+ "",
1088
+ "!".repeat(72),
1089
+ `[relay] WARNING: POST /write is binding ${writeHost}:${blsPort} (a`,
1090
+ "[relay] non-loopback/non-internal interface) while event verification",
1091
+ options.devMode ? "[relay] is fully DISABLED (devMode)." : "[relay] is SKIPPED for paid ephemeral kinds (relay#85 default).",
1092
+ "[relay] This is safe ONLY if the write port is reachable exclusively",
1093
+ "[relay] through the payment-gating connector. In docker, do NOT",
1094
+ "[relay] host-publish this port (`expose:`, never `ports:` -- published",
1095
+ "[relay] ports bypass ufw). If the port is directly reachable, either",
1096
+ "[relay] bind it internally (TOON_WRITE_HOST=127.0.0.1) or restore full",
1097
+ "[relay] verification (TOON_VERIFY_EPHEMERAL=true).",
1098
+ "!".repeat(72),
1099
+ ""
1100
+ ].join("\n")
1101
+ );
1102
+ return true;
1103
+ }
901
1104
  function createSubscription(relayUrl, filter, eventStore, activeSubscriptions) {
902
1105
  if (!relayUrl.startsWith("ws://") && !relayUrl.startsWith("wss://")) {
903
1106
  throw new Error(
@@ -930,14 +1133,24 @@ async function startRelay(config) {
930
1133
  const relayPort = config.relayPort ?? 7100;
931
1134
  const blsPort = config.blsPort ?? 3100;
932
1135
  const host = config.host ?? "0.0.0.0";
1136
+ const writeHost = config.writeHost ?? "0.0.0.0";
1137
+ const maxConnections = config.maxConnections ?? DEFAULT_RELAY_CONFIG.maxConnections;
933
1138
  const dataDir = config.dataDir ?? "./data";
934
1139
  const devMode = config.devMode ?? false;
1140
+ const verifyEphemeral = config.verifyEphemeral ?? false;
1141
+ const verifyWorkers = config.verifyWorkers ?? defaultVerifyWorkers();
1142
+ const logWrites = config.logWrites ?? false;
935
1143
  const resolvedConfig = {
936
1144
  relayPort,
937
1145
  blsPort,
938
1146
  host,
1147
+ writeHost,
1148
+ maxConnections,
939
1149
  dataDir,
940
- devMode
1150
+ devMode,
1151
+ verifyEphemeral,
1152
+ verifyWorkers,
1153
+ logWrites
941
1154
  };
942
1155
  let eventStore;
943
1156
  if (config.eventStore) {
@@ -946,15 +1159,43 @@ async function startRelay(config) {
946
1159
  mkdirSync(dataDir, { recursive: true });
947
1160
  eventStore = new SqliteEventStore(join(dataDir, "events.db"));
948
1161
  }
949
- const wsRelay = new NostrRelayServer({ port: relayPort, host }, eventStore);
1162
+ const wsRelay = new NostrRelayServer(
1163
+ { port: relayPort, host, maxConnections },
1164
+ eventStore
1165
+ );
950
1166
  const app = new Hono();
951
1167
  app.get(
952
1168
  "/health",
953
1169
  (c) => c.json(createHealthResponse({ pubkey: identity.pubkey }))
954
1170
  );
1171
+ const metrics = createMetricsRegistry({
1172
+ verifyImplementation,
1173
+ verifyWorkers: 0
1174
+ // updated once the pool reports its live size below
1175
+ });
1176
+ const verifyPool = createVerifyPool({
1177
+ size: verifyWorkers,
1178
+ onMeasure: (ms) => metrics.recordVerify(ms)
1179
+ });
1180
+ metrics.setVerifyWorkers(verifyPool.size);
1181
+ app.get("/metrics", (c) => c.json(metrics.snapshot()));
1182
+ console.log(`[relay] event signature verify: ${verifyImplementation}`);
1183
+ console.log(
1184
+ `[relay] ephemeral-kind schnorr verify: ${devMode ? "skipped (devMode)" : verifyEphemeral ? "full (TOON_VERIFY_EPHEMERAL)" : "skipped -- payment-gated write path, id check kept (relay#85)"}`
1185
+ );
1186
+ warnIfWritePortExposed(writeHost, blsPort, { verifyEphemeral, devMode });
1187
+ console.log(
1188
+ `[relay] verify pool: ${verifyPool.size > 0 ? `${verifyPool.size} worker thread(s)` : "inline (0 workers -- verification on the event loop)"}`
1189
+ );
955
1190
  const writeHandler = createWriteHandler({
956
1191
  eventStore,
957
1192
  devMode,
1193
+ verifyEphemeral,
1194
+ verifyEvent: (event) => {
1195
+ metrics.setVerifyWorkers(verifyPool.size);
1196
+ return verifyPool.verify(event);
1197
+ },
1198
+ logWrites,
958
1199
  onStored: (event) => {
959
1200
  try {
960
1201
  wsRelay.broadcastEvent(event);
@@ -965,7 +1206,7 @@ async function startRelay(config) {
965
1206
  app.post("/write", (c) => writeHandler.handleWrite(c));
966
1207
  const blsServer = await new Promise((resolve) => {
967
1208
  const server = serve(
968
- { fetch: app.fetch, port: blsPort },
1209
+ { fetch: app.fetch, port: blsPort, hostname: writeHost },
969
1210
  () => resolve(server)
970
1211
  );
971
1212
  });
@@ -996,6 +1237,8 @@ async function startRelay(config) {
996
1237
  activeSubscriptions.clear();
997
1238
  await wsRelay.stop();
998
1239
  blsServer.close();
1240
+ metrics.stop();
1241
+ await verifyPool.destroy();
999
1242
  if (!config.eventStore) {
1000
1243
  eventStore.close?.();
1001
1244
  }
@@ -1013,15 +1256,18 @@ export {
1013
1256
  InMemoryEventStore,
1014
1257
  RelayError,
1015
1258
  SqliteEventStore,
1016
- ToonEncodeError,
1017
- ToonDecodeError,
1018
- encodeEventToToon,
1019
- decodeEventFromToon,
1259
+ defaultVerifyWorkers,
1260
+ createVerifyPool,
1261
+ serializeEventFrame,
1020
1262
  ConnectionHandler,
1263
+ readOpenFilesSoftLimit,
1021
1264
  NostrRelayServer,
1022
1265
  RelaySubscriber,
1266
+ createMetricsRegistry,
1023
1267
  createWriteHandler,
1024
1268
  createHealthResponse,
1269
+ isInternalBindHost,
1270
+ warnIfWritePortExposed,
1025
1271
  startRelay
1026
1272
  };
1027
- //# sourceMappingURL=chunk-745ADETR.js.map
1273
+ //# sourceMappingURL=chunk-QZQRHQEQ.js.map