From 68d0617cf547cfe0a95209f7da55081d71f42d5d Mon Sep 17 00:00:00 2001 From: turbocrime Date: Thu, 13 Jun 2024 07:45:01 -0700 Subject: [PATCH 1/5] react wallet --- .changeset/dirty-mice-help.md | 5 + packages/react/README.md | 212 ++++++++++++++++++ packages/react/eslint.config.mjs | 13 ++ packages/react/package.json | 52 +++++ .../src/components/penumbra-provider.tsx | 167 ++++++++++++++ .../react/src/hooks/use-penumbra-service.ts | 19 ++ .../react/src/hooks/use-penumbra-transport.ts | 75 +++++++ packages/react/src/hooks/use-penumbra.ts | 4 + packages/react/src/index.ts | 2 + packages/react/src/manifest.ts | 40 ++++ packages/react/src/penumbra-context.ts | 16 ++ packages/react/src/util.ts | 27 +++ packages/react/tsconfig.json | 12 + pnpm-lock.yaml | 25 +++ 14 files changed, 669 insertions(+) create mode 100644 .changeset/dirty-mice-help.md create mode 100644 packages/react/README.md create mode 100644 packages/react/eslint.config.mjs create mode 100644 packages/react/package.json create mode 100644 packages/react/src/components/penumbra-provider.tsx create mode 100644 packages/react/src/hooks/use-penumbra-service.ts create mode 100644 packages/react/src/hooks/use-penumbra-transport.ts create mode 100644 packages/react/src/hooks/use-penumbra.ts create mode 100644 packages/react/src/index.ts create mode 100644 packages/react/src/manifest.ts create mode 100644 packages/react/src/penumbra-context.ts create mode 100644 packages/react/src/util.ts create mode 100644 packages/react/tsconfig.json diff --git a/.changeset/dirty-mice-help.md b/.changeset/dirty-mice-help.md new file mode 100644 index 0000000000..47339b8e58 --- /dev/null +++ b/.changeset/dirty-mice-help.md @@ -0,0 +1,5 @@ +--- +'@penumbra-zone/react': major +--- + +initial react wallet diff --git a/packages/react/README.md b/packages/react/README.md new file mode 100644 index 0000000000..4fabdd06ad --- /dev/null +++ b/packages/react/README.md @@ -0,0 +1,212 @@ +# `@penumbra-zone/react` + +This package contains a React context provider and some simple hooks for using +the page API described in `@penumbra-zone/client`. You might want to use this if +you're writing a Penumbra dapp in React. + +**To use this package, you need to [enable the Buf Schema Registry](https://buf.build/docs/bsr/generated-sdks/npm):** + +```sh +npm config set @buf:registry https://buf.build/gen/npm/v1 +``` + +## Overview + +You must independently identify a Penumbra extension to which your app wishes to +connect. + +Then, use of `` with an `origin` prop identifying your +preferred extension, or `injection` prop identifying the actual page injection +from your preferred extension, will result in automatic progress towards a +successful connection. + +Hooks `usePenumbraTransport` and `usePenumbraService` will promise a transport +or client that inits when the configured provider becomes connected, or rejects +with a failure before connection. + +Hooks `usePenumbraTransportSync` or `usePenumbraServiceSync` will +unconditionally provide a transport or client to the Penumbra extension that +queues requests while connection is pending, and begins returning responses when +appropriate. If the provider fails to connect, requests via the transport or +client may time out. + +## `` + +This wrapping component will provide a context available to all child components +that is directly accessible by `usePenumbra`, or additionally by +`usePenumbraTransport` or `usePenumbraService`. + +### Unary requests may use `@connectrpc/connect-query` + +If you'd like to use `@connectrpc/connect-query`, you may call +`usePenumbraTransport` to satisfy ``. + +Be aware that connect query only supports unary requests at the moment (no +streaming). + +A wrapping component: + +```tsx +import { Outlet } from 'react-router-dom'; +import { PenumbraProvider } from '@penumbra-zone/react'; +import { usePenumbraTransportSync } from '@penumbra-zone/react/hooks/use-penumbra-transport'; +import { TransportProvider } from '@connectrpc/connect-query'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; + +const praxOrigin = 'chrome-extension://lkpmkhpnhknhmibgnmmhdhgdilepfghe'; +const queryClient = new QueryClient(); + +export const PenumbraDappPage = () => ( + + + + + + + +); +``` + +A querying component: + +```tsx +import { addressByIndex } from '@buf/penumbra-zone_penumbra.connectrpc_query-es/penumbra/view/v1/view-ViewService_connectquery'; +import { useQuery } from '@connectrpc/connect-query'; +import { bech32mAddress } from '@penumbra-zone/bech32m/penumbra'; + +export const PraxAddress = ({ account }: { account?: number }) => { + const { data } = useQuery(addressByIndex, { addressIndex: { account } }); + return data?.address && bech32mAddress(data.address); +}; +``` + +### Streaming requests must directly use a `PromiseClient` + +If you'd like to make streaming queries, or you just want to manage queries +yourself, you can call `usePenumbraService` with the `ServiceType` you're +interested in to acquire a `PromiseClient` of that service. A simplistic example +is below. + +Some streaming queries may return large amounts of data, or stream updates +continuosuly until aborted. For a good user experience with those queries, you +may need more complex query and state management. + +```tsx +import { AssetId } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/asset/v1/asset_pb.js'; +import { BalancesResponse } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/view/v1/view_pb.js'; +import { usePenumbraServiceSync } from '@penumbra-zone/react/hooks/use-penumbra-service'; +import { ViewService } from '@penumbra-zone/protobuf'; +import { useQuery } from '@tanstack/react-query'; +import { AccountBalancesTable } from './imaginary-components'; + +export default function AssetBalancesByAccount({ assetIdFilter }: { assetIdFilter: AssetId }) { + const viewClient = usePenumbraServiceSync(ViewService); + + const { isPending, data: groupedBalances } = useQuery({ + queryKey: ['balances', assetIdFilter.inner], + + queryFn: ({ signal }): Promise => + // wait for stream to collect + Array.fromAsync(viewClient.balances({ assetIdFilter }, { signal })), + + select: (data: BalancesResponse[]) => + Map.groupBy( + // filter undefined + data.filter(({ balanceView, accountAddress }) => accountAddress?.addressView?.value), + // group by account + ({ accountAddress }) => accountAddress.addressView.value.index, + ), + }); + + if (isPending) return ; + if (groupedBalances) + return Array.from(groupedBalances.entries()).map(([accountIndex, balanceResponses]) => ( + + )); +} +``` + +## Possible provider states + +On the bare Penumbra injection, there is only a boolean/undefined +`isConnected()` state and a few simple actions available. It is generally robust +and should asynchronously progress towards an active connection if possible, +even if steps are performed 'out-of-order'. + +This package's exported `` component handles this state and +all of these transitions for you. Use of `` with an `origin` +or `injection` prop will result in automatic progress towards a `Connected` +state. + +During this progress, the context exposes an explicit status, so you may easily +condition your layout and display. You can access this status via +`usePenumbra().state`. All possible values are represented by the exported enum +`PenumbraProviderState`. + +Hooks `usePenumbraTransport` and `usePenumbraService` conceal this state, and +unconditionally provide a transport or client. + +`Connected` is the only state in which a `MessagePort`, working `Transport`, or +working client is available. + +### State chart + +This flowchart reads from top (page load) to bottom (page unload). Each labelled +chart node is a possible value of `PenumbraProviderState`. Diamond-shaped nodes +are conditions described by the surrounding path labels. + +There are more possible transitions than diagrammed here - for instance once +methods are exposed, a `disconnect()` call will always transition directly into +a `Disconnected` state. A developer not using this wrapper, calling methods +directly, may enjoy failures at any moment. This diagram only represents a +typical state flow. + +The far right side path is the "happy path". + +```mermaid +stateDiagram-v2 + classDef GoodNode fill:chartreuse + classDef BadNode fill:salmon + classDef PossibleNode fill:thistle + + state global_exists <> + state manifest_present <> + state make_request <> + + + [*] --> global_exists: window[Symbol.for('penumbra')][validOrigin] + global_exists --> [*]: undefined + + Failed:::BadNode --> [*]: p.failure + Disconnected --> [*] + Connected:::GoodNode --> [*] + + manifest_present --> Failed + RequestPending --> Failed + ConnectPending --> Failed + + global_exists --> manifest_present: fetch(p.manifest) + manifest_present --> Present: json + + Present:::PossibleNode --> make_request: makeApprovalRequest + + make_request --> RequestPending: p.request() + RequestPending:::PossibleNode --> Requested + Requested:::PossibleNode --> ConnectPending: p.connect() + + + make_request --> ConnectPending: p.connect() + ConnectPending:::PossibleNode --> Connected:::PossibleNode + + Connected --> Disconnected: p.disconnect() + + note left of Present + Methods on the injection may + be called after this point. + end note + + note left of Connected + Port is acquired and + transports become active. + end note +``` diff --git a/packages/react/eslint.config.mjs b/packages/react/eslint.config.mjs new file mode 100644 index 0000000000..11c6ce9137 --- /dev/null +++ b/packages/react/eslint.config.mjs @@ -0,0 +1,13 @@ +import { penumbraEslintConfig } from '@repo/eslint-config'; +import { config, parser } from 'typescript-eslint'; + +export default config({ + ...penumbraEslintConfig, + languageOptions: { + parser, + parserOptions: { + project: true, + tsconfigRootDir: import.meta.dirname, + }, + }, +}); diff --git a/packages/react/package.json b/packages/react/package.json new file mode 100644 index 0000000000..e08356da10 --- /dev/null +++ b/packages/react/package.json @@ -0,0 +1,52 @@ +{ + "name": "@penumbra-zone/react", + "version": "0.0.1", + "license": "(MIT OR Apache-2.0)", + "description": "Reactive package for connecting to any Penumbra extension, including Prax.", + "type": "module", + "scripts": { + "build": "tsc --build --verbose", + "clean": "rm -rfv dist *.tsbuildinfo package penumbra-zone-*.tgz", + "dev:pack": "tsc-watch --onSuccess \"$npm_execpath pack\"", + "lint": "eslint src" + }, + "files": [ + "dist" + ], + "exports": { + ".": "./src/index.ts", + "./components/*": "./src/components/*.tsx", + "./hooks/*": "./src/hooks/*.ts" + }, + "publishConfig": { + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./components/*": { + "types": "./dist/components/*.d.ts", + "default": "./dist/components/*.js" + }, + "./hooks/*": { + "types": "./dist/hooks/*.d.ts", + "default": "./dist/hooks/*.js" + } + } + }, + "dependencies": { + "@penumbra-zone/client": "workspace:*", + "@penumbra-zone/protobuf": "workspace:*", + "@penumbra-zone/transport-dom": "workspace:*" + }, + "devDependencies": { + "@connectrpc/connect": "^1.4.0", + "@types/react": "^18.3.2", + "react": "^18.3.1" + }, + "peerDependencies": { + "@bufbuild/protobuf": "^1.10.0", + "@connectrpc/connect": "^1.4.0", + "react": "^18.3.1" + } +} diff --git a/packages/react/src/components/penumbra-provider.tsx b/packages/react/src/components/penumbra-provider.tsx new file mode 100644 index 0000000000..b549a454a3 --- /dev/null +++ b/packages/react/src/components/penumbra-provider.tsx @@ -0,0 +1,167 @@ +import { PenumbraInjection, PenumbraInjectionState } from '@penumbra-zone/client'; +import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'; +import { PenumbraManifest } from '../manifest'; +import { PenumbraContext, penumbraContext } from '../penumbra-context'; +import { assertManifestOrigin, injectionOfKey, keyOfInjection } from '../util'; + +type PenumbraProviderProps = { + children?: ReactNode; + injection?: PenumbraInjection; + origin?: string; + makeApprovalRequest?: boolean; +} & ({ injection: PenumbraInjection } | { origin: string }); + +export const PenumbraProvider = ({ + children, + origin: providerOrigin, + injection: providerInjection, + makeApprovalRequest = false, +}: PenumbraProviderProps) => { + providerOrigin ??= keyOfInjection(providerInjection); + providerInjection ??= injectionOfKey(providerOrigin); + + const [providerState, setProviderState] = useState(providerInjection?.state()); + const [providerConnected, setProviderConnected] = useState(providerInjection?.isConnected()); + const updateProviderState = useCallback(() => { + // skip uninitialized state + if ( + providerState === undefined && + providerConnected === undefined && + providerInjection === undefined + ) + return; + + // skip final states + if ( + providerConnected === false && + (providerState === PenumbraInjectionState.Failed || + providerState === PenumbraInjectionState.Disconnected) + ) + return; + + setProviderState(providerInjection?.state()); + setProviderConnected(providerInjection?.isConnected()); + }, [providerInjection, providerState, providerConnected, setProviderState, setProviderConnected]); + + const [failure, setFailureError] = useState(); + const setFailureUnknown = useCallback( + (cause: unknown) => { + if (failure) + console.error('Not replacing existing PenumbraProvider failure', { failure, cause }); + else + setFailureError(cause instanceof Error ? cause : new Error('Unknown failure', { cause })); + }, + [failure, setFailureError], + ); + + const [providerPort, setProviderPort] = useState(); + const [manifest, setManifest] = useState(); + + const createdContext: PenumbraContext = useMemo( + () => ({ + failure, + manifest, + origin: providerOrigin, + + // require manifest to forward state + state: manifest && providerState, + + // require manifest and no failures to forward injected methods + ...(manifest && !failure + ? { + port: providerConnected && providerPort, + connect: providerInjection?.connect, + request: providerInjection?.request, + disconnect: providerInjection?.disconnect, + } + : {}), + }), + [ + failure, + manifest, + providerPort, + providerInjection?.connect, + providerInjection?.connect, + providerInjection?.disconnect, + providerOrigin, + providerState, + ], + ); + + useEffect(() => updateProviderState()); + + // fetch manifest to confirm presence of provider + useEffect(() => { + // require provider + if (!providerOrigin || !providerInjection) return; + // don't repeat + if (manifest) return; + // unnecessary if failed + if (failure) return; + + // sync assertion + try { + assertManifestOrigin(providerOrigin, providerInjection); + } catch (cause) { + setFailureUnknown(cause); + return; + } + + // async fetch + const ac = new AbortController(); + void fetch(providerInjection.manifest, { signal: ac.signal }) + .then( + async res => { + // this cast is fairly safe coming from an extension manifest, where + // schema is enforced by chrome store. + const manifestJson = (await res.json()) as PenumbraManifest; + setManifest(manifestJson); + }, + (noAbortError: unknown) => { + // abort is not a failure + if (noAbortError instanceof Error && noAbortError.name === 'AbortError') return; + else throw noAbortError; + }, + ) + .catch(setFailureUnknown); + + // useEffect cleanup + return () => ac.abort(); + }, [providerOrigin, providerInjection, manifest, setManifest]); + + // request effect + useEffect(() => { + if (!manifest || failure) return; + switch (providerState) { + case PenumbraInjectionState.Present: + if (makeApprovalRequest) void providerInjection?.request().catch(setFailureUnknown); + break; + default: + break; + } + }, [makeApprovalRequest, providerState, providerInjection?.request, manifest, failure]); + + // connect effect + useEffect(() => { + if (!manifest || failure) return; + switch (providerState) { + case PenumbraInjectionState.Present: + if (!makeApprovalRequest) + void providerInjection + ?.connect() + .then(p => setProviderPort(p)) + .catch(setFailureUnknown); + break; + case PenumbraInjectionState.Requested: + void providerInjection + ?.connect() + .then(p => setProviderPort(p)) + .catch(setFailureUnknown); + break; + default: + break; + } + }, [makeApprovalRequest, providerState, providerInjection?.connect, manifest, failure]); + + return {children}; +}; diff --git a/packages/react/src/hooks/use-penumbra-service.ts b/packages/react/src/hooks/use-penumbra-service.ts new file mode 100644 index 0000000000..35b4265cae --- /dev/null +++ b/packages/react/src/hooks/use-penumbra-service.ts @@ -0,0 +1,19 @@ +import { createPromiseClient, PromiseClient } from '@connectrpc/connect'; +import { PenumbraService } from '@penumbra-zone/protobuf'; +import { useMemo } from 'react'; +import { usePenumbraTransport, usePenumbraTransportSync } from './use-penumbra-transport.js'; + +export const usePenumbraServiceSync = (service: S): PromiseClient => { + const transport = usePenumbraTransportSync(); + return useMemo(() => createPromiseClient(service, transport), [service, transport]); +}; + +export const usePenumbraService = ( + service: S, +): Promise> => { + const transportPromise = usePenumbraTransport(); + return useMemo( + () => transportPromise.then(transport => createPromiseClient(service, transport)), + [service, transportPromise], + ); +}; diff --git a/packages/react/src/hooks/use-penumbra-transport.ts b/packages/react/src/hooks/use-penumbra-transport.ts new file mode 100644 index 0000000000..ee03b3ab20 --- /dev/null +++ b/packages/react/src/hooks/use-penumbra-transport.ts @@ -0,0 +1,75 @@ +import { + type ChannelTransportOptions, + createChannelTransport, +} from '@penumbra-zone/transport-dom/create'; +import { useEffect, useMemo, useState } from 'react'; +import { usePenumbra } from './use-penumbra.js'; +import { PenumbraInjectionState } from '@penumbra-zone/client'; + +/** Unconditionally returns a Transport to the provided Penumbra context. This + * transport will always create synchronously, but may reject all requests if + * the Penumbra context does not provide a port within your configured + * defaultTimeoutMs (defaults to 10 seconds). */ +export const usePenumbraTransportSync = (opts?: Omit) => { + const penumbra = usePenumbra(); + const { port, failure, state } = penumbra; + + // use a local promise to avoid re-rendering when the port is set + const [{ resolve: resolvePort, reject: rejectPort, promise: portPromise }] = useState( + Promise.withResolvers(), + ); + + // memoize the transport to avoid re-creating it on every render + const transport = useMemo( + () => createChannelTransport({ ...opts, getPort: () => portPromise }), + [portPromise], + ); + + // handle context updates + useEffect(() => { + if (port) { + resolvePort(port); + } else if (failure) { + rejectPort(failure); + } + }, [failure, penumbra, port, resolvePort, rejectPort, state]); + + return transport; +}; + +/** Promises a Transport to the provided Penumbra context. Awaits confirmation + * of a MessagePort to the provider in context before attempting to create the + * Transport, so this will not time out if approval takes very long - but it + * must be async. The returned promise may reject with a connection failure. */ +export const usePenumbraTransport = (opts?: Omit) => { + const penumbra = usePenumbra(); + const { port, failure, state } = penumbra; + + // use a local promise to avoid re-rendering when the port is set + const [{ resolve: resolvePort, reject: rejectPort, promise: portPromise }] = useState( + Promise.withResolvers(), + ); + + // memoize the transport to avoid re-creating it on every render + const transportPromise = useMemo( + () => portPromise.then(() => createChannelTransport({ ...opts, getPort: () => portPromise })), + [portPromise], + ); + + // handle context updates + useEffect(() => { + if (port) { + resolvePort(port); + } else if (failure ?? state === PenumbraInjectionState.Failed) { + rejectPort(failure ?? new Error('Unknown failure')); + } + }, [failure, penumbra, port, resolvePort, rejectPort, state]); + + switch (state) { + case PenumbraInjectionState.Disconnected: + case PenumbraInjectionState.Failed: + return Promise.reject(failure ?? new Error(state)); + default: + return transportPromise; + } +}; diff --git a/packages/react/src/hooks/use-penumbra.ts b/packages/react/src/hooks/use-penumbra.ts new file mode 100644 index 0000000000..06ab4e0802 --- /dev/null +++ b/packages/react/src/hooks/use-penumbra.ts @@ -0,0 +1,4 @@ +import { useContext } from 'react'; +import { penumbraContext } from '../penumbra-context.js'; + +export const usePenumbra = () => useContext(penumbraContext); diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts new file mode 100644 index 0000000000..7b1d5b069a --- /dev/null +++ b/packages/react/src/index.ts @@ -0,0 +1,2 @@ +export { usePenumbra } from './hooks/use-penumbra.js'; +export { PenumbraProvider } from './components/penumbra-provider.js'; diff --git a/packages/react/src/manifest.ts b/packages/react/src/manifest.ts new file mode 100644 index 0000000000..35c3a07ae0 --- /dev/null +++ b/packages/react/src/manifest.ts @@ -0,0 +1,40 @@ +/** Currently, Penumbra manifests are chrome extension manifest v3. There's no type + * guard because manifest format is enforced by chrome. This type only describes + * fields we're interested in. + * + * @see https://developer.chrome.com/docs/extensions/reference/manifest#keys + */ +export interface PenumbraManifest { + /** + * manifest id is present in production, but generally not in dev, because + * they are inserted by chrome store tooling. crx id are simple hashes of the + * 'key' field, an extension-specific public key. + * + * developers may configure a public key in dev, and the extension id will + * match appropriately, but will not be present in the manifest. + * + * the extension id is also part of the extension's origin URI. + * + * @see https://developer.chrome.com/docs/extensions/reference/manifest/key + * @see https://web.archive.org/web/20120606044635/http://supercollider.dk/2010/01/calculating-chrome-extension-id-from-your-private-key-233 + */ + id?: string; + key?: string; + + // these are required + name: string; + version: string; + description: string; + + // these are optional + homepage_url?: string; + options_ui?: { page: string }; + options_page?: string; + + // icons are not indexed by number, but by a stringified number. they may be + // any square size but the power-of-two sizes are typical. the chrome store + // requires a '128' icon. + icons: Record<`${number}`, string> & { + ['128']: string; + }; +} diff --git a/packages/react/src/penumbra-context.ts b/packages/react/src/penumbra-context.ts new file mode 100644 index 0000000000..5955f5cf68 --- /dev/null +++ b/packages/react/src/penumbra-context.ts @@ -0,0 +1,16 @@ +import { createContext } from 'react'; +import { PenumbraInjectionState, PenumbraSymbol } from '@penumbra-zone/client'; +import type { PenumbraManifest } from './manifest.js'; + +const penumbraGlobal = window[PenumbraSymbol]; + +export interface PenumbraContext { + origin?: keyof NonNullable; + manifest?: PenumbraManifest; + disconnect?: () => Promise; + port?: MessagePort | false; + failure?: Error; + state?: PenumbraInjectionState; +} + +export const penumbraContext = createContext({}); diff --git a/packages/react/src/util.ts b/packages/react/src/util.ts new file mode 100644 index 0000000000..e45e24bf74 --- /dev/null +++ b/packages/react/src/util.ts @@ -0,0 +1,27 @@ +import { PenumbraInjection, PenumbraSymbol } from '@penumbra-zone/client'; + +export const keyOfInjection = (injection?: PenumbraInjection) => + Object.entries(window[PenumbraSymbol] ?? {}).find( + ([keyOrigin, valueInjection]) => + keyOrigin && + // matching injection + valueInjection === injection, + )?.[0]; + +export const injectionOfKey = (keyOrigin?: string) => + keyOrigin ? window[PenumbraSymbol]?.[keyOrigin] : undefined; + +export const assertStringIsOrigin = (s?: string) => { + if (!s || new URL(s).origin !== s) { + throw new TypeError('Invalid origin'); + } + return s; +}; + +export const assertManifestOrigin = (s?: string, injection?: PenumbraInjection) => { + const originString = assertStringIsOrigin(s); + if (!injection?.manifest || new URL(injection.manifest).origin !== originString) { + throw new TypeError('Invalid manifest origin'); + } + return [originString, injection] satisfies [string, PenumbraInjection]; +}; diff --git a/packages/react/tsconfig.json b/packages/react/tsconfig.json new file mode 100644 index 0000000000..885e36dc3c --- /dev/null +++ b/packages/react/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "exactOptionalPropertyTypes": false, + "composite": true, + "jsx": "react-jsx", + "module": "Node16", + "noEmit": true, + "target": "ESNext" + }, + "extends": "@tsconfig/strictest/tsconfig.json", + "include": ["src"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5d9fa622d2..a3e5ec1004 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -490,6 +490,31 @@ importers: specifier: workspace:* version: link:../wasm + packages/react: + dependencies: + '@bufbuild/protobuf': + specifier: ^1.10.0 + version: 1.10.0 + '@penumbra-zone/client': + specifier: workspace:* + version: link:../client + '@penumbra-zone/protobuf': + specifier: workspace:* + version: link:../protobuf + '@penumbra-zone/transport-dom': + specifier: workspace:* + version: link:../transport-dom + devDependencies: + '@connectrpc/connect': + specifier: ^1.4.0 + version: 1.4.0(@bufbuild/protobuf@1.10.0) + '@types/react': + specifier: ^18.3.2 + version: 18.3.3 + react: + specifier: ^18.3.1 + version: 18.3.1 + packages/services: devDependencies: '@buf/penumbra-zone_penumbra.bufbuild_es': From fa7af46d28a5c91dbda2adf7fea81a8bf54753ee Mon Sep 17 00:00:00 2001 From: turbocrime Date: Wed, 10 Jul 2024 18:28:15 -0700 Subject: [PATCH 2/5] provide more specific interface, type guards from client package --- .changeset/tidy-spiders-hang.md | 5 ++++ packages/client/src/event.ts | 42 +++++++++++++++++++++++++++++++-- packages/client/src/index.ts | 15 ++++++++---- 3 files changed, 55 insertions(+), 7 deletions(-) create mode 100644 .changeset/tidy-spiders-hang.md diff --git a/.changeset/tidy-spiders-hang.md b/.changeset/tidy-spiders-hang.md new file mode 100644 index 0000000000..5a6864e106 --- /dev/null +++ b/.changeset/tidy-spiders-hang.md @@ -0,0 +1,5 @@ +--- +'@penumbra-zone/client': minor +--- + +provide type guards for event, and better types for event interface diff --git a/packages/client/src/event.ts b/packages/client/src/event.ts index 1501b72f29..fdf19e066a 100644 --- a/packages/client/src/event.ts +++ b/packages/client/src/event.ts @@ -1,9 +1,11 @@ import { PenumbraInjectionState, PenumbraSymbol } from './index.js'; -export class PenumbraInjectionStateEvent extends CustomEvent<{ +export interface PenumbraInjectionStateEventDetail { origin: string; state?: PenumbraInjectionState; -}> { +} + +export class PenumbraInjectionStateEvent extends CustomEvent { constructor(injectionProviderOrigin: string, injectionState?: PenumbraInjectionState) { super('penumbrastate', { detail: { @@ -13,3 +15,39 @@ export class PenumbraInjectionStateEvent extends CustomEvent<{ }); } } + +export const isPenumbraInjectionStateEvent = (evt: Event): evt is PenumbraInjectionStateEvent => + evt instanceof PenumbraInjectionStateEvent || + ('detail' in evt && isPenumbraInjectionStateEventDetail(evt.detail)); + +export const isPenumbraInjectionStateEventDetail = ( + detail: unknown, +): detail is PenumbraInjectionStateEventDetail => + typeof detail === 'object' && + detail !== null && + 'origin' in detail && + typeof detail.origin === 'string'; + +// utility type for SpecificEventTarget. any and unused are required for type inference +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type ParametersTail any> = + Parameters extends [unknown, ...infer TailParams] ? TailParams : never; + +// like EventTarget, but restricts possible event types +interface SpecificEventTarget + extends EventTarget { + addEventListener: ( + type: SpecificTypeName, + ...rest: ParametersTail + ) => void; + removeEventListener: ( + type: SpecificTypeName, + ...rest: ParametersTail + ) => void; + dispatchEvent: (event: SpecificEvent) => boolean; +} + +export type PenumbraInjectionStateEventTarget = Omit< + SpecificEventTarget<'penumbrastate', never>, + 'dispatchEvent' +>; diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index e49e4874c5..920601637f 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -1,3 +1,5 @@ +import { PenumbraInjectionStateEventTarget } from './event.js'; + export * from './error.js'; export * from './event.js'; @@ -38,7 +40,7 @@ export const PenumbraSymbol = Symbol.for('penumbra'); * use the helpers available in `@penumbra-zone/client/create`. * */ -export interface PenumbraInjection { +export interface PenumbraInjection extends Readonly { /** Should contain a URI at the provider's origin, serving a manifest * describing this provider. */ readonly manifest: string; @@ -63,11 +65,14 @@ export interface PenumbraInjection { /** Synchronously return present injection state. */ readonly state: () => PenumbraInjectionState; - /** Emits `PenubraInjectionStateEvent` when state changes. Listen for + /** Like a normal EventTarget.addEventListener, but should only emit + * `PenubraInjectionStateEvent` when state changes. Listen for * `'penumbrastate'` events, and check the `detail` field for a - * `PenumbraInjectionState` value. */ - readonly addEventListener: EventTarget['addEventListener']; - readonly removeEventListener: EventTarget['removeEventListener']; + * `PenumbraInjectionState` value. + * @see https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener + */ + readonly addEventListener: PenumbraInjectionStateEventTarget['addEventListener']; + readonly removeEventListener: PenumbraInjectionStateEventTarget['addEventListener']; } export enum PenumbraInjectionState { From bfeadf06e2914e84de298a735bb824f0ad9c88b9 Mon Sep 17 00:00:00 2001 From: turbocrime Date: Thu, 11 Jul 2024 22:33:28 -0700 Subject: [PATCH 3/5] use listener --- .../src/components/penumbra-provider.tsx | 201 +++++++++++------- 1 file changed, 119 insertions(+), 82 deletions(-) diff --git a/packages/react/src/components/penumbra-provider.tsx b/packages/react/src/components/penumbra-provider.tsx index b549a454a3..9839ba6b8e 100644 --- a/packages/react/src/components/penumbra-provider.tsx +++ b/packages/react/src/components/penumbra-provider.tsx @@ -1,8 +1,12 @@ -import { PenumbraInjection, PenumbraInjectionState } from '@penumbra-zone/client'; +import { + isPenumbraInjectionStateEvent, + PenumbraInjection, + PenumbraInjectionState, +} from '@penumbra-zone/client'; import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'; -import { PenumbraManifest } from '../manifest'; -import { PenumbraContext, penumbraContext } from '../penumbra-context'; -import { assertManifestOrigin, injectionOfKey, keyOfInjection } from '../util'; +import { PenumbraManifest } from '../manifest.js'; +import { PenumbraContext, penumbraContext } from '../penumbra-context.js'; +import { assertManifestOrigin, injectionOfKey, keyOfInjection } from '../util.js'; type PenumbraProviderProps = { children?: ReactNode; @@ -22,82 +26,67 @@ export const PenumbraProvider = ({ const [providerState, setProviderState] = useState(providerInjection?.state()); const [providerConnected, setProviderConnected] = useState(providerInjection?.isConnected()); - const updateProviderState = useCallback(() => { - // skip uninitialized state - if ( - providerState === undefined && - providerConnected === undefined && - providerInjection === undefined - ) - return; - - // skip final states - if ( - providerConnected === false && - (providerState === PenumbraInjectionState.Failed || - providerState === PenumbraInjectionState.Disconnected) - ) - return; - - setProviderState(providerInjection?.state()); - setProviderConnected(providerInjection?.isConnected()); - }, [providerInjection, providerState, providerConnected, setProviderState, setProviderConnected]); const [failure, setFailureError] = useState(); const setFailureUnknown = useCallback( - (cause: unknown) => { - if (failure) - console.error('Not replacing existing PenumbraProvider failure', { failure, cause }); - else - setFailureError(cause instanceof Error ? cause : new Error('Unknown failure', { cause })); - }, + (cause: unknown) => + failure + ? console.error('Not replacing existing PenumbraProvider failure', { failure, cause }) + : setFailureError(cause instanceof Error ? cause : new Error('Unknown failure', { cause })), [failure, setFailureError], ); const [providerPort, setProviderPort] = useState(); const [manifest, setManifest] = useState(); - const createdContext: PenumbraContext = useMemo( - () => ({ - failure, - manifest, - origin: providerOrigin, + // force destruction of provider on failure + useEffect(() => { + if (failure) { + setProviderState(PenumbraInjectionState.Failed); + setProviderConnected(false); + setProviderPort(undefined); + } + }, [failure]); - // require manifest to forward state - state: manifest && providerState, + // attach state event listener + useEffect(() => { + // require manifest, no failures + if (!manifest || failure) { + return; + } - // require manifest and no failures to forward injected methods - ...(manifest && !failure - ? { - port: providerConnected && providerPort, - connect: providerInjection?.connect, - request: providerInjection?.request, - disconnect: providerInjection?.disconnect, - } - : {}), - }), - [ - failure, - manifest, - providerPort, - providerInjection?.connect, - providerInjection?.connect, - providerInjection?.disconnect, - providerOrigin, - providerState, - ], - ); + const listener = (evt: Event) => { + if (isPenumbraInjectionStateEvent(evt)) { + if (!providerInjection) { + setFailureError(new Error('State change event without injection')); + } else if (evt.detail.origin !== providerOrigin) { + setFailureError(new Error('State change from unexpected origin')); + } else if (evt.detail.state !== providerInjection.state()) { + console.warn('State change not verifiable'); + } else { + setProviderState(providerInjection.state()); + setProviderConnected(providerInjection.isConnected()); + } + } + }; - useEffect(() => updateProviderState()); + const ac = new AbortController(); + providerInjection?.addEventListener('penumbrastate', listener, { + signal: ac.signal, + }); + return () => ac.abort(); + }, [providerInjection, providerInjection?.addEventListener, manifest, failure]); // fetch manifest to confirm presence of provider useEffect(() => { // require provider - if (!providerOrigin || !providerInjection) return; - // don't repeat - if (manifest) return; - // unnecessary if failed - if (failure) return; + if (!providerOrigin || !providerInjection) { + return; + } + // don't repeat, unnecessary if failed + if (!!manifest || failure) { + return; + } // sync assertion try { @@ -107,34 +96,46 @@ export const PenumbraProvider = ({ return; } - // async fetch + // abortable fetch const ac = new AbortController(); - void fetch(providerInjection.manifest, { signal: ac.signal }) - .then( - async res => { + const fetchManifest = fetch(providerInjection.manifest, { signal: ac.signal }).catch( + (noAbortError: unknown) => { + // abort is not a failure + if (noAbortError instanceof Error && noAbortError.name === 'AbortError') { + return; + } else { + throw noAbortError; + } + }, + ); + + // async handle response + void fetchManifest + .then(async res => { + const manifestJson: unknown = await res?.json(); + if (manifestJson) { // this cast is fairly safe coming from an extension manifest, where // schema is enforced by chrome store. - const manifestJson = (await res.json()) as PenumbraManifest; - setManifest(manifestJson); - }, - (noAbortError: unknown) => { - // abort is not a failure - if (noAbortError instanceof Error && noAbortError.name === 'AbortError') return; - else throw noAbortError; - }, - ) + setManifest(manifestJson as PenumbraManifest); + } + }) .catch(setFailureUnknown); - // useEffect cleanup return () => ac.abort(); }, [providerOrigin, providerInjection, manifest, setManifest]); // request effect useEffect(() => { - if (!manifest || failure) return; + // require manifest, no failures + if (!manifest || failure) { + return; + } + switch (providerState) { case PenumbraInjectionState.Present: - if (makeApprovalRequest) void providerInjection?.request().catch(setFailureUnknown); + if (makeApprovalRequest) { + void providerInjection?.request().catch(setFailureUnknown); + } break; default: break; @@ -143,14 +144,19 @@ export const PenumbraProvider = ({ // connect effect useEffect(() => { - if (!manifest || failure) return; + // require manifest, no failures + if (!manifest || failure) { + return; + } + switch (providerState) { case PenumbraInjectionState.Present: - if (!makeApprovalRequest) + if (!makeApprovalRequest) { void providerInjection ?.connect() .then(p => setProviderPort(p)) .catch(setFailureUnknown); + } break; case PenumbraInjectionState.Requested: void providerInjection @@ -163,5 +169,36 @@ export const PenumbraProvider = ({ } }, [makeApprovalRequest, providerState, providerInjection?.connect, manifest, failure]); + const createdContext: PenumbraContext = useMemo( + () => ({ + failure, + manifest, + origin: providerOrigin, + + // require manifest to forward state + state: manifest && providerState, + + // require manifest and no failures to forward injected methods + ...(manifest && !failure + ? { + port: providerConnected && providerPort, + connect: providerInjection?.connect, + request: providerInjection?.request, + disconnect: providerInjection?.disconnect, + } + : {}), + }), + [ + failure, + manifest, + providerPort, + providerInjection?.connect, + providerInjection?.connect, + providerInjection?.disconnect, + providerOrigin, + providerState, + ], + ); + return {children}; }; From 47067e0b273873d06e456c64711e08bbcf884d7c Mon Sep 17 00:00:00 2001 From: turbocrime Date: Mon, 15 Jul 2024 00:48:49 -0700 Subject: [PATCH 4/5] review changes --- packages/client/package.json | 8 +- packages/client/src/assert.ts | 82 +++++++ packages/client/src/create.ts | 82 +------ packages/client/src/event.ts | 28 ++- packages/client/src/index.ts | 138 ++++-------- packages/{react => client}/src/manifest.ts | 23 +- packages/client/src/provider.ts | 73 +++++++ packages/client/src/state.ts | 20 ++ packages/client/src/symbol.ts | 1 + packages/react/README.md | 42 ++-- packages/react/package.json | 2 +- .../components/penumbra-context-provider.tsx | 185 ++++++++++++++++ .../src/components/penumbra-provider.tsx | 204 ------------------ .../react/src/hooks/use-penumbra-service.ts | 14 +- .../react/src/hooks/use-penumbra-transport.ts | 49 +++-- packages/react/src/index.ts | 2 +- packages/react/src/penumbra-context.ts | 19 +- packages/react/src/util.ts | 27 --- pnpm-lock.yaml | 12 +- 19 files changed, 534 insertions(+), 477 deletions(-) create mode 100644 packages/client/src/assert.ts rename packages/{react => client}/src/manifest.ts (65%) create mode 100644 packages/client/src/provider.ts create mode 100644 packages/client/src/state.ts create mode 100644 packages/client/src/symbol.ts create mode 100644 packages/react/src/components/penumbra-context-provider.tsx delete mode 100644 packages/react/src/components/penumbra-provider.tsx delete mode 100644 packages/react/src/util.ts diff --git a/packages/client/package.json b/packages/client/package.json index 3840b219d1..5884e33725 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -20,7 +20,7 @@ ], "exports": { ".": "./src/index.ts", - "./create": "./src/create.ts" + "./*": "./src/*.ts" }, "publishConfig": { "exports": { @@ -28,9 +28,9 @@ "types": "./dist/index.d.ts", "default": "./dist/index.js" }, - "./create": { - "types": "./dist/create.d.ts", - "default": "./dist/create.js" + "./*": { + "types": "./dist/*.d.ts", + "default": "./dist/*.js" } } }, diff --git a/packages/client/src/assert.ts b/packages/client/src/assert.ts new file mode 100644 index 0000000000..072e5adf04 --- /dev/null +++ b/packages/client/src/assert.ts @@ -0,0 +1,82 @@ +import { + PenumbraNotInstalledError, + PenumbraProviderNotAvailableError, + PenumbraProviderNotConnectedError, +} from './error.js'; +import { PenumbraSymbol } from './symbol.js'; + +export const assertStringIsOrigin = (s?: string) => { + if (!s || new URL(s).origin !== s) { + throw new TypeError('Invalid origin'); + } + return s; +}; + +export const assertGlobalPresent = () => { + if (!window[PenumbraSymbol]) { + throw new PenumbraNotInstalledError(); + } + return window[PenumbraSymbol]; +}; + +/** + * Given a specific origin, identify the relevant injection or throw. An + * `undefined` origin is accepted but will throw. + */ +export const assertProviderRecord = (providerOrigin?: string) => { + const provider = providerOrigin && assertGlobalPresent()[assertStringIsOrigin(providerOrigin)]; + if (!provider) { + throw new PenumbraProviderNotAvailableError(providerOrigin); + } + return provider; +}; + +export const assertProvider = (providerOrigin?: string) => + assertProviderManifest(providerOrigin).then(() => assertProviderRecord(providerOrigin)); + +/** + * 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 = assertProviderRecord(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, signal?: AbortSignal) => { + // confirm the provider injection is present + const provider = assertProviderRecord(providerOrigin); + + let manifest: unknown; + + 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, { signal }); + manifest = await req.json(); + + if (!manifest) { + throw new Error(`Cannot confirm ${providerOrigin} is real.`); + } + } catch (e) { + if (signal?.aborted !== true) { + console.warn(e); + throw new PenumbraProviderNotAvailableError(providerOrigin); + } + } + + return manifest; +}; diff --git a/packages/client/src/create.ts b/packages/client/src/create.ts index cea8bb2d4a..c2301c04a5 100644 --- a/packages/client/src/create.ts +++ b/packages/client/src/create.ts @@ -4,80 +4,12 @@ import { createChannelTransport, type ChannelTransportOptions, } from '@penumbra-zone/transport-dom/create'; -import { PenumbraSymbol, type PenumbraInjection } from './index.js'; -import { - PenumbraNotInstalledError, - PenumbraProviderNotAvailableError, - PenumbraProviderNotConnectedError, -} from './error.js'; +import { assertProviderManifest, assertProviderRecord } from './assert.js'; +import { PenumbraSymbol } from './symbol.js'; // Naively return the first available provider origin, or `undefined`. const availableOrigin = () => Object.keys(window[PenumbraSymbol] ?? {})[0]; -export const assertGlobalPresent = () => { - if (!window[PenumbraSymbol]) { - throw new PenumbraNotInstalledError(); - } - return window[PenumbraSymbol]; -}; - -/** - * 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 && assertGlobalPresent()[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(`Cannot confirm ${providerOrigin} is real.`); - } - } catch (e) { - console.warn(e); - throw new PenumbraProviderNotAvailableError(providerOrigin); - } - - return provider; -}; - /** * Asynchronously get a connection to the specified provider, or the first * available provider if unspecified. @@ -88,7 +20,9 @@ export const assertProviderManifest = async ( * @param requireProvider optional string identifying a provider origin */ export const getPenumbraPort = async (requireProvider?: string) => { - const provider = await assertProviderManifest(requireProvider ?? availableOrigin()); + const penumbraOrigin = requireProvider ?? availableOrigin(); + await assertProviderManifest(penumbraOrigin); + const provider = assertProviderRecord(penumbraOrigin); if (!provider.isConnected()) { await provider.request(); } @@ -108,7 +42,7 @@ export const getPenumbraPort = async (requireProvider?: string) => { * @param requireProvider optional string identifying a provider origin * @param transportOptions optional `ChannelTransportOptions` without `getPort` */ -export const syncCreatePenumbraChannelTransport = ( +export const createPenumbraChannelTransportSync = ( requireProvider?: string, transportOptions: Omit = { jsonOptions }, ): Transport => @@ -140,10 +74,10 @@ export const createPenumbraChannelTransport = async ( * * If the provider is unavailable, the client will fail to make requests. */ -export const syncCreatePenumbraClient =

( +export const createPenumbraClientSync =

( service: P, requireProvider?: string, -) => createPromiseClient(service, syncCreatePenumbraChannelTransport(requireProvider)); +) => createPromiseClient(service, createPenumbraChannelTransportSync(requireProvider)); /** * Asynchronously create a client for `service` from the specified provider, or diff --git a/packages/client/src/event.ts b/packages/client/src/event.ts index fdf19e066a..9e8a6c7da9 100644 --- a/packages/client/src/event.ts +++ b/packages/client/src/event.ts @@ -1,34 +1,32 @@ -import { PenumbraInjectionState, PenumbraSymbol } from './index.js'; +import { PenumbraState } from './state.js'; +import { PenumbraSymbol } from './symbol.js'; -export interface PenumbraInjectionStateEventDetail { +export interface PenumbraStateEventDetail { origin: string; - state?: PenumbraInjectionState; + state?: PenumbraState; } -export class PenumbraInjectionStateEvent extends CustomEvent { - constructor(injectionProviderOrigin: string, injectionState?: PenumbraInjectionState) { +export class PenumbraStateEvent extends CustomEvent { + constructor(penumbraOrigin: string, penumbraState?: PenumbraState) { super('penumbrastate', { detail: { - state: injectionState ?? window[PenumbraSymbol]?.[injectionProviderOrigin]?.state(), - origin: injectionProviderOrigin, + origin: penumbraOrigin, + state: penumbraState ?? window[PenumbraSymbol]?.[penumbraOrigin]?.state(), }, }); } } -export const isPenumbraInjectionStateEvent = (evt: Event): evt is PenumbraInjectionStateEvent => - evt instanceof PenumbraInjectionStateEvent || - ('detail' in evt && isPenumbraInjectionStateEventDetail(evt.detail)); +export const isPenumbraStateEvent = (evt: Event): evt is PenumbraStateEvent => + evt instanceof PenumbraStateEvent || ('detail' in evt && isPenumbraStateEventDetail(evt.detail)); -export const isPenumbraInjectionStateEventDetail = ( - detail: unknown, -): detail is PenumbraInjectionStateEventDetail => +export const isPenumbraStateEventDetail = (detail: unknown): detail is PenumbraStateEventDetail => typeof detail === 'object' && detail !== null && 'origin' in detail && typeof detail.origin === 'string'; -// utility type for SpecificEventTarget. any and unused are required for type inference +// utility type for SpecificEventTarget. any is required for type inference // eslint-disable-next-line @typescript-eslint/no-explicit-any type ParametersTail any> = Parameters extends [unknown, ...infer TailParams] ? TailParams : never; @@ -47,7 +45,7 @@ interface SpecificEventTarget boolean; } -export type PenumbraInjectionStateEventTarget = Omit< +export type PenumbraStateEventTarget = Omit< SpecificEventTarget<'penumbrastate', never>, 'dispatchEvent' >; diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 920601637f..ad69ec377b 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -1,105 +1,47 @@ -import { PenumbraInjectionStateEventTarget } from './event.js'; - -export * from './error.js'; -export * from './event.js'; - -export const PenumbraSymbol = Symbol.for('penumbra'); - -/** - * This interface describes the simple API to request, connect, or disconnect a - * provider. These methods allow a page to acquire permission to connect, and - * obtain a `MessagePort` to be used for client creation. - * - * There are three connection 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 call may be pending, or no call has been made - * - * Each injection should also track state-changing actions, so calling - * `.state()` should provide more detail including currently pending state, - * enumerated by `PenumbraInjectionState`. - * - * Any script in page scope may create an object like this, so clients should - * confirm a provider is actually present. Presence can be securely verified by - * fetching the identified provider manifest from the provider's origin. - * - * Presently clients can expect the manifest is a chrome extension manifest v3. - * Provider details such as name, version, website, brief descriptive text, and - * icons should be available in the manifest. - * @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 extends Readonly { - /** Should contain a URI at the provider's origin, serving a manifest - * describing this provider. */ - readonly manifest: string; - - /** Call to acquire a `MessagePort` to this provider, subject to approval. */ - readonly connect: () => Promise; - - /** Call to gain approval. May reject with a `PenumbraProviderRequestError` - * containing an enumerated `PenumbraRequestFailure` cause. */ - readonly request: () => Promise; - - /** Call to indicate the provider should discard 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` no attempt has resolved. connection may be attempted. - */ - readonly isConnected: () => boolean | undefined; - - /** Synchronously return present injection state. */ - readonly state: () => PenumbraInjectionState; - - /** Like a normal EventTarget.addEventListener, but should only emit - * `PenubraInjectionStateEvent` when state changes. Listen for - * `'penumbrastate'` events, and check the `detail` field for a - * `PenumbraInjectionState` value. - * @see https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener - */ - readonly addEventListener: PenumbraInjectionStateEventTarget['addEventListener']; - readonly removeEventListener: PenumbraInjectionStateEventTarget['addEventListener']; -} - -export enum PenumbraInjectionState { - /* error is present */ - 'Failed' = 'Failed', - - /* no action has been taken */ - 'Present' = 'Present', - - /* approval request pending */ - 'RequestPending' = 'RequestPending', - /* request for approval satisfied */ - 'Requested' = 'Requested', - - /* connection attempt pending */ - 'ConnectPending' = 'ConnectPending', - /* connection successful and active */ - 'Connected' = 'Connected', - - /* disconnect was called to release approval */ - 'Disconnected' = 'Disconnected', -} +import { assertGlobalPresent, assertProvider, assertProviderManifest } from './assert.js'; +import { isPenumbraManifest, type PenumbraManifest } from './manifest.js'; +import type { PenumbraProvider } from './provider.js'; +import { PenumbraSymbol } from './symbol.js'; declare global { interface Window { /** Records injected upon this global should identify themselves by a field * name matching the origin of the provider. */ - readonly [PenumbraSymbol]?: undefined | Readonly>; + readonly [PenumbraSymbol]?: undefined | Readonly>; } } + +/** Synchronously return the specified provider, without verifying anything. */ +export const getPenumbraProviderUnsafe = (penumbraOrigin: string) => + window[PenumbraSymbol]?.[penumbraOrigin]; + +/** Return the specified provider after confirming presence of its manifest. */ +export const getPenumbraProvider = (penumbraOrigin: string) => assertProvider(penumbraOrigin); + +/** Return the specified provider's manifest. */ +export const getPenumbraManifest = async ( + penumbraOrigin: string, + signal?: AbortSignal, +): Promise => { + const manifestJson = await assertProviderManifest(penumbraOrigin, signal); + if (!isPenumbraManifest(manifestJson)) { + throw new TypeError('Invalid manifest'); + } + return manifestJson; +}; + +export const getAllPenumbraManifests = (): Record< + keyof (typeof window)[typeof PenumbraSymbol], + Promise +> => + Object.fromEntries( + Object.keys(assertGlobalPresent()).map(providerOrigin => [ + providerOrigin, + getPenumbraManifest(providerOrigin), + ]), + ); + +export type { PenumbraManifest } from './manifest.js'; +export type { PenumbraProvider } from './provider.js'; +export { PenumbraState } from './state.js'; +export { PenumbraSymbol } from './symbol.js'; diff --git a/packages/react/src/manifest.ts b/packages/client/src/manifest.ts similarity index 65% rename from packages/react/src/manifest.ts rename to packages/client/src/manifest.ts index 35c3a07ae0..a6bb8cad0f 100644 --- a/packages/react/src/manifest.ts +++ b/packages/client/src/manifest.ts @@ -1,14 +1,14 @@ /** Currently, Penumbra manifests are chrome extension manifest v3. There's no type * guard because manifest format is enforced by chrome. This type only describes - * fields we're interested in. + * fields we're interested in as a client. * * @see https://developer.chrome.com/docs/extensions/reference/manifest#keys */ export interface PenumbraManifest { /** * manifest id is present in production, but generally not in dev, because - * they are inserted by chrome store tooling. crx id are simple hashes of the - * 'key' field, an extension-specific public key. + * they are inserted by chrome store tooling. chrome extension id are simple + * hashes of the 'key' field, an extension-specific public key. * * developers may configure a public key in dev, and the extension id will * match appropriately, but will not be present in the manifest. @@ -26,7 +26,7 @@ export interface PenumbraManifest { version: string; description: string; - // these are optional + // these are optional, but might be nice to have homepage_url?: string; options_ui?: { page: string }; options_page?: string; @@ -38,3 +38,18 @@ export interface PenumbraManifest { ['128']: string; }; } + +export const isPenumbraManifest = (mf: unknown): mf is PenumbraManifest => + mf !== null && + typeof mf === 'object' && + 'name' in mf && + typeof mf.name === 'string' && + 'version' in mf && + typeof mf.version === 'string' && + 'description' in mf && + typeof mf.description === 'string' && + 'icons' in mf && + typeof mf.icons === 'object' && + mf.icons !== null && + '128' in mf.icons && + mf.icons['128'] === 'string'; diff --git a/packages/client/src/provider.ts b/packages/client/src/provider.ts new file mode 100644 index 0000000000..726e527686 --- /dev/null +++ b/packages/client/src/provider.ts @@ -0,0 +1,73 @@ +import type { PenumbraStateEventTarget } from './event.js'; +import type { PenumbraState } from './state.js'; + +/** + * This interface describes the simple API to request, connect, or disconnect a + * provider. These methods allow a page to acquire permission to connect, and + * obtain a `MessagePort` to be used for client creation. + * + * There are three connection 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 call may be pending, or no call has been made + * + * Each injection should also track state-changing actions, so calling + * `.state()` should provide more detail including currently pending state, + * enumerated by `PenumbraInjectionState`. + * + * Any script in page scope may create an object like this, so clients should + * confirm a provider is actually present. Presence can be securely verified by + * fetching the identified provider manifest from the provider's origin. + * + * Presently clients can expect the manifest is a chrome extension manifest v3. + * Provider details such as name, version, website, brief descriptive text, and + * icons should be available in the manifest. + * @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 PenumbraProvider extends Readonly { + /** Should contain a URI at the provider's origin, serving a manifest + * describing this provider. */ + readonly manifest: string; + + /** Call to acquire a `MessagePort` to this provider, subject to approval. */ + readonly connect: () => Promise; + + /** Call to gain approval. May reject with a `PenumbraProviderRequestError` + * containing an enumerated `PenumbraRequestFailure` cause. */ + readonly request: () => Promise; + + /** Call to indicate the provider should discard 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` no attempt has resolved. connection may be attempted. + */ + readonly isConnected: () => boolean | undefined; + + /** Synchronously return present injection state. */ + readonly state: () => PenumbraState; + + /** Like a normal EventTarget.addEventListener, but should only emit + * `PenubraInjectionStateEvent` when state changes. Listen for + * `'penumbrastate'` events, and check the `detail` field for a + * `PenumbraInjectionState` value. + * @see https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener + */ + readonly addEventListener: PenumbraStateEventTarget['addEventListener']; + readonly removeEventListener: PenumbraStateEventTarget['addEventListener']; +} diff --git a/packages/client/src/state.ts b/packages/client/src/state.ts new file mode 100644 index 0000000000..8d4de8789f --- /dev/null +++ b/packages/client/src/state.ts @@ -0,0 +1,20 @@ +export enum PenumbraState { + /* error is present */ + 'Failed' = 'Failed', + + /* no action has been taken */ + 'Present' = 'Present', + + /* approval request pending */ + 'RequestPending' = 'RequestPending', + /* request for approval satisfied */ + 'Requested' = 'Requested', + + /* connection attempt pending */ + 'ConnectPending' = 'ConnectPending', + /* connection successful and active */ + 'Connected' = 'Connected', + + /* disconnect was called to release approval */ + 'Disconnected' = 'Disconnected', +} diff --git a/packages/client/src/symbol.ts b/packages/client/src/symbol.ts new file mode 100644 index 0000000000..12635d2a0a --- /dev/null +++ b/packages/client/src/symbol.ts @@ -0,0 +1 @@ +export const PenumbraSymbol = Symbol.for('penumbra'); diff --git a/packages/react/README.md b/packages/react/README.md index 4fabdd06ad..72f42d9da0 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -12,10 +12,17 @@ npm config set @buf:registry https://buf.build/gen/npm/v1 ## Overview -You must independently identify a Penumbra extension to which your app wishes to -connect. +If a user has a Penumbra provider in their browser, it may be present (injected) +in the record at the window global `window[Symbol.for('penumbra')]` identified +by a URL origin at which the provider can serve a manifest. For example, Prax +Wallet's origin is `chrome-extension://lkpmkhpnhknhmibgnmmhdhgdilepfghe`, so its provider record may be accessed like + +```ts +const prax: PenumbraProvider | undefined = + window[Symbol.for('penumbra')]?.['chrome-extension://lkpmkhpnhknhmibgnmmhdhgdilepfghe']; +``` -Then, use of `` with an `origin` prop identifying your +So, use of `` with an `origin` prop identifying your preferred extension, or `injection` prop identifying the actual page injection from your preferred extension, will result in automatic progress towards a successful connection. @@ -30,7 +37,7 @@ queues requests while connection is pending, and begins returning responses when appropriate. If the provider fails to connect, requests via the transport or client may time out. -## `` +## `` This wrapping component will provide a context available to all child components that is directly accessible by `usePenumbra`, or additionally by @@ -75,6 +82,7 @@ import { useQuery } from '@connectrpc/connect-query'; import { bech32mAddress } from '@penumbra-zone/bech32m/penumbra'; export const PraxAddress = ({ account }: { account?: number }) => { + // note this is not tanstack's useQuery const { data } = useQuery(addressByIndex, { addressIndex: { account } }); return data?.address && bech32mAddress(data.address); }; @@ -128,23 +136,23 @@ export default function AssetBalancesByAccount({ assetIdFilter }: { assetIdFilte ## Possible provider states -On the bare Penumbra injection, there is only a boolean/undefined -`isConnected()` state and a few simple actions available. It is generally robust -and should asynchronously progress towards an active connection if possible, -even if steps are performed 'out-of-order'. +Each Penumbra provider exposes a simple `.isConnected()` method and a more +complex `.state()` method, which also tracks pending transitions. It is +generally robust and should asynchronously progress towards an active connection +if possible, even if steps are performed slightly 'out-of-order'. -This package's exported `` component handles this state and -all of these transitions for you. Use of `` with an `origin` -or `injection` prop will result in automatic progress towards a `Connected` -state. +This package's exported `` component handles this state +and all of these transitions for you. Use of `` with an +`origin` or `provider` prop will result in automatic progress towards a +`Connected` state. During this progress, the context exposes an explicit status, so you may easily condition your layout and display. You can access this status via -`usePenumbra().state`. All possible values are represented by the exported enum -`PenumbraProviderState`. +`usePenumbra().state`. All possible values are represented by the enum +`PenumbraState` available from `@penumbra-zone/client`. -Hooks `usePenumbraTransport` and `usePenumbraService` conceal this state, and -unconditionally provide a transport or client. +Hooks `usePenumbraTransportSync` and `usePenumbraServiceSync` conceal this +state, and unconditionally provide a transport or client. `Connected` is the only state in which a `MessagePort`, working `Transport`, or working client is available. @@ -174,7 +182,7 @@ stateDiagram-v2 state make_request <> - [*] --> global_exists: window[Symbol.for('penumbra')][validOrigin] + [*] --> global_exists: p = window[Symbol.for('penumbra')][validOrigin] global_exists --> [*]: undefined Failed:::BadNode --> [*]: p.failure diff --git a/packages/react/package.json b/packages/react/package.json index e08356da10..3a099ddad2 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -2,7 +2,7 @@ "name": "@penumbra-zone/react", "version": "0.0.1", "license": "(MIT OR Apache-2.0)", - "description": "Reactive package for connecting to any Penumbra extension, including Prax.", + "description": "React package for connecting to any Penumbra extension, including Prax.", "type": "module", "scripts": { "build": "tsc --build --verbose", diff --git a/packages/react/src/components/penumbra-context-provider.tsx b/packages/react/src/components/penumbra-context-provider.tsx new file mode 100644 index 0000000000..015be093d2 --- /dev/null +++ b/packages/react/src/components/penumbra-context-provider.tsx @@ -0,0 +1,185 @@ +import { getPenumbraManifest, PenumbraProvider, PenumbraState } from '@penumbra-zone/client'; +import { isPenumbraStateEvent } from '@penumbra-zone/client/event'; +import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'; +import { PenumbraContext, penumbraContext } from '../penumbra-context.js'; +import { PenumbraManifest } from '@penumbra-zone/client/manifest'; +import { + ChannelTransportOptions, + createChannelTransport, +} from '@penumbra-zone/transport-dom/create'; +import { jsonOptions } from '@penumbra-zone/protobuf'; +import { assertProviderRecord } from '@penumbra-zone/client/assert'; + +type PenumbraContextProviderProps = { + children?: ReactNode; + origin: string; + makeApprovalRequest?: boolean; + transportOpts?: Omit; +} & ({ provider: PenumbraProvider } | { origin: string }); + +export const PenumbraContextProvider = ({ + children, + origin: providerOrigin, + makeApprovalRequest = false, + transportOpts, +}: PenumbraContextProviderProps) => { + const penumbra = assertProviderRecord(providerOrigin); + + const [providerConnected, setProviderConnected] = useState(); + const [providerManifest, setProviderManifest] = useState(); + const [providerPort, setProviderPort] = useState(); + const [providerState, setProviderState] = useState(); + const [failure, dispatchFailure] = useState(); + + // force destruction on any failure + const setFailure = useCallback( + (cause: unknown) => { + if (failure) { + console.warn('PenumbraContextProvider not replacing existing failure with new cause', { + failure, + cause, + }); + } + + setProviderConnected(false); + setProviderPort(undefined); + setProviderState(PenumbraState.Failed); + dispatchFailure( + failure ?? (cause instanceof Error ? cause : new Error('Unknown failure', { cause })), + ); + }, + [failure], + ); + + // fetch manifest to confirm presence of provider + useEffect(() => { + // require origin. skip if failure or manifest present + if (!providerOrigin || (failure ?? providerManifest)) { + return; + } + + // abortable effect + const ac = new AbortController(); + + void getPenumbraManifest(providerOrigin, ac.signal) + .then(manifestJson => ac.signal.aborted || setProviderManifest(manifestJson)) + .catch(setFailure); + + return () => ac.abort(); + }, [providerOrigin, penumbra, providerManifest, setProviderManifest]); + + // attach state event listener + useEffect(() => { + // require manifest. unnecessary if failed + if (!providerManifest || failure) { + return; + } + + // abortable listener + const ac = new AbortController(); + penumbra.addEventListener( + 'penumbrastate', + (evt: Event) => { + if (isPenumbraStateEvent(evt)) { + if (evt.detail.origin !== providerOrigin) { + setFailure(new Error('State change from unexpected origin')); + } else if (evt.detail.state !== penumbra.state()) { + console.warn('State change not verifiable'); + } else { + setProviderState(penumbra.state()); + setProviderConnected(penumbra.isConnected()); + } + } + }, + { signal: ac.signal }, + ); + return () => ac.abort(); + }, [penumbra, penumbra.addEventListener, providerManifest, failure]); + + // request effect + useEffect(() => { + // require manifest, no failures + if (providerManifest && !failure) { + switch (providerState) { + case PenumbraState.Present: + if (makeApprovalRequest) { + void penumbra.request().catch(setFailure); + } + break; + default: + break; + } + } + }, [makeApprovalRequest, providerState, penumbra.request, providerManifest, failure]); + + // connect effect + useEffect(() => { + // require manifest, no failures + if (providerManifest && !failure) { + switch (providerState) { + case PenumbraState.Present: + if (!makeApprovalRequest) { + void penumbra + .connect() + .then(p => setProviderPort(p)) + .catch(setFailure); + } + break; + case PenumbraState.Requested: + void penumbra + .connect() + .then(p => setProviderPort(p)) + .catch(setFailure); + break; + default: + break; + } + } + }, [makeApprovalRequest, providerState, penumbra.connect, providerManifest, failure]); + + const createdContext: PenumbraContext = useMemo( + () => ({ + failure, + manifest: providerManifest, + origin: providerOrigin, + + // require manifest to forward state + state: providerManifest && providerState, + transport: + providerConnected && providerPort + ? createChannelTransport({ + jsonOptions, + ...transportOpts, + getPort: () => Promise.resolve(providerPort), + }) + : undefined, + transportOpts, + + // require manifest and no failures to forward injected methods + ...(providerManifest && !failure + ? { + port: providerConnected && providerPort, + connect: penumbra.connect, + request: penumbra.request, + disconnect: penumbra.disconnect, + + addEventListener: penumbra.addEventListener, + removeEventListener: penumbra.removeEventListener, + } + : {}), + }), + [ + failure, + penumbra.connect, + penumbra.connect, + penumbra.disconnect, + providerManifest, + providerOrigin, + providerPort, + providerState, + transportOpts, + ], + ); + + return {children}; +}; diff --git a/packages/react/src/components/penumbra-provider.tsx b/packages/react/src/components/penumbra-provider.tsx deleted file mode 100644 index 9839ba6b8e..0000000000 --- a/packages/react/src/components/penumbra-provider.tsx +++ /dev/null @@ -1,204 +0,0 @@ -import { - isPenumbraInjectionStateEvent, - PenumbraInjection, - PenumbraInjectionState, -} from '@penumbra-zone/client'; -import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'; -import { PenumbraManifest } from '../manifest.js'; -import { PenumbraContext, penumbraContext } from '../penumbra-context.js'; -import { assertManifestOrigin, injectionOfKey, keyOfInjection } from '../util.js'; - -type PenumbraProviderProps = { - children?: ReactNode; - injection?: PenumbraInjection; - origin?: string; - makeApprovalRequest?: boolean; -} & ({ injection: PenumbraInjection } | { origin: string }); - -export const PenumbraProvider = ({ - children, - origin: providerOrigin, - injection: providerInjection, - makeApprovalRequest = false, -}: PenumbraProviderProps) => { - providerOrigin ??= keyOfInjection(providerInjection); - providerInjection ??= injectionOfKey(providerOrigin); - - const [providerState, setProviderState] = useState(providerInjection?.state()); - const [providerConnected, setProviderConnected] = useState(providerInjection?.isConnected()); - - const [failure, setFailureError] = useState(); - const setFailureUnknown = useCallback( - (cause: unknown) => - failure - ? console.error('Not replacing existing PenumbraProvider failure', { failure, cause }) - : setFailureError(cause instanceof Error ? cause : new Error('Unknown failure', { cause })), - [failure, setFailureError], - ); - - const [providerPort, setProviderPort] = useState(); - const [manifest, setManifest] = useState(); - - // force destruction of provider on failure - useEffect(() => { - if (failure) { - setProviderState(PenumbraInjectionState.Failed); - setProviderConnected(false); - setProviderPort(undefined); - } - }, [failure]); - - // attach state event listener - useEffect(() => { - // require manifest, no failures - if (!manifest || failure) { - return; - } - - const listener = (evt: Event) => { - if (isPenumbraInjectionStateEvent(evt)) { - if (!providerInjection) { - setFailureError(new Error('State change event without injection')); - } else if (evt.detail.origin !== providerOrigin) { - setFailureError(new Error('State change from unexpected origin')); - } else if (evt.detail.state !== providerInjection.state()) { - console.warn('State change not verifiable'); - } else { - setProviderState(providerInjection.state()); - setProviderConnected(providerInjection.isConnected()); - } - } - }; - - const ac = new AbortController(); - providerInjection?.addEventListener('penumbrastate', listener, { - signal: ac.signal, - }); - return () => ac.abort(); - }, [providerInjection, providerInjection?.addEventListener, manifest, failure]); - - // fetch manifest to confirm presence of provider - useEffect(() => { - // require provider - if (!providerOrigin || !providerInjection) { - return; - } - // don't repeat, unnecessary if failed - if (!!manifest || failure) { - return; - } - - // sync assertion - try { - assertManifestOrigin(providerOrigin, providerInjection); - } catch (cause) { - setFailureUnknown(cause); - return; - } - - // abortable fetch - const ac = new AbortController(); - const fetchManifest = fetch(providerInjection.manifest, { signal: ac.signal }).catch( - (noAbortError: unknown) => { - // abort is not a failure - if (noAbortError instanceof Error && noAbortError.name === 'AbortError') { - return; - } else { - throw noAbortError; - } - }, - ); - - // async handle response - void fetchManifest - .then(async res => { - const manifestJson: unknown = await res?.json(); - if (manifestJson) { - // this cast is fairly safe coming from an extension manifest, where - // schema is enforced by chrome store. - setManifest(manifestJson as PenumbraManifest); - } - }) - .catch(setFailureUnknown); - - return () => ac.abort(); - }, [providerOrigin, providerInjection, manifest, setManifest]); - - // request effect - useEffect(() => { - // require manifest, no failures - if (!manifest || failure) { - return; - } - - switch (providerState) { - case PenumbraInjectionState.Present: - if (makeApprovalRequest) { - void providerInjection?.request().catch(setFailureUnknown); - } - break; - default: - break; - } - }, [makeApprovalRequest, providerState, providerInjection?.request, manifest, failure]); - - // connect effect - useEffect(() => { - // require manifest, no failures - if (!manifest || failure) { - return; - } - - switch (providerState) { - case PenumbraInjectionState.Present: - if (!makeApprovalRequest) { - void providerInjection - ?.connect() - .then(p => setProviderPort(p)) - .catch(setFailureUnknown); - } - break; - case PenumbraInjectionState.Requested: - void providerInjection - ?.connect() - .then(p => setProviderPort(p)) - .catch(setFailureUnknown); - break; - default: - break; - } - }, [makeApprovalRequest, providerState, providerInjection?.connect, manifest, failure]); - - const createdContext: PenumbraContext = useMemo( - () => ({ - failure, - manifest, - origin: providerOrigin, - - // require manifest to forward state - state: manifest && providerState, - - // require manifest and no failures to forward injected methods - ...(manifest && !failure - ? { - port: providerConnected && providerPort, - connect: providerInjection?.connect, - request: providerInjection?.request, - disconnect: providerInjection?.disconnect, - } - : {}), - }), - [ - failure, - manifest, - providerPort, - providerInjection?.connect, - providerInjection?.connect, - providerInjection?.disconnect, - providerOrigin, - providerState, - ], - ); - - return {children}; -}; diff --git a/packages/react/src/hooks/use-penumbra-service.ts b/packages/react/src/hooks/use-penumbra-service.ts index 35b4265cae..ccbc4058e3 100644 --- a/packages/react/src/hooks/use-penumbra-service.ts +++ b/packages/react/src/hooks/use-penumbra-service.ts @@ -1,17 +1,25 @@ import { createPromiseClient, PromiseClient } from '@connectrpc/connect'; import { PenumbraService } from '@penumbra-zone/protobuf'; import { useMemo } from 'react'; -import { usePenumbraTransport, usePenumbraTransportSync } from './use-penumbra-transport.js'; +import { usePenumbraTransportAsync, usePenumbraTransportSync } from './use-penumbra-transport.js'; +import { usePenumbra } from './use-penumbra.js'; + +export const usePenumbraService = ( + service: S, +): PromiseClient | undefined => { + const { transport } = usePenumbra(); + return useMemo(() => transport && createPromiseClient(service, transport), [service, transport]); +}; export const usePenumbraServiceSync = (service: S): PromiseClient => { const transport = usePenumbraTransportSync(); return useMemo(() => createPromiseClient(service, transport), [service, transport]); }; -export const usePenumbraService = ( +export const usePenumbraServiceAsync = ( service: S, ): Promise> => { - const transportPromise = usePenumbraTransport(); + const transportPromise = usePenumbraTransportAsync(); return useMemo( () => transportPromise.then(transport => createPromiseClient(service, transport)), [service, transportPromise], diff --git a/packages/react/src/hooks/use-penumbra-transport.ts b/packages/react/src/hooks/use-penumbra-transport.ts index ee03b3ab20..65671dd0d7 100644 --- a/packages/react/src/hooks/use-penumbra-transport.ts +++ b/packages/react/src/hooks/use-penumbra-transport.ts @@ -4,12 +4,14 @@ import { } from '@penumbra-zone/transport-dom/create'; import { useEffect, useMemo, useState } from 'react'; import { usePenumbra } from './use-penumbra.js'; -import { PenumbraInjectionState } from '@penumbra-zone/client'; +import { PenumbraState } from '@penumbra-zone/client'; -/** Unconditionally returns a Transport to the provided Penumbra context. This - * transport will always create synchronously, but may reject all requests if - * the Penumbra context does not provide a port within your configured - * defaultTimeoutMs (defaults to 10 seconds). */ +export const usePenumbraTransport = () => usePenumbra().transport; + +/** This method immediately returns a new, unshared Transport to the surrounding + * Penumbra context. This transport will always create synchronously, but may + * time out and reject all requests if the Penumbra context does not provide a + * port within your configured defaultTimeoutMs (defaults to 10 seconds). */ export const usePenumbraTransportSync = (opts?: Omit) => { const penumbra = usePenumbra(); const { port, failure, state } = penumbra; @@ -21,8 +23,13 @@ export const usePenumbraTransportSync = (opts?: Omit createChannelTransport({ ...opts, getPort: () => portPromise }), - [portPromise], + () => + createChannelTransport({ + ...penumbra.transportOpts, + ...opts, + getPort: () => portPromise, + }), + [penumbra, portPromise, opts], ); // handle context updates @@ -37,11 +44,12 @@ export const usePenumbraTransportSync = (opts?: Omit) => { +/** This method Promises a new, unshared Transport to the provided Penumbra + * context. Awaits confirmation of a MessagePort to the provider in context + * before attempting to create the Transport, so this will not time out if + * approval takes very long - but it must be async. The returned promise may + * reject with a connection failure. */ +export const usePenumbraTransportAsync = (opts?: Omit) => { const penumbra = usePenumbra(); const { port, failure, state } = penumbra; @@ -52,22 +60,29 @@ export const usePenumbraTransport = (opts?: Omit portPromise.then(() => createChannelTransport({ ...opts, getPort: () => portPromise })), - [portPromise], + () => + portPromise.then(() => + createChannelTransport({ + ...penumbra.transportOpts, + ...opts, + getPort: () => portPromise, + }), + ), + [penumbra, portPromise, opts], ); // handle context updates useEffect(() => { if (port) { resolvePort(port); - } else if (failure ?? state === PenumbraInjectionState.Failed) { + } else if (failure ?? state === PenumbraState.Failed) { rejectPort(failure ?? new Error('Unknown failure')); } }, [failure, penumbra, port, resolvePort, rejectPort, state]); switch (state) { - case PenumbraInjectionState.Disconnected: - case PenumbraInjectionState.Failed: + case PenumbraState.Disconnected: + case PenumbraState.Failed: return Promise.reject(failure ?? new Error(state)); default: return transportPromise; diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 7b1d5b069a..160a0d591a 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -1,2 +1,2 @@ export { usePenumbra } from './hooks/use-penumbra.js'; -export { PenumbraProvider } from './components/penumbra-provider.js'; +export { PenumbraContextProvider } from './components/penumbra-context-provider.js'; diff --git a/packages/react/src/penumbra-context.ts b/packages/react/src/penumbra-context.ts index 5955f5cf68..3a300c2412 100644 --- a/packages/react/src/penumbra-context.ts +++ b/packages/react/src/penumbra-context.ts @@ -1,16 +1,23 @@ +import type { Transport } from '@connectrpc/connect'; +import { + PenumbraProvider, + PenumbraSymbol, + type PenumbraManifest, + type PenumbraState, +} from '@penumbra-zone/client'; +import type { ChannelTransportOptions } from '@penumbra-zone/transport-dom/create'; import { createContext } from 'react'; -import { PenumbraInjectionState, PenumbraSymbol } from '@penumbra-zone/client'; -import type { PenumbraManifest } from './manifest.js'; const penumbraGlobal = window[PenumbraSymbol]; -export interface PenumbraContext { +export type PenumbraContext = Partial> & { origin?: keyof NonNullable; manifest?: PenumbraManifest; - disconnect?: () => Promise; port?: MessagePort | false; failure?: Error; - state?: PenumbraInjectionState; -} + state?: PenumbraState; + transport?: Transport; + transportOpts?: Omit; +}; export const penumbraContext = createContext({}); diff --git a/packages/react/src/util.ts b/packages/react/src/util.ts deleted file mode 100644 index e45e24bf74..0000000000 --- a/packages/react/src/util.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { PenumbraInjection, PenumbraSymbol } from '@penumbra-zone/client'; - -export const keyOfInjection = (injection?: PenumbraInjection) => - Object.entries(window[PenumbraSymbol] ?? {}).find( - ([keyOrigin, valueInjection]) => - keyOrigin && - // matching injection - valueInjection === injection, - )?.[0]; - -export const injectionOfKey = (keyOrigin?: string) => - keyOrigin ? window[PenumbraSymbol]?.[keyOrigin] : undefined; - -export const assertStringIsOrigin = (s?: string) => { - if (!s || new URL(s).origin !== s) { - throw new TypeError('Invalid origin'); - } - return s; -}; - -export const assertManifestOrigin = (s?: string, injection?: PenumbraInjection) => { - const originString = assertStringIsOrigin(s); - if (!injection?.manifest || new URL(injection.manifest).origin !== originString) { - throw new TypeError('Invalid manifest origin'); - } - return [originString, injection] satisfies [string, PenumbraInjection]; -}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a3e5ec1004..82da4d97ad 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -176,7 +176,7 @@ importers: version: 3.3.0(vite@5.3.3(@types/node@20.14.10)(terser@5.31.1)) vitest: specifier: ^1.6.0 - version: 1.6.0(@types/node@20.14.10)(@vitest/browser@1.6.0)(jsdom@24.1.0(bufferutil@4.0.8)(utf-8-validate@5.0.10))(terser@5.31.1) + version: 1.6.0(@types/node@20.14.10)(@vitest/browser@1.6.0(playwright@1.45.1)(vitest@1.6.0))(jsdom@24.1.0(bufferutil@4.0.8)(utf-8-validate@5.0.10))(terser@5.31.1) apps/minifront: dependencies: @@ -17092,7 +17092,7 @@ snapshots: lodash: 4.17.21 redent: 3.0.0 optionalDependencies: - vitest: 1.6.0(@types/node@20.14.10)(@vitest/browser@1.6.0)(jsdom@24.1.0(bufferutil@4.0.8)(utf-8-validate@5.0.10))(terser@5.31.1) + vitest: 1.6.0(@types/node@20.14.10)(@vitest/browser@1.6.0(playwright@1.45.1)(vitest@1.6.0))(jsdom@24.1.0(bufferutil@4.0.8)(utf-8-validate@5.0.10))(terser@5.31.1) '@testing-library/jest-dom@6.4.6(vitest@1.6.0(@types/node@20.14.10)(@vitest/browser@1.6.0)(jsdom@24.1.0(bufferutil@4.0.8)(utf-8-validate@5.0.10))(terser@5.31.1))': dependencies: @@ -17105,7 +17105,7 @@ snapshots: lodash: 4.17.21 redent: 3.0.0 optionalDependencies: - vitest: 1.6.0(@types/node@20.14.10)(@vitest/browser@1.6.0)(jsdom@24.1.0(bufferutil@4.0.8)(utf-8-validate@5.0.10))(terser@5.31.1) + vitest: 1.6.0(@types/node@20.14.10)(@vitest/browser@1.6.0(playwright@1.45.1)(vitest@1.6.0))(jsdom@24.1.0(bufferutil@4.0.8)(utf-8-validate@5.0.10))(terser@5.31.1) '@testing-library/react@15.0.7(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: @@ -17699,7 +17699,7 @@ snapshots: '@vitest/utils': 1.6.0 magic-string: 0.30.10 sirv: 2.0.4 - vitest: 1.6.0(@types/node@20.14.10)(@vitest/browser@1.6.0)(jsdom@24.1.0(bufferutil@4.0.8)(utf-8-validate@5.0.10))(terser@5.31.1) + vitest: 1.6.0(@types/node@20.14.10)(@vitest/browser@1.6.0(playwright@1.45.1)(vitest@1.6.0))(jsdom@24.1.0(bufferutil@4.0.8)(utf-8-validate@5.0.10))(terser@5.31.1) optionalDependencies: playwright: 1.45.1 @@ -19630,7 +19630,7 @@ snapshots: '@typescript-eslint/utils': 7.16.0(eslint@9.6.0)(typescript@5.5.3) eslint: 9.6.0 optionalDependencies: - vitest: 1.6.0(@types/node@20.14.10)(@vitest/browser@1.6.0)(jsdom@24.1.0(bufferutil@4.0.8)(utf-8-validate@5.0.10))(terser@5.31.1) + vitest: 1.6.0(@types/node@20.14.10)(@vitest/browser@1.6.0(playwright@1.45.1)(vitest@1.6.0))(jsdom@24.1.0(bufferutil@4.0.8)(utf-8-validate@5.0.10))(terser@5.31.1) transitivePeerDependencies: - supports-color - typescript @@ -23388,7 +23388,7 @@ snapshots: fsevents: 2.3.3 terser: 5.31.1 - vitest@1.6.0(@types/node@20.14.10)(@vitest/browser@1.6.0)(jsdom@24.1.0(bufferutil@4.0.8)(utf-8-validate@5.0.10))(terser@5.31.1): + vitest@1.6.0(@types/node@20.14.10)(@vitest/browser@1.6.0(playwright@1.45.1)(vitest@1.6.0))(jsdom@24.1.0(bufferutil@4.0.8)(utf-8-validate@5.0.10))(terser@5.31.1): dependencies: '@vitest/expect': 1.6.0 '@vitest/runner': 1.6.0 From 87d69efdb7939db7c323d9f249e148fab88048de Mon Sep 17 00:00:00 2001 From: turbocrime Date: Mon, 15 Jul 2024 01:24:36 -0700 Subject: [PATCH 5/5] cleanup --- apps/minifront/src/prax.ts | 27 +++++--------- packages/client/src/index.ts | 5 ++- packages/react/eslint.config.mjs | 13 ------- packages/react/package.json | 4 +- .../components/penumbra-context-provider.tsx | 37 ++++++++++++++----- 5 files changed, 43 insertions(+), 43 deletions(-) delete mode 100644 packages/react/eslint.config.mjs diff --git a/apps/minifront/src/prax.ts b/apps/minifront/src/prax.ts index 8ef3b0c03a..f8123518db 100644 --- a/apps/minifront/src/prax.ts +++ b/apps/minifront/src/prax.ts @@ -1,10 +1,11 @@ import { createPromiseClient, PromiseClient, Transport } from '@connectrpc/connect'; +import { getPenumbraManifest } from '@penumbra-zone/client'; import { + assertProvider, assertProviderConnected, assertProviderManifest, - getPenumbraPort, - syncCreatePenumbraChannelTransport, -} from '@penumbra-zone/client/create'; +} from '@penumbra-zone/client/assert'; +import { createPenumbraChannelTransportSync } from '@penumbra-zone/client/create'; import { jsonOptions, PenumbraService } from '@penumbra-zone/protobuf'; const prax_id = 'lkpmkhpnhknhmibgnmmhdhgdilepfghe'; @@ -12,11 +13,7 @@ const prax_origin = `chrome-extension://${prax_id}`; export const getPraxOrigin = () => prax_origin; -export const getPraxManifest = async () => { - const { manifest } = await assertProviderManifest(prax_origin); - const requestManifest = await fetch(manifest); - return (await requestManifest.json()) as unknown; -}; +export const getPraxManifest = () => getPenumbraManifest(prax_origin); export const isPraxConnected = () => { try { @@ -29,7 +26,7 @@ export const isPraxConnected = () => { export const isPraxInstalled = async () => { try { - await assertProviderManifest(); + await assertProviderManifest(prax_origin); return true; } catch { return false; @@ -40,16 +37,10 @@ export const throwIfPraxNotConnected = () => assertProviderConnected(prax_origin export const throwIfPraxNotInstalled = async () => assertProviderManifest(prax_origin); -export const getPraxPort = () => getPenumbraPort(prax_origin); - -export const requestPraxAccess = () => getPraxPort(); - -export const praxTransportOptions = { - jsonOptions, - getPort: getPraxPort, -}; +export const requestPraxAccess = () => assertProvider(prax_origin).then(p => p.request()); -export const createPraxTransport = () => syncCreatePenumbraChannelTransport(prax_origin); +export const createPraxTransport = () => + createPenumbraChannelTransportSync(prax_origin, { jsonOptions }); let praxTransport: Transport | undefined; export const createPraxClient = (service: T): PromiseClient => diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index ad69ec377b..1c573c8f0b 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -12,11 +12,11 @@ declare global { } /** Synchronously return the specified provider, without verifying anything. */ -export const getPenumbraProviderUnsafe = (penumbraOrigin: string) => +export const getPenumbraUnsafe = (penumbraOrigin: string) => window[PenumbraSymbol]?.[penumbraOrigin]; /** Return the specified provider after confirming presence of its manifest. */ -export const getPenumbraProvider = (penumbraOrigin: string) => assertProvider(penumbraOrigin); +export const getPenumbra = (penumbraOrigin: string) => assertProvider(penumbraOrigin); /** Return the specified provider's manifest. */ export const getPenumbraManifest = async ( @@ -41,6 +41,7 @@ export const getAllPenumbraManifests = (): Record< ]), ); +export * from './error.js'; export type { PenumbraManifest } from './manifest.js'; export type { PenumbraProvider } from './provider.js'; export { PenumbraState } from './state.js'; diff --git a/packages/react/eslint.config.mjs b/packages/react/eslint.config.mjs deleted file mode 100644 index 11c6ce9137..0000000000 --- a/packages/react/eslint.config.mjs +++ /dev/null @@ -1,13 +0,0 @@ -import { penumbraEslintConfig } from '@repo/eslint-config'; -import { config, parser } from 'typescript-eslint'; - -export default config({ - ...penumbraEslintConfig, - languageOptions: { - parser, - parserOptions: { - project: true, - tsconfigRootDir: import.meta.dirname, - }, - }, -}); diff --git a/packages/react/package.json b/packages/react/package.json index 3a099ddad2..ab036389db 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -8,7 +8,9 @@ "build": "tsc --build --verbose", "clean": "rm -rfv dist *.tsbuildinfo package penumbra-zone-*.tgz", "dev:pack": "tsc-watch --onSuccess \"$npm_execpath pack\"", - "lint": "eslint src" + "lint": "eslint src", + "lint:fix": "eslint src --fix", + "lint:strict": "tsc --noEmit && eslint src --max-warnings 0" }, "files": [ "dist" diff --git a/packages/react/src/components/penumbra-context-provider.tsx b/packages/react/src/components/penumbra-context-provider.tsx index 015be093d2..2883ecd9f6 100644 --- a/packages/react/src/components/penumbra-context-provider.tsx +++ b/packages/react/src/components/penumbra-context-provider.tsx @@ -1,14 +1,14 @@ import { getPenumbraManifest, PenumbraProvider, PenumbraState } from '@penumbra-zone/client'; +import { assertProviderRecord } from '@penumbra-zone/client/assert'; import { isPenumbraStateEvent } from '@penumbra-zone/client/event'; -import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'; -import { PenumbraContext, penumbraContext } from '../penumbra-context.js'; import { PenumbraManifest } from '@penumbra-zone/client/manifest'; +import { jsonOptions } from '@penumbra-zone/protobuf'; import { ChannelTransportOptions, createChannelTransport, } from '@penumbra-zone/transport-dom/create'; -import { jsonOptions } from '@penumbra-zone/protobuf'; -import { assertProviderRecord } from '@penumbra-zone/client/assert'; +import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'; +import { PenumbraContext, penumbraContext } from '../penumbra-context.js'; type PenumbraContextProviderProps = { children?: ReactNode; @@ -66,7 +66,7 @@ export const PenumbraContextProvider = ({ .catch(setFailure); return () => ac.abort(); - }, [providerOrigin, penumbra, providerManifest, setProviderManifest]); + }, [failure, penumbra, providerManifest, providerOrigin, setFailure, setProviderManifest]); // attach state event listener useEffect(() => { @@ -94,7 +94,7 @@ export const PenumbraContextProvider = ({ { signal: ac.signal }, ); return () => ac.abort(); - }, [penumbra, penumbra.addEventListener, providerManifest, failure]); + }, [failure, penumbra, penumbra.addEventListener, providerManifest, providerOrigin, setFailure]); // request effect useEffect(() => { @@ -110,7 +110,15 @@ export const PenumbraContextProvider = ({ break; } } - }, [makeApprovalRequest, providerState, penumbra.request, providerManifest, failure]); + }, [ + failure, + makeApprovalRequest, + penumbra, + penumbra.request, + providerManifest, + providerState, + setFailure, + ]); // connect effect useEffect(() => { @@ -135,7 +143,15 @@ export const PenumbraContextProvider = ({ break; } } - }, [makeApprovalRequest, providerState, penumbra.connect, providerManifest, failure]); + }, [ + failure, + makeApprovalRequest, + penumbra, + penumbra.connect, + providerManifest, + providerState, + setFailure, + ]); const createdContext: PenumbraContext = useMemo( () => ({ @@ -170,9 +186,12 @@ export const PenumbraContextProvider = ({ }), [ failure, - penumbra.connect, + penumbra.addEventListener, penumbra.connect, penumbra.disconnect, + penumbra.removeEventListener, + penumbra.request, + providerConnected, providerManifest, providerOrigin, providerPort,