@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/cli.js
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
// ../core/src/crypto/consumer.ts
|
|
3
|
+
import { ed25519 as ed255192 } from "@noble/curves/ed25519";
|
|
4
|
+
import { hkdf } from "@noble/hashes/hkdf";
|
|
5
|
+
import { sha256 as sha2562 } from "@noble/hashes/sha2";
|
|
2
6
|
|
|
3
7
|
// ../core/src/encoding.ts
|
|
4
8
|
function b64urlEncode(bytes) {
|
|
@@ -15,6 +19,11 @@ function b64urlDecode(text) {
|
|
|
15
19
|
}
|
|
16
20
|
var enc = new TextEncoder();
|
|
17
21
|
var utf8 = (s) => enc.encode(s);
|
|
22
|
+
var fromUtf8 = (b) => new TextDecoder().decode(b);
|
|
23
|
+
|
|
24
|
+
// ../core/src/crypto/keys.ts
|
|
25
|
+
import { ed25519 } from "@noble/curves/ed25519";
|
|
26
|
+
import { sha256 } from "@noble/hashes/sha2";
|
|
18
27
|
|
|
19
28
|
// ../core/src/crypto/suite.ts
|
|
20
29
|
import { Chacha20Poly1305 } from "@hpke/chacha20poly1305";
|
|
@@ -26,19 +35,71 @@ var suite = new CipherSuite({
|
|
|
26
35
|
aead: new Chacha20Poly1305()
|
|
27
36
|
});
|
|
28
37
|
|
|
38
|
+
// ../core/src/crypto/keys.ts
|
|
39
|
+
function bindingMessage(encPub, idPub) {
|
|
40
|
+
return utf8(`relay-bind-v1
|
|
41
|
+
${b64urlEncode(encPub)}
|
|
42
|
+
${b64urlEncode(idPub)}`);
|
|
43
|
+
}
|
|
44
|
+
function signBinding(idPriv, encPub, idPub) {
|
|
45
|
+
return ed25519.sign(bindingMessage(encPub, idPub), idPriv);
|
|
46
|
+
}
|
|
47
|
+
function verifyBinding(sig, encPub, idPub) {
|
|
48
|
+
try {
|
|
49
|
+
return ed25519.verify(sig, bindingMessage(encPub, idPub), idPub);
|
|
50
|
+
} catch {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ../core/src/crypto/consumer.ts
|
|
56
|
+
var CONSUMER_SECRET_BYTES = 32;
|
|
57
|
+
function generateConsumerSecret() {
|
|
58
|
+
return b64urlEncode(
|
|
59
|
+
crypto.getRandomValues(new Uint8Array(CONSUMER_SECRET_BYTES))
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
var derive = (secret, label) => hkdf(sha2562, secret, void 0, utf8(`relay-consumer-v1/${label}`), 32);
|
|
63
|
+
async function consumerKeysFromSecret(secret) {
|
|
64
|
+
const raw = b64urlDecode(secret.trim());
|
|
65
|
+
if (raw.length !== CONSUMER_SECRET_BYTES)
|
|
66
|
+
throw new Error("A consumer key is 43 characters (32 bytes, base64url)");
|
|
67
|
+
const idPriv = derive(raw, "identity");
|
|
68
|
+
const idPub = ed255192.getPublicKey(idPriv);
|
|
69
|
+
const kp = await suite.kem.deriveKeyPair(derive(raw, "encryption"));
|
|
70
|
+
const encPriv = new Uint8Array(
|
|
71
|
+
await suite.kem.serializePrivateKey(kp.privateKey)
|
|
72
|
+
);
|
|
73
|
+
const encPub = new Uint8Array(
|
|
74
|
+
await suite.kem.serializePublicKey(kp.publicKey)
|
|
75
|
+
);
|
|
76
|
+
return { idPriv, idPub, encPriv, encPub };
|
|
77
|
+
}
|
|
78
|
+
async function consumerRegistration(secret) {
|
|
79
|
+
const k = await consumerKeysFromSecret(secret);
|
|
80
|
+
return {
|
|
81
|
+
idPub: b64urlEncode(k.idPub),
|
|
82
|
+
encPub: b64urlEncode(k.encPub),
|
|
83
|
+
bindSig: b64urlEncode(signBinding(k.idPriv, k.encPub, k.idPub))
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
29
87
|
// ../core/src/crypto/envelope.ts
|
|
30
88
|
var MAX_PLAINTEXT_BYTES = 2048;
|
|
89
|
+
var MAX_STREAM_EVENT_BYTES = 256 * 1024;
|
|
31
90
|
var PayloadTooLargeError = class extends Error {
|
|
32
|
-
constructor(size) {
|
|
33
|
-
super(`Payload is ${size} bytes; limit is ${
|
|
91
|
+
constructor(size, limit = MAX_PLAINTEXT_BYTES) {
|
|
92
|
+
super(`Payload is ${size} bytes; limit is ${limit}`);
|
|
34
93
|
this.name = "PayloadTooLargeError";
|
|
35
94
|
}
|
|
36
95
|
};
|
|
37
96
|
var infoFor = (c) => utf8(`relay/v1\0${c.deviceId}\0${c.messageId}\0${c.topicId}`);
|
|
38
|
-
|
|
97
|
+
function seal(encPub, ctx, plaintext, ekm) {
|
|
98
|
+
return sealUpTo(MAX_PLAINTEXT_BYTES, encPub, ctx, plaintext, ekm);
|
|
99
|
+
}
|
|
100
|
+
async function sealUpTo(limit, encPub, ctx, plaintext, ekm) {
|
|
39
101
|
const pt = utf8(plaintext);
|
|
40
|
-
if (pt.length >
|
|
41
|
-
throw new PayloadTooLargeError(pt.length);
|
|
102
|
+
if (pt.length > limit) throw new PayloadTooLargeError(pt.length, limit);
|
|
42
103
|
const recipientPublicKey = await suite.kem.deserializePublicKey(encPub);
|
|
43
104
|
const sender = await suite.createSenderContext({
|
|
44
105
|
recipientPublicKey,
|
|
@@ -52,26 +113,20 @@ async function seal(encPub, ctx, plaintext, ekm) {
|
|
|
52
113
|
ct: b64urlEncode(ct)
|
|
53
114
|
};
|
|
54
115
|
}
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
function verifyBinding(sig, encPub, idPub) {
|
|
65
|
-
try {
|
|
66
|
-
return ed25519.verify(sig, bindingMessage(encPub, idPub), idPub);
|
|
67
|
-
} catch {
|
|
68
|
-
return false;
|
|
69
|
-
}
|
|
116
|
+
async function open(encPriv, ctx, envelope) {
|
|
117
|
+
const recipientKey = await suite.kem.deserializePrivateKey(encPriv);
|
|
118
|
+
const recipient = await suite.createRecipientContext({
|
|
119
|
+
recipientKey,
|
|
120
|
+
enc: b64urlDecode(envelope.enc),
|
|
121
|
+
info: infoFor(ctx)
|
|
122
|
+
});
|
|
123
|
+
const pt = await recipient.open(b64urlDecode(envelope.ct));
|
|
124
|
+
return fromUtf8(new Uint8Array(pt));
|
|
70
125
|
}
|
|
71
126
|
|
|
72
127
|
// ../core/src/crypto/signing.ts
|
|
73
|
-
import { ed25519 as
|
|
74
|
-
import { sha256 as
|
|
128
|
+
import { ed25519 as ed255193 } from "@noble/curves/ed25519";
|
|
129
|
+
import { sha256 as sha2563 } from "@noble/hashes/sha2";
|
|
75
130
|
|
|
76
131
|
// ../core/src/lib/glyphs.ts
|
|
77
132
|
var GLYPHS = [
|
|
@@ -133,6 +188,415 @@ function ulid(now = Date.now()) {
|
|
|
133
188
|
return time + rand;
|
|
134
189
|
}
|
|
135
190
|
|
|
191
|
+
// src/stream.ts
|
|
192
|
+
import { gunzipSync } from "zlib";
|
|
193
|
+
var StreamError = class extends Error {
|
|
194
|
+
constructor(message, status, code) {
|
|
195
|
+
super(message);
|
|
196
|
+
this.status = status;
|
|
197
|
+
this.code = code;
|
|
198
|
+
this.name = "StreamError";
|
|
199
|
+
}
|
|
200
|
+
status;
|
|
201
|
+
code;
|
|
202
|
+
};
|
|
203
|
+
var sleep = (ms, signal) => new Promise((resolve) => {
|
|
204
|
+
const t = setTimeout(resolve, ms);
|
|
205
|
+
signal?.addEventListener(
|
|
206
|
+
"abort",
|
|
207
|
+
() => {
|
|
208
|
+
clearTimeout(t);
|
|
209
|
+
resolve();
|
|
210
|
+
},
|
|
211
|
+
{ once: true }
|
|
212
|
+
);
|
|
213
|
+
});
|
|
214
|
+
function binParser() {
|
|
215
|
+
let buf = new Uint8Array(0);
|
|
216
|
+
const dec = new TextDecoder();
|
|
217
|
+
const b64 = (b) => Buffer.from(b).toString("base64url");
|
|
218
|
+
const NAMES = { 1: "ready", 3: "gap", 4: "bye" };
|
|
219
|
+
return (chunk) => {
|
|
220
|
+
if (buf.length === 0) buf = chunk;
|
|
221
|
+
else {
|
|
222
|
+
const next = new Uint8Array(buf.length + chunk.length);
|
|
223
|
+
next.set(buf);
|
|
224
|
+
next.set(chunk, buf.length);
|
|
225
|
+
buf = next;
|
|
226
|
+
}
|
|
227
|
+
const out = [];
|
|
228
|
+
for (; ; ) {
|
|
229
|
+
if (buf.length < 5) break;
|
|
230
|
+
const dv = new DataView(
|
|
231
|
+
buf.buffer,
|
|
232
|
+
buf.byteOffset,
|
|
233
|
+
buf.byteLength
|
|
234
|
+
);
|
|
235
|
+
const len = dv.getUint32(1);
|
|
236
|
+
if (buf.length < 5 + len) break;
|
|
237
|
+
const kind = buf[0];
|
|
238
|
+
const body = buf.subarray(5, 5 + len);
|
|
239
|
+
buf = buf.subarray(5 + len);
|
|
240
|
+
if (kind === 5) out.push({ event: ":", data: "" });
|
|
241
|
+
else if (kind === 2) {
|
|
242
|
+
const bd = new DataView(
|
|
243
|
+
body.buffer,
|
|
244
|
+
body.byteOffset,
|
|
245
|
+
body.byteLength
|
|
246
|
+
);
|
|
247
|
+
const metaLen = bd.getUint32(0);
|
|
248
|
+
const meta = JSON.parse(dec.decode(body.subarray(4, 4 + metaLen)));
|
|
249
|
+
const encLen = body[4 + metaLen];
|
|
250
|
+
const encBytes = body.subarray(5 + metaLen, 5 + metaLen + encLen);
|
|
251
|
+
const ct = body.subarray(5 + metaLen + encLen);
|
|
252
|
+
out.push({
|
|
253
|
+
event: "event",
|
|
254
|
+
data: JSON.stringify({ ...meta, enc: b64(encBytes), ct: b64(ct) })
|
|
255
|
+
});
|
|
256
|
+
} else if (NAMES[kind])
|
|
257
|
+
out.push({ event: NAMES[kind], data: dec.decode(body) });
|
|
258
|
+
}
|
|
259
|
+
return out;
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
function sseParser() {
|
|
263
|
+
let buf = "";
|
|
264
|
+
return (chunk) => {
|
|
265
|
+
buf += chunk.replace(/\r\n/g, "\n");
|
|
266
|
+
const out = [];
|
|
267
|
+
let i;
|
|
268
|
+
while ((i = buf.indexOf("\n\n")) >= 0) {
|
|
269
|
+
const block = buf.slice(0, i);
|
|
270
|
+
buf = buf.slice(i + 2);
|
|
271
|
+
let event = "message";
|
|
272
|
+
let id;
|
|
273
|
+
const data = [];
|
|
274
|
+
let comment = false;
|
|
275
|
+
for (const line of block.split("\n")) {
|
|
276
|
+
if (line.startsWith(":")) comment = true;
|
|
277
|
+
else if (line.startsWith("event:")) event = line.slice(6).trim();
|
|
278
|
+
else if (line.startsWith("id:")) id = line.slice(3).trim();
|
|
279
|
+
else if (line.startsWith("data:"))
|
|
280
|
+
data.push(line.slice(5).replace(/^ /, ""));
|
|
281
|
+
}
|
|
282
|
+
if (comment && data.length === 0 && event === "message")
|
|
283
|
+
out.push({ event: ":", data: "" });
|
|
284
|
+
else out.push({ event, id, data: data.join("\n") });
|
|
285
|
+
}
|
|
286
|
+
return out;
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
var Subscription = class {
|
|
290
|
+
constructor(t, topic, name, secret, tuning = {}, o = { name }) {
|
|
291
|
+
this.t = t;
|
|
292
|
+
this.topic = topic;
|
|
293
|
+
this.name = name;
|
|
294
|
+
this.secret = secret;
|
|
295
|
+
this.tuning = tuning;
|
|
296
|
+
this.o = o;
|
|
297
|
+
}
|
|
298
|
+
t;
|
|
299
|
+
topic;
|
|
300
|
+
name;
|
|
301
|
+
secret;
|
|
302
|
+
tuning;
|
|
303
|
+
o;
|
|
304
|
+
queue = [];
|
|
305
|
+
waiter = null;
|
|
306
|
+
failure = null;
|
|
307
|
+
ctl = new AbortController();
|
|
308
|
+
stopped = false;
|
|
309
|
+
_state = "connecting";
|
|
310
|
+
listeners = { open: [], reconnecting: [], error: [], gap: [] };
|
|
311
|
+
firstConnect = true;
|
|
312
|
+
current = null;
|
|
313
|
+
settledEvents = /* @__PURE__ */ new WeakSet();
|
|
314
|
+
acks = [];
|
|
315
|
+
nacks = [];
|
|
316
|
+
flushing = null;
|
|
317
|
+
flushTimer = null;
|
|
318
|
+
flushWaiters = [];
|
|
319
|
+
started = null;
|
|
320
|
+
get state() {
|
|
321
|
+
return this._state;
|
|
322
|
+
}
|
|
323
|
+
on(ev, fn) {
|
|
324
|
+
this.listeners[ev].push(fn);
|
|
325
|
+
return this;
|
|
326
|
+
}
|
|
327
|
+
emit(ev, v) {
|
|
328
|
+
for (const fn of this.listeners[ev]) {
|
|
329
|
+
try {
|
|
330
|
+
fn(v);
|
|
331
|
+
} catch {
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
/** Resolves once the consumer is registered and the stream is open (or rejects if it can't be). */
|
|
336
|
+
ready() {
|
|
337
|
+
this.start();
|
|
338
|
+
return new Promise((resolve, reject) => {
|
|
339
|
+
if (this._state === "open") return resolve();
|
|
340
|
+
this.on("open", () => resolve());
|
|
341
|
+
this.on("error", (e) => reject(e));
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
close() {
|
|
345
|
+
this.stopped = true;
|
|
346
|
+
this._state = "closed";
|
|
347
|
+
this.ctl.abort();
|
|
348
|
+
this.waiter?.();
|
|
349
|
+
if (this.current && !this.settledEvents.has(this.current))
|
|
350
|
+
this.nacks.push(this.current.seq);
|
|
351
|
+
this.current = null;
|
|
352
|
+
for (const e of this.queue) this.nacks.push(e.seq);
|
|
353
|
+
this.queue = [];
|
|
354
|
+
void this.flush();
|
|
355
|
+
}
|
|
356
|
+
/** Acknowledgements and hand-backs are sent in one request after a short pause (or at 50), not one request per event. */
|
|
357
|
+
settle(seq, kind) {
|
|
358
|
+
(kind === "ack" ? this.acks : this.nacks).push(seq);
|
|
359
|
+
return new Promise((resolve, reject) => {
|
|
360
|
+
this.flushWaiters.push({ resolve, reject });
|
|
361
|
+
if (this.acks.length + this.nacks.length >= 50) void this.flush();
|
|
362
|
+
else this.flushTimer ??= setTimeout(() => void this.flush(), 25);
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
async flush() {
|
|
366
|
+
if (this.flushTimer) clearTimeout(this.flushTimer);
|
|
367
|
+
this.flushTimer = null;
|
|
368
|
+
if (this.flushing) await this.flushing.catch(() => {
|
|
369
|
+
});
|
|
370
|
+
if (this.acks.length === 0 && this.nacks.length === 0) return;
|
|
371
|
+
const body = {
|
|
372
|
+
consumer: this.name,
|
|
373
|
+
...this.acks.length ? { seqs: this.acks } : {},
|
|
374
|
+
...this.nacks.length ? { nack: this.nacks } : {}
|
|
375
|
+
};
|
|
376
|
+
const waiters = this.flushWaiters;
|
|
377
|
+
this.acks = [];
|
|
378
|
+
this.nacks = [];
|
|
379
|
+
this.flushWaiters = [];
|
|
380
|
+
this.flushing = this.t.ack(this.topic, body).then(() => waiters.forEach((w) => w.resolve())).catch((e) => {
|
|
381
|
+
const err = e instanceof Error ? e : new Error(String(e));
|
|
382
|
+
waiters.forEach((w) => w.reject(err));
|
|
383
|
+
this.emit("error", err);
|
|
384
|
+
});
|
|
385
|
+
await this.flushing;
|
|
386
|
+
}
|
|
387
|
+
[Symbol.asyncIterator]() {
|
|
388
|
+
this.start();
|
|
389
|
+
return {
|
|
390
|
+
next: async () => {
|
|
391
|
+
if (this.current && !this.settledEvents.has(this.current) && (this.o.ack ?? "auto") === "auto")
|
|
392
|
+
void this.current.ack().catch(() => {
|
|
393
|
+
});
|
|
394
|
+
for (; ; ) {
|
|
395
|
+
const e = this.queue.shift();
|
|
396
|
+
if (e) {
|
|
397
|
+
this.current = e;
|
|
398
|
+
return { value: e, done: false };
|
|
399
|
+
}
|
|
400
|
+
if (this.failure) throw this.failure;
|
|
401
|
+
if (this.stopped) return { value: void 0, done: true };
|
|
402
|
+
await new Promise((r) => {
|
|
403
|
+
this.waiter = r;
|
|
404
|
+
});
|
|
405
|
+
this.waiter = null;
|
|
406
|
+
}
|
|
407
|
+
},
|
|
408
|
+
return: async () => {
|
|
409
|
+
this.close();
|
|
410
|
+
return { value: void 0, done: true };
|
|
411
|
+
}
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
start() {
|
|
415
|
+
if (!this.started)
|
|
416
|
+
this.started = this.run().catch(
|
|
417
|
+
(e) => this.fail(e instanceof Error ? e : new Error(String(e)))
|
|
418
|
+
);
|
|
419
|
+
}
|
|
420
|
+
fail(e) {
|
|
421
|
+
if (this.stopped) return;
|
|
422
|
+
this.failure = e;
|
|
423
|
+
this._state = "closed";
|
|
424
|
+
this.emit("error", e);
|
|
425
|
+
this.waiter?.();
|
|
426
|
+
}
|
|
427
|
+
push(e) {
|
|
428
|
+
this.queue.push(e);
|
|
429
|
+
this.waiter?.();
|
|
430
|
+
}
|
|
431
|
+
async run() {
|
|
432
|
+
const keys = await consumerKeysFromSecret(this.secret);
|
|
433
|
+
const registration = {
|
|
434
|
+
...await consumerRegistration(this.secret),
|
|
435
|
+
options: this.o.filter ?? {}
|
|
436
|
+
};
|
|
437
|
+
let attempt = 0;
|
|
438
|
+
let ids = null;
|
|
439
|
+
while (!this.stopped) {
|
|
440
|
+
try {
|
|
441
|
+
ids = await this.t.register(this.topic, this.name, registration);
|
|
442
|
+
const res = await this.t.open(
|
|
443
|
+
this.topic,
|
|
444
|
+
this.name,
|
|
445
|
+
this.query(),
|
|
446
|
+
this.ctl.signal
|
|
447
|
+
);
|
|
448
|
+
if (!res.body)
|
|
449
|
+
throw new StreamError(
|
|
450
|
+
"The stream had no body",
|
|
451
|
+
res.status,
|
|
452
|
+
"no_body"
|
|
453
|
+
);
|
|
454
|
+
attempt = 0;
|
|
455
|
+
this._state = "open";
|
|
456
|
+
const bin = (res.headers.get("content-type") ?? "").includes(
|
|
457
|
+
"vnd.relay.stream"
|
|
458
|
+
);
|
|
459
|
+
const why = await this.read(res.body, keys.encPriv, ids, bin);
|
|
460
|
+
if (this.stopped) return;
|
|
461
|
+
this._state = "reconnecting";
|
|
462
|
+
this.emit("reconnecting", { attempt: 1, reason: why });
|
|
463
|
+
if (why === "revoked")
|
|
464
|
+
throw new StreamError(
|
|
465
|
+
"This consumer was removed",
|
|
466
|
+
0,
|
|
467
|
+
"consumer_revoked"
|
|
468
|
+
);
|
|
469
|
+
continue;
|
|
470
|
+
} catch (e) {
|
|
471
|
+
if (this.stopped) return;
|
|
472
|
+
const status = e.status ?? 0;
|
|
473
|
+
if (e instanceof StreamError && (e.code === "consumer_revoked" || e.status >= 400 && e.status < 500 && e.status !== 429))
|
|
474
|
+
throw e;
|
|
475
|
+
if (status >= 400 && status < 500 && status !== 429 && status !== 0)
|
|
476
|
+
throw e;
|
|
477
|
+
attempt++;
|
|
478
|
+
this._state = "reconnecting";
|
|
479
|
+
this.emit("reconnecting", {
|
|
480
|
+
attempt,
|
|
481
|
+
reason: e instanceof Error ? e.message : "error"
|
|
482
|
+
});
|
|
483
|
+
const cap = this.tuning.maxBackoffMs ?? 3e4;
|
|
484
|
+
const base = Math.min(cap, 500 * 2 ** Math.min(attempt, 7));
|
|
485
|
+
await sleep(base * (0.7 + Math.random() * 0.6), this.ctl.signal);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
/** The first connection starts where the caller asked; every later one resumes from what is still unacknowledged. */
|
|
490
|
+
query() {
|
|
491
|
+
const q = {};
|
|
492
|
+
if (this.firstConnect && this.o.from !== void 0) q.from = this.o.from;
|
|
493
|
+
this.firstConnect = false;
|
|
494
|
+
q.format = this.o.format ?? "bin";
|
|
495
|
+
if (this.o.prefetch) q.prefetch = this.o.prefetch;
|
|
496
|
+
if (this.o.lease) q.lease = this.o.lease;
|
|
497
|
+
return q;
|
|
498
|
+
}
|
|
499
|
+
/** Reads one connection until it ends. Returns why: `recycle`, `revoked`, `dropped`, or `silent` (heartbeat watchdog). */
|
|
500
|
+
async read(body, encPriv, ids, bin = false) {
|
|
501
|
+
const reader = body.getReader();
|
|
502
|
+
const dec = new TextDecoder();
|
|
503
|
+
const parse2 = sseParser();
|
|
504
|
+
const parseBin = binParser();
|
|
505
|
+
const timeout = this.tuning.heartbeatTimeoutMs ?? 45e3;
|
|
506
|
+
let why = "dropped";
|
|
507
|
+
try {
|
|
508
|
+
for (; ; ) {
|
|
509
|
+
const chunk = await Promise.race([
|
|
510
|
+
reader.read(),
|
|
511
|
+
sleep(timeout, this.ctl.signal).then(() => "silent")
|
|
512
|
+
]);
|
|
513
|
+
if (this.stopped) return "closed";
|
|
514
|
+
if (chunk === "silent") {
|
|
515
|
+
why = "silent";
|
|
516
|
+
break;
|
|
517
|
+
}
|
|
518
|
+
if (chunk.done) break;
|
|
519
|
+
for (const m of bin ? parseBin(chunk.value) : parse2(dec.decode(chunk.value, { stream: true }))) {
|
|
520
|
+
if (m.event === "ready") this.emit("open");
|
|
521
|
+
else if (m.event === "event")
|
|
522
|
+
await this.deliver(m.data, encPriv, ids);
|
|
523
|
+
else if (m.event === "gap") this.emit("gap", JSON.parse(m.data));
|
|
524
|
+
else if (m.event === "bye") {
|
|
525
|
+
why = JSON.parse(m.data || "{}").reason ?? "recycle";
|
|
526
|
+
return why;
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
} finally {
|
|
531
|
+
try {
|
|
532
|
+
await reader.cancel();
|
|
533
|
+
} catch {
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
return why;
|
|
537
|
+
}
|
|
538
|
+
async deliver(data, encPriv, ids) {
|
|
539
|
+
const w = JSON.parse(data);
|
|
540
|
+
let p;
|
|
541
|
+
try {
|
|
542
|
+
p = JSON.parse(
|
|
543
|
+
await open(
|
|
544
|
+
encPriv,
|
|
545
|
+
{ messageId: w.id, topicId: ids.topicId, deviceId: ids.id },
|
|
546
|
+
w
|
|
547
|
+
)
|
|
548
|
+
);
|
|
549
|
+
const z = p;
|
|
550
|
+
if (z.z === "gzip" && z.d)
|
|
551
|
+
p = JSON.parse(
|
|
552
|
+
gunzipSync(Buffer.from(z.d, "base64")).toString("utf8")
|
|
553
|
+
);
|
|
554
|
+
} catch {
|
|
555
|
+
this.emit(
|
|
556
|
+
"error",
|
|
557
|
+
new StreamError(
|
|
558
|
+
"An event could not be opened with this consumer key",
|
|
559
|
+
0,
|
|
560
|
+
"undecryptable"
|
|
561
|
+
)
|
|
562
|
+
);
|
|
563
|
+
void this.settle(w.seq, "ack").catch(() => {
|
|
564
|
+
});
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
const ev = {
|
|
568
|
+
id: p.id,
|
|
569
|
+
seq: w.seq,
|
|
570
|
+
attempt: w.attempt,
|
|
571
|
+
source: p.source,
|
|
572
|
+
type: p.type,
|
|
573
|
+
deliveryId: p.deliveryId,
|
|
574
|
+
receivedAt: p.receivedAt,
|
|
575
|
+
headers: p.headers,
|
|
576
|
+
contentType: p.contentType,
|
|
577
|
+
body: p.body,
|
|
578
|
+
bodyBytes: p.bodyBytes,
|
|
579
|
+
truncated: p.truncated === true,
|
|
580
|
+
projected: p.projected === true,
|
|
581
|
+
json: () => {
|
|
582
|
+
if (p.body == null) throw new Error("This event has no body");
|
|
583
|
+
return JSON.parse(p.body);
|
|
584
|
+
},
|
|
585
|
+
ack: () => {
|
|
586
|
+
if (this.settledEvents.has(ev)) return Promise.resolve();
|
|
587
|
+
this.settledEvents.add(ev);
|
|
588
|
+
return this.settle(w.seq, "ack");
|
|
589
|
+
},
|
|
590
|
+
nack: () => {
|
|
591
|
+
if (this.settledEvents.has(ev)) return Promise.resolve();
|
|
592
|
+
this.settledEvents.add(ev);
|
|
593
|
+
return this.settle(w.seq, "nack");
|
|
594
|
+
}
|
|
595
|
+
};
|
|
596
|
+
this.push(ev);
|
|
597
|
+
}
|
|
598
|
+
};
|
|
599
|
+
|
|
136
600
|
// src/index.ts
|
|
137
601
|
var deliverAtIso = (m) => {
|
|
138
602
|
if (m.deliverAt !== void 0 && m.delaySeconds !== void 0)
|
|
@@ -170,7 +634,7 @@ var RelayError = class extends Error {
|
|
|
170
634
|
retryAfterSeconds;
|
|
171
635
|
};
|
|
172
636
|
var DEFAULT_URL = "https://relay.phuzle.com";
|
|
173
|
-
var
|
|
637
|
+
var sleep2 = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
174
638
|
var Relay = class _Relay {
|
|
175
639
|
constructor(opts) {
|
|
176
640
|
this.opts = opts;
|
|
@@ -209,6 +673,90 @@ var Relay = class _Relay {
|
|
|
209
673
|
}
|
|
210
674
|
});
|
|
211
675
|
}
|
|
676
|
+
/** A new consumer key for `subscribe()`: 43 characters, shown once. Store it as `RELAY_CONSUMER_KEY`; Relay never sees it and cannot recover it. */
|
|
677
|
+
static generateConsumerKey() {
|
|
678
|
+
return generateConsumerSecret();
|
|
679
|
+
}
|
|
680
|
+
/** One topic by its full name (`<org>-<slug>`): send to it, list its phones, or subscribe to its events from a server. */
|
|
681
|
+
topic(name) {
|
|
682
|
+
return {
|
|
683
|
+
name,
|
|
684
|
+
send: (m) => this.send({ ...m, topic: name }),
|
|
685
|
+
devices: () => this.devices(name),
|
|
686
|
+
subscribe: (o) => this.subscribe(name, o)
|
|
687
|
+
};
|
|
688
|
+
}
|
|
689
|
+
/**
|
|
690
|
+
* Receive a topic's events on a server (Relay Streams): webhooks Relay received for this topic, delivered over one long-lived
|
|
691
|
+
* connection, sealed to your consumer key so only this process can open them. Needs an API key with “Can receive events” for the
|
|
692
|
+
* topic. Reconnects by itself, and **at-least-once**: events wait for you (for your plan's retention) until acknowledged, so a
|
|
693
|
+
* restart loses nothing; repeats are possible, so use `ev.id` to ignore ones you have handled.
|
|
694
|
+
*
|
|
695
|
+
* const sub = relay.topic("acme-github-api").subscribe({ name: "deploy-bot" }); // key from RELAY_CONSUMER_KEY
|
|
696
|
+
* for await (const ev of sub) { if (ev.type === "push") deploy(ev.json()); }
|
|
697
|
+
*/
|
|
698
|
+
subscribe(topic, o) {
|
|
699
|
+
const key = (o.key ?? process.env.RELAY_CONSUMER_KEY ?? "").trim();
|
|
700
|
+
if (!key)
|
|
701
|
+
throw new RelayError(
|
|
702
|
+
"A consumer key is required: pass `key` or set RELAY_CONSUMER_KEY (create one with Relay.generateConsumerKey())",
|
|
703
|
+
0,
|
|
704
|
+
"consumer_key_required"
|
|
705
|
+
);
|
|
706
|
+
const t = {
|
|
707
|
+
register: async (tp, name, body) => await this.request(
|
|
708
|
+
"PUT",
|
|
709
|
+
`/v1/stream/${encodeURIComponent(tp)}/consumers/${encodeURIComponent(name)}`,
|
|
710
|
+
body
|
|
711
|
+
),
|
|
712
|
+
ack: async (tp, body) => {
|
|
713
|
+
await this.parse(
|
|
714
|
+
await this.raw(
|
|
715
|
+
"POST",
|
|
716
|
+
`/v1/stream/${encodeURIComponent(tp)}/ack`,
|
|
717
|
+
body
|
|
718
|
+
)
|
|
719
|
+
);
|
|
720
|
+
},
|
|
721
|
+
open: async (tp, name, query, signal) => {
|
|
722
|
+
const qs = new URLSearchParams({ consumer: name });
|
|
723
|
+
for (const [k, v] of Object.entries(query)) qs.set(k, String(v));
|
|
724
|
+
const res = await this.raw(
|
|
725
|
+
"GET",
|
|
726
|
+
`/v1/stream/${encodeURIComponent(tp)}/events?${qs}`,
|
|
727
|
+
void 0,
|
|
728
|
+
{ accept: "text/event-stream" },
|
|
729
|
+
signal
|
|
730
|
+
);
|
|
731
|
+
if (!res.ok) await this.parse(res);
|
|
732
|
+
return res;
|
|
733
|
+
}
|
|
734
|
+
};
|
|
735
|
+
return new Subscription(t, topic, o.name, key, {}, o);
|
|
736
|
+
}
|
|
737
|
+
/** The topics this key may send to (its `trigger:*` / `trigger:<slug>` permissions), and whether it may create more. */
|
|
738
|
+
async topics() {
|
|
739
|
+
const r = await this.request("GET", "/v1/trigger/topics");
|
|
740
|
+
return { topics: r.data, canCreate: r.canCreate };
|
|
741
|
+
}
|
|
742
|
+
/**
|
|
743
|
+
* Create a topic. Needs the **Can create topics** permission on the key (`scope_denied` otherwise) and counts against the plan's
|
|
744
|
+
* 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:
|
|
745
|
+
* a second attempt would only answer `slug_taken`.
|
|
746
|
+
*/
|
|
747
|
+
async createTopic(t) {
|
|
748
|
+
return await this.parse(
|
|
749
|
+
await this.raw("POST", "/v1/trigger/topics", t)
|
|
750
|
+
);
|
|
751
|
+
}
|
|
752
|
+
/** The phones receiving `topic` (the full `<org>-<slug>` name). The key must be allowed to send to it. */
|
|
753
|
+
async devices(topic) {
|
|
754
|
+
const r = await this.request(
|
|
755
|
+
"GET",
|
|
756
|
+
`/v1/trigger/topics/${encodeURIComponent(topic)}/devices`
|
|
757
|
+
);
|
|
758
|
+
return r.data;
|
|
759
|
+
}
|
|
212
760
|
async send(m) {
|
|
213
761
|
checkRepeat(m.repeat);
|
|
214
762
|
checkRepeat(m.escalate?.repeat);
|
|
@@ -350,13 +898,14 @@ var Relay = class _Relay {
|
|
|
350
898
|
} catch (e) {
|
|
351
899
|
const transient = e instanceof RelayError ? e.status >= 500 || e.status === 0 && e.code === "network" : false;
|
|
352
900
|
if (!transient || attempt >= this.retries) throw e;
|
|
353
|
-
await
|
|
901
|
+
await sleep2(250 * 2 ** attempt++);
|
|
354
902
|
}
|
|
355
903
|
}
|
|
356
904
|
}
|
|
357
|
-
async raw(method, path, body, headers = {}) {
|
|
905
|
+
async raw(method, path, body, headers = {}, signal) {
|
|
358
906
|
try {
|
|
359
907
|
return await this.doFetch(this.base + path, {
|
|
908
|
+
...signal ? { signal } : {},
|
|
360
909
|
method,
|
|
361
910
|
headers: {
|
|
362
911
|
authorization: `Bearer ${this.opts.apiKey}`,
|
|
@@ -462,7 +1011,16 @@ function buildPlaintext(m) {
|
|
|
462
1011
|
}
|
|
463
1012
|
|
|
464
1013
|
// src/cli.ts
|
|
465
|
-
var USAGE = `relay
|
|
1014
|
+
var USAGE = `relay listen --topic <org-topic> [--name <consumer>] [--forward <url>] [--from last-acked|latest|<seq>] [--type <event>]... [--json] [--count <n>]
|
|
1015
|
+
receive the topic's webhooks on this machine (Relay Streams); with --forward, POST each
|
|
1016
|
+
one to a local URL with the original headers: a webhook tunnel with no public address.
|
|
1017
|
+
Events wait for you while you are away; one is marked handled once printed (or, with
|
|
1018
|
+
--forward, once your server answers below 500) and comes back after 30 s otherwise.
|
|
1019
|
+
relay key print a new consumer key (43 characters; keep it secret)
|
|
1020
|
+
relay topics [--json] list the topics this API key can send to (and whether it may create more)
|
|
1021
|
+
relay topics create <slug> --name <text> [--description <text>] create a topic (the key needs "Can create topics")
|
|
1022
|
+
relay devices --topic <org-topic> [--json] list the phones receiving a topic
|
|
1023
|
+
relay send --topic <org-topic> --title <text> [--body <text>] [--priority min|low|default|high|urgent]
|
|
466
1024
|
[--url <https url>] [--image <https url>] [--icon <name>] [--tag <t>]... [--group <g>] [--ttl <seconds>]
|
|
467
1025
|
[--api-url <https://your-relay>] [--action "Label=METHOD https://url"]... [--link "Label=https://url"]...
|
|
468
1026
|
(up to 3 buttons in total. --action sends a request from the phone; add "!" after the label for destructive: "Reboot!=POST \u2026".
|
|
@@ -521,15 +1079,76 @@ function link(spec) {
|
|
|
521
1079
|
if (!url) throw new Error(`Bad --link "${spec}": missing URL`);
|
|
522
1080
|
return { label: spec.slice(0, eq).trim(), link: url };
|
|
523
1081
|
}
|
|
1082
|
+
var COMMANDS = ["send", "topics", "devices", "listen", "key"];
|
|
524
1083
|
async function main(argv, env = process.env, out = console) {
|
|
525
1084
|
const [cmd, ...rest] = argv;
|
|
526
|
-
if (cmd
|
|
1085
|
+
if (!COMMANDS.includes(cmd ?? "") || rest.includes("--help")) {
|
|
527
1086
|
out.log(USAGE);
|
|
528
|
-
return cmd
|
|
1087
|
+
return !cmd || COMMANDS.includes(cmd) ? 0 : 1;
|
|
529
1088
|
}
|
|
530
1089
|
const f = parse(rest);
|
|
531
1090
|
const one = (k) => f.get(k)?.[0];
|
|
532
1091
|
try {
|
|
1092
|
+
if (cmd === "key") {
|
|
1093
|
+
out.log(Relay.generateConsumerKey());
|
|
1094
|
+
return 0;
|
|
1095
|
+
}
|
|
1096
|
+
if (cmd === "listen") return listen(f, one, env, out);
|
|
1097
|
+
if (cmd === "topics" || cmd === "devices") {
|
|
1098
|
+
const relay = Relay.fromEnv(env, { baseUrl: one("api-url") });
|
|
1099
|
+
const json = f.has("json");
|
|
1100
|
+
if (cmd === "devices") {
|
|
1101
|
+
const topic2 = one("topic");
|
|
1102
|
+
if (!topic2) throw new Error("--topic is required");
|
|
1103
|
+
const list = await relay.devices(topic2);
|
|
1104
|
+
if (json) out.log(JSON.stringify(list, null, 2));
|
|
1105
|
+
else if (list.length === 0)
|
|
1106
|
+
out.log(`no devices are receiving ${topic2}`);
|
|
1107
|
+
else
|
|
1108
|
+
for (const d of list)
|
|
1109
|
+
out.log(
|
|
1110
|
+
`${d.nickname ?? d.name ?? d.model ?? d.id} ${d.model ?? ""} app ${d.appVersion ?? "?"} last seen ${d.lastSeenAt ?? "never"} (${d.id})`.replace(
|
|
1111
|
+
/ {2,}/g,
|
|
1112
|
+
" "
|
|
1113
|
+
)
|
|
1114
|
+
);
|
|
1115
|
+
return 0;
|
|
1116
|
+
}
|
|
1117
|
+
const positional = [];
|
|
1118
|
+
for (const a of rest) {
|
|
1119
|
+
if (a.startsWith("--")) break;
|
|
1120
|
+
positional.push(a);
|
|
1121
|
+
}
|
|
1122
|
+
if (positional[0] === "create") {
|
|
1123
|
+
const slug = positional[1];
|
|
1124
|
+
if (!slug || !one("name"))
|
|
1125
|
+
throw new Error(
|
|
1126
|
+
"Usage: relay topics create <slug> --name <text> [--description <text>]"
|
|
1127
|
+
);
|
|
1128
|
+
const t = await relay.createTopic({
|
|
1129
|
+
slug,
|
|
1130
|
+
name: one("name"),
|
|
1131
|
+
description: one("description")
|
|
1132
|
+
});
|
|
1133
|
+
out.log(
|
|
1134
|
+
json ? JSON.stringify(t, null, 2) : `created ${t.fullName} (send to it with --topic ${t.fullName})`
|
|
1135
|
+
);
|
|
1136
|
+
return 0;
|
|
1137
|
+
}
|
|
1138
|
+
if (positional.length > 0)
|
|
1139
|
+
throw new Error(`Unknown: relay topics ${positional[0]}`);
|
|
1140
|
+
const { topics, canCreate } = await relay.topics();
|
|
1141
|
+
if (json) out.log(JSON.stringify({ topics, canCreate }, null, 2));
|
|
1142
|
+
else {
|
|
1143
|
+
if (topics.length === 0) out.log("this key can't send to any topic");
|
|
1144
|
+
for (const t of topics)
|
|
1145
|
+
out.log(
|
|
1146
|
+
`${t.fullName} ${t.devices} device${t.devices === 1 ? "" : "s"} ${t.name}`
|
|
1147
|
+
);
|
|
1148
|
+
out.log(canCreate ? "can create topics: yes" : "can create topics: no");
|
|
1149
|
+
}
|
|
1150
|
+
return 0;
|
|
1151
|
+
}
|
|
533
1152
|
const topic = one("topic");
|
|
534
1153
|
if (!topic || !one("title"))
|
|
535
1154
|
throw new Error("--topic and --title are required");
|
|
@@ -577,7 +1196,97 @@ async function main(argv, env = process.env, out = console) {
|
|
|
577
1196
|
}
|
|
578
1197
|
}
|
|
579
1198
|
if (import.meta.main) process.exit(await main(process.argv.slice(2)));
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
};
|
|
1199
|
+
async function keyFile(env, topic, name) {
|
|
1200
|
+
const { mkdir, readFile, writeFile } = await import("fs/promises");
|
|
1201
|
+
const { homedir } = await import("os");
|
|
1202
|
+
const { join } = await import("path");
|
|
1203
|
+
const dir = join(env.RELAY_HOME ?? join(homedir(), ".relay"), "consumers");
|
|
1204
|
+
const file = join(dir, `${topic}.${name}.key`);
|
|
1205
|
+
try {
|
|
1206
|
+
return (await readFile(file, "utf8")).trim();
|
|
1207
|
+
} catch {
|
|
1208
|
+
await mkdir(dir, { recursive: true, mode: 448 });
|
|
1209
|
+
const key = Relay.generateConsumerKey();
|
|
1210
|
+
await writeFile(file, `${key}
|
|
1211
|
+
`, { mode: 384 });
|
|
1212
|
+
return key;
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
async function listen(f, one, env, out) {
|
|
1216
|
+
const topic = one("topic");
|
|
1217
|
+
if (!topic) throw new Error("--topic is required");
|
|
1218
|
+
const name = one("name") ?? "cli";
|
|
1219
|
+
const forward = one("forward");
|
|
1220
|
+
if (forward && !/^https?:\/\//i.test(forward))
|
|
1221
|
+
throw new Error("--forward must be an http(s) URL");
|
|
1222
|
+
const max = one("count") ? Number(one("count")) : Infinity;
|
|
1223
|
+
const key = one("key") ?? env.RELAY_CONSUMER_KEY ?? await keyFile(env, topic, name);
|
|
1224
|
+
const relay = Relay.fromEnv(env, { baseUrl: one("api-url") });
|
|
1225
|
+
const fromFlag = one("from");
|
|
1226
|
+
const from = fromFlag === void 0 || fromFlag === "last-acked" ? void 0 : fromFlag === "latest" ? "latest" : Number(fromFlag);
|
|
1227
|
+
if (typeof from === "number" && !(Number.isInteger(from) && from > 0))
|
|
1228
|
+
throw new Error("--from is last-acked, latest or a sequence number");
|
|
1229
|
+
const sub = relay.subscribe(topic, {
|
|
1230
|
+
name,
|
|
1231
|
+
key,
|
|
1232
|
+
ack: "manual",
|
|
1233
|
+
...f.get("type")?.length ? { filter: { types: f.get("type") } } : {},
|
|
1234
|
+
...from !== void 0 ? { from } : {}
|
|
1235
|
+
});
|
|
1236
|
+
sub.on("reconnecting", (i) => out.error(`relay: reconnecting (${i.reason})`));
|
|
1237
|
+
sub.on(
|
|
1238
|
+
"gap",
|
|
1239
|
+
(g) => out.error(
|
|
1240
|
+
`relay: ${g.count} event${g.count === 1 ? "" : "s"} (#${g.fromSeq}\u2013${g.toSeq}) were dropped before you took them (${g.reason})`
|
|
1241
|
+
)
|
|
1242
|
+
);
|
|
1243
|
+
sub.on("error", (e) => {
|
|
1244
|
+
if (e.code === "undecryptable")
|
|
1245
|
+
out.error(`relay: ${e.message}`);
|
|
1246
|
+
});
|
|
1247
|
+
const stop = () => sub.close();
|
|
1248
|
+
process.once("SIGINT", stop);
|
|
1249
|
+
process.once("SIGTERM", stop);
|
|
1250
|
+
try {
|
|
1251
|
+
await sub.ready();
|
|
1252
|
+
out.error(
|
|
1253
|
+
`relay: listening to ${topic} as "${name}"${forward ? ` \u2192 ${forward}` : ""}. Ctrl-C to stop.`
|
|
1254
|
+
);
|
|
1255
|
+
let n = 0;
|
|
1256
|
+
for await (const ev of sub) {
|
|
1257
|
+
const head = `${ev.receivedAt.slice(11, 19)} ${ev.source} ${ev.type} ${ev.bodyBytes} B${ev.truncated ? " (too large, body not included)" : ""}${ev.attempt > 1 ? ` (attempt ${ev.attempt})` : ""}`;
|
|
1258
|
+
if (f.has("json")) out.log(JSON.stringify(ev));
|
|
1259
|
+
let line = head;
|
|
1260
|
+
let handled = true;
|
|
1261
|
+
if (forward && ev.body != null) {
|
|
1262
|
+
try {
|
|
1263
|
+
const headers = { ...ev.headers };
|
|
1264
|
+
delete headers["content-length"];
|
|
1265
|
+
headers["content-type"] = ev.contentType ?? "application/json";
|
|
1266
|
+
const r = await fetch(forward, {
|
|
1267
|
+
method: "POST",
|
|
1268
|
+
headers,
|
|
1269
|
+
body: ev.body
|
|
1270
|
+
});
|
|
1271
|
+
line += ` \u2192 ${r.status}`;
|
|
1272
|
+
if (r.status >= 500) handled = false;
|
|
1273
|
+
} catch (e) {
|
|
1274
|
+
line += ` \u2192 forward failed: ${e.message}`;
|
|
1275
|
+
handled = false;
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
if (handled) await ev.ack();
|
|
1279
|
+
else line += " (not acknowledged, it will come back)";
|
|
1280
|
+
if (!f.has("json")) out.log(line);
|
|
1281
|
+
if (++n >= max) break;
|
|
1282
|
+
}
|
|
1283
|
+
return 0;
|
|
1284
|
+
} finally {
|
|
1285
|
+
sub.close();
|
|
1286
|
+
process.off("SIGINT", stop);
|
|
1287
|
+
process.off("SIGTERM", stop);
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
// src/bin.ts
|
|
1292
|
+
process.exit(await main(process.argv.slice(2)));
|