diff --git a/.changeset/moody-bulldogs-mate.md b/.changeset/moody-bulldogs-mate.md new file mode 100644 index 000000000..e5c2723b9 --- /dev/null +++ b/.changeset/moody-bulldogs-mate.md @@ -0,0 +1,5 @@ +--- +'@penumbra-zone/client': major +--- + +add disconnect method, add `create` module diff --git a/.changeset/pink-starfishes-confess.md b/.changeset/pink-starfishes-confess.md new file mode 100644 index 000000000..3875707bb --- /dev/null +++ b/.changeset/pink-starfishes-confess.md @@ -0,0 +1,6 @@ +--- +'@penumbra-zone/transport-chrome': minor +'@penumbra-zone/transport-dom': minor +--- + +support disconnection diff --git a/.gitignore b/.gitignore index 131bda9a6..86257dc81 100644 --- a/.gitignore +++ b/.gitignore @@ -57,7 +57,8 @@ lerna-debug.log* *_pk.bin # pack outputs -penumbra-zone-*.tgz +packages/*/penumbra-zone-*.tgz +packages/*/repo-*-*.tgz packages/*/package tsconfig.tsbuildinfo \ No newline at end of file diff --git a/apps/minifront/src/components/shared/error-boundary.tsx b/apps/minifront/src/components/shared/error-boundary.tsx index eb9e4e77d..b7fde8f6b 100644 --- a/apps/minifront/src/components/shared/error-boundary.tsx +++ b/apps/minifront/src/components/shared/error-boundary.tsx @@ -1,5 +1,8 @@ import { isRouteErrorResponse, useRouteError } from 'react-router-dom'; -import { PenumbraNotInstalledError, PenumbraNotConnectedError } from '@penumbra-zone/client'; +import { + PenumbraProviderNotInstalledError, + PenumbraProviderNotConnectedError, +} from '@penumbra-zone/client'; import { ExtensionNotConnected } from '../extension-not-connected'; import { NotFound } from '../not-found'; import { ExtensionTransportDisconnected } from '../extension-transport-disconnected'; @@ -12,8 +15,8 @@ export const ErrorBoundary = () => { if (error instanceof ConnectError && error.code === Code.Unavailable) return ; - if (error instanceof PenumbraNotInstalledError) return ; - if (error instanceof PenumbraNotConnectedError) return ; + if (error instanceof PenumbraProviderNotInstalledError) return ; + if (error instanceof PenumbraProviderNotConnectedError) return ; if (isRouteErrorResponse(error) && error.status === 404) return ; console.error('ErrorBoundary caught error:', error); diff --git a/apps/minifront/src/prax.ts b/apps/minifront/src/prax.ts index f5f8ed8d9..8ef3b0c03 100644 --- a/apps/minifront/src/prax.ts +++ b/apps/minifront/src/prax.ts @@ -1,62 +1,55 @@ -import type { PromiseClient, Transport } from '@connectrpc/connect'; -import { createPromiseClient } from '@connectrpc/connect'; +import { createPromiseClient, PromiseClient, Transport } from '@connectrpc/connect'; import { - PenumbraNotConnectedError, - PenumbraNotInstalledError, - PenumbraSymbol, -} from '@penumbra-zone/client'; -import type { PenumbraService } from '@penumbra-zone/protobuf'; -import { jsonOptions } from '@penumbra-zone/protobuf'; -import { createChannelTransport } from '@penumbra-zone/transport-dom/create'; + assertProviderConnected, + assertProviderManifest, + getPenumbraPort, + syncCreatePenumbraChannelTransport, +} from '@penumbra-zone/client/create'; +import { jsonOptions, PenumbraService } from '@penumbra-zone/protobuf'; const prax_id = 'lkpmkhpnhknhmibgnmmhdhgdilepfghe'; const prax_origin = `chrome-extension://${prax_id}`; -const prax_manifest = `chrome-extension://${prax_id}/manifest.json`; + +export const getPraxOrigin = () => prax_origin; export const getPraxManifest = async () => { - const res = await fetch(prax_manifest); - return (await res.json()) as unknown; + const { manifest } = await assertProviderManifest(prax_origin); + const requestManifest = await fetch(manifest); + return (await requestManifest.json()) as unknown; }; -export const getPraxOrigin = () => prax_origin; - -export const isPraxConnected = () => Boolean(window[PenumbraSymbol]?.[prax_origin]?.isConnected()); +export const isPraxConnected = () => { + try { + assertProviderConnected(prax_origin); + return true; + } catch { + return false; + } +}; export const isPraxInstalled = async () => { try { - await getPraxManifest(); + await assertProviderManifest(); return true; } catch { return false; } }; -export const throwIfPraxNotConnected = () => { - if (!isPraxConnected()) - throw new PenumbraNotConnectedError('Prax not connected', { cause: prax_origin }); -}; +export const throwIfPraxNotConnected = () => assertProviderConnected(prax_origin); -export const throwIfPraxNotInstalled = async () => { - if (!(await isPraxInstalled())) - throw new PenumbraNotInstalledError('Prax not installed', { cause: prax_origin }); -}; +export const throwIfPraxNotInstalled = async () => assertProviderManifest(prax_origin); -export const getPraxPort = async () => { - await throwIfPraxNotInstalled(); - return window[PenumbraSymbol]![prax_origin]!.connect(); -}; +export const getPraxPort = () => getPenumbraPort(prax_origin); -export const requestPraxAccess = async () => { - await throwIfPraxNotInstalled(); - await window[PenumbraSymbol]?.[prax_origin]?.request(); -}; +export const requestPraxAccess = () => getPraxPort(); export const praxTransportOptions = { jsonOptions, getPort: getPraxPort, }; -export const createPraxTransport = () => createChannelTransport(praxTransportOptions); +export const createPraxTransport = () => syncCreatePenumbraChannelTransport(prax_origin); let praxTransport: Transport | undefined; export const createPraxClient = (service: T): PromiseClient => diff --git a/packages/client/README.md b/packages/client/README.md new file mode 100644 index 000000000..b03857280 --- /dev/null +++ b/packages/client/README.md @@ -0,0 +1,111 @@ +# `@penumbra-zone/client` + +This package contains interfaces, types, and some helpers for using the page API to Penumbra providers. + +**To use this package, you need to [enable the Buf Schema Registry](https://buf.build/docs/bsr/generated-sdks/npm):** + +```sh +echo "@buf:registry=https://buf.build/gen/npm/v1/" >> .npmrc +``` + +## A simple example + +```ts +import { bech32mAddress } from '@penumbra-zone/bech32m'; +import { createPenumbraClient } from '@penumbra-zone/client/create'; +import { ViewService, SctService } from '@penumbra-zone/protobuf'; + +// This may connect to any available injected provider. +const viewClient = createPenumbraClient(ViewService); + +// Or, you might prefer a specific provider. +const praxViewClient = createPenumbraClient( + ViewService, + 'chrome-extension://lkpmkhpnhknhmibgnmmhdhgdilepfghe', +); + +const { address } = await praxViewClient.addressByIndex({}); +console.log(bech32mAddress(address)); +``` + +## React use + +It's likely you want to use this client in your webapp, and there's a good +chance you're using React. Penumbra providers use `@connectrpc` tooling, so +these clients are supported by `@connectrpc/query` and `@tanstack/react-query`. + +After using `createPenumbraChannelTransport` from `@penumbra-zone/client/create` +and `TransportProvider` from `@connectrpc/query` in a parent component, you can +use convenient React idioms. + +You can see a full example of this at https://github.com/penumbra-zone/nextjs-penumbra-client-example + +### A parent component + +```ts +"use client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { syncCreatePenumbraChannelTransport } from "@penumbra-zone/client/create"; +import { TransportProvider } from "@connectrpc/connect-query"; +import { useMemo } from "react"; + +const queryClient = new QueryClient(); + +export const PenumbraQueryProvider = ({ + providerOrigin, + children, +}: { + providerOrigin: string; + children: React.ReactNode; +}) => { + const penumbraTransport = useMemo( + () => syncCreatePenumbraChannelTransport(providerOrigin), + [providerOrigin], + ); + return ( + + {children} + + ); +}; +``` + +### A querying component + +```ts +"use client"; +import { addressByIndex } from "@buf/penumbra-zone_penumbra.connectrpc_query-es/penumbra/view/v1/view-ViewService_connectquery"; +import { bech32mAddress } from "@penumbra-zone/bech32m/penumbra"; +import { useQuery } from "@connectrpc/connect-query"; + +export const PenumbraAddress = ({ account }: { account?: number }) => { + const { data } = useQuery(addressByIndex, { addressIndex: { account } }); + return ( + data?.address && ( + {bech32mAddress(data.address)} + ) + ); +}; +``` + +## You could access the providers directly, without importing this package. + +This example is javascript. + +```js +import { createChannelTransport } from '@penumbra-zone/transport-dom'; +import { createPromiseClient } from '@connectrpc/connect'; +import { jsonOptions, ViewService } from '@penumbra-zone/protobuf'; + +// naively get first available provider +const provider = Object.values(window[Symbol.for('penumbra')])[0]; +void provider.request(); + +// create a client +const viewClient = createPromiseClient( + ViewService, + createChannelTransport({ jsonOptions, getPort: provider.connect }), +); + +const { catchingUp, fullSyncHeight } = viewClient.status({}); +``` diff --git a/packages/client/package.json b/packages/client/package.json index 8df14b060..57065e594 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -17,17 +17,22 @@ ], "exports": { ".": "./src/index.ts", - "./prax": "./src/prax.ts" + "./create": "./src/create.ts" }, "publishConfig": { "exports": { ".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" + }, + "./create": { + "types": "./dist/create.d.ts", + "default": "./dist/create.js" } } }, "peerDependencies": { + "@connectrpc/connect": "^1.4.0", "@penumbra-zone/protobuf": "workspace:*", "@penumbra-zone/transport-dom": "workspace:*" } diff --git a/packages/client/src/create.ts b/packages/client/src/create.ts new file mode 100644 index 000000000..636e0d780 --- /dev/null +++ b/packages/client/src/create.ts @@ -0,0 +1,147 @@ +import { createPromiseClient, type Transport } from '@connectrpc/connect'; +import type { PenumbraService } from '@penumbra-zone/protobuf'; +import { jsonOptions } from '@penumbra-zone/protobuf'; +import { + createChannelTransport, + type ChannelTransportOptions, +} from '@penumbra-zone/transport-dom/create'; +import { + PenumbraProviderNotAvailableError, + PenumbraProviderNotConnectedError, + PenumbraProviderNotInstalledError, +} from './error'; +import { PenumbraSymbol, type PenumbraInjection } from '.'; + +// Naively return the first available provider origin, or `undefined`. +const availableOrigin = () => Object.keys(window[PenumbraSymbol] ?? {})[0]; + +/** + * Given a specific origin, identify the relevant injection or throw. An + * `undefined` origin is accepted but will throw. + */ +export const assertProvider = (providerOrigin?: string): PenumbraInjection => { + const provider = providerOrigin && window[PenumbraSymbol]?.[providerOrigin]; + if (!provider) throw new PenumbraProviderNotAvailableError(providerOrigin); + return provider; +}; + +/** + * Given a specific origin, identify the relevant injection, and confirm + * provider is connected or throw. An `undefined` origin is accepted but will + * throw. + */ +export const assertProviderConnected = (providerOrigin?: string) => { + const provider = assertProvider(providerOrigin); + if (!provider.isConnected()) throw new PenumbraProviderNotConnectedError(providerOrigin); + return provider; +}; + +/** + * Given a specific origin, identify the relevant injection, and confirm its + * manifest is actually present or throw. An `undefined` origin is accepted but + * will throw. + */ +export const assertProviderManifest = async ( + providerOrigin?: string, +): Promise => { + // confirm the provider injection is present + const provider = assertProvider(providerOrigin); + + try { + // confirm the provider manifest is located at the expected origin + if (new URL(provider.manifest).origin !== providerOrigin) + throw new Error('Manifest located at unexpected origin'); + + // confirm the provider manifest can be fetched, and is json + const req = await fetch(provider.manifest); + const manifest: unknown = await req.json(); + + if (!manifest) throw new Error('Empty manifest'); + } catch (e) { + console.warn(e); + throw new PenumbraProviderNotInstalledError(providerOrigin); + } + + return provider; +}; + +/** + * Asynchronously get a connection to the specified provider, or the first + * available provider if unspecified. + * + * Confirms presence of the provider's manifest. Will attempt to request + * approval if connection is not already active. + * + * @param requireProvider optional string identifying a provider origin + */ +export const getPenumbraPort = async (requireProvider?: string) => { + const provider = await assertProviderManifest(requireProvider ?? availableOrigin()); + if (provider.isConnected() === undefined) await provider.request(); + return provider.connect(); +}; + +/** + * Synchronously create a channel transport for the specified provider, or the + * first available provider if unspecified. + * + * Will always succeed, but the transport may fail if the provider is not + * present, or if the provider rejects the connection. + * + * Confirms presence of the provider's manifest. Will attempt to request + * approval if connection is not already active. + * + * @param requireProvider optional string identifying a provider origin + * @param transportOptions optional `ChannelTransportOptions` without `getPort` + */ +export const syncCreatePenumbraChannelTransport = ( + requireProvider?: string, + transportOptions: Omit = { jsonOptions }, +): Transport => + createChannelTransport({ + ...transportOptions, + getPort: () => getPenumbraPort(requireProvider), + }); + +/** + * Asynchronously create a channel transport for the specified provider, or the + * first available provider if unspecified. + * + * Like `syncCreatePenumbraChannelTransport`, but awaits connection init. + */ +export const createPenumbraChannelTransport = async ( + requireProvider?: string, + transportOptions: Omit = { jsonOptions }, +): Promise => { + const port = await getPenumbraPort(requireProvider); + return createChannelTransport({ + ...transportOptions, + getPort: () => Promise.resolve(port), + }); +}; + +/** + * Synchronously create a client for `service` from the specified provider, or the + * first available provider if unspecified. + * + * If the provider is unavailable, the client will fail to make requests. + */ +export const syncCreatePenumbraClient =

( + service: P, + requireProvider?: string, +) => createPromiseClient(service, syncCreatePenumbraChannelTransport(requireProvider)); + +/** + * Asynchronously create a client for `service` from the specified provider, or + * the first available provider if unspecified. + * + * Like `syncCreatePenumbraClient`, but awaits connection init. + */ +export const createPenumbraClient = async

( + service: P, + requireProvider?: string, + transportOptions?: Omit, +) => + createPromiseClient( + service, + await createPenumbraChannelTransport(requireProvider, transportOptions), + ); diff --git a/packages/client/src/error.ts b/packages/client/src/error.ts index ff250d2ad..66d9a9add 100644 --- a/packages/client/src/error.ts +++ b/packages/client/src/error.ts @@ -12,32 +12,41 @@ export class PenumbraNotAvailableError extends Error { this.name = 'PenumbraNotAvailableError'; } } +export class PenumbraProviderNotAvailableError extends Error { + constructor( + providerOrigin?: string, + public opts?: ErrorOptions, + ) { + super(`Penumbra provider ${providerOrigin} is not available`, opts); + this.name = 'PenumbraNotAvailableError'; + } +} -export class PenumbraNotConnectedError extends Error { +export class PenumbraProviderNotConnectedError extends Error { constructor( - message = 'Penumbra extension not connected', + providerOrigin?: string, public opts?: ErrorOptions, ) { - super(message, opts); + super(`Penumbra provider ${providerOrigin} is not connected`, opts); this.name = 'PenumbraNotConnectedError'; } } -export class PenumbraRequestError extends Error { +export class PenumbraProviderRequestError extends Error { constructor( - message = 'Penumbra request failed', + providerOrigin?: string, public opts?: ErrorOptions & { cause: PenumbraRequestFailure }, ) { - super(message, opts); + super(`Penumbra provider ${providerOrigin} did not approve request`, opts); this.name = 'PenumbraRequestError'; } } -export class PenumbraNotInstalledError extends Error { +export class PenumbraProviderNotInstalledError extends Error { constructor( - message = 'Penumbra not installed', + providerOrigin?: string, public opts?: ErrorOptions, ) { - super(message, opts); + super(`Penumbra provider ${providerOrigin} is not installed`, opts); this.name = 'PenumbraNotInstalledError'; } } diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 7a24f9b85..1c84988e6 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -2,15 +2,63 @@ export * from './error'; export const PenumbraSymbol = Symbol.for('penumbra'); +/** This interface describes the simple API to request, connect, or disconnect a provider. + * + * There are three states for each provider, which may be identified by calling + * the synchronous method `isConnected()`: + * - `true`: a connection is available, and a call to `connect` should resolve + * - `false`: no connection available. calls to `connect` or `request` will fail + * - `undefined`: a `request` may be pending, or no `request` has been made + * + * Any script in page scope may create an object like this, so clients should + * confirm a provider is actually present by confirming provider origin and + * fetching the provider manifest. Provider details such as name, version, + * website, brief descriptive text, and icons should be available in the + * manifest. + * + * Presently clients may expect the manifest is a chrome extension manifest v3. + * @see https://developer.chrome.com/docs/extensions/reference/manifest + * + * Clients may `request()` approval to connect. This method may reject if the + * provider chooses to deny approval. Approval granted by a successful + * request will persist accross sessions. + * + * Clients must `connect()` to acquire a `MessagePort`. The resulting + * `MessagePort` represents an active, type-safe communication channel to the + * provider. It is convenient to provide the `connect` method as the `getPort` + * option for `createChannelTransport` from `@penumbra-zone/transport-dom`, or + * use the helpers available in `@penumbra-zone/client/create`. + * + */ export interface PenumbraInjection { + /** Call when creating a channel transport to this provider. Returns a promise + * that may resolve with an active `MessagePort`. */ readonly connect: () => Promise; + + /** Call to gain approval to connect. Returns a `Promise` that may + * reject with an enumerated failure reason. */ readonly request: () => Promise; + + /** Call to indicate the provider should revoke approval of this origin. */ + readonly disconnect: () => Promise; + + /** Should synchronously return the present connection state. + * + * - `true` indicates active connection. + * - `false` indicates connection is closed or rejected. + * - `undefined` indicates connection may be attempted. + */ readonly isConnected: () => boolean | undefined; + + /** Should contain a URI at the provider's origin, which returns a chrome + * extension manifest v3 describing this provider. */ readonly manifest: string; } declare global { interface Window { + /** Records upon this global should identify themselves by a field name + * matching the origin of the provider. */ readonly [PenumbraSymbol]?: undefined | Readonly>; } } diff --git a/packages/transport-chrome/src/session-client.ts b/packages/transport-chrome/src/session-client.ts index 15e71aa8c..940e9aaaa 100644 --- a/packages/transport-chrome/src/session-client.ts +++ b/packages/transport-chrome/src/session-client.ts @@ -23,8 +23,35 @@ import { import { ChannelLabel, nameConnection } from './channel-names'; import { isTransportInitChannel, TransportInitChannel } from './message'; import { PortStreamSink, PortStreamSource } from './stream'; -import { Code, ConnectError } from '@connectrpc/connect'; -import { errorToJson } from '@connectrpc/connect/protocol-connect'; + +const localErrorJson = (err: unknown, relevantMessage?: unknown) => + err instanceof Error + ? { + message: err.message, + details: [ + { + type: err.name, + value: err.cause, + }, + relevantMessage, + ], + } + : { + message: String(err), + details: [ + { + type: String( + typeof err === 'function' + ? err.name + : typeof err === 'object' + ? (Object.getPrototypeOf(err) as unknown)?.constructor?.name ?? String(err) + : typeof err, + ), + value: err, + }, + relevantMessage, + ], + }; export class CRSessionClient { private static singleton?: CRSessionClient; @@ -42,7 +69,7 @@ export class CRSessionClient { }); this.servicePort.onMessage.addListener(this.serviceListener); - this.servicePort.onDisconnect.addListener(this.disconnect); + this.servicePort.onDisconnect.addListener(this.disconnectClient); this.clientPort.addEventListener('message', this.clientListener); this.clientPort.start(); } @@ -59,33 +86,37 @@ export class CRSessionClient { return port2; } - private disconnect = () => { + private disconnectClient = () => { this.clientPort.removeEventListener('message', this.clientListener); - this.clientPort.postMessage({ - error: errorToJson(new ConnectError('Connection closed', Code.Unavailable), undefined), - }); + this.clientPort.postMessage(false); this.clientPort.close(); }; + private disconnectService = () => { + this.servicePort.disconnect(); + }; + private clientListener = (ev: MessageEvent) => { try { - if (isTransportMessage(ev.data)) this.servicePort.postMessage(ev.data); + if (ev.data === false) this.disconnectService(); + else if (isTransportMessage(ev.data)) this.servicePort.postMessage(ev.data); else if (isTransportStream(ev.data)) this.servicePort.postMessage(this.requestChannelStream(ev.data)); else console.warn('Unknown item from client', ev.data); } catch (e) { - this.clientPort.postMessage({ error: errorToJson(ConnectError.from(e), undefined) }); + this.clientPort.postMessage({ error: localErrorJson(e, ev.data) }); } }; - private serviceListener = (m: unknown) => { + private serviceListener = (msg: unknown) => { try { - if (isTransportError(m) || isTransportMessage(m)) this.clientPort.postMessage(m); - else if (isTransportInitChannel(m)) - this.clientPort.postMessage(...this.acceptChannelStreamResponse(m)); - else console.warn('Unknown item from service', m); + if (msg === true) this.clientPort.postMessage(true); + else if (isTransportError(msg) || isTransportMessage(msg)) this.clientPort.postMessage(msg); + else if (isTransportInitChannel(msg)) + this.clientPort.postMessage(...this.acceptChannelStreamResponse(msg)); + else console.warn('Unknown item from service', msg); } catch (e) { - this.clientPort.postMessage({ error: errorToJson(ConnectError.from(e), undefined) }); + this.clientPort.postMessage({ error: localErrorJson(e, msg) }); } }; diff --git a/packages/transport-dom/src/create.ts b/packages/transport-dom/src/create.ts index a4c0ef4a4..91d16d62e 100644 --- a/packages/transport-dom/src/create.ts +++ b/packages/transport-dom/src/create.ts @@ -85,7 +85,11 @@ export const createChannelTransport = ({ }; const transportListener = ({ data }: MessageEvent) => { - if (isTransportEvent(data)) { + if (!data) { + console.warn(data); + // likely 'false' indicating a disconnect + listenerError.reject(new ConnectError('Connection closed', Code.Unavailable)); + } else if (isTransportEvent(data)) { // this is a response to a specific request. the port may be shared, so it // may contain a requestId we don't know about. the response may be // successful, or contain an error conveyed only to the caller. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fdd2d7344..8f854c5be 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -323,6 +323,9 @@ importers: packages/client: dependencies: + '@connectrpc/connect': + specifier: ^1.4.0 + version: 1.4.0(@bufbuild/protobuf@1.10.0) '@penumbra-zone/protobuf': specifier: workspace:* version: link:../protobuf