@holochain/client
Version:
A JavaScript client for the Holochain Conductor API
284 lines (283 loc) • 12.1 kB
JavaScript
import Emittery from "emittery";
import { omit } from "lodash-es";
import { CellType, } from "../admin/index.js";
import { catchError, DEFAULT_TIMEOUT, getBaseRoleNameFromCloneId, HolochainError, isCloneId, promiseTimeout, requesterTransformer, } from "../common.js";
import { getHostZomeCallSigner, getLauncherEnvironment, } from "../../environments/launcher.js";
import { decode, encode } from "@msgpack/msgpack";
import { getNonceExpiration, getSigningCredentials, randomNonce, } from "../zome-call-signing.js";
import { encodeHashToBase64 } from "../../utils/index.js";
import { hashZomeCall } from "@holochain/serialization";
import _sodium from "libsodium-wrappers";
import { WsClient } from "../client.js";
/**
* A class to establish a websocket connection to an App interface, for a
* specific agent and app.
*
* @public
*/
export class AppWebsocket {
client;
myPubKey;
installedAppId;
defaultTimeout;
emitter;
callZomeTransform;
appAuthenticationToken;
cachedAppInfo;
appInfoRequester;
callZomeRequester;
provideMemproofRequester;
enableAppRequester;
createCloneCellRequester;
enableCloneCellRequester;
disableCloneCellRequester;
networkInfoRequester;
constructor(client, appInfo, token, callZomeTransform, defaultTimeout) {
this.client = client;
this.myPubKey = appInfo.agent_pub_key;
this.installedAppId = appInfo.installed_app_id;
this.defaultTimeout = defaultTimeout ?? DEFAULT_TIMEOUT;
this.callZomeTransform = callZomeTransform ?? defaultCallZomeTransform;
this.appAuthenticationToken = token;
this.emitter = new Emittery();
this.cachedAppInfo = appInfo;
this.appInfoRequester = AppWebsocket.requester(this.client, "app_info", this.defaultTimeout);
this.callZomeRequester = AppWebsocket.requester(this.client, "call_zome", this.defaultTimeout, this.callZomeTransform);
this.provideMemproofRequester = AppWebsocket.requester(this.client, "provide_memproofs", this.defaultTimeout);
this.enableAppRequester = AppWebsocket.requester(this.client, "enable_app", this.defaultTimeout);
this.createCloneCellRequester = AppWebsocket.requester(this.client, "create_clone_cell", this.defaultTimeout);
this.enableCloneCellRequester = AppWebsocket.requester(this.client, "enable_clone_cell", this.defaultTimeout);
this.disableCloneCellRequester = AppWebsocket.requester(this.client, "disable_clone_cell", this.defaultTimeout);
this.networkInfoRequester = AppWebsocket.requester(this.client, "network_info", this.defaultTimeout);
// Ensure all super methods are bound to this instance because Emittery relies on `this` being the instance.
// Please retain until the upstream is fixed https://github.com/sindresorhus/emittery/issues/86.
Object.getOwnPropertyNames(Emittery.prototype).forEach((name) => {
const to_bind = this.emitter[name];
if (typeof to_bind === "function") {
this.emitter[name] =
to_bind.bind(this.emitter);
}
});
this.client.on("signal", (signal) => {
this.emitter.emit("signal", signal).catch(console.error);
});
}
/**
* Instance factory for creating an {@link AppWebsocket}.
*
* @param token - A token to authenticate the websocket connection. Get a token using AdminWebsocket#issueAppAuthenticationToken.
* @param options - {@link (WebsocketConnectionOptions:interface)}
* @returns A new instance of an AppWebsocket.
*/
static async connect(options = {}) {
// Check if we are in the launcher's environment, and if so, redirect the url to connect to
const env = getLauncherEnvironment();
if (env?.APP_INTERFACE_PORT) {
options.url = new URL(`ws://localhost:${env.APP_INTERFACE_PORT}`);
}
if (!options.url) {
throw new HolochainError("ConnectionUrlMissing", `unable to connect to Conductor API - no url provided and not in a launcher environment.`);
}
const client = await WsClient.connect(options.url, options.wsClientOptions);
const token = options.token ?? env?.APP_INTERFACE_TOKEN;
if (!token)
throw new HolochainError("AppAuthenticationTokenMissing", `unable to connect to Conductor API - no app authentication token provided.`);
await client.authenticate({ token });
const appInfo = await AppWebsocket.requester(client, "app_info", DEFAULT_TIMEOUT)(null);
if (!appInfo) {
throw new HolochainError("AppNotFound", `The app your connection token was issued for was not found. The app needs to be installed and enabled.`);
}
return new AppWebsocket(client, appInfo, token, options.callZomeTransform, options.defaultTimeout);
}
/**
* Request the app's info, including all cell infos.
*
* @param timeout - A timeout to override the default.
* @returns The app's {@link AppInfo}.
*/
async appInfo(timeout) {
const appInfo = await this.appInfoRequester(null, timeout);
if (!appInfo) {
throw new HolochainError("AppNotFound", `App info not found. App needs to be installed and enabled.`);
}
this.cachedAppInfo = appInfo;
return appInfo;
}
/**
* Provide membrane proofs for the app.
*
* @param memproofs - A map of {@link MembraneProof}s.
*/
async provideMemproofs(memproofs) {
await this.provideMemproofRequester(memproofs);
}
/**
* Enablie an app only if the app is in the `AppStatus::Disabled(DisabledAppReason::NotStartedAfterProvidingMemproofs)`
* state. Attempting to enable the app from other states (other than Running) will fail.
*/
async enableApp() {
await this.enableAppRequester();
}
/**
* Get a cell id by its role name or clone id.
*
* @param roleName - The role name or clone id of the cell.
* @param appInfo - The app info containing all cell infos.
* @returns The cell id or throws an error if not found.
*/
getCellIdFromRoleName(roleName, appInfo) {
if (isCloneId(roleName)) {
const baseRoleName = getBaseRoleNameFromCloneId(roleName);
if (!(baseRoleName in appInfo.cell_info)) {
throw new HolochainError("NoCellForRoleName", `no cell found with role_name ${roleName}`);
}
const cloneCell = appInfo.cell_info[baseRoleName].find((c) => CellType.Cloned in c && c[CellType.Cloned].clone_id === roleName);
if (!cloneCell || !(CellType.Cloned in cloneCell)) {
throw new HolochainError("NoCellForCloneId", `no clone cell found with clone id ${roleName}`);
}
return cloneCell[CellType.Cloned].cell_id;
}
if (!(roleName in appInfo.cell_info)) {
throw new HolochainError("NoCellForRoleName", `no cell found with role_name ${roleName}`);
}
const cell = appInfo.cell_info[roleName].find((c) => CellType.Provisioned in c);
if (!cell || !(CellType.Provisioned in cell)) {
throw new HolochainError("NoProvisionedCellForRoleName", `no provisioned cell found with role_name ${roleName}`);
}
return cell[CellType.Provisioned].cell_id;
}
/**
* Call a zome.
*
* @param request - The zome call arguments.
* @param timeout - A timeout to override the default.
* @returns The zome call's response.
*/
async callZome(request, timeout) {
if (!("provenance" in request)) {
request = {
...request,
provenance: this.myPubKey,
};
}
if ("role_name" in request && request.role_name) {
const appInfo = this.cachedAppInfo || (await this.appInfo());
const cell_id = this.getCellIdFromRoleName(request.role_name, appInfo);
const zomeCallPayload = {
...omit(request, "role_name"),
provenance: this.myPubKey,
cell_id: [cell_id[0], cell_id[1]],
};
return this.callZomeRequester(zomeCallPayload, timeout);
}
else if ("cell_id" in request && request.cell_id) {
return this.callZomeRequester(request, timeout);
}
throw new HolochainError("MissingRoleNameOrCellId", "callZome requires a role_name or cell_id argument");
}
/**
* Clone an existing provisioned cell.
*
* @param args - Specify the cell to clone.
* @returns The created clone cell.
*/
async createCloneCell(args) {
const clonedCell = this.createCloneCellRequester({
...args,
});
this.cachedAppInfo = undefined;
return clonedCell;
}
/**
* Enable a disabled clone cell.
*
* @param args - Specify the clone cell to enable.
* @returns The enabled clone cell.
*/
async enableCloneCell(args) {
return this.enableCloneCellRequester({
...args,
});
}
/**
* Disable an enabled clone cell.
*
* @param args - Specify the clone cell to disable.
*/
async disableCloneCell(args) {
return this.disableCloneCellRequester({
...args,
});
}
/**
* Request network info about gossip status.
* @param args - Specify the DNAs for which you want network info
* @returns Network info for the specified DNAs
*/
async networkInfo(args) {
return this.networkInfoRequester({
...args,
agent_pub_key: this.myPubKey,
});
}
/**
* Register an event listener for signals.
*
* @param eventName - Event name to listen to (currently only "signal").
* @param listener - The function to call when event is triggered.
* @returns A function to unsubscribe the event listener.
*/
on(eventName, listener) {
return this.emitter.on(eventName, listener);
}
static requester(client, tag, defaultTimeout, transformer) {
return requesterTransformer((req, timeout) => promiseTimeout(client.request(req), tag, timeout || defaultTimeout).then(catchError), tag, transformer);
}
}
const defaultCallZomeTransform = {
input: async (request) => {
if ("signature" in request) {
return request;
}
const hostSigner = getHostZomeCallSigner();
if (hostSigner) {
return hostSigner.signZomeCall(request);
}
else {
return signZomeCall(request);
}
},
output: (response) => decode(response),
};
const isSameCell = (cellId1, cellId2) => cellId1[0].every((byte, index) => byte === cellId2[0][index]) &&
cellId1[1].every((byte, index) => byte === cellId2[1][index]);
/**
* @public
*/
export const signZomeCall = async (request) => {
const signingCredentialsForCell = getSigningCredentials(request.cell_id);
if (!signingCredentialsForCell) {
throw new HolochainError("NoSigningCredentialsForCell", `no signing credentials have been authorized for cell [${encodeHashToBase64(request.cell_id[0])}, ${encodeHashToBase64(request.cell_id[1])}]`);
}
const unsignedZomeCallPayload = {
cap_secret: signingCredentialsForCell.capSecret,
cell_id: request.cell_id,
zome_name: request.zome_name,
fn_name: request.fn_name,
provenance: signingCredentialsForCell.signingKey,
payload: encode(request.payload),
nonce: await randomNonce(),
expires_at: getNonceExpiration(),
};
const hashedZomeCall = await hashZomeCall(unsignedZomeCallPayload);
await _sodium.ready;
const sodium = _sodium;
const signature = sodium
.crypto_sign(hashedZomeCall, signingCredentialsForCell.keyPair.privateKey)
.subarray(0, sodium.crypto_sign_BYTES);
const signedZomeCall = {
...unsignedZomeCallPayload,
signature,
};
return signedZomeCall;
};