@noverachat/sdk-web 0.3.0 → 0.5.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noverachat/sdk-web",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "NoveraChat browser/Node SDK — WebSocket + REST client with optimistic UI, reconnection and gap-fill",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/cache.ts ADDED
@@ -0,0 +1,67 @@
1
+ /**
2
+ * 스냅샷 캐시 — 콜드 스타트 스피너 제거용 렌더 힌트 계층.
3
+ * (noverachat_dart `lib/src/cache.dart`의 TS 미러 — 의미론 동일)
4
+ *
5
+ * 원칙:
6
+ * 1. 캐시 = 렌더 힌트, 서버가 항상 이긴다 — 시작 시 스냅샷 즉시 렌더,
7
+ * API 도착 시 기본 통째 교체(replaceByApi). 옵트인 mergeDiff는
8
+ * fresh보다 오래된 메시지만 보존하는 병합(서버를 덮어쓰지 않음).
9
+ * 2. 저장소는 SDK가 정하지 않는다 — `CacheStore` 인터페이스만 정의,
10
+ * 구현은 앱이 주입(localStorage/IndexedDB 등).
11
+ * 3. 버전 스탬프 + 통째 폐기 — 키에 스키마 버전(`nc.v1.…`), 파싱 실패
12
+ * 시 해당 키를 드랍.
13
+ */
14
+
15
+ /**
16
+ * 앱이 주입하는 key-value 스냅샷 저장소.
17
+ *
18
+ * SDK는 저장 방식에 관여하지 않는다 — 값은 불투명한 JSON 문자열이며,
19
+ * 앱이 localStorage·IndexedDB·파일 등 아무 곳에나 보관하면 된다.
20
+ * 모든 메서드는 실패해도 SDK 동작에 영향이 없다(캐시는 힌트일 뿐).
21
+ *
22
+ * ```ts
23
+ * const localStorageCache: CacheStore = {
24
+ * async read(key) { return localStorage.getItem(key); },
25
+ * async write(key, value) { localStorage.setItem(key, value); },
26
+ * async remove(key) { localStorage.removeItem(key); },
27
+ * };
28
+ *
29
+ * const chat = new NoveraChat({
30
+ * appId, endpoint, tokenProvider,
31
+ * cacheStore: localStorageCache,
32
+ * });
33
+ * ```
34
+ */
35
+ export interface CacheStore {
36
+ /** `key`의 스냅샷을 돌려준다. 없으면 null. */
37
+ read(key: string): Promise<string | null>;
38
+ /** `key`에 스냅샷을 저장한다(덮어쓰기). */
39
+ write(key: string, value: string): Promise<void>;
40
+ /** `key`의 스냅샷을 지운다. 없어도 에러 없이 통과. */
41
+ remove(key: string): Promise<void>;
42
+ }
43
+
44
+ /**
45
+ * 캐시 스냅샷과 API 응답을 어떻게 합칠지.
46
+ *
47
+ * - `"replaceByApi"` (기본) — 스냅샷 즉시 렌더 후 API 도착 시 통째 교체.
48
+ * - `"mergeDiff"` — fresh 페이지보다 오래된 캐시 메시지를 보존·병합해 더
49
+ * 긴 연속 히스토리 유지. `history_from`으로 컷오프분을, fresh 대조로
50
+ * 삭제분을 걸러내고, 캐시·fresh 사이 갭(겹침 없음)이면 통째 교체 폴백.
51
+ */
52
+ export type CachePolicy = "replaceByApi" | "mergeDiff";
53
+
54
+ /**
55
+ * 캐시 키 규칙 — `nc.<스키마버전>.<대상>`.
56
+ *
57
+ * 스키마 버전이 키에 박혀 있으므로, 저장 포맷이 바뀌면 버전을 올리는
58
+ * 것만으로 구버전 스냅샷은 다시는 읽히지 않는다(자연 폐기).
59
+ */
60
+ export const CacheKeys = {
61
+ /** 저장 포맷 스키마 버전. 저장되는 JSON 모양이 바뀌면 올린다. */
62
+ version: "v1",
63
+ /** 방 목록 스냅샷 (`GET /users/me/unread` 원문). */
64
+ rooms: "nc.v1.rooms",
65
+ /** 방 히스토리 최신 페이지 스냅샷 (`GET /rooms/{id}/messages` 원문). */
66
+ msgs: (roomId: string): string => `nc.v1.msgs.${roomId}`,
67
+ } as const;
@@ -0,0 +1,214 @@
1
+ /**
2
+ * UI 지향 메시지 뷰모델 — sdk-react에서 코어로 이동.
3
+ * (`MessageCollection`이 이 모양으로 상태를 소유하고, 바인딩은 재수출만 한다.
4
+ * noverachat_dart `lib/src/chat_message.dart`의 TS 대응물.)
5
+ */
6
+ import type {
7
+ FileInline,
8
+ MessageOut,
9
+ ReactionEntry,
10
+ SenderMini,
11
+ WsChatReceive,
12
+ } from "./types.js";
13
+
14
+ /** Delivery status of an outgoing (optimistic) message. */
15
+ export type ChatMessageStatus = "sending" | "sent" | "failed";
16
+
17
+ // Snake_case inline file shape as it rides on WS frames — derived
18
+ // structurally from the frame type (not exported by name from types).
19
+ type FileInlineWire = NonNullable<WsChatReceive["file"]>;
20
+
21
+ /**
22
+ * A single message as the UI wants it — one uniform shape over the SDK's
23
+ * three sources: `MessageOut` (history/REST), `WsChatReceive` (live/WS), and
24
+ * an optimistic bubble we just sent.
25
+ *
26
+ * Treat instances as immutable — `MessageCollection` evolves them by
27
+ * replacement, never mutation.
28
+ */
29
+ export interface ChatMessage {
30
+ /** The server message id, or the `tempId` while still `sending`. */
31
+ id: string;
32
+ /** Author user id. `null` means the current user (an optimistic bubble). */
33
+ senderId: string | null;
34
+ content: string | null;
35
+ /** Creation time in unix milliseconds. */
36
+ createdAtMs: number | null;
37
+ status: ChatMessageStatus;
38
+ /** `TEXT | FILE | EMOTICON | ADMIN | SYSTEM` — UIs use this to render
39
+ * system/admin lines (centered notices) differently from chat bubbles. */
40
+ messageType: string;
41
+ /** Sender identity (nickname / profile image) when the server included it.
42
+ * Null for optimistic bubbles and live frames (which carry only
43
+ * `senderId`) — UIs typically resolve the profile from their member list. */
44
+ sender: SenderMini | null;
45
+ /** App-defined subtype (e.g. a message-priority convention). */
46
+ customType: string | null;
47
+ /** Snowflake id (string) of the attached file, when this is a file message. */
48
+ fileId: string | null;
49
+ /** Inline metadata of the attached file — enough to render a placeholder
50
+ * without a round-trip. May be null even when `fileId` is set; fetch via
51
+ * `chat.getFileMeta` then. */
52
+ file: FileInline | null;
53
+ /** Multi-file bundle (`file_group`). Null for single-file / text messages.
54
+ * Check `files?.length` first and fall back to the `file` singleton. */
55
+ files: FileInline[] | null;
56
+ /** Id of the message this one replies to, if any. */
57
+ replyToId: string | null;
58
+ /** Emoji reactions grouped by key. Kept live by `MessageCollection` from
59
+ * the reaction WS events. */
60
+ reactions: ReactionEntry[];
61
+ /** Sender-attached custom metadata (`data._customMeta`). */
62
+ customMeta: Record<string, unknown> | null;
63
+ /** How many times the message was edited. `> 0` → show an "edited" mark. */
64
+ editedCount: number;
65
+ /** Upload progress in [0, 1] while an optimistic FILE message's bytes are
66
+ * still going up to S3. Null once committed (or for non-file messages). */
67
+ uploadProgress: number | null;
68
+ /** The optimistic temp id, kept for ack reconciliation. Null once confirmed. */
69
+ tempId: string | null;
70
+ isDeleted: boolean;
71
+ }
72
+
73
+ function fileInlineFromWire(w: FileInlineWire): FileInline {
74
+ return {
75
+ id: w.id,
76
+ kind: w.kind,
77
+ mime: w.mime,
78
+ size: w.size,
79
+ name: w.name ?? null,
80
+ width: w.width ?? null,
81
+ height: w.height ?? null,
82
+ durationSec: w.duration_sec ?? null,
83
+ thumbnailStatus: w.thumbnail_status,
84
+ forwardBlocked: Boolean(w.forward_blocked),
85
+ };
86
+ }
87
+
88
+ /** Build a `ChatMessage` from server history (REST). */
89
+ export function chatMessageFromHistory(m: MessageOut): ChatMessage {
90
+ return {
91
+ id: m.messageId,
92
+ senderId: m.senderId ?? null,
93
+ content: m.content ?? null,
94
+ createdAtMs: m.createdAtMs,
95
+ status: "sent",
96
+ messageType: m.messageType,
97
+ sender: m.sender ?? null,
98
+ customType: m.customType ?? null,
99
+ fileId: m.fileId ?? null,
100
+ file: m.file ?? null,
101
+ files: m.files ?? null,
102
+ replyToId: m.replyToId ?? null,
103
+ reactions: m.reactions ?? [],
104
+ customMeta: m.customMeta ?? null,
105
+ editedCount: m.editedCount ?? 0,
106
+ uploadProgress: null,
107
+ tempId: null,
108
+ isDeleted: m.deletedAt != null,
109
+ };
110
+ }
111
+
112
+ /** Build a `ChatMessage` from a live inbound frame (WS). */
113
+ export function chatMessageFromLive(f: WsChatReceive): ChatMessage {
114
+ const data = f.data ?? null;
115
+ const customMeta =
116
+ data && typeof data === "object" && "_customMeta" in data
117
+ ? ((data as Record<string, unknown>)._customMeta as
118
+ | Record<string, unknown>
119
+ | null)
120
+ : null;
121
+ return {
122
+ id: f.message_id,
123
+ senderId: f.sender_id,
124
+ content: f.content ?? null,
125
+ createdAtMs: f.created_at,
126
+ status: "sent",
127
+ messageType: f.message_type,
128
+ sender: null,
129
+ customType: f.custom_type ?? null,
130
+ fileId: f.file_id ?? null,
131
+ file: f.file ? fileInlineFromWire(f.file) : null,
132
+ files: f.files ? f.files.map(fileInlineFromWire) : null,
133
+ replyToId: f.reply_to_id ?? null,
134
+ reactions: [],
135
+ customMeta,
136
+ editedCount: 0,
137
+ uploadProgress: null,
138
+ tempId: null,
139
+ isDeleted: false,
140
+ };
141
+ }
142
+
143
+ /** An optimistic bubble for a message we just sent, before the server acks. */
144
+ export function pendingChatMessage(p: {
145
+ tempId: string;
146
+ content: string;
147
+ replyToId?: string;
148
+ customType?: string;
149
+ customMeta?: Record<string, unknown>;
150
+ }): ChatMessage {
151
+ return {
152
+ id: p.tempId,
153
+ senderId: null,
154
+ content: p.content,
155
+ createdAtMs: Date.now(),
156
+ status: "sending",
157
+ messageType: "TEXT",
158
+ sender: null,
159
+ customType: p.customType ?? null,
160
+ fileId: null,
161
+ file: null,
162
+ files: null,
163
+ replyToId: p.replyToId ?? null,
164
+ reactions: [],
165
+ customMeta: p.customMeta ?? null,
166
+ editedCount: 0,
167
+ uploadProgress: null,
168
+ tempId: p.tempId,
169
+ isDeleted: false,
170
+ };
171
+ }
172
+
173
+ /** An optimistic bubble for a FILE message whose bytes are still uploading.
174
+ * `uploadProgress` starts at 0 and is advanced by `MessageCollection`. */
175
+ export function pendingFileChatMessage(p: {
176
+ tempId: string;
177
+ name: string;
178
+ mime?: string;
179
+ size?: number;
180
+ }): ChatMessage {
181
+ const mime = p.mime ?? "application/octet-stream";
182
+ return {
183
+ id: p.tempId,
184
+ senderId: null,
185
+ content: null,
186
+ createdAtMs: Date.now(),
187
+ status: "sending",
188
+ messageType: "FILE",
189
+ sender: null,
190
+ customType: null,
191
+ fileId: null,
192
+ file: {
193
+ id: "",
194
+ kind: mime.startsWith("image/")
195
+ ? "image"
196
+ : mime.startsWith("video/")
197
+ ? "video"
198
+ : "file",
199
+ mime,
200
+ size: p.size ?? 0,
201
+ name: p.name,
202
+ thumbnailStatus: "pending",
203
+ forwardBlocked: false,
204
+ },
205
+ files: null,
206
+ replyToId: null,
207
+ reactions: [],
208
+ customMeta: null,
209
+ editedCount: 0,
210
+ uploadProgress: 0,
211
+ tempId: p.tempId,
212
+ isDeleted: false,
213
+ };
214
+ }
package/src/client.ts CHANGED
@@ -42,6 +42,7 @@ function idGreater(a: MessageIdStr | null | undefined, b: MessageIdStr | null |
42
42
  if (!b) return true;
43
43
  try { return BigInt(a) > BigInt(b); } catch { return a > b; }
44
44
  }
45
+ import { CacheKeys } from "./cache.js";
45
46
  import { HttpClient } from "./transport/http.js";
46
47
  import { ReconnectingWs } from "./transport/ws.js";
47
48
  import { Room } from "./features/room.js";
@@ -146,7 +147,11 @@ export class NoveraChat {
146
147
  room(roomId: string): Room {
147
148
  let r = this.rooms.get(roomId);
148
149
  if (!r) {
149
- r = new Room(roomId, this.http, this.ws, this.readDebounceMs);
150
+ r = new Room(roomId, this.http, this.ws, this.readDebounceMs, {
151
+ ...(this.opts.cacheStore ? { cacheStore: this.opts.cacheStore } : {}),
152
+ ...(this.opts.cachePolicy ? { cachePolicy: this.opts.cachePolicy } : {}),
153
+ ...(this.logger ? { logger: this.logger } : {}),
154
+ });
150
155
  this.rooms.set(roomId, r);
151
156
  }
152
157
  return r;
@@ -409,7 +414,44 @@ export class NoveraChat {
409
414
 
410
415
  async unreadSummary(): Promise<UnreadSummary> {
411
416
  const raw = await this.http.request<unknown>("GET", "/api/v1/users/me/unread");
412
- return parseUnreadSummary(raw);
417
+ const out = parseUnreadSummary(raw); // 파싱 성공한 응답만 캐시에 넣는다
418
+ void this.cacheWriteRooms(raw);
419
+ return out;
420
+ }
421
+
422
+ /**
423
+ * 방 목록의 캐시 스냅샷 — 마지막 `unreadSummary()` 성공 응답의 원문.
424
+ *
425
+ * 콜드 스타트 렌더 힌트 용도다: 있으면 즉시 그리고, `unreadSummary()`가
426
+ * 도착하면 통째로 교체할 것. 캐시가 없거나(`cacheStore` 미주입 포함)
427
+ * 파싱이 실패하면 `null`을 돌려주고, 깨진 스냅샷은 지운다.
428
+ */
429
+ async cachedUnreadSummary(): Promise<UnreadSummary | null> {
430
+ const store = this.opts.cacheStore;
431
+ if (!store) return null;
432
+ try {
433
+ const raw = await store.read(CacheKeys.rooms);
434
+ if (raw == null) return null;
435
+ return parseUnreadSummary(JSON.parse(raw));
436
+ } catch (err) {
437
+ this.logger?.("warn", `cache_read_failed key=${CacheKeys.rooms}`, err);
438
+ try {
439
+ await store.remove(CacheKeys.rooms);
440
+ } catch {
441
+ // 폐기 실패도 무시 — 캐시는 힌트일 뿐
442
+ }
443
+ return null;
444
+ }
445
+ }
446
+
447
+ private async cacheWriteRooms(raw: unknown): Promise<void> {
448
+ const store = this.opts.cacheStore;
449
+ if (!store) return;
450
+ try {
451
+ await store.write(CacheKeys.rooms, JSON.stringify(raw));
452
+ } catch (err) {
453
+ this.logger?.("warn", `cache_write_failed key=${CacheKeys.rooms}`, err);
454
+ }
413
455
  }
414
456
 
415
457
  /**