@noverachat/sdk-web 0.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.
package/src/client.ts ADDED
@@ -0,0 +1,733 @@
1
+ /**
2
+ * NoveraChat — top-level client facade.
3
+ *
4
+ * const chat = new NoveraChat({ appId, endpoint, tokenProvider });
5
+ * await chat.connect();
6
+ * const room = chat.room("room_123");
7
+ * room.on("message", ...);
8
+ * await room.send("hi");
9
+ * chat.disconnect();
10
+ */
11
+
12
+ import type {
13
+ AcceptInviteResult, AppPolicy, Block, ClientOptions, DeviceInfo, FileMeta,
14
+ MessageIdStr, RegisterDeviceParams, UnreadSummary, WsAnnouncementEvent,
15
+ WsInbound, WsPresence, WsRoomEvent,
16
+ } from "./types.js";
17
+
18
+ /** Client-level events (not tied to a single Room facade). `roomEvent` fires
19
+ * for membership / room-lifecycle changes on ANY room — including ones the
20
+ * caller hasn't opened yet (e.g. being added to a brand-new room), which a
21
+ * per-room `room.on(...)` listener could never catch. `presence` fires
22
+ * when any user this session shares a room with transitions online or
23
+ * offline (first / last live session only — multi-tab open/close stays
24
+ * silent). */
25
+ export interface ClientEvents {
26
+ roomEvent: WsRoomEvent;
27
+ presence: WsPresence;
28
+ }
29
+
30
+ /**
31
+ * BigInt comparison helper. Snowflake ids are 63-bit strings on the wire
32
+ * (see types.ts MessageIdStr) — JS Number would lose precision, so we
33
+ * compare via BigInt. Empty / null / "0" sort as -∞.
34
+ */
35
+ function idGreater(a: MessageIdStr | null | undefined, b: MessageIdStr | null | undefined): boolean {
36
+ if (!a) return false;
37
+ if (!b) return true;
38
+ try { return BigInt(a) > BigInt(b); } catch { return a > b; }
39
+ }
40
+ import { HttpClient } from "./transport/http.js";
41
+ import { ReconnectingWs } from "./transport/ws.js";
42
+ import { Room } from "./features/room.js";
43
+ import { EventBus } from "./internal/event-bus.js";
44
+ import { NoveraChatError } from "./errors.js";
45
+
46
+ export class NoveraChat {
47
+ private http: HttpClient;
48
+ private ws: ReconnectingWs;
49
+ private rooms = new Map<string, Room>();
50
+ private bus = new EventBus<ClientEvents>();
51
+ private readDebounceMs: number;
52
+ private logger: ClientOptions["logger"];
53
+ private connectedOnce = false;
54
+ private lastMessageIdByRoom = new Map<string, MessageIdStr>();
55
+ /** Most recently dispatched presence event key, `${userId}:${at}:${online}`.
56
+ * Used to drop the N-1 shared-room copies of a single online/offline
57
+ * transition — the server fans out one publish per room, and we don't
58
+ * want the caller's listener firing N times for one UX event. */
59
+ private lastPresenceKey: string | null = null;
60
+
61
+ constructor(private opts: ClientOptions) {
62
+ if (!opts.appId) throw new Error("appId required");
63
+ if (!opts.endpoint) throw new Error("endpoint required");
64
+ if (!opts.tokenProvider) throw new Error("tokenProvider required");
65
+
66
+ // 400ms is the read-receipt-UX sweet spot for chat apps. Shorter and
67
+ // we waste WS frames on every micro-scroll; longer and the "✓ read"
68
+ // indicator feels laggy. Callers can override via the constructor
69
+ // option for slower / battery-sensitive clients.
70
+ this.readDebounceMs = opts.readWatermarkDebounceMs ?? 400;
71
+ this.logger = opts.logger;
72
+
73
+ // Seed last-seen ids so the first WS handshake can include `?since=...`
74
+ // and a subsequent `chat.backfill()` knows where to start. Callers
75
+ // typically persist these to localStorage on each `chat_receive` and
76
+ // hand them back here at cold-start. Non-positive entries are ignored.
77
+ if (opts.initialLastMessageIds) {
78
+ for (const [roomId, id] of Object.entries(opts.initialLastMessageIds)) {
79
+ // Accept both legacy number and the new string form. Coerce number
80
+ // → string defensively (callers really should hand us strings now,
81
+ // but a freshly-migrated app might still have number-typed
82
+ // localStorage residue).
83
+ const sid = typeof id === "number" ? String(id) : id;
84
+ if (sid && sid !== "0") {
85
+ this.lastMessageIdByRoom.set(roomId, sid);
86
+ }
87
+ }
88
+ }
89
+
90
+ this.http = new HttpClient({
91
+ baseUrl: opts.endpoint,
92
+ appId: opts.appId,
93
+ tokenProvider: opts.tokenProvider,
94
+ ...(opts.fetch ? { fetch: opts.fetch } : {}),
95
+ // Same structured sink as the WS transport — the SDK is one source
96
+ // of truth for both pipes' traces. Debug-level lines (rest_req /
97
+ // rest_res) are off by default at the consumer side (e.g. the demo
98
+ // filters them behind a "Verbose" toggle).
99
+ ...(this.logger ? { logger: this.logger } : {}),
100
+ });
101
+
102
+ const wsBase = opts.wsEndpoint
103
+ ?? opts.endpoint.replace(/^http/, "ws");
104
+
105
+ this.ws = new ReconnectingWs({
106
+ urlBuilder: async () => {
107
+ const token = await opts.tokenProvider();
108
+ const since = this.highestKnownMessageId();
109
+ const u = new URL(wsBase.replace(/\/+$/, "") + `/ws/${opts.appId}`);
110
+ u.searchParams.set("token", token);
111
+ if (since) u.searchParams.set("since", since);
112
+ return u.toString();
113
+ },
114
+ onMessage: (msg) => this.dispatch(msg),
115
+ onOpen: () => {
116
+ if (this.connectedOnce) {
117
+ // reconnect path — ask for any gap since the highest seen id
118
+ void this.backfillAllRooms();
119
+ } else {
120
+ this.connectedOnce = true;
121
+ }
122
+ },
123
+ onStateChange: (s) => this.logger?.("info", `ws_state=${s}`),
124
+ autoReconnect: opts.autoReconnect ?? true,
125
+ pingIntervalMs: opts.pingIntervalMs ?? 20_000,
126
+ maxReconnectDelayMs: opts.maxReconnectDelayMs ?? 30_000,
127
+ ...(opts.WebSocketImpl ? { WebSocketImpl: opts.WebSocketImpl } : {}),
128
+ ...(opts.logger ? { logger: opts.logger } : {}),
129
+ });
130
+ }
131
+
132
+ async connect(): Promise<void> {
133
+ await this.ws.connect();
134
+ }
135
+
136
+ disconnect(): void {
137
+ this.ws.close();
138
+ this.rooms.clear();
139
+ }
140
+
141
+ room(roomId: string): Room {
142
+ let r = this.rooms.get(roomId);
143
+ if (!r) {
144
+ r = new Room(roomId, this.http, this.ws, this.readDebounceMs);
145
+ this.rooms.set(roomId, r);
146
+ }
147
+ return r;
148
+ }
149
+
150
+ /** Subscribe to client-level events. Currently `roomEvent` — membership /
151
+ * room-lifecycle changes for ANY room (added, kicked, restored, frozen,
152
+ * …), including rooms the caller hasn't opened. Returns an unsubscribe fn.
153
+ * Use this to refresh a channel list the instant you're added to a new
154
+ * room, rather than waiting for a poll. */
155
+ on<K extends keyof ClientEvents>(event: K, fn: (p: ClientEvents[K]) => void): () => void {
156
+ return this.bus.on(event, fn);
157
+ }
158
+
159
+ off<K extends keyof ClientEvents>(event: K, fn: (p: ClientEvents[K]) => void): void {
160
+ this.bus.off(event, fn);
161
+ }
162
+
163
+ /**
164
+ * Manually fetch all messages newer than the highest known `message_id`
165
+ * for each room and surface them as regular `room.on("message", ...)`
166
+ * events. Useful at cold start (after `chat.connect()`) to pull the
167
+ * "unread since last session" tail — the WS reconnect path calls this
168
+ * automatically, but the first connect does not.
169
+ *
170
+ * No-op for rooms with no seeded id.
171
+ */
172
+ async backfill(): Promise<void> {
173
+ await this.backfillAllRooms();
174
+ }
175
+
176
+ /** Highest `message_id` the client has seen for a given room, or null. */
177
+ lastSeenMessageId(roomId: string): MessageIdStr | null {
178
+ return this.lastMessageIdByRoom.get(roomId) ?? null;
179
+ }
180
+
181
+ /**
182
+ * Monotonically advance the per-room "highest-seen message_id" anchor.
183
+ * Used by callers that learn a newer watermark from out-of-band sources —
184
+ * typically the server's own `last_read_message_id` (via the unread
185
+ * endpoint) so the next `backfill()` doesn't re-pull messages the user
186
+ * already marked read on another device. Never moves the value backward;
187
+ * a smaller `messageId` is silently ignored. Accepts either the string
188
+ * form (preferred) or a number (legacy / convenience).
189
+ */
190
+ setLastSeen(roomId: string, messageId: MessageIdStr | number): void {
191
+ const sid = typeof messageId === "number" ? String(messageId) : messageId;
192
+ if (!sid || sid === "0") return;
193
+ const cur = this.lastMessageIdByRoom.get(roomId);
194
+ if (idGreater(sid, cur)) this.lastMessageIdByRoom.set(roomId, sid);
195
+ }
196
+
197
+ /** Server-side aggregate unread state: total count + per-room breakdown
198
+ * for the authenticated user. Single REST call; cheap enough to poll
199
+ * every few seconds for a badge, or just refresh on focus + after
200
+ * receiving new messages. */
201
+ /** Client-facing feature flags for the caller's app. Cheap (one row
202
+ * read on the server) — fetch once on connect to decide which
203
+ * destructive buttons to expose. */
204
+ async appPolicy(): Promise<AppPolicy> {
205
+ const raw = await this.http.request<{
206
+ app_id: string;
207
+ allow_member_bulk_delete: boolean;
208
+ read_receipts_enabled: boolean;
209
+ typing_indicators_enabled: boolean;
210
+ allow_member_delete_any_message: boolean;
211
+ }>("GET", "/api/v1/app/policy");
212
+ return {
213
+ appId: raw.app_id,
214
+ allowMemberBulkDelete: raw.allow_member_bulk_delete,
215
+ readReceiptsEnabled: raw.read_receipts_enabled ?? true,
216
+ typingIndicatorsEnabled: raw.typing_indicators_enabled ?? true,
217
+ deleteAnyMessageEnabled: raw.allow_member_delete_any_message ?? false,
218
+ };
219
+ }
220
+
221
+ // ---- device tokens (push) ----
222
+
223
+ /**
224
+ * Register (or refresh) this device's push token. Call after login and
225
+ * whenever the platform hands you a rotated FCM/APNS token. Idempotent
226
+ * on `deviceId` — the server upserts, so re-registering is cheap and
227
+ * safe. Returns the stored device record (token shown only as a prefix).
228
+ */
229
+ async registerDevice(params: RegisterDeviceParams): Promise<DeviceInfo> {
230
+ const raw = await this.http.request<{
231
+ device_id: string;
232
+ token_type: DeviceInfo["tokenType"];
233
+ token_preview: string;
234
+ os: DeviceInfo["os"];
235
+ app_version: string | null;
236
+ push_enabled: boolean;
237
+ last_active_at: string | null;
238
+ created_at: string;
239
+ }>("POST", "/api/v1/users/me/devices", {
240
+ device_id: params.deviceId,
241
+ token: params.token,
242
+ token_type: params.tokenType,
243
+ os: params.os ?? null,
244
+ app_version: params.appVersion ?? null,
245
+ });
246
+ return this._toDeviceInfo(raw);
247
+ }
248
+
249
+ /** List the devices registered for the authenticated user. */
250
+ async listDevices(): Promise<DeviceInfo[]> {
251
+ const raw = await this.http.request<{
252
+ items: Array<Parameters<NoveraChat["_toDeviceInfo"]>[0]>;
253
+ total: number;
254
+ }>("GET", "/api/v1/users/me/devices");
255
+ return raw.items.map((d) => this._toDeviceInfo(d));
256
+ }
257
+
258
+ /**
259
+ * Turn push delivery on/off for one device without unregistering it —
260
+ * the token stays stored, the push pipeline just skips it. Use for an
261
+ * in-app "notifications" toggle.
262
+ */
263
+ async setDevicePushEnabled(deviceId: string, enabled: boolean): Promise<void> {
264
+ await this.http.request<void>(
265
+ "PATCH", `/api/v1/users/me/devices/${encodeURIComponent(deviceId)}`,
266
+ { push_enabled: enabled },
267
+ );
268
+ }
269
+
270
+ /** Unregister a device — call on logout so the user stops receiving
271
+ * push on a device they're no longer signed into. */
272
+ async unregisterDevice(deviceId: string): Promise<void> {
273
+ await this.http.request<void>(
274
+ "DELETE", `/api/v1/users/me/devices/${encodeURIComponent(deviceId)}`,
275
+ );
276
+ }
277
+
278
+ // ---- global push preference (user-level kill switch) ----
279
+
280
+ /**
281
+ * Read the caller's GLOBAL push preference. When `pushEnabled` is
282
+ * false the push worker skips this user entirely — every per-room
283
+ * `push_trigger` setting is ignored. Fail-open: an unknown user
284
+ * comes back as `pushEnabled: true`.
285
+ *
286
+ * Two levels of push control:
287
+ * 1. **Per-room** — `room.setPushTrigger("ALL" | "MENTION_ONLY" | "OFF")`.
288
+ * 2. **Global (this)** — overrides all rooms when off.
289
+ */
290
+ async getPushPreferences(): Promise<{ pushEnabled: boolean }> {
291
+ const raw = await this.http.request<{ push_enabled: boolean }>(
292
+ "GET", "/api/v1/users/me/push-preferences",
293
+ );
294
+ return { pushEnabled: Boolean(raw.push_enabled) };
295
+ }
296
+
297
+ /**
298
+ * Flip the caller's global push kill switch. Pass `false` to mute
299
+ * every push notification for this user across every room; pass
300
+ * `true` to restore normal delivery. Idempotent.
301
+ */
302
+ async setPushEnabled(enabled: boolean): Promise<{ pushEnabled: boolean }> {
303
+ const raw = await this.http.request<{ push_enabled: boolean }>(
304
+ "PATCH", "/api/v1/users/me/push-preferences",
305
+ { push_enabled: enabled },
306
+ );
307
+ return { pushEnabled: Boolean(raw.push_enabled) };
308
+ }
309
+
310
+ // -------------------------------------------------------------------------
311
+ // User blocks — KakaoTalk-style one-way block.
312
+ //
313
+ // The block is directional: `chat.blockUser(otherId)` stops `otherId`'s
314
+ // messages from reaching the caller. The blocked side is NEVER notified;
315
+ // their sends still ack. There's no admin escape hatch or "who blocked
316
+ // me" query — that's the whole point of a one-way block.
317
+ // -------------------------------------------------------------------------
318
+
319
+ /**
320
+ * Block a user (one-way). Idempotent — repeated calls return the
321
+ * existing row without changing `createdAt` or `reason`.
322
+ *
323
+ * @param userId The user to block. `chat.blockUser(myUserId)` throws
324
+ * a validation error server-side.
325
+ * @param reason Optional local note ≤500 chars.
326
+ */
327
+ async blockUser(userId: string, reason?: string): Promise<Block> {
328
+ const raw = await this.http.request<{
329
+ blocked_user_id: string;
330
+ reason: string | null;
331
+ created_at: string;
332
+ }>(
333
+ "POST", `/api/v1/users/me/blocks/${encodeURIComponent(userId)}`,
334
+ { reason: reason ?? null },
335
+ );
336
+ return {
337
+ blockedUserId: raw.blocked_user_id,
338
+ reason: raw.reason,
339
+ createdAt: raw.created_at,
340
+ };
341
+ }
342
+
343
+ /** Unblock a user. Idempotent — resolves either way. */
344
+ async unblockUser(userId: string): Promise<void> {
345
+ await this.http.request<void>(
346
+ "DELETE", `/api/v1/users/me/blocks/${encodeURIComponent(userId)}`,
347
+ );
348
+ }
349
+
350
+ /** List every user the caller has blocked, newest first. */
351
+ async listBlocks(): Promise<Block[]> {
352
+ const raw = await this.http.request<Array<{
353
+ blocked_user_id: string;
354
+ reason: string | null;
355
+ created_at: string;
356
+ }>>("GET", "/api/v1/users/me/blocks");
357
+ return raw.map((r) => ({
358
+ blockedUserId: r.blocked_user_id,
359
+ reason: r.reason,
360
+ createdAt: r.created_at,
361
+ }));
362
+ }
363
+
364
+ private _toDeviceInfo(raw: {
365
+ device_id: string;
366
+ token_type: DeviceInfo["tokenType"];
367
+ token_preview: string;
368
+ os: DeviceInfo["os"];
369
+ app_version: string | null;
370
+ push_enabled: boolean;
371
+ last_active_at: string | null;
372
+ created_at: string;
373
+ }): DeviceInfo {
374
+ return {
375
+ deviceId: raw.device_id,
376
+ tokenType: raw.token_type,
377
+ tokenPreview: raw.token_preview,
378
+ os: raw.os,
379
+ appVersion: raw.app_version,
380
+ pushEnabled: raw.push_enabled,
381
+ lastActiveAt: raw.last_active_at,
382
+ createdAt: raw.created_at,
383
+ };
384
+ }
385
+
386
+ /**
387
+ * Authorization-gated URL for downloading a file attachment.
388
+ *
389
+ * The backend runs the request through the authorization gate (caller
390
+ * must be a non-KICKED member of some room with a non-deleted,
391
+ * post-cutoff message referencing this file) and then 302-redirects
392
+ * to a short-lived signed S3 URL. ``<img src=...>`` / ``<video
393
+ * src=...>`` / ``<a href=...>`` consumers transparently follow the
394
+ * redirect.
395
+ *
396
+ * Why the optional ``token`` argument? Browsers don't let
397
+ * ``<img>``/``<video>`` tags carry custom Authorization headers —
398
+ * so URLs destined for those elements have to embed credentials in
399
+ * the URL itself. The backend's ``require_user`` dep accepts
400
+ * ``?token=...&app_id=...`` query params as a fallback for exactly
401
+ * this case (it still prefers headers when present). Pass the same
402
+ * JWT the SDK was constructed with; ``app_id`` is filled in
403
+ * automatically from the client options. Omit the token when you're
404
+ * going to ``fetch()`` the URL yourself with the Authorization
405
+ * header set (e.g. server-side preview).
406
+ *
407
+ * Note: each call returns a fresh URL but the URL embeds a JWT — its
408
+ * lifetime is the JWT's TTL (~24h by default), not the few-seconds
409
+ * window of the S3 signed URL the backend redirects to. Don't log it.
410
+ */
411
+ fileUrl(fileId: MessageIdStr, token?: string): string {
412
+ const base = this.opts.endpoint.replace(/\/+$/, "");
413
+ const u = new URL(`${base}/api/v1/files/${encodeURIComponent(fileId)}`);
414
+ if (token) {
415
+ u.searchParams.set("token", token);
416
+ u.searchParams.set("app_id", this.opts.appId);
417
+ }
418
+ return u.toString();
419
+ }
420
+
421
+ /**
422
+ * Thumbnail URL for an image / video attachment. Returns 404 when
423
+ * the thumbnail isn't ready yet (poll ``getFileMeta`` then re-render
424
+ * once ``thumbnailStatus === "ready"``). Same token-in-query rule
425
+ * as ``fileUrl()``.
426
+ */
427
+ fileThumbnailUrl(fileId: MessageIdStr, token?: string): string {
428
+ const base = this.opts.endpoint.replace(/\/+$/, "");
429
+ const u = new URL(`${base}/api/v1/files/${encodeURIComponent(fileId)}/thumbnail`);
430
+ if (token) {
431
+ u.searchParams.set("token", token);
432
+ u.searchParams.set("app_id", this.opts.appId);
433
+ }
434
+ return u.toString();
435
+ }
436
+
437
+ /**
438
+ * Fetch a file's metadata without committing to a download. Useful
439
+ * when ``Message.file`` (the inline copy) is missing — e.g. for
440
+ * messages loaded from REST history that predate the inline-meta
441
+ * shipping date.
442
+ */
443
+ async getFileMeta(fileId: MessageIdStr): Promise<FileMeta> {
444
+ // Wire shape mirrors backend ``FileOut`` (Pydantic v2). The ``id``
445
+ // field comes through with its alias name, not the model attribute
446
+ // ``file_id`` — easy mistake to make when looking at the SQLAlchemy
447
+ // model. ``original_name`` was renamed to ``name`` in the inline
448
+ // shape but the standalone meta still uses ``original_name``.
449
+ const raw = await this.http.request<{
450
+ id: MessageIdStr;
451
+ kind: "image" | "video" | "file";
452
+ mime: string;
453
+ size_bytes: number;
454
+ original_name: string | null;
455
+ width: number | null;
456
+ height: number | null;
457
+ duration_sec: number | null;
458
+ thumbnail_status: "pending" | "ready" | "failed" | "skipped";
459
+ forward_blocked?: boolean;
460
+ }>("GET", `/api/v1/files/${encodeURIComponent(fileId)}/meta`);
461
+ return {
462
+ id: raw.id,
463
+ kind: raw.kind,
464
+ mime: raw.mime,
465
+ size: raw.size_bytes,
466
+ name: raw.original_name ?? null,
467
+ width: raw.width ?? null,
468
+ height: raw.height ?? null,
469
+ durationSec: raw.duration_sec ?? null,
470
+ thumbnailStatus: raw.thumbnail_status,
471
+ forwardBlocked: Boolean(raw.forward_blocked),
472
+ };
473
+ }
474
+
475
+ async unreadSummary(): Promise<UnreadSummary> {
476
+ const raw = await this.http.request<{
477
+ total: number;
478
+ rooms: Array<{
479
+ room_id: string;
480
+ name: string | null;
481
+ room_type: string | null;
482
+ unread_count: number;
483
+ last_message_id: string | null;
484
+ last_message_preview: string | null;
485
+ last_read_message_id: string | null;
486
+ member_count: number;
487
+ members_preview: Array<{
488
+ user_id: string;
489
+ nickname: string | null;
490
+ profile_image_url: string | null;
491
+ is_online: boolean | null;
492
+ }>;
493
+ }>;
494
+ }>("GET", "/api/v1/users/me/unread");
495
+ return {
496
+ total: raw.total,
497
+ rooms: raw.rooms.map((r) => ({
498
+ roomId: r.room_id,
499
+ name: r.name,
500
+ roomType: r.room_type ?? null,
501
+ unreadCount: r.unread_count,
502
+ lastMessageId: r.last_message_id,
503
+ lastMessagePreview: r.last_message_preview ?? null,
504
+ lastReadMessageId: r.last_read_message_id,
505
+ memberCount: r.member_count ?? 0,
506
+ members: (r.members_preview ?? []).map((m) => ({
507
+ userId: m.user_id,
508
+ nickname: m.nickname ?? null,
509
+ profileImageUrl: m.profile_image_url ?? null,
510
+ isOnline: m.is_online ?? null,
511
+ })),
512
+ })),
513
+ };
514
+ }
515
+
516
+ /**
517
+ * Accept an invite link — call this in flows where you receive a
518
+ * shareable URL (e.g. after parsing a deep link like
519
+ * `#invite/<token>`). Consumes one use of the token and joins the
520
+ * caller to the target room; peers already in the room receive a
521
+ * `MEMBER_JOINED` roomEvent and a SYSTEM chat message.
522
+ *
523
+ * Idempotent: if the caller is already an active member the call
524
+ * succeeds with `wasAlreadyMember: true` and does NOT decrement the
525
+ * token's remaining uses — safe to call again on every app open.
526
+ *
527
+ * Throws (via HttpClient's `NoveraChatError`) when the token is
528
+ * invalid, expired, revoked, exhausted, or when the caller has been
529
+ * kicked from the target room.
530
+ */
531
+ async acceptInvite(token: string): Promise<AcceptInviteResult> {
532
+ const raw = await this.http.request<{
533
+ room_id: string;
534
+ room_name: string | null;
535
+ was_already_member: boolean;
536
+ }>("POST", `/api/v1/invites/${encodeURIComponent(token)}/accept`);
537
+ return {
538
+ roomId: raw.room_id,
539
+ roomName: raw.room_name,
540
+ wasAlreadyMember: raw.was_already_member,
541
+ };
542
+ }
543
+
544
+ // ---- internal ----
545
+
546
+ private dispatch(msg: WsInbound): void {
547
+ switch (msg.type) {
548
+ case "connected":
549
+ return;
550
+ case "pong":
551
+ return;
552
+ case "error": {
553
+ this.logger?.("warn", "ws_error_frame", msg);
554
+ // Server echoes the offending frame's temp_id back in `details`
555
+ // (e.g. NC-RATE-001 from a rate-limit reject) so we can correlate
556
+ // it back to the pending send. Without this we'd wait 15s for the
557
+ // ack timeout to fire — terrible UX for an instantly-knowable
558
+ // failure.
559
+ const tempId = (msg.details && typeof msg.details === "object")
560
+ ? (msg.details as { temp_id?: unknown }).temp_id
561
+ : undefined;
562
+ if (typeof tempId === "string" && tempId.length > 0) {
563
+ // Find the owning room first (temp_ids are globally unique per
564
+ // session but mapping is per-room).
565
+ let target: import("./features/room.js").Room | null = null;
566
+ for (const room of this.rooms.values()) {
567
+ if (room._hasPendingTempId(tempId)) { target = room; break; }
568
+ }
569
+ if (target) {
570
+ // Rate-limit is the one error class where transparent retry is
571
+ // safe and almost always succeeds: the token bucket refills
572
+ // every second, and chat_send is naturally idempotent on
573
+ // temp_id (server self-echo dedupes). For every other code
574
+ // (auth, payload too large, frozen room, etc.) the failure
575
+ // is deterministic — retrying buys nothing.
576
+ const code = msg.code || "WS_ERROR";
577
+ if (code === "NC-RATE-001" && target._retryPending(tempId)) {
578
+ this.logger?.("info", "ws_send_retry_scheduled", {
579
+ temp_id: tempId, code,
580
+ });
581
+ return;
582
+ }
583
+ const details = (msg.details ?? undefined) as
584
+ | Record<string, unknown>
585
+ | undefined;
586
+ const err = details !== undefined
587
+ ? new NoveraChatError(
588
+ msg.message || "server rejected frame",
589
+ code, 0, details,
590
+ )
591
+ : new NoveraChatError(
592
+ msg.message || "server rejected frame",
593
+ code,
594
+ );
595
+ target._rejectPending(tempId, err);
596
+ }
597
+ }
598
+ return;
599
+ }
600
+ case "close_notice":
601
+ this.logger?.("warn", "ws_close_notice", msg);
602
+ return;
603
+ case "room_event": {
604
+ // Membership / room-lifecycle change. Surface at the CLIENT level so
605
+ // callers can react even for rooms with no Room facade yet (e.g. being
606
+ // added to a brand-new room) — the `default` case below would drop it
607
+ // (`this.rooms.get(roomId)` is undefined). Also forward to the room's
608
+ // facade if one exists, so room-scoped listeners can observe it later.
609
+ this.bus.emit("roomEvent", msg);
610
+ return;
611
+ }
612
+ case "presence": {
613
+ // Presence transition (online / offline boundary). The server
614
+ // publishes one copy per room the peer is a member of, so a
615
+ // receiver who shares N rooms with the peer sees the same event
616
+ // N times. Dedupe by (userId, at, online): the server stamps a
617
+ // single `at` for the whole fan-out, so any repeat with the same
618
+ // key is a shared-room duplicate we can drop.
619
+ const key = `${msg.user_id}:${msg.at}:${msg.online ? 1 : 0}`;
620
+ if (this.lastPresenceKey === key) return;
621
+ this.lastPresenceKey = key;
622
+ this.bus.emit("presence", msg);
623
+ return;
624
+ }
625
+ case "ack": {
626
+ // Treat our own send-and-acked id as "seen" — most servers don't
627
+ // echo a sender's message back as `chat_receive`, so without this
628
+ // bookkeeping the sender's own ids would be missing from the
629
+ // reconnect `?since=` watermark and trigger redundant backfill.
630
+ if (idGreater(msg.message_id, this.lastMessageIdByRoom.get(msg.room_id))) {
631
+ this.lastMessageIdByRoom.set(msg.room_id, msg.message_id);
632
+ }
633
+ const room = this.rooms.get(msg.room_id);
634
+ room?._dispatchAck(msg.temp_id, msg.message_id);
635
+ return;
636
+ }
637
+ case "announcement_event": {
638
+ const room = this.rooms.get(msg.room_id);
639
+ room?._dispatchAnnouncement(msg);
640
+ return;
641
+ }
642
+ default: {
643
+ const roomId = "room_id" in msg ? msg.room_id : undefined;
644
+ if (!roomId) return;
645
+ const room = this.rooms.get(roomId);
646
+ if (!room) return;
647
+ if (msg.type === "chat_receive" && idGreater(msg.message_id, this.lastMessageIdByRoom.get(roomId))) {
648
+ this.lastMessageIdByRoom.set(roomId, msg.message_id);
649
+ }
650
+ // Self-echo dedup. The server fans this room's traffic back to the
651
+ // sender's own session too, so we'd render the message twice
652
+ // (once optimistically, once on this frame). When `temp_id` is set
653
+ // and matches one of our outgoing sends, treat the frame like an
654
+ // ack — bind the optimistic bubble to the server id — and skip
655
+ // the regular `message` emit so the UI doesn't see a duplicate.
656
+ if (msg.type === "chat_receive") {
657
+ const tid = msg.temp_id ?? null;
658
+ if (tid && room._isMySentTempId(tid)) {
659
+ // ack may arrive before or after this frame; whichever comes
660
+ // first does the swap, the other is a no-op (pendingAcks
661
+ // entry already cleared).
662
+ room._dispatchAck(tid, msg.message_id);
663
+ room._forgetMySentTempId(tid);
664
+ return;
665
+ }
666
+ }
667
+ room._dispatchInbound(msg);
668
+ }
669
+ }
670
+ }
671
+
672
+ private highestKnownMessageId(): MessageIdStr | null {
673
+ let max: MessageIdStr | null = null;
674
+ for (const v of this.lastMessageIdByRoom.values()) {
675
+ if (idGreater(v, max)) max = v;
676
+ }
677
+ return max;
678
+ }
679
+
680
+ private async backfillAllRooms(): Promise<void> {
681
+ for (const [roomId, lastSeen] of this.lastMessageIdByRoom) {
682
+ try {
683
+ const room = this.rooms.get(roomId);
684
+ if (!room) continue;
685
+ const { items } = await room.listSince(lastSeen, 200);
686
+ for (const m of items) {
687
+ // Surface as regular chat_receive so UIs don't need a second code path
688
+ room._dispatchInbound({
689
+ type: "chat_receive",
690
+ room_id: roomId,
691
+ message_id: m.messageId,
692
+ sender_id: m.senderId ?? null,
693
+ message_type: m.messageType,
694
+ custom_type: m.customType ?? null,
695
+ content: m.content ?? null,
696
+ data: m.data ?? null,
697
+ meta: m.meta ?? null,
698
+ file_id: m.fileId ?? null,
699
+ // Inline file metadata loaded via REST has the same shape as
700
+ // the WS frame's `file` field — pass through if the history
701
+ // entry carries it; otherwise null and the UI calls
702
+ // ``chat.getFileMeta(fileId)`` to fetch on demand.
703
+ file: m.file
704
+ ? {
705
+ id: m.file.id,
706
+ kind: m.file.kind,
707
+ mime: m.file.mime,
708
+ size: m.file.size,
709
+ name: m.file.name ?? null,
710
+ width: m.file.width ?? null,
711
+ height: m.file.height ?? null,
712
+ duration_sec: m.file.durationSec ?? null,
713
+ thumbnail_status: m.file.thumbnailStatus,
714
+ }
715
+ : null,
716
+ reply_to_id: m.replyToId ?? null,
717
+ created_at: m.createdAtMs,
718
+ server_ts: Date.now(),
719
+ });
720
+ if (idGreater(m.messageId, this.lastMessageIdByRoom.get(roomId))) {
721
+ this.lastMessageIdByRoom.set(roomId, m.messageId);
722
+ }
723
+ }
724
+ } catch (err) {
725
+ if (err instanceof NoveraChatError) {
726
+ this.logger?.("warn", `backfill_failed room=${roomId}`, err);
727
+ } else {
728
+ throw err;
729
+ }
730
+ }
731
+ }
732
+ }
733
+ }