UNPKG

accounts

Version:

Tempo Accounts SDK

306 lines 12.6 kB
import { Provider as core_Provider } from 'ox'; import { PostMessage, Transport, Wata, postMessage as core_postMessage } from 'wata'; 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) { const { close, host, icon, name, rdns, target } = options; let mount; let pending = 0; let queue = Promise.resolve(); /** Reconciles local connection state against the wallet's asserted accounts. */ let reconcile; let fallback; let inflight; /** Rejects the in-flight send when the user dismisses the mount UI. */ let reject_inflight; let resend = false; let session; /** The wallet asked to continue in a popup; stick to it for this provider. */ let sticky_popup = false; function ensure() { 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) { 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 ?? [])); }); 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) { // 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, active) { active.mount?.show(); inflight = active; const cancelled = new Promise((_, 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() { let session_popup; 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) { // 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) { if (!isSafari()) return false; return ['wallet_connect', 'eth_requestAccounts'].includes(request.method); } /** * 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, mode) { 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(); } //# sourceMappingURL=postMessage.js.map