Skip to content

Commit

Permalink
react provider 3 (#1450)
Browse files Browse the repository at this point in the history
* react wallet
* provide more specific interface, type guards from client package
* use event targets and listeners
  • Loading branch information
turbocrime authored Jul 15, 2024
1 parent 161ed5c commit 72d83ea
Show file tree
Hide file tree
Showing 22 changed files with 1,013 additions and 202 deletions.
5 changes: 5 additions & 0 deletions .changeset/dirty-mice-help.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@penumbra-zone/react': major
---

initial react wallet
5 changes: 5 additions & 0 deletions .changeset/tidy-spiders-hang.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@penumbra-zone/client': minor
---

provide type guards for event, and better types for event interface
27 changes: 9 additions & 18 deletions apps/minifront/src/prax.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,19 @@
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';
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 {
Expand All @@ -29,7 +26,7 @@ export const isPraxConnected = () => {

export const isPraxInstalled = async () => {
try {
await assertProviderManifest();
await assertProviderManifest(prax_origin);
return true;
} catch {
return false;
Expand All @@ -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 = <T extends PenumbraService>(service: T): PromiseClient<T> =>
Expand Down
8 changes: 4 additions & 4 deletions packages/client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,17 +20,17 @@
],
"exports": {
".": "./src/index.ts",
"./create": "./src/create.ts"
"./*": "./src/*.ts"
},
"publishConfig": {
"exports": {
".": {
"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"
}
}
},
Expand Down
82 changes: 82 additions & 0 deletions packages/client/src/assert.ts
Original file line number Diff line number Diff line change
@@ -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;
};
82 changes: 8 additions & 74 deletions packages/client/src/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<PenumbraInjection> => {
// 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.
Expand All @@ -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();
}
Expand All @@ -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<ChannelTransportOptions, 'getPort'> = { jsonOptions },
): Transport =>
Expand Down Expand Up @@ -140,10 +74,10 @@ export const createPenumbraChannelTransport = async (
*
* If the provider is unavailable, the client will fail to make requests.
*/
export const syncCreatePenumbraClient = <P extends PenumbraService>(
export const createPenumbraClientSync = <P extends PenumbraService>(
service: P,
requireProvider?: string,
) => createPromiseClient(service, syncCreatePenumbraChannelTransport(requireProvider));
) => createPromiseClient(service, createPenumbraChannelTransportSync(requireProvider));

/**
* Asynchronously create a client for `service` from the specified provider, or
Expand Down
50 changes: 43 additions & 7 deletions packages/client/src/event.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,51 @@
import { PenumbraInjectionState, PenumbraSymbol } from './index.js';
import { PenumbraState } from './state.js';
import { PenumbraSymbol } from './symbol.js';

export class PenumbraInjectionStateEvent extends CustomEvent<{
export interface PenumbraStateEventDetail {
origin: string;
state?: PenumbraInjectionState;
}> {
constructor(injectionProviderOrigin: string, injectionState?: PenumbraInjectionState) {
state?: PenumbraState;
}

export class PenumbraStateEvent extends CustomEvent<PenumbraStateEventDetail> {
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 isPenumbraStateEvent = (evt: Event): evt is PenumbraStateEvent =>
evt instanceof PenumbraStateEvent || ('detail' in evt && isPenumbraStateEventDetail(evt.detail));

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 is required for type inference
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type ParametersTail<T extends (...args: any[]) => any> =
Parameters<T> extends [unknown, ...infer TailParams] ? TailParams : never;

// like EventTarget, but restricts possible event types
interface SpecificEventTarget<SpecificTypeName extends string, SpecificEvent extends Event = Event>
extends EventTarget {
addEventListener: (
type: SpecificTypeName,
...rest: ParametersTail<EventTarget['addEventListener']>
) => void;
removeEventListener: (
type: SpecificTypeName,
...rest: ParametersTail<EventTarget['removeEventListener']>
) => void;
dispatchEvent: (event: SpecificEvent) => boolean;
}

export type PenumbraStateEventTarget = Omit<
SpecificEventTarget<'penumbrastate', never>,
'dispatchEvent'
>;
Loading

0 comments on commit 72d83ea

Please sign in to comment.