@shadow-js/realtime 1.0.1 → 1.0.3

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.
Files changed (53) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +35 -0
  3. package/dist/dev/client/core.js +107 -107
  4. package/dist/dev/client/core.js.map +1 -1
  5. package/dist/dev/client/sse.js +33 -24
  6. package/dist/dev/client/sse.js.map +1 -1
  7. package/dist/dev/client/types.d.ts +6 -0
  8. package/dist/dev/client/util.js +2 -2
  9. package/dist/dev/client/util.js.map +1 -1
  10. package/dist/dev/cloudflare/link.js +69 -8
  11. package/dist/dev/cloudflare/link.js.map +1 -1
  12. package/dist/dev/memory.d.ts +11 -0
  13. package/dist/dev/memory.js +38 -10
  14. package/dist/dev/memory.js.map +1 -1
  15. package/dist/dev/protocol.js +13 -12
  16. package/dist/dev/protocol.js.map +1 -1
  17. package/dist/dev/redis/topics.d.ts +6 -1
  18. package/dist/dev/redis/topics.js +4 -1
  19. package/dist/dev/redis/topics.js.map +1 -1
  20. package/dist/dev/server/http.js +37 -24
  21. package/dist/dev/server/http.js.map +1 -1
  22. package/dist/dev/server/hub.d.ts +1 -1
  23. package/dist/dev/server/hub.js +117 -69
  24. package/dist/dev/server/hub.js.map +1 -1
  25. package/dist/dev/server/index.d.ts +2 -2
  26. package/dist/dev/server/index.js.map +1 -1
  27. package/dist/dev/server/limits.d.ts +4 -1
  28. package/dist/dev/server/limits.js.map +1 -1
  29. package/dist/dev/server/rules.js.map +1 -1
  30. package/dist/prod/client/core.js +1 -1
  31. package/dist/prod/client/core.js.map +1 -1
  32. package/dist/prod/client/sse.js +1 -4
  33. package/dist/prod/client/sse.js.map +1 -1
  34. package/dist/prod/client/util.js +1 -1
  35. package/dist/prod/client/util.js.map +1 -1
  36. package/dist/prod/cloudflare/link.js +1 -1
  37. package/dist/prod/cloudflare/link.js.map +1 -1
  38. package/dist/prod/memory.js +1 -1
  39. package/dist/prod/memory.js.map +1 -1
  40. package/dist/prod/protocol.js +1 -1
  41. package/dist/prod/protocol.js.map +1 -1
  42. package/dist/prod/redis/topics.js +1 -1
  43. package/dist/prod/redis/topics.js.map +1 -1
  44. package/dist/prod/server/http.js +1 -1
  45. package/dist/prod/server/http.js.map +1 -1
  46. package/dist/prod/server/hub.js +1 -1
  47. package/dist/prod/server/hub.js.map +1 -1
  48. package/dist/prod/server/index.js.map +1 -1
  49. package/dist/prod/server/limits.js +1 -1
  50. package/dist/prod/server/limits.js.map +1 -1
  51. package/dist/prod/server/rules.js +1 -1
  52. package/dist/prod/server/rules.js.map +1 -1
  53. package/package.json +6 -5
@@ -9,11 +9,8 @@ const MAX_PUMP_BUFFER = 1e4;
9
9
  const ID_BYTES = 16;
10
10
  /** A random id: 128 bits, base64url. */
11
11
  function randomId() {
12
- const bytes = new Uint8Array(ID_BYTES);
13
- crypto.getRandomValues(bytes);
14
- let text = "";
15
- for (const byte of bytes) text += String.fromCharCode(byte);
16
- return btoa(text).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
12
+ const bytes = crypto.getRandomValues(new Uint8Array(ID_BYTES));
13
+ return btoa(String.fromCharCode(...bytes)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
17
14
  }
18
15
  function encodeFrame(frame) {
19
16
  return JSON.stringify(frame);
@@ -43,10 +40,20 @@ function createHubCore(options = {}) {
43
40
  const limits = resolveLimits(options.limits);
44
41
  const now = options.now ?? Date.now;
45
42
  const ownsAdapter = options.adapter === void 0;
46
- const adapter = options.adapter ?? memoryAdapter({ now });
43
+ const adapter = options.adapter ?? memoryAdapter({
44
+ now,
45
+ onError: (error, context) => report(error, {
46
+ where: `memory.${context.where}`,
47
+ channel: context.channel
48
+ })
49
+ });
47
50
  const rules = [];
48
51
  const conns = /* @__PURE__ */ new Map();
49
52
  const chans = /* @__PURE__ */ new Map();
53
+ /** Subscribes whose rules are being awaited: a revoke issued meanwhile marks them, so the grant cannot outlive it. */
54
+ const judging = /* @__PURE__ */ new Set();
55
+ /** The tail of each channel's presence joins that are queued or running; an entry leaves when its chain is idle. */
56
+ const joinChains = /* @__PURE__ */ new Map();
50
57
  let heartbeat;
51
58
  let closing = false;
52
59
  let beating = false;
@@ -104,9 +111,21 @@ function createHubCore(options = {}) {
104
111
  where: "transport.send",
105
112
  connection: conn.id
106
113
  });
114
+ onTransport(conn, "transport.close", () => conn.transport.close(1011, "write failed"));
107
115
  }
108
116
  closeConnection(conn);
109
117
  }
118
+ /** Runs a call on the transport; a transport that is already gone throws, which is only reported. */
119
+ function onTransport(conn, where, call) {
120
+ try {
121
+ call();
122
+ } catch (error) {
123
+ report(error, {
124
+ where,
125
+ connection: conn.id
126
+ });
127
+ }
128
+ }
110
129
  function sendFrame(conn, frame) {
111
130
  send(conn, encodeFrame(frame));
112
131
  }
@@ -117,22 +136,18 @@ function createHubCore(options = {}) {
117
136
  ...n === void 0 ? {} : { n },
118
137
  ...channel === void 0 ? {} : { c: channel },
119
138
  code,
120
- ...message === void 0 ? {} : { msg: message }
139
+ ...message === void 0 || message === "" ? {} : { msg: message }
121
140
  });
122
141
  }
123
142
  /** Sends a final frame, closes the transport and releases the connection. */
124
143
  function drop(conn, code, reason) {
125
144
  if (conn.closed) return;
126
- try {
127
- conn.transport.send(encodeFrame({
128
- t: "bye",
129
- code,
130
- why: reason
131
- }));
132
- } catch {}
133
- try {
134
- conn.transport.close(code, reason);
135
- } catch {}
145
+ onTransport(conn, "transport.send", () => conn.transport.send(encodeFrame({
146
+ t: "bye",
147
+ code,
148
+ why: reason
149
+ })));
150
+ onTransport(conn, "transport.close", () => conn.transport.close(code, reason));
136
151
  closeConnection(conn);
137
152
  }
138
153
  function channelState(name) {
@@ -151,23 +166,16 @@ function createHubCore(options = {}) {
151
166
  }
152
167
  return chan;
153
168
  }
154
- async function ensureAdapterSub(chan) {
155
- if (chan.adapterSub === void 0) {
156
- chan.adapterSub = adapter.subscribe(chan.name, (message) => onMessage(chan, message));
157
- chan.adapterSub.catch(() => {
158
- chan.adapterSub = void 0;
159
- });
160
- }
161
- await chan.adapterSub;
162
- }
163
- async function ensurePresenceSub(chan) {
164
- if (chan.presenceSub === void 0) {
165
- chan.presenceSub = adapter.presenceSubscribe(chan.name, (event) => onPresence(chan, event));
166
- chan.presenceSub.catch(() => {
167
- chan.presenceSub = void 0;
169
+ /** Opens one of the channel's two adapter feeds once; a failed attempt is forgotten so the next caller retries. */
170
+ async function ensureFeed(chan, key, open) {
171
+ if (chan[key] === void 0) {
172
+ const opened = open();
173
+ chan[key] = opened;
174
+ opened.catch(() => {
175
+ chan[key] = void 0;
168
176
  });
169
177
  }
170
- await chan.presenceSub;
178
+ await chan[key];
171
179
  }
172
180
  function onMessage(chan, message) {
173
181
  for (const sub of chan.subs) {
@@ -250,6 +258,10 @@ function createHubCore(options = {}) {
250
258
  }
251
259
  function onPresence(chan, event) {
252
260
  for (const sub of chan.subs) {
261
+ if (sub.joined && event.op !== "leave" && event.member.id === sub.conn.id && event.member.v > sub.version) {
262
+ sub.version = event.member.v;
263
+ sub.state = event.member.s;
264
+ }
253
265
  if (!sub.active || !sub.joined && !sub.watch) continue;
254
266
  if (sub.presenceQueue !== void 0) sub.presenceQueue.push(event);
255
267
  else sendPresence(sub, event);
@@ -322,9 +334,19 @@ function createHubCore(options = {}) {
322
334
  if (conn.subs.has(name)) return fail(conn, n, "conflict", name, "already subscribed");
323
335
  if (conn.subs.size >= limits.maxChannelsPerConnection) return fail(conn, n, "limit", name, "too many channels on this connection");
324
336
  const found = match(name);
325
- if (found === void 0 || !await allowed(found.rule, "subscribe", name, found.params, conn.identity)) return fail(conn, n, identityDenied(conn), name);
337
+ if (found === void 0) return fail(conn, n, identityDenied(conn), name);
326
338
  const wantsPresence = frame.hasPresence || frame.watch === true;
327
- if (wantsPresence && !await allowed(found.rule, "presence", name, found.params, conn.identity)) return fail(conn, n, identityDenied(conn), name);
339
+ const pending = {
340
+ conn,
341
+ channel: name,
342
+ revoked: false
343
+ };
344
+ judging.add(pending);
345
+ try {
346
+ if (!await allowed(found.rule, "subscribe", name, found.params, conn.identity) || wantsPresence && !await allowed(found.rule, "presence", name, found.params, conn.identity) || pending.revoked) return fail(conn, n, identityDenied(conn), name);
347
+ } finally {
348
+ judging.delete(pending);
349
+ }
328
350
  if (frame.hasPresence && byteLength(JSON.stringify(frame.presence ?? null)) > limits.maxStateBytes) return fail(conn, n, "too_large", name, "the presence state is too large");
329
351
  if (conn.closed) return;
330
352
  const chan = channelState(name);
@@ -338,6 +360,7 @@ function createHubCore(options = {}) {
338
360
  watch: frame.watch === true,
339
361
  joined: false,
340
362
  version: 0,
363
+ state: null,
341
364
  lastSeq: 0,
342
365
  lastAt: now(),
343
366
  pumping: true,
@@ -350,9 +373,9 @@ function createHubCore(options = {}) {
350
373
  conn.subs.set(name, sub);
351
374
  chan.subs.add(sub);
352
375
  try {
353
- await ensureAdapterSub(chan);
376
+ await ensureFeed(chan, "adapterSub", () => adapter.subscribe(chan.name, (message) => onMessage(chan, message)));
354
377
  if (wantsPresence) {
355
- await ensurePresenceSub(chan);
378
+ await ensureFeed(chan, "presenceSub", () => adapter.presenceSubscribe(chan.name, (event) => onPresence(chan, event)));
356
379
  startHeartbeat();
357
380
  }
358
381
  const probe = await adapter.history(name, Number.MAX_SAFE_INTEGER, 0);
@@ -398,18 +421,35 @@ function createHubCore(options = {}) {
398
421
  }
399
422
  }
400
423
  }
401
- async function joinPresence(sub, state) {
424
+ /** Joins one at a time per channel: the capacity check reads the set, so two joins in flight would both see room. */
425
+ function joinPresence(sub, state) {
426
+ const name = sub.channel.name;
427
+ const run = (joinChains.get(name) ?? Promise.resolve()).then(() => joinOne(sub, state));
428
+ const settle = () => {
429
+ if (joinChains.get(name) === tail) joinChains.delete(name);
430
+ };
431
+ const tail = run.then(settle, settle);
432
+ joinChains.set(name, tail);
433
+ return run;
434
+ }
435
+ async function joinOne(sub, state) {
436
+ if (!sub.active) return;
402
437
  if ((await adapter.presenceList(sub.channel.name)).length >= limits.maxPresenceMembers) throw new RealtimeError("limit", "the presence set is full");
403
438
  sub.version = 1;
439
+ sub.state = state;
404
440
  sub.joined = true;
405
441
  startHeartbeat();
406
- const member = {
442
+ await adapter.presenceSet(sub.channel.name, memberOf(sub), limits.presenceTtlMs);
443
+ }
444
+ /** The presence member a joined subscription stands for. */
445
+ function memberOf(sub) {
446
+ const { user } = sub.conn.identity;
447
+ return {
407
448
  id: sub.conn.id,
408
- ...sub.conn.identity.user === void 0 ? {} : { u: sub.conn.identity.user },
409
- s: state,
410
- v: 1
449
+ ...user === void 0 ? {} : { u: user },
450
+ s: sub.state,
451
+ v: sub.version
411
452
  };
412
- await adapter.presenceSet(sub.channel.name, member, limits.presenceTtlMs);
413
453
  }
414
454
  async function syncPresence(sub) {
415
455
  const members = await adapter.presenceList(sub.channel.name);
@@ -435,14 +475,9 @@ function createHubCore(options = {}) {
435
475
  if (byteLength(JSON.stringify(frame.s)) > limits.maxStateBytes) return fail(conn, void 0, "too_large", frame.c, "the presence state is too large");
436
476
  if (!conn.publishBucket.take(now())) return limit(conn, void 0, frame.c);
437
477
  sub.version += 1;
438
- const member = {
439
- id: conn.id,
440
- ...conn.identity.user === void 0 ? {} : { u: conn.identity.user },
441
- s: frame.s,
442
- v: sub.version
443
- };
478
+ sub.state = frame.s;
444
479
  try {
445
- await adapter.presenceSet(frame.c, member, limits.presenceTtlMs);
480
+ await adapter.presenceSet(frame.c, memberOf(sub), limits.presenceTtlMs);
446
481
  } catch (error) {
447
482
  report(error, {
448
483
  where: "presenceSet",
@@ -472,7 +507,7 @@ function createHubCore(options = {}) {
472
507
  history: found.rule.history ?? limits.history,
473
508
  historyTtlMs: limits.historyTtlMs,
474
509
  idempotencyTtlMs: limits.idempotencyTtlMs,
475
- ...frame.k === void 0 ? {} : { key: frame.k }
510
+ ...frame.k === void 0 ? {} : { key: `${frame.k}/${conn.identity.user === void 0 ? "a" : `u:${conn.identity.user}`}` }
476
511
  });
477
512
  counters.published += result.duplicate ? 0 : 1;
478
513
  if (n !== void 0) sendFrame(conn, {
@@ -541,14 +576,20 @@ function createHubCore(options = {}) {
541
576
  }
542
577
  })();
543
578
  }
579
+ /** Answers a frame that cannot be handled; a peer that keeps sending them is dropped. */
580
+ function refuseFrame(conn, n, code, why) {
581
+ fail(conn, n, code, void 0, why);
582
+ if (conn.invalid.hit(now())) drop(conn, 1008, "too many invalid frames");
583
+ }
544
584
  async function handle(conn, text) {
545
585
  const decoded = decodeClientFrame(text, limits.maxMessageBytes);
546
586
  if (!decoded.ok) {
547
- fail(conn, decoded.n, decoded.code, void 0, decoded.why);
548
- if (conn.invalid.hit(now())) drop(conn, 1008, "too many invalid frames");
587
+ refuseFrame(conn, decoded.n, decoded.code, decoded.why);
549
588
  return;
550
589
  }
551
590
  const frame = decoded.frame;
591
+ const n = "n" in frame ? frame.n : void 0;
592
+ if ((frame.t === "sub" || frame.t === "unsub") && !conn.controlBucket.take(now()) && frame.t === "sub") return limit(conn, n, frame.c);
552
593
  try {
553
594
  switch (frame.t) {
554
595
  case "hello": return;
@@ -570,7 +611,6 @@ function createHubCore(options = {}) {
570
611
  case "pres": return await setPresence(conn, frame);
571
612
  }
572
613
  } catch (error) {
573
- const n = "n" in frame ? frame.n : void 0;
574
614
  const channel = "c" in frame ? frame.c : void 0;
575
615
  if (error instanceof RealtimeError) fail(conn, n, error.code, channel);
576
616
  else {
@@ -664,12 +704,7 @@ function createHubCore(options = {}) {
664
704
  for (const ref of missing) {
665
705
  const sub = conns.get(ref.id)?.subs.get(ref.channel);
666
706
  if (sub === void 0 || !sub.joined) continue;
667
- await adapter.presenceSet(ref.channel, {
668
- id: ref.id,
669
- ...sub.conn.identity.user === void 0 ? {} : { u: sub.conn.identity.user },
670
- s: null,
671
- v: sub.version
672
- }, limits.presenceTtlMs);
707
+ await adapter.presenceSet(ref.channel, memberOf(sub), limits.presenceTtlMs);
673
708
  }
674
709
  for (const channel of watched) await adapter.presenceReap(channel);
675
710
  } catch (error) {
@@ -702,6 +737,7 @@ function createHubCore(options = {}) {
702
737
  subs: /* @__PURE__ */ new Map(),
703
738
  queue: [],
704
739
  publishBucket: connectOptions.publishBucket ?? new TokenBucket(limits.publishesPerSecond, limits.publishBurst, now()),
740
+ controlBucket: new TokenBucket(limits.publishesPerSecond, Math.max(limits.publishBurst, limits.maxChannelsPerConnection), now()),
705
741
  stateless: connectOptions.stateless === true,
706
742
  waiters: [],
707
743
  invalid: new WindowCounter(10, 6e4),
@@ -726,6 +762,10 @@ function createHubCore(options = {}) {
726
762
  done,
727
763
  receive(text) {
728
764
  if (conn.closed) return;
765
+ if (byteLength(text) > limits.maxMessageBytes) {
766
+ refuseFrame(conn, void 0, "too_large");
767
+ return;
768
+ }
729
769
  if (conn.queue.length >= limits.maxQueuedFrames) {
730
770
  drop(conn, 1013, "too many frames waiting");
731
771
  return;
@@ -764,7 +804,7 @@ function createHubCore(options = {}) {
764
804
  history: found?.rule.history ?? limits.history,
765
805
  historyTtlMs: limits.historyTtlMs,
766
806
  idempotencyTtlMs: limits.idempotencyTtlMs,
767
- ...publishOptions.key === void 0 ? {} : { key: publishOptions.key }
807
+ ...publishOptions.key === void 0 ? {} : { key: `${publishOptions.key}/s` }
768
808
  });
769
809
  counters.published += result.duplicate ? 0 : 1;
770
810
  return result;
@@ -776,24 +816,32 @@ function createHubCore(options = {}) {
776
816
  if (byteLength(JSON.stringify(state)) > limits.maxStateBytes) throw new RealtimeError("too_large", "the presence state is too large");
777
817
  const member = (await adapter.presenceList(channel)).find((item) => item.id === connectionId);
778
818
  if (member === void 0 || member.u !== identity.user) throw new RealtimeError("forbidden", "that connection is not yours");
819
+ const local = conns.get(connectionId)?.subs.get(channel);
820
+ const version = Math.max(member.v, local?.version ?? 0) + 1;
821
+ if (local?.joined === true) {
822
+ local.version = version;
823
+ local.state = state;
824
+ }
779
825
  await adapter.presenceSet(channel, {
780
826
  id: member.id,
781
827
  u: member.u,
782
828
  s: state,
783
- v: member.v + 1
829
+ v: version
784
830
  }, limits.presenceTtlMs);
785
831
  },
786
832
  revoke(target) {
787
833
  if (target.user === void 0 && target.connection === void 0 && target.channel === void 0) throw new TypeError("revoke needs a user, a connection or a channel.");
834
+ const hit = (conn, channel) => (target.connection === void 0 || conn.id === target.connection) && (target.user === void 0 || conn.identity.user === target.user) && (target.channel === void 0 || channel === target.channel);
788
835
  let count = 0;
789
- for (const conn of [...conns.values()]) {
790
- if (target.connection !== void 0 && conn.id !== target.connection) continue;
791
- if (target.user !== void 0 && conn.identity.user !== target.user) continue;
792
- for (const sub of [...conn.subs.values()]) {
793
- if (target.channel !== void 0 && sub.channel.name !== target.channel) continue;
794
- removeSub(sub, "revoked");
795
- count += 1;
796
- }
836
+ for (const item of judging) {
837
+ if (item.revoked || !hit(item.conn, item.channel)) continue;
838
+ item.revoked = true;
839
+ count += 1;
840
+ }
841
+ for (const conn of [...conns.values()]) for (const sub of [...conn.subs.values()]) {
842
+ if (!hit(conn, sub.channel.name)) continue;
843
+ removeSub(sub, "revoked");
844
+ count += 1;
797
845
  }
798
846
  return count;
799
847
  },