@parity/product-sdk-signer 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,15 @@
1
+ // Provider interface
2
+ export type { SignerProvider, Unsubscribe } from "./types.js";
3
+
4
+ // Dev provider (testnet accounts)
5
+ export { DevProvider } from "./dev.js";
6
+ export type { DevProviderOptions, DevAccountName, DevKeyType } from "./dev.js";
7
+
8
+ // Host provider (Polkadot Desktop / Mobile)
9
+ export { HostProvider } from "./host.js";
10
+ export type {
11
+ HostProviderOptions,
12
+ ProductAccount,
13
+ ContextualAlias,
14
+ RingLocation,
15
+ } from "./host.js";
@@ -0,0 +1,50 @@
1
+ import type { SignerError } from "../errors.js";
2
+ import type { ConnectionStatus, ProviderType, Result, SignerAccount } from "../types.js";
3
+
4
+ /** Function that unsubscribes a listener when called. */
5
+ export type Unsubscribe = () => void;
6
+
7
+ /**
8
+ * Interface that all signer providers must implement.
9
+ *
10
+ * Providers are responsible for discovering accounts and creating signers
11
+ * from a specific source (Host API or dev accounts).
12
+ */
13
+ export interface SignerProvider {
14
+ /** Unique identifier for this provider type. */
15
+ readonly type: ProviderType;
16
+
17
+ /**
18
+ * Attempt to connect and discover accounts.
19
+ *
20
+ * @param signal - Optional AbortSignal to cancel the connection attempt.
21
+ * @returns Accounts on success, typed error on failure.
22
+ */
23
+ connect(signal?: AbortSignal): Promise<Result<SignerAccount[], SignerError>>;
24
+
25
+ /**
26
+ * Disconnect and clean up resources.
27
+ * Safe to call multiple times.
28
+ */
29
+ disconnect(): void;
30
+
31
+ /**
32
+ * Subscribe to connection status changes.
33
+ *
34
+ * Not all providers emit status changes — for example, dev accounts
35
+ * are always "connected" and never emit.
36
+ *
37
+ * @returns Unsubscribe function.
38
+ */
39
+ onStatusChange(callback: (status: ConnectionStatus) => void): Unsubscribe;
40
+
41
+ /**
42
+ * Subscribe to account list changes.
43
+ *
44
+ * Emitted when the set of available accounts changes (e.g., user
45
+ * connects/disconnects in the host wallet).
46
+ *
47
+ * @returns Unsubscribe function.
48
+ */
49
+ onAccountsChange(callback: (accounts: SignerAccount[]) => void): Unsubscribe;
50
+ }
package/src/retry.ts ADDED
@@ -0,0 +1,250 @@
1
+ /**
2
+ * Result-based retry with exponential backoff for signer connection attempts.
3
+ *
4
+ * This retry utility works with `Result<T, E>` return values (not exceptions).
5
+ * It exists separately from the tx package's exception-based `withRetry` because:
6
+ *
7
+ * - **Signer retry**: retries connection attempts that return `Result<T, SignerError>`.
8
+ * Errors are expected values, not exceptional conditions. Supports AbortSignal.
9
+ * - **TX retry**: retries transaction submissions that throw exceptions.
10
+ * Non-retryable errors (dispatch, rejection, timeout) are detected and rethrown.
11
+ *
12
+ * Both use exponential backoff but serve different abstraction layers.
13
+ *
14
+ * @module
15
+ */
16
+ import { sleep } from "./sleep.js";
17
+ import type { Result } from "./types.js";
18
+
19
+ /** Options for retry with exponential backoff. */
20
+ export interface RetryOptions {
21
+ /** Maximum number of attempts. Default: 3 */
22
+ maxAttempts?: number;
23
+ /** Initial delay in ms before first retry. Default: 500 */
24
+ initialDelay?: number;
25
+ /** Multiplier applied to delay after each attempt. Default: 2 */
26
+ backoffMultiplier?: number;
27
+ /** Maximum delay cap in ms. Default: 10_000 */
28
+ maxDelay?: number;
29
+ /** AbortSignal to cancel retries early. */
30
+ signal?: AbortSignal;
31
+ }
32
+
33
+ const DEFAULT_MAX_ATTEMPTS = 3;
34
+ const DEFAULT_INITIAL_DELAY = 500;
35
+ const DEFAULT_BACKOFF_MULTIPLIER = 2;
36
+ const DEFAULT_MAX_DELAY = 10_000;
37
+
38
+ /**
39
+ * Retry an async operation with exponential backoff.
40
+ *
41
+ * Calls `fn` up to `maxAttempts` times. If `fn` returns an error result,
42
+ * waits with exponential backoff before the next attempt. Returns the first
43
+ * successful result or the last error.
44
+ */
45
+ export async function withRetry<T, E>(
46
+ fn: (attempt: number) => Promise<Result<T, E>>,
47
+ options?: RetryOptions,
48
+ ): Promise<Result<T, E>> {
49
+ const maxAttempts = Math.max(1, options?.maxAttempts ?? DEFAULT_MAX_ATTEMPTS);
50
+ const initialDelay = options?.initialDelay ?? DEFAULT_INITIAL_DELAY;
51
+ const backoffMultiplier = options?.backoffMultiplier ?? DEFAULT_BACKOFF_MULTIPLIER;
52
+ const maxDelay = options?.maxDelay ?? DEFAULT_MAX_DELAY;
53
+ const signal = options?.signal;
54
+
55
+ let lastResult: Result<T, E> | undefined;
56
+ let delay = initialDelay;
57
+
58
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
59
+ if (signal?.aborted && lastResult) {
60
+ return lastResult;
61
+ }
62
+
63
+ lastResult = await fn(attempt);
64
+ if (lastResult.ok) {
65
+ return lastResult;
66
+ }
67
+
68
+ // Don't delay after the last attempt
69
+ if (attempt < maxAttempts - 1) {
70
+ await sleep(delay, signal);
71
+ delay = Math.min(delay * backoffMultiplier, maxDelay);
72
+ }
73
+ }
74
+
75
+ // lastResult is always defined here since maxAttempts >= 1
76
+ return lastResult!;
77
+ }
78
+
79
+ if (import.meta.vitest) {
80
+ const { test, expect, describe, vi, beforeEach, afterEach } = import.meta.vitest;
81
+ const { ok, err } = await import("./types.js");
82
+
83
+ beforeEach(() => {
84
+ vi.useFakeTimers();
85
+ });
86
+
87
+ afterEach(() => {
88
+ vi.useRealTimers();
89
+ });
90
+
91
+ describe("withRetry", () => {
92
+ test("succeeds on first attempt with no delay", async () => {
93
+ const fn = vi.fn().mockResolvedValue(ok("done"));
94
+ const promise = withRetry(fn);
95
+ const result = await promise;
96
+ expect(result).toEqual(ok("done"));
97
+ expect(fn).toHaveBeenCalledTimes(1);
98
+ expect(fn).toHaveBeenCalledWith(0);
99
+ });
100
+
101
+ test("retries on failure and succeeds on second attempt", async () => {
102
+ const fn = vi
103
+ .fn()
104
+ .mockResolvedValueOnce(err("fail1"))
105
+ .mockResolvedValueOnce(ok("success"));
106
+
107
+ const promise = withRetry(fn, { initialDelay: 100 });
108
+ await vi.advanceTimersByTimeAsync(100);
109
+ const result = await promise;
110
+
111
+ expect(result).toEqual(ok("success"));
112
+ expect(fn).toHaveBeenCalledTimes(2);
113
+ expect(fn).toHaveBeenNthCalledWith(1, 0);
114
+ expect(fn).toHaveBeenNthCalledWith(2, 1);
115
+ });
116
+
117
+ test("exhausts maxAttempts and returns last error", async () => {
118
+ const fn = vi
119
+ .fn()
120
+ .mockResolvedValueOnce(err("fail1"))
121
+ .mockResolvedValueOnce(err("fail2"))
122
+ .mockResolvedValueOnce(err("fail3"));
123
+
124
+ const promise = withRetry(fn, {
125
+ maxAttempts: 3,
126
+ initialDelay: 100,
127
+ backoffMultiplier: 2,
128
+ });
129
+
130
+ await vi.advanceTimersByTimeAsync(100); // delay after attempt 0
131
+ await vi.advanceTimersByTimeAsync(200); // delay after attempt 1
132
+ const result = await promise;
133
+
134
+ expect(result).toEqual(err("fail3"));
135
+ expect(fn).toHaveBeenCalledTimes(3);
136
+ });
137
+
138
+ test("respects AbortSignal cancellation", async () => {
139
+ const controller = new AbortController();
140
+ const fn = vi.fn().mockResolvedValue(err("fail"));
141
+
142
+ const promise = withRetry(fn, {
143
+ maxAttempts: 5,
144
+ initialDelay: 1000,
145
+ signal: controller.signal,
146
+ });
147
+
148
+ // First attempt runs, then abort during delay
149
+ await vi.advanceTimersByTimeAsync(0);
150
+ controller.abort();
151
+ await vi.advanceTimersByTimeAsync(0);
152
+ const result = await promise;
153
+
154
+ expect(result.ok).toBe(false);
155
+ // Should not have retried all 5 times
156
+ expect(fn.mock.calls.length).toBeLessThan(5);
157
+ });
158
+
159
+ test("backoff delay increases correctly", async () => {
160
+ const fn = vi
161
+ .fn()
162
+ .mockResolvedValueOnce(err("e1"))
163
+ .mockResolvedValueOnce(err("e2"))
164
+ .mockResolvedValueOnce(err("e3"))
165
+ .mockResolvedValueOnce(ok("done"));
166
+
167
+ const promise = withRetry(fn, {
168
+ maxAttempts: 4,
169
+ initialDelay: 100,
170
+ backoffMultiplier: 2,
171
+ });
172
+
173
+ // After attempt 0: delay 100ms
174
+ await vi.advanceTimersByTimeAsync(100);
175
+ // After attempt 1: delay 200ms
176
+ await vi.advanceTimersByTimeAsync(200);
177
+ // After attempt 2: delay 400ms
178
+ await vi.advanceTimersByTimeAsync(400);
179
+ const result = await promise;
180
+
181
+ expect(result).toEqual(ok("done"));
182
+ expect(fn).toHaveBeenCalledTimes(4);
183
+ });
184
+
185
+ test("caps delay at maxDelay", async () => {
186
+ const fn = vi.fn().mockImplementation(async () => {
187
+ return err("fail");
188
+ });
189
+
190
+ const promise = withRetry(fn, {
191
+ maxAttempts: 4,
192
+ initialDelay: 5000,
193
+ backoffMultiplier: 3,
194
+ maxDelay: 8000,
195
+ });
196
+
197
+ // Attempt 0, delay 5000ms
198
+ await vi.advanceTimersByTimeAsync(5000);
199
+ // Attempt 1, delay min(15000, 8000) = 8000ms
200
+ await vi.advanceTimersByTimeAsync(8000);
201
+ // Attempt 2, delay min(24000, 8000) = 8000ms
202
+ await vi.advanceTimersByTimeAsync(8000);
203
+ const result = await promise;
204
+
205
+ expect(result.ok).toBe(false);
206
+ expect(fn).toHaveBeenCalledTimes(4);
207
+ });
208
+
209
+ test("attempt number is passed correctly to fn", async () => {
210
+ const attempts: number[] = [];
211
+ const fn = vi.fn().mockImplementation(async (attempt: number) => {
212
+ attempts.push(attempt);
213
+ return attempt < 2 ? err("retry") : ok("done");
214
+ });
215
+
216
+ const promise = withRetry(fn, {
217
+ maxAttempts: 3,
218
+ initialDelay: 50,
219
+ });
220
+ await vi.advanceTimersByTimeAsync(50);
221
+ await vi.advanceTimersByTimeAsync(100);
222
+ await promise;
223
+
224
+ expect(attempts).toEqual([0, 1, 2]);
225
+ });
226
+
227
+ test("single attempt with maxAttempts=1", async () => {
228
+ const fn = vi.fn().mockResolvedValue(err("fail"));
229
+
230
+ const result = await withRetry(fn, { maxAttempts: 1 });
231
+ expect(result).toEqual(err("fail"));
232
+ expect(fn).toHaveBeenCalledTimes(1);
233
+ });
234
+
235
+ test("signal already aborted before first attempt — fn still called once", async () => {
236
+ const controller = new AbortController();
237
+ controller.abort();
238
+
239
+ const fn = vi.fn().mockResolvedValue(err("fail"));
240
+ const result = await withRetry(fn, {
241
+ maxAttempts: 3,
242
+ signal: controller.signal,
243
+ });
244
+
245
+ expect(result.ok).toBe(false);
246
+ // fn is called once so it can produce a properly-typed error
247
+ expect(fn).toHaveBeenCalledTimes(1);
248
+ });
249
+ });
250
+ }