UNPKG

@triplit/client

Version:
214 lines (192 loc) 6.56 kB
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; }