@abndnce/pulsar-client 0.0.9 → 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
  /**
@@ -105,14 +114,13 @@ declare class DataChannelSocket extends EventTarget {
105
114
  * import { connectDirect, libcurlTransport } from '@abndnce/pulsar-client';
106
115
  * import { libcurl } from 'libcurl.js';
107
116
  *
108
- * const tunnel = await connectDirect('216.250.119.217', 42069);
117
+ * const tunnel = await connectDirect('1.2.3.4', 1234);
109
118
  * libcurl.transport = libcurlTransport(tunnel.pc);
110
119
  * libcurl.set_websocket('wss://pulsar-tunnel.local/');
111
120
  * ```
112
121
  *
113
122
  * libcurl.js calls the factory with URLs like:
114
123
  * `wss://pulsar-tunnel.local/example.com:80`
115
- * `wss://pulsar-tunnel.local/216.250.119.217:443`
116
124
  *
117
125
  * The factory parses `<hostname>:<port>` from the URL path, opens a
118
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
+ }
75
101
  /**
76
- * Connect via a Nostr relay (placeholder).
102
+ * Derive a shared ECDH secret between a private key and a peer's
103
+ * 32-byte x-only public key (Nostr format).
77
104
  *
78
- * In the future, this will implement a Nostr-based signaling layer for
79
- * establishing peer connections through a Nostr relay.
105
+ * Schnorr public keys always have even y, so we reconstruct the
106
+ * full point via ProjectivePoint and do scalar multiplication.
80
107
  */
81
- async function connectNostr(_relay, _pubkey) {
82
- throw new Error("Nostr mode not yet implemented");
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
+ }
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
+ 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
@@ -272,14 +607,13 @@ var DataChannelSocket = class DataChannelSocket extends EventTarget {
272
607
  * import { connectDirect, libcurlTransport } from '@abndnce/pulsar-client';
273
608
  * import { libcurl } from 'libcurl.js';
274
609
  *
275
- * const tunnel = await connectDirect('216.250.119.217', 42069);
610
+ * const tunnel = await connectDirect('1.2.3.4', 1234);
276
611
  * libcurl.transport = libcurlTransport(tunnel.pc);
277
612
  * libcurl.set_websocket('wss://pulsar-tunnel.local/');
278
613
  * ```
279
614
  *
280
615
  * libcurl.js calls the factory with URLs like:
281
616
  * `wss://pulsar-tunnel.local/example.com:80`
282
- * `wss://pulsar-tunnel.local/216.250.119.217:443`
283
617
  *
284
618
  * The factory parses `<hostname>:<port>` from the URL path, opens a
285
619
  * 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.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"