@abndnce/pulsar-client 0.0.10 → 0.0.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.mts CHANGED
@@ -22,21 +22,6 @@ interface PulsarClientConnection {
22
22
  declare function connectDirect(host: string, port: number): Promise<PulsarClientConnection>;
23
23
  //#endregion
24
24
  //#region lib/connection/nostr.d.ts
25
- /**
26
- * Establish a Pulsar tunnel via Nostr relay signaling.
27
- *
28
- * 1. Connects to a Nostr relay (tries nostr.data.haus first,
29
- * then kotukonostr.onrender.com)
30
- * 2. Looks up the tunnel's discovery event (Kind 38000, d="pulsar-tunnel").
31
- * If `tunnelCode` is given (e.g. "a3f2"), filters to the tunnel
32
- * whose pubkey begins with those 4 hex characters.
33
- * 3. Generates an ephemeral client keypair
34
- * 4. Creates a WebRTC offer
35
- * 5. Encrypts the offer and sends via a Kind 28000 ephemeral event
36
- * 6. Waits for the encrypted answer
37
- * 7. Sets the answer as remote description
38
- * 8. Returns the connected PulsarClientConnection
39
- */
40
25
  declare function connectNostr(tunnelCode?: string): Promise<PulsarClientConnection>;
41
26
  //#endregion
42
27
  //#region lib/socket-channel.d.ts
package/dist/index.mjs CHANGED
@@ -1,12 +1,164 @@
1
1
  import { schnorr, secp256k1 } from "@noble/curves/secp256k1";
2
2
  //#region ../core/constants.ts
3
+ const SOCKET_PREFIX = "socket/";
4
+ const KEEPALIVE_LABEL = "keepalive";
3
5
  const PULSAR_UFRAG = "pulsar";
4
6
  const PULSAR_PWD = "pulsarpulsarpulsarpuls";
5
7
  const PULSAR_FINGERPRINT = "F1:85:10:8F:36:FF:58:D8:D0:4B:52:D7:ED:DC:5C:28:AE:7D:DB:54:0E:2A:DD:C7:C3:94:EA:A1:27:D0:4E:78";
6
- /** Label prefix for socket tunnel data channels. */
7
- const SOCKET_PREFIX = "socket/";
8
- /** Label for the mandatory keepalive data channel. */
9
- const KEEPALIVE_LABEL = "keepalive";
8
+ //#endregion
9
+ //#region ../core/webrtc.ts
10
+ const DEFAULT_ICE_SERVERS = [{ urls: "stun:stun.l.google.com:19302" }, { urls: "stun:global.stun.twilio.com:3478" }];
11
+ const ICE_GATHER_TIMEOUT_MS = 3e3;
12
+ const CONNECTION_TIMEOUT_MS = 3e4;
13
+ async function waitForIceGathering(pc, timeoutMs = ICE_GATHER_TIMEOUT_MS) {
14
+ if (pc.iceGatheringState === "complete") return;
15
+ if (pc.connectionState === "failed" || pc.connectionState === "closed") throw new Error(`Peer connection ${pc.connectionState}`);
16
+ await new Promise((resolve, reject) => {
17
+ const cleanup = () => {
18
+ clearTimeout(timeout);
19
+ pc.removeEventListener("icegatheringstatechange", onStateChange);
20
+ pc.removeEventListener("icecandidate", onCandidate);
21
+ pc.removeEventListener("connectionstatechange", onConnectionStateChange);
22
+ };
23
+ const timeout = setTimeout(() => {
24
+ cleanup();
25
+ resolve();
26
+ }, timeoutMs);
27
+ const onStateChange = () => {
28
+ if (pc.iceGatheringState === "complete") {
29
+ cleanup();
30
+ resolve();
31
+ }
32
+ };
33
+ const onCandidate = (event) => {
34
+ if (!("candidate" in event) || !event.candidate) {
35
+ cleanup();
36
+ resolve();
37
+ }
38
+ };
39
+ const onConnectionStateChange = () => {
40
+ if (pc.connectionState === "failed" || pc.connectionState === "closed") {
41
+ cleanup();
42
+ reject(/* @__PURE__ */ new Error(`Peer connection ${pc.connectionState}`));
43
+ }
44
+ };
45
+ pc.addEventListener("icegatheringstatechange", onStateChange);
46
+ pc.addEventListener("icecandidate", onCandidate);
47
+ pc.addEventListener("connectionstatechange", onConnectionStateChange);
48
+ });
49
+ }
50
+ async function waitForPeerConnectionConnected(pc, timeoutMs = CONNECTION_TIMEOUT_MS) {
51
+ if (pc.connectionState === "connected") return;
52
+ if (pc.connectionState === "failed" || pc.connectionState === "disconnected" || pc.connectionState === "closed") throw new Error(`Connection failed: ${pc.connectionState}`);
53
+ await new Promise((resolve, reject) => {
54
+ const cleanup = () => {
55
+ clearTimeout(timeout);
56
+ pc.removeEventListener("connectionstatechange", onStateChange);
57
+ };
58
+ const timeout = setTimeout(() => {
59
+ cleanup();
60
+ reject(/* @__PURE__ */ new Error(`WebRTC connection timed out after ${timeoutMs}ms`));
61
+ }, timeoutMs);
62
+ const onStateChange = () => {
63
+ if (pc.connectionState === "connected") {
64
+ cleanup();
65
+ resolve();
66
+ } else if (pc.connectionState === "failed" || pc.connectionState === "disconnected" || pc.connectionState === "closed") {
67
+ cleanup();
68
+ reject(/* @__PURE__ */ new Error(`Connection failed: ${pc.connectionState}`));
69
+ }
70
+ };
71
+ pc.addEventListener("connectionstatechange", onStateChange);
72
+ });
73
+ }
74
+ //#endregion
75
+ //#region ../core/socket.ts
76
+ function socketChannelLabel(hostname, port) {
77
+ return `${SOCKET_PREFIX}${formatSocketDestination(hostname, port)}`;
78
+ }
79
+ function formatSocketDestination(hostname, port) {
80
+ return `${hostname.includes(":") && !hostname.startsWith("[") ? `[${hostname}]` : hostname}:${port}`;
81
+ }
82
+ //#endregion
83
+ //#region lib/socket-channel.ts
84
+ /**
85
+ * Shared utilities for opening Pulsar socket tunnel data channels.
86
+ *
87
+ * These work with any RTCPeerConnection, regardless of how it was
88
+ * established (direct, nostr, etc.).
89
+ */
90
+ /**
91
+ * Wait for an RTCDataChannel to enter the "open" state.
92
+ *
93
+ * Also monitors the RTCPeerConnection for failure/closure, and
94
+ * accepts an optional AbortSignal for cancellation.
95
+ *
96
+ * Rejects if the channel closes, the peer connection fails, or
97
+ * the timeout elapses before the channel opens.
98
+ */
99
+ function waitForDataChannelOpen(channel, pc, timeoutMs = 1e4, signal) {
100
+ return new Promise((resolve, reject) => {
101
+ if (signal?.aborted) {
102
+ reject(new DOMException("The operation was aborted.", "AbortError"));
103
+ return;
104
+ }
105
+ if (channel.readyState === "open") {
106
+ resolve();
107
+ return;
108
+ }
109
+ const timeout = setTimeout(() => {
110
+ cleanup();
111
+ reject(/* @__PURE__ */ new Error(`DataChannel open timed out after ${timeoutMs}ms`));
112
+ }, timeoutMs);
113
+ const cleanup = () => {
114
+ clearTimeout(timeout);
115
+ channel.removeEventListener("open", onOpen);
116
+ channel.removeEventListener("close", onClose);
117
+ channel.removeEventListener("error", onError);
118
+ pc.removeEventListener("connectionstatechange", onStateChange);
119
+ signal?.removeEventListener("abort", onAbort);
120
+ };
121
+ const onOpen = () => {
122
+ cleanup();
123
+ resolve();
124
+ };
125
+ const onClose = () => {
126
+ cleanup();
127
+ reject(/* @__PURE__ */ new Error("DataChannel closed before opening"));
128
+ };
129
+ const onError = () => {
130
+ cleanup();
131
+ reject(/* @__PURE__ */ new Error("DataChannel error before opening"));
132
+ };
133
+ const onStateChange = () => {
134
+ if (pc.connectionState === "failed" || pc.connectionState === "closed") {
135
+ cleanup();
136
+ reject(/* @__PURE__ */ new Error(`Peer connection ${pc.connectionState}`));
137
+ }
138
+ };
139
+ const onAbort = () => {
140
+ cleanup();
141
+ reject(new DOMException("The operation was aborted.", "AbortError"));
142
+ };
143
+ channel.addEventListener("open", onOpen, { once: true });
144
+ channel.addEventListener("close", onClose, { once: true });
145
+ channel.addEventListener("error", onError, { once: true });
146
+ pc.addEventListener("connectionstatechange", onStateChange);
147
+ signal?.addEventListener("abort", onAbort, { once: true });
148
+ });
149
+ }
150
+ /**
151
+ * Create a data channel on the given `pc` with the Pulsar socket label
152
+ * convention (`socket/<hostname>:<port>`) and wait for it to open.
153
+ *
154
+ * This is the low-level primitive used by `connect()` and
155
+ * `libcurlTransport()`.
156
+ */
157
+ function openSocketChannel(pc, hostname, port, timeoutMs, signal) {
158
+ const label = socketChannelLabel(hostname, port);
159
+ const channel = pc.createDataChannel(label, { ordered: true });
160
+ return waitForDataChannelOpen(channel, pc, timeoutMs, signal).then(() => channel);
161
+ }
10
162
  //#endregion
11
163
  //#region lib/connection/direct.ts
12
164
  /**
@@ -48,20 +200,8 @@ async function connectDirect(host, port) {
48
200
  sdpMid: "0",
49
201
  sdpMLineIndex: 0
50
202
  });
51
- await new Promise((resolve, reject) => {
52
- const timeout = setTimeout(() => {
53
- reject(/* @__PURE__ */ new Error("Connection timed out after 30s"));
54
- }, 3e4);
55
- pc.onconnectionstatechange = () => {
56
- if (pc.connectionState === "connected") {
57
- clearTimeout(timeout);
58
- resolve();
59
- } else if (pc.connectionState === "failed" || pc.connectionState === "disconnected") {
60
- clearTimeout(timeout);
61
- reject(/* @__PURE__ */ new Error(`Connection failed: ${pc.connectionState}`));
62
- }
63
- };
64
- });
203
+ await waitForPeerConnectionConnected(pc);
204
+ await waitForDataChannelOpen(keepalive, pc);
65
205
  return {
66
206
  keepalive,
67
207
  pc,
@@ -74,115 +214,189 @@ async function connectDirect(host, port) {
74
214
  //#endregion
75
215
  //#region ../core/nostr.ts
76
216
  const NOSTR_RELAYS = ["wss://nostr.data.haus", "wss://kotukonostr.onrender.com"];
77
- /** Ephemeral event kind used for encrypted WebRTC signaling. */
78
- const SIGNALING_KIND = 28e3;
79
- /** Parameterized replaceable event kind used for server discovery. */
80
- const DISCOVERY_KIND = 38e3;
81
- /** The `d` tag identifier for the Pulsar tunnel discovery event. */
217
+ const SIGNALING_KIND = 24393;
218
+ const DISCOVERY_KIND = 34393;
82
219
  const D_TAG_ID = "pulsar-tunnel";
83
- //#endregion
84
- //#region lib/connection/nostr.ts
220
+ const textEncoder = new TextEncoder();
221
+ const textDecoder = new TextDecoder();
222
+ function nowSeconds() {
223
+ return Math.floor(Date.now() / 1e3);
224
+ }
225
+ function bytesToHex(bytes) {
226
+ return Array.from(bytes).map((byte) => byte.toString(16).padStart(2, "0")).join("");
227
+ }
85
228
  function hexToBytes(hex) {
229
+ if (hex.length % 2 !== 0 || /[^0-9a-f]/i.test(hex)) throw new Error("Invalid hex string");
86
230
  const bytes = new Uint8Array(hex.length / 2);
87
- for (let i = 0; i < hex.length; i += 2) bytes[i / 2] = parseInt(hex.slice(i, i + 2), 16);
231
+ for (let i = 0; i < hex.length; i += 2) bytes[i / 2] = Number.parseInt(hex.slice(i, i + 2), 16);
88
232
  return bytes;
89
233
  }
90
- function bytesToHex(bytes) {
91
- return Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join("");
92
- }
93
- function generateKeypair() {
234
+ function generateNostrKeypair() {
94
235
  const seckey = secp256k1.utils.randomPrivateKey();
95
- const pubkey = secp256k1.getPublicKey(seckey, true).slice(1);
96
236
  return {
97
237
  seckey: bytesToHex(seckey),
98
- pubkey: bytesToHex(pubkey)
238
+ pubkey: bytesToHex(schnorr.getPublicKey(seckey))
99
239
  };
100
240
  }
101
- /**
102
- * Derive a shared ECDH secret between a private key and a peer's
103
- * 32-byte x-only public key (Nostr format).
104
- *
105
- * Schnorr public keys always have even y, so we reconstruct the
106
- * full point via ProjectivePoint and do scalar multiplication.
107
- */
108
- function getSharedXOnly(seckey, pubkeyXOnly) {
109
- const compressed = new Uint8Array(33);
110
- compressed[0] = 2;
111
- compressed.set(pubkeyXOnly, 1);
112
- const pubPoint = secp256k1.ProjectivePoint.fromHex(compressed);
113
- const scalar = bytesToBigInt(seckey);
114
- const shared = pubPoint.multiply(scalar);
115
- return new Uint8Array(shared.toRawBytes(false).slice(1, 33));
241
+ function hasTag(event, name, value) {
242
+ return event.tags.some((tag) => tag[0] === name && tag[1] === value);
116
243
  }
117
- function bytesToBigInt(bytes) {
118
- let hex = "";
119
- for (const b of bytes) hex += b.toString(16).padStart(2, "0");
120
- return BigInt("0x" + hex);
244
+ function isAddressedTo(event, pubkey) {
245
+ return hasTag(event, "p", pubkey);
121
246
  }
122
- async function getConversationKey(seckeyBytes, pubkeyBytes) {
123
- const sharedSecret = getSharedXOnly(seckeyBytes, pubkeyBytes);
124
- const hkdfKey = await crypto.subtle.importKey("raw", sharedSecret, "HKDF", false, ["deriveBits", "deriveKey"]);
125
- return crypto.subtle.deriveKey({
126
- name: "HKDF",
127
- hash: "SHA-256",
128
- salt: new Uint8Array(0),
129
- info: new TextEncoder().encode("nip44-v2")
130
- }, hkdfKey, {
131
- name: "AES-GCM",
132
- length: 256
133
- }, false, ["encrypt", "decrypt"]);
247
+ function serializeEvent(event) {
248
+ return JSON.stringify([
249
+ 0,
250
+ event.pubkey,
251
+ event.created_at,
252
+ event.kind,
253
+ event.tags,
254
+ event.content
255
+ ]);
256
+ }
257
+ async function computeEventId(event) {
258
+ const hash = await crypto.subtle.digest("SHA-256", textEncoder.encode(serializeEvent(event)));
259
+ return bytesToHex(new Uint8Array(hash));
260
+ }
261
+ async function signNostrEvent(event, seckeyHex) {
262
+ const id = await computeEventId(event);
263
+ const sig = schnorr.sign(hexToBytes(id), hexToBytes(seckeyHex));
264
+ return {
265
+ ...event,
266
+ id,
267
+ sig: bytesToHex(sig)
268
+ };
269
+ }
270
+ async function verifyNostrEvent(event) {
271
+ try {
272
+ if (event.id !== await computeEventId(event)) return false;
273
+ return schnorr.verify(hexToBytes(event.sig), hexToBytes(event.id), hexToBytes(event.pubkey));
274
+ } catch {
275
+ return false;
276
+ }
277
+ }
278
+ function makeSignalEvent(pubkey, peerPubkey, content) {
279
+ return {
280
+ pubkey,
281
+ created_at: nowSeconds(),
282
+ kind: SIGNALING_KIND,
283
+ tags: [["p", peerPubkey]],
284
+ content
285
+ };
286
+ }
287
+ function makeSignalingFilter(pubkey) {
288
+ return {
289
+ kinds: [SIGNALING_KIND],
290
+ "#p": [pubkey],
291
+ since: nowSeconds() - 5
292
+ };
293
+ }
294
+ function makeDiscoveryFilter() {
295
+ return {
296
+ kinds: [DISCOVERY_KIND],
297
+ "#d": [D_TAG_ID],
298
+ limit: 20
299
+ };
300
+ }
301
+ function sendNostrEvent(ws, event) {
302
+ const msg = ["EVENT", event];
303
+ ws.send(JSON.stringify(msg));
304
+ }
305
+ function sendNostrReq(ws, subId, filter) {
306
+ const msg = [
307
+ "REQ",
308
+ subId,
309
+ filter
310
+ ];
311
+ ws.send(JSON.stringify(msg));
134
312
  }
135
- async function nip44Encrypt(plaintext, seckeyHex, pubkeyHex) {
136
- const convKey = await getConversationKey(hexToBytes(seckeyHex), hexToBytes(pubkeyHex));
313
+ function closeNostrReq(ws, subId) {
314
+ const msg = ["CLOSE", subId];
315
+ try {
316
+ ws.send(JSON.stringify(msg));
317
+ } catch {}
318
+ }
319
+ function parseNostrMessage(data) {
320
+ if (typeof data !== "string") return null;
321
+ try {
322
+ const msg = JSON.parse(data);
323
+ if (!Array.isArray(msg) || typeof msg[0] !== "string") return null;
324
+ return msg;
325
+ } catch {
326
+ return null;
327
+ }
328
+ }
329
+ async function encryptSignal(plaintext, seckeyHex, pubkeyHex) {
330
+ const key = await getSignalKey(hexToBytes(seckeyHex), hexToBytes(pubkeyHex));
137
331
  const nonce = crypto.getRandomValues(new Uint8Array(12));
138
- const encoded = new TextEncoder().encode(plaintext);
139
332
  const encrypted = await crypto.subtle.encrypt({
140
333
  name: "AES-GCM",
141
334
  iv: nonce,
142
335
  tagLength: 128
143
- }, convKey, encoded);
144
- const result = new Uint8Array(13 + encrypted.byteLength);
145
- result[0] = 2;
146
- result.set(nonce, 1);
147
- result.set(new Uint8Array(encrypted), 13);
148
- return btoa(String.fromCharCode(...result));
149
- }
150
- async function nip44Decrypt(ciphertextB64, seckeyHex, pubkeyHex) {
151
- const convKey = await getConversationKey(hexToBytes(seckeyHex), hexToBytes(pubkeyHex));
152
- const raw = Uint8Array.from(atob(ciphertextB64), (c) => c.charCodeAt(0));
336
+ }, key, toArrayBuffer(textEncoder.encode(plaintext)));
337
+ const bytes = new Uint8Array(1 + nonce.byteLength + encrypted.byteLength);
338
+ bytes[0] = 1;
339
+ bytes.set(nonce, 1);
340
+ bytes.set(new Uint8Array(encrypted), 1 + nonce.byteLength);
341
+ return bytesToBase64(bytes);
342
+ }
343
+ async function decryptSignal(ciphertextB64, seckeyHex, pubkeyHex) {
344
+ const raw = base64ToBytes(ciphertextB64);
153
345
  if (raw.length < 13) throw new Error("Ciphertext too short");
346
+ if (raw[0] !== 1) throw new Error(`Unsupported signal encryption version ${raw[0]}`);
347
+ const key = await getSignalKey(hexToBytes(seckeyHex), hexToBytes(pubkeyHex));
154
348
  const nonce = raw.slice(1, 13);
155
349
  const ciphertext = raw.slice(13);
156
350
  const decrypted = await crypto.subtle.decrypt({
157
351
  name: "AES-GCM",
158
352
  iv: nonce,
159
353
  tagLength: 128
160
- }, convKey, ciphertext);
161
- return new TextDecoder().decode(decrypted);
354
+ }, key, toArrayBuffer(ciphertext));
355
+ return textDecoder.decode(decrypted);
162
356
  }
163
- function serializeEvent(ev) {
164
- return JSON.stringify([
165
- 0,
166
- ev.pubkey,
167
- ev.created_at,
168
- ev.kind,
169
- ev.tags,
170
- ev.content
171
- ]);
357
+ function getSharedXOnly(seckey, pubkeyXOnly) {
358
+ const compressed = new Uint8Array(33);
359
+ compressed[0] = 2;
360
+ compressed.set(pubkeyXOnly, 1);
361
+ const sharedPoint = secp256k1.ProjectivePoint.fromHex(compressed).multiply(bytesToBigInt(seckey));
362
+ return new Uint8Array(sharedPoint.toRawBytes(false).slice(1, 33));
172
363
  }
173
- async function computeEventId(ev) {
174
- const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(serializeEvent(ev)));
175
- return bytesToHex(new Uint8Array(hash));
364
+ async function getSignalKey(seckeyBytes, pubkeyBytes) {
365
+ const hkdfKey = await crypto.subtle.importKey("raw", toArrayBuffer(getSharedXOnly(seckeyBytes, pubkeyBytes)), "HKDF", false, ["deriveKey"]);
366
+ return crypto.subtle.deriveKey({
367
+ name: "HKDF",
368
+ hash: "SHA-256",
369
+ salt: /* @__PURE__ */ new ArrayBuffer(0),
370
+ info: toArrayBuffer(textEncoder.encode("pulsar-nostr-signal-v1"))
371
+ }, hkdfKey, {
372
+ name: "AES-GCM",
373
+ length: 256
374
+ }, false, ["encrypt", "decrypt"]);
176
375
  }
177
- async function signEvent(ev, seckey) {
178
- const id = await computeEventId(ev);
179
- const sig = schnorr.sign(hexToBytes(id), seckey);
180
- return {
181
- ...ev,
182
- id,
183
- sig: bytesToHex(sig)
184
- };
376
+ function bytesToBigInt(bytes) {
377
+ let hex = "";
378
+ for (const byte of bytes) hex += byte.toString(16).padStart(2, "0");
379
+ return BigInt(`0x${hex}`);
380
+ }
381
+ function toArrayBuffer(bytes) {
382
+ const buffer = new ArrayBuffer(bytes.byteLength);
383
+ new Uint8Array(buffer).set(bytes);
384
+ return buffer;
385
+ }
386
+ function bytesToBase64(bytes) {
387
+ let binary = "";
388
+ const chunkSize = 32768;
389
+ for (let i = 0; i < bytes.length; i += chunkSize) binary += String.fromCharCode(...bytes.subarray(i, i + chunkSize));
390
+ return btoa(binary);
185
391
  }
392
+ function base64ToBytes(base64) {
393
+ const binary = atob(base64);
394
+ const bytes = new Uint8Array(binary.length);
395
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
396
+ return bytes;
397
+ }
398
+ //#endregion
399
+ //#region lib/connection/nostr.ts
186
400
  function connectRelay(url, timeoutMs = 1e4) {
187
401
  return new Promise((resolve, reject) => {
188
402
  const ws = new WebSocket(url);
@@ -200,69 +414,101 @@ function connectRelay(url, timeoutMs = 1e4) {
200
414
  });
201
415
  });
202
416
  }
203
- /**
204
- * Try each relay in order until one connects successfully.
205
- */
206
- async function connectToAnyRelay() {
417
+ async function connectToServerRelay(pubkeyPrefix) {
207
418
  const errors = [];
208
- for (const relayUrl of NOSTR_RELAYS) try {
209
- const ws = await connectRelay(relayUrl);
210
- console.log(`[nostr] Connected to ${relayUrl}`);
211
- return ws;
212
- } catch (err) {
213
- const msg = err instanceof Error ? err.message : String(err);
214
- errors.push(msg);
215
- console.warn(`[nostr] ${relayUrl}: ${msg}`);
419
+ for (const relayUrl of NOSTR_RELAYS) {
420
+ let ws;
421
+ try {
422
+ ws = await connectRelay(relayUrl);
423
+ console.log(`[nostr] Connected to ${relayUrl}`);
424
+ const serverPubkey = await findServer(ws, pubkeyPrefix);
425
+ return {
426
+ ws,
427
+ serverPubkey
428
+ };
429
+ } catch (err) {
430
+ const message = err instanceof Error ? err.message : String(err);
431
+ errors.push(`${relayUrl}: ${message}`);
432
+ console.warn(`[nostr] ${relayUrl}: ${message}`);
433
+ try {
434
+ ws?.close();
435
+ } catch {}
436
+ }
216
437
  }
217
- throw new Error(`Failed to connect to any Nostr relay: ${errors.join("; ")}`);
438
+ throw new Error(`Failed to find a Pulsar server: ${errors.join("; ")}`);
218
439
  }
219
- /**
220
- * Wait for a single event matching a filter, with timeout.
221
- */
222
- function waitForEvent(ws, subId, filter, timeoutMs = 15e3) {
440
+ function makeSubId(prefix) {
441
+ return `${prefix}-${typeof crypto.randomUUID === "function" ? crypto.randomUUID() : Math.random().toString(16).slice(2)}`;
442
+ }
443
+ async function findServer(ws, pubkeyPrefix, timeoutMs = 15e3) {
444
+ const subId = makeSubId("pulsar-discover");
223
445
  return new Promise((resolve, reject) => {
446
+ let done = false;
447
+ const finish = (fn) => {
448
+ if (done) return;
449
+ done = true;
450
+ clearTimeout(timeout);
451
+ ws.removeEventListener("message", onMessage);
452
+ closeNostrReq(ws, subId);
453
+ fn();
454
+ };
224
455
  const timeout = setTimeout(() => {
225
- ws.send(JSON.stringify(["CLOSE", subId]));
226
- reject(/* @__PURE__ */ new Error("Timed out waiting for Nostr event"));
456
+ finish(() => {
457
+ reject(/* @__PURE__ */ new Error(pubkeyPrefix ? `No Pulsar server with tunnel code "pulsar${pubkeyPrefix}" found` : "No Pulsar server found on Nostr relay"));
458
+ });
227
459
  }, timeoutMs);
228
- ws.send(JSON.stringify([
229
- "REQ",
230
- subId,
231
- filter
232
- ]));
233
460
  const onMessage = (event) => {
234
- let msg;
235
- try {
236
- msg = JSON.parse(event.data);
237
- } catch {
238
- return;
239
- }
240
- if (msg[0] === "EVENT" && msg[1] === subId) {
241
- clearTimeout(timeout);
242
- ws.removeEventListener("message", onMessage);
243
- ws.send(JSON.stringify(["CLOSE", subId]));
244
- resolve(msg[2]);
245
- }
461
+ const msg = parseNostrMessage(event.data);
462
+ if (!msg || msg[0] !== "EVENT" || msg[1] !== subId) return;
463
+ (async () => {
464
+ const relayEvent = msg[2];
465
+ if (!await verifyNostrEvent(relayEvent)) return;
466
+ if (pubkeyPrefix && !relayEvent.pubkey.startsWith(pubkeyPrefix)) return;
467
+ finish(() => resolve(relayEvent.pubkey));
468
+ })().catch(() => {});
246
469
  };
247
470
  ws.addEventListener("message", onMessage);
471
+ sendNostrReq(ws, subId, makeDiscoveryFilter());
472
+ });
473
+ }
474
+ function waitForSignalEvent(ws, recipientPubkey, expectedSenderPubkey, timeoutMs = 3e4) {
475
+ const subId = makeSubId("pulsar-answer");
476
+ return new Promise((resolve, reject) => {
477
+ let done = false;
478
+ const finish = (fn) => {
479
+ if (done) return;
480
+ done = true;
481
+ clearTimeout(timeout);
482
+ ws.removeEventListener("message", onMessage);
483
+ closeNostrReq(ws, subId);
484
+ fn();
485
+ };
486
+ const timeout = setTimeout(() => {
487
+ finish(() => reject(/* @__PURE__ */ new Error("Timed out waiting for Nostr answer")));
488
+ }, timeoutMs);
489
+ const onMessage = (event) => {
490
+ const msg = parseNostrMessage(event.data);
491
+ if (!msg || msg[0] !== "EVENT" || msg[1] !== subId) return;
492
+ (async () => {
493
+ const relayEvent = msg[2];
494
+ if (relayEvent.pubkey !== expectedSenderPubkey) return;
495
+ if (!isAddressedTo(relayEvent, recipientPubkey)) return;
496
+ if (!await verifyNostrEvent(relayEvent)) return;
497
+ finish(() => resolve(relayEvent));
498
+ })().catch(() => {});
499
+ };
500
+ ws.addEventListener("message", onMessage);
501
+ sendNostrReq(ws, subId, makeSignalingFilter(recipientPubkey));
248
502
  });
249
503
  }
250
- const defaultIceServers = [{ urls: "stun:stun.l.google.com:19302" }, { urls: "stun:global.stun.twilio.com:3478" }];
251
- /**
252
- * Represents a Pulsar tunnel connection established via Nostr signaling.
253
- */
254
504
  var NostrClientConnection = class {
255
505
  keepalive;
256
506
  pc;
257
- _ws;
258
- _seckey;
259
- _serverPubkey;
260
- constructor(keepalive, pc, _ws, _seckey, _serverPubkey) {
507
+ ws;
508
+ constructor(keepalive, pc, ws) {
261
509
  this.keepalive = keepalive;
262
510
  this.pc = pc;
263
- this._ws = _ws;
264
- this._seckey = _seckey;
265
- this._serverPubkey = _serverPubkey;
511
+ this.ws = ws;
266
512
  }
267
513
  async close() {
268
514
  try {
@@ -272,229 +518,58 @@ var NostrClientConnection = class {
272
518
  this.pc.close();
273
519
  } catch {}
274
520
  try {
275
- this._ws.close();
521
+ this.ws.close();
276
522
  } catch {}
277
523
  }
278
524
  };
279
- /**
280
- * Find a Pulsar server via discovery events.
281
- *
282
- * If `pubkeyPrefix` is given (4 hex chars), returns the first server
283
- * whose pubkey starts with that prefix. Otherwise returns any server.
284
- */
285
- async function findServer(ws, pubkeyPrefix, timeoutMs = 15e3) {
286
- return new Promise((resolve, reject) => {
287
- const timeout = setTimeout(() => {
288
- ws.send(JSON.stringify(["CLOSE", "pulsar-discover"]));
289
- reject(/* @__PURE__ */ new Error(pubkeyPrefix ? `No Pulsar server with tunnel code "pulsar${pubkeyPrefix}" found` : "No Pulsar server found on Nostr relay"));
290
- }, timeoutMs);
291
- const subId = "pulsar-discover";
292
- ws.send(JSON.stringify([
293
- "REQ",
294
- subId,
295
- {
296
- kinds: [DISCOVERY_KIND],
297
- "#d": [D_TAG_ID],
298
- limit: 0
299
- }
300
- ]));
301
- const onMessage = (event) => {
302
- let msg;
303
- try {
304
- msg = JSON.parse(event.data);
305
- } catch {
306
- return;
307
- }
308
- if (msg[0] === "EVENT" && msg[1] === subId) {
309
- const ev = msg[2];
310
- if (pubkeyPrefix) {
311
- if (ev.pubkey.startsWith(pubkeyPrefix)) {
312
- clearTimeout(timeout);
313
- ws.removeEventListener("message", onMessage);
314
- ws.send(JSON.stringify(["CLOSE", subId]));
315
- resolve(ev.pubkey);
316
- }
317
- } else {
318
- clearTimeout(timeout);
319
- ws.removeEventListener("message", onMessage);
320
- ws.send(JSON.stringify(["CLOSE", subId]));
321
- resolve(ev.pubkey);
322
- }
323
- }
324
- };
325
- ws.addEventListener("message", onMessage);
326
- });
327
- }
328
- /**
329
- * Establish a Pulsar tunnel via Nostr relay signaling.
330
- *
331
- * 1. Connects to a Nostr relay (tries nostr.data.haus first,
332
- * then kotukonostr.onrender.com)
333
- * 2. Looks up the tunnel's discovery event (Kind 38000, d="pulsar-tunnel").
334
- * If `tunnelCode` is given (e.g. "a3f2"), filters to the tunnel
335
- * whose pubkey begins with those 4 hex characters.
336
- * 3. Generates an ephemeral client keypair
337
- * 4. Creates a WebRTC offer
338
- * 5. Encrypts the offer and sends via a Kind 28000 ephemeral event
339
- * 6. Waits for the encrypted answer
340
- * 7. Sets the answer as remote description
341
- * 8. Returns the connected PulsarClientConnection
342
- */
343
525
  async function connectNostr(tunnelCode) {
344
- const ws = await connectToAnyRelay();
345
526
  const pubkeyPrefix = tunnelCode ? tunnelCode.replace(/^pulsar/, "").slice(0, 4) : void 0;
346
527
  console.log("[nostr] Looking up Pulsar tunnel" + (pubkeyPrefix ? ` (code ${tunnelCode})` : "") + "...");
347
- const serverPubkey = await findServer(ws, pubkeyPrefix);
348
- console.log(`[nostr] Found server: ${serverPubkey.slice(0, 16)}...`);
349
- const clientKeys = generateKeypair();
350
- console.log(`[nostr] Client pubkey: ${clientKeys.pubkey.slice(0, 16)}...`);
351
- const pc = new RTCPeerConnection({ iceServers: defaultIceServers });
352
- const keepalive = pc.createDataChannel(KEEPALIVE_LABEL, { ordered: true });
353
- keepalive.binaryType = "arraybuffer";
354
- const offer = await pc.createOffer();
355
- await pc.setLocalDescription(offer);
356
- await new Promise((resolve) => {
357
- const timeout = setTimeout(() => resolve(), 2e3);
358
- const onStateChange = () => {
359
- if (pc.iceGatheringState === "complete") {
360
- clearTimeout(timeout);
361
- resolve();
362
- }
528
+ const { ws, serverPubkey } = await connectToServerRelay(pubkeyPrefix);
529
+ let pc;
530
+ try {
531
+ console.log(`[nostr] Found server: ${serverPubkey.slice(0, 16)}...`);
532
+ const clientKeys = generateNostrKeypair();
533
+ console.log(`[nostr] Client pubkey: ${clientKeys.pubkey.slice(0, 16)}...`);
534
+ pc = new RTCPeerConnection({ iceServers: [...DEFAULT_ICE_SERVERS] });
535
+ const keepalive = pc.createDataChannel(KEEPALIVE_LABEL, { ordered: true });
536
+ keepalive.binaryType = "arraybuffer";
537
+ const keepaliveReady = waitForDataChannelOpen(keepalive, pc);
538
+ const offer = await pc.createOffer();
539
+ await pc.setLocalDescription(offer);
540
+ await waitForIceGathering(pc);
541
+ const localDesc = pc.localDescription;
542
+ if (!localDesc) throw new Error("Failed to create local offer");
543
+ const offerPayload = {
544
+ type: "offer",
545
+ sdp: localDesc.sdp
363
546
  };
364
- const onCandidate = (event) => {
365
- if (!event.candidate) {
366
- clearTimeout(timeout);
367
- resolve();
368
- }
369
- };
370
- pc.addEventListener("icegatheringstatechange", onStateChange);
371
- pc.addEventListener("icecandidate", onCandidate);
372
- });
373
- const localDesc = pc.localDescription;
374
- if (!localDesc) throw new Error("Failed to create local offer");
375
- const offerPayload = {
376
- type: "offer",
377
- sdp: localDesc.sdp
378
- };
379
- const encryptedOffer = await nip44Encrypt(JSON.stringify(offerPayload), clientKeys.seckey, serverPubkey);
380
- const offerEvent = await signEvent({
381
- pubkey: clientKeys.pubkey,
382
- created_at: Math.floor(Date.now() / 1e3),
383
- kind: SIGNALING_KIND,
384
- tags: [["p", serverPubkey]],
385
- content: encryptedOffer
386
- }, hexToBytes(clientKeys.seckey));
387
- const answerPromise = waitForEvent(ws, "pulsar-answer", {
388
- kinds: [SIGNALING_KIND],
389
- "#p": [clientKeys.pubkey],
390
- limit: 1
391
- }, 3e4);
392
- ws.send(JSON.stringify(["EVENT", offerEvent]));
393
- console.log("[nostr] Sent encrypted offer, waiting for answer...");
394
- const answerPlaintext = await nip44Decrypt((await answerPromise).content, clientKeys.seckey, serverPubkey);
395
- const answerPayload = JSON.parse(answerPlaintext);
396
- if (answerPayload.type !== "answer" || !answerPayload.sdp) throw new Error("Invalid answer from server");
397
- console.log("[nostr] Received answer, connecting WebRTC...");
398
- await pc.setRemoteDescription({
399
- type: "answer",
400
- sdp: answerPayload.sdp
401
- });
402
- await new Promise((resolve, reject) => {
403
- const timeout = setTimeout(() => {
404
- reject(/* @__PURE__ */ new Error("WebRTC connection timed out after 30s"));
405
- }, 3e4);
406
- pc.addEventListener("connectionstatechange", () => {
407
- if (pc.connectionState === "connected") {
408
- clearTimeout(timeout);
409
- resolve();
410
- } else if (pc.connectionState === "failed" || pc.connectionState === "disconnected") {
411
- clearTimeout(timeout);
412
- reject(/* @__PURE__ */ new Error(`Connection failed: ${pc.connectionState}`));
413
- }
547
+ const encryptedOffer = await encryptSignal(JSON.stringify(offerPayload), clientKeys.seckey, serverPubkey);
548
+ const offerEvent = await signNostrEvent(makeSignalEvent(clientKeys.pubkey, serverPubkey, encryptedOffer), clientKeys.seckey);
549
+ const answerPromise = waitForSignalEvent(ws, clientKeys.pubkey, serverPubkey);
550
+ sendNostrEvent(ws, offerEvent);
551
+ console.log("[nostr] Sent encrypted offer, waiting for answer...");
552
+ const answerPlaintext = await decryptSignal((await answerPromise).content, clientKeys.seckey, serverPubkey);
553
+ const answerPayload = JSON.parse(answerPlaintext);
554
+ if (answerPayload.type !== "answer" || !answerPayload.sdp) throw new Error("Invalid answer from server");
555
+ console.log("[nostr] Received answer, connecting WebRTC...");
556
+ await pc.setRemoteDescription({
557
+ type: "answer",
558
+ sdp: answerPayload.sdp
414
559
  });
415
- });
416
- console.log("[nostr] WebRTC connected!");
417
- return new NostrClientConnection(keepalive, pc, ws, clientKeys.seckey, serverPubkey);
418
- }
419
- //#endregion
420
- //#region lib/socket-channel.ts
421
- /**
422
- * Shared utilities for opening Pulsar socket tunnel data channels.
423
- *
424
- * These work with any RTCPeerConnection, regardless of how it was
425
- * established (direct, nostr, etc.).
426
- */
427
- /**
428
- * Wait for an RTCDataChannel to enter the "open" state.
429
- *
430
- * Also monitors the RTCPeerConnection for failure/closure, and
431
- * accepts an optional AbortSignal for cancellation.
432
- *
433
- * Rejects if the channel closes, the peer connection fails, or
434
- * the timeout elapses before the channel opens.
435
- */
436
- function waitForDataChannelOpen(channel, pc, timeoutMs = 1e4, signal) {
437
- return new Promise((resolve, reject) => {
438
- if (signal?.aborted) {
439
- reject(new DOMException("The operation was aborted.", "AbortError"));
440
- return;
441
- }
442
- if (channel.readyState === "open") {
443
- resolve();
444
- return;
445
- }
446
- const timeout = setTimeout(() => {
447
- cleanup();
448
- reject(/* @__PURE__ */ new Error(`DataChannel open timed out after ${timeoutMs}ms`));
449
- }, timeoutMs);
450
- const cleanup = () => {
451
- clearTimeout(timeout);
452
- channel.removeEventListener("open", onOpen);
453
- channel.removeEventListener("close", onClose);
454
- channel.removeEventListener("error", onError);
455
- pc.removeEventListener("connectionstatechange", onStateChange);
456
- signal?.removeEventListener("abort", onAbort);
457
- };
458
- const onOpen = () => {
459
- cleanup();
460
- resolve();
461
- };
462
- const onClose = () => {
463
- cleanup();
464
- reject(/* @__PURE__ */ new Error("DataChannel closed before opening"));
465
- };
466
- const onError = () => {
467
- cleanup();
468
- reject(/* @__PURE__ */ new Error("DataChannel error before opening"));
469
- };
470
- const onStateChange = () => {
471
- if (pc.connectionState === "failed" || pc.connectionState === "closed") {
472
- cleanup();
473
- reject(/* @__PURE__ */ new Error(`Peer connection ${pc.connectionState}`));
474
- }
475
- };
476
- const onAbort = () => {
477
- cleanup();
478
- reject(new DOMException("The operation was aborted.", "AbortError"));
479
- };
480
- channel.addEventListener("open", onOpen, { once: true });
481
- channel.addEventListener("close", onClose, { once: true });
482
- channel.addEventListener("error", onError, { once: true });
483
- pc.addEventListener("connectionstatechange", onStateChange);
484
- signal?.addEventListener("abort", onAbort, { once: true });
485
- });
486
- }
487
- /**
488
- * Create a data channel on the given `pc` with the Pulsar socket label
489
- * convention (`socket/<hostname>:<port>`) and wait for it to open.
490
- *
491
- * This is the low-level primitive used by `connect()` and
492
- * `libcurlTransport()`.
493
- */
494
- function openSocketChannel(pc, hostname, port, timeoutMs, signal) {
495
- const label = `${SOCKET_PREFIX}${hostname}:${port}`;
496
- const channel = pc.createDataChannel(label, { ordered: true });
497
- return waitForDataChannelOpen(channel, pc, timeoutMs, signal).then(() => channel);
560
+ await waitForPeerConnectionConnected(pc);
561
+ await keepaliveReady;
562
+ console.log("[nostr] WebRTC connected");
563
+ return new NostrClientConnection(keepalive, pc, ws);
564
+ } catch (err) {
565
+ try {
566
+ pc?.close();
567
+ } catch {}
568
+ try {
569
+ ws.close();
570
+ } catch {}
571
+ throw err;
572
+ }
498
573
  }
499
574
  //#endregion
500
575
  //#region lib/tunnel.ts
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@abndnce/pulsar-client",
3
3
  "type": "module",
4
- "version": "0.0.10",
4
+ "version": "0.0.12",
5
5
  "homepage": "https://github.com/abndnce/pulsar",
6
6
  "license": "MIT",
7
7
  "exports": {
@@ -11,17 +11,16 @@
11
11
  "files": [
12
12
  "dist"
13
13
  ],
14
+ "scripts": {
15
+ "build": "tsdown",
16
+ "dev": "tsdown --watch",
17
+ "typecheck": "tsc --noEmit",
18
+ "prepublishOnly": "pnpm run build"
19
+ },
14
20
  "dependencies": {
15
21
  "@noble/curves": "^1.8.1"
16
22
  },
17
23
  "devDependencies": {
18
- "tsdown": "^0.22.0",
19
- "typescript": "^6.0.3"
20
- },
21
- "scripts": {
22
- "build": "tsdown",
23
- "dev": "tsdown --watch",
24
- "test": "vitest",
25
- "typecheck": "tsc --noEmit"
24
+ "tsdown": "^0.22.0"
26
25
  }
27
- }
26
+ }
package/LICENSE DELETED
@@ -1,21 +0,0 @@
1
- MIT License
2
-
3
- Copyright (c) 2026
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.