@noverachat/sdk-web 0.2.0 → 0.4.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/README.ko.md +1 -1
- package/README.md +1 -1
- package/dist/index.cjs +672 -13
- package/dist/index.d.cts +385 -5
- package/dist/index.d.ts +385 -5
- package/dist/index.js +665 -12
- package/package.json +1 -1
- package/src/cache.ts +65 -0
- package/src/chat-message.ts +214 -0
- package/src/client.ts +49 -2
- package/src/features/message-collection.ts +504 -0
- package/src/features/room.ts +118 -5
- package/src/index.ts +44 -0
- package/src/internal/compare-id.ts +17 -0
- package/src/types.ts +14 -2
package/package.json
CHANGED
package/src/cache.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 스냅샷 캐시 — 콜드 스타트 스피너 제거용 렌더 힌트 계층.
|
|
3
|
+
* (noverachat_dart `lib/src/cache.dart`의 TS 미러 — 의미론 동일)
|
|
4
|
+
*
|
|
5
|
+
* 원칙:
|
|
6
|
+
* 1. 캐시 = 렌더 힌트, 서버가 항상 이긴다 — 시작 시 스냅샷 즉시 렌더,
|
|
7
|
+
* API 도착 시 통째 교체(diff 병합 없음).
|
|
8
|
+
* 2. 저장소는 SDK가 정하지 않는다 — `CacheStore` 인터페이스만 정의,
|
|
9
|
+
* 구현은 앱이 주입(localStorage/IndexedDB 등).
|
|
10
|
+
* 3. 버전 스탬프 + 통째 폐기 — 키에 스키마 버전(`nc.v1.…`), 파싱 실패
|
|
11
|
+
* 시 해당 키를 드랍. 삭제 타이머 켜진 방은 캐시 대상에서 제외.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* 앱이 주입하는 key-value 스냅샷 저장소.
|
|
16
|
+
*
|
|
17
|
+
* SDK는 저장 방식에 관여하지 않는다 — 값은 불투명한 JSON 문자열이며,
|
|
18
|
+
* 앱이 localStorage·IndexedDB·파일 등 아무 곳에나 보관하면 된다.
|
|
19
|
+
* 모든 메서드는 실패해도 SDK 동작에 영향이 없다(캐시는 힌트일 뿐).
|
|
20
|
+
*
|
|
21
|
+
* ```ts
|
|
22
|
+
* const localStorageCache: CacheStore = {
|
|
23
|
+
* async read(key) { return localStorage.getItem(key); },
|
|
24
|
+
* async write(key, value) { localStorage.setItem(key, value); },
|
|
25
|
+
* async remove(key) { localStorage.removeItem(key); },
|
|
26
|
+
* };
|
|
27
|
+
*
|
|
28
|
+
* const chat = new NoveraChat({
|
|
29
|
+
* appId, endpoint, tokenProvider,
|
|
30
|
+
* cacheStore: localStorageCache,
|
|
31
|
+
* });
|
|
32
|
+
* ```
|
|
33
|
+
*/
|
|
34
|
+
export interface CacheStore {
|
|
35
|
+
/** `key`의 스냅샷을 돌려준다. 없으면 null. */
|
|
36
|
+
read(key: string): Promise<string | null>;
|
|
37
|
+
/** `key`에 스냅샷을 저장한다(덮어쓰기). */
|
|
38
|
+
write(key: string, value: string): Promise<void>;
|
|
39
|
+
/** `key`의 스냅샷을 지운다. 없어도 에러 없이 통과. */
|
|
40
|
+
remove(key: string): Promise<void>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 캐시 스냅샷과 API 응답을 어떻게 합칠지.
|
|
45
|
+
*
|
|
46
|
+
* 현재는 `"replaceByApi"`만 구현되어 있다 — `"mergeDiff"`는 서버 측
|
|
47
|
+
* 무효화 규칙(삭제 타이머 만료 통지·재입장 컷오프 등)이 확정된 뒤
|
|
48
|
+
* 병합 함수가 채워질 진입점이다.
|
|
49
|
+
*/
|
|
50
|
+
export type CachePolicy = "replaceByApi" | "mergeDiff";
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 캐시 키 규칙 — `nc.<스키마버전>.<대상>`.
|
|
54
|
+
*
|
|
55
|
+
* 스키마 버전이 키에 박혀 있으므로, 저장 포맷이 바뀌면 버전을 올리는
|
|
56
|
+
* 것만으로 구버전 스냅샷은 다시는 읽히지 않는다(자연 폐기).
|
|
57
|
+
*/
|
|
58
|
+
export const CacheKeys = {
|
|
59
|
+
/** 저장 포맷 스키마 버전. 저장되는 JSON 모양이 바뀌면 올린다. */
|
|
60
|
+
version: "v1",
|
|
61
|
+
/** 방 목록 스냅샷 (`GET /users/me/unread` 원문). */
|
|
62
|
+
rooms: "nc.v1.rooms",
|
|
63
|
+
/** 방 히스토리 최신 페이지 스냅샷 (`GET /rooms/{id}/messages` 원문). */
|
|
64
|
+
msgs: (roomId: string): string => `nc.v1.msgs.${roomId}`,
|
|
65
|
+
} 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";
|
|
@@ -67,6 +68,11 @@ export class NoveraChat {
|
|
|
67
68
|
if (!opts.appId) throw new Error("appId required");
|
|
68
69
|
if (!opts.endpoint) throw new Error("endpoint required");
|
|
69
70
|
if (!opts.tokenProvider) throw new Error("tokenProvider required");
|
|
71
|
+
if (opts.cachePolicy === "mergeDiff") {
|
|
72
|
+
throw new Error(
|
|
73
|
+
'CachePolicy "mergeDiff" 는 아직 미구현 — 현재 "replaceByApi"만 지원',
|
|
74
|
+
);
|
|
75
|
+
}
|
|
70
76
|
|
|
71
77
|
// 400ms is the read-receipt-UX sweet spot for chat apps. Shorter and
|
|
72
78
|
// we waste WS frames on every micro-scroll; longer and the "✓ read"
|
|
@@ -146,7 +152,11 @@ export class NoveraChat {
|
|
|
146
152
|
room(roomId: string): Room {
|
|
147
153
|
let r = this.rooms.get(roomId);
|
|
148
154
|
if (!r) {
|
|
149
|
-
r = new Room(roomId, this.http, this.ws, this.readDebounceMs
|
|
155
|
+
r = new Room(roomId, this.http, this.ws, this.readDebounceMs, {
|
|
156
|
+
...(this.opts.cacheStore ? { cacheStore: this.opts.cacheStore } : {}),
|
|
157
|
+
...(this.opts.cachePolicy ? { cachePolicy: this.opts.cachePolicy } : {}),
|
|
158
|
+
...(this.logger ? { logger: this.logger } : {}),
|
|
159
|
+
});
|
|
150
160
|
this.rooms.set(roomId, r);
|
|
151
161
|
}
|
|
152
162
|
return r;
|
|
@@ -409,7 +419,44 @@ export class NoveraChat {
|
|
|
409
419
|
|
|
410
420
|
async unreadSummary(): Promise<UnreadSummary> {
|
|
411
421
|
const raw = await this.http.request<unknown>("GET", "/api/v1/users/me/unread");
|
|
412
|
-
|
|
422
|
+
const out = parseUnreadSummary(raw); // 파싱 성공한 응답만 캐시에 넣는다
|
|
423
|
+
void this.cacheWriteRooms(raw);
|
|
424
|
+
return out;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* 방 목록의 캐시 스냅샷 — 마지막 `unreadSummary()` 성공 응답의 원문.
|
|
429
|
+
*
|
|
430
|
+
* 콜드 스타트 렌더 힌트 용도다: 있으면 즉시 그리고, `unreadSummary()`가
|
|
431
|
+
* 도착하면 통째로 교체할 것. 캐시가 없거나(`cacheStore` 미주입 포함)
|
|
432
|
+
* 파싱이 실패하면 `null`을 돌려주고, 깨진 스냅샷은 지운다.
|
|
433
|
+
*/
|
|
434
|
+
async cachedUnreadSummary(): Promise<UnreadSummary | null> {
|
|
435
|
+
const store = this.opts.cacheStore;
|
|
436
|
+
if (!store) return null;
|
|
437
|
+
try {
|
|
438
|
+
const raw = await store.read(CacheKeys.rooms);
|
|
439
|
+
if (raw == null) return null;
|
|
440
|
+
return parseUnreadSummary(JSON.parse(raw));
|
|
441
|
+
} catch (err) {
|
|
442
|
+
this.logger?.("warn", `cache_read_failed key=${CacheKeys.rooms}`, err);
|
|
443
|
+
try {
|
|
444
|
+
await store.remove(CacheKeys.rooms);
|
|
445
|
+
} catch {
|
|
446
|
+
// 폐기 실패도 무시 — 캐시는 힌트일 뿐
|
|
447
|
+
}
|
|
448
|
+
return null;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
private async cacheWriteRooms(raw: unknown): Promise<void> {
|
|
453
|
+
const store = this.opts.cacheStore;
|
|
454
|
+
if (!store) return;
|
|
455
|
+
try {
|
|
456
|
+
await store.write(CacheKeys.rooms, JSON.stringify(raw));
|
|
457
|
+
} catch (err) {
|
|
458
|
+
this.logger?.("warn", `cache_write_failed key=${CacheKeys.rooms}`, err);
|
|
459
|
+
}
|
|
413
460
|
}
|
|
414
461
|
|
|
415
462
|
/**
|