diff --git a/README.md b/README.md index 85a950e..78b58a8 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,15 @@ const result = await client.invokeMethod({ await client.revokeSession(); ``` +### Configuring Transport Timeout + +You can configure a default timeout (in milliseconds) for all requests made through the transport by passing the `defaultTimeout` option to `getDefaultTransport`: + +```typescript +const transport = getDefaultTransport({ defaultTimeout: 5000 }); // 5 seconds timeout for all requests +const client = getMultichainClient({ transport }); +``` + ## Extending RPC Types The client's RPC requests are strongly typed, enforcing the RPC methods and params to be defined ahead of usage. The client supports extending @@ -78,6 +87,16 @@ const result = await client.invokeMethod({ Transports handle the communication layer between your application and the wallet. You can create custom transports for different environments or communication methods. +### Timeout Responsibility + +It is recommended that each custom transport implements its own request timeout mechanism rather than relying on higher layers. This ensures: + +- Transport-specific optimizations (e.g., aborting underlying network channels, clearing listeners) +- Consistent error semantics (e.g., always throwing a dedicated `TransportTimeoutError` or custom error type) +- Better resource cleanup in environments like browsers or workers + +Your `request` implementation should accept an optional `{ timeout?: number }` argument. + ### Transport Interface A transport must implement the following interface: @@ -87,7 +106,10 @@ type Transport = { connect: () => Promise; disconnect: () => Promise; isConnected: () => boolean; - request: (request: TRequest) => Promise; + request: ( + request: TRequest, + options?: { timeout?: number } + ) => Promise; onNotification: (callback: (data: unknown) => void) => () => void; }; ``` @@ -95,21 +117,36 @@ type Transport = { ### Example: Custom Transport ```typescript +import { TransportError, TransportTimeoutError } from '@metamask/multichain-api-client'; import type { Transport, TransportRequest, TransportResponse } from '@metamask/multichain-api-client'; -export function getCustomTransport(): Transport { +type CustomTransportOptions = { + defaultTimeout?: number; // ms +}; + +export function getCustomTransport(options: CustomTransportOptions = {}): Transport { + const { defaultTimeout = 5000 } = options; + return { connect: async () => { ... }, disconnect: async () => { ... }, isConnected: () => { ...}, - request: async ( request: TRequest ): Promise => { ... }, + request: async ( request: TRequest, { timeout }: { timeout?: number } = {}): Promise => { ... }, onNotification: (callback: (data: unknown) => void) => { ... }, }; } // Usage -const transport = getCustomTransport(); +const transport = getCustomTransport({ defaultTimeout: 8000 }); const client = getMultichainClient({ transport }); + +// Per-request override +await client.invokeMethod({ + scope: 'eip155:1', + request: { method: 'eth_chainId', params: [] }, + // The transport's request implementation can expose a timeout override + { timeout: 10000 // 10 seconds timeout for this request only +}); ``` ## Error Handling diff --git a/src/helpers/utils.test.ts b/src/helpers/utils.test.ts index e98817c..559fc47 100644 --- a/src/helpers/utils.test.ts +++ b/src/helpers/utils.test.ts @@ -1,65 +1,110 @@ import { describe, expect, it } from 'vitest'; -import { withRetry } from './utils'; +import { withRetry, withTimeout } from './utils'; -/** - * Mock function that returns a promise that resolves only when called again after a delay - * This function mocks MetaMask Multichain API wallet_getSession method, where early calls may never resolve - * - * @returns A promise that resolves after a delay - */ -function mockMultichainApiRequest() { - const startTime = Date.now(); +describe('utils', () => { + class CustomTimeoutError extends Error {} + class CustomError extends Error {} - // Delay for the first call to resolve - const successThresholdDelay = 300; + describe('withRetry', () => { + it('retries on thrown error until success', async () => { + let attempts = 0; + const fn = async () => { + attempts++; + if (attempts < 3) { + throw new Error('fail'); + } + return 'ok'; + }; + const result = await withRetry(fn, { maxRetries: 5 }); + expect(result).toBe('ok'); + expect(attempts).toBe(3); + }); - return async () => { - const callTime = Date.now(); - if (callTime - startTime < successThresholdDelay) { - // Promise that never resolves - await new Promise(() => {}); - } - return 'success'; - }; -} + it('throws last error after exceeding maxRetries', async () => { + let attempts = 0; + const fn = async () => { + attempts++; + throw new Error('boom'); + }; + await expect(withRetry(fn, { maxRetries: 2 })).rejects.toThrow('boom'); + // maxRetries=2 => attempts 0,1,2 (3 total) + expect(attempts).toBe(3); + }); -function mockThrowingFn() { - const startTime = Date.now(); + it('retries only specific error class with delay', async () => { + let attempts = 0; + const fn = async () => { + attempts++; + if (attempts < 3) { + throw new CustomError('Custom Error'); + } + return 'done'; + }; + const start = Date.now(); + const result = await withRetry(fn, { maxRetries: 5, retryDelay: 20, timeoutErrorClass: CustomTimeoutError }); + const elapsed = Date.now() - start; + expect(result).toBe('done'); + expect(attempts).toBe(3); + // Two retries with ~20ms delay each (allow some tolerance) + expect(elapsed).toBeGreaterThanOrEqual(30); + }); - // Delay for the first call to resolve - const successThresholdDelay = 300; + it('retries only TimeoutError class without delay', async () => { + let attempts = 0; + const fn = async () => { + attempts++; + if (attempts < 3) { + throw new CustomTimeoutError('Custom Error'); + } + return 'done'; + }; + const start = Date.now(); + const result = await withRetry(fn, { maxRetries: 5, retryDelay: 20, timeoutErrorClass: CustomTimeoutError }); + const elapsed = Date.now() - start; + expect(result).toBe('done'); + expect(attempts).toBe(3); + expect(elapsed).toBeLessThanOrEqual(20); + }); - return async () => { - const callTime = Date.now(); - if (callTime - startTime < successThresholdDelay) { - throw new Error('error'); - } - return 'success'; - }; -} + it('continues retrying even if non-timeout errors occur (no delay applied for them)', async () => { + const sequenceErrors = [new Error('other'), new CustomTimeoutError('timeout'), new CustomTimeoutError('timeout')]; + let attempts = 0; + const fn = async () => { + if (attempts < sequenceErrors.length) { + const err = sequenceErrors[attempts]; + attempts++; + throw err; + } + attempts++; + return 'final'; + }; + const result = await withRetry(fn, { maxRetries: 5, retryDelay: 10, timeoutErrorClass: CustomTimeoutError }); + expect(result).toBe('final'); + expect(attempts).toBe(4); // 3 fail + 1 success + }); + }); -describe('utils', () => { - describe('withRetry', () => { - it('should retry a function until it succeeds', async () => { - const result = await withRetry(mockMultichainApiRequest(), { maxRetries: 4, requestTimeout: 100 }); - expect(result).toBe('success'); + describe('withTimeout', () => { + it('should resolve before timeout', async () => { + const result = await withTimeout(Promise.resolve('ok'), 1000); + expect(result).toBe('ok'); }); - it('should retry a function that never resolves until it succeeds', async () => { - await expect( - async () => await withRetry(mockMultichainApiRequest(), { maxRetries: 2, requestTimeout: 100 }), - ).rejects.toThrow('Timeout reached'); + it('should reject after timeout', async () => { + await expect(withTimeout(new Promise(() => {}), 50)).rejects.toThrow('Timeout after 50ms'); }); - it('should retry a throwing function until it succeeds', async () => { - const result = await withRetry(mockThrowingFn(), { maxRetries: 4, requestTimeout: 100 }); - expect(result).toBe('success'); + it('should propagate rejection from promise', async () => { + await expect(withTimeout(Promise.reject(new Error('fail')), 1000)).rejects.toThrow('fail'); }); - it('should retry a throwing function until it succeeds', async () => { - await expect( - async () => await withRetry(mockThrowingFn(), { maxRetries: 2, requestTimeout: 100 }), - ).rejects.toThrow('error'); + it('should use custom error from errorFactory', async () => { + await expect(withTimeout(new Promise(() => {}), 10, () => new CustomTimeoutError('custom'))).rejects.toThrow( + CustomTimeoutError, + ); + await expect(withTimeout(new Promise(() => {}), 10, () => new CustomTimeoutError('custom'))).rejects.toThrow( + 'custom', + ); }); }); }); diff --git a/src/helpers/utils.ts b/src/helpers/utils.ts index 9265507..1c74515 100644 --- a/src/helpers/utils.ts +++ b/src/helpers/utils.ts @@ -1,3 +1,6 @@ +// chrome is a global injected by browser extensions +declare const chrome: any; + /** * Detects if we're in a Chrome-like environment with extension support */ @@ -17,22 +20,15 @@ export async function withRetry( fn: () => Promise, options: { maxRetries?: number; - requestTimeout?: number; retryDelay?: number; + timeoutErrorClass?: new (...args: any[]) => Error; } = {}, ): Promise { - const { maxRetries = 10, requestTimeout = 200, retryDelay = requestTimeout } = options; - const errorMessage = 'Timeout reached'; + const { maxRetries = 10, retryDelay = 200, timeoutErrorClass } = options; for (let attempt = 0; attempt <= maxRetries; attempt++) { try { - // Use Promise.race to implement timeout - const timeoutPromise = new Promise((_, reject) => { - setTimeout(() => reject(new Error(errorMessage)), requestTimeout); - }); - - const result = await Promise.race([fn(), timeoutPromise]); - return result; + return await fn(); } catch (error) { // If this was the last attempt, throw the error if (attempt >= maxRetries) { @@ -40,12 +36,41 @@ export async function withRetry( } // Wait before retrying (unless it was a timeout, then retry immediately) - if (error instanceof Error && error.message !== errorMessage) { - await new Promise((resolve) => setTimeout(resolve, retryDelay)); + if (timeoutErrorClass && typeof timeoutErrorClass === 'function' && error instanceof timeoutErrorClass) { + continue; } + + await new Promise((resolve) => setTimeout(resolve, retryDelay)); } } // This should never be reached due to the throw in the loop throw new Error('Max retries exceeded'); } + +/** + * Returns a promise that resolves or rejects like the given promise, but fails if the timeout is exceeded. + * @param promise - The promise to monitor + * @param timeoutMs - Maximum duration in ms + * @param errorFactory - Optional callback to generate a custom error on timeout + */ +export function withTimeout(promise: Promise, timeoutMs: number, errorFactory?: () => Error): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + if (errorFactory) { + reject(errorFactory()); + } else { + reject(new Error(`Timeout after ${timeoutMs}ms`)); + } + }, timeoutMs); + promise + .then((value) => { + clearTimeout(timer); + resolve(value); + }) + .catch((err) => { + clearTimeout(timer); + reject(err); + }); + }); +} diff --git a/src/index.ts b/src/index.ts index d573425..ce7e447 100644 --- a/src/index.ts +++ b/src/index.ts @@ -20,9 +20,14 @@ import type { Transport } from './types/transport'; * const transport = getDefaultTransport({ extensionId: '...' }); * ``` */ -function getDefaultTransport(params: { extensionId?: string } = {}): Transport { +function getDefaultTransport({ + extensionId, + defaultTimeout, +}: { extensionId?: string; defaultTimeout?: number } = {}): Transport { const isChrome = isChromeRuntime(); - return isChrome ? getExternallyConnectableTransport(params) : getWindowPostMessageTransport(); + return isChrome + ? getExternallyConnectableTransport({ extensionId, defaultTimeout }) + : getWindowPostMessageTransport({ defaultTimeout }); } export { getMultichainClient, getDefaultTransport, getExternallyConnectableTransport, getWindowPostMessageTransport }; diff --git a/src/multichainClient.test.ts b/src/multichainClient.test.ts index cb8b187..3e17b2d 100644 --- a/src/multichainClient.test.ts +++ b/src/multichainClient.test.ts @@ -1,9 +1,15 @@ -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { getMockTransport, mockScope, mockSession } from '../tests/mocks'; import { getMultichainClient } from './multichainClient'; +import { TransportTimeoutError } from './types/errors'; +import type { Transport } from './types/transport'; const mockTransport = getMockTransport(); describe('getMultichainClient', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + it('should create a client with all required methods', async () => { const client = getMultichainClient({ transport: mockTransport }); @@ -20,10 +26,17 @@ describe('getMultichainClient', () => { const result = await client.createSession(params); expect(result).toEqual(mockSession); - expect(mockTransport.request).toHaveBeenCalledWith({ - method: 'wallet_createSession', - params, - }); + // First call from initialization + expect(mockTransport.request).toHaveBeenNthCalledWith(1, { method: 'wallet_getSession' }); + // Second call is the createSession request including options object + expect(mockTransport.request).toHaveBeenNthCalledWith( + 2, + { + method: 'wallet_createSession', + params, + }, + { timeout: undefined }, + ); }); it('should get session successfully', async () => { @@ -41,10 +54,11 @@ describe('getMultichainClient', () => { const client = getMultichainClient({ transport: mockTransport }); await client.revokeSession({}); - expect(mockTransport.request).toHaveBeenCalledWith({ - method: 'wallet_revokeSession', - params: {}, - }); + expect(mockTransport.request).toHaveBeenNthCalledWith( + 2, + { method: 'wallet_revokeSession', params: {} }, + { timeout: undefined }, + ); }); it('should disconnect transport after revoking session', async () => { @@ -71,20 +85,25 @@ describe('getMultichainClient', () => { }, }); expect(signAndSendResult).toEqual({ signature: 'mock-signature' }); - expect(mockTransport.request).toHaveBeenLastCalledWith({ - method: 'wallet_invokeMethod', - params: { - scope: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdpK', - request: { - method: 'signAndSendTransaction', - params: { - account: { address: 'mock-address' }, - transaction: 'mock-transaction', - scope: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdpK', + expect(mockTransport.request).toHaveBeenNthCalledWith(1, { method: 'wallet_getSession' }); + expect(mockTransport.request).toHaveBeenNthCalledWith( + 2, + { + method: 'wallet_invokeMethod', + params: { + scope: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdpK', + request: { + method: 'signAndSendTransaction', + params: { + account: { address: 'mock-address' }, + transaction: 'mock-transaction', + scope: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdpK', + }, }, }, }, - }); + { timeout: undefined }, + ); // Test signMessage const signMessageResult = await client.invokeMethod({ @@ -119,4 +138,17 @@ describe('getMultichainClient', () => { expect(mockIsConnected).toHaveBeenCalled(); expect(mockConnect).toHaveBeenCalled(); }); + + it('should timeout if transport is too slow', async () => { + const slowTransport: Transport = { + ...mockTransport, + request: vi.fn(() => { + throw new TransportTimeoutError(); + }) as Transport['request'], + connect: vi.fn(() => Promise.resolve()), + isConnected: vi.fn(() => false), + }; + const client = getMultichainClient({ transport: slowTransport }); + await expect(client.getSession()).rejects.toThrow('Transport request timed out'); + }); }); diff --git a/src/multichainClient.ts b/src/multichainClient.ts index f31fefc..66c06f7 100644 --- a/src/multichainClient.ts +++ b/src/multichainClient.ts @@ -18,6 +18,7 @@ import type { Transport, TransportRequest, TransportResponse } from './types/tra * * @param options - Configuration options for the client * @param options.transport - The transport layer to use for communication with the wallet + * @param options.requestTimeout - Maximum delay before aborting each request attempt * @returns A promise that resolves to a MultichainApiClient instance * * @example @@ -43,7 +44,9 @@ import type { Transport, TransportRequest, TransportResponse } from './types/tra */ export function getMultichainClient({ transport, -}: { transport: Transport }): MultichainApiClient { +}: { + transport: Transport; +}): MultichainApiClient { let initializationPromise: Promise | undefined = undefined; let connectionPromise: Promise | undefined = undefined; @@ -111,15 +114,23 @@ async function request({ transport, method, params, + timeout, }: { transport: Transport; method: M; params?: MultichainApiParams; + timeout?: number; }): Promise> { const res = await transport.request< TransportRequest>, TransportResponse> - >({ method, params }); + >( + { + method, + params, + }, + { timeout }, + ); if (res?.error) { throw new MultichainApiError(res.error); diff --git a/src/transports/constants.ts b/src/transports/constants.ts index f18c312..abb2f69 100644 --- a/src/transports/constants.ts +++ b/src/transports/constants.ts @@ -5,3 +5,4 @@ export const INPAGE = 'metamask-inpage'; export const MULTICHAIN_SUBSTREAM_NAME = 'metamask-multichain-provider'; export const METAMASK_PROVIDER_STREAM_NAME = 'metamask-provider'; export const METAMASK_EXTENSION_CONNECT_CAN_RETRY = 'METAMASK_EXTENSION_CONNECT_CAN_RETRY'; +export const DEFAULT_REQUEST_TIMEOUT = 200; // 200ms diff --git a/src/transports/externallyConnectableTransport.test.ts b/src/transports/externallyConnectableTransport.test.ts index 4ef6700..74ef97c 100644 --- a/src/transports/externallyConnectableTransport.test.ts +++ b/src/transports/externallyConnectableTransport.test.ts @@ -126,7 +126,7 @@ describe('ExternallyConnectableTransport', () => { }); it('should throw error when making request while disconnected', async () => { - expect(() => transport.request({ method: 'wallet_getSession' })).toThrow( + await expect(() => transport.request({ method: 'wallet_getSession' })).rejects.toThrow( new TransportError('Chrome port not connected'), ); }); @@ -140,4 +140,35 @@ describe('ExternallyConnectableTransport', () => { expect(error).toBeInstanceOf(TransportError); expect(error.message).toBe('Failed to connect to MetaMask'); }); + + it('should timeout if no response is received', async () => { + await transport.connect(); + // On ne simule pas de réponse, la promesse doit timeout + await expect(transport.request({ method: 'wallet_getSession' }, { timeout: 10 })).rejects.toThrow( + 'Transport request timed out', + ); + await expect(transport.request({ method: 'wallet_getSession' }, { timeout: 10 })).rejects.toThrow(TransportError); + }); + + it('should cleanup pending request after timeout allowing subsequent requests', async () => { + await transport.connect(); + await expect(transport.request({ method: 'wallet_getSession' }, { timeout: 10 })).rejects.toThrow( + 'Transport request timed out', + ); + + // Second request should work (id 2) + const secondPromise = transport.request({ method: 'wallet_getSession' }); + + messageHandler({ + type: 'caip-348', + data: { + id: 2, + jsonrpc: '2.0', + result: mockSession, + }, + }); + + const response = await secondPromise; + expect(response).toEqual({ id: 2, jsonrpc: '2.0', result: mockSession }); + }); }); diff --git a/src/transports/externallyConnectableTransport.ts b/src/transports/externallyConnectableTransport.ts index 0c36119..1c9158f 100644 --- a/src/transports/externallyConnectableTransport.ts +++ b/src/transports/externallyConnectableTransport.ts @@ -1,7 +1,8 @@ import { detectMetamaskExtensionId } from '../helpers/metamaskExtensionId'; -import { TransportError } from '../types/errors'; +import { withTimeout } from '../helpers/utils'; +import { TransportError, TransportTimeoutError } from '../types/errors'; import type { Transport, TransportResponse } from '../types/transport'; -import { REQUEST_CAIP } from './constants'; +import { DEFAULT_REQUEST_TIMEOUT, REQUEST_CAIP } from './constants'; /** * Creates a transport that communicates with the MetaMask extension via Chrome's externally_connectable API @@ -21,8 +22,11 @@ import { REQUEST_CAIP } from './constants'; * }); * ``` */ -export function getExternallyConnectableTransport(params: { extensionId?: string } = {}): Transport { +export function getExternallyConnectableTransport( + params: { extensionId?: string; defaultTimeout?: number } = {}, +): Transport { let { extensionId } = params; + const { defaultTimeout = DEFAULT_REQUEST_TIMEOUT } = params; let chromePort: chrome.runtime.Port | undefined; let requestId = 1; const pendingRequests = new Map void>(); @@ -113,7 +117,11 @@ export function getExternallyConnectableTransport(params: { extensionId?: string } }, isConnected: () => chromePort !== undefined, - request: (params: ParamsType): Promise => { + request: async ( + params: ParamsType, + options: { timeout?: number } = {}, + ): Promise => { + const { timeout = defaultTimeout } = options; const currentChromePort = chromePort; if (!currentChromePort) { throw new TransportError('Chrome port not connected'); @@ -125,10 +133,22 @@ export function getExternallyConnectableTransport(params: { extensionId?: string ...params, }; - return new Promise((resolve) => { - pendingRequests.set(id, resolve); - currentChromePort.postMessage({ type: REQUEST_CAIP, data: requestPayload }); - }); + try { + return await withTimeout( + new Promise((resolve) => { + pendingRequests.set(id, resolve); + currentChromePort.postMessage({ type: REQUEST_CAIP, data: requestPayload }); + }), + timeout, + () => new TransportTimeoutError(), + ); + } catch (err) { + // Ensure we cleanup pendingRequests on timeout (or any error before resolution) to avoid memory leaks + if (pendingRequests.has(id)) { + pendingRequests.delete(id); + } + throw err; + } }, onNotification: (callback: (data: unknown) => void) => { notificationCallbacks.add(callback); diff --git a/src/transports/windowPostMessageTransport.test.ts b/src/transports/windowPostMessageTransport.test.ts index 3ef8999..5e43c39 100644 --- a/src/transports/windowPostMessageTransport.test.ts +++ b/src/transports/windowPostMessageTransport.test.ts @@ -304,4 +304,42 @@ describe('WindowPostMessageTransport', () => { expect(mockWindow.addEventListener).toHaveBeenCalledTimes(2); expect(transport.isConnected()).toBe(true); }); + + it('should timeout if no response is received', async () => { + await transport.connect(); + // Do not simulate a response: it should timeout + await expect(transport.request({ method: 'wallet_getSession' }, { timeout: 10 })).rejects.toThrow( + 'Transport request timed out', + ); + await expect(transport.request({ method: 'wallet_getSession' }, { timeout: 10 })).rejects.toThrow(TransportError); + }); + + it('should cleanup pending request after timeout allowing subsequent requests', async () => { + await transport.connect(); + // First request times out + await expect(transport.request({ method: 'wallet_getSession' }, { timeout: 10 })).rejects.toThrow( + 'Transport request timed out', + ); + + // Second request should still work (simulate response) + const secondPromise = transport.request({ method: 'wallet_getSession' }); + + // Simulate response for id 2 (because first timed out with id 1, second increments to 2) + messageHandler({ + data: { + target: INPAGE, + data: { + name: MULTICHAIN_SUBSTREAM_NAME, + data: { + id: 2, + result: mockSession, + }, + }, + }, + origin: mockLocation.origin, + } as MessageEvent); + + const result = await secondPromise; + expect(result).toEqual({ id: 2, result: mockSession }); + }); }); diff --git a/src/transports/windowPostMessageTransport.ts b/src/transports/windowPostMessageTransport.ts index 1df92af..603f839 100644 --- a/src/transports/windowPostMessageTransport.ts +++ b/src/transports/windowPostMessageTransport.ts @@ -1,6 +1,7 @@ -import { TransportError } from '../types/errors'; +import { withTimeout } from '../helpers/utils'; +import { TransportError, TransportTimeoutError } from '../types/errors'; import type { Transport, TransportResponse } from '../types/transport'; -import { CONTENT_SCRIPT, INPAGE, MULTICHAIN_SUBSTREAM_NAME } from './constants'; +import { CONTENT_SCRIPT, DEFAULT_REQUEST_TIMEOUT, INPAGE, MULTICHAIN_SUBSTREAM_NAME } from './constants'; /** * Creates a transport that communicates with the MetaMask extension via window.postMessage @@ -15,7 +16,8 @@ import { CONTENT_SCRIPT, INPAGE, MULTICHAIN_SUBSTREAM_NAME } from './constants'; * const result = await transport.request({ method: 'eth_getBalance', params: ['0x123', 'latest'] }); * ``` */ -export function getWindowPostMessageTransport(): Transport { +export function getWindowPostMessageTransport(params: { defaultTimeout?: number } = {}): Transport { + const { defaultTimeout = DEFAULT_REQUEST_TIMEOUT } = params; let messageListener: ((event: MessageEvent) => void) | null = null; const pendingRequests: Map void> = new Map(); let requestId = 1; @@ -93,10 +95,13 @@ export function getWindowPostMessageTransport(): Transport { window.addEventListener('message', messageListener); }, - disconnect, isConnected, - request: (params: ParamsType): Promise => { + request: ( + params: ParamsType, + options: { timeout?: number } = {}, + ): Promise => { + const { timeout = defaultTimeout } = options; if (!isConnected()) { throw new TransportError('Transport not connected'); } @@ -108,9 +113,20 @@ export function getWindowPostMessageTransport(): Transport { ...params, }; - return new Promise((resolve) => { - pendingRequests.set(id, resolve); - sendRequest(request); + return withTimeout( + new Promise((resolve) => { + // Resolve will actually get a TransportResponse; we coerce at the end. + pendingRequests.set(id, (value) => resolve(value as ReturnType)); + sendRequest(request); + }), + timeout, + () => new TransportTimeoutError(), + ).catch((err) => { + // Cleanup pending request on timeout (or other rejection before resolution) to prevent leaks + if (pendingRequests.has(id)) { + pendingRequests.delete(id); + } + throw err; }); }, diff --git a/src/types/errors.ts b/src/types/errors.ts index 818449d..904ad59 100644 --- a/src/types/errors.ts +++ b/src/types/errors.ts @@ -15,3 +15,9 @@ export class TransportError extends Error { Object.setPrototypeOf(this, this.constructor.prototype); } } + +export class TransportTimeoutError extends TransportError { + constructor(message = 'Transport request timed out', originalError?: unknown) { + super(message, originalError); + } +} diff --git a/src/types/transport.ts b/src/types/transport.ts index b3db9b3..2d9614c 100644 --- a/src/types/transport.ts +++ b/src/types/transport.ts @@ -35,10 +35,15 @@ export type Transport = { * @template TRequest - The request type containing method and params * @template TResponse - The expected response type * @param request - Request object with method and optional params + * @param options - Optional settings for the request + * @param options.timeout - Maximum time (in ms) before the request fails with a timeout error. Overrides the transport's default timeout if set. * @returns A promise that resolves to the response */ request: ( request: TRequest, + options?: { + timeout?: number; + }, ) => Promise; /**