Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 79 additions & 42 deletions packages/wallet-sdk/src/core/Dialog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,21 +37,44 @@ export type SecurityState = {
host: boolean;
};

/**
* Inbound callbacks the consumer (e.g. `Wallet.ts`) passes when constructing a
* dialog. The factory wires these into the messenger internally — consumers
* never touch the underlying transport directly. Mirrors Porto's design,
* which uses a shared store + handler functions instead of exposing the
* messenger surface (see `porto/src/core/Dialog.ts`).
*/
export type DialogHandlers = {
/** Fired when the wallet host returns an RPC response. */
onResponse(
response: Messenger.RpcResponse & { _request: Messenger.RpcRequest },
): void;
/** Fired for internal control payloads (e.g. mode switch requests). */
onInternal(payload: Messenger.InternalPayload): void;
/** Fired when the wallet host (or popup-close watcher) signals close. */
onClose(): void;
};

export type DialogHandle = {
readonly mode: DialogMode;
readonly messenger: Messenger.Bridge;
open(): void;
close(): void;
destroy(): void;
/** Run the security checklist. Resolves once the messenger is ready. */
secure(): Promise<SecurityState>;
/** Forward an RPC request to the wallet host. */
syncRequest(request: Messenger.RpcRequest): void;
/** Resolve once the wallet host announces ready (with its capabilities). */
waitForReady(): Promise<Messenger.ReadyOptions>;
};

export type DialogFactory = (parameters: {
/** URL of the wallet host (e.g. `https://wallet.abs.xyz`). */
host: string;
/** Theme hint forwarded in the init payload. */
theme?: "light" | "dark" | undefined;
/** Inbound callbacks. Wired into the messenger by the factory. */
handlers: DialogHandlers;
}) => DialogHandle;

// ---------- Internals ----------
Expand Down Expand Up @@ -112,7 +135,7 @@ export type IframeOptions = {
export function iframe(options: IframeOptions = {}): DialogFactory {
const { skipProtocolCheck = false } = options;

return ({ host, theme }) => {
return ({ host, theme, handlers }) => {
if (typeof window === "undefined") return noopHandle();

const hostOrigin = originOf(host);
Expand Down Expand Up @@ -199,6 +222,11 @@ export function iframe(options: IframeOptions = {}): DialogFactory {
waitForReady: true,
});

// Inbound wiring is internal — the consumer never touches the messenger.
messenger.on("rpc-response", handlers.onResponse);
messenger.on("__internal", handlers.onInternal);
messenger.on("close", handlers.onClose);

const drawerModeQuery = window.matchMedia(
`(max-width: ${DRAWER_BREAKPOINT}px)`,
);
Expand Down Expand Up @@ -303,7 +331,6 @@ export function iframe(options: IframeOptions = {}): DialogFactory {

const handle: DialogHandle = {
mode: "iframe",
messenger,
open() {
showDialog();
activateDialog();
Expand Down Expand Up @@ -337,6 +364,12 @@ export function iframe(options: IframeOptions = {}): DialogFactory {
const frameOk = IO.supported() || trustedHost;
return { protocol, frame: frameOk, host: trustedHost };
},
syncRequest(request) {
messenger.send("rpc-request", request);
},
waitForReady() {
return messenger.waitForReady();
},
};

return handle;
Expand All @@ -354,7 +387,7 @@ export type PopupOptions = {
export function popup(options: PopupOptions = {}): DialogFactory {
const { type = "auto", size = DEFAULT_POPUP_SIZE } = options;

return ({ host, theme }) => {
return ({ host, theme, handlers }) => {
if (typeof window === "undefined") return noopHandle();

const hostOrigin = originOf(host);
Expand All @@ -364,7 +397,11 @@ export function popup(options: PopupOptions = {}): DialogFactory {
: "popup";

let win: Window | null = null;
let bridge: Messenger.Bridge | null = null;
// The messenger is constructed inside `open()` because `Messenger.fromWindow`
// requires the popup's Window reference, which only exists after
// `window.open()`. Mirrors Porto's design — consumers never touch this
// directly; they go through `syncRequest` / `waitForReady` instead.
let messenger: Messenger.Bridge | null = null;
let pollClosed: ReturnType<typeof setInterval> | null = null;

function teardownPoll() {
Expand All @@ -376,21 +413,16 @@ export function popup(options: PopupOptions = {}): DialogFactory {

const handle: DialogHandle = {
mode: "popup",
// Lazily-bound — the bridge isn't constructable until `open()` runs.
// Consumers should always call `open()` before reading `messenger`.
get messenger(): Messenger.Bridge {
if (!bridge)
throw new Error(
"Popup messenger not initialised — call open() before sending",
);
return bridge;
},
open() {
if (win && !win.closed) {
win.focus();
return;
}

teardownPoll();
messenger?.destroy();
messenger = null;

const features =
resolvedType === "popup"
? `width=${size.width},height=${size.height},left=${
Expand All @@ -409,25 +441,31 @@ export function popup(options: PopupOptions = {}): DialogFactory {
);
if (!win) throw new Error("Popup blocked by browser");

bridge = Messenger.bridge({
messenger = Messenger.bridge({
from: Messenger.fromWindow(window, { targetOrigin: hostOrigin }),
to: Messenger.fromWindow(win, { targetOrigin: hostOrigin }),
waitForReady: true,
});

bridge.send("__internal", {
// Wire inbound listeners now that the bridge exists. They live for
// the lifetime of this handle (until `destroy()`).
messenger.on("rpc-response", handlers.onResponse);
messenger.on("__internal", handlers.onInternal);
messenger.on("close", handlers.onClose);

messenger.send("__internal", {
type: "init",
mode: "popup",
referrer: getReferrer(),
theme,
});

// If the user closes the popup without acting, treat that as a
// rejection — the consumer's outstanding requests should reject.
// If the user closes the popup without acting, surface the close
// locally so outstanding requests get rejected as user-rejections.
pollClosed = setInterval(() => {
if (win?.closed) {
teardownPoll();
bridge?.send("close", undefined);
handlers.onClose();
}
}, 250);
},
Expand All @@ -439,17 +477,30 @@ export function popup(options: PopupOptions = {}): DialogFactory {
/* COOP severs the WindowProxy — popup closes itself anyway */
}
win = null;
messenger?.destroy();
messenger = null;
},
destroy() {
this.close();
bridge?.destroy();
bridge = null;
},
async secure() {
// Popups don't suffer from clickjacking — the wallet runs in its own
// top-level window, fully visible to the user. So all gates pass.
return { protocol: true, frame: true, host: true };
},
syncRequest(request) {
// Optional-chained — mirrors Porto's `messenger?.send(...)`. If a
// caller forgets to `open()` first, the send is silently dropped
// rather than throwing; the request stays in the consumer's pending
// map and will be retried on the next mode switch.
messenger?.send("rpc-request", request);
},
async waitForReady() {
if (!messenger) {
throw new Error("Popup not opened — call open() first");
}
return messenger.waitForReady();
},
};

return handle;
Expand All @@ -459,29 +510,8 @@ export function popup(options: PopupOptions = {}): DialogFactory {
// ---------- Noop (SSR / non-browser) ----------

function noopHandle(): DialogHandle {
const noopMessenger: Messenger.Bridge = {
on: () => () => {
/* no-op: SSR / non-browser environments have no message bus */
},
send: <T extends Messenger.Topic>(
topic: T,
payload: Messenger.Payload<T>,
) => ({ id: "", topic, payload }),
destroy: () => {
/* no-op: nothing to tear down */
},
ready: () => {
/* no-op: ready handshake never fires in SSR */
},
waitForReady: () =>
new Promise<Messenger.ReadyOptions>(() => {
/* never resolves in SSR — caller is expected to abort on its own */
}),
};

return {
mode: "iframe",
messenger: noopMessenger,
open() {
/* no-op: cannot mount a dialog without a window */
},
Expand All @@ -494,5 +524,12 @@ function noopHandle(): DialogHandle {
async secure() {
return { protocol: false, frame: false, host: false };
},
syncRequest() {
/* no-op: no transport in SSR */
},
waitForReady() {
// Never resolves in SSR — caller is expected to abort on its own.
return new Promise<Messenger.ReadyOptions>(() => {});
},
};
}
50 changes: 27 additions & 23 deletions packages/wallet-sdk/src/core/Wallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,19 +91,13 @@ export function createWallet(config: WalletConfig): Wallet {
const pending = new Map<number | string, Pending>();
let nextRequestId = 1;

function ensureActive(targetMode: Dialog.DialogMode): Dialog.DialogHandle {
if (active && activeMode === targetMode) return active;
// Tear down previous mode (if any) before swapping.
active?.destroy();
const factory = targetMode === "iframe" ? iframeFactory : popupFactory;
active = factory({ host, theme });
activeMode = targetMode;
bindMessengerListeners(active);
return active;
}

function bindMessengerListeners(handle: Dialog.DialogHandle) {
handle.messenger.on("rpc-response", (response) => {
// Inbound callbacks the dialog factories wire into their messenger
// internally. We rebuild this struct per-handle so the closures always
// close over the correct `active` reference (after a mode swap, stale
// events from the previous transport are ignored because that handle's
// messenger is destroyed by `previous?.destroy()` in `switchMode`).
const handlers: Dialog.DialogHandlers = {
onResponse(response) {
const p = pending.get(response.id);
if (!p) return;
pending.delete(response.id);
Expand All @@ -115,13 +109,13 @@ export function createWallet(config: WalletConfig): Wallet {
}),
);
else p.resolve(response.result);
if (pending.size === 0) handle.close();
});
handle.messenger.on("__internal", (payload) => {
if (pending.size === 0) active?.close();
},
onInternal(payload) {
if (payload.type === "switch") void switchMode(payload.mode);
});
handle.messenger.on("close", () => {
handle.close();
},
onClose() {
active?.close();
// Reject every outstanding request with a user-rejected style error.
for (const [id, p] of pending) {
pending.delete(id);
Expand All @@ -131,7 +125,17 @@ export function createWallet(config: WalletConfig): Wallet {
}),
);
}
});
},
};

function ensureActive(targetMode: Dialog.DialogMode): Dialog.DialogHandle {
if (active && activeMode === targetMode) return active;
// Tear down previous mode (if any) before swapping.
active?.destroy();
const factory = targetMode === "iframe" ? iframeFactory : popupFactory;
active = factory({ host, theme, handlers });
activeMode = targetMode;
return active;
}

async function switchMode(target: Dialog.DialogMode) {
Expand All @@ -141,7 +145,7 @@ export function createWallet(config: WalletConfig): Wallet {
next.open();
// Re-deliver pending requests against the new transport.
for (const p of pending.values()) {
next.messenger.send("rpc-request", p.request);
next.syncRequest(p.request);
}
previous?.destroy();
}
Expand Down Expand Up @@ -207,7 +211,7 @@ export function createWallet(config: WalletConfig): Wallet {
// safe. If not, transparently downgrade to popup before sending.
let targetHandle = handle;
if (initialMode === "iframe") {
const ready = await handle.messenger.waitForReady();
const ready = await handle.waitForReady();
const sec = await handle.secure();
const alwaysHeadless = isAlwaysHeadless(rpc, ready.methodPolicies);
if (!alwaysHeadless && (!sec.protocol || !sec.frame)) {
Expand All @@ -227,7 +231,7 @@ export function createWallet(config: WalletConfig): Wallet {

return new Promise((resolve, reject) => {
pending.set(id, { request: rpc, resolve, reject });
targetHandle.messenger.send("rpc-request", rpc);
targetHandle.syncRequest(rpc);
});
}

Expand Down
1 change: 1 addition & 0 deletions packages/wallet-sdk/src/exports/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export type {
DialogFactory,
DialogHandle,
DialogHandlers,
DialogMode,
IframeOptions,
PopupOptions,
Expand Down
Loading
Loading