UNPKG

@frak-labs/frame-connector

Version:

Generic, type-safe RPC communication layer for bidirectional postMessage communication

838 lines (792 loc) 27.5 kB
import type { Prettify } from 'viem'; /** * Union of all message types that can be received */ export declare type AnyMessage = RpcMessage | LifecycleMessage; /** * Lifecycle message format for client-to-iframe communication * These messages handle connection lifecycle events (handshake, heartbeat, etc.) */ export declare type ClientLifecycleMessage = { clientLifecycle: string; data?: unknown; }; /** @ignore */ export declare class ClientNotFound extends FrakRpcError { constructor(); } /** * The received encoded data from a client * -> The encoded should contain a HashProtectedData once decoded */ export declare type CompressedData = Uint8Array; /** * Compress JSON data using CBOR encoding * * @param data - The data to compress * @returns CBOR-encoded data * * @example * ```ts * const compressed = compressJson({ foo: 'bar' }) * // Returns Uint8Array with CBOR-encoded data * ``` */ export declare function compressJson(data: unknown): Uint8Array; /** * Client-side compression middleware * * Compresses outgoing requests and decompresses incoming responses. * Always uses the format: {method: string, params: unknown} * * @example Client side * ```ts * const client = createRpcClient({ * transport: iframe.contentWindow, * targetOrigin: 'https://wallet.frak.id', * middleware: [createClientCompressionMiddleware()] * }) * ``` */ export declare const createClientCompressionMiddleware: <TSchema extends RpcSchema, TContext>() => RpcMiddleware<TSchema, TContext>; /** * Listener-side compression middleware * * Decompresses incoming requests and compresses outgoing responses. * Always uses the format: {method: string, params: unknown} * * @example Listener side * ```ts * const listener = createRpcListener({ * transport: window, * allowedOrigins: ['https://example.com'], * middleware: [createListenerCompressionMiddleware()] * }) * ``` */ export declare const createListenerCompressionMiddleware: <TSchema extends RpcSchema, TContext>() => RpcMiddleware<TSchema, TContext>; /** * Create an RPC client for SDK-side communication * * @typeParam TSchema - The RPC schema type * @typeParam TLifecycleEvent - Lifecycle event union type (e.g., ClientLifecycleEvent | IFrameLifecycleEvent) * @param config - Client configuration * @returns RPC client instance * * @example * ```ts * import type { IFrameRpcSchema, ClientLifecycleEvent, IFrameLifecycleEvent } from '@frak-labs/core-sdk' * * const client = createRpcClient<IFrameRpcSchema, ClientLifecycleEvent | IFrameLifecycleEvent>({ * emittingTransport: window, * listeningTransport: window, * targetOrigin: 'https://wallet.frak.id', * lifecycleHandlers: { * iframeLifecycle: (event, data) => { * // event and data are now strongly typed! * } * } * }) * * // One-shot request * const result = await client.request('frak_sendInteraction', [productId, interaction]) * * // Listener * const unsubscribe = client.listen('frak_listenToWalletStatus', (status) => { * console.log('Wallet status:', status) * }) * ``` */ export declare function createRpcClient<TSchema extends RpcSchema, TLifecycleEvent extends LifecycleMessage = LifecycleMessage>(config: RpcClientConfig<TSchema, TLifecycleEvent>): RpcClient<TSchema, TLifecycleEvent>; /** * Create an RPC listener for Wallet-side communication * * Supports multiple schemas via union types, enabling a single listener to handle * different RPC protocols (e.g., IFrameRpcSchema | SsoRpcSchema). * * @typeParam TSchema - The RPC schema type (can be a union of multiple schemas) * @typeParam TContext - Custom context type augmented by middleware * @typeParam TLifecycleEvent - Lifecycle event union type (e.g., ClientLifecycleEvent | IFrameLifecycleEvent) * @param config - Listener configuration * @returns RPC listener instance * * @example * ```ts * import type { IFrameRpcSchema, SsoRpcSchema, ClientLifecycleEvent, IFrameLifecycleEvent } from '@frak-labs/core-sdk' * * // Single schema * const listener = createRpcListener<IFrameRpcSchema>({ * transport: window, * allowedOrigins: ['https://example.com'] * }) * * // Multiple schemas (union type) with lifecycle events * type CombinedSchema = IFrameRpcSchema | SsoRpcSchema * const listener = createRpcListener<CombinedSchema, WalletContext, ClientLifecycleEvent | IFrameLifecycleEvent>({ * transport: window, * allowedOrigins: '*', * middleware: [compressionMiddleware, contextMiddleware], * lifecycleHandlers: { * clientLifecycle: (event, data, context) => { * // event and data are now strongly typed! * } * } * }) * * // Register handlers for IFrame methods * listener.handle('frak_sendInteraction', async (params, context) => { * return { status: 'success', hash: '0x...' } * }) * * // Register handlers for SSO methods * listener.handle('sso_complete', async (params, context) => { * const [session, sdkJwt, ssoId] = params * return { success: true } * }) * ``` */ export declare function createRpcListener<TSchema extends RpcSchema, TContext = Record<string, never>, TLifecycleEvent extends LifecycleMessage = LifecycleMessage>(config: RpcListenerConfig<TSchema, TContext, TLifecycleEvent>): RpcListener<TSchema, TContext>; /** * Decompress and validate hash-protected data * * Security: * - Validates hash to ensure data integrity * - Throws RpcError if hash validation fails * - Prevents corrupted or tampered messages from processing * * @param compressedData - The compressed data to decompress * @returns The decompressed data with validation hash * @throws {Error} If decompression fails or hash validation fails * * @example * ```ts * const decompressed = decompressDataAndCheckHash(compressedData) * // Returns { foo: 'bar', baz: 123, validationHash: '0x...' } * ``` */ export declare function decompressDataAndCheckHash<T>(compressedData: CompressedData): HashProtectedData<T>; /** * Decompress CBOR-encoded data * * @param data - The compressed data * @returns Decompressed data or null if decompression fails * * @example * ```ts * const decompressed = decompressJson<MyType>(compressedData) * if (decompressed) { * // Use decompressed data * } * ``` */ export declare function decompressJson<T>(data: Uint8Array): T | null; /** * Simple deferred promise wrapper * @ignore */ export declare class Deferred<T> { private readonly _promise; private _resolve; private _reject; constructor(); get promise(): Promise<T>; resolve: (value: T | PromiseLike<T>) => void; reject: (reason?: unknown) => void; } /** * Type that extract the possible parameters from a RPC Schema * @ignore */ export declare type ExtractedParametersFromRpc<TRpcSchema extends RpcSchema> = { [K in keyof TRpcSchema]: Prettify<{ method: TRpcSchema[K] extends TRpcSchema[number] ? TRpcSchema[K]["Method"] : string; } & (TRpcSchema[K] extends TRpcSchema[number] ? TRpcSchema[K]["Parameters"] extends undefined ? { params?: never; } : { params: TRpcSchema[K]["Parameters"]; } : never)>; }[number]; /** * Type that extract the possible parameters from a RPC Schema * @ignore */ declare type ExtractedSpecificParametersFromRpc<TRpcSchema extends RpcSchema, TMethod extends ExtractMethod<TRpcSchema>> = Extract<ExtractedParametersFromRpc<TRpcSchema>, { method: TMethod; }>; /** * Extract method names from a schema * * @typeParam TSchema - The RPC schema type * * @example * ```ts * type Methods = ExtractMethod<MySchema> * // "greet" | "watchTime" * ``` */ export declare type ExtractMethod<TSchema extends RpcSchema> = TSchema[number]["Method"]; /** * Extract parameters type for a specific method * * @typeParam TSchema - The RPC schema type * @typeParam TMethod - The method name * * @example * ```ts * type GreetParams = ExtractParams<MySchema, "greet"> * // [name: string] * ``` */ export declare type ExtractParams<TSchema extends RpcSchema, TMethod extends ExtractMethod<TSchema>> = ExtractSchemaEntry<TSchema, TMethod>["Parameters"]; /** * Extract return type for a specific method * * @typeParam TSchema - The RPC schema type * @typeParam TMethod - The method name * * @example * ```ts * type GreetReturn = ExtractReturnType<MySchema, "greet"> * // string * ``` */ export declare type ExtractReturnType<TSchema extends RpcSchema, TMethod extends ExtractMethod<TSchema>> = ExtractSchemaEntry<TSchema, TMethod>["ReturnType"]; /** * Extract a specific schema entry by method name * * @typeParam TSchema - The RPC schema type * @typeParam TMethod - The method name to extract * * @example * ```ts * type GreetEntry = ExtractSchemaEntry<MySchema, "greet"> * // { Method: "greet"; Parameters: [name: string]; ReturnType: string; ResponseType: "promise" } * ``` */ export declare type ExtractSchemaEntry<TSchema extends RpcSchema, TMethod extends ExtractMethod<TSchema>> = Extract<TSchema[number], { Method: TMethod; }>; /** * Generic Frak RPC error * @ignore */ export declare class FrakRpcError<T = undefined> extends Error { code: number; data?: T | undefined; constructor(code: number, message: string, data?: T | undefined); toJSON(): RpcError; } /** * Compress the given data with hash protection to prevent tampering * * Performance considerations: * - CBOR encoding is more compact than JSON and faster to parse * - Hash validation prevents man-in-the-middle modifications * - Single-pass encoding minimizes allocations * * @param data - The data to compress and protect * @returns Compressed CBOR-encoded data with validation hash * * @example * ```ts * const compressed = hashAndCompressData({ foo: 'bar', baz: 123 }) * // Returns Uint8Array with CBOR-encoded data + validation hash * ``` */ export declare function hashAndCompressData<T>(data: T): CompressedData; /** * The encoded data to send to a client / received by a client */ export declare type HashProtectedData<DataType> = Readonly<DataType & { validationHash: string; }>; /** * Lifecycle message format for iframe-to-client communication */ export declare type IFrameLifecycleMessage = { iframeLifecycle: string; data?: unknown; }; /** @ignore */ export declare class InternalError extends FrakRpcError { constructor(message: string); } /** * Lifecycle handler function * Handles lifecycle events using discriminated unions for automatic type narrowing * * @typeParam TLifecycleEvent - The lifecycle event union type (e.g., ClientLifecycleEvent | IFrameLifecycleEvent) * * @param event - The full lifecycle event object with discriminated union * @param context - Request context with origin and source * * @example * ```ts * const handler: LifecycleHandler<ClientLifecycleEvent> = (event, context) => { * if (event.clientLifecycle === "modal-css") { * // event.data is automatically typed as { cssLink: string } * console.log(event.data.cssLink) * } * } * ``` */ export declare type LifecycleHandler<TLifecycleEvent = unknown> = (event: TLifecycleEvent, context: RpcRequestContext) => void | Promise<void>; /** * Union of all lifecycle message types */ export declare type LifecycleMessage = ClientLifecycleMessage | IFrameLifecycleMessage; /** @ignore */ export declare class MethodNotFoundError extends FrakRpcError<{ method: string; }> { constructor(message: string, method: string); } /** * RPC Client interface * Provides methods for making RPC calls to the wallet * * @typeParam TSchema - The RPC schema type */ export declare type RpcClient<TSchema extends RpcSchema, TLifecycleEvent extends LifecycleMessage> = { /** * Make a one-shot request that returns a promise * Used for methods with ResponseType: "promise" */ request: <TMethod extends ExtractMethod<TSchema>>(args: ExtractedSpecificParametersFromRpc<TSchema, TMethod>) => Promise<ExtractReturnType<TSchema, TMethod>>; /** * Subscribe to a listener method with a callback * Used for methods with ResponseType: "stream" * Returns an unsubscribe function * * @example * ```ts * const unsubscribe = client.listen('frak_listenToWalletStatus', (status) => { * console.log('Status:', status) * }) * * // Later, unsubscribe * unsubscribe() * ``` */ listen: <TMethod extends ExtractMethod<TSchema>>(args: ExtractedSpecificParametersFromRpc<TSchema, TMethod>, callback: (result: ExtractReturnType<TSchema, TMethod>) => void) => () => void; /** * Send a lifecycle event to the server * Bypasses middleware and is used for connection management * * @example * ```ts * client.sendLifecycle({ clientLifecycle: 'heartbeat' }) * client.sendLifecycle({ clientLifecycle: 'modal-css', data: { cssLink: '...' } }) * ``` */ sendLifecycle: (message: TLifecycleEvent) => void; /** * Clean up resources and close connections */ cleanup: () => void; }; /** * RPC Client configuration * * @typeParam TSchema - The RPC schema type * @typeParam TLifecycleEvent - Lifecycle event union type (e.g., ClientLifecycleEvent | IFrameLifecycleEvent) */ export declare type RpcClientConfig<TSchema extends RpcSchema, TLifecycleEvent = unknown> = { /** * The transport to use for emitting events (e.g., window or iframe.contentWindow) */ emittingTransport: RpcTransport; /** * The transport to use for listening to events (e.g., window or iframe.contentWindow) */ listeningTransport: RpcTransport; /** * The target origin for postMessage */ targetOrigin: string; /** * Middleware stack (executed in order) * Middleware can transform outgoing requests and incoming responses * Client-side middleware uses empty context {} * * @example * ```ts * middleware: [ * compressionMiddleware, // Compress outgoing, decompress incoming * loggingMiddleware, // Log RPC calls * ] * ``` */ middleware?: RpcMiddleware<TSchema>[]; /** * Lifecycle event handlers * Handles incoming lifecycle events from the server * * @example * ```ts * lifecycleHandlers: { * iframeLifecycle: (event, data) => { * if (event === 'connected') { * console.log('Wallet ready') * } * } * } * ``` */ lifecycleHandlers?: { clientLifecycle?: LifecycleHandler<Extract<TLifecycleEvent, { clientLifecycle: string; }>>; iframeLifecycle?: LifecycleHandler<Extract<TLifecycleEvent, { iframeLifecycle: string; }>>; }; }; /** * RPC error object */ export declare type RpcError = { code: number; message: string; data?: unknown; }; /** * RPC error codes * Follows JSON-RPC 2.0 specification with Frak-specific extensions */ export declare const RpcErrorCodes: { readonly parseError: -32700; readonly invalidRequest: -32600; readonly methodNotFound: -32601; readonly invalidParams: -32602; readonly internalError: -32603; readonly serverError: -32000; readonly clientNotConnected: -32001; readonly configError: -32002; readonly corruptedResponse: -32003; readonly clientAborted: -32004; readonly walletNotConnected: -32005; readonly serverErrorForInteractionDelegation: -32006; readonly userRejected: -32007; }; /** * RPC Listener interface * Handles incoming RPC requests from the SDK * * @typeParam TSchema - The RPC schema type * @typeParam TContext - Custom context type augmented by middleware */ export declare type RpcListener<TSchema extends RpcSchema, TContext = Record<string, never>> = { /** * Register a handler for a promise-based method */ handle: <TMethod extends ExtractMethod<TSchema>>(method: TMethod, handler: RpcPromiseHandler<TSchema, TMethod, TContext>) => void; /** * Register a handler for a streaming method */ handleStream: <TMethod extends ExtractMethod<TSchema>>(method: TMethod, handler: RpcStreamHandler<TSchema, TMethod, TContext>) => void; /** * Unregister a handler */ unregister: (method: ExtractMethod<TSchema>) => void; /** * Clean up resources */ cleanup: () => void; }; /** * RPC Listener configuration * * @typeParam TSchema - The RPC schema type * @typeParam TContext - Custom context type to augment base context * @typeParam TLifecycleEvent - Lifecycle event union type (e.g., ClientLifecycleEvent | IFrameLifecycleEvent) */ export declare type RpcListenerConfig<TSchema extends RpcSchema, TContext = Record<string, never>, TLifecycleEvent extends LifecycleMessage = LifecycleMessage> = { /** * The transport to use for communication (e.g., window) */ transport: RpcTransport; /** * Allowed origins for security * Can be a single origin or array of origins */ allowedOrigins: string | string[]; /** * Middleware stack (executed in order) * Middleware can augment context, validate requests, and transform responses * * Note: Middleware only applies to RPC messages, not lifecycle or custom messages * * @example * ```ts * middleware: [ * loggingMiddleware, * compressionMiddleware, * contextAugmentationMiddleware * ] * ``` */ middleware?: RpcMiddleware<TSchema, TContext>[]; /** * Lifecycle event handlers * Handles client-to-iframe and iframe-to-client lifecycle events * * @example * ```ts * lifecycleHandlers: { * clientLifecycle: (event, data, context) => { * if (event === 'heartbeat') { * console.log('Client heartbeat received') * } * } * } * ``` */ lifecycleHandlers?: { clientLifecycle?: LifecycleHandler<Extract<TLifecycleEvent, { clientLifecycle: string; }>>; iframeLifecycle?: LifecycleHandler<Extract<TLifecycleEvent, { iframeLifecycle: string; }>>; }; }; /** * RPC message format (maintains backward compatibility) * This is the exact format sent over the wire * * @typeParam TMethod - The method name type (defaults to string for flexibility) */ export declare type RpcMessage<TMethod extends string = string> = { /** * Unique message identifier for correlating requests and responses */ id: string; /** * The RPC method name (topic for backward compatibility) */ topic: TMethod; /** * The message payload (compressed data) or raw params */ data: unknown; }; /** * Unified middleware function for RPC requests (both listener and client) * Works on both listener-side (with context augmentation) and client-side (empty context) * * Key features: * - Can mutate message.data directly for efficiency (compression, validation) * - Can mutate response.result directly for transformation * - Listener-side: Can augment context by returning modified context * - Client-side: Uses TContext = {} (empty context), always returns unchanged * * @typeParam TSchema - The RPC schema type * @typeParam TContext - Custom context type to augment base context (empty {} for client-side) * * @example Listener-side with context augmentation * ```ts * type WalletContext = { productId: string, sourceUrl: string } * const contextMiddleware: RpcMiddleware<MySchema, WalletContext> = { * onRequest: async (message, context) => { * // Read from store and augment context * const productId = await getProductId(context.origin) * return { ...context, productId, sourceUrl: context.origin } * } * } * ``` * * @example Client-side (empty context) * ```ts * const compressionMiddleware: RpcMiddleware<MySchema> = { * onRequest: async (message, context) => { * // Mutate message.data directly * message.data = compress(message.data) * return context // Empty context, unchanged * }, * onResponse: async (message, response, context) => { * // Mutate response.result directly * response.result = decompress(response.result) * return response * } * } * ``` * * @example Shared middleware (works on both sides) * ```ts * const loggingMiddleware: RpcMiddleware<MySchema> = { * onRequest: async (message, context) => { * console.log(`[RPC] ${message.topic}`, context.origin || 'client') * return context * }, * onResponse: async (message, response, context) => { * console.log(`[RPC] ${message.topic} completed`) * return response * } * } * ``` */ export declare type RpcMiddleware<TSchema extends RpcSchema, TContext = Record<string, never>> = { /** * Called before handler execution (listener) or before sending (client) * * For listener: Can augment context and mutate message * For client: Can mutate message, context is empty {} * * @param message - The RPC message (can be mutated) * @param context - Request context (listener-side) or empty (client-side) * @returns Updated context (listener mutates this, client returns unchanged) * @throws FrakRpcError to reject the request with a specific error code */ onRequest?: (message: RpcMessage<ExtractMethod<TSchema>>, context: RpcMiddlewareContext<TContext>) => Promise<RpcMiddlewareContext<TContext>> | RpcMiddlewareContext<TContext>; /** * Called after handler execution (listener) or after receiving (client) * * @param message - The original RPC message * @param response - The response (can be mutated) * @param context - Request context (listener-side) or empty (client-side) * @returns Transformed response * @throws Error to send an error response instead */ onResponse?: (message: RpcMessage<ExtractMethod<TSchema>>, response: RpcResponse, context: RpcMiddlewareContext<TContext>) => Promise<RpcResponse> | RpcResponse; }; /** * Middleware context that can be augmented with custom fields * Generic type parameter allows domain-specific context augmentation * * @typeParam TCustomContext - Custom context fields to merge with base context * * @example * ```ts * type WalletContext = RpcMiddlewareContext<{ * productId: string * sourceUrl: string * isAutoContext: boolean * }> * // { origin: string, source: MessageEventSource | null, productId: string, sourceUrl: string, isAutoContext: boolean } * ``` */ export declare type RpcMiddlewareContext<TCustomContext = Record<string, never>> = RpcRequestContext & TCustomContext; /** * Promise handler function type * Handles one-shot requests that return a single promise * * @typeParam TSchema - The RPC schema type * @typeParam TMethod - The method name from the schema * @typeParam TContext - Custom context type augmented by middleware */ export declare type RpcPromiseHandler<TSchema extends RpcSchema, TMethod extends ExtractMethod<TSchema>, TContext = Record<string, never>> = (params: ExtractParams<TSchema, TMethod>, context: RpcMiddlewareContext<TContext>) => Promise<ExtractReturnType<TSchema, TMethod>>; /** * Request context for handlers * Contains information about the origin and source of the request */ export declare type RpcRequestContext = { /** * Origin of the request */ origin: string; /** * Message source (for responding) */ source: MessageEventSource | null; }; /** * RPC response wrapper * Contains either a successful result or an error */ export declare type RpcResponse<TResult = unknown> = { result: TResult; error?: never; } | { result?: never; error: RpcError; }; /** * An RPC schema is a readonly array of schema entries * * @example * ```ts * type MySchema = [ * { * Method: "greet"; * Parameters: [name: string]; * ReturnType: string; * ResponseType: "promise"; * }, * { * Method: "watchTime"; * Parameters?: undefined; * ReturnType: number; * ResponseType: "stream"; * } * ] * ``` */ export declare type RpcSchema = readonly RpcSchemaEntry[]; /** * Generic shape of a single RPC schema entry * * Each entry defines a method with its parameters, return type, and response kind * * @typeParam TMethod - The method name (string literal) * @typeParam TParams - The parameters type (can be undefined for no parameters) * @typeParam TReturn - The return type * @typeParam TResponseKind - Either "promise" or "stream" */ export declare type RpcSchemaEntry<TMethod extends string = string, TParams = unknown, TReturn = unknown> = { /** * The method name (e.g., "frak_sendInteraction") */ Method: TMethod; /** * The parameters type (undefined if no parameters) */ Parameters?: TParams; /** * The return type */ ReturnType: TReturn; }; /** * Stream handler function type * Handles streaming requests that can emit multiple values * * @typeParam TSchema - The RPC schema type * @typeParam TMethod - The method name from the schema * @typeParam TContext - Custom context type augmented by middleware */ export declare type RpcStreamHandler<TSchema extends RpcSchema, TMethod extends ExtractMethod<TSchema>, TContext = Record<string, never>> = (params: ExtractParams<TSchema, TMethod>, emitter: StreamEmitter<ExtractReturnType<TSchema, TMethod>>, context: RpcMiddlewareContext<TContext>) => Promise<void> | void; /** * Transport interface for RPC communication * Abstracts the underlying message passing mechanism (postMessage, etc) */ export declare type RpcTransport = { /** * Send a message through the transport */ postMessage: (message: RpcMessage, targetOrigin: string) => void; /** * Listen for messages */ addEventListener: (type: "message", listener: (event: MessageEvent<RpcMessage>) => void) => void; /** * Remove message listener */ removeEventListener: (type: "message", listener: (event: MessageEvent<RpcMessage>) => void) => void; }; /** * Stream emitter function * Used by stream handlers to emit multiple values */ export declare type StreamEmitter<TResult> = (chunk: TResult) => void; /** * Type-safe request parameters * * @typeParam TSchema - The RPC schema type * @typeParam TMethod - The method name from the schema */ export declare type TypedRpcRequest<TSchema extends RpcSchema, TMethod extends ExtractMethod<TSchema>> = { method: TMethod; params: ExtractParams<TSchema, TMethod>; }; export { }