@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/LICENSE +21 -0
- package/README.ko.md +86 -0
- package/README.md +86 -0
- package/dist/index.cjs +1737 -0
- package/dist/index.d.cts +1151 -0
- package/dist/index.d.ts +1151 -0
- package/dist/index.js +1707 -0
- package/package.json +43 -0
- package/src/client.ts +733 -0
- package/src/errors.ts +59 -0
- package/src/features/room.ts +1292 -0
- package/src/generated/README.ko.md +15 -0
- package/src/generated/README.md +15 -0
- package/src/generated/wire.ts +224 -0
- package/src/index.ts +27 -0
- package/src/internal/event-bus.ts +42 -0
- package/src/internal/temp-id.ts +10 -0
- package/src/transport/http.ts +81 -0
- package/src/transport/ws.ts +200 -0
- package/src/types.ts +375 -0
- package/src/utils/debounce.ts +14 -0
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
|
+
}
|