@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.
- package/dist/ids.d.ts +6 -0
- package/dist/ids.js +17 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +7 -0
- package/dist/realtime/mock-transport.d.ts +17 -0
- package/dist/realtime/mock-transport.js +41 -0
- package/dist/realtime/ping-pong.d.ts +26 -0
- package/dist/realtime/ping-pong.js +105 -0
- package/dist/realtime/protocol-client.d.ts +66 -0
- package/dist/realtime/protocol-client.js +277 -0
- package/dist/realtime/transport.d.ts +16 -0
- package/dist/realtime/transport.js +9 -0
- package/dist/realtime/types.d.ts +31 -0
- package/dist/realtime/types.js +1 -0
- package/dist/realtime/ws-transport.d.ts +36 -0
- package/dist/realtime/ws-transport.js +147 -0
- package/dist/sdk/inline-sdk-client.d.ts +79 -0
- package/dist/sdk/inline-sdk-client.js +541 -0
- package/dist/sdk/logger.d.ts +7 -0
- package/dist/sdk/logger.js +1 -0
- package/dist/sdk/sdk-version.d.ts +1 -0
- package/dist/sdk/sdk-version.js +24 -0
- package/dist/sdk/types.d.ts +177 -0
- package/dist/sdk/types.js +76 -0
- package/dist/state/json-file-state-store.d.ts +7 -0
- package/dist/state/json-file-state-store.js +25 -0
- package/dist/state/serde.d.ts +3 -0
- package/dist/state/serde.js +37 -0
- package/dist/time.d.ts +6 -0
- package/dist/time.js +17 -0
- package/dist/utils/async-channel.d.ts +8 -0
- package/dist/utils/async-channel.js +41 -0
- package/package.json +36 -0
package/dist/ids.d.ts
ADDED
package/dist/ids.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export class InlineIdError extends Error {
|
|
2
|
+
constructor(message) {
|
|
3
|
+
super(message);
|
|
4
|
+
this.name = "InlineIdError";
|
|
5
|
+
}
|
|
6
|
+
}
|
|
7
|
+
export const asInlineId = (value, fieldName = "id") => {
|
|
8
|
+
if (typeof value === "bigint")
|
|
9
|
+
return value;
|
|
10
|
+
if (typeof value !== "number") {
|
|
11
|
+
throw new InlineIdError(`invalid ${fieldName}: expected number|bigint`);
|
|
12
|
+
}
|
|
13
|
+
if (!Number.isSafeInteger(value)) {
|
|
14
|
+
throw new InlineIdError(`invalid ${fieldName}: number must be a safe integer`);
|
|
15
|
+
}
|
|
16
|
+
return BigInt(value);
|
|
17
|
+
};
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export { InlineSdkClient } from "./sdk/inline-sdk-client.js";
|
|
2
|
+
export type { InlineSdkClientOptions, InlineSdkState, InlineSdkStateStore, InlineInboundEvent, RpcInputForMethod, RpcResultForMethod, } from "./sdk/types.js";
|
|
3
|
+
export type { InlineSdkLogger } from "./sdk/logger.js";
|
|
4
|
+
export { JsonFileStateStore } from "./state/json-file-state-store.js";
|
|
5
|
+
export { serializeStateV1, deserializeStateV1 } from "./state/serde.js";
|
|
6
|
+
export type { InlineId, InlineIdLike } from "./ids.js";
|
|
7
|
+
export { asInlineId } from "./ids.js";
|
|
8
|
+
export type { InlineUnixSeconds, InlineUnixSecondsLike } from "./time.js";
|
|
9
|
+
export { asInlineUnixSeconds } from "./time.js";
|
|
10
|
+
export * from "@inline-chat/protocol";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { InlineSdkClient } from "./sdk/inline-sdk-client.js";
|
|
2
|
+
export { JsonFileStateStore } from "./state/json-file-state-store.js";
|
|
3
|
+
export { serializeStateV1, deserializeStateV1 } from "./state/serde.js";
|
|
4
|
+
export { asInlineId } from "./ids.js";
|
|
5
|
+
export { asInlineUnixSeconds } from "./time.js";
|
|
6
|
+
// Re-export the full protocol surface for convenience.
|
|
7
|
+
export * from "@inline-chat/protocol";
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { ClientMessage, ServerProtocolMessage } from "@inline-chat/protocol/core";
|
|
2
|
+
import { AsyncChannel } from "../utils/async-channel.js";
|
|
3
|
+
import type { TransportEvent } from "./types.js";
|
|
4
|
+
import { type Transport } from "./transport.js";
|
|
5
|
+
export type MockTransportState = "idle" | "connecting" | "connected";
|
|
6
|
+
export declare class MockTransport implements Transport {
|
|
7
|
+
readonly events: AsyncChannel<TransportEvent>;
|
|
8
|
+
readonly sent: ClientMessage[];
|
|
9
|
+
state: MockTransportState;
|
|
10
|
+
start(): Promise<void>;
|
|
11
|
+
stop(): Promise<void>;
|
|
12
|
+
send(message: ClientMessage): Promise<void>;
|
|
13
|
+
stopConnection(): Promise<void>;
|
|
14
|
+
reconnect(): Promise<void>;
|
|
15
|
+
connect(): Promise<void>;
|
|
16
|
+
emitMessage(message: ServerProtocolMessage): Promise<void>;
|
|
17
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { AsyncChannel } from "../utils/async-channel.js";
|
|
2
|
+
import { TransportError } from "./transport.js";
|
|
3
|
+
export class MockTransport {
|
|
4
|
+
events = new AsyncChannel();
|
|
5
|
+
sent = [];
|
|
6
|
+
state = "idle";
|
|
7
|
+
async start() {
|
|
8
|
+
if (this.state !== "idle")
|
|
9
|
+
return;
|
|
10
|
+
this.state = "connecting";
|
|
11
|
+
await this.events.send({ type: "connecting" });
|
|
12
|
+
}
|
|
13
|
+
async stop() {
|
|
14
|
+
if (this.state === "idle")
|
|
15
|
+
return;
|
|
16
|
+
this.state = "idle";
|
|
17
|
+
await this.events.send({ type: "stopping" });
|
|
18
|
+
}
|
|
19
|
+
async send(message) {
|
|
20
|
+
if (this.state !== "connected") {
|
|
21
|
+
throw TransportError.notConnected();
|
|
22
|
+
}
|
|
23
|
+
this.sent.push(message);
|
|
24
|
+
}
|
|
25
|
+
async stopConnection() {
|
|
26
|
+
if (this.state === "idle")
|
|
27
|
+
return;
|
|
28
|
+
this.state = "connecting";
|
|
29
|
+
}
|
|
30
|
+
async reconnect() {
|
|
31
|
+
this.state = "connecting";
|
|
32
|
+
await this.events.send({ type: "connecting" });
|
|
33
|
+
}
|
|
34
|
+
async connect() {
|
|
35
|
+
this.state = "connected";
|
|
36
|
+
await this.events.send({ type: "connected" });
|
|
37
|
+
}
|
|
38
|
+
async emitMessage(message) {
|
|
39
|
+
await this.events.send({ type: "message", message });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { InlineSdkLogger } from "../sdk/logger.js";
|
|
2
|
+
import type { ProtocolClient } from "./protocol-client.js";
|
|
3
|
+
export declare class PingPongService {
|
|
4
|
+
private readonly log;
|
|
5
|
+
private readonly crypto;
|
|
6
|
+
private client;
|
|
7
|
+
private running;
|
|
8
|
+
private sleepTimer;
|
|
9
|
+
private sleepResolver;
|
|
10
|
+
private pings;
|
|
11
|
+
constructor(options?: {
|
|
12
|
+
logger?: InlineSdkLogger;
|
|
13
|
+
crypto?: Crypto | null;
|
|
14
|
+
});
|
|
15
|
+
configure(client: ProtocolClient): void;
|
|
16
|
+
start(): void;
|
|
17
|
+
stop(): void;
|
|
18
|
+
ping(): Promise<void>;
|
|
19
|
+
pong(nonce: bigint): void;
|
|
20
|
+
private loop;
|
|
21
|
+
private reset;
|
|
22
|
+
private checkConnection;
|
|
23
|
+
private sleep;
|
|
24
|
+
private clearSleep;
|
|
25
|
+
private randomNonce;
|
|
26
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
export class PingPongService {
|
|
2
|
+
log;
|
|
3
|
+
crypto;
|
|
4
|
+
client = null;
|
|
5
|
+
running = false;
|
|
6
|
+
sleepTimer = null;
|
|
7
|
+
sleepResolver = null;
|
|
8
|
+
pings = new Map();
|
|
9
|
+
constructor(options) {
|
|
10
|
+
this.log = options?.logger ?? {};
|
|
11
|
+
const cryptoAny = globalThis.crypto;
|
|
12
|
+
this.crypto = options?.crypto === undefined ? (isCrypto(cryptoAny) ? cryptoAny : null) : options.crypto;
|
|
13
|
+
}
|
|
14
|
+
configure(client) {
|
|
15
|
+
this.client = client;
|
|
16
|
+
}
|
|
17
|
+
start() {
|
|
18
|
+
if (this.running)
|
|
19
|
+
return;
|
|
20
|
+
this.running = true;
|
|
21
|
+
this.reset();
|
|
22
|
+
void this.loop();
|
|
23
|
+
}
|
|
24
|
+
stop() {
|
|
25
|
+
this.running = false;
|
|
26
|
+
this.clearSleep();
|
|
27
|
+
this.reset();
|
|
28
|
+
}
|
|
29
|
+
async ping() {
|
|
30
|
+
const client = this.client;
|
|
31
|
+
if (!client)
|
|
32
|
+
return;
|
|
33
|
+
if (client.state !== "open")
|
|
34
|
+
return;
|
|
35
|
+
const nonce = this.randomNonce();
|
|
36
|
+
await client.sendPing(nonce);
|
|
37
|
+
this.pings.set(nonce, Date.now());
|
|
38
|
+
}
|
|
39
|
+
pong(nonce) {
|
|
40
|
+
const pingDate = this.pings.get(nonce);
|
|
41
|
+
if (!pingDate)
|
|
42
|
+
return;
|
|
43
|
+
this.pings.delete(nonce);
|
|
44
|
+
}
|
|
45
|
+
async loop() {
|
|
46
|
+
while (this.running) {
|
|
47
|
+
await this.checkConnection();
|
|
48
|
+
await this.sleep(10_000);
|
|
49
|
+
if (!this.running)
|
|
50
|
+
break;
|
|
51
|
+
await this.ping();
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
reset() {
|
|
55
|
+
this.pings.clear();
|
|
56
|
+
}
|
|
57
|
+
async checkConnection() {
|
|
58
|
+
const client = this.client;
|
|
59
|
+
if (!client)
|
|
60
|
+
return;
|
|
61
|
+
if (client.state !== "open")
|
|
62
|
+
return;
|
|
63
|
+
const now = Date.now();
|
|
64
|
+
const hasTimedOutPing = [...this.pings.values()].some((timestamp) => now - timestamp > 30_000);
|
|
65
|
+
if (!hasTimedOutPing)
|
|
66
|
+
return;
|
|
67
|
+
this.log.warn?.("Ping timeout, reconnecting");
|
|
68
|
+
await client.reconnect();
|
|
69
|
+
}
|
|
70
|
+
async sleep(ms) {
|
|
71
|
+
if (ms <= 0)
|
|
72
|
+
return;
|
|
73
|
+
await new Promise((resolve) => {
|
|
74
|
+
this.sleepResolver = resolve;
|
|
75
|
+
this.sleepTimer = setTimeout(() => {
|
|
76
|
+
this.sleepTimer = null;
|
|
77
|
+
this.sleepResolver = null;
|
|
78
|
+
resolve();
|
|
79
|
+
}, ms);
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
clearSleep() {
|
|
83
|
+
if (this.sleepTimer) {
|
|
84
|
+
clearTimeout(this.sleepTimer);
|
|
85
|
+
this.sleepTimer = null;
|
|
86
|
+
}
|
|
87
|
+
if (this.sleepResolver) {
|
|
88
|
+
this.sleepResolver();
|
|
89
|
+
this.sleepResolver = null;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
randomNonce() {
|
|
93
|
+
if (this.crypto) {
|
|
94
|
+
const buf = new Uint32Array(2);
|
|
95
|
+
this.crypto.getRandomValues(buf);
|
|
96
|
+
// Under `noUncheckedIndexedAccess`, `buf[0]` becomes `number | undefined`.
|
|
97
|
+
// This array is fixed-size (2), so the cast is safe.
|
|
98
|
+
const hi = buf[0];
|
|
99
|
+
const lo = buf[1];
|
|
100
|
+
return (BigInt(hi) << 32n) | BigInt(lo);
|
|
101
|
+
}
|
|
102
|
+
return BigInt(Math.floor(Math.random() * Number.MAX_SAFE_INTEGER));
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
const isCrypto = (value) => typeof value === "object" && value !== null && "getRandomValues" in value && typeof value.getRandomValues === "function";
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { type ConnectionInit } from "@inline-chat/protocol/core";
|
|
2
|
+
import type { Method, RpcCall, RpcResult } from "@inline-chat/protocol/core";
|
|
3
|
+
import { AsyncChannel } from "../utils/async-channel.js";
|
|
4
|
+
import { PingPongService } from "./ping-pong.js";
|
|
5
|
+
import type { Transport } from "./transport.js";
|
|
6
|
+
import type { ClientEvent, ClientState } from "./types.js";
|
|
7
|
+
import type { InlineSdkLogger } from "../sdk/logger.js";
|
|
8
|
+
export type ProtocolClientOptions = {
|
|
9
|
+
transport: Transport;
|
|
10
|
+
getConnectionInit: () => ConnectionInit | null;
|
|
11
|
+
logger?: InlineSdkLogger;
|
|
12
|
+
};
|
|
13
|
+
export declare class ProtocolClient {
|
|
14
|
+
readonly events: AsyncChannel<ClientEvent>;
|
|
15
|
+
readonly transport: Transport;
|
|
16
|
+
readonly pingPong: PingPongService;
|
|
17
|
+
state: ClientState;
|
|
18
|
+
private readonly log;
|
|
19
|
+
private readonly getConnectionInit;
|
|
20
|
+
private rpcContinuations;
|
|
21
|
+
private seq;
|
|
22
|
+
private lastTimestamp;
|
|
23
|
+
private sequence;
|
|
24
|
+
private readonly epochSeconds;
|
|
25
|
+
private connectionAttemptNo;
|
|
26
|
+
private reconnectionTimer;
|
|
27
|
+
private authenticationTimeout;
|
|
28
|
+
private listenersStarted;
|
|
29
|
+
constructor(options: ProtocolClientOptions);
|
|
30
|
+
startTransport(): Promise<void>;
|
|
31
|
+
stopTransport(): Promise<void>;
|
|
32
|
+
sendPing(nonce: bigint): Promise<void>;
|
|
33
|
+
reconnect(options?: {
|
|
34
|
+
skipDelay?: boolean;
|
|
35
|
+
}): Promise<void>;
|
|
36
|
+
sendRpc(method: Method, input?: RpcCall["input"]): Promise<bigint>;
|
|
37
|
+
callRpc(method: Method, input?: RpcCall["input"], options?: {
|
|
38
|
+
timeoutMs?: number;
|
|
39
|
+
}): Promise<RpcResult["result"]>;
|
|
40
|
+
private startListeners;
|
|
41
|
+
private handleTransportMessage;
|
|
42
|
+
private sendConnectionInit;
|
|
43
|
+
private authenticate;
|
|
44
|
+
private connectionOpen;
|
|
45
|
+
private connecting;
|
|
46
|
+
private reset;
|
|
47
|
+
private startAuthenticationTimeout;
|
|
48
|
+
private stopAuthenticationTimeout;
|
|
49
|
+
private handleClientFailure;
|
|
50
|
+
private getReconnectionDelay;
|
|
51
|
+
private wrapMessage;
|
|
52
|
+
private advanceSeq;
|
|
53
|
+
private generateId;
|
|
54
|
+
private currentTimestamp;
|
|
55
|
+
private completeRpcResult;
|
|
56
|
+
private completeRpcError;
|
|
57
|
+
private failRpcContinuation;
|
|
58
|
+
private getAndRemoveRpcContinuation;
|
|
59
|
+
private cancelAllRpcContinuations;
|
|
60
|
+
}
|
|
61
|
+
export declare class ProtocolClientError extends Error {
|
|
62
|
+
constructor(code: "not-authorized" | "not-connected" | "rpc-error" | "stopped" | "timeout", details?: {
|
|
63
|
+
code?: number;
|
|
64
|
+
message?: string;
|
|
65
|
+
});
|
|
66
|
+
}
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import { ClientMessage, ServerProtocolMessage } from "@inline-chat/protocol/core";
|
|
2
|
+
import { AsyncChannel } from "../utils/async-channel.js";
|
|
3
|
+
import { PingPongService } from "./ping-pong.js";
|
|
4
|
+
const emptyRpcInput = { oneofKind: undefined };
|
|
5
|
+
export class ProtocolClient {
|
|
6
|
+
events = new AsyncChannel();
|
|
7
|
+
transport;
|
|
8
|
+
pingPong;
|
|
9
|
+
state = "connecting";
|
|
10
|
+
log;
|
|
11
|
+
getConnectionInit;
|
|
12
|
+
rpcContinuations = new Map();
|
|
13
|
+
seq = 0;
|
|
14
|
+
lastTimestamp = 0;
|
|
15
|
+
sequence = 0;
|
|
16
|
+
epochSeconds = 1_735_689_600;
|
|
17
|
+
connectionAttemptNo = 0;
|
|
18
|
+
reconnectionTimer = null;
|
|
19
|
+
authenticationTimeout = null;
|
|
20
|
+
listenersStarted = false;
|
|
21
|
+
constructor(options) {
|
|
22
|
+
this.transport = options.transport;
|
|
23
|
+
this.log = options.logger ?? {};
|
|
24
|
+
this.getConnectionInit = options.getConnectionInit;
|
|
25
|
+
this.pingPong = new PingPongService({ logger: this.log });
|
|
26
|
+
this.pingPong.configure(this);
|
|
27
|
+
this.startListeners();
|
|
28
|
+
}
|
|
29
|
+
async startTransport() {
|
|
30
|
+
await this.transport.start();
|
|
31
|
+
}
|
|
32
|
+
async stopTransport() {
|
|
33
|
+
await this.transport.stop();
|
|
34
|
+
}
|
|
35
|
+
async sendPing(nonce) {
|
|
36
|
+
const message = this.wrapMessage({
|
|
37
|
+
oneofKind: "ping",
|
|
38
|
+
ping: { nonce },
|
|
39
|
+
});
|
|
40
|
+
try {
|
|
41
|
+
await this.transport.send(message);
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
this.log.error?.("Failed to send ping", error);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
async reconnect(options) {
|
|
48
|
+
await this.transport.reconnect({ skipDelay: options?.skipDelay });
|
|
49
|
+
}
|
|
50
|
+
async sendRpc(method, input = emptyRpcInput) {
|
|
51
|
+
const message = this.wrapMessage({
|
|
52
|
+
oneofKind: "rpcCall",
|
|
53
|
+
rpcCall: { method, input },
|
|
54
|
+
});
|
|
55
|
+
await this.transport.send(message);
|
|
56
|
+
return message.id;
|
|
57
|
+
}
|
|
58
|
+
async callRpc(method, input = emptyRpcInput, options) {
|
|
59
|
+
const message = this.wrapMessage({
|
|
60
|
+
oneofKind: "rpcCall",
|
|
61
|
+
rpcCall: { method, input },
|
|
62
|
+
});
|
|
63
|
+
return await new Promise((resolve, reject) => {
|
|
64
|
+
const continuation = { resolve, reject };
|
|
65
|
+
this.rpcContinuations.set(message.id, continuation);
|
|
66
|
+
void this.transport.send(message).catch((error) => {
|
|
67
|
+
this.failRpcContinuation(message.id, error instanceof Error ? error : new Error("send-failed"));
|
|
68
|
+
});
|
|
69
|
+
const timeoutMs = options?.timeoutMs ?? 15_000;
|
|
70
|
+
if (timeoutMs > 0) {
|
|
71
|
+
continuation.timeout = setTimeout(() => {
|
|
72
|
+
this.failRpcContinuation(message.id, new ProtocolClientError("timeout"));
|
|
73
|
+
}, timeoutMs);
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
async startListeners() {
|
|
78
|
+
if (this.listenersStarted)
|
|
79
|
+
return;
|
|
80
|
+
this.listenersStarted = true;
|
|
81
|
+
(async () => {
|
|
82
|
+
for await (const event of this.transport.events) {
|
|
83
|
+
switch (event.type) {
|
|
84
|
+
case "connected":
|
|
85
|
+
await this.authenticate();
|
|
86
|
+
break;
|
|
87
|
+
case "message":
|
|
88
|
+
await this.handleTransportMessage(event.message);
|
|
89
|
+
break;
|
|
90
|
+
case "connecting":
|
|
91
|
+
await this.connecting();
|
|
92
|
+
break;
|
|
93
|
+
case "stopping":
|
|
94
|
+
await this.reset();
|
|
95
|
+
break;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
})().catch((error) => {
|
|
99
|
+
this.log.error?.("Protocol client listener crashed", error);
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
async handleTransportMessage(message) {
|
|
103
|
+
switch (message.body.oneofKind) {
|
|
104
|
+
case "connectionOpen":
|
|
105
|
+
await this.connectionOpen();
|
|
106
|
+
break;
|
|
107
|
+
case "rpcResult":
|
|
108
|
+
this.completeRpcResult(message.body.rpcResult.reqMsgId, message.body.rpcResult.result);
|
|
109
|
+
await this.events.send({
|
|
110
|
+
type: "rpcResult",
|
|
111
|
+
msgId: message.body.rpcResult.reqMsgId,
|
|
112
|
+
rpcResult: message.body.rpcResult.result,
|
|
113
|
+
});
|
|
114
|
+
break;
|
|
115
|
+
case "rpcError":
|
|
116
|
+
this.completeRpcError(message.body.rpcError.reqMsgId, message.body.rpcError);
|
|
117
|
+
await this.events.send({
|
|
118
|
+
type: "rpcError",
|
|
119
|
+
msgId: message.body.rpcError.reqMsgId,
|
|
120
|
+
rpcError: message.body.rpcError,
|
|
121
|
+
});
|
|
122
|
+
break;
|
|
123
|
+
case "ack":
|
|
124
|
+
await this.events.send({ type: "ack", msgId: message.body.ack.msgId });
|
|
125
|
+
break;
|
|
126
|
+
case "message":
|
|
127
|
+
if (message.body.message.payload.oneofKind === "update") {
|
|
128
|
+
await this.events.send({ type: "updates", updates: message.body.message.payload.update });
|
|
129
|
+
}
|
|
130
|
+
break;
|
|
131
|
+
case "pong":
|
|
132
|
+
this.pingPong.pong(message.body.pong.nonce);
|
|
133
|
+
break;
|
|
134
|
+
case "connectionError":
|
|
135
|
+
this.handleClientFailure();
|
|
136
|
+
break;
|
|
137
|
+
default:
|
|
138
|
+
break;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
async sendConnectionInit() {
|
|
142
|
+
const connectionInit = this.getConnectionInit();
|
|
143
|
+
if (!connectionInit)
|
|
144
|
+
throw new ProtocolClientError("not-authorized");
|
|
145
|
+
const message = this.wrapMessage({
|
|
146
|
+
oneofKind: "connectionInit",
|
|
147
|
+
connectionInit,
|
|
148
|
+
});
|
|
149
|
+
await this.transport.send(message);
|
|
150
|
+
}
|
|
151
|
+
async authenticate() {
|
|
152
|
+
try {
|
|
153
|
+
await this.sendConnectionInit();
|
|
154
|
+
this.startAuthenticationTimeout();
|
|
155
|
+
}
|
|
156
|
+
catch (error) {
|
|
157
|
+
this.log.error?.("Failed to authenticate", error);
|
|
158
|
+
this.handleClientFailure();
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
async connectionOpen() {
|
|
162
|
+
this.state = "open";
|
|
163
|
+
await this.events.send({ type: "open" });
|
|
164
|
+
this.stopAuthenticationTimeout();
|
|
165
|
+
if (this.reconnectionTimer) {
|
|
166
|
+
clearTimeout(this.reconnectionTimer);
|
|
167
|
+
this.reconnectionTimer = null;
|
|
168
|
+
}
|
|
169
|
+
this.connectionAttemptNo = 0;
|
|
170
|
+
this.pingPong.start();
|
|
171
|
+
}
|
|
172
|
+
async connecting() {
|
|
173
|
+
this.state = "connecting";
|
|
174
|
+
await this.events.send({ type: "connecting" });
|
|
175
|
+
}
|
|
176
|
+
async reset() {
|
|
177
|
+
this.pingPong.stop();
|
|
178
|
+
this.stopAuthenticationTimeout();
|
|
179
|
+
this.cancelAllRpcContinuations(new ProtocolClientError("stopped"));
|
|
180
|
+
this.state = "connecting";
|
|
181
|
+
}
|
|
182
|
+
startAuthenticationTimeout() {
|
|
183
|
+
this.stopAuthenticationTimeout();
|
|
184
|
+
this.authenticationTimeout = setTimeout(() => {
|
|
185
|
+
if (this.state === "open")
|
|
186
|
+
return;
|
|
187
|
+
this.handleClientFailure();
|
|
188
|
+
}, 10_000);
|
|
189
|
+
}
|
|
190
|
+
stopAuthenticationTimeout() {
|
|
191
|
+
if (!this.authenticationTimeout)
|
|
192
|
+
return;
|
|
193
|
+
clearTimeout(this.authenticationTimeout);
|
|
194
|
+
this.authenticationTimeout = null;
|
|
195
|
+
}
|
|
196
|
+
handleClientFailure() {
|
|
197
|
+
this.pingPong.stop();
|
|
198
|
+
this.cancelAllRpcContinuations(new ProtocolClientError("not-connected"));
|
|
199
|
+
this.stopAuthenticationTimeout();
|
|
200
|
+
if (this.reconnectionTimer) {
|
|
201
|
+
clearTimeout(this.reconnectionTimer);
|
|
202
|
+
}
|
|
203
|
+
this.connectionAttemptNo = (this.connectionAttemptNo + 1) >>> 0;
|
|
204
|
+
this.reconnectionTimer = setTimeout(() => {
|
|
205
|
+
if (this.state === "open")
|
|
206
|
+
return;
|
|
207
|
+
void this.reconnect({ skipDelay: true });
|
|
208
|
+
}, this.getReconnectionDelay() * 1000);
|
|
209
|
+
}
|
|
210
|
+
getReconnectionDelay() {
|
|
211
|
+
const attemptNo = this.connectionAttemptNo;
|
|
212
|
+
if (attemptNo >= 8)
|
|
213
|
+
return 8.0 + Math.random() * 5.0;
|
|
214
|
+
return Math.min(8.0, 0.2 + Math.pow(attemptNo, 1.5) * 0.4);
|
|
215
|
+
}
|
|
216
|
+
wrapMessage(body) {
|
|
217
|
+
this.advanceSeq();
|
|
218
|
+
return ClientMessage.create({
|
|
219
|
+
id: this.generateId(),
|
|
220
|
+
seq: this.seq,
|
|
221
|
+
body,
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
advanceSeq() {
|
|
225
|
+
this.seq = (this.seq + 1) >>> 0;
|
|
226
|
+
}
|
|
227
|
+
generateId() {
|
|
228
|
+
const timestamp = this.currentTimestamp();
|
|
229
|
+
if (timestamp === this.lastTimestamp) {
|
|
230
|
+
this.sequence = (this.sequence + 1) >>> 0;
|
|
231
|
+
}
|
|
232
|
+
else {
|
|
233
|
+
this.sequence = 0;
|
|
234
|
+
this.lastTimestamp = timestamp;
|
|
235
|
+
}
|
|
236
|
+
return (BigInt(timestamp) << 32n) | BigInt(this.sequence);
|
|
237
|
+
}
|
|
238
|
+
currentTimestamp() {
|
|
239
|
+
return Math.floor(Date.now() / 1000) - this.epochSeconds;
|
|
240
|
+
}
|
|
241
|
+
completeRpcResult(msgId, rpcResult) {
|
|
242
|
+
const continuation = this.getAndRemoveRpcContinuation(msgId);
|
|
243
|
+
continuation?.resolve(rpcResult);
|
|
244
|
+
}
|
|
245
|
+
completeRpcError(msgId, rpcError) {
|
|
246
|
+
const error = new ProtocolClientError("rpc-error", { code: rpcError.code, message: rpcError.message });
|
|
247
|
+
const continuation = this.getAndRemoveRpcContinuation(msgId);
|
|
248
|
+
continuation?.reject(error);
|
|
249
|
+
}
|
|
250
|
+
failRpcContinuation(msgId, error) {
|
|
251
|
+
const continuation = this.getAndRemoveRpcContinuation(msgId);
|
|
252
|
+
continuation?.reject(error);
|
|
253
|
+
}
|
|
254
|
+
getAndRemoveRpcContinuation(msgId) {
|
|
255
|
+
const continuation = this.rpcContinuations.get(msgId);
|
|
256
|
+
if (!continuation)
|
|
257
|
+
return null;
|
|
258
|
+
if (continuation.timeout)
|
|
259
|
+
clearTimeout(continuation.timeout);
|
|
260
|
+
this.rpcContinuations.delete(msgId);
|
|
261
|
+
return continuation;
|
|
262
|
+
}
|
|
263
|
+
cancelAllRpcContinuations(error) {
|
|
264
|
+
for (const continuation of this.rpcContinuations.values()) {
|
|
265
|
+
continuation.reject(error);
|
|
266
|
+
if (continuation.timeout)
|
|
267
|
+
clearTimeout(continuation.timeout);
|
|
268
|
+
}
|
|
269
|
+
this.rpcContinuations.clear();
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
export class ProtocolClientError extends Error {
|
|
273
|
+
constructor(code, details) {
|
|
274
|
+
super(details?.message ?? code);
|
|
275
|
+
this.name = `ProtocolClientError:${code}`;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { ClientMessage } from "@inline-chat/protocol/core";
|
|
2
|
+
import type { TransportEvent } from "./types.js";
|
|
3
|
+
export declare class TransportError extends Error {
|
|
4
|
+
constructor(message: string);
|
|
5
|
+
static notConnected(): TransportError;
|
|
6
|
+
}
|
|
7
|
+
export type Transport = {
|
|
8
|
+
events: AsyncIterable<TransportEvent>;
|
|
9
|
+
start: () => Promise<void>;
|
|
10
|
+
stop: () => Promise<void>;
|
|
11
|
+
send: (message: ClientMessage) => Promise<void>;
|
|
12
|
+
stopConnection: () => Promise<void>;
|
|
13
|
+
reconnect: (options?: {
|
|
14
|
+
skipDelay?: boolean;
|
|
15
|
+
}) => Promise<void>;
|
|
16
|
+
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { RpcError, RpcResult, ServerProtocolMessage, UpdatesPayload } from "@inline-chat/protocol/core";
|
|
2
|
+
export type ClientState = "connecting" | "open";
|
|
3
|
+
export type TransportEvent = {
|
|
4
|
+
type: "connecting";
|
|
5
|
+
} | {
|
|
6
|
+
type: "connected";
|
|
7
|
+
} | {
|
|
8
|
+
type: "stopping";
|
|
9
|
+
} | {
|
|
10
|
+
type: "message";
|
|
11
|
+
message: ServerProtocolMessage;
|
|
12
|
+
};
|
|
13
|
+
export type ClientEvent = {
|
|
14
|
+
type: "connecting";
|
|
15
|
+
} | {
|
|
16
|
+
type: "open";
|
|
17
|
+
} | {
|
|
18
|
+
type: "ack";
|
|
19
|
+
msgId: bigint;
|
|
20
|
+
} | {
|
|
21
|
+
type: "rpcResult";
|
|
22
|
+
msgId: bigint;
|
|
23
|
+
rpcResult: RpcResult["result"];
|
|
24
|
+
} | {
|
|
25
|
+
type: "rpcError";
|
|
26
|
+
msgId: bigint;
|
|
27
|
+
rpcError: RpcError;
|
|
28
|
+
} | {
|
|
29
|
+
type: "updates";
|
|
30
|
+
updates: UpdatesPayload;
|
|
31
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { ClientMessage } from "@inline-chat/protocol/core";
|
|
2
|
+
import { AsyncChannel } from "../utils/async-channel.js";
|
|
3
|
+
import { type Transport } from "./transport.js";
|
|
4
|
+
import type { TransportEvent } from "./types.js";
|
|
5
|
+
import type { InlineSdkLogger } from "../sdk/logger.js";
|
|
6
|
+
export type WebSocketTransportOptions = {
|
|
7
|
+
url: string;
|
|
8
|
+
logger?: InlineSdkLogger;
|
|
9
|
+
};
|
|
10
|
+
export declare class WebSocketTransport implements Transport {
|
|
11
|
+
readonly events: AsyncChannel<TransportEvent>;
|
|
12
|
+
private readonly url;
|
|
13
|
+
private readonly log;
|
|
14
|
+
private state;
|
|
15
|
+
private connectionAttemptNo;
|
|
16
|
+
private socket;
|
|
17
|
+
private reconnectionTimer;
|
|
18
|
+
constructor(options: WebSocketTransportOptions);
|
|
19
|
+
start(): Promise<void>;
|
|
20
|
+
stop(): Promise<void>;
|
|
21
|
+
send(message: ClientMessage): Promise<void>;
|
|
22
|
+
stopConnection(): Promise<void>;
|
|
23
|
+
reconnect(options?: {
|
|
24
|
+
skipDelay?: boolean;
|
|
25
|
+
}): Promise<void>;
|
|
26
|
+
private cleanUpPreviousConnection;
|
|
27
|
+
private getReconnectionDelaySeconds;
|
|
28
|
+
private openConnection;
|
|
29
|
+
private connectionDidOpen;
|
|
30
|
+
private handleMessage;
|
|
31
|
+
private coerceBinary;
|
|
32
|
+
private handleClose;
|
|
33
|
+
private handleError;
|
|
34
|
+
private setIdle;
|
|
35
|
+
private setConnecting;
|
|
36
|
+
}
|