@inline-chat/realtime-sdk 0.0.1

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.
@@ -0,0 +1,541 @@
1
+ import { GetChatInput, GetMeInput, GetUpdatesInput, GetUpdatesResult_ResultType, GetUpdatesStateInput, InputPeer, MessageEntities, MessageSendMode, Method, UpdateBucket, UpdateComposeAction_ComposeAction, } from "@inline-chat/protocol/core";
2
+ import { asInlineId } from "../ids.js";
3
+ import { AsyncChannel } from "../utils/async-channel.js";
4
+ import { ProtocolClient } from "../realtime/protocol-client.js";
5
+ import { WebSocketTransport } from "../realtime/ws-transport.js";
6
+ import { rpcInputKindByMethod, rpcResultKindByMethod } from "./types.js";
7
+ import { noopLogger } from "./logger.js";
8
+ import { getSdkVersion } from "./sdk-version.js";
9
+ const nowSeconds = () => BigInt(Math.floor(Date.now() / 1000));
10
+ const sdkLayer = 1;
11
+ function extractFirstMessageId(updates) {
12
+ for (const update of updates ?? []) {
13
+ if (update.update.oneofKind === "newMessage") {
14
+ const message = update.update.newMessage.message;
15
+ if (message)
16
+ return message.id;
17
+ }
18
+ }
19
+ return null;
20
+ }
21
+ export class InlineSdkClient {
22
+ options;
23
+ log;
24
+ transport;
25
+ protocol;
26
+ eventStream = new AsyncChannel();
27
+ started = false;
28
+ openPromise = null;
29
+ openResolver = null;
30
+ openRejecter = null;
31
+ state = { version: 1 };
32
+ saveTimer = null;
33
+ saveInFlight = null;
34
+ catchUpInFlightByChatId = new Map();
35
+ constructor(options) {
36
+ this.options = options;
37
+ this.log = options.logger ?? noopLogger;
38
+ const baseUrl = options.baseUrl ?? "https://api.inline.chat";
39
+ const url = resolveRealtimeUrl(baseUrl);
40
+ this.transport = options.transport ?? new WebSocketTransport({ url, logger: options.logger });
41
+ this.protocol = new ProtocolClient({
42
+ transport: this.transport,
43
+ getConnectionInit: () => ({
44
+ token: options.token,
45
+ layer: sdkLayer,
46
+ clientVersion: getSdkVersion(),
47
+ }),
48
+ logger: options.logger,
49
+ });
50
+ void this.startListeners();
51
+ }
52
+ async connect(signal) {
53
+ if (this.started) {
54
+ // If a connection attempt is already in-flight, callers should still
55
+ // await readiness.
56
+ if (this.openPromise)
57
+ await this.openPromise;
58
+ return;
59
+ }
60
+ this.started = true;
61
+ if (signal?.aborted)
62
+ throw new Error("aborted");
63
+ const openPromise = new Promise((resolve, reject) => {
64
+ this.openResolver = resolve;
65
+ this.openRejecter = reject;
66
+ });
67
+ this.openPromise = openPromise;
68
+ // If connect() fails before we ever `await openPromise`, we still reject it
69
+ // to unblock concurrent callers. Ensure the rejection is always handled.
70
+ openPromise.catch(() => { });
71
+ if (signal) {
72
+ signal.addEventListener("abort", () => {
73
+ // Ensure connect() doesn't hang if we're aborted before `open`.
74
+ this.rejectOpen(new Error("aborted"));
75
+ void this.close();
76
+ }, { once: true });
77
+ }
78
+ try {
79
+ await this.loadState();
80
+ await this.protocol.startTransport();
81
+ // Wait until authenticated and connection is open.
82
+ await openPromise;
83
+ }
84
+ catch (error) {
85
+ // If connect() fails, leave the client in a "stopped" state so callers can retry.
86
+ this.started = false;
87
+ const err = error instanceof Error ? error : new Error(String(error));
88
+ this.rejectOpen(err);
89
+ await this.protocol.stopTransport().catch(() => { });
90
+ throw err;
91
+ }
92
+ finally {
93
+ if (this.openPromise === openPromise) {
94
+ this.openPromise = null;
95
+ }
96
+ }
97
+ }
98
+ async close() {
99
+ if (!this.started)
100
+ return;
101
+ this.started = false;
102
+ this.rejectOpen(new Error("closed"));
103
+ this.eventStream.close();
104
+ await Promise.allSettled(this.catchUpInFlightByChatId.values());
105
+ await this.flushStateSave();
106
+ await this.protocol.stopTransport();
107
+ }
108
+ rejectOpen(error) {
109
+ this.openRejecter?.(error);
110
+ this.openResolver = null;
111
+ this.openRejecter = null;
112
+ }
113
+ events() {
114
+ return this.eventStream;
115
+ }
116
+ exportState() {
117
+ return {
118
+ version: 1,
119
+ ...(this.state.dateCursor != null ? { dateCursor: this.state.dateCursor } : {}),
120
+ ...(this.state.lastSeqByChatId != null ? { lastSeqByChatId: { ...this.state.lastSeqByChatId } } : {}),
121
+ };
122
+ }
123
+ async getMe() {
124
+ const result = await this.invoke(Method.GET_ME, { oneofKind: "getMe", getMe: GetMeInput.create({}) });
125
+ if (!result.getMe.user)
126
+ throw new Error("getMe: missing user");
127
+ return { userId: result.getMe.user.id };
128
+ }
129
+ async getChat(params) {
130
+ const peerId = InputPeer.create({
131
+ type: { oneofKind: "chat", chat: { chatId: asInlineId(params.chatId, "chatId") } },
132
+ });
133
+ const result = await this.invoke(Method.GET_CHAT, {
134
+ oneofKind: "getChat",
135
+ getChat: GetChatInput.create({ peerId }),
136
+ });
137
+ const chat = result.getChat.chat;
138
+ if (!chat)
139
+ throw new Error("getChat: missing chat");
140
+ return { chatId: chat.id, peer: chat.peerId, title: chat.title };
141
+ }
142
+ async sendMessage(params) {
143
+ if (params.entities != null && params.parseMarkdown != null) {
144
+ throw new Error("sendMessage: provide either `entities` or `parseMarkdown`, not both");
145
+ }
146
+ const peerId = this.inputPeerFromTarget(params, "sendMessage");
147
+ const result = await this.invoke(Method.SEND_MESSAGE, {
148
+ oneofKind: "sendMessage",
149
+ sendMessage: {
150
+ peerId,
151
+ message: params.text,
152
+ ...(params.replyToMsgId != null ? { replyToMsgId: asInlineId(params.replyToMsgId, "replyToMsgId") } : {}),
153
+ ...(params.parseMarkdown != null ? { parseMarkdown: params.parseMarkdown } : {}),
154
+ ...(params.entities != null ? { entities: params.entities } : {}),
155
+ ...(params.sendMode === "silent" ? { sendMode: MessageSendMode.MODE_SILENT } : {}),
156
+ },
157
+ });
158
+ const messageId = extractFirstMessageId(result.sendMessage.updates);
159
+ return { messageId };
160
+ }
161
+ async sendTyping(params) {
162
+ const peerId = InputPeer.create({
163
+ type: { oneofKind: "chat", chat: { chatId: asInlineId(params.chatId, "chatId") } },
164
+ });
165
+ await this.invoke(Method.SEND_COMPOSE_ACTION, {
166
+ oneofKind: "sendComposeAction",
167
+ sendComposeAction: {
168
+ peerId,
169
+ ...(params.typing ? { action: UpdateComposeAction_ComposeAction.TYPING } : {}),
170
+ },
171
+ });
172
+ }
173
+ // Raw RPC invocation escape hatch. Validates method/input/result when the SDK
174
+ // has a mapping for the method; otherwise behaves like unchecked raw.
175
+ async invokeRaw(method, input = { oneofKind: undefined }, options) {
176
+ if (hasMethodMapping(method)) {
177
+ this.assertMethodInputMatch(method, input);
178
+ }
179
+ const result = await this.protocol.callRpc(method, input, options);
180
+ if (hasMethodMapping(method)) {
181
+ this.assertMethodResultMatch(method, result);
182
+ }
183
+ return result;
184
+ }
185
+ // Unchecked raw RPC invocation for forward-compat when new methods/types land
186
+ // before the SDK updates its method<->oneof mappings.
187
+ async invokeUncheckedRaw(method, input = { oneofKind: undefined }, options) {
188
+ return await this.protocol.callRpc(method, input, options);
189
+ }
190
+ async invoke(method, input, options) {
191
+ this.assertMethodInputMatch(method, input);
192
+ const result = await this.protocol.callRpc(method, input, options);
193
+ this.assertMethodResultMatch(method, result);
194
+ return result;
195
+ }
196
+ assertMethodInputMatch(method, input) {
197
+ const expected = rpcInputKindByMethod[method];
198
+ if (expected == null) {
199
+ if (input.oneofKind !== undefined) {
200
+ throw new Error(`rpc input mismatch: method ${Method[method]} expects no input`);
201
+ }
202
+ return;
203
+ }
204
+ if (input.oneofKind !== expected) {
205
+ throw new Error(`rpc input mismatch: method ${Method[method]} expects ${expected}`);
206
+ }
207
+ }
208
+ assertMethodResultMatch(method, result) {
209
+ const expected = rpcResultKindByMethod[method];
210
+ if (expected == null) {
211
+ if (result.oneofKind !== undefined) {
212
+ throw new Error(`rpc result mismatch: method ${Method[method]} expects no result`);
213
+ }
214
+ return;
215
+ }
216
+ if (result.oneofKind !== expected) {
217
+ throw new Error(`rpc result mismatch: method ${Method[method]} expects ${expected}`);
218
+ }
219
+ }
220
+ async startListeners() {
221
+ ;
222
+ (async () => {
223
+ for await (const event of this.protocol.events) {
224
+ switch (event.type) {
225
+ case "open":
226
+ await this.onOpen();
227
+ break;
228
+ case "updates":
229
+ await this.onUpdates(event.updates.updates);
230
+ break;
231
+ case "rpcError":
232
+ case "rpcResult":
233
+ case "ack":
234
+ case "connecting":
235
+ break;
236
+ }
237
+ }
238
+ })().catch((error) => {
239
+ this.log.error?.("SDK listener crashed", error);
240
+ this.rejectOpen(error instanceof Error ? error : new Error("listener-crashed"));
241
+ });
242
+ }
243
+ async onOpen() {
244
+ this.openResolver?.();
245
+ this.openResolver = null;
246
+ this.openRejecter = null;
247
+ // Best-effort: do not block `connect()` on cursor initialization.
248
+ void this.initializeDateCursor();
249
+ }
250
+ async initializeDateCursor() {
251
+ const date = this.state.dateCursor ?? nowSeconds();
252
+ try {
253
+ const result = await this.invoke(Method.GET_UPDATES_STATE, {
254
+ oneofKind: "getUpdatesState",
255
+ getUpdatesState: GetUpdatesStateInput.create({ date }),
256
+ }, { timeoutMs: 1500 });
257
+ this.state.dateCursor = result.getUpdatesState.date;
258
+ this.scheduleStateSave();
259
+ }
260
+ catch (error) {
261
+ // Not all deployments may support this yet; treat as best-effort.
262
+ this.log.warn?.("GET_UPDATES_STATE failed (continuing without date cursor)", error);
263
+ }
264
+ }
265
+ async onUpdates(updates) {
266
+ for (const update of updates) {
267
+ await this.handleUpdate(update);
268
+ }
269
+ }
270
+ async handleUpdate(update) {
271
+ const seq = update.seq ?? 0;
272
+ const date = update.date ?? 0n;
273
+ switch (update.update.oneofKind) {
274
+ case "newMessage": {
275
+ const message = update.update.newMessage.message;
276
+ if (!message)
277
+ return;
278
+ this.bumpChatSeq(message.chatId, seq);
279
+ await this.eventStream.send({
280
+ kind: "message.new",
281
+ chatId: message.chatId,
282
+ message,
283
+ seq,
284
+ date,
285
+ });
286
+ return;
287
+ }
288
+ case "editMessage": {
289
+ const message = update.update.editMessage.message;
290
+ if (!message)
291
+ return;
292
+ this.bumpChatSeq(message.chatId, seq);
293
+ await this.eventStream.send({
294
+ kind: "message.edit",
295
+ chatId: message.chatId,
296
+ message,
297
+ seq,
298
+ date,
299
+ });
300
+ return;
301
+ }
302
+ case "deleteMessages": {
303
+ const payload = update.update.deleteMessages;
304
+ const chatId = payload.peerId?.type.oneofKind === "chat" ? payload.peerId.type.chat.chatId : null;
305
+ if (!chatId) {
306
+ this.log.warn?.("Skipping deleteMessages update without chat peer", payload.peerId);
307
+ return;
308
+ }
309
+ this.bumpChatSeq(chatId, seq);
310
+ await this.eventStream.send({
311
+ kind: "message.delete",
312
+ chatId,
313
+ messageIds: payload.messageIds,
314
+ seq,
315
+ date,
316
+ });
317
+ return;
318
+ }
319
+ case "updateReaction": {
320
+ const reaction = update.update.updateReaction.reaction;
321
+ if (!reaction)
322
+ return;
323
+ this.bumpChatSeq(reaction.chatId, seq);
324
+ await this.eventStream.send({
325
+ kind: "reaction.add",
326
+ chatId: reaction.chatId,
327
+ reaction,
328
+ seq,
329
+ date,
330
+ });
331
+ return;
332
+ }
333
+ case "deleteReaction": {
334
+ const payload = update.update.deleteReaction;
335
+ this.bumpChatSeq(payload.chatId, seq);
336
+ await this.eventStream.send({
337
+ kind: "reaction.delete",
338
+ chatId: payload.chatId,
339
+ emoji: payload.emoji,
340
+ messageId: payload.messageId,
341
+ userId: payload.userId,
342
+ seq,
343
+ date,
344
+ });
345
+ return;
346
+ }
347
+ case "chatHasNewUpdates": {
348
+ const payload = update.update.chatHasNewUpdates;
349
+ await this.eventStream.send({
350
+ kind: "chat.hasUpdates",
351
+ chatId: payload.chatId,
352
+ seq,
353
+ date,
354
+ });
355
+ await this.catchUpChat({ chatId: payload.chatId, peer: payload.peerId, updateSeq: payload.updateSeq, update });
356
+ return;
357
+ }
358
+ case "spaceHasNewUpdates": {
359
+ const payload = update.update.spaceHasNewUpdates;
360
+ await this.eventStream.send({
361
+ kind: "space.hasUpdates",
362
+ spaceId: payload.spaceId,
363
+ seq,
364
+ date,
365
+ });
366
+ // Space catch-up is intentionally a no-op for MVP.
367
+ return;
368
+ }
369
+ default:
370
+ return;
371
+ }
372
+ }
373
+ bumpChatSeq(chatId, seq) {
374
+ if (!Number.isFinite(seq))
375
+ return;
376
+ if (!this.state.lastSeqByChatId)
377
+ this.state.lastSeqByChatId = {};
378
+ const key = chatId.toString();
379
+ const prev = this.state.lastSeqByChatId[key] ?? 0;
380
+ if (seq > prev) {
381
+ this.state.lastSeqByChatId[key] = seq;
382
+ this.scheduleStateSave();
383
+ }
384
+ }
385
+ async catchUpChat(params) {
386
+ const key = params.chatId.toString();
387
+ const lastSeq = this.state.lastSeqByChatId?.[key];
388
+ const startSeq = lastSeq ?? params.updateSeq; // default skip backlog
389
+ if (params.updateSeq <= startSeq)
390
+ return;
391
+ if (this.catchUpInFlightByChatId.has(params.chatId)) {
392
+ await this.catchUpInFlightByChatId.get(params.chatId);
393
+ return;
394
+ }
395
+ const task = this.doCatchUpChat(params.chatId, params.peer, startSeq, params.updateSeq).finally(() => {
396
+ this.catchUpInFlightByChatId.delete(params.chatId);
397
+ });
398
+ this.catchUpInFlightByChatId.set(params.chatId, task);
399
+ await task;
400
+ }
401
+ async doCatchUpChat(chatId, peer, startSeq, endSeq) {
402
+ let cursor = startSeq;
403
+ while (cursor < endSeq) {
404
+ const result = await this.invoke(Method.GET_UPDATES, {
405
+ oneofKind: "getUpdates",
406
+ getUpdates: GetUpdatesInput.create({
407
+ bucket: UpdateBucket.create({
408
+ type: {
409
+ oneofKind: "chat",
410
+ chat: {
411
+ peerId: this.peerToInputPeer(peer, chatId),
412
+ },
413
+ },
414
+ }),
415
+ startSeq: BigInt(cursor),
416
+ seqEnd: BigInt(endSeq),
417
+ totalLimit: 1000,
418
+ }),
419
+ });
420
+ const payload = result.getUpdates;
421
+ if (payload.resultType === GetUpdatesResult_ResultType.TOO_LONG) {
422
+ this.log.warn?.("GET_UPDATES too long; fast-forwarding cursor", { chatId: chatId.toString(), seq: payload.seq });
423
+ this.bumpChatSeq(chatId, endSeq);
424
+ if (payload.date !== 0n) {
425
+ this.state.dateCursor = payload.date;
426
+ }
427
+ this.scheduleStateSave();
428
+ return;
429
+ }
430
+ const deliveredSeq = Number(payload.seq ?? 0n);
431
+ if (!Number.isSafeInteger(deliveredSeq)) {
432
+ this.log.warn?.("GET_UPDATES returned non-integer seq; aborting catch-up", { chatId: chatId.toString() });
433
+ return;
434
+ }
435
+ // Mark the cursor as caught up to this slice before emitting any events.
436
+ this.bumpChatSeq(chatId, deliveredSeq);
437
+ for (const update of payload.updates) {
438
+ await this.handleUpdate(update);
439
+ }
440
+ if (payload.date !== 0n) {
441
+ this.state.dateCursor = payload.date;
442
+ }
443
+ this.scheduleStateSave();
444
+ if (payload.final)
445
+ return;
446
+ if (deliveredSeq <= cursor) {
447
+ this.log.warn?.("GET_UPDATES made no progress; aborting catch-up", { chatId: chatId.toString(), cursor, deliveredSeq });
448
+ return;
449
+ }
450
+ cursor = deliveredSeq;
451
+ }
452
+ }
453
+ peerToInputPeer(peer, chatId) {
454
+ if (!peer) {
455
+ return InputPeer.create({ type: { oneofKind: "chat", chat: { chatId } } });
456
+ }
457
+ switch (peer.type.oneofKind) {
458
+ case "chat":
459
+ return InputPeer.create({ type: { oneofKind: "chat", chat: { chatId: peer.type.chat.chatId } } });
460
+ case "user":
461
+ return InputPeer.create({ type: { oneofKind: "user", user: { userId: peer.type.user.userId } } });
462
+ default:
463
+ return InputPeer.create({ type: { oneofKind: "chat", chat: { chatId } } });
464
+ }
465
+ }
466
+ inputPeerFromTarget(params, methodName) {
467
+ const hasChatId = params.chatId != null;
468
+ const hasUserId = params.userId != null;
469
+ if (hasChatId === hasUserId) {
470
+ throw new Error(`${methodName}: provide exactly one of \`chatId\` or \`userId\``);
471
+ }
472
+ if (hasUserId) {
473
+ return InputPeer.create({
474
+ type: { oneofKind: "user", user: { userId: asInlineId(params.userId, "userId") } },
475
+ });
476
+ }
477
+ return InputPeer.create({
478
+ type: { oneofKind: "chat", chat: { chatId: asInlineId(params.chatId, "chatId") } },
479
+ });
480
+ }
481
+ async loadState() {
482
+ const store = this.options.state;
483
+ if (!store)
484
+ return;
485
+ const loaded = await store.load();
486
+ if (!loaded)
487
+ return;
488
+ if (loaded.version !== 1)
489
+ return;
490
+ this.state = loaded;
491
+ }
492
+ scheduleStateSave() {
493
+ const store = this.options.state;
494
+ if (!store)
495
+ return;
496
+ if (!this.started)
497
+ return;
498
+ if (this.saveTimer)
499
+ return;
500
+ this.saveTimer = setTimeout(() => {
501
+ this.saveTimer = null;
502
+ void this.flushStateSave();
503
+ }, 250);
504
+ }
505
+ async flushStateSave() {
506
+ const store = this.options.state;
507
+ if (!store)
508
+ return;
509
+ if (this.saveTimer) {
510
+ clearTimeout(this.saveTimer);
511
+ this.saveTimer = null;
512
+ }
513
+ if (this.saveInFlight) {
514
+ await this.saveInFlight;
515
+ return;
516
+ }
517
+ const snapshot = {
518
+ version: 1,
519
+ ...(this.state.dateCursor != null ? { dateCursor: this.state.dateCursor } : {}),
520
+ ...(this.state.lastSeqByChatId != null ? { lastSeqByChatId: { ...this.state.lastSeqByChatId } } : {}),
521
+ };
522
+ this.saveInFlight = store
523
+ .save(snapshot)
524
+ .catch((error) => {
525
+ this.log.warn?.("Failed to persist SDK state", error);
526
+ })
527
+ .finally(() => {
528
+ this.saveInFlight = null;
529
+ });
530
+ await this.saveInFlight;
531
+ }
532
+ }
533
+ const resolveRealtimeUrl = (baseUrl) => {
534
+ const url = new URL(baseUrl);
535
+ const isSecure = url.protocol === "https:";
536
+ url.protocol = isSecure ? "wss:" : "ws:";
537
+ url.pathname = url.pathname.replace(/\/+$/, "") + "/realtime";
538
+ return url.toString();
539
+ };
540
+ const hasMethodMapping = (method) => Object.prototype.hasOwnProperty.call(rpcInputKindByMethod, method) &&
541
+ Object.prototype.hasOwnProperty.call(rpcResultKindByMethod, method);
@@ -0,0 +1,7 @@
1
+ export type InlineSdkLogger = {
2
+ debug?: (msg: string, meta?: unknown) => void;
3
+ info?: (msg: string, meta?: unknown) => void;
4
+ warn?: (msg: string, meta?: unknown) => void;
5
+ error?: (msg: string, meta?: unknown) => void;
6
+ };
7
+ export declare const noopLogger: InlineSdkLogger;
@@ -0,0 +1 @@
1
+ export const noopLogger = {};
@@ -0,0 +1 @@
1
+ export declare const getSdkVersion: () => string;
@@ -0,0 +1,24 @@
1
+ import { readFileSync } from "node:fs";
2
+ let cached = null;
3
+ // Best-effort. Used for ConnectionInit.clientVersion.
4
+ export const getSdkVersion = () => {
5
+ if (cached)
6
+ return cached;
7
+ try {
8
+ // This file compiles to `dist/sdk/sdk-version.js`, so `../../package.json`
9
+ // resolves to the published package's root package.json.
10
+ const pkgUrl = new URL("../../package.json", import.meta.url);
11
+ const raw = readFileSync(pkgUrl, "utf8");
12
+ const parsed = JSON.parse(raw);
13
+ if (typeof parsed === "object" && parsed !== null && "version" in parsed && typeof parsed.version === "string") {
14
+ const version = parsed.version;
15
+ cached = version;
16
+ return version;
17
+ }
18
+ }
19
+ catch {
20
+ // ignore
21
+ }
22
+ cached = "unknown";
23
+ return cached;
24
+ };