@phuzle/relay 0.1.0 → 0.1.2
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/README.md +26 -0
- package/cli.js +741 -32
- package/index.cjs +579 -25
- package/index.d.cts +67 -0
- package/index.d.ts +67 -0
- package/index.js +577 -25
- package/package.json +1 -1
package/index.js
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
// ../core/src/crypto/consumer.ts
|
|
2
|
+
import { ed25519 as ed255192 } from "@noble/curves/ed25519";
|
|
3
|
+
import { hkdf } from "@noble/hashes/hkdf";
|
|
4
|
+
import { sha256 as sha2562 } from "@noble/hashes/sha2";
|
|
5
|
+
|
|
1
6
|
// ../core/src/encoding.ts
|
|
2
7
|
function b64urlEncode(bytes) {
|
|
3
8
|
let s = "";
|
|
@@ -13,6 +18,11 @@ function b64urlDecode(text) {
|
|
|
13
18
|
}
|
|
14
19
|
var enc = new TextEncoder();
|
|
15
20
|
var utf8 = (s) => enc.encode(s);
|
|
21
|
+
var fromUtf8 = (b) => new TextDecoder().decode(b);
|
|
22
|
+
|
|
23
|
+
// ../core/src/crypto/keys.ts
|
|
24
|
+
import { ed25519 } from "@noble/curves/ed25519";
|
|
25
|
+
import { sha256 } from "@noble/hashes/sha2";
|
|
16
26
|
|
|
17
27
|
// ../core/src/crypto/suite.ts
|
|
18
28
|
import { Chacha20Poly1305 } from "@hpke/chacha20poly1305";
|
|
@@ -24,19 +34,71 @@ var suite = new CipherSuite({
|
|
|
24
34
|
aead: new Chacha20Poly1305()
|
|
25
35
|
});
|
|
26
36
|
|
|
37
|
+
// ../core/src/crypto/keys.ts
|
|
38
|
+
function bindingMessage(encPub, idPub) {
|
|
39
|
+
return utf8(`relay-bind-v1
|
|
40
|
+
${b64urlEncode(encPub)}
|
|
41
|
+
${b64urlEncode(idPub)}`);
|
|
42
|
+
}
|
|
43
|
+
function signBinding(idPriv, encPub, idPub) {
|
|
44
|
+
return ed25519.sign(bindingMessage(encPub, idPub), idPriv);
|
|
45
|
+
}
|
|
46
|
+
function verifyBinding(sig, encPub, idPub) {
|
|
47
|
+
try {
|
|
48
|
+
return ed25519.verify(sig, bindingMessage(encPub, idPub), idPub);
|
|
49
|
+
} catch {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ../core/src/crypto/consumer.ts
|
|
55
|
+
var CONSUMER_SECRET_BYTES = 32;
|
|
56
|
+
function generateConsumerSecret() {
|
|
57
|
+
return b64urlEncode(
|
|
58
|
+
crypto.getRandomValues(new Uint8Array(CONSUMER_SECRET_BYTES))
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
var derive = (secret, label) => hkdf(sha2562, secret, void 0, utf8(`relay-consumer-v1/${label}`), 32);
|
|
62
|
+
async function consumerKeysFromSecret(secret) {
|
|
63
|
+
const raw = b64urlDecode(secret.trim());
|
|
64
|
+
if (raw.length !== CONSUMER_SECRET_BYTES)
|
|
65
|
+
throw new Error("A consumer key is 43 characters (32 bytes, base64url)");
|
|
66
|
+
const idPriv = derive(raw, "identity");
|
|
67
|
+
const idPub = ed255192.getPublicKey(idPriv);
|
|
68
|
+
const kp = await suite.kem.deriveKeyPair(derive(raw, "encryption"));
|
|
69
|
+
const encPriv = new Uint8Array(
|
|
70
|
+
await suite.kem.serializePrivateKey(kp.privateKey)
|
|
71
|
+
);
|
|
72
|
+
const encPub = new Uint8Array(
|
|
73
|
+
await suite.kem.serializePublicKey(kp.publicKey)
|
|
74
|
+
);
|
|
75
|
+
return { idPriv, idPub, encPriv, encPub };
|
|
76
|
+
}
|
|
77
|
+
async function consumerRegistration(secret) {
|
|
78
|
+
const k = await consumerKeysFromSecret(secret);
|
|
79
|
+
return {
|
|
80
|
+
idPub: b64urlEncode(k.idPub),
|
|
81
|
+
encPub: b64urlEncode(k.encPub),
|
|
82
|
+
bindSig: b64urlEncode(signBinding(k.idPriv, k.encPub, k.idPub))
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
27
86
|
// ../core/src/crypto/envelope.ts
|
|
28
87
|
var MAX_PLAINTEXT_BYTES = 2048;
|
|
88
|
+
var MAX_STREAM_EVENT_BYTES = 256 * 1024;
|
|
29
89
|
var PayloadTooLargeError = class extends Error {
|
|
30
|
-
constructor(size) {
|
|
31
|
-
super(`Payload is ${size} bytes; limit is ${
|
|
90
|
+
constructor(size, limit = MAX_PLAINTEXT_BYTES) {
|
|
91
|
+
super(`Payload is ${size} bytes; limit is ${limit}`);
|
|
32
92
|
this.name = "PayloadTooLargeError";
|
|
33
93
|
}
|
|
34
94
|
};
|
|
35
95
|
var infoFor = (c) => utf8(`relay/v1\0${c.deviceId}\0${c.messageId}\0${c.topicId}`);
|
|
36
|
-
|
|
96
|
+
function seal(encPub, ctx, plaintext, ekm) {
|
|
97
|
+
return sealUpTo(MAX_PLAINTEXT_BYTES, encPub, ctx, plaintext, ekm);
|
|
98
|
+
}
|
|
99
|
+
async function sealUpTo(limit, encPub, ctx, plaintext, ekm) {
|
|
37
100
|
const pt = utf8(plaintext);
|
|
38
|
-
if (pt.length >
|
|
39
|
-
throw new PayloadTooLargeError(pt.length);
|
|
101
|
+
if (pt.length > limit) throw new PayloadTooLargeError(pt.length, limit);
|
|
40
102
|
const recipientPublicKey = await suite.kem.deserializePublicKey(encPub);
|
|
41
103
|
const sender = await suite.createSenderContext({
|
|
42
104
|
recipientPublicKey,
|
|
@@ -50,26 +112,20 @@ async function seal(encPub, ctx, plaintext, ekm) {
|
|
|
50
112
|
ct: b64urlEncode(ct)
|
|
51
113
|
};
|
|
52
114
|
}
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
function verifyBinding(sig, encPub, idPub) {
|
|
63
|
-
try {
|
|
64
|
-
return ed25519.verify(sig, bindingMessage(encPub, idPub), idPub);
|
|
65
|
-
} catch {
|
|
66
|
-
return false;
|
|
67
|
-
}
|
|
115
|
+
async function open(encPriv, ctx, envelope) {
|
|
116
|
+
const recipientKey = await suite.kem.deserializePrivateKey(encPriv);
|
|
117
|
+
const recipient = await suite.createRecipientContext({
|
|
118
|
+
recipientKey,
|
|
119
|
+
enc: b64urlDecode(envelope.enc),
|
|
120
|
+
info: infoFor(ctx)
|
|
121
|
+
});
|
|
122
|
+
const pt = await recipient.open(b64urlDecode(envelope.ct));
|
|
123
|
+
return fromUtf8(new Uint8Array(pt));
|
|
68
124
|
}
|
|
69
125
|
|
|
70
126
|
// ../core/src/crypto/signing.ts
|
|
71
|
-
import { ed25519 as
|
|
72
|
-
import { sha256 as
|
|
127
|
+
import { ed25519 as ed255193 } from "@noble/curves/ed25519";
|
|
128
|
+
import { sha256 as sha2563 } from "@noble/hashes/sha2";
|
|
73
129
|
|
|
74
130
|
// ../core/src/lib/glyphs.ts
|
|
75
131
|
var GLYPHS = [
|
|
@@ -131,6 +187,415 @@ function ulid(now = Date.now()) {
|
|
|
131
187
|
return time + rand;
|
|
132
188
|
}
|
|
133
189
|
|
|
190
|
+
// src/stream.ts
|
|
191
|
+
import { gunzipSync } from "zlib";
|
|
192
|
+
var StreamError = class extends Error {
|
|
193
|
+
constructor(message, status, code) {
|
|
194
|
+
super(message);
|
|
195
|
+
this.status = status;
|
|
196
|
+
this.code = code;
|
|
197
|
+
this.name = "StreamError";
|
|
198
|
+
}
|
|
199
|
+
status;
|
|
200
|
+
code;
|
|
201
|
+
};
|
|
202
|
+
var sleep = (ms, signal) => new Promise((resolve) => {
|
|
203
|
+
const t = setTimeout(resolve, ms);
|
|
204
|
+
signal?.addEventListener(
|
|
205
|
+
"abort",
|
|
206
|
+
() => {
|
|
207
|
+
clearTimeout(t);
|
|
208
|
+
resolve();
|
|
209
|
+
},
|
|
210
|
+
{ once: true }
|
|
211
|
+
);
|
|
212
|
+
});
|
|
213
|
+
function binParser() {
|
|
214
|
+
let buf = new Uint8Array(0);
|
|
215
|
+
const dec = new TextDecoder();
|
|
216
|
+
const b64 = (b) => Buffer.from(b).toString("base64url");
|
|
217
|
+
const NAMES = { 1: "ready", 3: "gap", 4: "bye" };
|
|
218
|
+
return (chunk) => {
|
|
219
|
+
if (buf.length === 0) buf = chunk;
|
|
220
|
+
else {
|
|
221
|
+
const next = new Uint8Array(buf.length + chunk.length);
|
|
222
|
+
next.set(buf);
|
|
223
|
+
next.set(chunk, buf.length);
|
|
224
|
+
buf = next;
|
|
225
|
+
}
|
|
226
|
+
const out = [];
|
|
227
|
+
for (; ; ) {
|
|
228
|
+
if (buf.length < 5) break;
|
|
229
|
+
const dv = new DataView(
|
|
230
|
+
buf.buffer,
|
|
231
|
+
buf.byteOffset,
|
|
232
|
+
buf.byteLength
|
|
233
|
+
);
|
|
234
|
+
const len = dv.getUint32(1);
|
|
235
|
+
if (buf.length < 5 + len) break;
|
|
236
|
+
const kind = buf[0];
|
|
237
|
+
const body = buf.subarray(5, 5 + len);
|
|
238
|
+
buf = buf.subarray(5 + len);
|
|
239
|
+
if (kind === 5) out.push({ event: ":", data: "" });
|
|
240
|
+
else if (kind === 2) {
|
|
241
|
+
const bd = new DataView(
|
|
242
|
+
body.buffer,
|
|
243
|
+
body.byteOffset,
|
|
244
|
+
body.byteLength
|
|
245
|
+
);
|
|
246
|
+
const metaLen = bd.getUint32(0);
|
|
247
|
+
const meta = JSON.parse(dec.decode(body.subarray(4, 4 + metaLen)));
|
|
248
|
+
const encLen = body[4 + metaLen];
|
|
249
|
+
const encBytes = body.subarray(5 + metaLen, 5 + metaLen + encLen);
|
|
250
|
+
const ct = body.subarray(5 + metaLen + encLen);
|
|
251
|
+
out.push({
|
|
252
|
+
event: "event",
|
|
253
|
+
data: JSON.stringify({ ...meta, enc: b64(encBytes), ct: b64(ct) })
|
|
254
|
+
});
|
|
255
|
+
} else if (NAMES[kind])
|
|
256
|
+
out.push({ event: NAMES[kind], data: dec.decode(body) });
|
|
257
|
+
}
|
|
258
|
+
return out;
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
function sseParser() {
|
|
262
|
+
let buf = "";
|
|
263
|
+
return (chunk) => {
|
|
264
|
+
buf += chunk.replace(/\r\n/g, "\n");
|
|
265
|
+
const out = [];
|
|
266
|
+
let i;
|
|
267
|
+
while ((i = buf.indexOf("\n\n")) >= 0) {
|
|
268
|
+
const block = buf.slice(0, i);
|
|
269
|
+
buf = buf.slice(i + 2);
|
|
270
|
+
let event = "message";
|
|
271
|
+
let id;
|
|
272
|
+
const data = [];
|
|
273
|
+
let comment = false;
|
|
274
|
+
for (const line of block.split("\n")) {
|
|
275
|
+
if (line.startsWith(":")) comment = true;
|
|
276
|
+
else if (line.startsWith("event:")) event = line.slice(6).trim();
|
|
277
|
+
else if (line.startsWith("id:")) id = line.slice(3).trim();
|
|
278
|
+
else if (line.startsWith("data:"))
|
|
279
|
+
data.push(line.slice(5).replace(/^ /, ""));
|
|
280
|
+
}
|
|
281
|
+
if (comment && data.length === 0 && event === "message")
|
|
282
|
+
out.push({ event: ":", data: "" });
|
|
283
|
+
else out.push({ event, id, data: data.join("\n") });
|
|
284
|
+
}
|
|
285
|
+
return out;
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
var Subscription = class {
|
|
289
|
+
constructor(t, topic, name, secret, tuning = {}, o = { name }) {
|
|
290
|
+
this.t = t;
|
|
291
|
+
this.topic = topic;
|
|
292
|
+
this.name = name;
|
|
293
|
+
this.secret = secret;
|
|
294
|
+
this.tuning = tuning;
|
|
295
|
+
this.o = o;
|
|
296
|
+
}
|
|
297
|
+
t;
|
|
298
|
+
topic;
|
|
299
|
+
name;
|
|
300
|
+
secret;
|
|
301
|
+
tuning;
|
|
302
|
+
o;
|
|
303
|
+
queue = [];
|
|
304
|
+
waiter = null;
|
|
305
|
+
failure = null;
|
|
306
|
+
ctl = new AbortController();
|
|
307
|
+
stopped = false;
|
|
308
|
+
_state = "connecting";
|
|
309
|
+
listeners = { open: [], reconnecting: [], error: [], gap: [] };
|
|
310
|
+
firstConnect = true;
|
|
311
|
+
current = null;
|
|
312
|
+
settledEvents = /* @__PURE__ */ new WeakSet();
|
|
313
|
+
acks = [];
|
|
314
|
+
nacks = [];
|
|
315
|
+
flushing = null;
|
|
316
|
+
flushTimer = null;
|
|
317
|
+
flushWaiters = [];
|
|
318
|
+
started = null;
|
|
319
|
+
get state() {
|
|
320
|
+
return this._state;
|
|
321
|
+
}
|
|
322
|
+
on(ev, fn) {
|
|
323
|
+
this.listeners[ev].push(fn);
|
|
324
|
+
return this;
|
|
325
|
+
}
|
|
326
|
+
emit(ev, v) {
|
|
327
|
+
for (const fn of this.listeners[ev]) {
|
|
328
|
+
try {
|
|
329
|
+
fn(v);
|
|
330
|
+
} catch {
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
/** Resolves once the consumer is registered and the stream is open (or rejects if it can't be). */
|
|
335
|
+
ready() {
|
|
336
|
+
this.start();
|
|
337
|
+
return new Promise((resolve, reject) => {
|
|
338
|
+
if (this._state === "open") return resolve();
|
|
339
|
+
this.on("open", () => resolve());
|
|
340
|
+
this.on("error", (e) => reject(e));
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
close() {
|
|
344
|
+
this.stopped = true;
|
|
345
|
+
this._state = "closed";
|
|
346
|
+
this.ctl.abort();
|
|
347
|
+
this.waiter?.();
|
|
348
|
+
if (this.current && !this.settledEvents.has(this.current))
|
|
349
|
+
this.nacks.push(this.current.seq);
|
|
350
|
+
this.current = null;
|
|
351
|
+
for (const e of this.queue) this.nacks.push(e.seq);
|
|
352
|
+
this.queue = [];
|
|
353
|
+
void this.flush();
|
|
354
|
+
}
|
|
355
|
+
/** Acknowledgements and hand-backs are sent in one request after a short pause (or at 50), not one request per event. */
|
|
356
|
+
settle(seq, kind) {
|
|
357
|
+
(kind === "ack" ? this.acks : this.nacks).push(seq);
|
|
358
|
+
return new Promise((resolve, reject) => {
|
|
359
|
+
this.flushWaiters.push({ resolve, reject });
|
|
360
|
+
if (this.acks.length + this.nacks.length >= 50) void this.flush();
|
|
361
|
+
else this.flushTimer ??= setTimeout(() => void this.flush(), 25);
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
async flush() {
|
|
365
|
+
if (this.flushTimer) clearTimeout(this.flushTimer);
|
|
366
|
+
this.flushTimer = null;
|
|
367
|
+
if (this.flushing) await this.flushing.catch(() => {
|
|
368
|
+
});
|
|
369
|
+
if (this.acks.length === 0 && this.nacks.length === 0) return;
|
|
370
|
+
const body = {
|
|
371
|
+
consumer: this.name,
|
|
372
|
+
...this.acks.length ? { seqs: this.acks } : {},
|
|
373
|
+
...this.nacks.length ? { nack: this.nacks } : {}
|
|
374
|
+
};
|
|
375
|
+
const waiters = this.flushWaiters;
|
|
376
|
+
this.acks = [];
|
|
377
|
+
this.nacks = [];
|
|
378
|
+
this.flushWaiters = [];
|
|
379
|
+
this.flushing = this.t.ack(this.topic, body).then(() => waiters.forEach((w) => w.resolve())).catch((e) => {
|
|
380
|
+
const err = e instanceof Error ? e : new Error(String(e));
|
|
381
|
+
waiters.forEach((w) => w.reject(err));
|
|
382
|
+
this.emit("error", err);
|
|
383
|
+
});
|
|
384
|
+
await this.flushing;
|
|
385
|
+
}
|
|
386
|
+
[Symbol.asyncIterator]() {
|
|
387
|
+
this.start();
|
|
388
|
+
return {
|
|
389
|
+
next: async () => {
|
|
390
|
+
if (this.current && !this.settledEvents.has(this.current) && (this.o.ack ?? "auto") === "auto")
|
|
391
|
+
void this.current.ack().catch(() => {
|
|
392
|
+
});
|
|
393
|
+
for (; ; ) {
|
|
394
|
+
const e = this.queue.shift();
|
|
395
|
+
if (e) {
|
|
396
|
+
this.current = e;
|
|
397
|
+
return { value: e, done: false };
|
|
398
|
+
}
|
|
399
|
+
if (this.failure) throw this.failure;
|
|
400
|
+
if (this.stopped) return { value: void 0, done: true };
|
|
401
|
+
await new Promise((r) => {
|
|
402
|
+
this.waiter = r;
|
|
403
|
+
});
|
|
404
|
+
this.waiter = null;
|
|
405
|
+
}
|
|
406
|
+
},
|
|
407
|
+
return: async () => {
|
|
408
|
+
this.close();
|
|
409
|
+
return { value: void 0, done: true };
|
|
410
|
+
}
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
start() {
|
|
414
|
+
if (!this.started)
|
|
415
|
+
this.started = this.run().catch(
|
|
416
|
+
(e) => this.fail(e instanceof Error ? e : new Error(String(e)))
|
|
417
|
+
);
|
|
418
|
+
}
|
|
419
|
+
fail(e) {
|
|
420
|
+
if (this.stopped) return;
|
|
421
|
+
this.failure = e;
|
|
422
|
+
this._state = "closed";
|
|
423
|
+
this.emit("error", e);
|
|
424
|
+
this.waiter?.();
|
|
425
|
+
}
|
|
426
|
+
push(e) {
|
|
427
|
+
this.queue.push(e);
|
|
428
|
+
this.waiter?.();
|
|
429
|
+
}
|
|
430
|
+
async run() {
|
|
431
|
+
const keys = await consumerKeysFromSecret(this.secret);
|
|
432
|
+
const registration = {
|
|
433
|
+
...await consumerRegistration(this.secret),
|
|
434
|
+
options: this.o.filter ?? {}
|
|
435
|
+
};
|
|
436
|
+
let attempt = 0;
|
|
437
|
+
let ids = null;
|
|
438
|
+
while (!this.stopped) {
|
|
439
|
+
try {
|
|
440
|
+
ids = await this.t.register(this.topic, this.name, registration);
|
|
441
|
+
const res = await this.t.open(
|
|
442
|
+
this.topic,
|
|
443
|
+
this.name,
|
|
444
|
+
this.query(),
|
|
445
|
+
this.ctl.signal
|
|
446
|
+
);
|
|
447
|
+
if (!res.body)
|
|
448
|
+
throw new StreamError(
|
|
449
|
+
"The stream had no body",
|
|
450
|
+
res.status,
|
|
451
|
+
"no_body"
|
|
452
|
+
);
|
|
453
|
+
attempt = 0;
|
|
454
|
+
this._state = "open";
|
|
455
|
+
const bin = (res.headers.get("content-type") ?? "").includes(
|
|
456
|
+
"vnd.relay.stream"
|
|
457
|
+
);
|
|
458
|
+
const why = await this.read(res.body, keys.encPriv, ids, bin);
|
|
459
|
+
if (this.stopped) return;
|
|
460
|
+
this._state = "reconnecting";
|
|
461
|
+
this.emit("reconnecting", { attempt: 1, reason: why });
|
|
462
|
+
if (why === "revoked")
|
|
463
|
+
throw new StreamError(
|
|
464
|
+
"This consumer was removed",
|
|
465
|
+
0,
|
|
466
|
+
"consumer_revoked"
|
|
467
|
+
);
|
|
468
|
+
continue;
|
|
469
|
+
} catch (e) {
|
|
470
|
+
if (this.stopped) return;
|
|
471
|
+
const status = e.status ?? 0;
|
|
472
|
+
if (e instanceof StreamError && (e.code === "consumer_revoked" || e.status >= 400 && e.status < 500 && e.status !== 429))
|
|
473
|
+
throw e;
|
|
474
|
+
if (status >= 400 && status < 500 && status !== 429 && status !== 0)
|
|
475
|
+
throw e;
|
|
476
|
+
attempt++;
|
|
477
|
+
this._state = "reconnecting";
|
|
478
|
+
this.emit("reconnecting", {
|
|
479
|
+
attempt,
|
|
480
|
+
reason: e instanceof Error ? e.message : "error"
|
|
481
|
+
});
|
|
482
|
+
const cap = this.tuning.maxBackoffMs ?? 3e4;
|
|
483
|
+
const base = Math.min(cap, 500 * 2 ** Math.min(attempt, 7));
|
|
484
|
+
await sleep(base * (0.7 + Math.random() * 0.6), this.ctl.signal);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
/** The first connection starts where the caller asked; every later one resumes from what is still unacknowledged. */
|
|
489
|
+
query() {
|
|
490
|
+
const q = {};
|
|
491
|
+
if (this.firstConnect && this.o.from !== void 0) q.from = this.o.from;
|
|
492
|
+
this.firstConnect = false;
|
|
493
|
+
q.format = this.o.format ?? "bin";
|
|
494
|
+
if (this.o.prefetch) q.prefetch = this.o.prefetch;
|
|
495
|
+
if (this.o.lease) q.lease = this.o.lease;
|
|
496
|
+
return q;
|
|
497
|
+
}
|
|
498
|
+
/** Reads one connection until it ends. Returns why: `recycle`, `revoked`, `dropped`, or `silent` (heartbeat watchdog). */
|
|
499
|
+
async read(body, encPriv, ids, bin = false) {
|
|
500
|
+
const reader = body.getReader();
|
|
501
|
+
const dec = new TextDecoder();
|
|
502
|
+
const parse = sseParser();
|
|
503
|
+
const parseBin = binParser();
|
|
504
|
+
const timeout = this.tuning.heartbeatTimeoutMs ?? 45e3;
|
|
505
|
+
let why = "dropped";
|
|
506
|
+
try {
|
|
507
|
+
for (; ; ) {
|
|
508
|
+
const chunk = await Promise.race([
|
|
509
|
+
reader.read(),
|
|
510
|
+
sleep(timeout, this.ctl.signal).then(() => "silent")
|
|
511
|
+
]);
|
|
512
|
+
if (this.stopped) return "closed";
|
|
513
|
+
if (chunk === "silent") {
|
|
514
|
+
why = "silent";
|
|
515
|
+
break;
|
|
516
|
+
}
|
|
517
|
+
if (chunk.done) break;
|
|
518
|
+
for (const m of bin ? parseBin(chunk.value) : parse(dec.decode(chunk.value, { stream: true }))) {
|
|
519
|
+
if (m.event === "ready") this.emit("open");
|
|
520
|
+
else if (m.event === "event")
|
|
521
|
+
await this.deliver(m.data, encPriv, ids);
|
|
522
|
+
else if (m.event === "gap") this.emit("gap", JSON.parse(m.data));
|
|
523
|
+
else if (m.event === "bye") {
|
|
524
|
+
why = JSON.parse(m.data || "{}").reason ?? "recycle";
|
|
525
|
+
return why;
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
} finally {
|
|
530
|
+
try {
|
|
531
|
+
await reader.cancel();
|
|
532
|
+
} catch {
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
return why;
|
|
536
|
+
}
|
|
537
|
+
async deliver(data, encPriv, ids) {
|
|
538
|
+
const w = JSON.parse(data);
|
|
539
|
+
let p;
|
|
540
|
+
try {
|
|
541
|
+
p = JSON.parse(
|
|
542
|
+
await open(
|
|
543
|
+
encPriv,
|
|
544
|
+
{ messageId: w.id, topicId: ids.topicId, deviceId: ids.id },
|
|
545
|
+
w
|
|
546
|
+
)
|
|
547
|
+
);
|
|
548
|
+
const z = p;
|
|
549
|
+
if (z.z === "gzip" && z.d)
|
|
550
|
+
p = JSON.parse(
|
|
551
|
+
gunzipSync(Buffer.from(z.d, "base64")).toString("utf8")
|
|
552
|
+
);
|
|
553
|
+
} catch {
|
|
554
|
+
this.emit(
|
|
555
|
+
"error",
|
|
556
|
+
new StreamError(
|
|
557
|
+
"An event could not be opened with this consumer key",
|
|
558
|
+
0,
|
|
559
|
+
"undecryptable"
|
|
560
|
+
)
|
|
561
|
+
);
|
|
562
|
+
void this.settle(w.seq, "ack").catch(() => {
|
|
563
|
+
});
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
const ev = {
|
|
567
|
+
id: p.id,
|
|
568
|
+
seq: w.seq,
|
|
569
|
+
attempt: w.attempt,
|
|
570
|
+
source: p.source,
|
|
571
|
+
type: p.type,
|
|
572
|
+
deliveryId: p.deliveryId,
|
|
573
|
+
receivedAt: p.receivedAt,
|
|
574
|
+
headers: p.headers,
|
|
575
|
+
contentType: p.contentType,
|
|
576
|
+
body: p.body,
|
|
577
|
+
bodyBytes: p.bodyBytes,
|
|
578
|
+
truncated: p.truncated === true,
|
|
579
|
+
projected: p.projected === true,
|
|
580
|
+
json: () => {
|
|
581
|
+
if (p.body == null) throw new Error("This event has no body");
|
|
582
|
+
return JSON.parse(p.body);
|
|
583
|
+
},
|
|
584
|
+
ack: () => {
|
|
585
|
+
if (this.settledEvents.has(ev)) return Promise.resolve();
|
|
586
|
+
this.settledEvents.add(ev);
|
|
587
|
+
return this.settle(w.seq, "ack");
|
|
588
|
+
},
|
|
589
|
+
nack: () => {
|
|
590
|
+
if (this.settledEvents.has(ev)) return Promise.resolve();
|
|
591
|
+
this.settledEvents.add(ev);
|
|
592
|
+
return this.settle(w.seq, "nack");
|
|
593
|
+
}
|
|
594
|
+
};
|
|
595
|
+
this.push(ev);
|
|
596
|
+
}
|
|
597
|
+
};
|
|
598
|
+
|
|
134
599
|
// src/index.ts
|
|
135
600
|
var deliverAtIso = (m) => {
|
|
136
601
|
if (m.deliverAt !== void 0 && m.delaySeconds !== void 0)
|
|
@@ -168,7 +633,7 @@ var RelayError = class extends Error {
|
|
|
168
633
|
retryAfterSeconds;
|
|
169
634
|
};
|
|
170
635
|
var DEFAULT_URL = "https://relay.phuzle.com";
|
|
171
|
-
var
|
|
636
|
+
var sleep2 = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
172
637
|
var Relay = class _Relay {
|
|
173
638
|
constructor(opts) {
|
|
174
639
|
this.opts = opts;
|
|
@@ -207,6 +672,90 @@ var Relay = class _Relay {
|
|
|
207
672
|
}
|
|
208
673
|
});
|
|
209
674
|
}
|
|
675
|
+
/** A new consumer key for `subscribe()`: 43 characters, shown once. Store it as `RELAY_CONSUMER_KEY`; Relay never sees it and cannot recover it. */
|
|
676
|
+
static generateConsumerKey() {
|
|
677
|
+
return generateConsumerSecret();
|
|
678
|
+
}
|
|
679
|
+
/** One topic by its full name (`<org>-<slug>`): send to it, list its phones, or subscribe to its events from a server. */
|
|
680
|
+
topic(name) {
|
|
681
|
+
return {
|
|
682
|
+
name,
|
|
683
|
+
send: (m) => this.send({ ...m, topic: name }),
|
|
684
|
+
devices: () => this.devices(name),
|
|
685
|
+
subscribe: (o) => this.subscribe(name, o)
|
|
686
|
+
};
|
|
687
|
+
}
|
|
688
|
+
/**
|
|
689
|
+
* Receive a topic's events on a server (Relay Streams): webhooks Relay received for this topic, delivered over one long-lived
|
|
690
|
+
* connection, sealed to your consumer key so only this process can open them. Needs an API key with “Can receive events” for the
|
|
691
|
+
* topic. Reconnects by itself, and **at-least-once**: events wait for you (for your plan's retention) until acknowledged, so a
|
|
692
|
+
* restart loses nothing; repeats are possible, so use `ev.id` to ignore ones you have handled.
|
|
693
|
+
*
|
|
694
|
+
* const sub = relay.topic("acme-github-api").subscribe({ name: "deploy-bot" }); // key from RELAY_CONSUMER_KEY
|
|
695
|
+
* for await (const ev of sub) { if (ev.type === "push") deploy(ev.json()); }
|
|
696
|
+
*/
|
|
697
|
+
subscribe(topic, o) {
|
|
698
|
+
const key = (o.key ?? process.env.RELAY_CONSUMER_KEY ?? "").trim();
|
|
699
|
+
if (!key)
|
|
700
|
+
throw new RelayError(
|
|
701
|
+
"A consumer key is required: pass `key` or set RELAY_CONSUMER_KEY (create one with Relay.generateConsumerKey())",
|
|
702
|
+
0,
|
|
703
|
+
"consumer_key_required"
|
|
704
|
+
);
|
|
705
|
+
const t = {
|
|
706
|
+
register: async (tp, name, body) => await this.request(
|
|
707
|
+
"PUT",
|
|
708
|
+
`/v1/stream/${encodeURIComponent(tp)}/consumers/${encodeURIComponent(name)}`,
|
|
709
|
+
body
|
|
710
|
+
),
|
|
711
|
+
ack: async (tp, body) => {
|
|
712
|
+
await this.parse(
|
|
713
|
+
await this.raw(
|
|
714
|
+
"POST",
|
|
715
|
+
`/v1/stream/${encodeURIComponent(tp)}/ack`,
|
|
716
|
+
body
|
|
717
|
+
)
|
|
718
|
+
);
|
|
719
|
+
},
|
|
720
|
+
open: async (tp, name, query, signal) => {
|
|
721
|
+
const qs = new URLSearchParams({ consumer: name });
|
|
722
|
+
for (const [k, v] of Object.entries(query)) qs.set(k, String(v));
|
|
723
|
+
const res = await this.raw(
|
|
724
|
+
"GET",
|
|
725
|
+
`/v1/stream/${encodeURIComponent(tp)}/events?${qs}`,
|
|
726
|
+
void 0,
|
|
727
|
+
{ accept: "text/event-stream" },
|
|
728
|
+
signal
|
|
729
|
+
);
|
|
730
|
+
if (!res.ok) await this.parse(res);
|
|
731
|
+
return res;
|
|
732
|
+
}
|
|
733
|
+
};
|
|
734
|
+
return new Subscription(t, topic, o.name, key, {}, o);
|
|
735
|
+
}
|
|
736
|
+
/** The topics this key may send to (its `trigger:*` / `trigger:<slug>` permissions), and whether it may create more. */
|
|
737
|
+
async topics() {
|
|
738
|
+
const r = await this.request("GET", "/v1/trigger/topics");
|
|
739
|
+
return { topics: r.data, canCreate: r.canCreate };
|
|
740
|
+
}
|
|
741
|
+
/**
|
|
742
|
+
* Create a topic. Needs the **Can create topics** permission on the key (`scope_denied` otherwise) and counts against the plan's
|
|
743
|
+
* topic limit (`topic_limit`). Only keys that can send to all topics can have this permission, so the new topic is usable straight away. Not retried automatically:
|
|
744
|
+
* a second attempt would only answer `slug_taken`.
|
|
745
|
+
*/
|
|
746
|
+
async createTopic(t) {
|
|
747
|
+
return await this.parse(
|
|
748
|
+
await this.raw("POST", "/v1/trigger/topics", t)
|
|
749
|
+
);
|
|
750
|
+
}
|
|
751
|
+
/** The phones receiving `topic` (the full `<org>-<slug>` name). The key must be allowed to send to it. */
|
|
752
|
+
async devices(topic) {
|
|
753
|
+
const r = await this.request(
|
|
754
|
+
"GET",
|
|
755
|
+
`/v1/trigger/topics/${encodeURIComponent(topic)}/devices`
|
|
756
|
+
);
|
|
757
|
+
return r.data;
|
|
758
|
+
}
|
|
210
759
|
async send(m) {
|
|
211
760
|
checkRepeat(m.repeat);
|
|
212
761
|
checkRepeat(m.escalate?.repeat);
|
|
@@ -348,13 +897,14 @@ var Relay = class _Relay {
|
|
|
348
897
|
} catch (e) {
|
|
349
898
|
const transient = e instanceof RelayError ? e.status >= 500 || e.status === 0 && e.code === "network" : false;
|
|
350
899
|
if (!transient || attempt >= this.retries) throw e;
|
|
351
|
-
await
|
|
900
|
+
await sleep2(250 * 2 ** attempt++);
|
|
352
901
|
}
|
|
353
902
|
}
|
|
354
903
|
}
|
|
355
|
-
async raw(method, path, body, headers = {}) {
|
|
904
|
+
async raw(method, path, body, headers = {}, signal) {
|
|
356
905
|
try {
|
|
357
906
|
return await this.doFetch(this.base + path, {
|
|
907
|
+
...signal ? { signal } : {},
|
|
358
908
|
method,
|
|
359
909
|
headers: {
|
|
360
910
|
authorization: `Bearer ${this.opts.apiKey}`,
|
|
@@ -462,5 +1012,7 @@ export {
|
|
|
462
1012
|
PayloadTooLargeError,
|
|
463
1013
|
Relay,
|
|
464
1014
|
RelayError,
|
|
1015
|
+
StreamError,
|
|
1016
|
+
Subscription,
|
|
465
1017
|
buildPlaintext
|
|
466
1018
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@phuzle/relay",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Send end-to-end encrypted push notifications through Relay: Node/Bun SDK and `relay` CLI. The message is encrypted on your machine; Relay only routes ciphertext.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"push",
|