diff --git a/apps/extension/src/entry/offscreen-handler.ts b/apps/extension/src/entry/offscreen-handler.ts index 8c6d01587b..4813098133 100644 --- a/apps/extension/src/entry/offscreen-handler.ts +++ b/apps/extension/src/entry/offscreen-handler.ts @@ -1,53 +1,59 @@ -import type { JsonValue } from '@bufbuild/protobuf'; +import { ConnectError } from '@connectrpc/connect'; +import { errorToJson } from '@connectrpc/connect/protocol-connect'; import { ActionBuildRequest, + ActionBuildResponse, isActionBuildRequest, isOffscreenRequest, } from '@penumbra-zone/types/src/internal-msg/offscreen'; chrome.runtime.onMessage.addListener((req, _sender, respond) => { - if (!isOffscreenRequest(req)) return false; - if (isActionBuildRequest(req.request)) { + if (isOffscreenRequest(req)) { const { type, request } = req; - void (async () => { - const response = spawnWorker(request); - const res = await response - .then(data => ({ type, data })) - .catch((e: Error) => ({ - type, - error: `Offscreen: ${e.message}`, - })); - respond(res); - })(); + if (isActionBuildRequest(request)) { + void spawnActionBuildWorker(request) + .then( + data => ({ type, data }), + e => ({ + type, + error: errorToJson(ConnectError.from(e), undefined), + }), + ) + .then(respond); + return true; + } } - return true; + return false; }); -const spawnWorker = (req: ActionBuildRequest): Promise => { - return new Promise((resolve, reject) => { - const worker = new Worker(new URL('../wasm-build-action.ts', import.meta.url)); +const spawnActionBuildWorker = (req: ActionBuildRequest) => { + const worker = new Worker(new URL('../wasm-build-action.ts', import.meta.url)); + return new Promise((resolve, reject) => { + worker.addEventListener( + 'message', + (e: MessageEvent) => resolve(e.data as ActionBuildResponse), + { once: true }, + ); - const onWorkerMessage = (e: MessageEvent) => { - resolve(e.data as JsonValue); - worker.removeEventListener('error', onWorkerError); - worker.terminate(); - }; + worker.addEventListener( + 'error', + ({ error, filename, lineno, colno, message }: ErrorEvent) => + reject( + error instanceof Error + ? error + : new Error(`Worker ErrorEvent ${filename}:${lineno}:${colno} ${message}`), + ), + { once: true }, + ); - const onWorkerError = (ev: ErrorEvent) => { - const { filename, lineno, colno, message } = ev; - reject( - ev.error instanceof Error - ? ev.error - : new Error(`Worker ErrorEvent ${filename}:${lineno}:${colno} ${message}`), - ); - worker.removeEventListener('message', onWorkerMessage); - worker.terminate(); - }; + worker.addEventListener( + 'messageerror', + (ev: MessageEvent) => reject(ConnectError.from(ev.data ?? ev)), - worker.addEventListener('message', onWorkerMessage, { once: true }); - worker.addEventListener('error', onWorkerError, { once: true }); + { once: true }, + ); // Send data to web worker worker.postMessage(req); - }); + }).finally(() => worker.terminate()); }; diff --git a/packages/router/src/grpc/offscreen-client.ts b/packages/router/src/grpc/offscreen-client.ts index 968f35efab..c3ce41b4e2 100644 --- a/packages/router/src/grpc/offscreen-client.ts +++ b/packages/router/src/grpc/offscreen-client.ts @@ -1,5 +1,6 @@ import { Action, + ActionPlan, TransactionPlan, WitnessData, } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/transaction/v1/transaction_pb'; @@ -9,6 +10,10 @@ import { OffscreenMessage, } from '@penumbra-zone/types/src/internal-msg/offscreen'; import { InternalRequest, InternalResponse } from '@penumbra-zone/types/src/internal-msg/shared'; +import { ConnectError } from '@connectrpc/connect'; +import { errorToJson } from '@connectrpc/connect/protocol-connect'; +import type { Jsonified } from '@penumbra-zone/types/src/jsonified'; +import type { JsonValue } from '@bufbuild/protobuf'; const OFFSCREEN_DOCUMENT_PATH = '/offscreen.html'; @@ -44,8 +49,23 @@ const releaseOffscreen = async () => { const sendOffscreenMessage = async ( req: InternalRequest, -): Promise> => - chrome.runtime.sendMessage, InternalResponse>(req); +): Promise> => + chrome.runtime.sendMessage, InternalResponse>(req).catch(e => ({ + type: req.type, + error: errorToJson(ConnectError.from(e), undefined), + })); + +type JsonifiedTransactionPlanWithActions = Jsonified & { + actions: Jsonified[]; +}; + +const isSuccessfulResponse = ( + r: InternalResponse | null, +): r is InternalResponse & { data: T['response'] } => r != null && 'data' in r; + +const isErrorResponse = ( + r: InternalResponse | null, +): r is InternalResponse & { error: JsonValue } => r != null && 'error' in r; /** * Build actions in parallel, in an offscreen window where we can run wasm. @@ -67,15 +87,16 @@ const buildActions = ( sendOffscreenMessage({ type: 'BUILD_ACTION', request: { - transactionPlan: transactionPlan.toJson(), - witness: witness.toJson(), + transactionPlan: transactionPlan.toJson() as JsonifiedTransactionPlanWithActions, + witness: witness.toJson() as Jsonified, fullViewingKey, actionPlanIndex, - } as ActionBuildRequest, + } satisfies ActionBuildRequest, }), ]); - if ('error' in buildRes) throw new Error(String(buildRes.error)); - return Action.fromJson(buildRes.data); + if (isSuccessfulResponse(buildRes)) return Action.fromJson(buildRes.data); + else if (isErrorResponse(buildRes)) throw ConnectError.from(buildRes.error); + else throw ConnectError.from(buildRes); }); void Promise.all(buildTasks).finally(() => void releaseOffscreen()); return buildTasks;