@perawallet/connect
Version:
JavaScript SDK for integrating Pera Wallet to web applications.
546 lines (462 loc) • 15.3 kB
text/typescript
/* eslint-disable max-lines */
/**
* Unified Webview Bridge
* Handles communication with both Android and iOS platforms using JSON-RPC 2.0
* Combines JSON-RPC utilities, error handling, platform detection, message listener, and bridge functions
*/
// Reference global types
// eslint-disable-next-line spaced-comment, @typescript-eslint/triple-slash-reference
/// <reference path="./webviewBridgeTypes.d.ts" />
import {
type Platform,
type JsonRpcRequest,
type JsonRpcNotification,
type JsonRpcResponse,
type JsonRpcBatchRequest,
type JsonRpcBatchResponse,
type JsonRpcError,
JsonRpcErrorCode
} from "./webviewApiTypes";
/**
* Mobile method names that can be called via the bridge
*/
export type MobileMethodName = "getPublicSettings";
// ============================================================================
// JSON-RPC Utilities
// ============================================================================
/**
* Request ID generator
* Uses timestamp + incrementing counter for better uniqueness
*/
let requestIdCounter = 0;
/**
* Generate a unique request ID
* Combines timestamp (last 8 digits) with a counter for uniqueness
* This ensures uniqueness even if counter wraps around or multiple SDK instances exist
*/
function generateRequestId(): number {
const timestamp = Date.now();
// eslint-disable-next-line no-plusplus
const counter = requestIdCounter++;
// Use last 8 digits of timestamp + 4-digit counter
// This gives us: YYYYMMDDHHMMSS + 0000-9999 counter
// Example: 12345678 + 0001 = 123456780001
// eslint-disable-next-line no-magic-numbers
const timestampPart = timestamp.toString().slice(-8);
// eslint-disable-next-line no-magic-numbers
const counterPart = counter.toString().padStart(4, "0");
return parseInt(`${timestampPart}${counterPart}`, 10);
}
/**
* Create a JSON-RPC 2.0 request object
*/
function createRequest(
method: string,
params?: unknown,
id?: number | string
): JsonRpcRequest {
const requestId = id ?? generateRequestId();
return {
jsonrpc: "2.0",
method,
...(params !== undefined && { params }),
id: requestId
};
}
/**
* Type guard: Check if object is a JSON-RPC response (success or error)
*/
function isJsonRpcResponse(obj: unknown): obj is JsonRpcResponse {
if (!obj || typeof obj !== "object") {
return false;
}
const resp = obj as Record<string, unknown>;
return (
resp.jsonrpc === "2.0" &&
("result" in resp || "error" in resp) &&
(resp.id === null || typeof resp.id === "string" || typeof resp.id === "number")
);
}
/**
* Type guard: Check if object is a JSON-RPC batch response
*/
function isJsonRpcBatchResponse(obj: unknown): obj is JsonRpcBatchResponse {
return Array.isArray(obj) && obj.every((item) => isJsonRpcResponse(item));
}
/**
* Type guard: Check if object is a JSON-RPC notification
*/
function isJsonRpcNotification(obj: unknown): obj is JsonRpcNotification {
if (!obj || typeof obj !== "object") {
return false;
}
const notif = obj as Record<string, unknown>;
return (
notif.jsonrpc === "2.0" &&
typeof notif.method === "string" &&
!("id" in notif)
);
}
// ============================================================================
// JSON-RPC Error Handling
// ============================================================================
/**
* Base class for JSON-RPC errors
*/
class JsonRpcErrorClass extends Error {
public readonly code: number;
public readonly data?: unknown;
constructor(error: JsonRpcError) {
super(error.message);
this.name = "JsonRpcError";
this.code = error.code;
this.data = error.data;
// Maintains proper stack trace for where our error was thrown (only available on V8)
if (Error.captureStackTrace) {
Error.captureStackTrace(this, JsonRpcErrorClass);
}
}
/**
* Get error message with code
*/
getMessage(): string {
return `[${this.code}] ${this.message}`;
}
}
/**
* Convert JSON-RPC error object to appropriate error class instance
*/
function createJsonRpcError(error: JsonRpcError): JsonRpcErrorClass {
switch (error.code) {
case JsonRpcErrorCode.ParseError:
return new JsonRpcErrorClass({
code: JsonRpcErrorCode.ParseError,
message: "Parse error",
data: error.data
});
case JsonRpcErrorCode.InvalidRequest:
return new JsonRpcErrorClass({
code: JsonRpcErrorCode.InvalidRequest,
message: "Invalid Request",
data: error.data
});
case JsonRpcErrorCode.MethodNotFound:
return new JsonRpcErrorClass({
code: JsonRpcErrorCode.MethodNotFound,
message: error.message || "Method not found",
data: error.data
});
case JsonRpcErrorCode.InvalidParams:
return new JsonRpcErrorClass({
code: JsonRpcErrorCode.InvalidParams,
message: "Invalid params",
data: error.data
});
case JsonRpcErrorCode.InternalError:
return new JsonRpcErrorClass({
code: JsonRpcErrorCode.InternalError,
message: "Internal error",
data: error.data
});
default:
// Server errors (-32000 to -32099) or custom errors
if (
error.code >= JsonRpcErrorCode.ServerErrorStart &&
error.code <= JsonRpcErrorCode.ServerErrorEnd
) {
return new JsonRpcErrorClass(error);
}
// Custom application errors
return new JsonRpcErrorClass(error);
}
}
// ============================================================================
// Platform Detection
// ============================================================================
/**
* Detect the current platform
* Reserved for future use
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export function detectPlatform(): Platform {
if (typeof window === "undefined") {
return "unknown";
}
if (window.peraMobileInterface) {
return "android";
}
if (window.webkit?.messageHandlers) {
return "ios";
}
return "unknown";
}
/**
* Get Android interface if available
*/
function getAndroidInterface() {
return window.peraMobileInterface as PeraMobileAndroidInterface | undefined;
}
/**
* Get iOS interface if available
*/
function getIosInterface() {
return window.webkit?.messageHandlers as PeraMobileIosInterface | undefined;
}
// ============================================================================
// Message Listener
// ============================================================================
type ActionHandler<T> = (payload: T) => void;
interface PendingRequest<T> {
resolve: (value: T) => void;
reject: (error: Error) => void;
timeoutHandle: ReturnType<typeof setTimeout>;
method: string;
}
class MessageListener {
private listeners: Map<string, Set<ActionHandler<unknown>>> = new Map();
private pendingRequests: Map<string | number, PendingRequest<unknown>> = new Map();
private isListening = false;
/**
* Initialize the global message listener (only once)
*/
private startListening(): void {
if (this.isListening) {
return;
}
window.addEventListener("message", this.handleMessage.bind(this));
this.isListening = true;
}
/**
* Handle incoming messages from mobile
* Supports JSON-RPC 2.0 format
*/
private handleMessage(event: MessageEvent): void {
try {
// Parse event.data as JSON
const data = typeof event.data === "string" ? JSON.parse(event.data) : event.data;
if (!data || typeof data !== "object") {
return;
}
// Handle JSON-RPC 2.0 batch response
if (isJsonRpcBatchResponse(data)) {
this.handleBatchResponse(data);
return;
}
// Handle JSON-RPC 2.0 single response
if (isJsonRpcResponse(data)) {
this.handleJsonRpcResponse(data);
return;
}
// Handle JSON-RPC 2.0 notification (for event-based subscriptions)
if (isJsonRpcNotification(data)) {
this.handleJsonRpcNotification(data);
}
} catch (error) {
// Silently ignore parsing errors
// This prevents errors from malformed messages breaking the app
}
}
/**
* Handle JSON-RPC 2.0 batch response
*/
private handleBatchResponse(batchResponse: JsonRpcBatchResponse): void {
batchResponse.forEach((response) => {
this.handleJsonRpcResponse(response);
});
}
/**
* Handle JSON-RPC 2.0 response (success or error)
*/
private handleJsonRpcResponse(response: JsonRpcResponse): void {
const { id } = response;
// Responses with null id are invalid (except for parse errors)
if (id === null) {
return;
}
const pendingRequest = this.pendingRequests.get(id);
if (!pendingRequest) {
return;
}
clearTimeout(pendingRequest.timeoutHandle);
this.pendingRequests.delete(id);
// Handle error response
if ("error" in response) {
const error = createJsonRpcError(response.error);
pendingRequest.reject(error);
return;
}
// Handle success response
if ("result" in response) {
try {
const parsedResult = this.parsePayload(response.result);
pendingRequest.resolve(parsedResult as never);
} catch (error) {
pendingRequest.reject(
error instanceof Error
? error
: new Error(`Failed to parse result for method ${pendingRequest.method}`)
);
}
}
}
/**
* Handle JSON-RPC 2.0 notification (for event-based subscriptions)
*/
private handleJsonRpcNotification(notification: { method: string; params?: unknown }): void {
const { method, params } = notification;
const handlers = this.listeners.get(method);
if (handlers) {
const parsedParams = this.parsePayload(params);
handlers.forEach((handler) => {
try {
handler(parsedParams);
} catch (error) {
console.error(`Error in handler for method ${method}:`, error);
}
});
}
}
/**
* Parse payload - handles base64 decoding and JSON parsing
*/
private parsePayload(payload: unknown): unknown {
if (typeof payload === "string") {
// Try base64 decode first
try {
const decoded = window.atob(payload);
return JSON.parse(decoded);
} catch {
// If base64 decode fails, try direct JSON parse
try {
return JSON.parse(payload);
} catch {
// If both fail, return as-is
return payload;
}
}
}
// If payload is already an object, return as-is
return payload;
}
/**
* Register a one-time listener for a JSON-RPC response by ID
* Returns a promise that resolves when the response is received
*/
// eslint-disable-next-line no-magic-numbers
waitForResponse<T>(id: string | number, method: string, timeoutMs = 5000): Promise<T> {
this.startListening();
return new Promise<T>((resolve, reject) => {
// Check if there's already a pending request for this ID
const existing = this.pendingRequests.get(id);
if (existing) {
clearTimeout(existing.timeoutHandle);
existing.reject(new Error(`New request for ${method} (id: ${id}) cancelled previous request`));
}
const timeoutHandle = setTimeout(() => {
this.pendingRequests.delete(id);
reject(new Error(`Timeout waiting for response from ${method} (id: ${id})`));
}, timeoutMs);
this.pendingRequests.set(id, {
resolve: resolve as (value: unknown) => void,
reject,
timeoutHandle,
method
});
});
}
/**
* Register a persistent listener for a specific action/method
* Useful for event-based subscriptions (e.g., onBackPressed)
* Works with JSON-RPC 2.0 notifications (method field)
*/
onAction<T>(action: string, handler: ActionHandler<T>): () => void {
this.startListening();
if (!this.listeners.has(action)) {
this.listeners.set(action, new Set());
}
const handlers = this.listeners.get(action)!;
handlers.add(handler as ActionHandler<unknown>);
// Return unsubscribe function
return () => {
handlers.delete(handler as ActionHandler<unknown>);
if (handlers.size === 0) {
this.listeners.delete(action);
}
};
}
/**
* Remove all listeners and pending requests
* Useful for cleanup
*/
cleanup(): void {
// Reject all pending requests
this.pendingRequests.forEach((request) => {
clearTimeout(request.timeoutHandle);
request.reject(new Error("Message listener cleaned up"));
});
this.pendingRequests.clear();
// Clear all listeners
this.listeners.clear();
// Remove global listener
if (this.isListening) {
window.removeEventListener("message", this.handleMessage.bind(this));
this.isListening = false;
}
}
}
const messageListener = new MessageListener();
// ============================================================================
// Bridge Functions
// ============================================================================
/**
* Send a JSON-RPC message to the mobile interface
* Works for both Android and iOS
* Uses unified handleRequest function for both single requests/notifications and batch requests
*/
function sendJsonRpcMessage(message: JsonRpcRequest | JsonRpcNotification | JsonRpcBatchRequest): void {
const androidInterface = getAndroidInterface();
const iosInterface = getIosInterface();
// Always stringify the message (single object or array)
const stringifiedMessage = JSON.stringify(message);
// Send to Android interface using handleRequest
if (androidInterface?.handleRequest) {
androidInterface.handleRequest(stringifiedMessage);
}
// Send to iOS interface using handleRequest.postMessage
if (iosInterface?.handleRequest?.postMessage) {
iosInterface.handleRequest.postMessage(stringifiedMessage);
}
}
/**
* Call a mobile method that expects a response
* Sends a JSON-RPC 2.0 request and waits for response via postMessage
* Returns a promise that resolves when the matching response is received
*/
export function callMobileMethodWithResponse<T>(
methodName: MobileMethodName,
// eslint-disable-next-line no-magic-numbers
timeoutMs = 5000,
params?: unknown
): Promise<T> {
// Generate unique request ID
const requestId = generateRequestId();
// Register listener for the response before sending the message
const responsePromise = messageListener.waitForResponse<T>(requestId, methodName, timeoutMs);
// Create and send JSON-RPC request
const request = createRequest(methodName, params, requestId);
sendJsonRpcMessage(request);
return responsePromise;
}
/**
* Check if the mobile interface is available
* With the unified handleRequest interface, method availability is handled by the mobile app
*/
export function isMobileMethodAvailable(): boolean {
const androidInterface = getAndroidInterface();
const iosInterface = getIosInterface();
// Check if handleRequest exists (method availability is now handled by mobile app)
return Boolean(
androidInterface?.handleRequest ||
iosInterface?.handleRequest?.postMessage
);
}