accounts
Version:
Tempo Accounts SDK
377 lines (358 loc) • 13.8 kB
text/typescript
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()
}