@foony/chat 0.1.0 → 0.3.0

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 (47) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/lib/chatClient.d.ts +12 -1
  3. package/lib/chatClient.d.ts.map +1 -1
  4. package/lib/chatClient.js +2 -2
  5. package/lib/chatClient.js.map +1 -1
  6. package/lib/index.d.ts +2 -1
  7. package/lib/index.d.ts.map +1 -1
  8. package/lib/index.js +1 -0
  9. package/lib/index.js.map +1 -1
  10. package/lib/messages.d.ts +45 -2
  11. package/lib/messages.d.ts.map +1 -1
  12. package/lib/messages.js +216 -16
  13. package/lib/messages.js.map +1 -1
  14. package/lib/room.d.ts +2 -1
  15. package/lib/room.d.ts.map +1 -1
  16. package/lib/room.js +2 -2
  17. package/lib/room.js.map +1 -1
  18. package/lib/rooms.d.ts +3 -1
  19. package/lib/rooms.d.ts.map +1 -1
  20. package/lib/rooms.js +4 -2
  21. package/lib/rooms.js.map +1 -1
  22. package/lib/storage.d.ts +33 -0
  23. package/lib/storage.d.ts.map +1 -0
  24. package/lib/storage.js +53 -0
  25. package/lib/storage.js.map +1 -0
  26. package/lib/types.d.ts +2 -2
  27. package/lib/types.d.ts.map +1 -1
  28. package/lib-cjs/chatClient.js +2 -2
  29. package/lib-cjs/chatClient.js.map +1 -1
  30. package/lib-cjs/index.js +3 -1
  31. package/lib-cjs/index.js.map +1 -1
  32. package/lib-cjs/messages.js +216 -16
  33. package/lib-cjs/messages.js.map +1 -1
  34. package/lib-cjs/room.js +2 -2
  35. package/lib-cjs/room.js.map +1 -1
  36. package/lib-cjs/rooms.js +4 -2
  37. package/lib-cjs/rooms.js.map +1 -1
  38. package/lib-cjs/storage.js +56 -0
  39. package/lib-cjs/storage.js.map +1 -0
  40. package/package.json +3 -2
  41. package/src/chatClient.ts +14 -2
  42. package/src/index.ts +2 -1
  43. package/src/messages.ts +224 -16
  44. package/src/room.ts +3 -1
  45. package/src/rooms.ts +3 -1
  46. package/src/storage.ts +80 -0
  47. package/src/types.ts +2 -2
@@ -0,0 +1,56 @@
1
+ "use strict";
2
+ /**
3
+ * Optional message persistence. A {@link ChatStorage} keeps a per-room snapshot between page
4
+ * loads, so a returning client renders instantly from its own copy and the server replays only
5
+ * what was missed (via the channel's resume cursor) instead of serving history again.
6
+ */
7
+ Object.defineProperty(exports, "__esModule", { value: true });
8
+ exports.indexedDbChatStorage = indexedDbChatStorage;
9
+ /** Object store holding one record per room inside the IndexedDB database. */
10
+ const STORE = 'rooms';
11
+ /**
12
+ * A {@link ChatStorage} backed by IndexedDB, or null where IndexedDB does not exist (Node,
13
+ * some webviews) so callers can pass the result straight to `new ChatClient(...)`.
14
+ *
15
+ * @example
16
+ * const chat = new ChatClient(realtime, { storage: indexedDbChatStorage() ?? undefined });
17
+ */
18
+ function indexedDbChatStorage(dbName = 'foony-chat') {
19
+ if (typeof indexedDB === 'undefined') {
20
+ return null;
21
+ }
22
+ const database = openDatabase(dbName);
23
+ return {
24
+ async load(room) {
25
+ const db = await database;
26
+ return await requestOf(db.transaction(STORE, 'readonly').objectStore(STORE).get(room)) ?? null;
27
+ },
28
+ async save(room, state) {
29
+ const db = await database;
30
+ await requestOf(db.transaction(STORE, 'readwrite').objectStore(STORE).put(state, room));
31
+ },
32
+ async remove(room) {
33
+ const db = await database;
34
+ await requestOf(db.transaction(STORE, 'readwrite').objectStore(STORE).delete(room));
35
+ },
36
+ };
37
+ }
38
+ /** Open (or create) the database with its single room store. */
39
+ function openDatabase(dbName) {
40
+ return new Promise((resolve, reject) => {
41
+ const request = indexedDB.open(dbName, 1);
42
+ request.onupgradeneeded = () => {
43
+ request.result.createObjectStore(STORE);
44
+ };
45
+ request.onsuccess = () => resolve(request.result);
46
+ request.onerror = () => reject(request.error ?? new Error('indexedDB open failed'));
47
+ });
48
+ }
49
+ /** Promisify one IDBRequest. */
50
+ function requestOf(request) {
51
+ return new Promise((resolve, reject) => {
52
+ request.onsuccess = () => resolve(request.result);
53
+ request.onerror = () => reject(request.error ?? new Error('indexedDB request failed'));
54
+ });
55
+ }
56
+ //# sourceMappingURL=storage.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"storage.js","sourceRoot":"","sources":["../src/storage.ts"],"names":[],"mappings":";AAAA;;;;GAIG;;AAkCH,oDAqBC;AA/BD,8EAA8E;AAC9E,MAAM,KAAK,GAAG,OAAO,CAAC;AAEtB;;;;;;GAMG;AACH,SAAgB,oBAAoB,CAAC,MAAM,GAAG,YAAY;IACxD,IAAI,OAAO,SAAS,KAAK,WAAW,EAAE,CAAC;QACrC,OAAO,IAAI,CAAC;IACd,CAAC;IACD,MAAM,QAAQ,GAAG,YAAY,CAAC,MAAM,CAAC,CAAC;IACtC,OAAO;QACL,KAAK,CAAC,IAAI,CAAC,IAAY;YACrB,MAAM,EAAE,GAAG,MAAM,QAAQ,CAAC;YAC1B,OAAO,MAAM,SAAS,CACpB,EAAE,CAAC,WAAW,CAAC,KAAK,EAAE,UAAU,CAAC,CAAC,WAAW,CAAC,KAAK,CAAC,CAAC,GAAG,CAAC,IAAI,CAAC,CAC/D,IAAI,IAAI,CAAC;QACZ,CAAC;QACD,KAAK,CAAC,IAAI,CAAC,IAAY,EAAE,KAAyB;YAChD,MAAM,EAAE,GAAG,MAAM,QAAQ,CAAC;YAC1B,MAAM,SAAS,CAAC,EAAE,CAAC,WAAW,CAAC,KAAK,EAAE,WAAW,CAAC,CAAC,WAAW,CAAC,KAAK,CAAC,CAAC,GAAG,CAAC,KAAK,EAAE,IAAI,CAAC,CAAC,CAAC;QAC1F,CAAC;QACD,KAAK,CAAC,MAAM,CAAC,IAAY;YACvB,MAAM,EAAE,GAAG,MAAM,QAAQ,CAAC;YAC1B,MAAM,SAAS,CAAC,EAAE,CAAC,WAAW,CAAC,KAAK,EAAE,WAAW,CAAC,CAAC,WAAW,CAAC,KAAK,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC;QACtF,CAAC;KACF,CAAC;AACJ,CAAC;AAED,gEAAgE;AAChE,SAAS,YAAY,CAAC,MAAc;IAClC,OAAO,IAAI,OAAO,CAAC,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE;QACrC,MAAM,OAAO,GAAG,SAAS,CAAC,IAAI,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC;QAC1C,OAAO,CAAC,eAAe,GAAG,GAAG,EAAE;YAC7B,OAAO,CAAC,MAAM,CAAC,iBAAiB,CAAC,KAAK,CAAC,CAAC;QAC1C,CAAC,CAAC;QACF,OAAO,CAAC,SAAS,GAAG,GAAG,EAAE,CAAC,OAAO,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC;QAClD,OAAO,CAAC,OAAO,GAAG,GAAG,EAAE,CAAC,MAAM,CAAC,OAAO,CAAC,KAAK,IAAI,IAAI,KAAK,CAAC,uBAAuB,CAAC,CAAC,CAAC;IACtF,CAAC,CAAC,CAAC;AACL,CAAC;AAED,gCAAgC;AAChC,SAAS,SAAS,CAAI,OAAsB;IAC1C,OAAO,IAAI,OAAO,CAAC,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE;QACrC,OAAO,CAAC,SAAS,GAAG,GAAG,EAAE,CAAC,OAAO,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC;QAClD,OAAO,CAAC,OAAO,GAAG,GAAG,EAAE,CAAC,MAAM,CAAC,OAAO,CAAC,KAAK,IAAI,IAAI,KAAK,CAAC,0BAA0B,CAAC,CAAC,CAAC;IACzF,CAAC,CAAC,CAAC;AACL,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@foony/chat",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Chat API (rooms, messages, typing, reactions, presence, occupancy) built on @foony/realtime.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -35,6 +35,7 @@
35
35
  }
36
36
  },
37
37
  "scripts": {
38
+ "prepublishOnly": "npm run build && npm test",
38
39
  "build": "tsc && tsc -p tsconfig.cjs.json && node ./scripts/write-cjs-package-json.mjs",
39
40
  "test": "vitest run",
40
41
  "test:watch": "vitest"
@@ -43,7 +44,7 @@
43
44
  "access": "public"
44
45
  },
45
46
  "peerDependencies": {
46
- "@foony/realtime": ">=0.3.0"
47
+ "@foony/realtime": ">=0.16.0"
47
48
  },
48
49
  "devDependencies": {
49
50
  "@foony/realtime": "file:../realtime-js",
package/src/chatClient.ts CHANGED
@@ -6,14 +6,26 @@
6
6
 
7
7
  import type { Connection, Realtime } from '@foony/realtime';
8
8
  import { Rooms } from './rooms.js';
9
+ import type { ChatStorage } from './storage.js';
10
+
11
+ /** Client-wide options. */
12
+ export type ChatClientOptions = {
13
+ /**
14
+ * Persist each room's recent messages between page loads (see
15
+ * {@link indexedDbChatStorage | `indexedDbChatStorage()`}). A returning client renders from
16
+ * its stored copy and the server replays only what it missed, instead of serving history
17
+ * again on every visit. Omit to keep everything in memory.
18
+ */
19
+ readonly storage?: ChatStorage;
20
+ };
9
21
 
10
22
  /** Chat client built on top of a {@link Realtime} instance. */
11
23
  export class ChatClient {
12
24
  /** Room registry and factory. */
13
25
  readonly rooms: Rooms;
14
26
 
15
- constructor(private readonly realtime: Realtime) {
16
- this.rooms = new Rooms(realtime, () => this.clientId);
27
+ constructor(private readonly realtime: Realtime, options?: ChatClientOptions) {
28
+ this.rooms = new Rooms(realtime, () => this.clientId, options?.storage ?? null);
17
29
  }
18
30
 
19
31
  /** The underlying realtime connection (status, events). */
package/src/index.ts CHANGED
@@ -7,7 +7,8 @@
7
7
  * package (planned as a separate `@foony/chat/react` subpath).
8
8
  */
9
9
 
10
- export { ChatClient } from './chatClient.js';
10
+ export { ChatClient, type ChatClientOptions } from './chatClient.js';
11
+ export { indexedDbChatStorage, type ChatStorage, type PersistedRoomState } from './storage.js';
11
12
  export { Rooms } from './rooms.js';
12
13
  export { Room, type RoomStatus, type DiscontinuityListener } from './room.js';
13
14
  export { Messages, type MessageListener } from './messages.js';
package/src/messages.ts CHANGED
@@ -10,6 +10,7 @@
10
10
  import type { Channel, MessageFrame, UnsubscribeFn } from '@foony/realtime';
11
11
  import { MESSAGE_EVENT, PAYLOAD_VERSION, type MessagePayload } from './protocol.js';
12
12
  import { MessageReconciler } from './reconciler.js';
13
+ import type { ChatStorage } from './storage.js';
13
14
  import type { ChatMessageEvent, Message, MessagePage, SendMessageParams, UpdateMessageParams } from './types.js';
14
15
  import { newMessageId } from './util.js';
15
16
 
@@ -17,12 +18,19 @@ import { newMessageId } from './util.js';
17
18
  export type MessageListener = (event: ChatMessageEvent) => void;
18
19
 
19
20
  /**
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.
21
+ * How many stored frames a room keeps. Matches a deep scrollback page: anything older is
22
+ * re-fetchable through `history()` and not worth the disk.
24
23
  */
25
- const MESSAGE_TTL_MS = 365 * 24 * 60 * 60 * 1000;
24
+ const PERSIST_MAX_FRAMES = 300;
25
+
26
+ /** Delay between an applied frame and the storage write, so bursts save once. */
27
+ const PERSIST_DEBOUNCE_MS = 300;
28
+
29
+ /** Page size fetched when a stored resume cursor has aged out of retention. */
30
+ const RECOVER_PAGE_LIMIT = 100;
31
+
32
+ /** Default history page size, matching the server's default. */
33
+ const DEFAULT_HISTORY_LIMIT = 100;
26
34
 
27
35
  /** The message feature of a {@link Room}. */
28
36
  export class Messages {
@@ -30,12 +38,108 @@ export class Messages {
30
38
  private readonly listeners = new Set<MessageListener>();
31
39
  private channelUnsubscribe: UnsubscribeFn | null = null;
32
40
 
41
+ /** Resolves once the stored snapshot (if any) has been replayed and the resume cursor seeded. */
42
+ private readonly ready: Promise<void>;
43
+ /** Frames worth persisting (those with a seq), oldest-first after each normalize. */
44
+ private frameLog: MessageFrame[] = [];
45
+ /** True when older messages remain on the server below the stored window. */
46
+ private storedHasMore = false;
47
+ /** The seeded resume serial, until the first attach reports whether it held. */
48
+ private seededSerial = 0;
49
+ private channelStateOff: UnsubscribeFn | null = null;
50
+ private persistTimer: ReturnType<typeof setTimeout> | null = null;
51
+ private pendingSubscribe = false;
52
+
33
53
  constructor(
34
54
  private readonly channel: Channel,
35
55
  private readonly roomName: string,
36
56
  private readonly getClientId: () => string | null,
57
+ private readonly storage: ChatStorage | null = null,
37
58
  ) {
38
59
  this.reconciler = new MessageReconciler(roomName);
60
+ this.ready = this.storage ? this.restore() : Promise.resolve();
61
+ }
62
+
63
+ /**
64
+ * Replay the stored snapshot into the reconciler and seed the channel's resume cursor, so the
65
+ * first attach replays only the gap. Runs before the first channel subscribe (see
66
+ * `ensureChannelSubscription`), because a seed after attach is a no-op.
67
+ */
68
+ private async restore(): Promise<void> {
69
+ let state = null;
70
+ try {
71
+ state = await this.storage!.load(this.roomName);
72
+ } catch {
73
+ return;
74
+ }
75
+ if (!state || state.frames.length === 0) {
76
+ return;
77
+ }
78
+ for (const frame of state.frames) {
79
+ if (frame.name === MESSAGE_EVENT) {
80
+ this.reconciler.apply(frame);
81
+ }
82
+ if (frame.seq !== undefined && frame.seq > 0) {
83
+ this.frameLog.push(frame);
84
+ }
85
+ }
86
+ this.storedHasMore = state.hasMore;
87
+ if (state.serial > 0) {
88
+ this.seededSerial = state.serial;
89
+ this.channel.resumeFrom(state.serial);
90
+ this.channelStateOff = this.channel.on((change) => this.onChannelState(change));
91
+ }
92
+ }
93
+
94
+ /**
95
+ * The seeded cursor is judged by the first attach: `resumed: false` means the serial aged out
96
+ * of retention, so the stored copy may be missing messages and is thrown away.
97
+ */
98
+ private onChannelState(change: { readonly current: string; readonly resumed: boolean }): void {
99
+ if (change.current !== 'attached' || this.seededSerial === 0) {
100
+ return;
101
+ }
102
+ this.seededSerial = 0;
103
+ this.channelStateOff?.();
104
+ this.channelStateOff = null;
105
+ if (!change.resumed) {
106
+ void this.recoverFromLostCursor();
107
+ }
108
+ }
109
+
110
+ /**
111
+ * The stored window could not be stitched to the live stream, so drop it and fetch a fresh
112
+ * page, emitting the result to subscribers like live traffic (their render already happened
113
+ * from the stale copy).
114
+ */
115
+ private async recoverFromLostCursor(): Promise<void> {
116
+ try {
117
+ await this.storage!.remove(this.roomName);
118
+ } catch {
119
+ // Best effort: a failed remove leaves a stale copy that the next failed resume retries.
120
+ }
121
+ this.frameLog = [];
122
+ this.storedHasMore = true;
123
+ try {
124
+ const { messages: frames, more } = await this.channel.history({ limit: RECOVER_PAGE_LIMIT });
125
+ this.storedHasMore = more;
126
+ for (const frame of frames) {
127
+ this.addToFrameLog(frame);
128
+ if (frame.name !== MESSAGE_EVENT) {
129
+ continue;
130
+ }
131
+ const event = this.reconciler.apply(frame);
132
+ if (event === null) {
133
+ continue;
134
+ }
135
+ for (const listener of [...this.listeners]) {
136
+ listener(event);
137
+ }
138
+ }
139
+ this.schedulePersist();
140
+ } catch {
141
+ // The room still works live; the next history() call backfills.
142
+ }
39
143
  }
40
144
 
41
145
  /** Send a new message. Returns the message optimistically; its `id` is stable. */
@@ -49,7 +153,7 @@ export class Messages {
49
153
  ...(params.metadata === undefined ? {} : { metadata: params.metadata }),
50
154
  ...(params.headers === undefined ? {} : { headers: params.headers }),
51
155
  };
52
- await this.channel.publish(MESSAGE_EVENT, payload, { ttlMs: MESSAGE_TTL_MS });
156
+ await this.channel.publish(MESSAGE_EVENT, payload);
53
157
  const now = new Date();
54
158
  return {
55
159
  id,
@@ -75,13 +179,13 @@ export class Messages {
75
179
  ...(params.metadata === undefined ? {} : { metadata: params.metadata }),
76
180
  ...(params.headers === undefined ? {} : { headers: params.headers }),
77
181
  };
78
- await this.channel.publish(MESSAGE_EVENT, payload, { ttlMs: MESSAGE_TTL_MS });
182
+ await this.channel.publish(MESSAGE_EVENT, payload);
79
183
  }
80
184
 
81
185
  /** Delete a message by id. */
82
186
  async delete(id: string): Promise<void> {
83
187
  const payload: MessagePayload = { v: PAYLOAD_VERSION, action: 'delete', id };
84
- await this.channel.publish(MESSAGE_EVENT, payload, { ttlMs: MESSAGE_TTL_MS });
188
+ await this.channel.publish(MESSAGE_EVENT, payload);
85
189
  }
86
190
 
87
191
  /**
@@ -105,13 +209,23 @@ export class Messages {
105
209
  * reconciler as the live stream. Pass `cursor` (a previous page's
106
210
  * `nextCursor`) to page further back.
107
211
  */
108
- async history(params?: { limit?: number; cursor?: string }): Promise<MessagePage> {
212
+ async history(params?: { limit?: number; cursor?: number }): Promise<MessagePage> {
213
+ if (this.storage) {
214
+ await this.ready;
215
+ if (params?.cursor === undefined) {
216
+ const stored = this.storedPage(params?.limit ?? DEFAULT_HISTORY_LIMIT);
217
+ if (stored) {
218
+ return stored;
219
+ }
220
+ }
221
+ }
109
222
  const { messages: frames, more } = await this.channel.history({
110
223
  ...(params?.limit === undefined ? {} : { limit: params.limit }),
111
- ...(params?.cursor === undefined ? {} : { start: params.cursor }),
224
+ ...(params?.cursor === undefined ? {} : { before: params.cursor }),
112
225
  });
113
226
  const touched: string[] = [];
114
227
  for (const frame of frames) {
228
+ this.addToFrameLog(frame);
115
229
  if (frame.name !== MESSAGE_EVENT) {
116
230
  continue;
117
231
  }
@@ -121,30 +235,124 @@ export class Messages {
121
235
  touched.push(id);
122
236
  }
123
237
  }
238
+ this.schedulePersist();
124
239
  const messages = touched
125
240
  .map((id) => this.reconciler.get(id))
126
241
  .filter((message): message is Message => message !== undefined);
127
242
  return {
128
243
  messages,
129
244
  hasMore: more,
130
- ...(frames.length > 0 && frames[0] ? { nextCursor: frames[0].messageId } : {}),
245
+ ...(frames.length > 0 && frames[0]?.seq !== undefined ? { nextCursor: frames[0].seq } : {}),
246
+ };
247
+ }
248
+
249
+ /**
250
+ * Build the newest page from the stored window, or null when the window is too small for the
251
+ * request (fewer frames than asked and older messages remain on the server).
252
+ */
253
+ private storedPage(limit: number): MessagePage | null {
254
+ this.normalizeFrameLog();
255
+ if (this.frameLog.length === 0 || (this.frameLog.length < limit && this.storedHasMore)) {
256
+ return null;
257
+ }
258
+ const window = this.frameLog.slice(-limit);
259
+ const touched: string[] = [];
260
+ for (const frame of window) {
261
+ const id = messageIdOf(frame);
262
+ if (id !== null && !touched.includes(id)) {
263
+ touched.push(id);
264
+ }
265
+ }
266
+ const messages = touched
267
+ .map((id) => this.reconciler.get(id))
268
+ .filter((message): message is Message => message !== undefined);
269
+ return {
270
+ messages,
271
+ hasMore: this.storedHasMore || this.frameLog.length > window.length,
272
+ ...(window[0]?.seq === undefined ? {} : { nextCursor: window[0].seq }),
131
273
  };
132
274
  }
133
275
 
134
276
  /** Lazily attach the single channel subscription that feeds the reconciler. */
135
277
  private ensureChannelSubscription(): void {
136
- if (this.channelUnsubscribe) {
278
+ if (this.channelUnsubscribe || this.pendingSubscribe) {
279
+ return;
280
+ }
281
+ if (!this.storage) {
282
+ this.channelUnsubscribe = this.channel.subscribe(MESSAGE_EVENT, (frame) => this.onLiveFrame(frame));
137
283
  return;
138
284
  }
139
- this.channelUnsubscribe = this.channel.subscribe(MESSAGE_EVENT, (frame) => {
140
- const event = this.reconciler.apply(frame);
141
- if (event === null) {
285
+ // With storage, the subscribe waits for the restore: the resume seed must be in place
286
+ // before the attach the subscription triggers, or the server backfills nothing.
287
+ this.pendingSubscribe = true;
288
+ void this.ready.then(() => {
289
+ this.pendingSubscribe = false;
290
+ if (this.channelUnsubscribe || this.listeners.size === 0) {
142
291
  return;
143
292
  }
293
+ this.channelUnsubscribe = this.channel.subscribe(MESSAGE_EVENT, (frame) => this.onLiveFrame(frame));
294
+ });
295
+ }
296
+
297
+ /** Apply one live frame, fan it out, and keep the stored window fresh. */
298
+ private onLiveFrame(frame: MessageFrame): void {
299
+ this.addToFrameLog(frame);
300
+ const event = this.reconciler.apply(frame);
301
+ if (event !== null) {
144
302
  for (const listener of [...this.listeners]) {
145
303
  listener(event);
146
304
  }
147
- });
305
+ }
306
+ this.schedulePersist();
307
+ }
308
+
309
+ /** Track a sequenced frame for persistence. No-op without storage. */
310
+ private addToFrameLog(frame: MessageFrame): void {
311
+ if (!this.storage || frame.seq === undefined || frame.seq <= 0) {
312
+ return;
313
+ }
314
+ this.frameLog.push(frame);
315
+ if (this.frameLog.length > PERSIST_MAX_FRAMES * 2) {
316
+ this.normalizeFrameLog();
317
+ }
318
+ }
319
+
320
+ /** Sort, dedupe by seq, and trim the frame log to the newest window. */
321
+ private normalizeFrameLog(): void {
322
+ if (this.frameLog.length === 0) {
323
+ return;
324
+ }
325
+ const bySeq = new Map<number, MessageFrame>();
326
+ for (const frame of this.frameLog) {
327
+ bySeq.set(frame.seq!, frame);
328
+ }
329
+ const sorted = [...bySeq.values()].sort((left, right) => left.seq! - right.seq!);
330
+ if (sorted.length > PERSIST_MAX_FRAMES) {
331
+ this.frameLog = sorted.slice(-PERSIST_MAX_FRAMES);
332
+ this.storedHasMore = true;
333
+ } else {
334
+ this.frameLog = sorted;
335
+ }
336
+ }
337
+
338
+ /** Save the stored window shortly, coalescing bursts into one write. */
339
+ private schedulePersist(): void {
340
+ if (!this.storage || this.persistTimer !== null) {
341
+ return;
342
+ }
343
+ this.persistTimer = setTimeout(() => {
344
+ this.persistTimer = null;
345
+ this.normalizeFrameLog();
346
+ if (this.frameLog.length === 0) {
347
+ return;
348
+ }
349
+ const serial = this.frameLog[this.frameLog.length - 1]!.seq!;
350
+ void this.storage!
351
+ .save(this.roomName, { frames: [...this.frameLog], serial, hasMore: this.storedHasMore })
352
+ .catch(() => {
353
+ // Best effort: a lost save costs the next load a server history fetch, nothing more.
354
+ });
355
+ }, PERSIST_DEBOUNCE_MS);
148
356
  }
149
357
  }
150
358
 
package/src/room.ts CHANGED
@@ -6,6 +6,7 @@
6
6
  */
7
7
 
8
8
  import type { Channel, ChannelStateChange, UnsubscribeFn } from '@foony/realtime';
9
+ import type { ChatStorage } from './storage.js';
9
10
  import { Messages } from './messages.js';
10
11
  import { Occupancy } from './occupancy.js';
11
12
  import { Presence } from './presence.js';
@@ -41,8 +42,9 @@ export class Room {
41
42
  private readonly channel: Channel,
42
43
  getClientId: () => string | null,
43
44
  options?: RoomOptions,
45
+ storage: ChatStorage | null = null,
44
46
  ) {
45
- this.messages = new Messages(channel, name, getClientId);
47
+ this.messages = new Messages(channel, name, getClientId, storage);
46
48
  this.presence = new Presence(channel);
47
49
  this.typing = new Typing(channel, getClientId, options?.typing?.heartbeatThrottleMs);
48
50
  this.reactions = new Reactions(channel, getClientId);
package/src/rooms.ts CHANGED
@@ -7,6 +7,7 @@
7
7
  import type { Realtime } from '@foony/realtime';
8
8
  import { roomChannelName } from './protocol.js';
9
9
  import { Room } from './room.js';
10
+ import type { ChatStorage } from './storage.js';
10
11
  import type { RoomOptions } from './types.js';
11
12
 
12
13
  /** Factory and cache for {@link Room} instances on a {@link ChatClient}. */
@@ -16,6 +17,7 @@ export class Rooms {
16
17
  constructor(
17
18
  private readonly realtime: Realtime,
18
19
  private readonly getClientId: () => string | null,
20
+ private readonly storage: ChatStorage | null = null,
19
21
  ) {}
20
22
 
21
23
  /** Get (or create) the room named `name`. Stable instance per name. */
@@ -23,7 +25,7 @@ export class Rooms {
23
25
  let existing = this.byName.get(name);
24
26
  if (!existing) {
25
27
  const channel = this.realtime.channels.get(roomChannelName(name), options?.cipher ? { cipher: options.cipher } : undefined);
26
- existing = new Room(name, channel, this.getClientId, options);
28
+ existing = new Room(name, channel, this.getClientId, options, this.storage);
27
29
  this.byName.set(name, existing);
28
30
  }
29
31
  return existing;
package/src/storage.ts ADDED
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Optional message persistence. A {@link ChatStorage} keeps a per-room snapshot between page
3
+ * loads, so a returning client renders instantly from its own copy and the server replays only
4
+ * what was missed (via the channel's resume cursor) instead of serving history again.
5
+ */
6
+
7
+ import type { MessageFrame } from '@foony/realtime';
8
+
9
+ /** Snapshot of one room persisted between sessions. */
10
+ export type PersistedRoomState = {
11
+ /** Raw message frames, oldest-first, replayed through the reconciler on load. */
12
+ readonly frames: readonly MessageFrame[];
13
+ /** Newest stored seq, seeded as the resume cursor so the server replays only the gap. */
14
+ readonly serial: number;
15
+ /** True when older messages remained on the server below the stored window when saved. */
16
+ readonly hasMore: boolean;
17
+ };
18
+
19
+ /** Where room snapshots live. Implementations must tolerate concurrent tabs (last write wins). */
20
+ export type ChatStorage = {
21
+ /** The stored snapshot for `room`, or null when none exists. */
22
+ load(room: string): Promise<PersistedRoomState | null>;
23
+ /** Overwrite `room`'s snapshot. */
24
+ save(room: string, state: PersistedRoomState): Promise<void>;
25
+ /** Drop `room`'s snapshot (its resume cursor aged out, or the room was released). */
26
+ remove(room: string): Promise<void>;
27
+ };
28
+
29
+ /** Object store holding one record per room inside the IndexedDB database. */
30
+ const STORE = 'rooms';
31
+
32
+ /**
33
+ * A {@link ChatStorage} backed by IndexedDB, or null where IndexedDB does not exist (Node,
34
+ * some webviews) so callers can pass the result straight to `new ChatClient(...)`.
35
+ *
36
+ * @example
37
+ * const chat = new ChatClient(realtime, { storage: indexedDbChatStorage() ?? undefined });
38
+ */
39
+ export function indexedDbChatStorage(dbName = 'foony-chat'): ChatStorage | null {
40
+ if (typeof indexedDB === 'undefined') {
41
+ return null;
42
+ }
43
+ const database = openDatabase(dbName);
44
+ return {
45
+ async load(room: string): Promise<PersistedRoomState | null> {
46
+ const db = await database;
47
+ return await requestOf<PersistedRoomState | undefined>(
48
+ db.transaction(STORE, 'readonly').objectStore(STORE).get(room),
49
+ ) ?? null;
50
+ },
51
+ async save(room: string, state: PersistedRoomState): Promise<void> {
52
+ const db = await database;
53
+ await requestOf(db.transaction(STORE, 'readwrite').objectStore(STORE).put(state, room));
54
+ },
55
+ async remove(room: string): Promise<void> {
56
+ const db = await database;
57
+ await requestOf(db.transaction(STORE, 'readwrite').objectStore(STORE).delete(room));
58
+ },
59
+ };
60
+ }
61
+
62
+ /** Open (or create) the database with its single room store. */
63
+ function openDatabase(dbName: string): Promise<IDBDatabase> {
64
+ return new Promise((resolve, reject) => {
65
+ const request = indexedDB.open(dbName, 1);
66
+ request.onupgradeneeded = () => {
67
+ request.result.createObjectStore(STORE);
68
+ };
69
+ request.onsuccess = () => resolve(request.result);
70
+ request.onerror = () => reject(request.error ?? new Error('indexedDB open failed'));
71
+ });
72
+ }
73
+
74
+ /** Promisify one IDBRequest. */
75
+ function requestOf<T>(request: IDBRequest<T>): Promise<T> {
76
+ return new Promise((resolve, reject) => {
77
+ request.onsuccess = () => resolve(request.result);
78
+ request.onerror = () => reject(request.error ?? new Error('indexedDB request failed'));
79
+ });
80
+ }
package/src/types.ts CHANGED
@@ -70,8 +70,8 @@ export type MessagePage = {
70
70
  readonly messages: readonly Message[];
71
71
  /** True when older messages remain beyond this page. */
72
72
  readonly hasMore: boolean;
73
- /** Cursor (oldest message id in the page) to pass back for the next page. */
74
- readonly nextCursor?: string;
73
+ /** Cursor (the page's oldest message serial) to pass back for the next page. */
74
+ readonly nextCursor?: number;
75
75
  };
76
76
 
77
77
  /** A single presence member in a room. */