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