From 2427bbd9677946174bc50f765ebeff38fe91e177 Mon Sep 17 00:00:00 2001 From: Matthias Date: Mon, 14 Oct 2024 23:44:05 +0200 Subject: [PATCH 01/14] add logic back to try to reconnect to the app websocket if socket is closed --- README.md | 5 +++-- src/api/app/websocket.ts | 32 ++++++++++++++++------------ src/api/client.ts | 29 +++++++++++++++++++++++++ test/e2e/common.ts | 5 ++++- test/e2e/index.ts | 46 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 101 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index e7b7e318..75ed7805 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/src/api/app/websocket.ts b/src/api/app/websocket.ts index 14fe737a..bc52ee8c 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 ? 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 ); diff --git a/src/api/client.ts b/src/api/client.ts index 5ce90f00..7cca8786 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -40,6 +40,7 @@ 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(); @@ -178,6 +179,7 @@ export class WsClient extends Emittery { * @param request - The authentication request, containing an app authentication token. */ async authenticate(request: AppAuthenticationRequest): Promise { + this.authenticationToken = request.token; return this.exchange(request, (request, resolve) => { const encodedMsg = encode({ type: "authenticate", @@ -212,6 +214,33 @@ export class WsClient extends Emittery { sendHandler(request, resolve, reject); }); return promise as Promise; + } else if (this.url && this.authenticationToken) { + const response = new Promise((resolve, reject) => { + // typescript forgets in this promise scope that this.url is undefined + const socket = new IsoWebSocket(this.url as URL, this.options); + this.socket = socket; + socket.onerror = (errorEvent) => { + reject( + new HolochainError( + "ConnectionError", + `could not connect to Holochain Conductor API at ${this.url} - ${errorEvent.error}` + ) + ); + }; + socket.onopen = () => { + // Send authentication token + const encodedMsg = encode({ + type: "authenticate", + data: encode({ + token: this.authenticationToken as AppAuthenticationToken, + }), + }); + this.socket.send(encodedMsg); + sendHandler(request, resolve, reject); + }; + this.setupSocket(); + }); + return response as Promise; } else { return Promise.reject(new Error("Socket is not open")); } diff --git a/test/e2e/common.ts b/test/e2e/common.ts index 8e2a6c9b..7f40dbe1 100644 --- a/test/e2e/common.ts +++ b/test/e2e/common.ts @@ -97,7 +97,8 @@ export const withConductor = }; export const installAppAndDna = async ( - adminPort: number + adminPort: number, + nonExpiringToken?: boolean ): Promise<{ installed_app_id: InstalledAppId; cell_id: CellId; @@ -127,6 +128,8 @@ export const installAppAndDna = async ( }); const issued = await admin.issueAppAuthenticationToken({ installed_app_id, + single_use: nonExpiringToken ? false : true, + expiry_seconds: nonExpiringToken ? 0 : 30, }); 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..82c31ee6 100644 --- a/test/e2e/index.ts +++ b/test/e2e/index.ts @@ -1441,6 +1441,52 @@ 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, true); + await admin.authorizeSigningCredentials(cell_id); + await client.client.close(); + const call = client.callZome({ + cell_id, + zome_name: TEST_ZOME_NAME, + fn_name: "bar", + provenance: cell_id[1], + payload: null, + }); + try { + await call; + t.pass("websocket was reconnected successfully"); + } catch (error) { + t.fail("websocket was not reconnected"); + } + }) +); + +test( + "client fails to reconnect to websocket if closed before making a zome call and does not end up in a loop", + withConductor(ADMIN_PORT, async (t) => { + const { cell_id, client, admin } = await installAppAndDna(ADMIN_PORT); + await admin.authorizeSigningCredentials(cell_id); + await client.client.close(); + const call = client.callZome({ + cell_id, + zome_name: TEST_ZOME_NAME, + fn_name: "bar", + provenance: cell_id[1], + payload: null, + }); + try { + await call; + t.fail( + "reconnecting to websocket should have failed due to an invalid token." + ); + } catch (error) { + t.pass("reconnecting to websocket failed"); + } + }) +); + test( "Rust enums are serialized correctly", withConductor(ADMIN_PORT, async (t) => { From 46d887e5610256925cea5c373736e41b70074226 Mon Sep 17 00:00:00 2001 From: Matthias Date: Tue, 15 Oct 2024 11:01:34 +0200 Subject: [PATCH 02/14] cleanup --- src/api/app/websocket.ts | 2 +- src/api/client.ts | 2 +- test/e2e/common.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/api/app/websocket.ts b/src/api/app/websocket.ts index bc52ee8c..5344151d 100644 --- a/src/api/app/websocket.ts +++ b/src/api/app/websocket.ts @@ -212,7 +212,7 @@ export class AppWebsocket implements AppClient { const client = await WsClient.connect(options.url, options.wsClientOptions); - const token = options.token ? options.token : env?.APP_INTERFACE_TOKEN; + const token = options.token ?? env?.APP_INTERFACE_TOKEN; if (!token) throw new HolochainError( diff --git a/src/api/client.ts b/src/api/client.ts index 7cca8786..7dd1f61a 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -216,7 +216,7 @@ export class WsClient extends Emittery { return promise as Promise; } else if (this.url && this.authenticationToken) { const response = new Promise((resolve, reject) => { - // typescript forgets in this promise scope that this.url is undefined + // typescript forgets in this promise scope that `this.url` is not undefined const socket = new IsoWebSocket(this.url as URL, this.options); this.socket = socket; socket.onerror = (errorEvent) => { diff --git a/test/e2e/common.ts b/test/e2e/common.ts index 7f40dbe1..8ca05450 100644 --- a/test/e2e/common.ts +++ b/test/e2e/common.ts @@ -128,7 +128,7 @@ export const installAppAndDna = async ( }); const issued = await admin.issueAppAuthenticationToken({ installed_app_id, - single_use: nonExpiringToken ? false : true, + single_use: !nonExpiringToken, expiry_seconds: nonExpiringToken ? 0 : 30, }); const client = await AppWebsocket.connect({ From 76eb3232325c62c3ffb6ca97f58f63925ea0edec Mon Sep 17 00:00:00 2001 From: Matthias Date: Tue, 15 Oct 2024 11:31:22 +0200 Subject: [PATCH 03/14] include error in test failure --- test/e2e/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/e2e/index.ts b/test/e2e/index.ts index 82c31ee6..5f09cf1c 100644 --- a/test/e2e/index.ts +++ b/test/e2e/index.ts @@ -1458,7 +1458,7 @@ test( await call; t.pass("websocket was reconnected successfully"); } catch (error) { - t.fail("websocket was not reconnected"); + t.fail(`websocket was not reconnected: ${error}`); } }) ); From d6a7db7113ba2f14a688045a36866e0a83fbf9de Mon Sep 17 00:00:00 2001 From: Matthias Date: Tue, 15 Oct 2024 12:05:48 +0200 Subject: [PATCH 04/14] make arguments more aligned with conductor API --- .gitignore | 2 +- test/e2e/common.ts | 13 ++++++++++--- 2 files changed, 11 insertions(+), 4 deletions(-) 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/test/e2e/common.ts b/test/e2e/common.ts index 8ca05450..6e202ea0 100644 --- a/test/e2e/common.ts +++ b/test/e2e/common.ts @@ -98,7 +98,14 @@ export const withConductor = export const installAppAndDna = async ( adminPort: number, - nonExpiringToken?: boolean + /** + * 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; @@ -128,8 +135,8 @@ export const installAppAndDna = async ( }); const issued = await admin.issueAppAuthenticationToken({ installed_app_id, - single_use: !nonExpiringToken, - expiry_seconds: nonExpiringToken ? 0 : 30, + single_use: singleUse, + expiry_seconds: expirySeconds, }); const client = await AppWebsocket.connect({ url: new URL(`ws://localhost:${appPort}`), From 1ff4ba2f6c4b41f7c19a5d7ffa8d1ab917b2be38 Mon Sep 17 00:00:00 2001 From: Matthias Date: Tue, 15 Oct 2024 12:33:07 +0200 Subject: [PATCH 05/14] fix ws options type --- docs/client.wsclient.md | 2 +- docs/client.wsclient.options.md | 2 +- src/api/client.ts | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/client.wsclient.md b/docs/client.wsclient.md index f9e80aab..e9699d45 100644 --- a/docs/client.wsclient.md +++ b/docs/client.wsclient.md @@ -82,7 +82,7 @@ Description -[WsClientOptions](./client.wsclientoptions.md) +[WsClientOptions](./client.wsclientoptions.md) \| undefined diff --git a/docs/client.wsclient.options.md b/docs/client.wsclient.options.md index d9bb5198..40863ea2 100644 --- a/docs/client.wsclient.options.md +++ b/docs/client.wsclient.options.md @@ -7,5 +7,5 @@ **Signature:** ```typescript -options: WsClientOptions; +options: WsClientOptions | undefined; ``` diff --git a/src/api/client.ts b/src/api/client.ts index 7dd1f61a..21731feb 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -37,7 +37,7 @@ export interface AppAuthenticationRequest { export class WsClient extends Emittery { socket: IsoWebSocket; url: URL | undefined; - options: WsClientOptions; + options: WsClientOptions | undefined; private pendingRequests: Record; private index: number; private authenticationToken: AppAuthenticationToken | undefined; @@ -46,7 +46,7 @@ export class WsClient extends Emittery { super(); this.socket = socket; this.url = url; - this.options = options || {}; + this.options = options; this.pendingRequests = {}; this.index = 0; From d95a4d1b058eb5c7a82048e870aa5326f136332a Mon Sep 17 00:00:00 2001 From: Matthias Date: Tue, 15 Oct 2024 13:18:46 +0200 Subject: [PATCH 06/14] fix test --- test/e2e/index.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/test/e2e/index.ts b/test/e2e/index.ts index 5f09cf1c..3c35fefc 100644 --- a/test/e2e/index.ts +++ b/test/e2e/index.ts @@ -1444,7 +1444,11 @@ 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, true); + const { cell_id, client, admin } = await installAppAndDna( + ADMIN_PORT, + false, + 0 + ); await admin.authorizeSigningCredentials(cell_id); await client.client.close(); const call = client.callZome({ From 6dcaeec4305cd76be0200ff9657f28189063b1e8 Mon Sep 17 00:00:00 2001 From: Matthias Date: Wed, 16 Oct 2024 13:28:13 +0200 Subject: [PATCH 07/14] updated test to assert on error message --- test/e2e/index.ts | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/test/e2e/index.ts b/test/e2e/index.ts index 3c35fefc..5e1360dd 100644 --- a/test/e2e/index.ts +++ b/test/e2e/index.ts @@ -1468,7 +1468,7 @@ test( ); test( - "client fails to reconnect to websocket if closed before making a zome call and does not end up in a loop", + "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); @@ -1486,7 +1486,19 @@ test( "reconnecting to websocket should have failed due to an invalid token." ); } catch (error) { - t.pass("reconnecting to websocket failed"); + if ( + error + .toString() + .includes( + "client closed with pending requests - close event code: 1005" + ) + ) { + t.pass("reconnecting to websocket failed with the expected error"); + } else { + t.fail( + `reconnecting to websocket failed with an unexpected error: ${error}` + ); + } } }) ); From 939c11c8d12286e89686b3cab7a4d0d2820345a4 Mon Sep 17 00:00:00 2001 From: Jost Schulte Date: Thu, 17 Oct 2024 13:38:19 -0600 Subject: [PATCH 08/14] refactor(app): remove unused fn containsCell --- src/api/app/websocket.ts | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/src/api/app/websocket.ts b/src/api/app/websocket.ts index 5344151d..1004cd5e 100644 --- a/src/api/app/websocket.ts +++ b/src/api/app/websocket.ts @@ -449,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< From 868e5d17453e75d6ed8afb477c273a01d0d60143 Mon Sep 17 00:00:00 2001 From: Jost Schulte Date: Thu, 17 Oct 2024 13:57:43 -0600 Subject: [PATCH 09/14] refactor(client): unify usage of listeners & handle invalid app tokens --- src/api/client.ts | 334 +++++++++++++++++++++++++++------------------- test/e2e/index.ts | 76 ++++++++--- 2 files changed, 250 insertions(+), 160 deletions(-) diff --git a/src/api/client.ts b/src/api/client.ts index 21731feb..8455e5f8 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -44,93 +44,13 @@ export class WsClient extends Emittery { 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]; - }); - } - }; } /** @@ -143,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 } + ); }); } @@ -180,17 +104,48 @@ export class WsClient extends Emittery { */ async authenticate(request: AppAuthenticationRequest): Promise { this.authenticationToken = request.token; - return this.exchange(request, (request, resolve) => { + return this.exchange(request, (request, resolve, reject) => { + const invalidTokenCloseListener = ( + closeEvent: IsoWebSocket.CloseEvent + ) => { + 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. * @@ -201,7 +156,7 @@ export class WsClient extends Emittery { return this.exchange(request, this.sendMessage.bind(this)); } - private exchange( + private async exchange( request: unknown, sendHandler: ( request: unknown, @@ -215,34 +170,17 @@ export class WsClient extends Emittery { }); return promise as Promise; } else if (this.url && this.authenticationToken) { - const response = new Promise((resolve, reject) => { - // typescript forgets in this promise scope that `this.url` is not undefined - const socket = new IsoWebSocket(this.url as URL, this.options); - this.socket = socket; - socket.onerror = (errorEvent) => { - reject( - new HolochainError( - "ConnectionError", - `could not connect to Holochain Conductor API at ${this.url} - ${errorEvent.error}` - ) - ); - }; - socket.onopen = () => { - // Send authentication token - const encodedMsg = encode({ - type: "authenticate", - data: encode({ - token: this.authenticationToken as AppAuthenticationToken, - }), - }); - this.socket.send(encodedMsg); - sendHandler(request, resolve, reject); - }; - this.setupSocket(); - }); - return response as Promise; + 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") + ); } } @@ -262,6 +200,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]) { @@ -279,26 +355,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/test/e2e/index.ts b/test/e2e/index.ts index 5e1360dd..4c176224 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, installed_app_id } = 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)) @@ -1451,15 +1471,15 @@ test( ); await admin.authorizeSigningCredentials(cell_id); await client.client.close(); - const call = client.callZome({ + const callParams = { cell_id, zome_name: TEST_ZOME_NAME, fn_name: "bar", provenance: cell_id[1], payload: null, - }); + }; try { - await call; + await client.callZome(callParams); t.pass("websocket was reconnected successfully"); } catch (error) { t.fail(`websocket was not reconnected: ${error}`); @@ -1473,32 +1493,46 @@ test( const { cell_id, client, admin } = await installAppAndDna(ADMIN_PORT); await admin.authorizeSigningCredentials(cell_id); await client.client.close(); - const call = client.callZome({ + 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 { - await call; + await client.callZome(callParams, 3000); t.fail( "reconnecting to websocket should have failed due to an invalid token." ); } catch (error) { - if ( - error - .toString() - .includes( - "client closed with pending requests - close event code: 1005" - ) - ) { - t.pass("reconnecting to websocket failed with the expected error"); - } else { - t.fail( - `reconnecting to websocket failed with an unexpected error: ${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, 3000); + 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" + ); } }) ); From b14870ccfaf5e39f0dce4edc812fbb688b85dbbe Mon Sep 17 00:00:00 2001 From: Jost Schulte Date: Thu, 17 Oct 2024 14:00:21 -0600 Subject: [PATCH 10/14] docs: update changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a226ef69..755421cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ 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. ### Fixed ### Changed ### Removed From b38aa5b0daf324696d676e799f5c550e5b1c7178 Mon Sep 17 00:00:00 2001 From: Jost Schulte Date: Thu, 17 Oct 2024 14:14:44 -0600 Subject: [PATCH 11/14] revert: make WsClientOptions optional --- src/api/client.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/api/client.ts b/src/api/client.ts index 8455e5f8..89b880fd 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -37,7 +37,7 @@ export interface AppAuthenticationRequest { export class WsClient extends Emittery { socket: IsoWebSocket; url: URL | undefined; - options: WsClientOptions | undefined; + options: WsClientOptions; private pendingRequests: Record; private index: number; private authenticationToken: AppAuthenticationToken | undefined; @@ -48,7 +48,7 @@ export class WsClient extends Emittery { this.registerCloseListener(socket); this.socket = socket; this.url = url; - this.options = options; + this.options = options || {}; this.pendingRequests = {}; this.index = 0; } From 382405b7e93190414987d4e60b3dcfb5c128231d Mon Sep 17 00:00:00 2001 From: Jost Schulte Date: Thu, 17 Oct 2024 19:49:36 -0600 Subject: [PATCH 12/14] fix(common): return request error without awaiting timeout --- src/api/common.ts | 1 + test/e2e/index.ts | 10 ++++++---- 2 files changed, 7 insertions(+), 4 deletions(-) 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/index.ts b/test/e2e/index.ts index 4c176224..ba916bd4 100644 --- a/test/e2e/index.ts +++ b/test/e2e/index.ts @@ -425,7 +425,7 @@ test( test( "invalid app authentication token fails", withConductor(ADMIN_PORT, async (t) => { - const { admin, installed_app_id } = await installAppAndDna(ADMIN_PORT); + const { admin } = await installAppAndDna(ADMIN_PORT); const { port } = await admin.attachAppInterface({ allowed_origins: "client-test-app", }); @@ -1487,7 +1487,7 @@ test( }) ); -test( +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); @@ -1504,7 +1504,9 @@ test( // Websocket is closed and app authentication token has expired. Websocket reconnection // should fail. try { - await client.callZome(callParams, 3000); + console.log("now calling"); + console.log(); + await client.callZome(callParams); t.fail( "reconnecting to websocket should have failed due to an invalid token." ); @@ -1520,7 +1522,7 @@ test( // Websocket reconnection has failed and subsequent calls should just return a websocket // closed error. try { - await client.callZome(callParams, 3000); + await client.callZome(callParams); t.fail("should not be attempted to reconnect websocket"); } catch (error) { t.assert( From d3d8412bdde5aafd1c9074ca6b915bd3bd93a58d Mon Sep 17 00:00:00 2001 From: Jost Schulte Date: Mon, 28 Oct 2024 13:14:00 -0600 Subject: [PATCH 13/14] fix(client): unset auth token on rejected connection attempt --- src/api/client.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/api/client.ts b/src/api/client.ts index 89b880fd..1ddfad9c 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -108,6 +108,7 @@ export class WsClient extends Emittery { const invalidTokenCloseListener = ( closeEvent: IsoWebSocket.CloseEvent ) => { + this.authenticationToken = undefined; reject( new HolochainError( "InvalidTokenError", From 3ffb508307dd60b8aec600dfd87dca04142f3a65 Mon Sep 17 00:00:00 2001 From: Jost Schulte Date: Mon, 28 Oct 2024 13:14:26 -0600 Subject: [PATCH 14/14] build(nix): update flake --- flake.lock | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/flake.lock b/flake.lock index e9680b09..305b13a4 100644 --- a/flake.lock +++ b/flake.lock @@ -53,11 +53,11 @@ "hc-scaffold": { "flake": false, "locked": { - "lastModified": 1727375207, - "narHash": "sha256-wGS+cOhvakLWscqPI0LaBZVZ3ryORV3YDvL+bfhI+WA=", + "lastModified": 1729842397, + "narHash": "sha256-RGojwMWA5MXAsK/vy78Gb2JYoKrD+zzY0rl3KLt1LK4=", "owner": "holochain", "repo": "scaffolding", - "rev": "b218f253a124b6e7b5be0600c3aab7a57344f0f2", + "rev": "27715e5fdf56e03720f22f77068c1f16c33f0881", "type": "github" }, "original": { @@ -70,16 +70,16 @@ "holochain": { "flake": false, "locked": { - "lastModified": 1728435929, - "narHash": "sha256-KhZVfzRfJiXswukigC7mteF43WdTlcJR9k8x3yeRbyk=", + "lastModified": 1729645370, + "narHash": "sha256-RLF+nMYJTalQppstYc1OeGfFpmJxu1ACYNU0CycHIXo=", "owner": "holochain", "repo": "holochain", - "rev": "f4c1eea0832aae1beb3fb02be92cb28dc639f4bf", + "rev": "87a9abad0ab7cef890586fa7e9b793ea44031670", "type": "github" }, "original": { "owner": "holochain", - "ref": "holochain-0.5.0-dev.0", + "ref": "holochain-0.5.0-dev.2", "repo": "holochain", "type": "github" } @@ -96,11 +96,11 @@ "rust-overlay": "rust-overlay" }, "locked": { - "lastModified": 1728501093, - "narHash": "sha256-V1So3W9hPdaEkQ2fHmrDv4JVtwY4gJ4kYWNmjfCHcX4=", + "lastModified": 1730137475, + "narHash": "sha256-LEoelTswbqDj1dPwUxWYcOiSLbvTJou2SpWWetKiEH8=", "owner": "holochain", "repo": "holonix", - "rev": "6a9de675dfe4f04b6296955f0c4f841c6a8dc72c", + "rev": "694a20d2f6345cfb81e4db6879a899130bea0c1f", "type": "github" }, "original": {