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