UNPKG

@holochain/client

Version:

A JavaScript client for the Holochain Conductor API

262 lines (261 loc) 10.5 kB
import { decode, encode } from "@msgpack/msgpack"; import Emittery from "emittery"; import IsoWebSocket from "isomorphic-ws"; import { HolochainError } from "./common.js"; import { SignalType } from "./app/index.js"; /** * A WebSocket client which can make requests and receive responses, * as well as send and receive signals. * * Uses Holochain's WireMessage for communication. * * @public */ export class WsClient extends Emittery { socket; url; options; pendingRequests; index; authenticationToken; constructor(socket, url, options) { super(); this.registerMessageListener(socket); this.registerCloseListener(socket); this.socket = socket; this.url = url; this.options = options || {}; this.pendingRequests = {}; this.index = 0; } /** * Instance factory for creating WsClients. * * @param url - The WebSocket URL to connect to. * @param options - Options for the WsClient. * @returns An new instance of the WsClient. */ static connect(url, options) { return new Promise((resolve, reject) => { const socket = new IsoWebSocket(url, options); socket.addEventListener("error", (errorEvent) => { reject(new HolochainError("ConnectionError", `could not connect to Holochain Conductor API at ${url} - ${errorEvent.error}`)); }); socket.addEventListener("open", (_) => { const client = new WsClient(socket, url, options); resolve(client); }, { once: true }); }); } /** * Sends data as a signal. * * @param data - Data to send. */ emitSignal(data) { const encodedMsg = encode({ type: "signal", data: encode(data), }); this.socket.send(encodedMsg); } /** * Authenticate the client with the conductor. * * This is only relevant for app websockets. * * @param request - The authentication request, containing an app authentication token. */ async authenticate(request) { this.authenticationToken = request.token; return this.exchange(request, (request, resolve, reject) => { const invalidTokenCloseListener = (closeEvent) => { this.authenticationToken = undefined; reject(new HolochainError("InvalidTokenError", `could not connect to ${this.url} due to an invalid app authentication token - close code ${closeEvent.code}`)); }; this.socket.addEventListener("close", invalidTokenCloseListener, { once: true, }); const encodedMsg = encode({ type: "authenticate", data: encode(request), }); this.socket.send(encodedMsg); // Wait before resolving in case authentication fails. setTimeout(() => { this.socket.removeEventListener("close", invalidTokenCloseListener); resolve(null); }, 10); }); } /** * Close the websocket connection. */ close(code = 1000) { const closedPromise = new Promise((resolve) => this.socket.addEventListener("close", (closeEvent) => resolve(closeEvent), { once: true })); this.socket.close(code); return closedPromise; } /** * Send requests to the connected websocket. * * @param request - The request to send over the websocket. * @returns */ async request(request) { return this.exchange(request, this.sendMessage.bind(this)); } async exchange(request, sendHandler) { if (this.socket.readyState === this.socket.OPEN) { const promise = new Promise((resolve, reject) => { sendHandler(request, resolve, reject); }); return promise; } else if (this.url && this.authenticationToken) { await this.reconnectWebsocket(this.url, this.authenticationToken); this.registerMessageListener(this.socket); this.registerCloseListener(this.socket); const promise = new Promise((resolve, reject) => sendHandler(request, resolve, reject)); return promise; } else { return Promise.reject(new HolochainError("WebsocketClosedError", "Websocket is not open")); } } sendMessage(request, resolve, reject) { const id = this.index; const encodedMsg = encode({ id, type: "request", data: encode(request), }); this.socket.send(encodedMsg); this.pendingRequests[id] = { resolve, reject }; this.index += 1; } registerMessageListener(socket) { socket.onmessage = async (serializedMessage) => { // If data is not a buffer (nodejs), it will be a blob (browser) let deserializedData; if (globalThis.window && serializedMessage.data instanceof globalThis.window.Blob) { deserializedData = await serializedMessage.data.arrayBuffer(); } else { if (typeof Buffer !== "undefined" && Buffer.isBuffer(serializedMessage.data)) { deserializedData = serializedMessage.data; } else { throw new HolochainError("UnknownMessageFormat", `incoming message has unknown message format - ${deserializedData}`); } } const message = decode(deserializedData); assertHolochainMessage(message); if (message.type === "signal") { if (message.data === null) { throw new HolochainError("UnknownSignalFormat", "incoming signal has no data"); } const deserializedSignal = decode(message.data); assertHolochainSignal(deserializedSignal); if (SignalType.System in deserializedSignal) { this.emit("signal", { System: deserializedSignal[SignalType.System], }); } else { const encodedAppSignal = deserializedSignal[SignalType.App]; // In order to return readable content to the UI, the signal payload must also be deserialized. const payload = decode(encodedAppSignal.signal); const signal = { cell_id: encodedAppSignal.cell_id, zome_name: encodedAppSignal.zome_name, payload, }; this.emit("signal", { App: signal }); } } else if (message.type === "response") { this.handleResponse(message); } else { throw new HolochainError("UnknownMessageType", `incoming message has unknown type - ${message.type}`); } }; } registerCloseListener(socket) { socket.addEventListener("close", (closeEvent) => { const pendingRequestIds = Object.keys(this.pendingRequests).map((id) => parseInt(id)); if (pendingRequestIds.length) { pendingRequestIds.forEach((id) => { const error = new HolochainError("ClientClosedWithPendingRequests", `client closed with pending requests - close event code: ${closeEvent.code}, request id: ${id}`); this.pendingRequests[id].reject(error); delete this.pendingRequests[id]; }); } }, { once: true }); } async reconnectWebsocket(url, token) { return new Promise((resolve, reject) => { this.socket = new IsoWebSocket(url, this.options); // This error event never occurs in tests. Could be removed? this.socket.addEventListener("error", (errorEvent) => { this.authenticationToken = undefined; reject(new HolochainError("ConnectionError", `could not connect to Holochain Conductor API at ${url} - ${errorEvent.message}`)); }, { once: true }); const invalidTokenCloseListener = (closeEvent) => { this.authenticationToken = undefined; reject(new HolochainError("InvalidTokenError", `could not connect to ${this.url} due to an invalid app authentication token - close code ${closeEvent.code}`)); }; this.socket.addEventListener("close", invalidTokenCloseListener, { once: true, }); this.socket.addEventListener("open", async (_) => { const encodedMsg = encode({ type: "authenticate", data: encode({ token }), }); this.socket.send(encodedMsg); // Wait in case authentication fails. setTimeout(() => { this.socket.removeEventListener("close", invalidTokenCloseListener); resolve(); }, 10); }, { once: true }); }); } handleResponse(msg) { const id = msg.id; if (this.pendingRequests[id]) { if (msg.data === null || msg.data === undefined) { this.pendingRequests[id].reject(new Error("Response canceled by responder")); } else { this.pendingRequests[id].resolve(decode(msg.data)); } delete this.pendingRequests[id]; } else { console.error(`got response with no matching request. id = ${id} msg = ${msg}`); } } } function assertHolochainMessage(message) { if (typeof message === "object" && message !== null && "type" in message && "data" in message) { return; } throw new HolochainError("UnknownMessageFormat", `incoming message has unknown message format ${JSON.stringify(message, null, 4)}`); } function assertHolochainSignal(signal) { if (typeof signal === "object" && signal !== null && Object.values(SignalType).some((type) => type in signal)) { return; } throw new HolochainError("UnknownSignalFormat", `incoming signal has unknown signal format ${JSON.stringify(signal, null, 4)}`); } export { IsoWebSocket };