diff --git a/.changeset/moody-bulldogs-mate.md b/.changeset/moody-bulldogs-mate.md index 140a6ef7a..e5c2723b9 100644 --- a/.changeset/moody-bulldogs-mate.md +++ b/.changeset/moody-bulldogs-mate.md @@ -2,4 +2,4 @@ '@penumbra-zone/client': major --- -add disconnect method +add disconnect method, add `create` module diff --git a/.gitignore b/.gitignore index 131bda9a6..86257dc81 100644 --- a/.gitignore +++ b/.gitignore @@ -57,7 +57,8 @@ lerna-debug.log* *_pk.bin # pack outputs -penumbra-zone-*.tgz +packages/*/penumbra-zone-*.tgz +packages/*/repo-*-*.tgz packages/*/package tsconfig.tsbuildinfo \ No newline at end of file diff --git a/apps/extension/src/content-scripts/injected-disconnect-listener.ts b/apps/extension/src/content-scripts/injected-disconnect-listener.ts deleted file mode 100644 index 8e2840529..000000000 --- a/apps/extension/src/content-scripts/injected-disconnect-listener.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { isPraxEndMessageEvent } from './message-event'; -import { PraxConnection } from '../message/prax'; - -const handleDisconnect = (ev: MessageEvent) => { - if (ev.origin === window.origin && isPraxEndMessageEvent(ev)) { - window.removeEventListener('message', handleDisconnect); - void chrome.runtime.sendMessage(PraxConnection.End); - } -}; -window.addEventListener('message', handleDisconnect); diff --git a/packages/client/package.json b/packages/client/package.json index 8b682ff20..3eaa75d22 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -16,18 +16,24 @@ "dist" ], "exports": { - ".": "./src/index.ts" + ".": "./src/index.ts", + "./create": "./src/create.ts" }, "publishConfig": { "exports": { ".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" + }, + "./create": { + "types": "./dist/create.d.ts", + "default": "./dist/create.js" } } }, "peerDependencies": { "@penumbra-zone/protobuf": "workspace:*", - "@penumbra-zone/transport-dom": "workspace:*" + "@penumbra-zone/transport-dom": "workspace:*", + "@connectrpc/connect": "^1.4.0" } } diff --git a/packages/client/src/create.ts b/packages/client/src/create.ts new file mode 100644 index 000000000..19d121409 --- /dev/null +++ b/packages/client/src/create.ts @@ -0,0 +1,127 @@ +import { createPromiseClient, type Transport } from '@connectrpc/connect'; +import type { PenumbraService } from '@penumbra-zone/protobuf'; +import { jsonOptions } from '@penumbra-zone/protobuf'; +import { + createChannelTransport, + type ChannelTransportOptions, +} from '@penumbra-zone/transport-dom/create'; +import { PenumbraSymbol, type PenumbraInjection } from '.'; + +// Naively return the first available provider origin, or `undefined`. +const availableOrigin = () => Object.keys(window[PenumbraSymbol] ?? {})[0]; + +/** + * Given a specific origin, identify the relevant injection or throw. An + * `undefined` origin is accepted but will throw. + */ +const assertProvider = (providerOrigin?: string): PenumbraInjection => { + const provider = providerOrigin && window[PenumbraSymbol]?.[providerOrigin]; + if (!provider) throw new Error(`Provider ${providerOrigin} not available`); + 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. + */ +const assertProviderManifest = async (providerOrigin?: string): Promise => { + // confirm the provider injection is present + const provider = assertProvider(providerOrigin); + + // confirm the provider manifest is located at the expected origin + if (new URL(provider.manifest).origin !== providerOrigin) + throw new Error('Provider manifest origin mismatch'); + + // confirm the provider manifest exists, can be fetched, and is json + try { + const req = await fetch(provider.manifest); + const manifest: unknown = await req.json(); + if (!manifest) throw new Error('Provider manifest not present'); + } catch { + throw new Error('Provider manifest not present'); + } + + return provider; +}; + +/** + * Asynchronously get a connection to the specified provider, or the first + * available provider if unspecified. + * + * Confirms presence of the provider's manifest. Will attempt to request + * approval if connection is not already active. + * + * @param requireProvider optional string identifying a provider origin + */ +export const getPenumbraPort = async (requireProvider?: string) => { + const provider = await assertProviderManifest(requireProvider ?? availableOrigin()); + if (provider.isConnected() === undefined) await provider.request(); + return provider.connect(); +}; + +/** + * Synchronously create a channel transport for the specified provider, or the + * first available provider if unspecified. + * + * Will always succeed, but the transport may fail if the provider is not + * present, or if the provider rejects the connection. + * + * Confirms presence of the provider's manifest. Will attempt to request + * approval if connection is not already active. + * + * @param requireProvider optional string identifying a provider origin + * @param transportOptions optional `ChannelTransportOptions` without `getPort` + */ +export const syncCreatePenumbraChannelTransport = ( + requireProvider?: string, + transportOptions: Omit = { jsonOptions }, +): Transport => + createChannelTransport({ + ...transportOptions, + getPort: () => getPenumbraPort(requireProvider), + }); + +/** + * Asynchronously create a channel transport for the specified provider, or the + * first available provider if unspecified. + * + * Like `syncCreatePenumbraChannelTransport`, but awaits connection init. + */ +export const createPenumbraChannelTransport = async ( + requireProvider?: string, + transportOptions: Omit = { jsonOptions }, +): Promise => { + const port = await getPenumbraPort(requireProvider); + return createChannelTransport({ + ...transportOptions, + getPort: () => Promise.resolve(port), + }); +}; + +/** + * Synchronously create a client for `service` from the specified provider, or the + * first available provider if unspecified. + * + * If the provider is unavailable, the client will fail to make requests. + */ +export const syncCreatePenumbraClient =

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

( + service: P, + requireProvider?: string, + transportOptions?: Omit, +) => + createPromiseClient( + service, + await createPenumbraChannelTransport(requireProvider, transportOptions), + ); diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 5577f218c..b785ececd 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -2,16 +2,76 @@ export * from './error'; export const PenumbraSymbol = Symbol.for('penumbra'); +/** This interface describes the simple API to request, connect, or disconnect a provider. + * + * There are three states for each provider, which may be identified by calling + * the synchronous method `isConnected()`: + * - `true`: a connection is available, and a call to `connect` should resolve + * - `false`: no connection available. calls to `connect` or `request` will fail + * - `undefined`: a `request` may be pending, or no `request` has been made + * + * Any script in scope may create an object like this, so clients should confirm + * a provider is actually present by confirming provider origin and fetching the + * provider manifest. Provider details such as name, version, website, brief + * descriptive text, and icons should be available in the manifest. + * + * Presently clients may expect the manifest is a chrome extension manifest v3. + * @see https://developer.chrome.com/docs/extensions/reference/manifest + * + * Clients may `request()` approval to connect. This method may reject if the + * provider chooses to deny approval. + * + * 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`. + * + ```js + import { jsonOptions } from '@penumbra-zone/protobuf'; + import { createChannelTransport } from '@penumbra-zone/transport-dom'; + + // naively get first available provider + const provider = Object.values(window[Symbol.for('penumbra')])[0]; + void provider.request(); + + // establish a transport + const transport = createChannelTransport({ jsonOptions, getPort: provider.connect }); + + // export function to create client + export const createPenumbraClient = serviceType => createPromiseClient(serviceType, transport); + ``` +* +* +*/ export interface PenumbraInjection { + /** Should be called by a page creating a channel transport. It returns a + * promise that may resolve with an active `MessagePort` providing a channel + * to this provider. */ readonly connect: () => Promise; + + /** Should be called by a page that does not yet have approval to connect. + * Returns a promise that may reject with an enumerated failure reason. */ readonly request: () => Promise; + + /** Indicate the provider should revoke approval of this origin. */ readonly disconnect: () => Promise; + + /** Should synchronously return the present connection state. + * + * - `true` indicates active connection. + * - `false` indicates connection is closed or rejected. + * - `undefined` indicates connection may be attempted. + */ readonly isConnected: () => boolean | undefined; + + /** Should contain a URI located at the provider's origin, which returns a + * chrome extension manifest v3 describing this provider. */ readonly manifest: string; } declare global { interface Window { + /** Record upon this global should identify themselves by a field name matching their origin. */ readonly [PenumbraSymbol]?: undefined | Readonly>; } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fdd2d7344..8f854c5be 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -323,6 +323,9 @@ importers: packages/client: dependencies: + '@connectrpc/connect': + specifier: ^1.4.0 + version: 1.4.0(@bufbuild/protobuf@1.10.0) '@penumbra-zone/protobuf': specifier: workspace:* version: link:../protobuf