@abndnce/pulsar-client 0.0.9 → 0.0.11

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/LICENSE ADDED
@@ -0,0 +1,21 @@
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.
package/dist/index.d.mts CHANGED
@@ -22,13 +22,7 @@ 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
- * Connect via a Nostr relay (placeholder).
27
- *
28
- * In the future, this will implement a Nostr-based signaling layer for
29
- * establishing peer connections through a Nostr relay.
30
- */
31
- declare function connectNostr(_relay: string, _pubkey: string): Promise<PulsarClientConnection>;
25
+ declare function connectNostr(tunnelCode?: string): Promise<PulsarClientConnection>;
32
26
  //#endregion
33
27
  //#region lib/socket-channel.d.ts
34
28
  /**
@@ -105,14 +99,13 @@ declare class DataChannelSocket extends EventTarget {
105
99
  * import { connectDirect, libcurlTransport } from '@abndnce/pulsar-client';
106
100
  * import { libcurl } from 'libcurl.js';
107
101
  *
108
- * const tunnel = await connectDirect('216.250.119.217', 42069);
102
+ * const tunnel = await connectDirect('1.2.3.4', 1234);
109
103
  * libcurl.transport = libcurlTransport(tunnel.pc);
110
104
  * libcurl.set_websocket('wss://pulsar-tunnel.local/');
111
105
  * ```
112
106
  *
113
107
  * libcurl.js calls the factory with URLs like:
114
108
  * `wss://pulsar-tunnel.local/example.com:80`
115
- * `wss://pulsar-tunnel.local/216.250.119.217:443`
116
109
  *
117
110
  * The factory parses `<hostname>:<port>` from the URL path, opens a
118
111
  * Pulsar socket data channel, and returns a WebSocket-like adapter.
package/dist/index.mjs CHANGED
@@ -1,85 +1,83 @@
1
+ import { schnorr, secp256k1 } from "@noble/curves/secp256k1";
1
2
  //#region ../core/constants.ts
3
+ const SOCKET_PREFIX = "socket/";
4
+ const KEEPALIVE_LABEL = "keepalive";
2
5
  const PULSAR_UFRAG = "pulsar";
3
6
  const PULSAR_PWD = "pulsarpulsarpulsarpuls";
4
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";
5
- /** Label prefix for socket tunnel data channels. */
6
- const SOCKET_PREFIX = "socket/";
7
- /** Label for the mandatory keepalive data channel. */
8
- const KEEPALIVE_LABEL = "keepalive";
9
8
  //#endregion
10
- //#region lib/connection/direct.ts
11
- /**
12
- * Connect to a remote Pulsar server in direct mode.
13
- *
14
- * Designed for browsers, using the native `RTCPeerConnection` API.
15
- *
16
- * @param host Server IP address
17
- * @param port Server UDP port
18
- * @returns A connected PulsarClientConnection with an open keepalive channel
19
- * and a `connect()` helper for opening socket tunnels.
20
- */
21
- async function connectDirect(host, port) {
22
- const pc = new RTCPeerConnection();
23
- const keepalive = pc.createDataChannel(KEEPALIVE_LABEL, { ordered: true });
24
- const offer = await pc.createOffer();
25
- await pc.setLocalDescription(offer);
26
- const remoteSdp = [
27
- "v=0",
28
- "o=- 111 222 IN IP4 0.0.0.0",
29
- "s=-",
30
- "t=0 0",
31
- `m=application ${port} UDP/DTLS/SCTP webrtc-datachannel`,
32
- `c=IN IP4 ${host}`,
33
- `a=ice-ufrag:${PULSAR_UFRAG}`,
34
- `a=ice-pwd:${PULSAR_PWD}`,
35
- `a=fingerprint:sha-256 ${PULSAR_FINGERPRINT}`,
36
- "a=setup:active",
37
- "a=mid:0",
38
- "a=sctp-port:5000",
39
- ""
40
- ].join("\r\n");
41
- await pc.setRemoteDescription({
42
- type: "answer",
43
- sdp: remoteSdp
44
- });
45
- await pc.addIceCandidate({
46
- candidate: `candidate:1 1 UDP 2130706431 ${host} ${port} typ host`,
47
- sdpMid: "0",
48
- sdpMLineIndex: 0
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);
49
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}`);
50
53
  await new Promise((resolve, reject) => {
54
+ const cleanup = () => {
55
+ clearTimeout(timeout);
56
+ pc.removeEventListener("connectionstatechange", onStateChange);
57
+ };
51
58
  const timeout = setTimeout(() => {
52
- reject(/* @__PURE__ */ new Error("Connection timed out after 30s"));
53
- }, 3e4);
54
- pc.onconnectionstatechange = () => {
59
+ cleanup();
60
+ reject(/* @__PURE__ */ new Error(`WebRTC connection timed out after ${timeoutMs}ms`));
61
+ }, timeoutMs);
62
+ const onStateChange = () => {
55
63
  if (pc.connectionState === "connected") {
56
- clearTimeout(timeout);
64
+ cleanup();
57
65
  resolve();
58
- } else if (pc.connectionState === "failed" || pc.connectionState === "disconnected") {
59
- clearTimeout(timeout);
66
+ } else if (pc.connectionState === "failed" || pc.connectionState === "disconnected" || pc.connectionState === "closed") {
67
+ cleanup();
60
68
  reject(/* @__PURE__ */ new Error(`Connection failed: ${pc.connectionState}`));
61
69
  }
62
70
  };
71
+ pc.addEventListener("connectionstatechange", onStateChange);
63
72
  });
64
- return {
65
- keepalive,
66
- pc,
67
- async close() {
68
- keepalive.close();
69
- pc.close();
70
- }
71
- };
72
73
  }
73
74
  //#endregion
74
- //#region lib/connection/nostr.ts
75
- /**
76
- * Connect via a Nostr relay (placeholder).
77
- *
78
- * In the future, this will implement a Nostr-based signaling layer for
79
- * establishing peer connections through a Nostr relay.
80
- */
81
- async function connectNostr(_relay, _pubkey) {
82
- throw new Error("Nostr mode not yet implemented");
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}`;
83
81
  }
84
82
  //#endregion
85
83
  //#region lib/socket-channel.ts
@@ -157,11 +155,423 @@ function waitForDataChannelOpen(channel, pc, timeoutMs = 1e4, signal) {
157
155
  * `libcurlTransport()`.
158
156
  */
159
157
  function openSocketChannel(pc, hostname, port, timeoutMs, signal) {
160
- const label = `${SOCKET_PREFIX}${hostname}:${port}`;
158
+ const label = socketChannelLabel(hostname, port);
161
159
  const channel = pc.createDataChannel(label, { ordered: true });
162
160
  return waitForDataChannelOpen(channel, pc, timeoutMs, signal).then(() => channel);
163
161
  }
164
162
  //#endregion
163
+ //#region lib/connection/direct.ts
164
+ /**
165
+ * Connect to a remote Pulsar server in direct mode.
166
+ *
167
+ * Designed for browsers, using the native `RTCPeerConnection` API.
168
+ *
169
+ * @param host Server IP address
170
+ * @param port Server UDP port
171
+ * @returns A connected PulsarClientConnection with an open keepalive channel
172
+ * and a `connect()` helper for opening socket tunnels.
173
+ */
174
+ async function connectDirect(host, port) {
175
+ const pc = new RTCPeerConnection();
176
+ const keepalive = pc.createDataChannel(KEEPALIVE_LABEL, { ordered: true });
177
+ const offer = await pc.createOffer();
178
+ await pc.setLocalDescription(offer);
179
+ const remoteSdp = [
180
+ "v=0",
181
+ "o=- 111 222 IN IP4 0.0.0.0",
182
+ "s=-",
183
+ "t=0 0",
184
+ `m=application ${port} UDP/DTLS/SCTP webrtc-datachannel`,
185
+ `c=IN IP4 ${host}`,
186
+ `a=ice-ufrag:${PULSAR_UFRAG}`,
187
+ `a=ice-pwd:${PULSAR_PWD}`,
188
+ `a=fingerprint:sha-256 ${PULSAR_FINGERPRINT}`,
189
+ "a=setup:active",
190
+ "a=mid:0",
191
+ "a=sctp-port:5000",
192
+ ""
193
+ ].join("\r\n");
194
+ await pc.setRemoteDescription({
195
+ type: "answer",
196
+ sdp: remoteSdp
197
+ });
198
+ await pc.addIceCandidate({
199
+ candidate: `candidate:1 1 UDP 2130706431 ${host} ${port} typ host`,
200
+ sdpMid: "0",
201
+ sdpMLineIndex: 0
202
+ });
203
+ await waitForPeerConnectionConnected(pc);
204
+ await waitForDataChannelOpen(keepalive, pc);
205
+ return {
206
+ keepalive,
207
+ pc,
208
+ async close() {
209
+ keepalive.close();
210
+ pc.close();
211
+ }
212
+ };
213
+ }
214
+ //#endregion
215
+ //#region ../core/nostr.ts
216
+ const NOSTR_RELAYS = ["wss://nostr.data.haus", "wss://kotukonostr.onrender.com"];
217
+ const SIGNALING_KIND = 28e3;
218
+ const DISCOVERY_KIND = 38e3;
219
+ const D_TAG_ID = "pulsar-tunnel";
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
+ }
228
+ function hexToBytes(hex) {
229
+ if (hex.length % 2 !== 0 || /[^0-9a-f]/i.test(hex)) throw new Error("Invalid hex string");
230
+ const bytes = new Uint8Array(hex.length / 2);
231
+ for (let i = 0; i < hex.length; i += 2) bytes[i / 2] = Number.parseInt(hex.slice(i, i + 2), 16);
232
+ return bytes;
233
+ }
234
+ function generateNostrKeypair() {
235
+ const seckey = secp256k1.utils.randomPrivateKey();
236
+ return {
237
+ seckey: bytesToHex(seckey),
238
+ pubkey: bytesToHex(schnorr.getPublicKey(seckey))
239
+ };
240
+ }
241
+ function hasTag(event, name, value) {
242
+ return event.tags.some((tag) => tag[0] === name && tag[1] === value);
243
+ }
244
+ function isAddressedTo(event, pubkey) {
245
+ return hasTag(event, "p", pubkey);
246
+ }
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));
312
+ }
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));
331
+ const nonce = crypto.getRandomValues(new Uint8Array(12));
332
+ const encrypted = await crypto.subtle.encrypt({
333
+ name: "AES-GCM",
334
+ iv: nonce,
335
+ tagLength: 128
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);
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));
348
+ const nonce = raw.slice(1, 13);
349
+ const ciphertext = raw.slice(13);
350
+ const decrypted = await crypto.subtle.decrypt({
351
+ name: "AES-GCM",
352
+ iv: nonce,
353
+ tagLength: 128
354
+ }, key, toArrayBuffer(ciphertext));
355
+ return textDecoder.decode(decrypted);
356
+ }
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));
363
+ }
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"]);
375
+ }
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);
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
400
+ function connectRelay(url, timeoutMs = 1e4) {
401
+ return new Promise((resolve, reject) => {
402
+ const ws = new WebSocket(url);
403
+ const timeout = setTimeout(() => {
404
+ ws.close();
405
+ reject(/* @__PURE__ */ new Error(`Connection to ${url} timed out`));
406
+ }, timeoutMs);
407
+ ws.addEventListener("open", () => {
408
+ clearTimeout(timeout);
409
+ resolve(ws);
410
+ });
411
+ ws.addEventListener("error", () => {
412
+ clearTimeout(timeout);
413
+ reject(/* @__PURE__ */ new Error(`Failed to connect to ${url}`));
414
+ });
415
+ });
416
+ }
417
+ async function connectToServerRelay(pubkeyPrefix) {
418
+ const errors = [];
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
+ }
437
+ }
438
+ throw new Error(`Failed to find a Pulsar server: ${errors.join("; ")}`);
439
+ }
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");
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
+ };
455
+ const timeout = setTimeout(() => {
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
+ });
459
+ }, timeoutMs);
460
+ const onMessage = (event) => {
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(() => {});
469
+ };
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));
502
+ });
503
+ }
504
+ var NostrClientConnection = class {
505
+ keepalive;
506
+ pc;
507
+ ws;
508
+ constructor(keepalive, pc, ws) {
509
+ this.keepalive = keepalive;
510
+ this.pc = pc;
511
+ this.ws = ws;
512
+ }
513
+ async close() {
514
+ try {
515
+ this.keepalive.close();
516
+ } catch {}
517
+ try {
518
+ this.pc.close();
519
+ } catch {}
520
+ try {
521
+ this.ws.close();
522
+ } catch {}
523
+ }
524
+ };
525
+ async function connectNostr(tunnelCode) {
526
+ const pubkeyPrefix = tunnelCode ? tunnelCode.replace(/^pulsar/, "").slice(0, 4) : void 0;
527
+ console.log("[nostr] Looking up Pulsar tunnel" + (pubkeyPrefix ? ` (code ${tunnelCode})` : "") + "...");
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
546
+ };
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
559
+ });
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
+ }
573
+ }
574
+ //#endregion
165
575
  //#region lib/tunnel.ts
166
576
  /**
167
577
  * WebSocket-compatible wrapper around an RTCDataChannel.
@@ -272,14 +682,13 @@ var DataChannelSocket = class DataChannelSocket extends EventTarget {
272
682
  * import { connectDirect, libcurlTransport } from '@abndnce/pulsar-client';
273
683
  * import { libcurl } from 'libcurl.js';
274
684
  *
275
- * const tunnel = await connectDirect('216.250.119.217', 42069);
685
+ * const tunnel = await connectDirect('1.2.3.4', 1234);
276
686
  * libcurl.transport = libcurlTransport(tunnel.pc);
277
687
  * libcurl.set_websocket('wss://pulsar-tunnel.local/');
278
688
  * ```
279
689
  *
280
690
  * libcurl.js calls the factory with URLs like:
281
691
  * `wss://pulsar-tunnel.local/example.com:80`
282
- * `wss://pulsar-tunnel.local/216.250.119.217:443`
283
692
  *
284
693
  * The factory parses `<hostname>:<port>` from the URL path, opens a
285
694
  * Pulsar socket data channel, and returns a WebSocket-like adapter.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@abndnce/pulsar-client",
3
3
  "type": "module",
4
- "version": "0.0.9",
4
+ "version": "0.0.11",
5
5
  "homepage": "https://github.com/abndnce/pulsar",
6
6
  "license": "MIT",
7
7
  "exports": {
@@ -11,14 +11,15 @@
11
11
  "files": [
12
12
  "dist"
13
13
  ],
14
+ "dependencies": {
15
+ "@noble/curves": "^1.8.1"
16
+ },
14
17
  "devDependencies": {
15
- "tsdown": "^0.22.0",
16
- "typescript": "^6.0.3"
18
+ "tsdown": "^0.22.0"
17
19
  },
18
20
  "scripts": {
19
21
  "build": "tsdown",
20
22
  "dev": "tsdown --watch",
21
- "test": "vitest",
22
23
  "typecheck": "tsc --noEmit"
23
24
  }
24
25
  }