@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 +21 -0
- package/dist/index.d.mts +54 -28
- package/dist/index.mjs +457 -46
- package/package.json +4 -1
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
|
-
*
|
|
26
|
+
* Establish a Pulsar tunnel via Nostr relay signaling.
|
|
27
27
|
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
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(
|
|
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
|
-
*
|
|
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
|
-
*
|
|
70
|
+
* WebSocket-compatible wrapper around an RTCDataChannel.
|
|
57
71
|
*
|
|
58
|
-
* libcurl.js
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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('
|
|
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
|
-
*
|
|
329
|
+
* Establish a Pulsar tunnel via Nostr relay signaling.
|
|
77
330
|
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
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(
|
|
82
|
-
|
|
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
|
-
*
|
|
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.
|
|
109
|
-
channel.
|
|
110
|
-
channel.
|
|
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
|
-
|
|
458
|
+
const onOpen = () => {
|
|
113
459
|
cleanup();
|
|
114
460
|
resolve();
|
|
115
461
|
};
|
|
116
|
-
|
|
462
|
+
const onClose = () => {
|
|
117
463
|
cleanup();
|
|
118
464
|
reject(/* @__PURE__ */ new Error("DataChannel closed before opening"));
|
|
119
465
|
};
|
|
120
|
-
|
|
466
|
+
const onError = () => {
|
|
121
467
|
cleanup();
|
|
122
|
-
reject(/* @__PURE__ */ new Error(
|
|
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
|
-
*
|
|
502
|
+
* WebSocket-compatible wrapper around an RTCDataChannel.
|
|
142
503
|
*
|
|
143
|
-
* libcurl.js
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
*
|
|
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
|
-
|
|
150
|
-
|
|
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
|
-
|
|
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
|
|
158
|
-
|
|
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
|
-
|
|
557
|
+
this._dispatch(new MessageEvent("message", { data: event.data }));
|
|
162
558
|
};
|
|
163
559
|
channel.onclose = () => {
|
|
164
|
-
this.
|
|
165
|
-
this.
|
|
560
|
+
this._readyState = DataChannelSocket.CLOSED;
|
|
561
|
+
this._dispatchClose();
|
|
166
562
|
};
|
|
167
|
-
channel.onerror = (
|
|
168
|
-
this.
|
|
563
|
+
channel.onerror = () => {
|
|
564
|
+
this._dispatchError();
|
|
169
565
|
};
|
|
566
|
+
this._dispatch(new Event("open"));
|
|
170
567
|
}
|
|
171
568
|
get readyState() {
|
|
172
|
-
return this.
|
|
569
|
+
return this._readyState;
|
|
173
570
|
}
|
|
174
|
-
get
|
|
175
|
-
return
|
|
571
|
+
get bufferedAmount() {
|
|
572
|
+
return this._channel?.bufferedAmount ?? 0;
|
|
176
573
|
}
|
|
177
|
-
set binaryType(_) {}
|
|
178
574
|
send(data) {
|
|
179
|
-
if (this.
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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('
|
|
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
|
|
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.
|
|
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"
|