UNPKG

accounts

Version:

Tempo Accounts SDK

377 lines (358 loc) 13.8 kB
import { Provider as core_Provider } from 'ox' import { PostMessage, Transport, Wata, postMessage as core_postMessage } from 'wata' import type * as Adapter from '../../Adapter.js' import { isSafari } from '../../Dialog.js' import * as Store from '../../Store.js' import { fromRequest } from '../internal/fromRequest.js' import * as Mount from './mount.js' /** * Creates a postMessage adapter that forwards wallet RPC through a Wata * postMessage session. * * One provider holds one session to one wallet window. The wallet page * mounts in a hidden overlay iframe by default (a popup where iframes * can't work — see {@link Mount.auto}), surfaces while requests are * pending, and is put away once the queue drains: the iframe hides, a * popup closes. Dismissing the UI or closing the window rejects the * in-flight request; `wallet_disconnect` tears the session down. * * Safari account requests use a temporary popup because Safari rejects * WebAuthn creation inside cross-origin iframes. When the wallet detects its * iframe is occluded it asks to continue in a popup; the adapter remounts and * re-sends the in-flight request there. */ export function postMessage(options: postMessage.Options): Adapter.Adapter { const { close, host, icon, name, rdns, target } = options type Session = ReturnType<typeof create> type Target = { close?: (() => Promise<void>) | undefined mount: Mount.Mount | undefined session: Session } let mount: Mount.Mount | undefined let pending = 0 let queue: Promise<unknown> = Promise.resolve() /** Reconciles local connection state against the wallet's asserted accounts. */ let reconcile: ((accounts: readonly string[]) => void) | undefined let fallback: Target | undefined let inflight: Target | undefined /** Rejects the in-flight send when the user dismisses the mount UI. */ let reject_inflight: ((error: Error) => void) | undefined let resend = false let session: Session | undefined /** The wallet asked to continue in a popup; stick to it for this provider. */ let sticky_popup = false function ensure(): Session { if (session) return session if (target) { session = create({ close, host: hostUrl(host), target }) return session } const factory = sticky_popup ? Mount.popup() : (options.mount ?? Mount.auto({ source: new URL(host).hostname.replace(/^www\./, ''), })) const url = hostUrl(host, factory.mode) const mount_ = factory({ host: url, onDismiss: cancel, onInvalidate: () => void session?.close().catch(() => {}), }) mount = mount_ session = create({ connect: mount_.mode === 'iframe' ? 'eager' : 'lazy', close: (handle) => mount_.close(handle), host: url, target: () => mount_.target(), }) return session } // Creates and starts a session, wiring inbound wallet notifications. function create(transport: { close: ((handle: Window | MessagePort) => void | Promise<void>) | undefined connect?: 'eager' | 'lazy' | undefined host: string target: NonNullable<postMessage.Options['target']> }) { const wata = Wata.create({ transports: [ core_postMessage({ host: transport.host, ...(transport.close ? { close: transport.close } : {}), ...(transport.connect ? { connect: transport.connect } : {}), async target(parameters) { const acquired = await transport.target(parameters) if (!acquired) throw new PostMessage.PopupBlockedError('the wallet page popup was blocked') return acquired }, }), ], }) const session = wata.start() session.onNotification((event) => { if (event.method === 'switch-mode') void switchToPopup() // The wallet asserts its current accounts (e.g. on connect, or a // wallet-side logout) so the SDK can drop a stale persisted session. else if (event.method === 'accountsChanged') void reconcile?.((event.params ?? []) as readonly string[]) }) return session } /** * Remounts the session in a popup at the wallet's request (occluded * iframe). Closing the old session rejects the in-flight send, which * `send` then replays over the popup session. */ async function switchToPopup() { if (sticky_popup || target) return sticky_popup = true resend = pending > 0 const mount_old = mount const session_old = session mount = undefined session = undefined mount_old?.destroy() await session_old?.close().catch(() => {}) } /** * Dismissal from the mount UI. Rejects the in-flight request locally * right away — a wedged iframe has no closed-window poll, so waiting for * the wallet's response could hang forever — while still notifying the * wallet so it tears down its own pending request and returns to idle. */ function cancel() { void inflight?.session.notify({ method: 'cancel', params: [] }).catch(() => {}) reject_inflight?.(new core_Provider.UserRejectedRequestError()) inflight?.mount?.hide() } async function send(request: { method: string params?: readonly unknown[] | undefined context?: { account?: string | undefined; chainId?: number | undefined } | undefined }) { // Loops only to replay once after an occlusion-driven popup switch // (`resend`); the request otherwise leaves via one of three exits — // resolved, locally cancelled (dismiss), or rejected (window closed). for (;;) { const session = ensure() if (requiresSafariPopup(request) && !target && !sticky_popup && mount?.mode !== 'popup') return await sendWithPopup(request) try { return await sendWith(request, { mount, session }) } catch (error) { if (error instanceof Transport.ClosedError) { // The wallet asked to continue in a popup — replay there. if (resend) { resend = false continue } // Otherwise the wallet window closing is the user backing out. throw new core_Provider.UserRejectedRequestError() } throw error } } } async function sendWith( request: { method: string params?: readonly unknown[] | undefined context?: { account?: string | undefined; chainId?: number | undefined } | undefined }, active: Target, ) { active.mount?.show() inflight = active const cancelled = new Promise<never>((_, reject) => { reject_inflight = reject }) try { const sent = active.session.send({ method: request.method, params: request.params ?? [], ...(request.context ? { context: request.context } : {}), }) // Once `cancelled` wins the race, the wallet's eventual answer is // ignored; swallow it so it never surfaces as an unhandled rejection. void sent.catch(() => {}) return (await Promise.race([sent, cancelled])).result } finally { reject_inflight = undefined inflight = undefined } } // Builds a popup channel (mount + session). Acquiring its `target` opens the // window; the caller drives that so the open can land inside a user gesture // (Safari only sizes a gesture-opened popup). function openPopup(): Target { let session_popup: Session | undefined const factory = Mount.popup() const url = hostUrl(host, factory.mode) const mount_popup = factory({ host: url, onDismiss: cancel, onInvalidate: () => void session_popup?.close().catch(() => {}), }) let closed = false return { close: async () => { if (closed) return closed = true mount_popup.destroy() await session_popup?.close().catch(() => {}) }, mount: mount_popup, session: (session_popup = create({ close: (handle) => mount_popup.close(handle), host: url, target: () => mount_popup.target(), })), } } async function sendWithPopup(request: { method: string params?: readonly unknown[] | undefined context?: { account?: string | undefined; chainId?: number | undefined } | undefined }) { // Reuse the popup `request` pre-opened in the user's gesture, if any. const active = fallback ?? openPopup() fallback = active try { return await sendWith(request, active) } catch (error) { if (error instanceof Transport.ClosedError) throw new core_Provider.UserRejectedRequestError() throw error } finally { if (fallback === active) fallback = undefined await active.close?.() } } // Warm the wallet page and start the session before the first request. // `start()` connects in the background; failures surface on the next send. if (typeof window !== 'undefined' && !target && document.body) try { ensure() } catch {} return fromRequest({ ...(icon ? { icon } : {}), name, rdns, bind({ store }) { // Drop a persisted session the wallet no longer honors: if none of // the cached accounts appear in the wallet's asserted list, disconnect // locally. Only acts when there is cached state to reconcile, and // never establishes a connection the app didn't ask for. reconcile = async (accounts) => { // The wallet can assert before the store finishes rehydrating its // persisted accounts; wait so we both see the cached account and // win against the hydration that would otherwise re-add it. await Store.waitForHydration(store) const cached = store.getState().accounts if (cached.length === 0) return const asserted = new Set(accounts.map((address) => address.toLowerCase())) if (cached.some((account) => asserted.has(account.address.toLowerCase()))) return store.disconnect() } }, // No `close` (disconnect) hook: the session and mount stay warm across // disconnect so the next login reuses the already-handshaked session. cleanup() { reject_inflight?.(new core_Provider.UserRejectedRequestError()) void session?.close().catch(() => {}) void fallback?.close?.() fallback = undefined mount?.destroy() mount = undefined }, request(request) { pending += 1 // Open the popup inside the caller's gesture; Safari only sizes a popup // opened synchronously from the click, and the session otherwise acquires // it from the async `queue.then` below (full-screen). Covers the popup // mount and the per-request Safari account popup; `target` is idempotent, // so the transport's later acquire reuses the same window. if (mount?.mode === 'popup') mount.target() else if (requiresSafariPopup(request) && !target && !sticky_popup) { fallback ??= openPopup() fallback.mount?.target() } const result = queue.then(() => send(request)) queue = result.catch(() => undefined) void result .catch(() => undefined) .finally(() => { pending -= 1 if (pending === 0) mount?.hide() }) return result }, }) } function requiresSafariPopup(request: { method: string params?: readonly unknown[] | undefined }): boolean { if (!isSafari()) return false return ['wallet_connect', 'eth_requestAccounts'].includes(request.method) } export declare namespace postMessage { /** Options for {@link postMessage}. */ export type Options = { /** * Cleanup for a `target`-acquired handle, called when the session * closes. Only used with {@link Options.target}; mounts own their * handle cleanup. @default `handle.close()` */ close?: ((handle: Window | MessagePort) => void | Promise<void>) | undefined /** URL of the wallet's post-message page. */ host: string /** Data URI of the provider icon, announced via EIP-6963. */ icon?: Adapter.Meta['icon'] | undefined /** * Where the wallet page lives and how it surfaces for requests. * @default `Mount.auto()` — an overlay iframe, or a popup where * iframes can't work (insecure context, no IO v2 for untrusted hosts). */ mount?: Mount.Factory | undefined /** Provider display name. */ name: string /** Reverse-DNS provider identifier. */ rdns: string /** * Low-level override for the session's window handle, bypassing * mounts entirely (no UI is created). Receives the page URL and * returns a `Window` or `MessagePort`. A nullish handle rejects the * request with `PostMessage.PopupBlockedError`. */ target?: | ((parameters: { /** Wallet page URL to mount. */ host: string | undefined }) => | PostMessage.Target | null | undefined | Promise<PostMessage.Target | null | undefined>) | undefined } } /** * Tags the wallet page URL with this app's origin — so the wallet can pin * its `postMessage` responses before the first frame arrives — and the * mount mode, so approvals render matching chrome from first paint. A page * claiming a foreign origin gains nothing: the wallet only honors frames * whose event origin matches the pinned value. * * Non-browser sessions (e.g. `MessagePort` targets in tests) carry no * origin, so the URL is passed through untouched. */ function hostUrl(host: string, mode?: 'iframe' | 'popup'): string { if (typeof window === 'undefined') return host const url = new URL(host) url.searchParams.set('origin', window.location.origin) if (mode) url.searchParams.set('mode', mode) return url.toString() }