UNPKG

@leosprograms/vf-graphql-holochain

Version:

GraphQL schema bindings for the Holochain implementation of ValueFlows

466 lines (402 loc) 17.9 kB
/** * Connection wrapper for Holochain DNA method calls * * :TODO: :WARNING: * * This layer is currently unsuitable for mixing with DNAs that use dna-local identifier formats, and * will cause encoding errors if 2-element lists of identifiers are passed. * * Such tuples are interpreted as [`DnaHash`, `AnyDhtHash`] pairs by the GraphQL <-> Holochain * serialisation layer and transformed into compound IDs at I/O time. So, this adapter should * *only* be used to wrap DNAs explicitly developed with multi-DNA references in mind. * * Also :TODO: - standardise a binary format for universally unique Holochain entry/header identifiers. * * @package: hREA * @since: 2019-05-20 */ import { SignalCb, AppWebsocket, AdminWebsocket, CellId, CellType, HoloHash, AppClient } from '@holochain/client' import deepForEach from 'deep-for-each' import isObject from 'is-object' import { Buffer } from 'buffer' import { format, parse } from 'fecha' import { DNAIdMappings } from './types' import { fromByteArray, toByteArray } from 'base64-js'; type RecordId = [HoloHash, HoloHash] //---------------------------------------------------------------------------------------------------------------------- // Connection persistence and multi-conductor / multi-agent handling //---------------------------------------------------------------------------------------------------------------------- // :NOTE: when calling AppWebsocket.connect for the Launcher Context // it just expects an empty string for the socketURI. Other environments require it. let ENV_CONNECTION_URI = process.env.REACT_APP_HC_CONN_URL as string || '' let ENV_ADMIN_CONNECTION_URI = process.env.REACT_APP_HC_ADMIN_CONN_URL as string || '' let ENV_HOLOCHAIN_APP_ID = process.env.REACT_APP_HC_APP_ID as string || '' const CONNECTION_CACHE: { [i: string]: Promise<AppWebsocket> } = {} const APP_AGENT_CONNECTION_CACHE: { [i: string]: Promise<AppClient> } = {} /** * If no `conductorUri` is provided or is otherwise empty or undefined, * a connection is attempted via the `REACT_APP_HC_CONN_URL` environment variable. * Only if running in a Holochain Launcher context, can both of the before-mentioned values * be left undefined or empty, and the websocket connection can still be established. */ export async function autoConnect(weaveAppAgentClient?: any, conductorUri?: string, adminConductorUri?: string, appID?: string, traceAppSignals?: SignalCb, origin?: string) { console.log(`Auto-connect to Holochain conductor: ${conductorUri}, admin: ${adminConductorUri}, appID: ${appID}, origin: ${origin}`) conductorUri = conductorUri || ENV_CONNECTION_URI adminConductorUri = adminConductorUri || ENV_ADMIN_CONNECTION_URI if (weaveAppAgentClient) { await openWeaveConnection(conductorUri, weaveAppAgentClient, traceAppSignals) const { dnaConfig, appId: realAppId, } = await sniffHolochainAppCells(weaveAppAgentClient.appWebsocket, appID) return { conn: weaveAppAgentClient, adminConn: null, dnaConfig, conductorUri, adminConductorUri, appId: appID } } let adminConn: AdminWebsocket | null = null let token; if (adminConductorUri && appID) { if (origin) { adminConn = await AdminWebsocket.connect({url: adminConductorUri, wsClientOptions: { origin: origin}, defaultTimeout: 999999999}) } else { adminConn = await AdminWebsocket.connect({url: adminConductorUri, defaultTimeout: 999999999}) } let tokenResp = await adminConn.issueAppAuthenticationToken({ installed_app_id: appID, }); token = tokenResp.token; } let conn; if (origin) { console.log(`Holochain connection to ${conductorUri} with origin ${origin}`) if (token) { conn = await openConnection(conductorUri, traceAppSignals, token, origin); } else { conn = await openConnection(conductorUri, traceAppSignals, null, origin); } } else { console.log(`Holochain connection to ${conductorUri} without origin`) if (token) { conn = await openConnection(conductorUri, traceAppSignals, token); } else { conn = await openConnection(conductorUri, traceAppSignals); } } const { dnaConfig, appId: realAppId, } = await sniffHolochainAppCells(conn, appID); if (adminConn) { for await (let cellId of Object.values(dnaConfig)) { await adminConn.authorizeSigningCredentials(cellId) } } return { conn, adminConn, dnaConfig, conductorUri, adminConductorUri, appId: realAppId } } /** * Inits a connection for the given weave client. */ export const openWeaveConnection = (appSocketURI: string, appAgentClient: AppClient, traceAppSignals?: SignalCb) => { console.log(`Save Holochain connection from openWeaveConnection:`, appAgentClient) APP_AGENT_CONNECTION_CACHE[appSocketURI] = Promise.resolve(appAgentClient) console.log(`Holochain saved to ${APP_AGENT_CONNECTION_CACHE[appSocketURI]} OK from openWeaveConnection`) return APP_AGENT_CONNECTION_CACHE[appSocketURI] } /** * Inits a connection for the given websocket URI. * * This method gives calling code an opportunity to register globals for all future * instances of a connection of the same `socketURI`. To ensure this is done reliably, * a runtime error will be thrown by `getConnection` if no `openConnection` has * been previously performed for the same `socketURI`. */ export const openConnection = (appSocketURI: string, traceAppSignals?: SignalCb, token?: any, origin?: string) => { console.log(`Init Holochain connection: ${appSocketURI}, origin: ${origin}`) if (origin) { console.log(`Holochain connection to ${appSocketURI} with origin ${origin}`) CONNECTION_CACHE[appSocketURI] = AppWebsocket.connect({url: appSocketURI, wsClientOptions: { origin: origin}, token: token}) .then((client) => { console.log(`Holochain connection to ${appSocketURI} OK`) if (traceAppSignals) { client.on('signal', traceAppSignals) } return client }) return CONNECTION_CACHE[appSocketURI] } else { console.log(`Holochain connection to ${appSocketURI} without origin`) CONNECTION_CACHE[appSocketURI] = AppWebsocket.connect({url: appSocketURI, token: token}) .then((client) => { console.log(`Holochain connection to ${appSocketURI} OK`) if (traceAppSignals) { client.on('signal', traceAppSignals) } return client }) return CONNECTION_CACHE[appSocketURI] } } const getConnection = (appSocketURI: string) => { if (!CONNECTION_CACHE[appSocketURI]) { throw new Error(`Connection for ${appSocketURI} not initialised! Please call openConnection() first.`) } return CONNECTION_CACHE[appSocketURI] } const getWeaveConnection = (appSocketURI: string) => { if (!APP_AGENT_CONNECTION_CACHE[appSocketURI]) { throw new Error(`Connection for ${appSocketURI} not initialised! Please call openConnection() first.`) } // console.log(`Holochain connection from getWeaveConnection:`, APP_AGENT_CONNECTION_CACHE[appSocketURI]) return APP_AGENT_CONNECTION_CACHE[appSocketURI] } /** * Introspect an active Holochain connection's app cells to determine cell IDs * for mapping to the schema resolvers. * If no `appId` is provided or is otherwise empty or undefined, * it will try to use the `REACT_APP_HC_APP_ID` environment variable. * Only if running in a Holochain Launcher context, can both of the before-mentioned values * be left undefined or empty, and the AppWebsocket will know which appId to introspect into. */ export async function sniffHolochainAppCells(conn: AppWebsocket, appId?: string) { // console.log("sniff holochain app cells", conn, appId) // use the default set by the environment variable // and furthermore, note that both of these will be ignored // in the Holochain Launcher context // which will override any given value to the AppWebsocket // for installed_app_id appId = appId || ENV_HOLOCHAIN_APP_ID const appInfo = await conn.appInfo() if (!appInfo) { throw new Error(`appInfo call failed for Holochain app '${appId}' - ensure the name is correct and that the app installation has succeeded`) } let dnaConfig: DNAIdMappings = {} Object.entries(appInfo.cell_info).forEach(([roleName, cellInfos]) => { // this is the "magic pattern" of having for // example the "agreement" DNA, it should have // an assigned "role_name" in the happ of // "hrea_agreement_1" or "hrea_observation_2" // and the middle section should match the expected name // for DNAIdMappings, which are also used during zome calls const hrea_cell_match = roleName.match(/hrea_(\w+)_\d+/) if (!hrea_cell_match) return const hreaRole = hrea_cell_match[1] as keyof DNAIdMappings if (cellInfos) { const firstCell = cellInfos[0] if (CellType.Provisioned in firstCell) { dnaConfig[hreaRole] = firstCell[CellType.Provisioned].cell_id } } }) console.info('Connecting to detected Holochain cells:', dnaConfig) return { dnaConfig, appId, } } //---------------------------------------------------------------------------------------------------------------------- // Holochain / GraphQL type translation layer //---------------------------------------------------------------------------------------------------------------------- // @see https://crates.io/crates/holo_hash const HOLOCHAIN_IDENTIFIER_LEN = 39 // @see holo_hash::hash_type::primitive const HOLOHASH_PREFIX_DNA = [0x84, 0x2d, 0x24] // uhC0k const HOLOHASH_PREFIX_ENTRY = [0x84, 0x21, 0x24] // uhCEk const HOLOHASH_PREFIX_HEADER = [0x84, 0x29, 0x24] // uhCkk const HOLOHASH_PREFIX_AGENT = [0x84, 0x20, 0x24] // uhCAk const serializedHashMatchRegex = /^[A-Za-z0-9_+\-/]{53}={0,2}$/ const idMatchRegex = /^[A-Za-z0-9_+\-/]{53}={0,2}:[A-Za-z0-9_+\-/]{53}={0,2}$/ // something like // $:uhC0k1mcUqQIbtT0mkdTldhBaAvR6KlKxIV2IYwJemHt-NO92uXG5 // or kg:uhC0k1mcUqQIbtT0mkdTldhBaAvR6KlKxIV2IYwJemHt-NO92uXG5 // but not 9:uhC0k1mcUqQIbtT0mkdTldhBaAvR6KlKxIV2IYwJemHt-NO92uXG5 (i.e. no digits in the id) const stringIdRegex = /^\D+?:[A-Za-z0-9_+\-/]{53}={0,2}$/ // @see https://github.com/holochain-open-dev/core-types/blob/main/src/utils.ts export function deserializeHash(hash: string): Uint8Array { // return Base64.toUint8Array(hash.slice(1)) return toByteArray(hash.slice(1)) } export function deserializeId(field: string): RecordId { const matches = field.split(':') return [ Buffer.from(deserializeHash(matches[1])), Buffer.from(deserializeHash(matches[0])), ] } function deserializeStringId(field: string): [Buffer,string] { const matches = field.split(':') return [ Buffer.from(deserializeHash(matches[1])), matches[0], ] } // @see https://github.com/holochain-open-dev/core-types/blob/main/src/utils.ts export function serializeHash(hash: Uint8Array): string { // return `u${Base64.fromUint8Array(hash, true)}` return `u${fromByteArray(hash)}` } function serializeId(id: RecordId): string { return `${serializeHash(id[1])}:${serializeHash(id[0])}` } function seralizeStringId(id: [Buffer,string]): string { return `${id[1]}:${serializeHash(id[0])}` } // Construct appropriate IDs for records in associated DNAs by substituting // the CellId portion of the ID with that of an appropriate destination record export function remapCellId(originalId, newCellId) { const [origId, _origCell] = originalId.split(':') return `${origId}:${newCellId.split(':')[1]}` } const LONG_DATETIME_FORMAT = 'YYYY-MM-DDTHH:mm:ss.SSSZ' const SHORT_DATETIME_FORMAT = 'YYYY-MM-DDTHH:mm:ssZ' const isoDateRegex = /^\d{4}-\d\d-\d\d(T\d\d:\d\d:\d\d(\.\d\d\d)?)?([+-]\d\d:\d\d)?$/ /** * Decode raw data input coming from Holochain API websocket. * * Mutates in place- we have no need for the non-normalised primitive format and this saves memory. */ const decodeFields = (result: any): void => { deepForEach(result, (value, prop, subject) => { // ActionHash or AgentPubKey if ((value instanceof Buffer || value instanceof Uint8Array) && value.length === HOLOCHAIN_IDENTIFIER_LEN && (checkLeadingBytes(value, HOLOHASH_PREFIX_HEADER) || checkLeadingBytes(value, HOLOHASH_PREFIX_AGENT))) { subject[prop] = serializeHash(value as unknown as Uint8Array) } // RecordId | StringId (Agent, for now) if (Array.isArray(value) && value.length == 2 && (value[0] instanceof Buffer || value[0] instanceof Uint8Array) && value[0].length === HOLOCHAIN_IDENTIFIER_LEN && checkLeadingBytes(value[0], HOLOHASH_PREFIX_DNA)) { // Match 2-element arrays of Buffer objects as IDs. // Since we check the hash prefixes, this should make it safe to mix with fields which reference arrays of plain EntryHash / ActionHash data. if ((value[1] instanceof Buffer || value[1] instanceof Uint8Array) && value[1].length === HOLOCHAIN_IDENTIFIER_LEN && (checkLeadingBytes(value[1], HOLOHASH_PREFIX_ENTRY) || checkLeadingBytes(value[1], HOLOHASH_PREFIX_HEADER) || checkLeadingBytes(value[1], HOLOHASH_PREFIX_AGENT))) { subject[prop] = serializeId(value as RecordId) // Match 2-element pairs of Buffer/String as a "DNA-scoped identifier" (eg. UnitId) // :TODO: This one probably isn't safe for regular ID field mixing. // Custom serde de/serializer would make bind this handling to the appropriate fields without duck-typing issues. } else { subject[prop] = seralizeStringId(value as [Buffer, string]) } } // recursively check for Date strings and convert to JS date objects upon receiving if (value && value.match && value.match(isoDateRegex)) { subject[prop] = parse(value, LONG_DATETIME_FORMAT) if (subject[prop] === null) { subject[prop] = parse(value, SHORT_DATETIME_FORMAT) } } }) } function checkLeadingBytes(ofVar, against) { return ofVar[0] === against[0] && ofVar[1] === against[1] && ofVar[2] === against[2] } /** * Encode application runtime data into serialisable format for transmitting to API websocket. * * Clones data in order to keep input data pristine. */ const encodeFields = (args: any): any => { if (!args) return args let res = args // encode dates as ISO8601 DateTime strings if (args instanceof Date) { return format(args, LONG_DATETIME_FORMAT) } // deserialise any identifiers back to their binary format else if (args.match && args.match(serializedHashMatchRegex)) { return deserializeHash(args) } else if (args.match && args.match(idMatchRegex)) { return deserializeId(args) } else if (args.match && args.match(stringIdRegex)) { return deserializeStringId(args) } // recurse into child fields else if (Array.isArray(args)) { res = [] args.forEach((value, key) => { res[key] = encodeFields(value) }) } else if (isObject(args)) { res = {} for (const key in args) { res[key] = encodeFields(args[key]) } } return res } //---------------------------------------------------------------------------------------------------------------------- // Holochain cell API method binding API //---------------------------------------------------------------------------------------------------------------------- // explicit type-loss at the boundary export type BoundZomeFn<InputType, OutputType> = (args: InputType) => OutputType; /** * Higher-order function to generate async functions for calling zome RPC methods */ const zomeFunction = <InputType, OutputType>(socketURI: string, cell_id: CellId, zome_name: string, fn_name: string, skipEncodeDecode?: boolean): BoundZomeFn<InputType, Promise<OutputType>> => async (args): Promise<OutputType> => { // const startTime = new Date().getTime() // console.log(`Calling zome function ${fn_name} at time ${startTime}`) let noWeaveSocket = !APP_AGENT_CONNECTION_CACHE[socketURI] if (!noWeaveSocket) { const appAgentClient = await getWeaveConnection(socketURI) const res = await appAgentClient.callZome({ cell_id, zome_name, fn_name, payload: skipEncodeDecode ? args : encodeFields(args), }, 1200000) //20 minute timeout if (!skipEncodeDecode) decodeFields(res) return res } else { const midtime1 = new Date().getTime() const appAgentWebsocket = await getConnection(socketURI) const midTime2 = new Date().getTime() const res = await appAgentWebsocket.callZome({ cell_id, zome_name, fn_name, provenance: cell_id[1], payload: skipEncodeDecode ? args : encodeFields(args), }, 1200000) //20 minute timeout if (!skipEncodeDecode) decodeFields(res) // const endTime = new Date().getTime() // console.log('Done calling zome function at time', endTime) // console.log(`Done calling zome function ${fn_name} in time ${(endTime - startTime) / 1000}`) return res } } /** * External API for accessing zome methods, passing them through an optional intermediary DNA ID mapping * * @param mappings DNAIdMappings to use for this collaboration space. * `instance` must be present in the mapping, and the mapped CellId will be used instead of `instance` itself. * @param socketURI If provided, connects to the Holochain conductor on a different URI. * * @return bound async zome function which can be called directly */ export const mapZomeFn = <InputType, OutputType>(mappings: DNAIdMappings, socketURI: string, instance: string, zome: string, fn: string, skipEncodeDecode?: boolean) => { return zomeFunction<InputType, OutputType>(socketURI, (mappings && mappings[instance]), zome, fn, skipEncodeDecode) } export const extractEdges = <T>(withEdges: { edges: { node: T }[] }): T[] => { if (!withEdges.edges || !withEdges.edges.length) { return [] } return withEdges.edges.map(({ node }) => node) }