@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/README.ko.md +1 -1
- package/README.md +1 -1
- package/dist/index.cjs +700 -14
- package/dist/index.d.cts +404 -5
- package/dist/index.d.ts +404 -5
- package/dist/index.js +693 -13
- package/package.json +1 -1
- package/src/cache.ts +67 -0
- package/src/chat-message.ts +214 -0
- package/src/client.ts +44 -2
- package/src/features/message-collection.ts +561 -0
- package/src/features/room.ts +112 -5
- package/src/generated/rest.ts +64 -0
- package/src/index.ts +19 -0
- package/src/internal/compare-id.ts +17 -0
- package/src/types.ts +13 -2
package/src/features/room.ts
CHANGED
|
@@ -12,6 +12,7 @@ import type { HttpClient } from "../transport/http.js";
|
|
|
12
12
|
import type { ReconnectingWs } from "../transport/ws.js";
|
|
13
13
|
import type {
|
|
14
14
|
Announcement,
|
|
15
|
+
ClientOptions,
|
|
15
16
|
DeleteScope,
|
|
16
17
|
FileKind,
|
|
17
18
|
FileMeta,
|
|
@@ -20,6 +21,7 @@ import type {
|
|
|
20
21
|
MessageIdStr,
|
|
21
22
|
MessageOut,
|
|
22
23
|
RoomMemberOut,
|
|
24
|
+
RoomOut,
|
|
23
25
|
RoomUnread,
|
|
24
26
|
SendFileOptions,
|
|
25
27
|
SendParams,
|
|
@@ -33,8 +35,10 @@ import type {
|
|
|
33
35
|
WsSyncTick,
|
|
34
36
|
WsTypingBroadcast,
|
|
35
37
|
} from "../types.js";
|
|
38
|
+
import { CacheKeys, type CachePolicy, type CacheStore } from "../cache.js";
|
|
36
39
|
import { EventBus } from "../internal/event-bus.js";
|
|
37
40
|
import { tempId as newTempId } from "../internal/temp-id.js";
|
|
41
|
+
import { MessageCollection } from "./message-collection.js";
|
|
38
42
|
import {
|
|
39
43
|
parseAnnouncementOut,
|
|
40
44
|
parseBulkDeleteBySenderResult,
|
|
@@ -46,6 +50,7 @@ import {
|
|
|
46
50
|
parseJoinRequestListResponse,
|
|
47
51
|
parseMessageOut,
|
|
48
52
|
parseRoomMemberOut,
|
|
53
|
+
parseRoomOut,
|
|
49
54
|
parseRoomUnread,
|
|
50
55
|
} from "../generated/rest.js";
|
|
51
56
|
import type {
|
|
@@ -89,7 +94,7 @@ function toFileInline(f: {
|
|
|
89
94
|
// Enhance the GENERATED `parseMessageOut` with the two client-side
|
|
90
95
|
// extras it can't derive from the OpenAPI schema: parse `file`/`files`
|
|
91
96
|
// into the camelCase `FileInline` shape, and lift sender-attached
|
|
92
|
-
// `_customMeta` out of `data
|
|
97
|
+
// `_customMeta` out of `data`. Everything else — key
|
|
93
98
|
// renames, nested sender/reactions — comes straight from the codegen.
|
|
94
99
|
function toMessageOut(j: unknown): MessageOut {
|
|
95
100
|
const base = parseMessageOut(j);
|
|
@@ -196,13 +201,43 @@ export class Room {
|
|
|
196
201
|
*/
|
|
197
202
|
private mySentTempIds = new Set<string>();
|
|
198
203
|
|
|
204
|
+
// ── 스냅샷 캐시 협력자 (클라이언트 옵션에서 흘러듦) ──────────────
|
|
205
|
+
private readonly cacheStore: CacheStore | null;
|
|
206
|
+
private readonly cachePolicy: CachePolicy;
|
|
207
|
+
private readonly logger: ClientOptions["logger"];
|
|
199
208
|
constructor(
|
|
200
209
|
public readonly roomId: string,
|
|
201
210
|
private http: HttpClient,
|
|
202
211
|
private ws: ReconnectingWs,
|
|
203
212
|
readDebounceMs: number,
|
|
213
|
+
opts?: {
|
|
214
|
+
cacheStore?: CacheStore;
|
|
215
|
+
cachePolicy?: CachePolicy;
|
|
216
|
+
logger?: ClientOptions["logger"];
|
|
217
|
+
},
|
|
204
218
|
) {
|
|
205
219
|
this.readDebounceMs = readDebounceMs;
|
|
220
|
+
this.cacheStore = opts?.cacheStore ?? null;
|
|
221
|
+
this.cachePolicy = opts?.cachePolicy ?? "replaceByApi";
|
|
222
|
+
this.logger = opts?.logger;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* 이 방의 메시지 상태를 소유하는 `MessageCollection`을 만든다.
|
|
227
|
+
*
|
|
228
|
+
* 컬렉션이 이벤트 반영·낙관적 전송·캐시 hydrate→교체를 전부 담당하므로,
|
|
229
|
+
* 리스트 화면은 컬렉션 하나만 물면 된다. 여러 개 만들 수 있고 각자
|
|
230
|
+
* `dispose()`로 정리한다.
|
|
231
|
+
*
|
|
232
|
+
* ```ts
|
|
233
|
+
* const col = chat.room("room_123").collection();
|
|
234
|
+
* const unsub = col.on("change", () => render(col.messages));
|
|
235
|
+
* await col.load();
|
|
236
|
+
* col.send("hello");
|
|
237
|
+
* ```
|
|
238
|
+
*/
|
|
239
|
+
collection(policy?: CachePolicy): MessageCollection {
|
|
240
|
+
return new MessageCollection(this, policy ?? this.cachePolicy);
|
|
206
241
|
}
|
|
207
242
|
|
|
208
243
|
private _sendPendingMarkRead(): void {
|
|
@@ -260,7 +295,7 @@ export class Room {
|
|
|
260
295
|
forward_blocked: Boolean(p.forwardBlocked),
|
|
261
296
|
}
|
|
262
297
|
: (p.data ?? null);
|
|
263
|
-
//
|
|
298
|
+
// Merge client-supplied custom metadata under a reserved
|
|
264
299
|
// `_customMeta` key in `data`. Backend passes the whole `data` blob
|
|
265
300
|
// through untouched, so receiving clients read it back via
|
|
266
301
|
// `MessageOut.customMeta` (see `toMessageOut`).
|
|
@@ -788,15 +823,87 @@ export class Room {
|
|
|
788
823
|
items: MessageOut[];
|
|
789
824
|
nextCursor: MessageIdStr | null;
|
|
790
825
|
hasMore: boolean;
|
|
826
|
+
/** 히스토리 하한(unix ms). null=컷 없음 — 재입장 컷·대화 지우기가 수렴. */
|
|
827
|
+
historyFrom: number | null;
|
|
791
828
|
}> {
|
|
792
829
|
const raw = await this.http.request<{
|
|
793
830
|
items: unknown[]; next_cursor: string | null; has_more: boolean;
|
|
831
|
+
history_from?: number | null;
|
|
794
832
|
}>("GET", `/api/v1/rooms/${this.roomId}/messages`, undefined, { limit });
|
|
795
|
-
|
|
796
|
-
items: raw.items.map(toMessageOut),
|
|
833
|
+
const page = {
|
|
834
|
+
items: raw.items.map(toMessageOut), // 파싱 성공한 응답만 캐시에 넣는다
|
|
797
835
|
nextCursor: raw.next_cursor,
|
|
798
836
|
hasMore: raw.has_more,
|
|
837
|
+
historyFrom: raw.history_from ?? null,
|
|
799
838
|
};
|
|
839
|
+
void this.cacheWriteRecent(raw);
|
|
840
|
+
return page;
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
/** 이 방의 상세 정보 (`GET /rooms/{id}`) — 이름·멤버 수·삭제 타이머 등. */
|
|
844
|
+
async detail(): Promise<RoomOut> {
|
|
845
|
+
const raw = await this.http.request<unknown>(
|
|
846
|
+
"GET", `/api/v1/rooms/${this.roomId}`,
|
|
847
|
+
);
|
|
848
|
+
return parseRoomOut(raw);
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
/**
|
|
852
|
+
* 히스토리 최신 페이지의 캐시 스냅샷 — 마지막 `listRecent()` 성공 응답의
|
|
853
|
+
* 원문. 콜드 스타트 렌더 힌트 용도다: 있으면 즉시 그리고, `listRecent()`가
|
|
854
|
+
* 도착하면 통째로 교체할 것. 캐시가 없거나(`cacheStore` 미주입 포함)
|
|
855
|
+
* 파싱이 실패하면 `null`을 돌려주고, 깨진 스냅샷은 지운다.
|
|
856
|
+
*
|
|
857
|
+
* 스냅샷의 `nextCursor`/`hasMore`는 시점이 지난 값이므로 스크롤백
|
|
858
|
+
* 커서로 쓰지 말 것 — API 응답이 도착한 뒤의 값만 신뢰한다.
|
|
859
|
+
*/
|
|
860
|
+
async cachedRecent(): Promise<{
|
|
861
|
+
items: MessageOut[];
|
|
862
|
+
nextCursor: MessageIdStr | null;
|
|
863
|
+
hasMore: boolean;
|
|
864
|
+
historyFrom: number | null;
|
|
865
|
+
} | null> {
|
|
866
|
+
const store = this.cacheStore;
|
|
867
|
+
if (!store) return null;
|
|
868
|
+
const key = CacheKeys.msgs(this.roomId);
|
|
869
|
+
try {
|
|
870
|
+
const raw = await store.read(key);
|
|
871
|
+
if (raw == null) return null;
|
|
872
|
+
const j = JSON.parse(raw) as {
|
|
873
|
+
items: unknown[]; next_cursor: string | null; has_more: boolean;
|
|
874
|
+
history_from?: number | null;
|
|
875
|
+
};
|
|
876
|
+
return {
|
|
877
|
+
items: j.items.map(toMessageOut),
|
|
878
|
+
nextCursor: j.next_cursor,
|
|
879
|
+
hasMore: j.has_more,
|
|
880
|
+
historyFrom: j.history_from ?? null,
|
|
881
|
+
};
|
|
882
|
+
} catch (err) {
|
|
883
|
+
this.logger?.("warn", `cache_read_failed key=${key}`, err);
|
|
884
|
+
try {
|
|
885
|
+
await store.remove(key);
|
|
886
|
+
} catch {
|
|
887
|
+
// 폐기 실패도 무시 — 캐시는 힌트일 뿐
|
|
888
|
+
}
|
|
889
|
+
return null;
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
/**
|
|
894
|
+
* 최신 페이지 스냅샷 저장. 삭제 타이머 방도 저장한다 — 서버가 만료를
|
|
895
|
+
* 일반 `message_deleted` 프레임으로 브로드캐스트하고 히스토리에서도
|
|
896
|
+
* 즉시 제외하므로, 만료분은 라이브 이벤트/`history_from` diff로 정리된다.
|
|
897
|
+
*/
|
|
898
|
+
private async cacheWriteRecent(raw: unknown): Promise<void> {
|
|
899
|
+
const store = this.cacheStore;
|
|
900
|
+
if (!store) return;
|
|
901
|
+
const key = CacheKeys.msgs(this.roomId);
|
|
902
|
+
try {
|
|
903
|
+
await store.write(key, JSON.stringify(raw));
|
|
904
|
+
} catch (err) {
|
|
905
|
+
this.logger?.("warn", `cache_write_failed key=${key}`, err);
|
|
906
|
+
}
|
|
800
907
|
}
|
|
801
908
|
|
|
802
909
|
/**
|
|
@@ -822,7 +929,7 @@ export class Room {
|
|
|
822
929
|
};
|
|
823
930
|
}
|
|
824
931
|
|
|
825
|
-
/**
|
|
932
|
+
/** 방 내 메시지 검색.
|
|
826
933
|
*
|
|
827
934
|
* ``content`` 필드에 대한 대소문자 무시 부분 문자열 매칭 (ILIKE). 삭제된
|
|
828
935
|
* 메시지는 제외. 결과는 최신순 (message_id DESC) 으로 반환되고, ``before``
|
package/src/generated/rest.ts
CHANGED
|
@@ -44,6 +44,7 @@ export interface UnreadRoomItem {
|
|
|
44
44
|
lastMessageId?: string | null;
|
|
45
45
|
lastReadMessageId?: string | null;
|
|
46
46
|
lastMessagePreview?: string | null;
|
|
47
|
+
lastMessageAt?: number | null;
|
|
47
48
|
memberCount?: number;
|
|
48
49
|
membersPreview?: MemberPreview[];
|
|
49
50
|
}
|
|
@@ -57,6 +58,7 @@ export function parseUnreadRoomItem(j: any): UnreadRoomItem {
|
|
|
57
58
|
lastMessageId: j["last_message_id"],
|
|
58
59
|
lastReadMessageId: j["last_read_message_id"],
|
|
59
60
|
lastMessagePreview: j["last_message_preview"],
|
|
61
|
+
lastMessageAt: j["last_message_at"],
|
|
60
62
|
memberCount: j["member_count"],
|
|
61
63
|
membersPreview: j["members_preview"] == null ? undefined : (j["members_preview"] as unknown[]).map((e: any) => parseMemberPreview(e)),
|
|
62
64
|
} as UnreadRoomItem;
|
|
@@ -155,6 +157,7 @@ export interface MessageListResponse {
|
|
|
155
157
|
items: MessageOut[];
|
|
156
158
|
nextCursor?: string | null;
|
|
157
159
|
hasMore: boolean;
|
|
160
|
+
historyFrom?: number | null;
|
|
158
161
|
}
|
|
159
162
|
|
|
160
163
|
export function parseMessageListResponse(j: any): MessageListResponse {
|
|
@@ -162,6 +165,7 @@ export function parseMessageListResponse(j: any): MessageListResponse {
|
|
|
162
165
|
items: (j["items"] as unknown[]).map((e: any) => parseMessageOut(e)),
|
|
163
166
|
nextCursor: j["next_cursor"],
|
|
164
167
|
hasMore: j["has_more"],
|
|
168
|
+
historyFrom: j["history_from"],
|
|
165
169
|
} as MessageListResponse;
|
|
166
170
|
}
|
|
167
171
|
|
|
@@ -489,6 +493,20 @@ export function parseJoinRequestCreateResponse(j: any): JoinRequestCreateRespons
|
|
|
489
493
|
} as JoinRequestCreateResponse;
|
|
490
494
|
}
|
|
491
495
|
|
|
496
|
+
export interface RoomFilesResponse {
|
|
497
|
+
items: RoomFileItem[];
|
|
498
|
+
nextCursor?: string | null;
|
|
499
|
+
hasMore?: boolean;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
export function parseRoomFilesResponse(j: any): RoomFilesResponse {
|
|
503
|
+
return {
|
|
504
|
+
items: (j["items"] as unknown[]).map((e: any) => parseRoomFileItem(e)),
|
|
505
|
+
nextCursor: j["next_cursor"],
|
|
506
|
+
hasMore: j["has_more"],
|
|
507
|
+
} as RoomFilesResponse;
|
|
508
|
+
}
|
|
509
|
+
|
|
492
510
|
/** Minimal sender identity — no friend/block/profile-url fluff. */
|
|
493
511
|
export interface SenderMini {
|
|
494
512
|
userId: string;
|
|
@@ -527,3 +545,49 @@ export type JoinPolicy = "OPEN" | "APPROVAL_REQUIRED";
|
|
|
527
545
|
export type MemberRole = "OPERATOR" | "MEMBER" | "KICKED";
|
|
528
546
|
|
|
529
547
|
export type PushTrigger = "ALL" | "MENTION_ONLY" | "OFF";
|
|
548
|
+
|
|
549
|
+
/** 방에서 공유된 파일 1건 — 전달(forward)이면 메시지마다 한 번씩. */
|
|
550
|
+
export interface RoomFileItem {
|
|
551
|
+
messageId: string;
|
|
552
|
+
senderId: string;
|
|
553
|
+
createdAtMs: number;
|
|
554
|
+
file: RoomFileInfo;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
export function parseRoomFileItem(j: any): RoomFileItem {
|
|
558
|
+
return {
|
|
559
|
+
messageId: j["message_id"],
|
|
560
|
+
senderId: j["sender_id"],
|
|
561
|
+
createdAtMs: j["created_at_ms"],
|
|
562
|
+
file: parseRoomFileInfo(j["file"]),
|
|
563
|
+
} as RoomFileItem;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/** 한 파일의 메타 (rooms/{id}/files 항목의 file 필드). */
|
|
567
|
+
export interface RoomFileInfo {
|
|
568
|
+
id: string;
|
|
569
|
+
kind: string;
|
|
570
|
+
mime?: string | null;
|
|
571
|
+
sizeBytes?: number | null;
|
|
572
|
+
originalName?: string | null;
|
|
573
|
+
width?: number | null;
|
|
574
|
+
height?: number | null;
|
|
575
|
+
durationSec?: number | null;
|
|
576
|
+
thumbnailStatus?: string | null;
|
|
577
|
+
forwardBlocked?: boolean;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
export function parseRoomFileInfo(j: any): RoomFileInfo {
|
|
581
|
+
return {
|
|
582
|
+
id: j["id"],
|
|
583
|
+
kind: j["kind"],
|
|
584
|
+
mime: j["mime"],
|
|
585
|
+
sizeBytes: j["size_bytes"],
|
|
586
|
+
originalName: j["original_name"],
|
|
587
|
+
width: j["width"],
|
|
588
|
+
height: j["height"],
|
|
589
|
+
durationSec: j["duration_sec"],
|
|
590
|
+
thumbnailStatus: j["thumbnail_status"],
|
|
591
|
+
forwardBlocked: j["forward_blocked"],
|
|
592
|
+
} as RoomFileInfo;
|
|
593
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
export { NoveraChat } from "./client.js";
|
|
2
2
|
export { Room, type RoomEvents } from "./features/room.js";
|
|
3
|
+
|
|
4
|
+
// 스냅샷 캐시 — 앱 주입 저장소 인터페이스 + 정책 + 키 규칙
|
|
5
|
+
export { CacheKeys, type CachePolicy, type CacheStore } from "./cache.js";
|
|
6
|
+
|
|
7
|
+
// 코어 소유 메시지 상태 + 뷰모델 (sdk-react에서 이동)
|
|
8
|
+
export {
|
|
9
|
+
MessageCollection,
|
|
10
|
+
type CollectionChange,
|
|
11
|
+
type CollectionChangeKind,
|
|
12
|
+
} from "./features/message-collection.js";
|
|
13
|
+
export {
|
|
14
|
+
chatMessageFromHistory,
|
|
15
|
+
chatMessageFromLive,
|
|
16
|
+
pendingChatMessage,
|
|
17
|
+
pendingFileChatMessage,
|
|
18
|
+
type ChatMessage,
|
|
19
|
+
type ChatMessageStatus,
|
|
20
|
+
} from "./chat-message.js";
|
|
3
21
|
export {
|
|
4
22
|
ErrorCode,
|
|
5
23
|
NoveraChatError,
|
|
@@ -20,6 +38,7 @@ export type {
|
|
|
20
38
|
MessageType,
|
|
21
39
|
ReactionEntry,
|
|
22
40
|
RoomMemberOut,
|
|
41
|
+
RoomOut,
|
|
23
42
|
RoomUnread,
|
|
24
43
|
SendParams,
|
|
25
44
|
SenderMini,
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Snowflake id 비교 — 63비트 정수가 문자열로 오므로 BigInt로 비교한다
|
|
3
|
+
* (길이가 다르면 사전순 `>`는 틀린다: "9" > "10"). null/빈 문자열/"0"은
|
|
4
|
+
* "id 없음 = -∞"로 취급.
|
|
5
|
+
*/
|
|
6
|
+
export function idGreater(
|
|
7
|
+
a: string | null | undefined,
|
|
8
|
+
b: string | null | undefined,
|
|
9
|
+
): boolean {
|
|
10
|
+
if (!a || a === "0") return false;
|
|
11
|
+
if (!b || b === "0") return true;
|
|
12
|
+
try {
|
|
13
|
+
return BigInt(a) > BigInt(b);
|
|
14
|
+
} catch {
|
|
15
|
+
return a > b;
|
|
16
|
+
}
|
|
17
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -152,8 +152,7 @@ export interface SendParams {
|
|
|
152
152
|
forwardBlocked?: boolean;
|
|
153
153
|
replyToId?: MessageIdStr;
|
|
154
154
|
/** Set when this send is forwarding an existing message. The server
|
|
155
|
-
* uses it to enforce the source file's ``forwardBlocked`` flag
|
|
156
|
-
* C #7): if the source's file is no-forward AND the destination room
|
|
155
|
+
* uses it to enforce the source file's ``forwardBlocked`` flag: if the source's file is no-forward AND the destination room
|
|
157
156
|
* differs from the source room, the send is rejected with
|
|
158
157
|
* ``NC-FILE-014``. Pass the ORIGINAL message's ``messageId``. */
|
|
159
158
|
forwardOfMessageId?: MessageIdStr;
|
|
@@ -238,11 +237,23 @@ export interface FilePresignResponse {
|
|
|
238
237
|
|
|
239
238
|
// (wire frame types are generated — see re-export block near the top)
|
|
240
239
|
|
|
240
|
+
import type { CachePolicy, CacheStore } from "./cache.js";
|
|
241
|
+
|
|
241
242
|
export interface ClientOptions {
|
|
242
243
|
appId: string;
|
|
243
244
|
endpoint: string; // e.g. "https://chat.example.com"
|
|
244
245
|
wsEndpoint?: string; // defaults to endpoint with ws(s)://
|
|
245
246
|
tokenProvider: () => Promise<string>;
|
|
247
|
+
/**
|
|
248
|
+
* 스냅샷 캐시 저장소 — 생략(기본)하면 캐시 기능이 전부 꺼진다.
|
|
249
|
+
* 주입하면 방 목록·방 히스토리 최신 페이지가 REST 원문 그대로 저장되고,
|
|
250
|
+
* `cachedUnreadSummary()` / `room.cachedRecent()` 로 콜드 스타트 즉시
|
|
251
|
+
* 렌더에 쓸 수 있다. 자세한 건 `CacheStore` 문서 참조.
|
|
252
|
+
*/
|
|
253
|
+
cacheStore?: CacheStore;
|
|
254
|
+
/** 캐시 스냅샷과 API 응답의 합성 방식. 기본 `"replaceByApi"`(통째 교체),
|
|
255
|
+
* `"mergeDiff"`는 옵트인 — 옛 캐시 메시지를 보존·병합. */
|
|
256
|
+
cachePolicy?: CachePolicy;
|
|
246
257
|
autoReconnect?: boolean; // default true
|
|
247
258
|
pingIntervalMs?: number; // default 20_000
|
|
248
259
|
readWatermarkDebounceMs?: number;// default 1500
|