@abndnce/pulsar-client 0.0.8 → 0.0.10

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
@@ -23,12 +23,21 @@ declare function connectDirect(host: string, port: number): Promise<PulsarClient
23
23
  //#endregion
24
24
  //#region lib/connection/nostr.d.ts
25
25
  /**
26
- * Connect via a Nostr relay (placeholder).
26
+ * Establish a Pulsar tunnel via Nostr relay signaling.
27
27
  *
28
- * In the future, this will implement a Nostr-based signaling layer for
29
- * establishing peer connections through a Nostr relay.
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
30
39
  */
31
- declare function connectNostr(_relay: string, _pubkey: string): Promise<PulsarClientConnection>;
40
+ declare function connectNostr(tunnelCode?: string): Promise<PulsarClientConnection>;
32
41
  //#endregion
33
42
  //#region lib/socket-channel.d.ts
34
43
  /**
@@ -39,9 +48,14 @@ declare function connectNostr(_relay: string, _pubkey: string): Promise<PulsarCl
39
48
  */
40
49
  /**
41
50
  * Wait for an RTCDataChannel to enter the "open" state.
42
- * Rejects if it closes or errors before opening.
51
+ *
52
+ * Also monitors the RTCPeerConnection for failure/closure, and
53
+ * accepts an optional AbortSignal for cancellation.
54
+ *
55
+ * Rejects if the channel closes, the peer connection fails, or
56
+ * the timeout elapses before the channel opens.
43
57
  */
44
- declare function waitForDataChannelOpen(channel: RTCDataChannel, timeoutMs?: number): Promise<void>;
58
+ declare function waitForDataChannelOpen(channel: RTCDataChannel, pc: RTCPeerConnection, timeoutMs?: number, signal?: AbortSignal): Promise<void>;
45
59
  /**
46
60
  * Create a data channel on the given `pc` with the Pulsar socket label
47
61
  * convention (`socket/<hostname>:<port>`) and wait for it to open.
@@ -49,34 +63,47 @@ declare function waitForDataChannelOpen(channel: RTCDataChannel, timeoutMs?: num
49
63
  * This is the low-level primitive used by `connect()` and
50
64
  * `libcurlTransport()`.
51
65
  */
52
- declare function openSocketChannel(pc: RTCPeerConnection, hostname: string, port: number, timeoutMs?: number): Promise<RTCDataChannel>;
66
+ declare function openSocketChannel(pc: RTCPeerConnection, hostname: string, port: number, timeoutMs?: number, signal?: AbortSignal): Promise<RTCDataChannel>;
53
67
  //#endregion
54
68
  //#region lib/tunnel.d.ts
55
69
  /**
56
- * A minimal WebSocket-like wrapper around an RTCDataChannel.
70
+ * WebSocket-compatible wrapper around an RTCDataChannel.
57
71
  *
58
- * libcurl.js expects its transport factory to return objects
59
- * with `send()`, `close()`, `onopen`, `onmessage`, `onclose`,
60
- * and `onerror` which is exactly the RTCDataChannel API, so
61
- * the wrapper is thin.
72
+ * libcurl.js compares `readyState` against `WebSocket.OPEN` (numeric 1)
73
+ * and expects static constants (`CONNECTING`, `OPEN`, `CLOSING`, `CLOSED`).
74
+ * Using `EventTarget` ensures `addEventListener` / `removeEventListener`
75
+ * work, which libcurl's poll loop depends on.
62
76
  */
63
- declare class DataChannelSocket {
77
+ declare class DataChannelSocket extends EventTarget {
78
+ static readonly CONNECTING = 0;
79
+ static readonly OPEN = 1;
80
+ static readonly CLOSING = 2;
81
+ static readonly CLOSED = 3;
82
+ readonly CONNECTING = 0;
83
+ readonly OPEN = 1;
84
+ readonly CLOSING = 2;
85
+ readonly CLOSED = 3;
86
+ readonly url: string;
87
+ readonly protocol = "";
88
+ readonly extensions = "";
89
+ binaryType: string;
90
+ onopen: ((event: Event) => void) | null;
91
+ onclose: ((event: CloseEvent) => void) | null;
92
+ onerror: ((event: Event) => void) | null;
93
+ onmessage: ((event: MessageEvent) => void) | null;
64
94
  private _channel;
65
95
  private _closed;
66
- onopen: (() => void) | null;
67
- onmessage: ((event: {
68
- data: ArrayBuffer | string;
69
- }) => void) | null;
70
- onclose: (() => void) | null;
71
- onerror: ((event: {
72
- error?: string;
73
- }) => void) | null;
74
- constructor(channel: RTCDataChannel);
75
- get readyState(): string;
76
- get binaryType(): string;
77
- set binaryType(_: string);
78
- send(data: ArrayBuffer | string | ArrayBufferView): void;
96
+ private _closeDispatched;
97
+ private _readyState;
98
+ constructor(pc: RTCPeerConnection, destination: string);
99
+ private _open;
100
+ get readyState(): number;
101
+ get bufferedAmount(): number;
102
+ send(data: string | ArrayBufferLike | ArrayBufferView): void;
79
103
  close(): void;
104
+ private _dispatch;
105
+ private _dispatchError;
106
+ private _dispatchClose;
80
107
  }
81
108
  /**
82
109
  * Create a libcurl.js transport factory from an existing WebRTC
@@ -87,14 +114,13 @@ declare class DataChannelSocket {
87
114
  * import { connectDirect, libcurlTransport } from '@abndnce/pulsar-client';
88
115
  * import { libcurl } from 'libcurl.js';
89
116
  *
90
- * const tunnel = await connectDirect('216.250.119.217', 42069);
117
+ * const tunnel = await connectDirect('1.2.3.4', 1234);
91
118
  * libcurl.transport = libcurlTransport(tunnel.pc);
92
119
  * libcurl.set_websocket('wss://pulsar-tunnel.local/');
93
120
  * ```
94
121
  *
95
122
  * libcurl.js calls the factory with URLs like:
96
123
  * `wss://pulsar-tunnel.local/example.com:80`
97
- * `wss://pulsar-tunnel.local/216.250.119.217:443`
98
124
  *
99
125
  * The factory parses `<hostname>:<port>` from the URL path, opens a
100
126
  * Pulsar socket data channel, and returns a WebSocket-like adapter.
package/dist/index.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import { schnorr, secp256k1 } from "@noble/curves/secp256k1";
1
2
  //#region ../core/constants.ts
2
3
  const PULSAR_UFRAG = "pulsar";
3
4
  const PULSAR_PWD = "pulsarpulsarpulsarpuls";
@@ -71,15 +72,349 @@ async function connectDirect(host, port) {
71
72
  };
72
73
  }
73
74
  //#endregion
75
+ //#region ../core/nostr.ts
76
+ 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. */
82
+ const D_TAG_ID = "pulsar-tunnel";
83
+ //#endregion
74
84
  //#region lib/connection/nostr.ts
85
+ function hexToBytes(hex) {
86
+ 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);
88
+ return bytes;
89
+ }
90
+ function bytesToHex(bytes) {
91
+ return Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join("");
92
+ }
93
+ function generateKeypair() {
94
+ const seckey = secp256k1.utils.randomPrivateKey();
95
+ const pubkey = secp256k1.getPublicKey(seckey, true).slice(1);
96
+ return {
97
+ seckey: bytesToHex(seckey),
98
+ pubkey: bytesToHex(pubkey)
99
+ };
100
+ }
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));
116
+ }
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);
121
+ }
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"]);
134
+ }
135
+ async function nip44Encrypt(plaintext, seckeyHex, pubkeyHex) {
136
+ const convKey = await getConversationKey(hexToBytes(seckeyHex), hexToBytes(pubkeyHex));
137
+ const nonce = crypto.getRandomValues(new Uint8Array(12));
138
+ const encoded = new TextEncoder().encode(plaintext);
139
+ const encrypted = await crypto.subtle.encrypt({
140
+ name: "AES-GCM",
141
+ iv: nonce,
142
+ 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));
153
+ if (raw.length < 13) throw new Error("Ciphertext too short");
154
+ const nonce = raw.slice(1, 13);
155
+ const ciphertext = raw.slice(13);
156
+ const decrypted = await crypto.subtle.decrypt({
157
+ name: "AES-GCM",
158
+ iv: nonce,
159
+ tagLength: 128
160
+ }, convKey, ciphertext);
161
+ return new TextDecoder().decode(decrypted);
162
+ }
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
+ ]);
172
+ }
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));
176
+ }
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
+ };
185
+ }
186
+ function connectRelay(url, timeoutMs = 1e4) {
187
+ return new Promise((resolve, reject) => {
188
+ const ws = new WebSocket(url);
189
+ const timeout = setTimeout(() => {
190
+ ws.close();
191
+ reject(/* @__PURE__ */ new Error(`Connection to ${url} timed out`));
192
+ }, timeoutMs);
193
+ ws.addEventListener("open", () => {
194
+ clearTimeout(timeout);
195
+ resolve(ws);
196
+ });
197
+ ws.addEventListener("error", () => {
198
+ clearTimeout(timeout);
199
+ reject(/* @__PURE__ */ new Error(`Failed to connect to ${url}`));
200
+ });
201
+ });
202
+ }
203
+ /**
204
+ * Try each relay in order until one connects successfully.
205
+ */
206
+ async function connectToAnyRelay() {
207
+ 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}`);
216
+ }
217
+ throw new Error(`Failed to connect to any Nostr relay: ${errors.join("; ")}`);
218
+ }
219
+ /**
220
+ * Wait for a single event matching a filter, with timeout.
221
+ */
222
+ function waitForEvent(ws, subId, filter, timeoutMs = 15e3) {
223
+ return new Promise((resolve, reject) => {
224
+ const timeout = setTimeout(() => {
225
+ ws.send(JSON.stringify(["CLOSE", subId]));
226
+ reject(/* @__PURE__ */ new Error("Timed out waiting for Nostr event"));
227
+ }, timeoutMs);
228
+ ws.send(JSON.stringify([
229
+ "REQ",
230
+ subId,
231
+ filter
232
+ ]));
233
+ 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
+ }
246
+ };
247
+ ws.addEventListener("message", onMessage);
248
+ });
249
+ }
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
+ var NostrClientConnection = class {
255
+ keepalive;
256
+ pc;
257
+ _ws;
258
+ _seckey;
259
+ _serverPubkey;
260
+ constructor(keepalive, pc, _ws, _seckey, _serverPubkey) {
261
+ this.keepalive = keepalive;
262
+ this.pc = pc;
263
+ this._ws = _ws;
264
+ this._seckey = _seckey;
265
+ this._serverPubkey = _serverPubkey;
266
+ }
267
+ async close() {
268
+ try {
269
+ this.keepalive.close();
270
+ } catch {}
271
+ try {
272
+ this.pc.close();
273
+ } catch {}
274
+ try {
275
+ this._ws.close();
276
+ } catch {}
277
+ }
278
+ };
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
+ }
75
328
  /**
76
- * Connect via a Nostr relay (placeholder).
329
+ * Establish a Pulsar tunnel via Nostr relay signaling.
77
330
  *
78
- * In the future, this will implement a Nostr-based signaling layer for
79
- * establishing peer connections through a Nostr relay.
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
80
342
  */
81
- async function connectNostr(_relay, _pubkey) {
82
- throw new Error("Nostr mode not yet implemented");
343
+ async function connectNostr(tunnelCode) {
344
+ const ws = await connectToAnyRelay();
345
+ const pubkeyPrefix = tunnelCode ? tunnelCode.replace(/^pulsar/, "").slice(0, 4) : void 0;
346
+ 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
+ }
363
+ };
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
+ }
414
+ });
415
+ });
416
+ console.log("[nostr] WebRTC connected!");
417
+ return new NostrClientConnection(keepalive, pc, ws, clientKeys.seckey, serverPubkey);
83
418
  }
84
419
  //#endregion
85
420
  //#region lib/socket-channel.ts
@@ -91,10 +426,19 @@ async function connectNostr(_relay, _pubkey) {
91
426
  */
92
427
  /**
93
428
  * Wait for an RTCDataChannel to enter the "open" state.
94
- * Rejects if it closes or errors before opening.
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.
95
435
  */
96
- function waitForDataChannelOpen(channel, timeoutMs = 1e4) {
436
+ function waitForDataChannelOpen(channel, pc, timeoutMs = 1e4, signal) {
97
437
  return new Promise((resolve, reject) => {
438
+ if (signal?.aborted) {
439
+ reject(new DOMException("The operation was aborted.", "AbortError"));
440
+ return;
441
+ }
98
442
  if (channel.readyState === "open") {
99
443
  resolve();
100
444
  return;
@@ -105,22 +449,39 @@ function waitForDataChannelOpen(channel, timeoutMs = 1e4) {
105
449
  }, timeoutMs);
106
450
  const cleanup = () => {
107
451
  clearTimeout(timeout);
108
- channel.onopen = null;
109
- channel.onclose = null;
110
- channel.onerror = null;
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);
111
457
  };
112
- channel.onopen = () => {
458
+ const onOpen = () => {
113
459
  cleanup();
114
460
  resolve();
115
461
  };
116
- channel.onclose = () => {
462
+ const onClose = () => {
117
463
  cleanup();
118
464
  reject(/* @__PURE__ */ new Error("DataChannel closed before opening"));
119
465
  };
120
- channel.onerror = (e) => {
466
+ const onError = () => {
121
467
  cleanup();
122
- reject(/* @__PURE__ */ new Error(`DataChannel error before opening: ${e}`));
468
+ reject(/* @__PURE__ */ new Error("DataChannel error before opening"));
123
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 });
124
485
  });
125
486
  }
126
487
  /**
@@ -130,60 +491,111 @@ function waitForDataChannelOpen(channel, timeoutMs = 1e4) {
130
491
  * This is the low-level primitive used by `connect()` and
131
492
  * `libcurlTransport()`.
132
493
  */
133
- function openSocketChannel(pc, hostname, port, timeoutMs) {
494
+ function openSocketChannel(pc, hostname, port, timeoutMs, signal) {
134
495
  const label = `${SOCKET_PREFIX}${hostname}:${port}`;
135
496
  const channel = pc.createDataChannel(label, { ordered: true });
136
- return waitForDataChannelOpen(channel, timeoutMs).then(() => channel);
497
+ return waitForDataChannelOpen(channel, pc, timeoutMs, signal).then(() => channel);
137
498
  }
138
499
  //#endregion
139
500
  //#region lib/tunnel.ts
140
501
  /**
141
- * A minimal WebSocket-like wrapper around an RTCDataChannel.
502
+ * WebSocket-compatible wrapper around an RTCDataChannel.
142
503
  *
143
- * libcurl.js expects its transport factory to return objects
144
- * with `send()`, `close()`, `onopen`, `onmessage`, `onclose`,
145
- * and `onerror` which is exactly the RTCDataChannel API, so
146
- * the wrapper is thin.
504
+ * libcurl.js compares `readyState` against `WebSocket.OPEN` (numeric 1)
505
+ * and expects static constants (`CONNECTING`, `OPEN`, `CLOSING`, `CLOSED`).
506
+ * Using `EventTarget` ensures `addEventListener` / `removeEventListener`
507
+ * work, which libcurl's poll loop depends on.
147
508
  */
148
- var DataChannelSocket = class {
149
- _channel;
150
- _closed = false;
509
+ var DataChannelSocket = class DataChannelSocket extends EventTarget {
510
+ static CONNECTING = 0;
511
+ static OPEN = 1;
512
+ static CLOSING = 2;
513
+ static CLOSED = 3;
514
+ CONNECTING = 0;
515
+ OPEN = 1;
516
+ CLOSING = 2;
517
+ CLOSED = 3;
518
+ url;
519
+ protocol = "";
520
+ extensions = "";
521
+ binaryType = "arraybuffer";
151
522
  onopen = null;
152
- onmessage = null;
153
523
  onclose = null;
154
524
  onerror = null;
155
- constructor(channel) {
525
+ onmessage = null;
526
+ _channel = null;
527
+ _closed = false;
528
+ _closeDispatched = false;
529
+ _readyState = DataChannelSocket.CONNECTING;
530
+ constructor(pc, destination) {
531
+ super();
532
+ this.url = `wss://pulsar-tunnel.local/${destination}`;
533
+ const channel = pc.createDataChannel(`${SOCKET_PREFIX}${destination}`, { ordered: true });
534
+ channel.binaryType = "arraybuffer";
156
535
  this._channel = channel;
157
- channel.onopen = () => {
158
- if (!this._closed) this.onopen?.();
159
- };
536
+ this._open(channel, pc);
537
+ }
538
+ async _open(channel, pc) {
539
+ try {
540
+ await waitForDataChannelOpen(channel, pc);
541
+ } catch (error) {
542
+ if (!this._closed) {
543
+ console.error(`[DataChannelSocket] Failed to open channel: ${error instanceof Error ? error.message : String(error)}`);
544
+ this._dispatchError();
545
+ }
546
+ this._dispatchClose();
547
+ return;
548
+ }
549
+ if (this._closed) {
550
+ channel.close();
551
+ this._dispatchClose();
552
+ return;
553
+ }
554
+ this._channel = channel;
555
+ this._readyState = DataChannelSocket.OPEN;
160
556
  channel.onmessage = (event) => {
161
- if (!this._closed) this.onmessage?.({ data: event.data });
557
+ this._dispatch(new MessageEvent("message", { data: event.data }));
162
558
  };
163
559
  channel.onclose = () => {
164
- this._closed = true;
165
- this.onclose?.();
560
+ this._readyState = DataChannelSocket.CLOSED;
561
+ this._dispatchClose();
166
562
  };
167
- channel.onerror = (event) => {
168
- this.onerror?.({ error: String(event) });
563
+ channel.onerror = () => {
564
+ this._dispatchError();
169
565
  };
566
+ this._dispatch(new Event("open"));
170
567
  }
171
568
  get readyState() {
172
- return this._channel.readyState;
569
+ return this._readyState;
173
570
  }
174
- get binaryType() {
175
- return "arraybuffer";
571
+ get bufferedAmount() {
572
+ return this._channel?.bufferedAmount ?? 0;
176
573
  }
177
- set binaryType(_) {}
178
574
  send(data) {
179
- if (this._closed || this._channel.readyState !== "open") return;
180
- this._channel.send(data);
575
+ if (this._readyState !== DataChannelSocket.OPEN || !this._channel) throw new Error("DataChannelSocket is not open");
576
+ if (typeof data === "string") this._channel.send(data);
577
+ else this._channel.send(data);
181
578
  }
182
579
  close() {
580
+ if (this._closed) return;
183
581
  this._closed = true;
184
- try {
185
- this._channel.close();
186
- } catch {}
582
+ if (this._channel && this._channel.readyState !== "closed") this._channel.close();
583
+ else this._dispatchClose();
584
+ }
585
+ _dispatch(event) {
586
+ const handlerName = `on${event.type}`;
587
+ const handler = this[handlerName];
588
+ if (handler) handler(event);
589
+ this.dispatchEvent(event);
590
+ }
591
+ _dispatchError() {
592
+ this._dispatch(new Event("error"));
593
+ }
594
+ _dispatchClose() {
595
+ if (this._closeDispatched) return;
596
+ this._closeDispatched = true;
597
+ this._readyState = DataChannelSocket.CLOSED;
598
+ this._dispatch(new CloseEvent("close"));
187
599
  }
188
600
  };
189
601
  /**
@@ -195,14 +607,13 @@ var DataChannelSocket = class {
195
607
  * import { connectDirect, libcurlTransport } from '@abndnce/pulsar-client';
196
608
  * import { libcurl } from 'libcurl.js';
197
609
  *
198
- * const tunnel = await connectDirect('216.250.119.217', 42069);
610
+ * const tunnel = await connectDirect('1.2.3.4', 1234);
199
611
  * libcurl.transport = libcurlTransport(tunnel.pc);
200
612
  * libcurl.set_websocket('wss://pulsar-tunnel.local/');
201
613
  * ```
202
614
  *
203
615
  * libcurl.js calls the factory with URLs like:
204
616
  * `wss://pulsar-tunnel.local/example.com:80`
205
- * `wss://pulsar-tunnel.local/216.250.119.217:443`
206
617
  *
207
618
  * The factory parses `<hostname>:<port>` from the URL path, opens a
208
619
  * Pulsar socket data channel, and returns a WebSocket-like adapter.
@@ -218,7 +629,7 @@ function libcurlTransport(pc) {
218
629
  }
219
630
  if (!dest) throw new Error(`libcurl transport: no destination found in URL "${url}"`);
220
631
  if (dest.lastIndexOf(":") === -1) throw new Error(`libcurl transport: invalid destination "${dest}" — expected "hostname:port"`);
221
- return new DataChannelSocket(pc.createDataChannel(`${SOCKET_PREFIX}${dest}`, { ordered: true }));
632
+ return new DataChannelSocket(pc, dest);
222
633
  };
223
634
  }
224
635
  //#endregion
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@abndnce/pulsar-client",
3
3
  "type": "module",
4
- "version": "0.0.8",
4
+ "version": "0.0.10",
5
5
  "homepage": "https://github.com/abndnce/pulsar",
6
6
  "license": "MIT",
7
7
  "exports": {
@@ -11,6 +11,9 @@
11
11
  "files": [
12
12
  "dist"
13
13
  ],
14
+ "dependencies": {
15
+ "@noble/curves": "^1.8.1"
16
+ },
14
17
  "devDependencies": {
15
18
  "tsdown": "^0.22.0",
16
19
  "typescript": "^6.0.3"