diff options
Diffstat (limited to 'js/axon.js')
| -rw-r--r-- | js/axon.js | 519 |
1 files changed, 519 insertions, 0 deletions
diff --git a/js/axon.js b/js/axon.js new file mode 100644 index 0000000..9f50340 --- /dev/null +++ b/js/axon.js | |||
| @@ -0,0 +1,519 @@ | |||
| 1 | /** | ||
| 2 | * axon.js — framework-agnostic Axon protocol client | ||
| 3 | * | ||
| 4 | * Wire message types: | ||
| 5 | * Client → Relay: AUTH=1, SUBSCRIBE=2, UNSUBSCRIBE=3, PUBLISH=4 | ||
| 6 | * Relay → Client: CHALLENGE=10, EVENT=11, EOSE=12, OK=13, ERROR=14 | ||
| 7 | */ | ||
| 8 | |||
| 9 | import { ed25519, x25519, edwardsToMontgomeryPriv, edwardsToMontgomeryPub } from '@noble/curves/ed25519'; | ||
| 10 | import { sha256 } from '@noble/hashes/sha256'; | ||
| 11 | import { chacha20poly1305 } from '@noble/ciphers/chacha'; | ||
| 12 | import { encode, decode } from '@msgpack/msgpack'; | ||
| 13 | |||
| 14 | // --------------------------------------------------------------------------- | ||
| 15 | // Event kind constants | ||
| 16 | // --------------------------------------------------------------------------- | ||
| 17 | |||
| 18 | export const KIND = { | ||
| 19 | PROFILE: 0, | ||
| 20 | MESSAGE: 1000, | ||
| 21 | DM: 2000, | ||
| 22 | PROGRESS: 3000, | ||
| 23 | JOB_REQ: 5000, | ||
| 24 | JOB_RESULT: 6000, | ||
| 25 | JOB_FEEDBACK: 7000, | ||
| 26 | }; | ||
| 27 | |||
| 28 | // --------------------------------------------------------------------------- | ||
| 29 | // Hex utilities | ||
| 30 | // --------------------------------------------------------------------------- | ||
| 31 | |||
| 32 | export function toHex(bytes) { | ||
| 33 | return Array.from(bytes) | ||
| 34 | .map(b => b.toString(16).padStart(2, '0')) | ||
| 35 | .join(''); | ||
| 36 | } | ||
| 37 | |||
| 38 | export function fromHex(hex) { | ||
| 39 | if (hex.length % 2 !== 0) throw new Error('axon: odd hex length'); | ||
| 40 | const out = new Uint8Array(hex.length / 2); | ||
| 41 | for (let i = 0; i < out.length; i++) { | ||
| 42 | out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16); | ||
| 43 | } | ||
| 44 | return out; | ||
| 45 | } | ||
| 46 | |||
| 47 | // --------------------------------------------------------------------------- | ||
| 48 | // Key generation | ||
| 49 | // --------------------------------------------------------------------------- | ||
| 50 | |||
| 51 | export function generateSeed() { | ||
| 52 | const seed = new Uint8Array(32); | ||
| 53 | crypto.getRandomValues(seed); | ||
| 54 | return seed; | ||
| 55 | } | ||
| 56 | |||
| 57 | /** | ||
| 58 | * keypairFromSeed(seed) → { seed: Uint8Array(32), pubkey: Uint8Array(32) } | ||
| 59 | */ | ||
| 60 | export function keypairFromSeed(seed) { | ||
| 61 | const pubkey = ed25519.getPublicKey(seed); | ||
| 62 | return { seed: seed instanceof Uint8Array ? seed : new Uint8Array(seed), pubkey }; | ||
| 63 | } | ||
| 64 | |||
| 65 | // --------------------------------------------------------------------------- | ||
| 66 | // Canonical encoding (internal helpers) | ||
| 67 | // --------------------------------------------------------------------------- | ||
| 68 | |||
| 69 | /** | ||
| 70 | * canonicalTagsBytes encodes tags in the Axon canonical format (big-endian). | ||
| 71 | * | ||
| 72 | * Tags are sorted by name, then by first value for ties (ascending). | ||
| 73 | * | ||
| 74 | * Format: | ||
| 75 | * uint16(num_tags) | ||
| 76 | * for each tag: | ||
| 77 | * uint16(len(name)) || utf8(name) | ||
| 78 | * uint16(num_values) | ||
| 79 | * for each value: | ||
| 80 | * uint32(len(value)) || utf8(value) | ||
| 81 | */ | ||
| 82 | function canonicalTagsBytes(tags) { | ||
| 83 | const enc = new TextEncoder(); | ||
| 84 | |||
| 85 | // Sort a copy: by name ascending, then first value ascending for ties. | ||
| 86 | const sorted = tags.slice().sort((a, b) => { | ||
| 87 | if (a.name !== b.name) return a.name < b.name ? -1 : 1; | ||
| 88 | const va = (a.values && a.values.length > 0) ? a.values[0] : ''; | ||
| 89 | const vb = (b.values && b.values.length > 0) ? b.values[0] : ''; | ||
| 90 | return va < vb ? -1 : va > vb ? 1 : 0; | ||
| 91 | }); | ||
| 92 | |||
| 93 | // First pass: compute total byte length. | ||
| 94 | let size = 2; // uint16 num_tags | ||
| 95 | for (const tag of sorted) { | ||
| 96 | const nameBytes = enc.encode(tag.name); | ||
| 97 | size += 2 + nameBytes.length; // uint16(len) + name | ||
| 98 | size += 2; // uint16(num_values) | ||
| 99 | for (const v of (tag.values || [])) { | ||
| 100 | const vb = enc.encode(v); | ||
| 101 | size += 4 + vb.length; // uint32(len) + value | ||
| 102 | } | ||
| 103 | } | ||
| 104 | |||
| 105 | const buf = new Uint8Array(size); | ||
| 106 | const dv = new DataView(buf.buffer); | ||
| 107 | let off = 0; | ||
| 108 | |||
| 109 | dv.setUint16(off, sorted.length, false); off += 2; | ||
| 110 | |||
| 111 | for (const tag of sorted) { | ||
| 112 | const nameBytes = enc.encode(tag.name); | ||
| 113 | dv.setUint16(off, nameBytes.length, false); off += 2; | ||
| 114 | buf.set(nameBytes, off); off += nameBytes.length; | ||
| 115 | |||
| 116 | const values = tag.values || []; | ||
| 117 | dv.setUint16(off, values.length, false); off += 2; | ||
| 118 | |||
| 119 | for (const v of values) { | ||
| 120 | const vb = enc.encode(v); | ||
| 121 | dv.setUint32(off, vb.length, false); off += 4; | ||
| 122 | buf.set(vb, off); off += vb.length; | ||
| 123 | } | ||
| 124 | } | ||
| 125 | |||
| 126 | return buf; | ||
| 127 | } | ||
| 128 | |||
| 129 | /** | ||
| 130 | * canonicalPayload builds the deterministic byte blob that is SHA256-hashed | ||
| 131 | * to produce the event ID. | ||
| 132 | * | ||
| 133 | * Layout (big-endian): | ||
| 134 | * [0:2] uint16 = 32 pubkey length | ||
| 135 | * [2:34] bytes pubkey (32 bytes) | ||
| 136 | * [34:42] uint64 created_at | ||
| 137 | * [42:44] uint16 kind | ||
| 138 | * [44:48] uint32 content length | ||
| 139 | * [48:48+n] bytes content | ||
| 140 | * [48+n:80+n] bytes SHA256(canonical_tags_bytes), 32 bytes | ||
| 141 | */ | ||
| 142 | function canonicalPayload(pubkey, createdAt, kind, content, tags) { | ||
| 143 | const tagsHash = sha256(canonicalTagsBytes(tags)); | ||
| 144 | const n = content.length; | ||
| 145 | // Total: 2 + 32 + 8 + 2 + 4 + n + 32 = 80 + n | ||
| 146 | const buf = new Uint8Array(80 + n); | ||
| 147 | const dv = new DataView(buf.buffer); | ||
| 148 | |||
| 149 | dv.setUint16(0, 32, false); // pubkey length | ||
| 150 | buf.set(pubkey, 2); // pubkey [2:34] | ||
| 151 | dv.setBigUint64(34, BigInt(createdAt), false); // created_at [34:42] | ||
| 152 | dv.setUint16(42, kind, false); // kind [42:44] | ||
| 153 | dv.setUint32(44, n, false); // content length [44:48] | ||
| 154 | buf.set(content, 48); // content [48:48+n] | ||
| 155 | buf.set(tagsHash, 48 + n); // tags hash [48+n:80+n] | ||
| 156 | |||
| 157 | return buf; | ||
| 158 | } | ||
| 159 | |||
| 160 | // --------------------------------------------------------------------------- | ||
| 161 | // Event signing | ||
| 162 | // --------------------------------------------------------------------------- | ||
| 163 | |||
| 164 | /** | ||
| 165 | * signEvent(kp, kind, content, tags=[]) → Event object | ||
| 166 | * | ||
| 167 | * content may be a string (will be UTF-8 encoded) or Uint8Array. | ||
| 168 | * tags is an array of { name: string, values: string[] }. | ||
| 169 | * | ||
| 170 | * Returns: | ||
| 171 | * { id, pubkey, created_at, kind, content, sig, tags } | ||
| 172 | * where binary fields are Uint8Array. | ||
| 173 | */ | ||
| 174 | export function signEvent(kp, kind, content, tags = []) { | ||
| 175 | const enc = new TextEncoder(); | ||
| 176 | const contentBytes = typeof content === 'string' ? enc.encode(content) : content; | ||
| 177 | const createdAt = Math.floor(Date.now() / 1000); | ||
| 178 | |||
| 179 | const payload = canonicalPayload(kp.pubkey, createdAt, kind, contentBytes, tags); | ||
| 180 | const eventID = sha256(payload); | ||
| 181 | const sig = ed25519.sign(eventID, kp.seed); | ||
| 182 | |||
| 183 | return { | ||
| 184 | id: eventID, | ||
| 185 | pubkey: kp.pubkey, | ||
| 186 | created_at: createdAt, | ||
| 187 | kind, | ||
| 188 | content: contentBytes, | ||
| 189 | sig, | ||
| 190 | tags, | ||
| 191 | }; | ||
| 192 | } | ||
| 193 | |||
| 194 | // --------------------------------------------------------------------------- | ||
| 195 | // Challenge signing | ||
| 196 | // --------------------------------------------------------------------------- | ||
| 197 | |||
| 198 | /** | ||
| 199 | * signChallenge(kp, nonce, relayURL) → Uint8Array(64) | ||
| 200 | * | ||
| 201 | * sig = ed25519.sign(SHA256(nonce || utf8(relay_url)), seed) | ||
| 202 | */ | ||
| 203 | export function signChallenge(kp, nonce, relayURL) { | ||
| 204 | const enc = new TextEncoder(); | ||
| 205 | const urlBytes = enc.encode(relayURL); | ||
| 206 | const msg = new Uint8Array(nonce.length + urlBytes.length); | ||
| 207 | msg.set(nonce, 0); | ||
| 208 | msg.set(urlBytes, nonce.length); | ||
| 209 | const digest = sha256(msg); | ||
| 210 | return ed25519.sign(digest, kp.seed); | ||
| 211 | } | ||
| 212 | |||
| 213 | // --------------------------------------------------------------------------- | ||
| 214 | // Test vector runner | ||
| 215 | // --------------------------------------------------------------------------- | ||
| 216 | |||
| 217 | /** | ||
| 218 | * runVectors(vectors) → { pass: bool, results: [{label, pass, got, expected}] } | ||
| 219 | * | ||
| 220 | * Checks all 6 fields from the vectors JSON: | ||
| 221 | * canonical_tags_hex, canonical_tags_hash, pubkey_hex, | ||
| 222 | * canonical_payload, event_id, sig_hex | ||
| 223 | */ | ||
| 224 | export function runVectors(vectors) { | ||
| 225 | const results = []; | ||
| 226 | |||
| 227 | function check(label, got, expected) { | ||
| 228 | const pass = got === expected; | ||
| 229 | results.push({ label, pass, got, expected }); | ||
| 230 | } | ||
| 231 | |||
| 232 | // Reconstruct inputs from vectors. | ||
| 233 | const seed = fromHex(vectors.seed_hex); | ||
| 234 | const kp = keypairFromSeed(seed); | ||
| 235 | const content = fromHex(vectors.content_hex); | ||
| 236 | const tags = vectors.tags_input; // [{ name, values }] | ||
| 237 | |||
| 238 | // 1. canonical_tags_hex | ||
| 239 | const tagsBytes = canonicalTagsBytes(tags); | ||
| 240 | check('canonical_tags_hex', toHex(tagsBytes), vectors.canonical_tags_hex); | ||
| 241 | |||
| 242 | // 2. canonical_tags_hash | ||
| 243 | const tagsHash = sha256(tagsBytes); | ||
| 244 | check('canonical_tags_hash', toHex(tagsHash), vectors.canonical_tags_hash); | ||
| 245 | |||
| 246 | // 3. pubkey_hex | ||
| 247 | check('pubkey_hex', toHex(kp.pubkey), vectors.pubkey_hex); | ||
| 248 | |||
| 249 | // 4. canonical_payload | ||
| 250 | const payload = canonicalPayload( | ||
| 251 | kp.pubkey, | ||
| 252 | vectors.created_at, | ||
| 253 | vectors.kind, | ||
| 254 | content, | ||
| 255 | tags, | ||
| 256 | ); | ||
| 257 | check('canonical_payload', toHex(payload), vectors.canonical_payload); | ||
| 258 | |||
| 259 | // 5. event_id | ||
| 260 | const eventID = sha256(payload); | ||
| 261 | check('event_id', toHex(eventID), vectors.event_id); | ||
| 262 | |||
| 263 | // 6. sig_hex | ||
| 264 | const sig = ed25519.sign(eventID, seed); | ||
| 265 | check('sig_hex', toHex(sig), vectors.sig_hex); | ||
| 266 | |||
| 267 | const pass = results.every(r => r.pass); | ||
| 268 | return { pass, results }; | ||
| 269 | } | ||
| 270 | |||
| 271 | // --------------------------------------------------------------------------- | ||
| 272 | // DM encryption (X25519 ECDH + ChaCha20-Poly1305) | ||
| 273 | // --------------------------------------------------------------------------- | ||
| 274 | |||
| 275 | /** | ||
| 276 | * dhSharedSecret(senderSeed, recipientEd25519Pub) → Uint8Array(32) | ||
| 277 | * | ||
| 278 | * Converts both keys to X25519 form, then runs the DH scalar multiplication. | ||
| 279 | * Matches Go's DHSharedSecret exactly: | ||
| 280 | * - private: SHA-512(seed)[0:32] clamped per RFC 8032 | ||
| 281 | * - public: Edwards y → Montgomery u via (1+y)/(1-y) mod p | ||
| 282 | */ | ||
| 283 | function dhSharedSecret(localSeed, remoteEd25519Pub) { | ||
| 284 | const xPriv = edwardsToMontgomeryPriv(localSeed); | ||
| 285 | const xPub = edwardsToMontgomeryPub(remoteEd25519Pub); | ||
| 286 | return x25519.getSharedSecret(xPriv, xPub); | ||
| 287 | } | ||
| 288 | |||
| 289 | /** | ||
| 290 | * encryptDM(senderSeed, recipientPubkey, plaintext) → Uint8Array | ||
| 291 | * | ||
| 292 | * Returns nonce(12) || ciphertext+tag, matching Go's EncryptDM. | ||
| 293 | * plaintext may be string or Uint8Array. | ||
| 294 | */ | ||
| 295 | export function encryptDM(senderSeed, recipientPubkey, plaintext) { | ||
| 296 | const enc = new TextEncoder(); | ||
| 297 | const pt = typeof plaintext === 'string' ? enc.encode(plaintext) : plaintext; | ||
| 298 | const shared = dhSharedSecret(senderSeed, recipientPubkey); | ||
| 299 | const nonce = crypto.getRandomValues(new Uint8Array(12)); | ||
| 300 | const ct = chacha20poly1305(shared, nonce).encrypt(pt); | ||
| 301 | const out = new Uint8Array(12 + ct.length); | ||
| 302 | out.set(nonce); | ||
| 303 | out.set(ct, 12); | ||
| 304 | return out; | ||
| 305 | } | ||
| 306 | |||
| 307 | /** | ||
| 308 | * decryptDM(recipientSeed, senderPubkey, blob) → Uint8Array | ||
| 309 | * | ||
| 310 | * blob is nonce(12) || ciphertext+tag. Throws on authentication failure. | ||
| 311 | */ | ||
| 312 | export function decryptDM(recipientSeed, senderPubkey, blob) { | ||
| 313 | if (blob.length < 12) throw new Error('axon: dm blob too short'); | ||
| 314 | const shared = dhSharedSecret(recipientSeed, senderPubkey); | ||
| 315 | const nonce = blob.slice(0, 12); | ||
| 316 | const ct = blob.slice(12); | ||
| 317 | return chacha20poly1305(shared, nonce).decrypt(ct); | ||
| 318 | } | ||
| 319 | |||
| 320 | // --------------------------------------------------------------------------- | ||
| 321 | // AxonClient | ||
| 322 | // --------------------------------------------------------------------------- | ||
| 323 | |||
| 324 | const MSG = { | ||
| 325 | // Client → Relay | ||
| 326 | AUTH: 1, | ||
| 327 | SUBSCRIBE: 2, | ||
| 328 | UNSUBSCRIBE: 3, | ||
| 329 | PUBLISH: 4, | ||
| 330 | // Relay → Client | ||
| 331 | CHALLENGE: 10, | ||
| 332 | EVENT: 11, | ||
| 333 | EOSE: 12, | ||
| 334 | OK: 13, | ||
| 335 | ERROR: 14, | ||
| 336 | }; | ||
| 337 | |||
| 338 | export class AxonClient { | ||
| 339 | #ws = null; | ||
| 340 | #kp = null; | ||
| 341 | #relayURL = ''; | ||
| 342 | #authed = false; | ||
| 343 | #listeners = {}; | ||
| 344 | |||
| 345 | constructor(relayURL, kp) { | ||
| 346 | this.#relayURL = relayURL; | ||
| 347 | this.#kp = kp; | ||
| 348 | } | ||
| 349 | |||
| 350 | // ------------------------------------------------------------------------- | ||
| 351 | // Event emitter | ||
| 352 | // ------------------------------------------------------------------------- | ||
| 353 | |||
| 354 | on(event, fn) { | ||
| 355 | if (!this.#listeners[event]) this.#listeners[event] = []; | ||
| 356 | this.#listeners[event].push(fn); | ||
| 357 | return this; | ||
| 358 | } | ||
| 359 | |||
| 360 | #emit(name, ...args) { | ||
| 361 | for (const fn of (this.#listeners[name] || [])) { | ||
| 362 | try { fn(...args); } catch { /* ignore listener errors */ } | ||
| 363 | } | ||
| 364 | } | ||
| 365 | |||
| 366 | // ------------------------------------------------------------------------- | ||
| 367 | // Wire encoding | ||
| 368 | // ------------------------------------------------------------------------- | ||
| 369 | |||
| 370 | #send(type, payload) { | ||
| 371 | if (!this.#ws || this.#ws.readyState !== WebSocket.OPEN) return; | ||
| 372 | this.#ws.send(encode([type, payload])); | ||
| 373 | } | ||
| 374 | |||
| 375 | // ------------------------------------------------------------------------- | ||
| 376 | // Connection lifecycle | ||
| 377 | // ------------------------------------------------------------------------- | ||
| 378 | |||
| 379 | /** | ||
| 380 | * connect() → Promise | ||
| 381 | * | ||
| 382 | * Opens the WebSocket, performs the CHALLENGE → AUTH → OK handshake, | ||
| 383 | * then resolves. Rejects on error or if auth fails. | ||
| 384 | */ | ||
| 385 | connect() { | ||
| 386 | return new Promise((resolve, reject) => { | ||
| 387 | let settled = false; | ||
| 388 | const settle = (ok, value) => { | ||
| 389 | if (settled) return; | ||
| 390 | settled = true; | ||
| 391 | if (ok) resolve(value); | ||
| 392 | else reject(value); | ||
| 393 | }; | ||
| 394 | |||
| 395 | const ws = new WebSocket(this.#relayURL); | ||
| 396 | ws.binaryType = 'arraybuffer'; | ||
| 397 | this.#ws = ws; | ||
| 398 | |||
| 399 | ws.onopen = () => { | ||
| 400 | // Wait for CHALLENGE from relay; do not resolve yet. | ||
| 401 | }; | ||
| 402 | |||
| 403 | ws.onclose = (e) => { | ||
| 404 | this.#authed = false; | ||
| 405 | settle(false, new Error(`axon: ws closed (${e.code})`)); | ||
| 406 | this.#emit('disconnected', e.code, e.reason); | ||
| 407 | }; | ||
| 408 | |||
| 409 | ws.onerror = (e) => { | ||
| 410 | settle(false, new Error('axon: ws error')); | ||
| 411 | this.#emit('error', 0, 'websocket error'); | ||
| 412 | }; | ||
| 413 | |||
| 414 | ws.onmessage = (e) => { | ||
| 415 | let msg; | ||
| 416 | try { | ||
| 417 | msg = decode(new Uint8Array(e.data)); | ||
| 418 | } catch (err) { | ||
| 419 | this.#emit('error', 0, 'decode error: ' + err.message); | ||
| 420 | return; | ||
| 421 | } | ||
| 422 | |||
| 423 | if (!Array.isArray(msg) || msg.length < 2) { | ||
| 424 | this.#emit('error', 0, 'malformed message'); | ||
| 425 | return; | ||
| 426 | } | ||
| 427 | |||
| 428 | const [type, payload] = msg; | ||
| 429 | |||
| 430 | if (!this.#authed) { | ||
| 431 | // Pre-auth: only handle CHALLENGE and OK. | ||
| 432 | if (type === MSG.CHALLENGE) { | ||
| 433 | const nonce = payload.nonce; | ||
| 434 | const sig = signChallenge(this.#kp, nonce, this.#relayURL); | ||
| 435 | this.#send(MSG.AUTH, { pubkey: this.#kp.pubkey, sig }); | ||
| 436 | } else if (type === MSG.OK) { | ||
| 437 | this.#authed = true; | ||
| 438 | settle(true, undefined); | ||
| 439 | this.#emit('connected'); | ||
| 440 | } else if (type === MSG.ERROR) { | ||
| 441 | settle(false, new Error(`axon: auth error ${payload.code}: ${payload.message}`)); | ||
| 442 | this.#emit('error', payload.code, payload.message); | ||
| 443 | } | ||
| 444 | return; | ||
| 445 | } | ||
| 446 | |||
| 447 | // Post-auth: handle all message types. | ||
| 448 | switch (type) { | ||
| 449 | case MSG.EVENT: { | ||
| 450 | const ev = payload.event; | ||
| 451 | this.#emit('event', payload.sub_id, ev); | ||
| 452 | break; | ||
| 453 | } | ||
| 454 | case MSG.EOSE: | ||
| 455 | this.#emit('eose', payload.sub_id); | ||
| 456 | break; | ||
| 457 | case MSG.OK: | ||
| 458 | this.#emit('ok', payload.message); | ||
| 459 | break; | ||
| 460 | case MSG.ERROR: | ||
| 461 | this.#emit('error', payload.code, payload.message); | ||
| 462 | break; | ||
| 463 | default: | ||
| 464 | // Unknown message type — ignore. | ||
| 465 | break; | ||
| 466 | } | ||
| 467 | }; | ||
| 468 | }); | ||
| 469 | } | ||
| 470 | |||
| 471 | disconnect() { | ||
| 472 | if (this.#ws) { | ||
| 473 | this.#ws.close(1000); | ||
| 474 | this.#ws = null; | ||
| 475 | } | ||
| 476 | this.#authed = false; | ||
| 477 | } | ||
| 478 | |||
| 479 | // ------------------------------------------------------------------------- | ||
| 480 | // Protocol operations | ||
| 481 | // ------------------------------------------------------------------------- | ||
| 482 | |||
| 483 | /** | ||
| 484 | * publish(kind, content, tags=[]) | ||
| 485 | * | ||
| 486 | * Signs and publishes an event. content may be string or Uint8Array. | ||
| 487 | */ | ||
| 488 | publish(kind, content, tags = []) { | ||
| 489 | const event = signEvent(this.#kp, kind, content, tags); | ||
| 490 | this.#send(MSG.PUBLISH, { event }); | ||
| 491 | } | ||
| 492 | |||
| 493 | /** | ||
| 494 | * subscribe(subID, filter={}) | ||
| 495 | * | ||
| 496 | * Sends a SUBSCRIBE message. All filter fields are sent explicitly. | ||
| 497 | */ | ||
| 498 | subscribe(subID, filter = {}) { | ||
| 499 | this.#send(MSG.SUBSCRIBE, { | ||
| 500 | sub_id: subID, | ||
| 501 | filter: { | ||
| 502 | ids: filter.ids ?? [], | ||
| 503 | authors: filter.authors ?? [], | ||
| 504 | kinds: filter.kinds ?? [], | ||
| 505 | since: filter.since ?? 0, | ||
| 506 | until: filter.until ?? 0, | ||
| 507 | limit: filter.limit ?? 0, | ||
| 508 | tags: filter.tags ?? [], | ||
| 509 | }, | ||
| 510 | }); | ||
| 511 | } | ||
| 512 | |||
| 513 | /** | ||
| 514 | * unsubscribe(subID) | ||
| 515 | */ | ||
| 516 | unsubscribe(subID) { | ||
| 517 | this.#send(MSG.UNSUBSCRIBE, { sub_id: subID }); | ||
| 518 | } | ||
| 519 | } | ||
