UNPKG

ic-websocket-js

Version:
438 lines 19.9 kB
import { Cbor, HttpAgent, SignIdentity, } from "@dfinity/agent"; import { IDL } from "@dfinity/candid"; import { Principal } from "@dfinity/principal"; import { decodeWebsocketServiceMessageContent, encodeWebsocketServiceMessageContent, extractApplicationMessageIdlFromActor, isClientKeyEq, } from "./idl"; import { logger } from "./logger"; import { isMessageBodyValid, randomBigInt, safeExecute } from "./utils"; import { isClientIncomingMessage, isGatewayHandshakeMessage, } from "./types"; import { callCanisterWsMessage, callCanisterWsOpen } from "./actor"; import { AckMessagesQueue, BaseQueue, } from "./queues"; import { WsAgent } from "./agent"; /** * The default interval (in milliseconds) at which the canister sends an ack message. */ const DEFAULT_ACK_MESSAGE_INTERVAL_MS = 300000; /** * The maximum communication latency allowed between the client and the canister (same as in the canister). * * Used to determine the ack message timeout. */ export const COMMUNICATION_LATENCY_BOUND_MS = 30000; ; /** * Creates a new {@link IcWebSocketConfig} from arguments. */ export const createWsConfig = (c) => c; export class IcWebSocket { /** * Returns the state of the WebSocket object's connection. */ get readyState() { return this._wsInstance.readyState; } /** * Creates a new IcWebSocket instance, waiting **30 seconds** for the WebSocket to be open. * @param url The gateway address. * @param protocols The protocols to use in the WebSocket. * @param config The IcWebSocket configuration. Use {@link createWsConfig} to create a new configuration. */ constructor(url, protocols, config) { this._wsAgent = null; this._incomingSequenceNum = BigInt(1); this._outgoingSequenceNum = BigInt(0); this._isHandshakeCompleted = false; this._isConnectionEstablished = false; this._gatewayPrincipal = null; this._maxCertificateAgeInMinutes = 5; this._openTimeout = null; this.onclose = null; this.onerror = null; this.onmessage = null; this.onopen = null; // forwards the WebSocket state constants this.CLOSED = WebSocket.CLOSED; this.CLOSING = WebSocket.CLOSING; this.CONNECTING = WebSocket.CONNECTING; this.OPEN = WebSocket.OPEN; this.canisterId = Principal.from(config.canisterId); if (!config.canisterActor) { throw new Error("Canister actor is required"); } this._canisterActor = config.canisterActor; this._applicationMessageIdl = extractApplicationMessageIdlFromActor(this._canisterActor); if (!config.identity) { throw new Error("Identity is required"); } if (!(config.identity instanceof SignIdentity)) { throw new Error("Identity must be a SignIdentity"); } this._identity = config.identity; this._clientKey = { client_principal: this.getPrincipal(), client_nonce: randomBigInt(), }; if (!config.networkUrl) { throw new Error("Network url is required"); } this._httpAgent = HttpAgent.createSync({ host: config.networkUrl, identity: this._identity, }); // follow the same logic of the HttpAgent to decide whether to fetch the root key or not // see https://github.com/dfinity/agent-js/blob/ed4f2d0a204bb2737d2bc490dcbcabb8a87a8051/packages/agent/src/agent/http/index.ts#L336C9-L336C55 if (this._httpAgent.host.toString() !== 'https://icp-api.io') { void this._httpAgent.fetchRootKey(); } this._incomingMessagesQueue = new BaseQueue({ itemCallback: this._processIncomingMessage.bind(this), isDisabled: true, }); this._outgoingMessagesQueue = new BaseQueue({ itemCallback: this._sendMessageFromQueue.bind(this), isDisabled: true, }); this._ackMessagesQueue = new AckMessagesQueue({ expirationMs: (config.ackMessageIntervalMs || DEFAULT_ACK_MESSAGE_INTERVAL_MS) + COMMUNICATION_LATENCY_BOUND_MS, timeoutExpiredCallback: this._onAckMessageTimeout.bind(this), }); if (config.maxCertificateAgeInMinutes) { this._maxCertificateAgeInMinutes = config.maxCertificateAgeInMinutes; } this._wsInstance = new WebSocket(url, protocols); // Gateway address. Here localhost to reproduce the demo. this._wsInstance.binaryType = "arraybuffer"; this._bindWsEvents(); } send(message) { if (!this._isConnectionEstablished) { throw new Error("Connection is not established yet"); } const data = IDL.encode([this._applicationMessageIdl], [message]); this._outgoingMessagesQueue.addAndProcess(new Uint8Array(data)); } getPrincipal() { return this._identity.getPrincipal(); } close() { this._wsInstance.close(1000); } isConnectionEstablished() { return this._isConnectionEstablished; } _bindWsEvents() { this._wsInstance.onopen = this._onWsOpen.bind(this); this._wsInstance.onmessage = this._onWsMessage.bind(this); this._wsInstance.onclose = this._onWsClose.bind(this); this._wsInstance.onerror = this._onWsError.bind(this); } async _onWsOpen() { this._incomingMessagesQueue.enableAndProcess(); logger.debug("[onWsOpen] WebSocket opened"); } _onWsMessage(event) { this._incomingMessagesQueue.addAndProcess(event.data); } _startOpenTimeout() { // the timeout is double the maximum allowed network latency, // because opening the connection involves a message sent by the client and one by the canister this._openTimeout = setTimeout(() => { if (!this._isConnectionEstablished) { logger.error("[onWsOpen] Error: Open timeout expired before receiving the open message"); this._callOnErrorCallback(new Error("Open timeout expired before receiving the open message")); this._wsInstance.close(4000, "Open connection timeout"); } this._openTimeout = null; }, 2 * COMMUNICATION_LATENCY_BOUND_MS); } _cancelOpenTimeout() { if (this._openTimeout) { clearTimeout(this._openTimeout); this._openTimeout = null; } } async _handleHandshakeMessage(handshakeMessage) { // at this point, we're sure that the gateway_principal is valid // because the isGatewayHandshakeMessage function checks it this._gatewayPrincipal = Principal.from(handshakeMessage.gateway_principal); this._isHandshakeCompleted = true; try { await this._sendOpenMessage(); this._startOpenTimeout(); } catch (error) { logger.error("[onWsMessage] Handshake message error:", error); // if a handshake message fails, we can't continue this._wsInstance.close(4000, "Handshake message error"); return false; } return true; } _initializeWsAgent() { this._wsAgent = new WsAgent({ identity: this._identity, httpAgent: this._httpAgent, ws: this._wsInstance, }); } async _sendOpenMessage() { this._initializeWsAgent(); logger.debug("Sending open message"); // Call the canister's ws_open method // at this point, all the class properties that we need are initialized await callCanisterWsOpen(this.canisterId, this._wsAgent, { client_nonce: this._clientKey.client_nonce, gateway_principal: this._gatewayPrincipal, }); logger.debug("Open message sent, waiting for first open message from canister"); } async _processIncomingMessage(message) { try { const incomingMessage = this._decodeIncomingMessage(message); // if the handshake is not completed yet, we have to treat the first message as HandshakeMessage if (!this._isHandshakeCompleted) { if (!isGatewayHandshakeMessage(incomingMessage)) { throw new Error("First message is not a GatewayHandshakeMessage"); } return this._handleHandshakeMessage(incomingMessage); } // Check if the incoming message is a ClientIncomingMessage if (!isClientIncomingMessage(incomingMessage)) { throw new Error("Incoming message is not a ClientIncomingMessage"); } logger.debug("[onWsMessage] Incoming message received. Bytes:", message.byteLength, "bytes"); const websocketMessage = this._decodeIncomingMessageContent(incomingMessage); const isValidMessage = await this._isIncomingMessageValid(incomingMessage); if (!isValidMessage) { throw new Error("[onWsMessage] Certificate validation failed"); } const isSequenceNumValid = this._isWebsocketMessageSequenceNumberValid(websocketMessage); if (!isSequenceNumValid) { throw new Error(`[onWsMessage] Received message sequence number does not match next expected value. Expected: ${this._incomingSequenceNum}, received: ${websocketMessage.sequence_num}`); } // Increment the next expected sequence number this._incomingSequenceNum++; // handle the case in which the content is a service message if (websocketMessage.is_service_message) { logger.debug("[onWsMessage] Received service message from canister"); return this._handleServiceMessage(websocketMessage.content); } this._inspectWebsocketMessageTimestamp(websocketMessage); await this._callOnMessageCallback(new Uint8Array(websocketMessage.content)); } catch (error) { // for any error, we can't continue logger.error("[onWsMessage]", error); this._callOnErrorCallback(new Error(`Error receiving message: ${error}`)); this._wsInstance.close(4000, "Error receiving message"); return false; } return true; } async _handleServiceMessage(content) { try { const serviceMessage = decodeWebsocketServiceMessageContent(content); if ("OpenMessage" in serviceMessage) { logger.debug("[onWsMessage] Received open message from canister"); if (!isClientKeyEq(serviceMessage.OpenMessage.client_key, this._clientKey)) { throw new Error("Client key does not match"); } this._isConnectionEstablished = true; this._cancelOpenTimeout(); this._callOnOpenCallback(); this._outgoingMessagesQueue.enableAndProcess(); } else if ("AckMessage" in serviceMessage) { await this._handleAckMessageFromCanister(serviceMessage.AckMessage); } else if ("CloseMessage" in serviceMessage) { await this._handleCloseMessageFromCanister(serviceMessage.CloseMessage); // we don't have to process any further message (there shouldn't be any anyway) return false; } else { throw new Error("Invalid service message from canister"); } } catch (error) { logger.error("[onWsMessage] Service message error:", error); // if a service message fails, we can't continue this._wsInstance.close(4000, "Service message error"); return false; } return true; } async _handleAckMessageFromCanister(content) { const lastAckSequenceNumberFromCanister = BigInt(content.last_incoming_sequence_num); logger.debug("[onWsMessage] Received ack message from canister with sequence number", lastAckSequenceNumberFromCanister); try { this._ackMessagesQueue.ack(lastAckSequenceNumberFromCanister); } catch (error) { logger.error("[onWsMessage] Ack message error:", error); this._callOnErrorCallback(new Error(`Ack message error: ${error}`)); return this._wsInstance.close(4000, "Ack message error"); } await this._sendKeepAliveMessage(); } async _handleCloseMessageFromCanister(content) { if ("ClosedByApplication" in content.reason) { logger.debug("[onWsMessage] Received close message from canister. Reason: ClosedByApplication"); this._wsInstance.close(4001, "ClosedByApplication"); } else { logger.error("[onWsMessage] Received close message from canister. Reason:", content.reason); this._callOnErrorCallback(new Error(`Received close message from canister. Reason: ${content.reason}`)); this._wsInstance.close(4000, "Received close message from canister"); } } async _sendKeepAliveMessage() { const keepAliveMessageContent = { last_incoming_sequence_num: this._incomingSequenceNum - BigInt(1), }; const bytes = encodeWebsocketServiceMessageContent({ KeepAliveMessage: keepAliveMessageContent, }); const keepAliveMessage = this._makeWsMessageArguments(new Uint8Array(bytes), true); const sent = await this._sendMessageToCanister(keepAliveMessage); if (!sent) { logger.error("[onWsMessage] Keep alive message was not sent"); this._callOnErrorCallback(new Error("Keep alive message was not sent")); this._wsInstance.close(4000, "Keep alive message was not sent"); } } _onAckMessageTimeout(notReceivedAcks) { logger.error("[onAckMessageTimeout] Ack message timeout. Not received ack for sequence numbers:", notReceivedAcks); this._callOnErrorCallback(new Error(`Ack message timeout. Not received ack for sequence numbers: ${notReceivedAcks}`)); this._wsInstance.close(4000, "Ack message timeout"); } _onWsClose(event) { logger.debug(`[onWsClose] WebSocket closed, code=${event.code} reason=${event.reason}`); this._isConnectionEstablished = false; this._incomingMessagesQueue.disable(); this._outgoingMessagesQueue.disable(); this._ackMessagesQueue.clear(); this._callOnCloseCallback(event); } _onWsError(error) { logger.error("[onWsError]", error); this._callOnErrorCallback(new Error(`WebSocket error: ${error}`)); } _sendMessageFromQueue(messageContent) { const message = this._makeWsMessageArguments(messageContent); // we send the message via WebSocket to the gateway, which relays it to the canister return this._sendMessageToCanister(message); } /** * Sends a message to the canister via WebSocket, using a method that uses the {@link WsAgent}. * @param message * @returns {boolean} `true` if the message was sent successfully, `false` otherwise. */ async _sendMessageToCanister(message) { // we don't need to wait for the response, // as we'll receive the ack message via WebSocket from the canister try { await callCanisterWsMessage(this.canisterId, this._wsAgent, message); // add the sequence number to the ack messages queue this._ackMessagesQueue.add(message.msg.sequence_num); logger.debug("[send] Message sent"); } catch (error) { // the ws agent already tries 3 times under the hood, so if we get an error here, we can't continue logger.error("[send] Message sending failed:", error); this._callOnErrorCallback(new Error(`Message sending failed: ${error}`)); this._wsInstance.close(4000, "Message sending failed"); return false; } return true; } /** * CBOR decodes the incoming message from an ArrayBuffer and returns an object. * * @param {ArrayBuffer} buf - The ArrayBuffer containing the encoded message. * @returns {any} The decoded object. */ _decodeIncomingMessage(buf) { return Cbor.decode(buf); } async _isIncomingMessageValid(incomingMessage) { const key = incomingMessage.key; const content = new Uint8Array(incomingMessage.content); // make sure it's a Uint8Array const cert = incomingMessage.cert; const tree = incomingMessage.tree; // Verify the certificate (canister signature) const isValid = await isMessageBodyValid(this.canisterId, key, content, cert, tree, this._httpAgent, this._maxCertificateAgeInMinutes); return isValid; } _decodeIncomingMessageContent(incomingMessage) { const websocketMessage = Cbor.decode(incomingMessage.content); return websocketMessage; } _isWebsocketMessageSequenceNumberValid(incomingContent) { const receivedNum = incomingContent.sequence_num; logger.debug("[onWsMessage] Received message with sequence number", receivedNum); return BigInt(receivedNum) === this._incomingSequenceNum; } _inspectWebsocketMessageTimestamp(incomingContent) { const time = BigInt(incomingContent.timestamp) / BigInt(10 ** 6); const delayMilliseconds = BigInt(Date.now()) - time; logger.debug("[onWsMessage] Canister --> client latency(ms):", Number(delayMilliseconds)); } _makeWsMessageArguments(content, isServiceMessage = false) { this._outgoingSequenceNum++; const outgoingMessage = { client_key: this._clientKey, sequence_num: this._outgoingSequenceNum, timestamp: BigInt(Date.now()) * BigInt(10 ** 6), content, is_service_message: isServiceMessage, }; return { msg: outgoingMessage, }; } _callOnOpenCallback() { safeExecute(() => { if (this.onopen) { logger.debug("[onopen] Calling onopen callback"); this.onopen.call(this, new Event("open")); } else { logger.warn("[onopen] No onopen callback defined"); } }, "Calling onopen callback failed"); } async _callOnMessageCallback(data) { if (this.onmessage) { logger.debug("[onmessage] Calling onmessage callback"); const decoded = IDL.decode([this._applicationMessageIdl], data)[0]; await safeExecute(() => { this.onmessage.call(this, new MessageEvent("message", { data: decoded })); }, "Calling onmessage callback failed"); } else { logger.warn("[onmessage] No onmessage callback defined"); } } _callOnErrorCallback(error) { safeExecute(() => { if (this.onerror) { logger.debug("[onerror] Calling onerror callback"); this.onerror.call(this, new ErrorEvent("error", { error })); } else { logger.warn("[onerror] No onerror callback defined"); } }, "Calling onerror callback failed"); } _callOnCloseCallback(event) { safeExecute(() => { if (this.onclose) { logger.debug("[onclose] Calling onclose callback"); this.onclose.call(this, event); } else { logger.warn("[onclose] No onclose callback defined"); } }, "Calling onclose callback failed"); } } //# sourceMappingURL=ic-websocket.js.map