@foony/chat 0.0.1

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 (95) hide show
  1. package/LICENSE +215 -0
  2. package/README.md +67 -0
  3. package/lib/chatClient.d.ts +22 -0
  4. package/lib/chatClient.d.ts.map +1 -0
  5. package/lib/chatClient.js +28 -0
  6. package/lib/chatClient.js.map +1 -0
  7. package/lib/index.d.ts +19 -0
  8. package/lib/index.d.ts.map +1 -0
  9. package/lib/index.js +18 -0
  10. package/lib/index.js.map +1 -0
  11. package/lib/messages.d.ts +45 -0
  12. package/lib/messages.d.ts.map +1 -0
  13. package/lib/messages.js +146 -0
  14. package/lib/messages.js.map +1 -0
  15. package/lib/occupancy.d.ts +30 -0
  16. package/lib/occupancy.d.ts.map +1 -0
  17. package/lib/occupancy.js +65 -0
  18. package/lib/occupancy.js.map +1 -0
  19. package/lib/presence.d.ts +0 -0
  20. package/lib/presence.d.ts.map +1 -0
  21. package/lib/presence.js +0 -0
  22. package/lib/presence.js.map +1 -0
  23. package/lib/protocol.d.ts +52 -0
  24. package/lib/protocol.d.ts.map +1 -0
  25. package/lib/protocol.js +72 -0
  26. package/lib/protocol.js.map +1 -0
  27. package/lib/reactions.d.ts +26 -0
  28. package/lib/reactions.d.ts.map +1 -0
  29. package/lib/reactions.js +56 -0
  30. package/lib/reactions.js.map +1 -0
  31. package/lib/reconciler.d.ts +29 -0
  32. package/lib/reconciler.d.ts.map +1 -0
  33. package/lib/reconciler.js +149 -0
  34. package/lib/reconciler.js.map +1 -0
  35. package/lib/room.d.ts +53 -0
  36. package/lib/room.d.ts.map +1 -0
  37. package/lib/room.js +81 -0
  38. package/lib/room.js.map +1 -0
  39. package/lib/rooms.d.ts +20 -0
  40. package/lib/rooms.d.ts.map +1 -0
  41. package/lib/rooms.js +38 -0
  42. package/lib/rooms.js.map +1 -0
  43. package/lib/types.d.ts +139 -0
  44. package/lib/types.d.ts.map +1 -0
  45. package/lib/types.js +9 -0
  46. package/lib/types.js.map +1 -0
  47. package/lib/typing.d.ts +43 -0
  48. package/lib/typing.d.ts.map +1 -0
  49. package/lib/typing.js +108 -0
  50. package/lib/typing.js.map +1 -0
  51. package/lib/util.d.ts +9 -0
  52. package/lib/util.d.ts.map +1 -0
  53. package/lib/util.js +12 -0
  54. package/lib/util.js.map +1 -0
  55. package/lib-cjs/chatClient.js +32 -0
  56. package/lib-cjs/chatClient.js.map +1 -0
  57. package/lib-cjs/index.js +30 -0
  58. package/lib-cjs/index.js.map +1 -0
  59. package/lib-cjs/messages.js +150 -0
  60. package/lib-cjs/messages.js.map +1 -0
  61. package/lib-cjs/occupancy.js +69 -0
  62. package/lib-cjs/occupancy.js.map +1 -0
  63. package/lib-cjs/package.json +3 -0
  64. package/lib-cjs/presence.js +0 -0
  65. package/lib-cjs/presence.js.map +1 -0
  66. package/lib-cjs/protocol.js +79 -0
  67. package/lib-cjs/protocol.js.map +1 -0
  68. package/lib-cjs/reactions.js +60 -0
  69. package/lib-cjs/reactions.js.map +1 -0
  70. package/lib-cjs/reconciler.js +153 -0
  71. package/lib-cjs/reconciler.js.map +1 -0
  72. package/lib-cjs/room.js +85 -0
  73. package/lib-cjs/room.js.map +1 -0
  74. package/lib-cjs/rooms.js +42 -0
  75. package/lib-cjs/rooms.js.map +1 -0
  76. package/lib-cjs/types.js +10 -0
  77. package/lib-cjs/types.js.map +1 -0
  78. package/lib-cjs/typing.js +112 -0
  79. package/lib-cjs/typing.js.map +1 -0
  80. package/lib-cjs/util.js +15 -0
  81. package/lib-cjs/util.js.map +1 -0
  82. package/package.json +56 -0
  83. package/src/chatClient.ts +31 -0
  84. package/src/index.ts +32 -0
  85. package/src/messages.ts +159 -0
  86. package/src/occupancy.ts +78 -0
  87. package/src/presence.ts +0 -0
  88. package/src/protocol.ts +90 -0
  89. package/src/reactions.ts +64 -0
  90. package/src/reconciler.ts +169 -0
  91. package/src/room.ts +97 -0
  92. package/src/rooms.ts +42 -0
  93. package/src/types.ts +141 -0
  94. package/src/typing.ts +123 -0
  95. package/src/util.ts +12 -0
@@ -0,0 +1,42 @@
1
+ "use strict";
2
+ /**
3
+ * Room registry. Mirrors `Realtime.channels` — `get(name)` returns a stable
4
+ * {@link Room} per name (creating its `chat:<name>` channel on first use), and
5
+ * `release(name)` detaches and drops it.
6
+ */
7
+ Object.defineProperty(exports, "__esModule", { value: true });
8
+ exports.Rooms = void 0;
9
+ const protocol_js_1 = require("./protocol.js");
10
+ const room_js_1 = require("./room.js");
11
+ /** Factory and cache for {@link Room} instances on a {@link ChatClient}. */
12
+ class Rooms {
13
+ realtime;
14
+ getClientId;
15
+ byName = new Map();
16
+ constructor(realtime, getClientId) {
17
+ this.realtime = realtime;
18
+ this.getClientId = getClientId;
19
+ }
20
+ /** Get (or create) the room named `name`. Stable instance per name. */
21
+ get(name, options) {
22
+ let existing = this.byName.get(name);
23
+ if (!existing) {
24
+ const channel = this.realtime.channels.get((0, protocol_js_1.roomChannelName)(name));
25
+ existing = new room_js_1.Room(name, channel, this.getClientId, options);
26
+ this.byName.set(name, existing);
27
+ }
28
+ return existing;
29
+ }
30
+ /** Detach and forget the room named `name`. No-op if not present. */
31
+ release(name) {
32
+ const room = this.byName.get(name);
33
+ if (!room) {
34
+ return;
35
+ }
36
+ this.byName.delete(name);
37
+ room.detach().catch(() => { });
38
+ this.realtime.channels.release((0, protocol_js_1.roomChannelName)(name));
39
+ }
40
+ }
41
+ exports.Rooms = Rooms;
42
+ //# sourceMappingURL=rooms.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rooms.js","sourceRoot":"","sources":["../src/rooms.ts"],"names":[],"mappings":";AAAA;;;;GAIG;;;AAGH,+CAAgD;AAChD,uCAAiC;AAGjC,4EAA4E;AAC5E,MAAa,KAAK;IAIG;IACA;IAJF,MAAM,GAAG,IAAI,GAAG,EAAgB,CAAC;IAElD,YACmB,QAAkB,EAClB,WAAgC;QADhC,aAAQ,GAAR,QAAQ,CAAU;QAClB,gBAAW,GAAX,WAAW,CAAqB;IAChD,CAAC;IAEJ,uEAAuE;IACvE,GAAG,CAAC,IAAY,EAAE,OAAqB;QACrC,IAAI,QAAQ,GAAG,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;QACrC,IAAI,CAAC,QAAQ,EAAE,CAAC;YACd,MAAM,OAAO,GAAG,IAAI,CAAC,QAAQ,CAAC,QAAQ,CAAC,GAAG,CAAC,IAAA,6BAAe,EAAC,IAAI,CAAC,CAAC,CAAC;YAClE,QAAQ,GAAG,IAAI,cAAI,CAAC,IAAI,EAAE,OAAO,EAAE,IAAI,CAAC,WAAW,EAAE,OAAO,CAAC,CAAC;YAC9D,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,IAAI,EAAE,QAAQ,CAAC,CAAC;QAClC,CAAC;QACD,OAAO,QAAQ,CAAC;IAClB,CAAC;IAED,qEAAqE;IACrE,OAAO,CAAC,IAAY;QAClB,MAAM,IAAI,GAAG,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;QACnC,IAAI,CAAC,IAAI,EAAE,CAAC;YACV,OAAO;QACT,CAAC;QACD,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC;QACzB,IAAI,CAAC,MAAM,EAAE,CAAC,KAAK,CAAC,GAAG,EAAE,GAAE,CAAC,CAAC,CAAC;QAC9B,IAAI,CAAC,QAAQ,CAAC,QAAQ,CAAC,OAAO,CAAC,IAAA,6BAAe,EAAC,IAAI,CAAC,CAAC,CAAC;IACxD,CAAC;CACF;AA7BD,sBA6BC"}
@@ -0,0 +1,10 @@
1
+ "use strict";
2
+ /**
3
+ * Public types for the @foony/chat API.
4
+ *
5
+ * Foony-native shapes inspired by Ably Chat: a {@link Message} is keyed by a
6
+ * stable `id` (so edit/delete reference it directly), and presence/typing/
7
+ * reactions/occupancy mirror the concepts without copying Ably's exact names.
8
+ */
9
+ Object.defineProperty(exports, "__esModule", { value: true });
10
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":";AAAA;;;;;;GAMG"}
@@ -0,0 +1,112 @@
1
+ "use strict";
2
+ /**
3
+ * Per-room typing indicators — ephemeral, pure client-side logic over the
4
+ * channel's pub/sub (no persistence, no edge support needed).
5
+ *
6
+ * Sender: the first `keystroke()` broadcasts `started` immediately; further
7
+ * keystrokes within `heartbeatThrottleMs` are no-ops, and one after the window
8
+ * re-broadcasts. `stop()` broadcasts `stopped`. As long as a client keeps
9
+ * typing it keeps heartbeating; when it stops, receivers expire it.
10
+ *
11
+ * Receiver: tracks who is typing and auto-expires a typer after
12
+ * `heartbeatThrottleMs + GRACE_MS` with no heartbeat, emitting a synthetic
13
+ * `stopped`. The local client is excluded from the typing set.
14
+ */
15
+ Object.defineProperty(exports, "__esModule", { value: true });
16
+ exports.Typing = void 0;
17
+ const protocol_js_1 = require("./protocol.js");
18
+ /** Default heartbeat throttle; must be uniform across clients in a room. */
19
+ const DEFAULT_HEARTBEAT_THROTTLE_MS = 10_000;
20
+ /** Extra time a receiver waits past the throttle before expiring a typer. */
21
+ const GRACE_MS = 2_000;
22
+ /** The typing feature of a {@link Room}. */
23
+ class Typing {
24
+ channel;
25
+ getClientId;
26
+ heartbeatThrottleMs;
27
+ listeners = new Set();
28
+ /** Active typers (excluding self) → expiry timer. */
29
+ typers = new Map();
30
+ /** When the local client last broadcast a heartbeat; -Infinity means "never / send next". */
31
+ lastSentAt = Number.NEGATIVE_INFINITY;
32
+ channelUnsubscribe = null;
33
+ constructor(channel, getClientId, heartbeatThrottleMs) {
34
+ this.channel = channel;
35
+ this.getClientId = getClientId;
36
+ this.heartbeatThrottleMs = heartbeatThrottleMs ?? DEFAULT_HEARTBEAT_THROTTLE_MS;
37
+ }
38
+ /** Signal the local client is typing. Throttled to one heartbeat per window. */
39
+ async keystroke() {
40
+ const now = Date.now();
41
+ if (now - this.lastSentAt < this.heartbeatThrottleMs) {
42
+ return;
43
+ }
44
+ this.lastSentAt = now;
45
+ const payload = { state: 'started' };
46
+ await this.channel.publish(protocol_js_1.TYPING_EVENT, payload);
47
+ }
48
+ /** Signal the local client has stopped typing. */
49
+ async stop() {
50
+ this.lastSentAt = Number.NEGATIVE_INFINITY;
51
+ const payload = { state: 'stopped' };
52
+ await this.channel.publish(protocol_js_1.TYPING_EVENT, payload);
53
+ }
54
+ /** Client ids currently typing (excludes the local client). */
55
+ get currentlyTyping() {
56
+ return new Set(this.typers.keys());
57
+ }
58
+ /** Subscribe to typing changes. Attaches the channel on first listener. */
59
+ subscribe(listener) {
60
+ this.listeners.add(listener);
61
+ this.ensureChannelSubscription();
62
+ return () => {
63
+ this.listeners.delete(listener);
64
+ };
65
+ }
66
+ ensureChannelSubscription() {
67
+ if (this.channelUnsubscribe) {
68
+ return;
69
+ }
70
+ this.channelUnsubscribe = this.channel.subscribe(protocol_js_1.TYPING_EVENT, (frame) => {
71
+ const payload = (0, protocol_js_1.parseTypingPayload)(frame.data);
72
+ const clientId = frame.clientId;
73
+ if (payload === null || clientId === undefined || clientId === this.getClientId()) {
74
+ return;
75
+ }
76
+ if (payload.state === 'started') {
77
+ this.markTyping(clientId);
78
+ }
79
+ else {
80
+ this.markStopped(clientId);
81
+ }
82
+ });
83
+ }
84
+ markTyping(clientId) {
85
+ const existing = this.typers.get(clientId);
86
+ if (existing) {
87
+ clearTimeout(existing);
88
+ }
89
+ const timer = setTimeout(() => this.markStopped(clientId), this.heartbeatThrottleMs + GRACE_MS);
90
+ this.typers.set(clientId, timer);
91
+ if (!existing) {
92
+ this.emit({ type: 'started', clientId });
93
+ }
94
+ }
95
+ markStopped(clientId) {
96
+ const existing = this.typers.get(clientId);
97
+ if (!existing) {
98
+ return;
99
+ }
100
+ clearTimeout(existing);
101
+ this.typers.delete(clientId);
102
+ this.emit({ type: 'stopped', clientId });
103
+ }
104
+ emit(change) {
105
+ const event = { currentlyTyping: new Set(this.typers.keys()), change };
106
+ for (const listener of [...this.listeners]) {
107
+ listener(event);
108
+ }
109
+ }
110
+ }
111
+ exports.Typing = Typing;
112
+ //# sourceMappingURL=typing.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"typing.js","sourceRoot":"","sources":["../src/typing.ts"],"names":[],"mappings":";AAAA;;;;;;;;;;;;GAYG;;;AAGH,+CAAqF;AAMrF,4EAA4E;AAC5E,MAAM,6BAA6B,GAAG,MAAM,CAAC;AAC7C,6EAA6E;AAC7E,MAAM,QAAQ,GAAG,KAAK,CAAC;AAEvB,4CAA4C;AAC5C,MAAa,MAAM;IAUE;IACA;IAVF,mBAAmB,CAAS;IAC5B,SAAS,GAAG,IAAI,GAAG,EAAkB,CAAC;IACvD,qDAAqD;IACpC,MAAM,GAAG,IAAI,GAAG,EAAyC,CAAC;IAC3E,6FAA6F;IACrF,UAAU,GAAG,MAAM,CAAC,iBAAiB,CAAC;IACtC,kBAAkB,GAAyB,IAAI,CAAC;IAExD,YACmB,OAAgB,EAChB,WAAgC,EACjD,mBAA4B;QAFX,YAAO,GAAP,OAAO,CAAS;QAChB,gBAAW,GAAX,WAAW,CAAqB;QAGjD,IAAI,CAAC,mBAAmB,GAAG,mBAAmB,IAAI,6BAA6B,CAAC;IAClF,CAAC;IAED,gFAAgF;IAChF,KAAK,CAAC,SAAS;QACb,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC;QACvB,IAAI,GAAG,GAAG,IAAI,CAAC,UAAU,GAAG,IAAI,CAAC,mBAAmB,EAAE,CAAC;YACrD,OAAO;QACT,CAAC;QACD,IAAI,CAAC,UAAU,GAAG,GAAG,CAAC;QACtB,MAAM,OAAO,GAAkB,EAAE,KAAK,EAAE,SAAS,EAAE,CAAC;QACpD,MAAM,IAAI,CAAC,OAAO,CAAC,OAAO,CAAC,0BAAY,EAAE,OAAO,CAAC,CAAC;IACpD,CAAC;IAED,kDAAkD;IAClD,KAAK,CAAC,IAAI;QACR,IAAI,CAAC,UAAU,GAAG,MAAM,CAAC,iBAAiB,CAAC;QAC3C,MAAM,OAAO,GAAkB,EAAE,KAAK,EAAE,SAAS,EAAE,CAAC;QACpD,MAAM,IAAI,CAAC,OAAO,CAAC,OAAO,CAAC,0BAAY,EAAE,OAAO,CAAC,CAAC;IACpD,CAAC;IAED,+DAA+D;IAC/D,IAAI,eAAe;QACjB,OAAO,IAAI,GAAG,CAAC,IAAI,CAAC,MAAM,CAAC,IAAI,EAAE,CAAC,CAAC;IACrC,CAAC;IAED,2EAA2E;IAC3E,SAAS,CAAC,QAAwB;QAChC,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC;QAC7B,IAAI,CAAC,yBAAyB,EAAE,CAAC;QACjC,OAAO,GAAG,EAAE;YACV,IAAI,CAAC,SAAS,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC;QAClC,CAAC,CAAC;IACJ,CAAC;IAEO,yBAAyB;QAC/B,IAAI,IAAI,CAAC,kBAAkB,EAAE,CAAC;YAC5B,OAAO;QACT,CAAC;QACD,IAAI,CAAC,kBAAkB,GAAG,IAAI,CAAC,OAAO,CAAC,SAAS,CAAC,0BAAY,EAAE,CAAC,KAAK,EAAE,EAAE;YACvE,MAAM,OAAO,GAAG,IAAA,gCAAkB,EAAC,KAAK,CAAC,IAAI,CAAC,CAAC;YAC/C,MAAM,QAAQ,GAAG,KAAK,CAAC,QAAQ,CAAC;YAChC,IAAI,OAAO,KAAK,IAAI,IAAI,QAAQ,KAAK,SAAS,IAAI,QAAQ,KAAK,IAAI,CAAC,WAAW,EAAE,EAAE,CAAC;gBAClF,OAAO;YACT,CAAC;YACD,IAAI,OAAO,CAAC,KAAK,KAAK,SAAS,EAAE,CAAC;gBAChC,IAAI,CAAC,UAAU,CAAC,QAAQ,CAAC,CAAC;YAC5B,CAAC;iBAAM,CAAC;gBACN,IAAI,CAAC,WAAW,CAAC,QAAQ,CAAC,CAAC;YAC7B,CAAC;QACH,CAAC,CAAC,CAAC;IACL,CAAC;IAEO,UAAU,CAAC,QAAgB;QACjC,MAAM,QAAQ,GAAG,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC;QAC3C,IAAI,QAAQ,EAAE,CAAC;YACb,YAAY,CAAC,QAAQ,CAAC,CAAC;QACzB,CAAC;QACD,MAAM,KAAK,GAAG,UAAU,CAAC,GAAG,EAAE,CAAC,IAAI,CAAC,WAAW,CAAC,QAAQ,CAAC,EAAE,IAAI,CAAC,mBAAmB,GAAG,QAAQ,CAAC,CAAC;QAChG,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,QAAQ,EAAE,KAAK,CAAC,CAAC;QACjC,IAAI,CAAC,QAAQ,EAAE,CAAC;YACd,IAAI,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,SAAS,EAAE,QAAQ,EAAE,CAAC,CAAC;QAC3C,CAAC;IACH,CAAC;IAEO,WAAW,CAAC,QAAgB;QAClC,MAAM,QAAQ,GAAG,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC;QAC3C,IAAI,CAAC,QAAQ,EAAE,CAAC;YACd,OAAO;QACT,CAAC;QACD,YAAY,CAAC,QAAQ,CAAC,CAAC;QACvB,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC;QAC7B,IAAI,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,SAAS,EAAE,QAAQ,EAAE,CAAC,CAAC;IAC3C,CAAC;IAEO,IAAI,CAAC,MAA6B;QACxC,MAAM,KAAK,GAAgB,EAAE,eAAe,EAAE,IAAI,GAAG,CAAC,IAAI,CAAC,MAAM,CAAC,IAAI,EAAE,CAAC,EAAE,MAAM,EAAE,CAAC;QACpF,KAAK,MAAM,QAAQ,IAAI,CAAC,GAAG,IAAI,CAAC,SAAS,CAAC,EAAE,CAAC;YAC3C,QAAQ,CAAC,KAAK,CAAC,CAAC;QAClB,CAAC;IACH,CAAC;CACF;AA/FD,wBA+FC"}
@@ -0,0 +1,15 @@
1
+ "use strict";
2
+ /** Small internal helpers shared across chat features. */
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.newMessageId = newMessageId;
5
+ /**
6
+ * A sender-assigned, roughly time-sortable message id: `<unixMillis>-<random>`.
7
+ * Mirrors the realtime SDK's transport id format, but the chat id is decoupled
8
+ * from transport — it lives in the payload so `send` can return it immediately
9
+ * and `update`/`delete` can reference it.
10
+ */
11
+ function newMessageId() {
12
+ const random = Math.floor(Math.random() * 0x1_0000_0000).toString(16).padStart(8, '0');
13
+ return `${Date.now()}-${random}`;
14
+ }
15
+ //# sourceMappingURL=util.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"util.js","sourceRoot":"","sources":["../src/util.ts"],"names":[],"mappings":";AAAA,0DAA0D;;AAQ1D,oCAGC;AATD;;;;;GAKG;AACH,SAAgB,YAAY;IAC1B,MAAM,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,MAAM,EAAE,GAAG,aAAa,CAAC,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAC,QAAQ,CAAC,CAAC,EAAE,GAAG,CAAC,CAAC;IACvF,OAAO,GAAG,IAAI,CAAC,GAAG,EAAE,IAAI,MAAM,EAAE,CAAC;AACnC,CAAC"}
package/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "@foony/chat",
3
+ "version": "0.0.1",
4
+ "description": "Chat API (rooms, messages, typing, reactions, presence, occupancy) built on @foony/realtime.",
5
+ "license": "Apache-2.0",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/Foony-Limited/chat-js.git"
9
+ },
10
+ "keywords": [
11
+ "foony",
12
+ "chat",
13
+ "realtime",
14
+ "websocket",
15
+ "typescript"
16
+ ],
17
+ "type": "module",
18
+ "main": "./lib-cjs/index.js",
19
+ "module": "./lib/index.js",
20
+ "types": "./lib/index.d.ts",
21
+ "sideEffects": false,
22
+ "files": [
23
+ "lib-cjs",
24
+ "lib",
25
+ "src/**/*.ts",
26
+ "!src/**/*.test.ts"
27
+ ],
28
+ "exports": {
29
+ ".": {
30
+ "types": "./lib/index.d.ts",
31
+ "import": "./lib/index.js",
32
+ "require": "./lib-cjs/index.js",
33
+ "default": "./lib/index.js"
34
+ }
35
+ },
36
+ "scripts": {
37
+ "build": "tsc && tsc -p tsconfig.cjs.json && node ./scripts/write-cjs-package-json.mjs",
38
+ "test": "vitest run",
39
+ "test:watch": "vitest"
40
+ },
41
+ "publishConfig": {
42
+ "access": "public"
43
+ },
44
+ "peerDependencies": {
45
+ "@foony/realtime": ">=0.2.0"
46
+ },
47
+ "devDependencies": {
48
+ "@foony/realtime": "file:../realtime-js",
49
+ "@types/node": "^22.13.0",
50
+ "typescript": "6.0.3",
51
+ "vitest": "^4.0.14"
52
+ },
53
+ "engines": {
54
+ "node": ">=20"
55
+ }
56
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * ChatClient is the top-level entry point: wrap a connected (or connecting)
3
+ * {@link Realtime} client and use `chat.rooms.get(name)` to start chatting.
4
+ * The chat layer adds no new transport — it rides the existing connection.
5
+ */
6
+
7
+ import type { Connection, Realtime } from '@foony/realtime';
8
+ import { Rooms } from './rooms.js';
9
+
10
+ /** Chat client built on top of a {@link Realtime} instance. */
11
+ export class ChatClient {
12
+ /** Room registry and factory. */
13
+ readonly rooms: Rooms;
14
+
15
+ constructor(private readonly realtime: Realtime) {
16
+ this.rooms = new Rooms(realtime, () => this.clientId);
17
+ }
18
+
19
+ /** The underlying realtime connection (status, events). */
20
+ get connection(): Connection {
21
+ return this.realtime.connection;
22
+ }
23
+
24
+ /**
25
+ * The resolved client id for this connection, or null until the auth
26
+ * handshake completes. Used to attribute messages and detect self.
27
+ */
28
+ get clientId(): string | null {
29
+ return this.realtime.getClientId();
30
+ }
31
+ }
package/src/index.ts ADDED
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Public entry point for @foony/chat.
3
+ *
4
+ * A chat layer over @foony/realtime: construct a `ChatClient` from a `Realtime`
5
+ * instance, then `chat.rooms.get(name)` for messages, presence, typing,
6
+ * reactions and occupancy. React bindings are intentionally out of this core
7
+ * package (planned as a separate `@foony/chat/react` subpath).
8
+ */
9
+
10
+ export { ChatClient } from './chatClient.js';
11
+ export { Rooms } from './rooms.js';
12
+ export { Room, type RoomStatus, type DiscontinuityListener } from './room.js';
13
+ export { Messages, type MessageListener } from './messages.js';
14
+ export { Presence, type PresenceListener } from './presence.js';
15
+ export { Typing, type TypingListener } from './typing.js';
16
+ export { Reactions, type ReactionListener } from './reactions.js';
17
+ export { Occupancy, type OccupancyListener } from './occupancy.js';
18
+ export { MessageReconciler } from './reconciler.js';
19
+ export type {
20
+ Message,
21
+ MessageAction,
22
+ ChatMessageEvent,
23
+ SendMessageParams,
24
+ UpdateMessageParams,
25
+ MessagePage,
26
+ PresenceMember,
27
+ PresenceEvent,
28
+ TypingEvent,
29
+ RoomReaction,
30
+ Occupancy as OccupancyData,
31
+ RoomOptions,
32
+ } from './types.js';
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Per-room message API: send, edit, delete, subscribe, and history.
3
+ *
4
+ * One {@link MessageReconciler} is shared across every subscriber and the
5
+ * history backfill, so live and replayed frames converge on identical state.
6
+ * Subscribers see only live frames; history is returned as a separate page
7
+ * (the app renders backfill itself, matching Ably's historyBeforeSubscribe).
8
+ */
9
+
10
+ import type { Channel, MessageFrame, UnsubscribeFn } from '@foony/realtime';
11
+ import { MESSAGE_EVENT, PAYLOAD_VERSION, type MessagePayload } from './protocol.js';
12
+ import { MessageReconciler } from './reconciler.js';
13
+ import type { ChatMessageEvent, Message, MessagePage, SendMessageParams, UpdateMessageParams } from './types.js';
14
+ import { newMessageId } from './util.js';
15
+
16
+ /** Listener invoked for every materialized message change on the room. */
17
+ export type MessageListener = (event: ChatMessageEvent) => void;
18
+
19
+ /**
20
+ * Retention requested for chat messages: the maximum the platform offers (1
21
+ * year). The edge clamps this down to the app's plan ceiling, so a chat message
22
+ * persists as long as the plan allows — versus typing/reactions, which are left
23
+ * at the short ephemeral default. One year in milliseconds.
24
+ */
25
+ const MESSAGE_TTL_MS = 365 * 24 * 60 * 60 * 1000;
26
+
27
+ /** The message feature of a {@link Room}. */
28
+ export class Messages {
29
+ private readonly reconciler: MessageReconciler;
30
+ private readonly listeners = new Set<MessageListener>();
31
+ private channelUnsubscribe: UnsubscribeFn | null = null;
32
+
33
+ constructor(
34
+ private readonly channel: Channel,
35
+ private readonly roomName: string,
36
+ private readonly getClientId: () => string | null,
37
+ ) {
38
+ this.reconciler = new MessageReconciler(roomName);
39
+ }
40
+
41
+ /** Send a new message. Returns the message optimistically; its `id` is stable. */
42
+ async send(params: SendMessageParams): Promise<Message> {
43
+ const id = newMessageId();
44
+ const payload: MessagePayload = {
45
+ v: PAYLOAD_VERSION,
46
+ action: 'create',
47
+ id,
48
+ text: params.text,
49
+ ...(params.metadata === undefined ? {} : { metadata: params.metadata }),
50
+ ...(params.headers === undefined ? {} : { headers: params.headers }),
51
+ };
52
+ await this.channel.publish(MESSAGE_EVENT, payload, { ttlMs: MESSAGE_TTL_MS });
53
+ const now = new Date();
54
+ return {
55
+ id,
56
+ clientId: this.getClientId() ?? '',
57
+ roomName: this.roomName,
58
+ text: params.text,
59
+ metadata: params.metadata ?? {},
60
+ headers: params.headers ?? {},
61
+ createdAt: now,
62
+ updatedAt: now,
63
+ action: 'create',
64
+ deleted: false,
65
+ };
66
+ }
67
+
68
+ /** Edit a message by id. Replaces text/metadata/headers (omitted fields clear). */
69
+ async update(id: string, params: UpdateMessageParams): Promise<void> {
70
+ const payload: MessagePayload = {
71
+ v: PAYLOAD_VERSION,
72
+ action: 'update',
73
+ id,
74
+ text: params.text,
75
+ ...(params.metadata === undefined ? {} : { metadata: params.metadata }),
76
+ ...(params.headers === undefined ? {} : { headers: params.headers }),
77
+ };
78
+ await this.channel.publish(MESSAGE_EVENT, payload, { ttlMs: MESSAGE_TTL_MS });
79
+ }
80
+
81
+ /** Delete a message by id. */
82
+ async delete(id: string): Promise<void> {
83
+ const payload: MessagePayload = { v: PAYLOAD_VERSION, action: 'delete', id };
84
+ await this.channel.publish(MESSAGE_EVENT, payload, { ttlMs: MESSAGE_TTL_MS });
85
+ }
86
+
87
+ /**
88
+ * Subscribe to live message changes. The first subscriber attaches the
89
+ * underlying channel subscription; the last to unsubscribe removes it.
90
+ */
91
+ subscribe(listener: MessageListener): UnsubscribeFn {
92
+ this.listeners.add(listener);
93
+ this.ensureChannelSubscription();
94
+ return () => {
95
+ this.listeners.delete(listener);
96
+ if (this.listeners.size === 0 && this.channelUnsubscribe) {
97
+ this.channelUnsubscribe();
98
+ this.channelUnsubscribe = null;
99
+ }
100
+ };
101
+ }
102
+
103
+ /**
104
+ * Fetch a page of past messages, oldest-first, materialized through the same
105
+ * reconciler as the live stream. Pass `cursor` (a previous page's
106
+ * `nextCursor`) to page further back.
107
+ */
108
+ async history(params?: { limit?: number; cursor?: string }): Promise<MessagePage> {
109
+ const { messages: frames, more } = await this.channel.history({
110
+ ...(params?.limit === undefined ? {} : { limit: params.limit }),
111
+ ...(params?.cursor === undefined ? {} : { start: params.cursor }),
112
+ });
113
+ const touched: string[] = [];
114
+ for (const frame of frames) {
115
+ if (frame.name !== MESSAGE_EVENT) {
116
+ continue;
117
+ }
118
+ this.reconciler.apply(frame);
119
+ const id = messageIdOf(frame);
120
+ if (id !== null && !touched.includes(id)) {
121
+ touched.push(id);
122
+ }
123
+ }
124
+ const messages = touched
125
+ .map((id) => this.reconciler.get(id))
126
+ .filter((message): message is Message => message !== undefined);
127
+ return {
128
+ messages,
129
+ hasMore: more,
130
+ ...(frames.length > 0 && frames[0] ? { nextCursor: frames[0].messageId } : {}),
131
+ };
132
+ }
133
+
134
+ /** Lazily attach the single channel subscription that feeds the reconciler. */
135
+ private ensureChannelSubscription(): void {
136
+ if (this.channelUnsubscribe) {
137
+ return;
138
+ }
139
+ this.channelUnsubscribe = this.channel.subscribe(MESSAGE_EVENT, (frame) => {
140
+ const event = this.reconciler.apply(frame);
141
+ if (event === null) {
142
+ return;
143
+ }
144
+ for (const listener of [...this.listeners]) {
145
+ listener(event);
146
+ }
147
+ });
148
+ }
149
+ }
150
+
151
+ /** The chat message id a frame refers to (payload `id`), or null if unparseable. */
152
+ function messageIdOf(frame: MessageFrame): string | null {
153
+ const data = frame.data;
154
+ if (typeof data !== 'object' || data === null) {
155
+ return null;
156
+ }
157
+ const id = (data as Record<string, unknown>)['id'];
158
+ return typeof id === 'string' && id !== '' ? id : null;
159
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Per-room occupancy, derived entirely from the presence set — no edge call.
3
+ *
4
+ * `connections` counts distinct (clientId, connectionId) pairs present (a user
5
+ * on two devices = two connections); `presenceMembers` counts distinct client
6
+ * ids. This reuses presence's TTL-based ghost-leave detection, so counts
7
+ * self-heal after a crash. Limitation: it only counts members who entered
8
+ * presence — always the case in a chat room, where joining enters presence.
9
+ */
10
+
11
+ import type { UnsubscribeFn } from '@foony/realtime';
12
+ import type { Presence } from './presence.js';
13
+ import type { Occupancy as OccupancyData } from './types.js';
14
+
15
+ /** Listener invoked when occupancy counts change. */
16
+ export type OccupancyListener = (occupancy: OccupancyData) => void;
17
+
18
+ /** Default debounce for occupancy change events. */
19
+ const DEFAULT_DEBOUNCE_MS = 1_000;
20
+
21
+ /** The occupancy feature of a {@link Room}. */
22
+ export class Occupancy {
23
+ private readonly listeners = new Set<OccupancyListener>();
24
+ private readonly debounceMs: number;
25
+ private debounceTimer: ReturnType<typeof setTimeout> | null = null;
26
+ private lastEmitted: OccupancyData | null = null;
27
+ private membersChangedUnsubscribe: UnsubscribeFn | null = null;
28
+
29
+ constructor(
30
+ private readonly presence: Presence,
31
+ debounceMs?: number,
32
+ ) {
33
+ this.debounceMs = debounceMs ?? DEFAULT_DEBOUNCE_MS;
34
+ }
35
+
36
+ /** Current occupancy snapshot, computed from the local presence member set. */
37
+ get(): OccupancyData {
38
+ const members = this.presence.get();
39
+ const clientIds = new Set(members.map((member) => member.clientId));
40
+ return { connections: members.length, presenceMembers: clientIds.size };
41
+ }
42
+
43
+ /** Subscribe to occupancy changes (debounced, deduplicated). */
44
+ subscribe(listener: OccupancyListener): UnsubscribeFn {
45
+ this.listeners.add(listener);
46
+ if (!this.membersChangedUnsubscribe) {
47
+ this.membersChangedUnsubscribe = this.presence.onMembersChanged(() => this.scheduleEmit());
48
+ }
49
+ return () => {
50
+ this.listeners.delete(listener);
51
+ if (this.listeners.size === 0 && this.membersChangedUnsubscribe) {
52
+ this.membersChangedUnsubscribe();
53
+ this.membersChangedUnsubscribe = null;
54
+ if (this.debounceTimer) {
55
+ clearTimeout(this.debounceTimer);
56
+ this.debounceTimer = null;
57
+ }
58
+ }
59
+ };
60
+ }
61
+
62
+ private scheduleEmit(): void {
63
+ if (this.debounceTimer) {
64
+ return;
65
+ }
66
+ this.debounceTimer = setTimeout(() => {
67
+ this.debounceTimer = null;
68
+ const current = this.get();
69
+ if (this.lastEmitted && this.lastEmitted.connections === current.connections && this.lastEmitted.presenceMembers === current.presenceMembers) {
70
+ return;
71
+ }
72
+ this.lastEmitted = current;
73
+ for (const listener of [...this.listeners]) {
74
+ listener(current);
75
+ }
76
+ }, this.debounceMs);
77
+ }
78
+ }
Binary file
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Internal wire conventions for the chat layer: the reserved `name` values
3
+ * used on the underlying realtime channel and the JSON payload shapes carried
4
+ * in each. Not part of the public API — kept in one place so producers and the
5
+ * reconciler agree.
6
+ */
7
+
8
+ /** Channel name for a room. The edge grammar forbids `$`, so we namespace with `:`. */
9
+ export function roomChannelName(roomName: string): string {
10
+ return `chat:${roomName}`;
11
+ }
12
+
13
+ /** Event name carrying message create/update/delete payloads. */
14
+ export const MESSAGE_EVENT = 'chat.message';
15
+ /** Event name carrying ephemeral room-level reactions. */
16
+ export const REACTION_EVENT = 'chat.reaction';
17
+ /** Event name carrying ephemeral typing heartbeats and stops. */
18
+ export const TYPING_EVENT = 'chat.typing';
19
+
20
+ /** Payload schema version, bumped if the on-channel shape ever changes. */
21
+ export const PAYLOAD_VERSION = 1;
22
+
23
+ /** Payload published under {@link MESSAGE_EVENT}. `id` is the message id in all actions. */
24
+ export type MessagePayload =
25
+ | { readonly v: number; readonly action: 'create'; readonly id: string; readonly text: string; readonly metadata?: Record<string, unknown>; readonly headers?: Record<string, unknown> }
26
+ | { readonly v: number; readonly action: 'update'; readonly id: string; readonly text: string; readonly metadata?: Record<string, unknown>; readonly headers?: Record<string, unknown> }
27
+ | { readonly v: number; readonly action: 'delete'; readonly id: string };
28
+
29
+ /** Payload published under {@link TYPING_EVENT}. */
30
+ export type TypingPayload = { readonly state: 'started' | 'stopped' };
31
+
32
+ /** Payload published under {@link REACTION_EVENT}. */
33
+ export type ReactionPayload = { readonly name: string; readonly metadata?: unknown };
34
+
35
+ /** Parse an unknown message payload, returning null if it is malformed or a foreign frame. */
36
+ export function parseMessagePayload(data: unknown): MessagePayload | null {
37
+ if (typeof data !== 'object' || data === null) {
38
+ return null;
39
+ }
40
+ const payload = data as Record<string, unknown>;
41
+ const action = payload['action'];
42
+ const id = payload['id'];
43
+ if (typeof id !== 'string' || id === '') {
44
+ return null;
45
+ }
46
+ if (action === 'delete') {
47
+ return { v: PAYLOAD_VERSION, action, id };
48
+ }
49
+ if ((action === 'create' || action === 'update') && typeof payload['text'] === 'string') {
50
+ const text = payload['text'] as string;
51
+ const metadata = isRecord(payload['metadata']) ? (payload['metadata'] as Record<string, unknown>) : undefined;
52
+ const headers = isRecord(payload['headers']) ? (payload['headers'] as Record<string, unknown>) : undefined;
53
+ return {
54
+ v: PAYLOAD_VERSION,
55
+ action,
56
+ id,
57
+ text,
58
+ ...(metadata === undefined ? {} : { metadata }),
59
+ ...(headers === undefined ? {} : { headers }),
60
+ };
61
+ }
62
+ return null;
63
+ }
64
+
65
+ /** Parse an unknown typing payload, returning null if malformed. */
66
+ export function parseTypingPayload(data: unknown): TypingPayload | null {
67
+ if (typeof data !== 'object' || data === null) {
68
+ return null;
69
+ }
70
+ const state = (data as Record<string, unknown>)['state'];
71
+ return state === 'started' || state === 'stopped' ? { state } : null;
72
+ }
73
+
74
+ /** Parse an unknown reaction payload, returning null if malformed. */
75
+ export function parseReactionPayload(data: unknown): ReactionPayload | null {
76
+ if (typeof data !== 'object' || data === null) {
77
+ return null;
78
+ }
79
+ const name = (data as Record<string, unknown>)['name'];
80
+ if (typeof name !== 'string' || name === '') {
81
+ return null;
82
+ }
83
+ const metadata = (data as Record<string, unknown>)['metadata'];
84
+ return metadata === undefined ? { name } : { name, metadata };
85
+ }
86
+
87
+ /** True when `value` is a non-null, non-array object. */
88
+ function isRecord(value: unknown): value is Record<string, unknown> {
89
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
90
+ }