@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.
- package/README.md +34 -2
- package/dist/{chunk-745ADETR.js → chunk-QZQRHQEQ.js} +354 -108
- package/dist/chunk-QZQRHQEQ.js.map +1 -0
- package/dist/chunk-SMT6G3XD.js +92 -0
- package/dist/chunk-SMT6G3XD.js.map +1 -0
- package/dist/cli.js +58 -5
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +432 -12
- package/dist/index.js +118 -7
- package/dist/index.js.map +1 -1
- package/dist/verify-worker.d.ts +2 -0
- package/dist/verify-worker.js +17 -0
- package/dist/verify-worker.js.map +1 -0
- package/package.json +2 -1
- package/dist/chunk-745ADETR.js.map +0 -1
|
@@ -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:
|
|
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
|
-
|
|
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/
|
|
423
|
-
import {
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
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
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
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
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
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
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
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
|
-
|
|
480
|
-
const
|
|
481
|
-
if (
|
|
482
|
-
|
|
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
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
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(
|
|
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
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
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-
|
|
1273
|
+
//# sourceMappingURL=chunk-QZQRHQEQ.js.map
|