@triplit/client
Version:
214 lines (192 loc) • 6.56 kB
text/typescript
import { ClientSyncMessage, CloseReason } from '../@triplit/types/sync.js';
import { WebSocketsUnavailableError } from '../errors.js';
import {
ConnectionStatus,
SyncTransport,
TransportConnectParams,
} from '../types.js';
const DEFAULT_PAYLOAD_SIZE_LIMIT = (1024 * 1024) / 2;
interface WebSocketTransportOptions {
messagePayloadSizeLimit?: number;
}
function webSocketsAreAvailable(): boolean {
return typeof WebSocket !== 'undefined';
}
export class WebSocketTransport implements SyncTransport {
ws: WebSocket | undefined = undefined;
constructor(private options: WebSocketTransportOptions = {}) {
this.options.messagePayloadSizeLimit =
// allow 0 to disable the limit
this.options.messagePayloadSizeLimit == undefined
? DEFAULT_PAYLOAD_SIZE_LIMIT
: this.options.messagePayloadSizeLimit;
}
get isOpen(): boolean {
return !!this.ws && this.ws.readyState === this.ws.OPEN;
}
get connectionStatus(): ConnectionStatus {
return this.ws ? friendlyReadyState(this.ws) : 'UNINITIALIZED';
}
onOpen(callback: (ev: any) => void): void {
if (this.ws) this.ws.onopen = callback;
}
sendMessage(message: ClientSyncMessage): boolean {
// For now, skip sending messages if we're not connected. I dont think we need a queue yet.
if (!this.ws) return false;
if (!this.isOpen) {
// console.log('skipping', type, payload);
return false;
}
// Perform chunking if the message is too large
const serializedMessage = JSON.stringify(message);
const bytes = getPayloadSize(serializedMessage);
if (
this.options.messagePayloadSizeLimit &&
bytes > this.options.messagePayloadSizeLimit
) {
const chunks = chunkMessage(
serializedMessage,
Math.ceil(bytes / this.options.messagePayloadSizeLimit)
);
const messageid = (Math.random() + 1).toString(36).substring(7);
for (let i = 0; i < chunks.length; i++) {
this.ws.send(
JSON.stringify({
type: 'CHUNK',
payload: {
data: chunks[i],
total: chunks.length,
index: i,
id: messageid,
},
})
);
}
return true;
}
this.ws.send(JSON.stringify(message));
return true;
}
connect(params: TransportConnectParams): void {
// Close any existing connection
this.close();
// Setup connection URL
const { token, schema, syncSchema, server } = params;
const wsOptions = new URLSearchParams();
if (schema) {
wsOptions.set('schema', schema.toString());
}
wsOptions.set('sync-schema', String(!!syncSchema));
wsOptions.set('token', token);
const secure = server.startsWith('https://');
const domain = server.slice(secure ? 8 : 7); // remove protocol
const wsUri = `${
secure ? 'wss' : 'ws'
}://${domain}?${wsOptions.toString()}`;
if (!webSocketsAreAvailable()) {
throw new WebSocketsUnavailableError();
}
// Create a new WebSocket connection and set up event listeners
this.ws = new WebSocket(wsUri);
}
// TODO: feels a bit awkward that these have to be set up after connect()
onMessage(callback: (message: any) => void): void {
if (this.ws) this.ws.onmessage = callback;
}
onError(callback: (ev: any) => void): void {
if (this.ws) this.ws.onerror = callback;
}
close(reason?: CloseReason): void {
// Assuming normal close for now (1000), possibly map reasons to codes later
if (!this.ws) return;
// If socket is open, close
if (this.ws.readyState === this.ws.OPEN) {
return this.ws.close(1000, JSON.stringify(reason));
}
// If socket is connecting, close once open
if (this.ws.readyState === this.ws.CONNECTING) {
return this.ws.addEventListener(
'open',
() => {
this.ws?.close(1000, JSON.stringify(reason));
},
{ once: true }
);
}
}
onClose(callback: (ev: any) => void): void {
if (this.ws) this.ws.onclose = callback;
}
onConnectionChange(callback: (state: ConnectionStatus) => void): void {
if (this.ws) this.ws.onconnectionchange = callback;
}
}
function getPayloadSize(payload: string): number {
var sizeInBytes = 0;
for (let i = 0; i < payload.length; i++) {
const code = payload.charCodeAt(i);
sizeInBytes += code < 0x80 ? 1 : code < 0x800 ? 2 : code < 0x10000 ? 3 : 4;
}
return sizeInBytes;
}
function chunkMessage(message: string, numChunks: number): string[] {
let chunks = [];
const chunkSize = Math.ceil(message.length / numChunks);
for (let i = 0; i < message.length; i += chunkSize) {
chunks.push(message.slice(i, i + chunkSize));
}
return chunks;
}
declare global {
interface WebSocket {
onconnectionchange: (status: ConnectionStatus) => void;
}
}
function friendlyReadyState(conn: WebSocket): ConnectionStatus {
switch (conn.readyState) {
case conn.CONNECTING:
return 'CONNECTING';
case conn.OPEN:
return 'OPEN';
case conn.CLOSING:
return 'CLOSING'; // I'm not sure 'CLOSING' will ever be a state we see with connection change events
case conn.CLOSED:
// Default to closed... this shouldnt happen and probably indicates something is wrong
default:
return 'CLOSED';
}
}
if (typeof globalThis !== 'undefined' && globalThis.WebSocket) {
var WebSocketProxy = new Proxy(globalThis.WebSocket, {
construct: function (target, args) {
//@ts-expect-error
const instance = new target(...args);
function dispatchConnectionChangeEvent() {
instance.dispatchEvent(new Event('connectionchange'));
if (
instance.onconnectionchange &&
typeof instance.onconnectionchange === 'function'
) {
instance.onconnectionchange(friendlyReadyState(instance));
}
}
// Handle connecting state after constructor
setTimeout(() => {
dispatchConnectionChangeEvent();
}, 0);
const openHandler = () => {
dispatchConnectionChangeEvent();
};
const closeHandler = () => {
dispatchConnectionChangeEvent();
instance.removeEventListener('open', openHandler);
instance.removeEventListener('close', closeHandler);
};
instance.addEventListener('open', openHandler);
instance.addEventListener('close', closeHandler);
return instance;
},
});
// Replace native/global WebSocket with the proxy
globalThis.WebSocket = WebSocketProxy;
}