@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,147 @@
1
+ import { WebSocket } from "ws";
2
+ import { ClientMessage, ServerProtocolMessage } from "@inline-chat/protocol/core";
3
+ import { AsyncChannel } from "../utils/async-channel.js";
4
+ import { TransportError } from "./transport.js";
5
+ export class WebSocketTransport {
6
+ events = new AsyncChannel();
7
+ url;
8
+ log;
9
+ state = "idle";
10
+ connectionAttemptNo = 0;
11
+ socket = null;
12
+ reconnectionTimer = null;
13
+ constructor(options) {
14
+ this.url = options.url;
15
+ this.log = options.logger ?? {};
16
+ }
17
+ async start() {
18
+ if (this.state !== "idle")
19
+ return;
20
+ await this.setConnecting();
21
+ await this.openConnection();
22
+ }
23
+ async stop() {
24
+ if (this.state === "idle")
25
+ return;
26
+ await this.setIdle();
27
+ this.cleanUpPreviousConnection();
28
+ }
29
+ async send(message) {
30
+ if (this.state !== "connected" || !this.socket || this.socket.readyState !== WebSocket.OPEN) {
31
+ throw TransportError.notConnected();
32
+ }
33
+ this.socket.send(ClientMessage.toBinary(message));
34
+ }
35
+ async stopConnection() {
36
+ this.cleanUpPreviousConnection();
37
+ }
38
+ async reconnect(options) {
39
+ const skipDelay = options?.skipDelay ?? false;
40
+ await this.setConnecting();
41
+ this.cleanUpPreviousConnection();
42
+ this.connectionAttemptNo = (this.connectionAttemptNo + 1) >>> 0;
43
+ const delaySeconds = this.getReconnectionDelaySeconds(this.connectionAttemptNo);
44
+ this.log.debug?.("reconnect scheduled", { attempt: this.connectionAttemptNo, delaySeconds });
45
+ this.reconnectionTimer = setTimeout(() => {
46
+ this.reconnectionTimer = null;
47
+ if (this.state === "idle" || this.state === "connected")
48
+ return;
49
+ void this.openConnection();
50
+ }, skipDelay ? 0 : delaySeconds * 1000);
51
+ }
52
+ cleanUpPreviousConnection() {
53
+ if (this.reconnectionTimer) {
54
+ clearTimeout(this.reconnectionTimer);
55
+ this.reconnectionTimer = null;
56
+ }
57
+ if (this.socket) {
58
+ this.socket.removeAllListeners();
59
+ this.socket.close();
60
+ this.socket = null;
61
+ }
62
+ }
63
+ getReconnectionDelaySeconds(attemptNo) {
64
+ // Exponential-ish backoff with jitter.
65
+ // Cap quickly so callers don't stall for too long.
66
+ const base = Math.min(8.0, 0.2 + Math.pow(attemptNo, 1.5) * 0.4);
67
+ const jitter = attemptNo >= 8 ? Math.random() * 4.0 : 0;
68
+ return base + jitter;
69
+ }
70
+ async openConnection() {
71
+ if (this.state === "idle")
72
+ return;
73
+ const socket = new WebSocket(this.url);
74
+ this.socket = socket;
75
+ socket.on("open", () => {
76
+ void this.connectionDidOpen(socket);
77
+ });
78
+ socket.on("message", (data) => {
79
+ void this.handleMessage(socket, data);
80
+ });
81
+ socket.on("close", (code, reason) => {
82
+ void this.handleClose(socket, code, reason);
83
+ });
84
+ socket.on("error", (error) => {
85
+ void this.handleError(socket, error);
86
+ });
87
+ }
88
+ async connectionDidOpen(socket) {
89
+ if (this.socket !== socket)
90
+ return;
91
+ if (this.state !== "connecting")
92
+ return;
93
+ this.connectionAttemptNo = 0;
94
+ this.state = "connected";
95
+ await this.events.send({ type: "connected" });
96
+ }
97
+ async handleMessage(socket, data) {
98
+ if (this.socket !== socket)
99
+ return;
100
+ try {
101
+ const payload = this.coerceBinary(data);
102
+ const message = ServerProtocolMessage.fromBinary(payload);
103
+ await this.events.send({ type: "message", message });
104
+ }
105
+ catch (error) {
106
+ this.log.error?.("Failed to decode message", error);
107
+ }
108
+ }
109
+ coerceBinary(data) {
110
+ if (data instanceof ArrayBuffer)
111
+ return new Uint8Array(data);
112
+ // Covers Buffer and other typed-array views.
113
+ if (ArrayBuffer.isView(data))
114
+ return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
115
+ if (Array.isArray(data))
116
+ return new Uint8Array(Buffer.concat(data));
117
+ throw new Error("Unsupported WebSocket message payload");
118
+ }
119
+ async handleClose(socket, code, reason) {
120
+ if (this.socket !== socket)
121
+ return;
122
+ if (this.state === "idle")
123
+ return;
124
+ this.log.warn?.("WebSocket closed", { code, reason: reason.toString("utf8") });
125
+ await this.reconnect();
126
+ }
127
+ async handleError(socket, error) {
128
+ if (this.socket !== socket)
129
+ return;
130
+ if (this.state === "idle")
131
+ return;
132
+ this.log.error?.("WebSocket error", error);
133
+ await this.reconnect();
134
+ }
135
+ async setIdle() {
136
+ if (this.state === "idle")
137
+ return;
138
+ this.state = "idle";
139
+ await this.events.send({ type: "stopping" });
140
+ }
141
+ async setConnecting() {
142
+ if (this.state === "connecting")
143
+ return;
144
+ this.state = "connecting";
145
+ await this.events.send({ type: "connecting" });
146
+ }
147
+ }
@@ -0,0 +1,79 @@
1
+ import { MessageEntities, Method, type Peer, type RpcCall, type RpcResult } from "@inline-chat/protocol/core";
2
+ import { type InlineIdLike } from "../ids.js";
3
+ import type { InlineSdkClientOptions, InlineInboundEvent, InlineSdkState, MappedMethod, RpcInputForMethod, RpcResultForMethod } from "./types.js";
4
+ type SendMessageTarget = {
5
+ chatId: InlineIdLike;
6
+ userId?: never;
7
+ } | {
8
+ userId: InlineIdLike;
9
+ chatId?: never;
10
+ };
11
+ export declare class InlineSdkClient {
12
+ private readonly options;
13
+ private readonly log;
14
+ private readonly transport;
15
+ private readonly protocol;
16
+ private readonly eventStream;
17
+ private started;
18
+ private openPromise;
19
+ private openResolver;
20
+ private openRejecter;
21
+ private state;
22
+ private saveTimer;
23
+ private saveInFlight;
24
+ private catchUpInFlightByChatId;
25
+ constructor(options: InlineSdkClientOptions);
26
+ connect(signal?: AbortSignal): Promise<void>;
27
+ close(): Promise<void>;
28
+ private rejectOpen;
29
+ events(): AsyncIterable<InlineInboundEvent>;
30
+ exportState(): InlineSdkState;
31
+ getMe(): Promise<{
32
+ userId: bigint;
33
+ }>;
34
+ getChat(params: {
35
+ chatId: InlineIdLike;
36
+ }): Promise<{
37
+ chatId: bigint;
38
+ peer?: Peer;
39
+ title: string;
40
+ }>;
41
+ sendMessage(params: SendMessageTarget & {
42
+ text: string;
43
+ replyToMsgId?: InlineIdLike;
44
+ parseMarkdown?: boolean;
45
+ sendMode?: "silent";
46
+ entities?: MessageEntities;
47
+ }): Promise<{
48
+ messageId: bigint | null;
49
+ }>;
50
+ sendTyping(params: {
51
+ chatId: InlineIdLike;
52
+ typing: boolean;
53
+ }): Promise<void>;
54
+ invokeRaw(method: Method, input?: RpcCall["input"], options?: {
55
+ timeoutMs?: number;
56
+ }): Promise<RpcResult["result"]>;
57
+ invokeUncheckedRaw(method: Method, input?: RpcCall["input"], options?: {
58
+ timeoutMs?: number;
59
+ }): Promise<RpcResult["result"]>;
60
+ invoke<M extends MappedMethod>(method: M, input: RpcInputForMethod<M>, options?: {
61
+ timeoutMs?: number;
62
+ }): Promise<RpcResultForMethod<M>>;
63
+ private assertMethodInputMatch;
64
+ private assertMethodResultMatch;
65
+ private startListeners;
66
+ private onOpen;
67
+ private initializeDateCursor;
68
+ private onUpdates;
69
+ private handleUpdate;
70
+ private bumpChatSeq;
71
+ private catchUpChat;
72
+ private doCatchUpChat;
73
+ private peerToInputPeer;
74
+ private inputPeerFromTarget;
75
+ private loadState;
76
+ private scheduleStateSave;
77
+ private flushStateSave;
78
+ }
79
+ export {};