From c15ede3e891aad63c1adc622f0da6339d98df73d Mon Sep 17 00:00:00 2001 From: Rob Knight Date: Fri, 25 Oct 2024 00:28:06 +0200 Subject: [PATCH] Properly handle errors during connection process (#24) --- apps/client-web/src/App.tsx | 10 +++++--- apps/client-web/src/client/utils.ts | 4 ++-- apps/client-web/src/state.tsx | 19 ++++++++------- packages/app-connector-react/src/context.tsx | 21 +++++++++++++++-- packages/app-connector/src/adapters/hosted.ts | 14 +++++++---- packages/app-connector/src/adapters/iframe.ts | 23 +++++++++++++++---- packages/app-connector/src/errors.ts | 6 +++++ packages/app-connector/src/rpc_client.ts | 12 +++++++--- .../client-helpers/src/connection/advice.ts | 1 + .../client-helpers/src/connection/iframe.ts | 6 +++++ packages/client-rpc/src/protocol.ts | 4 ++++ 11 files changed, 91 insertions(+), 29 deletions(-) diff --git a/apps/client-web/src/App.tsx b/apps/client-web/src/App.tsx index 2f28ed6..8052f14 100644 --- a/apps/client-web/src/App.tsx +++ b/apps/client-web/src/App.tsx @@ -25,7 +25,11 @@ function App() { }, [dispatch]); useEffect(() => { - if (state.advice && state.connectionState === ConnectionState.CONNECTING) { + if ( + state.advice && + (state.connectionState === ConnectionState.CONNECTED || + state.connectionState === ConnectionState.CONNECTING) + ) { state.advice.showClient(); } }, [state.advice, state.connectionState]); @@ -64,7 +68,7 @@ function App() { {state.connectionState === ConnectionState.CONNECTING && ( @@ -395,7 +399,7 @@ function Authorize({ diff --git a/apps/client-web/src/client/utils.ts b/apps/client-web/src/client/utils.ts index 9837fc5..21a932c 100644 --- a/apps/client-web/src/client/utils.ts +++ b/apps/client-web/src/client/utils.ts @@ -12,13 +12,13 @@ export function loadPODsFromStorage(): Record { try { const serializedCollections = JSON.parse(storedSerializedPODs) as unknown; const parsed = v.parse( - v.record(v.string(), v.array(v.string())), + v.record(v.string(), v.array(v.any())), serializedCollections ); for (const [collectionId, serializedPODs] of Object.entries(parsed)) { result[collectionId] = new PODCollection( // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - serializedPODs.map((str) => POD.fromJSON(JSON.parse(str))) + serializedPODs.map((obj) => POD.fromJSON(obj)) ); } } catch (e) { diff --git a/apps/client-web/src/state.tsx b/apps/client-web/src/state.tsx index 136a4ee..439358e 100644 --- a/apps/client-web/src/state.tsx +++ b/apps/client-web/src/state.tsx @@ -17,10 +17,10 @@ import { PODCollectionManager } from "./client/pod_collection_manager"; import { getIdentity } from "./client/utils"; export enum ConnectionState { - DISCONNECTED, - CONNECTING, - CONNECTED, - AUTHORIZED + DISCONNECTED = "DISCONNECTED", + CONNECTING = "CONNECTING", + CONNECTED = "CONNECTED", + AUTHORIZED = "AUTHORIZED" } export const StateContext = createContext< @@ -93,8 +93,7 @@ export type ClientState = { export type ClientAction = | { - type: "login"; - loggedIn: boolean; + type: "connect"; } | { type: "authorize"; @@ -122,13 +121,13 @@ export type ClientAction = type: "clear-proof-in-progress"; } | { - type: "logout"; + type: "cancel-connection"; }; export function clientReducer(state: ClientState, action: ClientAction) { switch (action.type) { - case "login": - return { ...state, loggedIn: action.loggedIn }; + case "connect": + return { ...state, connectionState: ConnectionState.CONNECTED }; case "authorize": if (!state.zapp || !state.zappOrigin) { throw new Error("No zapp or zapp origin"); @@ -227,7 +226,7 @@ export function clientReducer(state: ClientState, action: ClientAction) { ...state, proofInProgress: undefined }; - case "logout": + case "cancel-connection": return initializeState(); } } diff --git a/packages/app-connector-react/src/context.tsx b/packages/app-connector-react/src/context.tsx index 23c6a95..c353473 100644 --- a/packages/app-connector-react/src/context.tsx +++ b/packages/app-connector-react/src/context.tsx @@ -1,6 +1,12 @@ import { type ParcnetAPI, type Zapp, connect } from "@parcnet-js/app-connector"; import type { ReactNode } from "react"; -import { createContext, useCallback, useRef, useState } from "react"; +import { + createContext, + useCallback, + useContext, + useRef, + useState +} from "react"; export enum ClientConnectionState { DISCONNECTED = "DISCONNECTED", @@ -13,6 +19,16 @@ export const ParcnetClientContext = createContext< ParcnetClientContextType | undefined >(undefined); +export const useParcnetClientContext = (): ParcnetClientContextType => { + const context = useContext(ParcnetClientContext); + if (!context) { + throw new Error( + "useParcnetClientContext must be used within a ParcnetClientProvider" + ); + } + return context; +}; + type ParcnetContextBase = { zapp: Zapp; connect: () => Promise; @@ -79,7 +95,8 @@ export function ParcnetIframeProvider({ } if ( url !== connectUrl || - connectionState === ClientConnectionState.DISCONNECTED + connectionState === ClientConnectionState.DISCONNECTED || + connectionState === ClientConnectionState.ERROR ) { setConnectionState(ClientConnectionState.CONNECTING); try { diff --git a/packages/app-connector/src/adapters/hosted.ts b/packages/app-connector/src/adapters/hosted.ts index 81c595e..4d3997c 100644 --- a/packages/app-connector/src/adapters/hosted.ts +++ b/packages/app-connector/src/adapters/hosted.ts @@ -2,6 +2,7 @@ import type { Zapp } from "@parcnet-js/client-rpc"; import { InitializationMessageType } from "@parcnet-js/client-rpc"; import { createNanoEvents } from "nanoevents"; import { ParcnetAPI } from "../api_wrapper.js"; +import { UserCancelledConnectionError } from "../errors.js"; import { ParcnetRPCConnector } from "../rpc_client.js"; import type { DialogController, ModalEvents } from "./iframe.js"; import { postWindowMessage } from "./iframe.js"; @@ -28,7 +29,7 @@ export function connectToHost(zapp: Zapp): Promise { const emitter = createNanoEvents(); - return new Promise((resolve) => { + return new Promise((resolve, reject) => { // Create a new MessageChannel to communicate with the parent window const chan = new MessageChannel(); @@ -42,9 +43,14 @@ export function connectToHost(zapp: Zapp): Promise { // promise and return the API wrapper to the caller. // See below for how the other port of the message channel is sent to // the client. - client.start(() => { - resolve(new ParcnetAPI(client, emitter)); - }); + client.start( + () => { + resolve(new ParcnetAPI(client, emitter)); + }, + () => { + reject(new UserCancelledConnectionError()); + } + ); // Send the other port of the message channel to the client postWindowMessage( diff --git a/packages/app-connector/src/adapters/iframe.ts b/packages/app-connector/src/adapters/iframe.ts index 35ffe66..0d92582 100644 --- a/packages/app-connector/src/adapters/iframe.ts +++ b/packages/app-connector/src/adapters/iframe.ts @@ -6,6 +6,7 @@ import type { import { InitializationMessageType } from "@parcnet-js/client-rpc"; import { createNanoEvents } from "nanoevents"; import { ParcnetAPI } from "../api_wrapper.js"; +import { UserCancelledConnectionError } from "../errors.js"; import { ParcnetRPCConnector } from "../rpc_client.js"; export type ModalEvents = { @@ -118,7 +119,10 @@ export function connect( iframe.style.height = "100%"; iframe.src = normalizedUrl.toString(); - return new Promise((resolve) => { + return new Promise((resolve, reject) => { + const unsubscribeCloseEvent = emitter.on("close", () => { + reject(new UserCancelledConnectionError()); + }); iframe.addEventListener( "load", () => { @@ -132,12 +136,21 @@ export function connect( ); // Tell the RPC client to start. It will call the function we pass in // when the connection is ready, at which point we can resolve the - // promise and return the API wrapper to the caller. + // promise and return the API wrapper to the caller. Alternatively, + // the user can cancel the connection, in which case we reject the + // promise. // See below for how the other port of the message channel is sent to // the client. - client.start(() => { - resolve(new ParcnetAPI(client, emitter)); - }); + client.start( + () => { + unsubscribeCloseEvent(); + resolve(new ParcnetAPI(client, emitter)); + }, + () => { + unsubscribeCloseEvent(); + reject(new UserCancelledConnectionError()); + } + ); if (iframe.contentWindow) { const contentWindow = iframe.contentWindow; diff --git a/packages/app-connector/src/errors.ts b/packages/app-connector/src/errors.ts index 4ff8d6f..dd286b2 100644 --- a/packages/app-connector/src/errors.ts +++ b/packages/app-connector/src/errors.ts @@ -15,3 +15,9 @@ export class ClientDisconnectedError extends Error { super("Client disconnected"); } } + +export class UserCancelledConnectionError extends Error { + constructor() { + super("User cancelled connection"); + } +} diff --git a/packages/app-connector/src/rpc_client.ts b/packages/app-connector/src/rpc_client.ts index f904ae4..77e3971 100644 --- a/packages/app-connector/src/rpc_client.ts +++ b/packages/app-connector/src/rpc_client.ts @@ -266,8 +266,8 @@ export class ParcnetRPCConnector implements ParcnetRPC, ParcnetEvents { * * @param onConnect - Callback to call when the client is ready. */ - public start(onConnect: () => void): void { - const eventLoop = this.main(onConnect); + public start(onConnect: () => void, onCancel: () => void): void { + const eventLoop = this.main(onConnect, onCancel); eventLoop.next(); // Set up a listener for messages from the client @@ -304,7 +304,10 @@ export class ParcnetRPCConnector implements ParcnetRPC, ParcnetEvents { * * @param onConnect - Callback to call when the client is ready. */ - private *main(onConnect: () => void): Generator { + private *main( + onConnect: () => void, + onCancel: () => void + ): Generator { // Loop indefinitely until we get a PARCNET_CLIENT_READY message // In the meantime, we will handle PARCNET_CLIENT_SHOW and // PARCNET_CLIENT_HIDE, as these may be necessary for the client to allow @@ -321,6 +324,9 @@ export class ParcnetRPCConnector implements ParcnetRPC, ParcnetEvents { this.#dialogController.show(); } else if (event.type === RPCMessageType.PARCNET_CLIENT_HIDE) { this.#dialogController.close(); + } else if (event.type === RPCMessageType.PARCNET_CLIENT_CANCEL) { + onCancel(); + return; } } diff --git a/packages/client-helpers/src/connection/advice.ts b/packages/client-helpers/src/connection/advice.ts index f325014..2ef6d2f 100644 --- a/packages/client-helpers/src/connection/advice.ts +++ b/packages/client-helpers/src/connection/advice.ts @@ -21,4 +21,5 @@ export interface ConnectorAdvice { result: SubscriptionUpdateResult, subscriptionSerial: number ): void; + cancel(): void; } diff --git a/packages/client-helpers/src/connection/iframe.ts b/packages/client-helpers/src/connection/iframe.ts index 0f13c93..ddcd19d 100644 --- a/packages/client-helpers/src/connection/iframe.ts +++ b/packages/client-helpers/src/connection/iframe.ts @@ -50,6 +50,12 @@ export class AdviceChannel implements ConnectorAdvice { }); } + public cancel(): void { + this.port.postMessage({ + type: RPCMessageType.PARCNET_CLIENT_CANCEL + }); + } + public subscriptionUpdate( { update, subscriptionId }: SubscriptionUpdateResult, subscriptionSerial: number diff --git a/packages/client-rpc/src/protocol.ts b/packages/client-rpc/src/protocol.ts index 3519d08..0497f63 100644 --- a/packages/client-rpc/src/protocol.ts +++ b/packages/client-rpc/src/protocol.ts @@ -13,6 +13,7 @@ export enum RPCMessageType { PARCNET_CLIENT_INVOKE_RESULT = "zupass-client-invoke-result", PARCNET_CLIENT_INVOKE_ERROR = "zupass-client-invoke-error", PARCNET_CLIENT_READY = "zupass-client-ready", + PARCNET_CLIENT_CANCEL = "zupass-client-cancel", PARCNET_CLIENT_SHOW = "zupass-client-show", PARCNET_CLIENT_HIDE = "zupass-client-hide", PARCNET_CLIENT_SUBSCRIPTION_UPDATE = "zupass-client-subscription-update" @@ -57,6 +58,9 @@ export const RPCMessageSchema = v.variant("type", [ v.object({ type: v.literal(RPCMessageType.PARCNET_CLIENT_READY) }), + v.object({ + type: v.literal(RPCMessageType.PARCNET_CLIENT_CANCEL) + }), v.object({ type: v.literal(RPCMessageType.PARCNET_CLIENT_SHOW) }),