pinomad 0.3.0 → 0.5.0

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.
@@ -0,0 +1,704 @@
1
+ // packages/protocol/src/relay.ts
2
+ import { ed25519 } from "@noble/curves/ed25519.js";
3
+
4
+ // packages/protocol/src/noise.ts
5
+ import { chacha20poly1305 } from "@noble/ciphers/chacha.js";
6
+ import { x25519 } from "@noble/curves/ed25519.js";
7
+ import { blake2s } from "@noble/hashes/blake2.js";
8
+ import { hmac } from "@noble/hashes/hmac.js";
9
+ var PROTOCOL_NAME = new TextEncoder().encode("Noise_IK_25519_ChaChaPoly_BLAKE2s");
10
+ var KEY_LENGTH = 32;
11
+ var TAG_LENGTH = 16;
12
+ var MAX_MESSAGE = 65535;
13
+ var MAX_NONCE = 2n ** 64n - 1n;
14
+ var hash = (data) => blake2s(data, { dkLen: KEY_LENGTH });
15
+ var concat = (...parts) => {
16
+ const out = new Uint8Array(parts.reduce((sum, part) => sum + part.length, 0));
17
+ let offset = 0;
18
+ for (const part of parts) {
19
+ out.set(part, offset);
20
+ offset += part.length;
21
+ }
22
+ return out;
23
+ };
24
+ function hkdf(chainingKey, ikm, outputs) {
25
+ const tempKey = hmac(blake2s, chainingKey, ikm);
26
+ const out1 = hmac(blake2s, tempKey, new Uint8Array([1]));
27
+ const out2 = hmac(blake2s, tempKey, concat(out1, new Uint8Array([2])));
28
+ return [out1, out2];
29
+ }
30
+ function generateKeyPair() {
31
+ return keyPairFromPrivate(x25519.utils.randomSecretKey());
32
+ }
33
+ function keyPairFromPrivate(privateKey) {
34
+ if (privateKey.length !== KEY_LENGTH)
35
+ throw new Error(`X25519 private key must be ${KEY_LENGTH} bytes`);
36
+ return { publicKey: x25519.getPublicKey(privateKey), privateKey };
37
+ }
38
+ var dh = (pair, remotePublic) => x25519.getSharedSecret(pair.privateKey, remotePublic);
39
+
40
+ class NoiseCipherState {
41
+ #key;
42
+ #nonce = 0n;
43
+ setKey(key) {
44
+ this.#key = key;
45
+ this.#nonce = 0n;
46
+ }
47
+ encrypt(plaintext, ad = new Uint8Array(0)) {
48
+ const key = this.#key;
49
+ if (key === undefined)
50
+ return plaintext;
51
+ return chacha20poly1305(key, this.#takeNonce(), ad).encrypt(plaintext);
52
+ }
53
+ decrypt(ciphertext, ad = new Uint8Array(0)) {
54
+ const key = this.#key;
55
+ if (key === undefined)
56
+ return ciphertext;
57
+ return chacha20poly1305(key, this.#takeNonce(), ad).decrypt(ciphertext);
58
+ }
59
+ #takeNonce() {
60
+ if (this.#nonce === MAX_NONCE)
61
+ throw new Error("nonce exhausted");
62
+ const nonce = new Uint8Array(12);
63
+ new DataView(nonce.buffer).setBigUint64(4, this.#nonce, true);
64
+ this.#nonce += 1n;
65
+ return nonce;
66
+ }
67
+ }
68
+
69
+ class SymmetricState {
70
+ #ck;
71
+ #h;
72
+ cipher = new NoiseCipherState;
73
+ constructor() {
74
+ const name = PROTOCOL_NAME;
75
+ this.#h = name.length <= KEY_LENGTH ? concat(name, new Uint8Array(KEY_LENGTH - name.length)) : hash(name);
76
+ this.#ck = this.#h.slice();
77
+ }
78
+ mixKey(ikm) {
79
+ const [ck, tempKey] = hkdf(this.#ck, ikm, 2);
80
+ this.#ck = ck;
81
+ this.cipher.setKey(tempKey.subarray(0, KEY_LENGTH));
82
+ }
83
+ mixHash(data) {
84
+ this.#h = hash(concat(this.#h, data));
85
+ }
86
+ encryptAndHash(plaintext) {
87
+ const ciphertext = this.cipher.encrypt(plaintext, this.#h);
88
+ this.mixHash(ciphertext);
89
+ return ciphertext;
90
+ }
91
+ decryptAndHash(ciphertext) {
92
+ const plaintext = this.cipher.decrypt(ciphertext, this.#h);
93
+ this.mixHash(ciphertext);
94
+ return plaintext;
95
+ }
96
+ get handshakeHash() {
97
+ return this.#h;
98
+ }
99
+ split() {
100
+ const [key1, key2] = hkdf(this.#ck, new Uint8Array(0), 2);
101
+ const first = new NoiseCipherState;
102
+ first.setKey(key1.subarray(0, KEY_LENGTH));
103
+ const second = new NoiseCipherState;
104
+ second.setKey(key2.subarray(0, KEY_LENGTH));
105
+ return [first, second];
106
+ }
107
+ }
108
+ function take(message, size) {
109
+ if (message.offset + size > message.buffer.length)
110
+ throw new Error("malformed Noise message");
111
+ const slice = message.buffer.subarray(message.offset, message.offset + size);
112
+ message.offset += size;
113
+ return slice;
114
+ }
115
+ function readMessage1(state, message, own) {
116
+ const remoteEphemeral = take(message, KEY_LENGTH).slice();
117
+ state.mixHash(remoteEphemeral);
118
+ state.mixKey(dh(own.static, remoteEphemeral));
119
+ const remoteStatic = state.decryptAndHash(take(message, KEY_LENGTH + TAG_LENGTH)).slice();
120
+ if (remoteStatic.length !== KEY_LENGTH)
121
+ throw new Error("bad remote static key");
122
+ state.mixKey(dh(own.static, remoteStatic));
123
+ return { remoteEphemeral, remoteStatic };
124
+ }
125
+ function writeMessage2(state, buffer, own, remoteEphemeral, remoteStatic) {
126
+ buffer.push(own.ephemeral.publicKey);
127
+ state.mixHash(own.ephemeral.publicKey);
128
+ state.mixKey(dh(own.ephemeral, remoteEphemeral));
129
+ state.mixKey(dh(own.ephemeral, remoteStatic));
130
+ }
131
+ var checkLength = (message, what) => {
132
+ if (message.length > MAX_MESSAGE)
133
+ throw new Error(`${what} exceeds ${MAX_MESSAGE} bytes`);
134
+ };
135
+ function initialize(prologue, responderStatic) {
136
+ const state = new SymmetricState;
137
+ state.mixHash(prologue);
138
+ state.mixHash(responderStatic);
139
+ return state;
140
+ }
141
+ function respondIK(options) {
142
+ const ephemeral = options.ephemeral ?? generateKeyPair();
143
+ const state = initialize(options.prologue, options.static.publicKey);
144
+ let remoteEphemeral;
145
+ let remoteStatic;
146
+ return {
147
+ readMessage1(message) {
148
+ checkLength(message, "handshake message");
149
+ const reader = { buffer: message, offset: 0 };
150
+ const remote = readMessage1(state, reader, { static: options.static });
151
+ remoteEphemeral = remote.remoteEphemeral;
152
+ remoteStatic = remote.remoteStatic;
153
+ const payload = state.decryptAndHash(take(reader, message.length - reader.offset));
154
+ return { payload, remoteStatic };
155
+ },
156
+ writeMessage2(payload) {
157
+ if (remoteEphemeral === undefined || remoteStatic === undefined)
158
+ throw new Error("message 2 before message 1");
159
+ const buffer = [];
160
+ writeMessage2(state, buffer, { ephemeral }, remoteEphemeral, remoteStatic);
161
+ buffer.push(state.encryptAndHash(payload));
162
+ const message = concat(...buffer);
163
+ checkLength(message, "handshake message");
164
+ const [receive, send] = state.split();
165
+ return { message, result: { send, receive, handshakeHash: state.handshakeHash } };
166
+ }
167
+ };
168
+ }
169
+
170
+ // packages/protocol/src/secure-channel.ts
171
+ var SECURE_PROLOGUE = new TextEncoder().encode("pinomad/1");
172
+ var utf8 = new TextEncoder;
173
+ var utf8Decode = new TextDecoder("utf-8", { fatal: true });
174
+ var encodeHostHello = (hello) => utf8.encode(JSON.stringify(hello));
175
+ function decodeJson(bytes) {
176
+ try {
177
+ return JSON.parse(utf8Decode.decode(bytes));
178
+ } catch {
179
+ throw new Error("handshake payload is not JSON");
180
+ }
181
+ }
182
+ var isString = (value) => typeof value === "string";
183
+ function decodeClientHello(bytes) {
184
+ const value = decodeJson(bytes);
185
+ if (value?.v !== 1)
186
+ throw new Error("unsupported client hello");
187
+ const pair = value.pair;
188
+ if (pair === undefined)
189
+ return { v: 1 };
190
+ const secret = pair.secret;
191
+ const name = pair.name;
192
+ if (!isString(secret) || !isString(name))
193
+ throw new Error("malformed pair");
194
+ return { v: 1, pair: { secret, name } };
195
+ }
196
+ var B64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
197
+ function toBase64Url(bytes) {
198
+ let out = "";
199
+ for (let index = 0;index < bytes.length; index += 3) {
200
+ const a = bytes[index];
201
+ const b = bytes[index + 1];
202
+ const c = bytes[index + 2];
203
+ out += B64_ALPHABET[a >> 2] + B64_ALPHABET[(a & 3) << 4 | (b ?? 0) >> 4];
204
+ if (b !== undefined)
205
+ out += B64_ALPHABET[(b & 15) << 2 | (c ?? 0) >> 6];
206
+ if (c !== undefined)
207
+ out += B64_ALPHABET[c & 63];
208
+ }
209
+ return out;
210
+ }
211
+ function fromBase64Url(text) {
212
+ if (text.length % 4 === 1)
213
+ throw new Error("invalid base64url");
214
+ const lookup = new Map(Array.from(B64_ALPHABET, (char, index) => [char, index]));
215
+ const bytes = [];
216
+ for (let index = 0;index < text.length; index += 4) {
217
+ const quad = text.slice(index, index + 4);
218
+ const values = [...quad].map((char) => {
219
+ const value = lookup.get(char);
220
+ if (value === undefined)
221
+ throw new Error("invalid base64url");
222
+ return value;
223
+ });
224
+ const packed = values.reduce((bits, value, index2) => bits | value << 18 - index2 * 6, 0);
225
+ bytes.push(packed >> 16 & 255);
226
+ if (values.length > 2)
227
+ bytes.push(packed >> 8 & 255);
228
+ if (values.length > 3)
229
+ bytes.push(packed & 255);
230
+ }
231
+ return new Uint8Array(bytes);
232
+ }
233
+ var MAX_BODY = 65535 - 16 - 1;
234
+
235
+ class SecureSession {
236
+ #send;
237
+ #receive;
238
+ #pending = [];
239
+ constructor(result) {
240
+ this.#send = result.send;
241
+ this.#receive = result.receive;
242
+ }
243
+ seal(text) {
244
+ const body = utf8.encode(text);
245
+ const chunks = [];
246
+ for (let offset = 0;offset <= body.length; offset += MAX_BODY) {
247
+ const last = offset + MAX_BODY >= body.length;
248
+ const record = new Uint8Array(1 + Math.min(MAX_BODY, body.length - offset));
249
+ record[0] = last ? 0 : 1;
250
+ record.set(body.subarray(offset, offset + MAX_BODY), 1);
251
+ chunks.push(this.#send.encrypt(record));
252
+ if (last)
253
+ break;
254
+ }
255
+ return chunks;
256
+ }
257
+ open(chunk) {
258
+ const record = this.#receive.decrypt(chunk);
259
+ const flag = record[0];
260
+ if (flag !== 0 && flag !== 1)
261
+ throw new Error("bad record flag");
262
+ this.#pending.push(record.subarray(1));
263
+ if (flag === 1)
264
+ return;
265
+ const whole = concatParts(this.#pending);
266
+ this.#pending = [];
267
+ return utf8Decode.decode(whole);
268
+ }
269
+ }
270
+ function concatParts(parts) {
271
+ const out = new Uint8Array(parts.reduce((sum, part) => sum + part.length, 0));
272
+ let offset = 0;
273
+ for (const part of parts) {
274
+ out.set(part, offset);
275
+ offset += part.length;
276
+ }
277
+ return out;
278
+ }
279
+
280
+ // packages/protocol/src/relay.ts
281
+ var RELAY_CLOSE = {
282
+ badSignature: 4601,
283
+ hostNotAllowed: 4603,
284
+ hostOffline: 4604,
285
+ unknownConnection: 4605,
286
+ replaced: 4609,
287
+ acceptTimeout: 4610
288
+ };
289
+ function encodeRelayFrame(frame) {
290
+ return JSON.stringify(frame);
291
+ }
292
+ var isString2 = (value) => typeof value === "string";
293
+ var isRecord = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
294
+ var isBoundedString = (value, max = 64) => isString2(value) && value.length > 0 && value.length <= max;
295
+ function decodeJson2(text) {
296
+ try {
297
+ return JSON.parse(text);
298
+ } catch {
299
+ throw new Error("relay frame is not JSON");
300
+ }
301
+ }
302
+ function decodeRelayToHost(text) {
303
+ const value = decodeJson2(text);
304
+ switch (value?.t) {
305
+ case "challenge":
306
+ if (!isString2(value.nonce))
307
+ throw new Error("malformed challenge");
308
+ return { t: "challenge", nonce: value.nonce };
309
+ case "registered":
310
+ return decodeRegistered(value);
311
+ case "incoming":
312
+ if (!isString2(value.connId))
313
+ throw new Error("malformed incoming");
314
+ return { t: "incoming", connId: value.connId };
315
+ case "pairing-request":
316
+ if (!isBoundedString(value.requestId))
317
+ throw new Error("malformed pairing-request");
318
+ return { t: "pairing-request", requestId: value.requestId };
319
+ case "push-result":
320
+ return decodePushResult(value);
321
+ case "joined":
322
+ if (!isString2(value.username))
323
+ throw new Error("malformed joined");
324
+ return { t: "joined", username: value.username };
325
+ case "left":
326
+ return { t: "left" };
327
+ case "refused":
328
+ if (!isString2(value.reason))
329
+ throw new Error("malformed refused");
330
+ return { t: "refused", reason: value.reason };
331
+ default:
332
+ throw new Error("unknown relay frame");
333
+ }
334
+ }
335
+ function decodeRegistered(value) {
336
+ if (value.push === undefined)
337
+ return { t: "registered" };
338
+ if (!Array.isArray(value.push) || value.push.length > 8 || !value.push.every(isString2)) {
339
+ throw new Error("malformed registered");
340
+ }
341
+ const push = value.push.filter((entry) => entry === "production" || entry === "development");
342
+ return { t: "registered", push };
343
+ }
344
+ function decodePushResult(value) {
345
+ if (!isBoundedString(value.requestId) || !isBoundedString(value.reason, 256) || value.result !== "accepted" && value.result !== "unregistered" && value.result !== "retry" && value.result !== "rejected" && value.result !== "unavailable") {
346
+ throw new Error("malformed push-result");
347
+ }
348
+ if (value.result === "unregistered") {
349
+ if (typeof value.unregisteredAt !== "number" || !Number.isFinite(value.unregisteredAt)) {
350
+ throw new Error("malformed push-result");
351
+ }
352
+ return {
353
+ t: "push-result",
354
+ requestId: value.requestId,
355
+ result: "unregistered",
356
+ reason: value.reason,
357
+ unregisteredAt: value.unregisteredAt
358
+ };
359
+ }
360
+ if (value.result === "retry") {
361
+ if (value.retryAfterMs !== undefined && (typeof value.retryAfterMs !== "number" || !Number.isFinite(value.retryAfterMs))) {
362
+ throw new Error("malformed push-result");
363
+ }
364
+ return {
365
+ t: "push-result",
366
+ requestId: value.requestId,
367
+ result: "retry",
368
+ reason: value.reason,
369
+ ...value.retryAfterMs === undefined ? {} : { retryAfterMs: value.retryAfterMs }
370
+ };
371
+ }
372
+ return { t: "push-result", requestId: value.requestId, result: value.result, reason: value.reason };
373
+ }
374
+ function decodeHostToRelay(text) {
375
+ const value = decodeJson2(text);
376
+ switch (value?.t) {
377
+ case "register":
378
+ return decodeRegister(value);
379
+ case "push":
380
+ return decodePush(value);
381
+ case "pairing":
382
+ if (!isBoundedString(value.requestId) || !isBoundedString(value.secret) || typeof value.expiresAt !== "number" || !Number.isFinite(value.expiresAt)) {
383
+ throw new Error("malformed pairing");
384
+ }
385
+ return { t: "pairing", requestId: value.requestId, secret: value.secret, expiresAt: value.expiresAt };
386
+ case "pairing-refused":
387
+ if (!isBoundedString(value.requestId) || !isBoundedString(value.reason)) {
388
+ throw new Error("malformed pairing-refused");
389
+ }
390
+ return { t: "pairing-refused", requestId: value.requestId, reason: value.reason };
391
+ default:
392
+ throw new Error("unknown host frame");
393
+ }
394
+ }
395
+ function decodePush(value) {
396
+ const n = value.notification;
397
+ if (!isBoundedString(value.requestId) || !isBoundedString(value.token, 256) || value.environment !== "production" && value.environment !== "development" || !isRecord(n) || !isBoundedString(n.title, 512) || !isBoundedString(n.body, 256) || !isBoundedString(n.collapseId, 64) || !isBoundedString(n.hostKey, 64) || typeof n.conversationId !== "number" || !Number.isSafeInteger(n.conversationId) || n.conversationId < 1) {
398
+ throw new Error("malformed push");
399
+ }
400
+ return {
401
+ t: "push",
402
+ requestId: value.requestId,
403
+ token: value.token,
404
+ environment: value.environment,
405
+ notification: {
406
+ title: n.title,
407
+ body: n.body,
408
+ collapseId: n.collapseId,
409
+ hostKey: n.hostKey,
410
+ conversationId: n.conversationId
411
+ }
412
+ };
413
+ }
414
+ function decodeRegister(value) {
415
+ if (value.t !== "register" || !isString2(value.hostId) || !isString2(value.signature)) {
416
+ throw new Error("malformed register");
417
+ }
418
+ if (value.op !== undefined && value.op !== "join" && value.op !== "leave")
419
+ throw new Error("malformed register");
420
+ if (value.signatureV2 !== undefined && !isString2(value.signatureV2))
421
+ throw new Error("malformed register");
422
+ if (value.noisePublicKey !== undefined && !isString2(value.noisePublicKey))
423
+ throw new Error("malformed register");
424
+ if (value.joinToken !== undefined && !isString2(value.joinToken))
425
+ throw new Error("malformed register");
426
+ if (value.name !== undefined && !isString2(value.name))
427
+ throw new Error("malformed register");
428
+ if (value.features !== undefined && (!Array.isArray(value.features) || value.features.length > 16 || !value.features.every((feature) => isString2(feature) && feature.length <= 64))) {
429
+ throw new Error("malformed register");
430
+ }
431
+ return {
432
+ t: "register",
433
+ hostId: value.hostId,
434
+ signature: value.signature,
435
+ ...value.signatureV2 === undefined ? {} : { signatureV2: value.signatureV2 },
436
+ ...value.noisePublicKey === undefined ? {} : { noisePublicKey: value.noisePublicKey },
437
+ ...value.op === undefined ? {} : { op: value.op },
438
+ ...value.joinToken === undefined ? {} : { joinToken: value.joinToken },
439
+ ...value.name === undefined ? {} : { name: value.name },
440
+ ...value.features === undefined ? {} : { features: value.features }
441
+ };
442
+ }
443
+ function relayHostId(publicKey) {
444
+ if (publicKey.length !== 32)
445
+ throw new Error("Ed25519 public key must be 32 bytes");
446
+ return toBase64Url(publicKey);
447
+ }
448
+ var utf82 = new TextEncoder;
449
+ function relayChallengeMessage(args) {
450
+ return utf82.encode(`pinomad-relay/1
451
+ ${args.relayOrigin}
452
+ ${args.hostId}
453
+ ${args.nonce}`);
454
+ }
455
+ function signRelayChallenge(secretKey, args) {
456
+ return toBase64Url(ed25519.sign(relayChallengeMessage(args), secretKey));
457
+ }
458
+ function verifyRelayChallenge(signature, args) {
459
+ try {
460
+ return ed25519.verify(fromBase64Url(signature), relayChallengeMessage(args), fromBase64Url(args.hostId));
461
+ } catch {
462
+ return false;
463
+ }
464
+ }
465
+ function relayChallengeMessageV2(args) {
466
+ return utf82.encode(`pinomad-relay/2
467
+ ${args.relayOrigin}
468
+ ${args.hostId}
469
+ ${args.nonce}
470
+ ${args.noisePublicKey}`);
471
+ }
472
+ function signRelayChallengeV2(secretKey, args) {
473
+ return toBase64Url(ed25519.sign(relayChallengeMessageV2(args), secretKey));
474
+ }
475
+ function verifyRelayChallengeV2(signature, args) {
476
+ try {
477
+ return ed25519.verify(fromBase64Url(signature), relayChallengeMessageV2(args), fromBase64Url(args.hostId));
478
+ } catch {
479
+ return false;
480
+ }
481
+ }
482
+
483
+ // apps/host/src/relay-link.ts
484
+ import { randomBytes } from "node:crypto";
485
+ import { WebSocket as WebSocket2 } from "ws";
486
+ function relayWsBase(origin) {
487
+ const normalized = origin.replace(/\/+$/, "");
488
+ if (normalized.startsWith("https://"))
489
+ return `wss://${normalized.slice("https://".length)}`;
490
+ if (normalized.startsWith("http://"))
491
+ return `ws://${normalized.slice("http://".length)}`;
492
+ throw new Error(`relay origin must be an http(s) URL: ${origin}`);
493
+ }
494
+ var REJECTION_REASONS = {
495
+ [RELAY_CLOSE.badSignature]: () => `the relay rejected this host's registration signature — a version mismatch?`,
496
+ [RELAY_CLOSE.hostNotAllowed]: (hostId) => `the server does not allow this host (${hostId}); join it to an account with \`pinomad join\``,
497
+ [RELAY_CLOSE.replaced]: () => `the relay replaced this control connection — another process registered the same hostId`
498
+ };
499
+ var PUSH_TIMEOUT_MS = 1e4;
500
+ function startRelayLink(options) {
501
+ const origin = options.origin.replace(/\/+$/, "");
502
+ const wsBase = relayWsBase(origin);
503
+ const hostId = relayHostId(options.signingKey.publicKey);
504
+ const minDelay = options.reconnectDelayMs?.min ?? 1000;
505
+ const maxDelay = options.reconnectDelayMs?.max ?? 30000;
506
+ const maxPendingAccepts = options.maxPendingAccepts ?? 16;
507
+ let state = { status: "connecting" };
508
+ let stopped = false;
509
+ let control;
510
+ let delay = minDelay;
511
+ let retryTimer;
512
+ let pendingAccepts = 0;
513
+ let pushEnvironments;
514
+ const pushes = new Map;
515
+ function setState(next) {
516
+ const changed = state.status !== next.status || state.status === "rejected" && next.status === "rejected" && state.reason !== next.reason;
517
+ state = next;
518
+ if (changed)
519
+ options.onStateChange?.(next);
520
+ }
521
+ const watched = new Set;
522
+ const ponged = new WeakMap;
523
+ const pingIntervalMs = options.pingIntervalMs ?? 30000;
524
+ const pingTimer = setInterval(() => {
525
+ for (const socket of watched) {
526
+ if (socket.readyState !== WebSocket2.OPEN)
527
+ continue;
528
+ if (ponged.get(socket) === true) {
529
+ ponged.set(socket, false);
530
+ socket.ping();
531
+ } else {
532
+ socket.terminate();
533
+ }
534
+ }
535
+ }, pingIntervalMs);
536
+ function watch(socket) {
537
+ watched.add(socket);
538
+ ponged.set(socket, true);
539
+ socket.on("pong", () => ponged.set(socket, true));
540
+ socket.once("close", () => watched.delete(socket));
541
+ socket.on("error", () => socket.terminate());
542
+ }
543
+ function connectControl() {
544
+ if (stopped)
545
+ return;
546
+ const socket = new WebSocket2(`${wsBase}/host`, { handshakeTimeout: pingIntervalMs });
547
+ control = socket;
548
+ pushEnvironments = undefined;
549
+ watch(socket);
550
+ socket.on("message", (data, isBinary) => onControlMessage(socket, data, isBinary));
551
+ socket.once("close", (code) => onControlClosed(socket, code));
552
+ }
553
+ function reconnect(at) {
554
+ if (stopped || retryTimer !== undefined)
555
+ return;
556
+ retryTimer = setTimeout(() => {
557
+ retryTimer = undefined;
558
+ connectControl();
559
+ }, at);
560
+ }
561
+ function onControlMessage(socket, data, isBinary) {
562
+ let frame;
563
+ try {
564
+ if (isBinary)
565
+ throw new Error("binary control frame");
566
+ frame = decodeRelayToHost(data.toString());
567
+ } catch {
568
+ socket.close();
569
+ return;
570
+ }
571
+ switch (frame.t) {
572
+ case "challenge": {
573
+ const noisePublicKey = toBase64Url(options.noisePublicKey);
574
+ const challenge = { relayOrigin: origin, hostId, nonce: frame.nonce };
575
+ socket.send(encodeRelayFrame({
576
+ t: "register",
577
+ hostId,
578
+ signature: signRelayChallenge(options.signingKey.secretKey, challenge),
579
+ signatureV2: signRelayChallengeV2(options.signingKey.secretKey, { ...challenge, noisePublicKey }),
580
+ noisePublicKey,
581
+ ...options.accountOffer === undefined ? {} : { features: ["pairing"] }
582
+ }));
583
+ break;
584
+ }
585
+ case "registered":
586
+ delay = minDelay;
587
+ pushEnvironments = frame.push;
588
+ setState({ status: "registered", ...frame.push === undefined ? {} : { push: frame.push } });
589
+ break;
590
+ case "push-result": {
591
+ const request = pushes.get(frame.requestId);
592
+ if (request === undefined || request.socket !== socket)
593
+ break;
594
+ pushes.delete(frame.requestId);
595
+ const { t: _t, requestId: _id, ...verdict } = frame;
596
+ request.settle(verdict);
597
+ break;
598
+ }
599
+ case "pairing-request": {
600
+ const accountOffer = options.accountOffer;
601
+ const refused = (reason) => {
602
+ socket.send(encodeRelayFrame({ t: "pairing-refused", requestId: frame.requestId, reason }));
603
+ };
604
+ if (accountOffer === undefined) {
605
+ refused("unavailable");
606
+ break;
607
+ }
608
+ accountOffer().then((result) => {
609
+ if (socket !== control || socket.readyState !== WebSocket2.OPEN)
610
+ return;
611
+ socket.send(encodeRelayFrame(result.ok ? { t: "pairing", requestId: frame.requestId, secret: result.secret, expiresAt: result.expiresAt } : { t: "pairing-refused", requestId: frame.requestId, reason: result.reason }));
612
+ }, () => {
613
+ if (socket === control && socket.readyState === WebSocket2.OPEN)
614
+ refused("error");
615
+ });
616
+ break;
617
+ }
618
+ case "incoming": {
619
+ if (pendingAccepts >= maxPendingAccepts)
620
+ break;
621
+ pendingAccepts += 1;
622
+ const data2 = new WebSocket2(`${wsBase}/accept/${frame.connId}`, { handshakeTimeout: pingIntervalMs });
623
+ watch(data2);
624
+ const release = () => {
625
+ pendingAccepts -= 1;
626
+ clearTimeout(ageOut);
627
+ data2.off("close", release);
628
+ };
629
+ const ageOut = setTimeout(release, 1e4);
630
+ data2.once("close", release);
631
+ options.accept(data2);
632
+ break;
633
+ }
634
+ }
635
+ }
636
+ function onControlClosed(socket, code) {
637
+ control = undefined;
638
+ pushEnvironments = undefined;
639
+ for (const [requestId, request] of pushes) {
640
+ if (request.socket === socket) {
641
+ pushes.delete(requestId);
642
+ request.settle({ result: "retry", reason: "control closed" });
643
+ }
644
+ }
645
+ if (stopped)
646
+ return;
647
+ const rejection = REJECTION_REASONS[code];
648
+ if (rejection !== undefined) {
649
+ const reason = `relay ${origin}: ${rejection(hostId)} (${code})`;
650
+ setState({ status: "rejected", reason });
651
+ delay = maxDelay;
652
+ reconnect(maxDelay);
653
+ return;
654
+ }
655
+ setState({ status: "connecting" });
656
+ reconnect(delay);
657
+ delay = Math.min(maxDelay, delay * 2);
658
+ }
659
+ connectControl();
660
+ return {
661
+ hostId,
662
+ origin,
663
+ state: () => state,
664
+ sendPush(request) {
665
+ const socket = control;
666
+ if (stopped || socket === undefined || socket.readyState !== WebSocket2.OPEN || state.status !== "registered") {
667
+ return Promise.resolve({ result: "retry", reason: "control disconnected" });
668
+ }
669
+ const envs = pushEnvironments;
670
+ if (envs === undefined) {
671
+ return Promise.resolve({ result: "unavailable", reason: "server does not declare push" });
672
+ }
673
+ if (!envs.includes(request.environment)) {
674
+ return Promise.resolve({ result: "unavailable", reason: `environment ${request.environment} not declared` });
675
+ }
676
+ return new Promise((resolve) => {
677
+ const requestId = randomBytes(16).toString("base64url");
678
+ const timer = setTimeout(() => {
679
+ if (pushes.delete(requestId))
680
+ resolve({ result: "retry", reason: "timeout" });
681
+ }, PUSH_TIMEOUT_MS);
682
+ pushes.set(requestId, {
683
+ socket,
684
+ settle: (verdict) => {
685
+ clearTimeout(timer);
686
+ resolve(verdict);
687
+ }
688
+ });
689
+ socket.send(encodeRelayFrame({ t: "push", requestId, ...request }));
690
+ });
691
+ },
692
+ close() {
693
+ stopped = true;
694
+ if (retryTimer !== undefined)
695
+ clearTimeout(retryTimer);
696
+ clearInterval(pingTimer);
697
+ for (const socket of watched)
698
+ socket.terminate();
699
+ return Promise.resolve();
700
+ }
701
+ };
702
+ }
703
+
704
+ export { generateKeyPair, keyPairFromPrivate, respondIK, SECURE_PROLOGUE, encodeHostHello, decodeClientHello, toBase64Url, fromBase64Url, SecureSession, RELAY_CLOSE, encodeRelayFrame, decodeRelayToHost, decodeHostToRelay, relayHostId, signRelayChallenge, verifyRelayChallenge, signRelayChallengeV2, verifyRelayChallengeV2, relayWsBase, startRelayLink };