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