@phuzle/relay 0.1.1 → 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.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 ${MAX_PLAINTEXT_BYTES}`);
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
- async function seal(encPub, ctx, plaintext, ekm) {
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 > MAX_PLAINTEXT_BYTES)
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
- // ../core/src/crypto/keys.ts
55
- import { ed25519 } from "@noble/curves/ed25519";
56
- import { sha256 } from "@noble/hashes/sha2";
57
- function bindingMessage(encPub, idPub) {
58
- return utf8(`relay-bind-v1
59
- ${b64urlEncode(encPub)}
60
- ${b64urlEncode(idPub)}`);
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 ed255192 } from "@noble/curves/ed25519";
72
- import { sha256 as sha2562 } from "@noble/hashes/sha2";
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 sleep = (ms) => new Promise((r) => setTimeout(r, ms));
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,67 @@ 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
+ }
210
736
  /** The topics this key may send to (its `trigger:*` / `trigger:<slug>` permissions), and whether it may create more. */
211
737
  async topics() {
212
738
  const r = await this.request("GET", "/v1/trigger/topics");
@@ -214,7 +740,7 @@ var Relay = class _Relay {
214
740
  }
215
741
  /**
216
742
  * Create a topic. Needs the **Can create topics** permission on the key (`scope_denied` otherwise) and counts against the plan's
217
- * topic limit (`topic_limit`). A key limited to specific topics may send to the ones it creates. Not retried automatically:
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:
218
744
  * a second attempt would only answer `slug_taken`.
219
745
  */
220
746
  async createTopic(t) {
@@ -371,13 +897,14 @@ var Relay = class _Relay {
371
897
  } catch (e) {
372
898
  const transient = e instanceof RelayError ? e.status >= 500 || e.status === 0 && e.code === "network" : false;
373
899
  if (!transient || attempt >= this.retries) throw e;
374
- await sleep(250 * 2 ** attempt++);
900
+ await sleep2(250 * 2 ** attempt++);
375
901
  }
376
902
  }
377
903
  }
378
- async raw(method, path, body, headers = {}) {
904
+ async raw(method, path, body, headers = {}, signal) {
379
905
  try {
380
906
  return await this.doFetch(this.base + path, {
907
+ ...signal ? { signal } : {},
381
908
  method,
382
909
  headers: {
383
910
  authorization: `Bearer ${this.opts.apiKey}`,
@@ -485,5 +1012,7 @@ export {
485
1012
  PayloadTooLargeError,
486
1013
  Relay,
487
1014
  RelayError,
1015
+ StreamError,
1016
+ Subscription,
488
1017
  buildPlaintext
489
1018
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phuzle/relay",
3
- "version": "0.1.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",