diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml index 899b28d4..a028c79c 100644 --- a/.github/workflows/integration-test.yml +++ b/.github/workflows/integration-test.yml @@ -4,7 +4,6 @@ on: pull_request: push: branches: - - "develop" - "main" concurrency: diff --git a/.gitignore b/.gitignore index f9b62fa6..61ccf980 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,4 @@ target node_modules /lib .tsbuildinfo -.hc \ No newline at end of file +.hc* \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c081c4e..c20075e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,12 +5,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## \[Unreleased\] ### Added +- Bring back a websocket reconnection automation for Admin and App websockets. When either of them is closed and a new request made, it will attempt to reconnect using the same app authentication token that was used to initially authenticate the websocket. A specific `InvalidTokenError` is returned if that fails. ### Changed - Update to Holochain 0.4.0-rc.0 ### Fixed ### Removed -## 2024-09-30: v0.18.0-dev.12 +## 2024-09-30: v0.18.0-dev.13 ### Fixed - Type `RevokeAgentKeyResponse`, which returns an array of tuples with cell id and error message for all cells that key revocation failed for. diff --git a/README.md b/README.md index 90153eb4..11421319 100644 --- a/README.md +++ b/README.md @@ -179,8 +179,9 @@ You need `holochain` and `hc` on your path, best to get them from nix with `nix- To perform the pre-requisite DNA compilation steps, and run the Nodejs test, run: ```bash -nix-shell -./run-test.sh +nix develop +./build-fixture.sh +npm run test ``` ## Contribute diff --git a/docs/client.wsclient.close.md b/docs/client.wsclient.close.md index f965e241..963613bb 100644 --- a/docs/client.wsclient.close.md +++ b/docs/client.wsclient.close.md @@ -9,7 +9,7 @@ Close the websocket connection. **Signature:** ```typescript -close(code?: number): Promise; +close(code?: number): Promise; ``` ## Parameters @@ -49,5 +49,5 @@ _(Optional)_ **Returns:** -Promise<CloseEvent> +Promise<IsoWebSocket.CloseEvent> diff --git a/src/api/app/websocket.ts b/src/api/app/websocket.ts index 14fe737a..1004cd5e 100644 --- a/src/api/app/websocket.ts +++ b/src/api/app/websocket.ts @@ -1,7 +1,12 @@ import Emittery, { UnsubscribeFunction } from "emittery"; import { omit } from "lodash-es"; import { AgentPubKey, CellId, InstalledAppId, RoleName } from "../../types.js"; -import { AppInfo, CellType, MemproofMap } from "../admin/index.js"; +import { + AppAuthenticationToken, + AppInfo, + CellType, + MemproofMap, +} from "../admin/index.js"; import { catchError, DEFAULT_TIMEOUT, @@ -77,6 +82,7 @@ export class AppWebsocket implements AppClient { CallZomeResponseGeneric, CallZomeResponse >; + private readonly appAuthenticationToken: AppAuthenticationToken; cachedAppInfo?: AppInfo | null; @@ -110,6 +116,7 @@ export class AppWebsocket implements AppClient { private constructor( client: WsClient, appInfo: AppInfo, + token: AppAuthenticationToken, callZomeTransform?: CallZomeTransform, defaultTimeout?: number ) { @@ -118,6 +125,7 @@ export class AppWebsocket implements AppClient { 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; @@ -204,18 +212,15 @@ export class AppWebsocket implements AppClient { const client = await WsClient.connect(options.url, options.wsClientOptions); - if (env?.APP_INTERFACE_TOKEN) { - // Note: This will only work for multiple connections if a single_use = false token is provided - await client.authenticate({ token: env.APP_INTERFACE_TOKEN }); - } else { - if (!options.token) { - throw new HolochainError( - "AppAuthenticationTokenMissing", - `unable to connect to Conductor API - no app authentication token provided.` - ); - } - await client.authenticate({ token: options.token }); - } + 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) as Requester< @@ -233,6 +238,7 @@ export class AppWebsocket implements AppClient { return new AppWebsocket( client, appInfo, + token, options.callZomeTransform, options.defaultTimeout ); @@ -443,27 +449,6 @@ export class AppWebsocket implements AppClient { transformer ); } - - private containsCell(cellId: CellId) { - const appInfo = this.cachedAppInfo; - if (!appInfo) { - return false; - } - for (const roleName of Object.keys(appInfo.cell_info)) { - for (const cellInfo of appInfo.cell_info[roleName]) { - const currentCellId = - CellType.Provisioned in cellInfo - ? cellInfo[CellType.Provisioned].cell_id - : CellType.Cloned in cellInfo - ? cellInfo[CellType.Cloned].cell_id - : undefined; - if (currentCellId && isSameCell(currentCellId, cellId)) { - return true; - } - } - } - return false; - } } const defaultCallZomeTransform: Transformer< diff --git a/src/api/client.ts b/src/api/client.ts index 5ce90f00..1ddfad9c 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -40,96 +40,17 @@ export class WsClient extends Emittery { options: WsClientOptions; private pendingRequests: Record; private index: number; + private authenticationToken: AppAuthenticationToken | undefined; constructor(socket: IsoWebSocket, url?: URL, options?: WsClientOptions) { super(); + this.registerMessageListener(socket); + this.registerCloseListener(socket); this.socket = socket; this.url = url; this.options = options || {}; this.pendingRequests = {}; this.index = 0; - - this.setupSocket(); - } - - private setupSocket() { - this.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], - } as Signal); - } 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: AppSignal = { - cell_id: encodedAppSignal.cell_id, - zome_name: encodedAppSignal.zome_name, - payload, - }; - this.emit("signal", { App: signal } as Signal); - } - } else if (message.type === "response") { - this.handleResponse(message); - } else { - throw new HolochainError( - "UnknownMessageType", - `incoming message has unknown type - ${message.type}` - ); - } - }; - - this.socket.onclose = (event) => { - 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: ${event.code}, request id: ${id}` - ); - this.pendingRequests[id].reject(error); - delete this.pendingRequests[id]; - }); - } - }; } /** @@ -142,18 +63,22 @@ export class WsClient extends Emittery { static connect(url: URL, options?: WsClientOptions) { return new Promise((resolve, reject) => { const socket = new IsoWebSocket(url, options); - socket.onerror = (errorEvent) => { + socket.addEventListener("error", (errorEvent) => { reject( new HolochainError( "ConnectionError", `could not connect to Holochain Conductor API at ${url} - ${errorEvent.error}` ) ); - }; - socket.onopen = () => { - const client = new WsClient(socket, url, options); - resolve(client); - }; + }); + socket.addEventListener( + "open", + (_) => { + const client = new WsClient(socket, url, options); + resolve(client); + }, + { once: true } + ); }); } @@ -178,17 +103,50 @@ export class WsClient extends Emittery { * @param request - The authentication request, containing an app authentication token. */ async authenticate(request: AppAuthenticationRequest): Promise { - return this.exchange(request, (request, resolve) => { + this.authenticationToken = request.token; + return this.exchange(request, (request, resolve, reject) => { + const invalidTokenCloseListener = ( + closeEvent: IsoWebSocket.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); - // Message just needs to be sent first, no need to wait for a response or even require a flush - resolve(null); + // 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. * @@ -199,7 +157,7 @@ export class WsClient extends Emittery { return this.exchange(request, this.sendMessage.bind(this)); } - private exchange( + private async exchange( request: unknown, sendHandler: ( request: unknown, @@ -212,8 +170,18 @@ export class WsClient extends Emittery { sendHandler(request, resolve, reject); }); return promise as 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 as Promise; } else { - return Promise.reject(new Error("Socket is not open")); + return Promise.reject( + new HolochainError("WebsocketClosedError", "Websocket is not open") + ); } } @@ -233,6 +201,144 @@ export class WsClient extends Emittery { this.index += 1; } + private registerMessageListener(socket: IsoWebSocket) { + 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], + } as Signal); + } 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: AppSignal = { + cell_id: encodedAppSignal.cell_id, + zome_name: encodedAppSignal.zome_name, + payload, + }; + this.emit("signal", { App: signal } as Signal); + } + } else if (message.type === "response") { + this.handleResponse(message); + } else { + throw new HolochainError( + "UnknownMessageType", + `incoming message has unknown type - ${message.type}` + ); + } + }; + } + + private registerCloseListener(socket: IsoWebSocket) { + 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 } + ); + } + + private async reconnectWebsocket(url: URL, token: AppAuthenticationToken) { + 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: IsoWebSocket.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 } + ); + }); + } + private handleResponse(msg: HolochainMessage) { const id = msg.id; if (this.pendingRequests[id]) { @@ -250,26 +356,6 @@ export class WsClient extends Emittery { ); } } - - /** - * Close the websocket connection. - */ - close(code = 1000) { - const closedPromise = new Promise( - (resolve) => - // for an unknown reason "addEventListener" is seen as a non-callable - // property and gives a ts2349 error - // type assertion as workaround - (this.socket as unknown as WebSocket).addEventListener( - "close", - (event) => resolve(event) - ) - // } - ); - - this.socket.close(code); - return closedPromise; - } } function assertHolochainMessage( diff --git a/src/api/common.ts b/src/api/common.ts index 0e308c5e..7062034f 100644 --- a/src/api/common.ts +++ b/src/api/common.ts @@ -96,6 +96,7 @@ export const promiseTimeout = ( return res(a); }) .catch((e) => { + clearTimeout(id); return rej(e); }) ); diff --git a/test/e2e/common.ts b/test/e2e/common.ts index 8e2a6c9b..6e202ea0 100644 --- a/test/e2e/common.ts +++ b/test/e2e/common.ts @@ -97,7 +97,15 @@ export const withConductor = }; export const installAppAndDna = async ( - adminPort: number + adminPort: number, + /** + * Whether the app authentication token is single use or not + */ + singleUse = true, + /** + * expiry seconds of the app authentication token + */ + expirySeconds = 30 ): Promise<{ installed_app_id: InstalledAppId; cell_id: CellId; @@ -127,6 +135,8 @@ export const installAppAndDna = async ( }); const issued = await admin.issueAppAuthenticationToken({ installed_app_id, + single_use: singleUse, + expiry_seconds: expirySeconds, }); const client = await AppWebsocket.connect({ url: new URL(`ws://localhost:${appPort}`), diff --git a/test/e2e/index.ts b/test/e2e/index.ts index b2b6e34d..ba916bd4 100644 --- a/test/e2e/index.ts +++ b/test/e2e/index.ts @@ -422,6 +422,28 @@ test( }) ); +test( + "invalid app authentication token fails", + withConductor(ADMIN_PORT, async (t) => { + const { admin } = await installAppAndDna(ADMIN_PORT); + const { port } = await admin.attachAppInterface({ + allowed_origins: "client-test-app", + }); + try { + await AppWebsocket.connect({ + url: new URL(`ws://localhost:${port}`), + wsClientOptions: { origin: "client-test-app" }, + token: [0], + }); + t.fail("could connect with invalid authentication token"); + } catch (error) { + t.assert(error instanceof HolochainError); + assert(error instanceof HolochainError); + t.equal(error.name, "InvalidTokenError", "expected InvalidTokenError"); + } + }) +); + test( "app websocket connection from allowed origin is established", withConductor(ADMIN_PORT, async (t) => { @@ -1352,8 +1374,6 @@ test( const response = await admin.storageInfo(); - console.log(response.blobs[1].dna); - t.equal(response.blobs.length, 2); t.assert( response.blobs.some((blob) => blob.dna.used_by.includes(installed_app_id)) @@ -1441,6 +1461,84 @@ test( }) ); +test( + "client reconnects websocket if closed before making a zome call", + withConductor(ADMIN_PORT, async (t) => { + const { cell_id, client, admin } = await installAppAndDna( + ADMIN_PORT, + false, + 0 + ); + await admin.authorizeSigningCredentials(cell_id); + await client.client.close(); + const callParams = { + cell_id, + zome_name: TEST_ZOME_NAME, + fn_name: "bar", + provenance: cell_id[1], + payload: null, + }; + try { + await client.callZome(callParams); + t.pass("websocket was reconnected successfully"); + } catch (error) { + t.fail(`websocket was not reconnected: ${error}`); + } + }) +); + +test.only( + "client fails to reconnect to websocket if closed before making a zome call if the provided token is invalid", + withConductor(ADMIN_PORT, async (t) => { + const { cell_id, client, admin } = await installAppAndDna(ADMIN_PORT); + await admin.authorizeSigningCredentials(cell_id); + await client.client.close(); + const callParams = { + cell_id, + zome_name: TEST_ZOME_NAME, + fn_name: "bar", + provenance: cell_id[1], + payload: null, + }; + + // Websocket is closed and app authentication token has expired. Websocket reconnection + // should fail. + try { + console.log("now calling"); + console.log(); + await client.callZome(callParams); + t.fail( + "reconnecting to websocket should have failed due to an invalid token." + ); + } catch (error) { + t.assert( + error instanceof HolochainError, + "error should be of type HolochainError" + ); + assert(error instanceof HolochainError); + t.equal(error.name, "InvalidTokenError", "expected an InvalidTokenError"); + } + + // Websocket reconnection has failed and subsequent calls should just return a websocket + // closed error. + try { + await client.callZome(callParams); + t.fail("should not be attempted to reconnect websocket"); + } catch (error) { + t.assert( + error instanceof HolochainError, + "error should be of type HolochainError" + ); + assert(error instanceof HolochainError); + t.equal( + error.name, + "WebsocketClosedError", + "expected a WebsocketClosedError" + ); + } + }) +); + test( "Rust enums are serialized correctly", withConductor(ADMIN_PORT, async (t) => {