accounts
Version:
Tempo Accounts SDK
306 lines • 12.6 kB
JavaScript
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