@blockula/sdk 0.5.1 → 1.0.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.
- package/README.md +143 -13
- package/dist/describe.d.ts +3 -1
- package/dist/describe.js +93 -2
- package/dist/index.d.ts +602 -11
- package/dist/index.js +1080 -58
- package/dist/networks.d.ts +25 -0
- package/dist/networks.js +27 -0
- package/dist/payments.d.ts +83 -0
- package/dist/payments.js +240 -0
- package/dist/programs.d.ts +471 -0
- package/dist/programs.js +779 -0
- package/dist/proof.d.ts +44 -0
- package/dist/proof.js +62 -0
- package/dist/tx.d.ts +135 -0
- package/dist/tx.js +213 -0
- package/package.json +6 -5
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** A network this SDK knows by name. */
|
|
2
|
+
export type NetworkName = "testnet" | "local";
|
|
3
|
+
/** A chain's genesis clock: its genesis.json's `created_at_ms` and `block_time_ms`. */
|
|
4
|
+
export interface NetworkClock {
|
|
5
|
+
createdAtMs: number;
|
|
6
|
+
blockTimeMs: number;
|
|
7
|
+
}
|
|
8
|
+
/** What a client for a known network uses and pins. */
|
|
9
|
+
export interface NetworkPreset {
|
|
10
|
+
name: NetworkName;
|
|
11
|
+
/** A public RPC node, when one is built in. Without one, pass your node's URL. */
|
|
12
|
+
rpc?: string;
|
|
13
|
+
/** The chain id every status must report. */
|
|
14
|
+
chainId?: string;
|
|
15
|
+
/** The genesis hash every status must report. */
|
|
16
|
+
genesisHash?: string;
|
|
17
|
+
/** The address prefix (`boo` or `tboo`), known before the first request. */
|
|
18
|
+
prefix?: string;
|
|
19
|
+
/** The genesis clock of `genesisHash`'s genesis: it bounds the height a tx is signed against. */
|
|
20
|
+
genesisClock?: NetworkClock;
|
|
21
|
+
}
|
|
22
|
+
/** The networks this SDK knows: `new Client({ network })` pins what they list. */
|
|
23
|
+
export declare const NETWORKS: Readonly<Record<NetworkName, Readonly<NetworkPreset>>>;
|
|
24
|
+
/** Whether `s` names a network this SDK knows. */
|
|
25
|
+
export declare function isNetworkName(s: unknown): s is NetworkName;
|
package/dist/networks.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// The networks this SDK knows by name, and what a client for each pins (DEV-1).
|
|
2
|
+
//
|
|
3
|
+
// `new Client({ network: "testnet" })` (or `Client.testnet()`) talks to the public testnet and
|
|
4
|
+
// pins its chain id, genesis hash, address prefix and genesis clock up front, so the first address
|
|
5
|
+
// it renders is already `tboo1…` and a node on any other chain is refused before anything is
|
|
6
|
+
// signed. `new Client("http://localhost:7171", { network: "testnet" })` does the same through your
|
|
7
|
+
// own node. For a chain of your own, pin it yourself: `new Client(rpc, { chainId, genesisHash })`.
|
|
8
|
+
//
|
|
9
|
+
// The testnet values match mcp/src/network.ts (`TESTNET_RPC`, `KNOWN_GENESIS`, `KNOWN_CLOCKS`) and
|
|
10
|
+
// web/lib/config.ts; test/integrators.ts checks the MCP's.
|
|
11
|
+
const TESTNET = {
|
|
12
|
+
name: "testnet",
|
|
13
|
+
rpc: "https://blockula-testnet.fly.dev",
|
|
14
|
+
chainId: "blockula-testnet-2",
|
|
15
|
+
genesisHash: "b004e2f91b63429fc009088b16ec02b6d5388a03642699ae3dc6e325939f19b2",
|
|
16
|
+
prefix: "tboo",
|
|
17
|
+
genesisClock: { createdAtMs: 1792328803000, blockTimeMs: 1000 },
|
|
18
|
+
};
|
|
19
|
+
/** A `blockula dev` chain on this machine. Nothing is pinned: `--reset` makes a new chain each time. */
|
|
20
|
+
const LOCAL = { name: "local", rpc: "http://localhost:7070" };
|
|
21
|
+
const frozen = (p) => Object.freeze({ ...p, genesisClock: p.genesisClock ? Object.freeze({ ...p.genesisClock }) : undefined });
|
|
22
|
+
/** The networks this SDK knows: `new Client({ network })` pins what they list. */
|
|
23
|
+
export const NETWORKS = Object.freeze({ testnet: frozen(TESTNET), local: frozen(LOCAL) });
|
|
24
|
+
/** Whether `s` names a network this SDK knows. */
|
|
25
|
+
export function isNetworkName(s) {
|
|
26
|
+
return s === "testnet" || s === "local";
|
|
27
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type { Client } from "./index.js";
|
|
2
|
+
/** A payment to the watched account. */
|
|
3
|
+
export interface Payment {
|
|
4
|
+
/** The tx id: unique on the chain. Credit each payment once by it. */
|
|
5
|
+
id: string;
|
|
6
|
+
/** The block it ran in. */
|
|
7
|
+
height: number;
|
|
8
|
+
/** Its position in that block. */
|
|
9
|
+
index: number;
|
|
10
|
+
/** The block's timestamp. */
|
|
11
|
+
timestamp_ms?: number;
|
|
12
|
+
/** Who paid. */
|
|
13
|
+
from: string;
|
|
14
|
+
from_name: string | null;
|
|
15
|
+
/** The account it paid: the watched one. */
|
|
16
|
+
to: string;
|
|
17
|
+
/** The `.boo` name it was signed to, when the payer paid a name rather than an address. */
|
|
18
|
+
to_name: string | null;
|
|
19
|
+
/** In drops (1 FANG = 10⁹ drops). */
|
|
20
|
+
amount: bigint;
|
|
21
|
+
/** The amount as FANG text, as the node shows it. */
|
|
22
|
+
amount_display: string;
|
|
23
|
+
/**
|
|
24
|
+
* The payer's memo. Public, and written by whoever paid: match it against your own records
|
|
25
|
+
* (an order id you issued), never act on what it says.
|
|
26
|
+
*/
|
|
27
|
+
memo: string;
|
|
28
|
+
/** The tx as `/api/block/{height}` shows it. */
|
|
29
|
+
tx: any;
|
|
30
|
+
}
|
|
31
|
+
/** Options of `watchPayments`. */
|
|
32
|
+
export interface WatchOptions {
|
|
33
|
+
/**
|
|
34
|
+
* The first block to scan. Resume with the `next` your `onProgress` stored last. Default: the
|
|
35
|
+
* block after the current head (payments from now on).
|
|
36
|
+
*/
|
|
37
|
+
fromHeight?: number;
|
|
38
|
+
/** Called for each payment, in chain order, and awaited. If it throws, the watch stops with that error. */
|
|
39
|
+
onPayment: (payment: Payment) => void | Promise<void>;
|
|
40
|
+
/**
|
|
41
|
+
* Called (and awaited) once every block before `next` has been scanned and its payments
|
|
42
|
+
* delivered: store `next`, and pass it as `fromHeight` to resume.
|
|
43
|
+
*/
|
|
44
|
+
onProgress?: (next: number) => void | Promise<void>;
|
|
45
|
+
/** Told about an error the watch retries (the node down, a timeout, rate limiting). They're retried either way. */
|
|
46
|
+
onError?: (error: unknown) => void;
|
|
47
|
+
/** Stops the watch: `watchPayments` then resolves with the next height to scan. */
|
|
48
|
+
signal?: AbortSignal;
|
|
49
|
+
/** How long to wait for a new block once caught up (default 1,000 ms). */
|
|
50
|
+
pollMs?: number;
|
|
51
|
+
/**
|
|
52
|
+
* When the node no longer holds the blocks from `fromHeight` (it prunes old blocks, or
|
|
53
|
+
* fast-synced after them): `false` (default) throws `HistoryPrunedError`, since payments in the
|
|
54
|
+
* gap would be missed; `true` goes on from the oldest block it holds.
|
|
55
|
+
*/
|
|
56
|
+
skipPruned?: boolean;
|
|
57
|
+
}
|
|
58
|
+
/** The node no longer holds a block the watch needs (it was pruned, or the node fast-synced past it). */
|
|
59
|
+
export declare class HistoryPrunedError extends Error {
|
|
60
|
+
readonly rpc: string;
|
|
61
|
+
readonly from: number;
|
|
62
|
+
readonly historyFrom: number;
|
|
63
|
+
constructor(rpc: string, from: number, historyFrom: number);
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* The payments to `account` in a block as `/api/block/{height}` serves it: transfers that ran
|
|
67
|
+
* without an error and paid `account` (the tx's `to`, which for a tx signed to a name is the
|
|
68
|
+
* account the name resolved to when it ran; never the action's `to`, which can be the name).
|
|
69
|
+
*/
|
|
70
|
+
export declare function paymentsIn(block: unknown, account: string): Payment[];
|
|
71
|
+
/**
|
|
72
|
+
* Watches `account` (an address: names expire and change hands, so watch the address they
|
|
73
|
+
* pointed to) for payments: finalized transfers that paid it without an error, delivered to
|
|
74
|
+
* `onPayment` in chain order. It scans from `fromHeight` (default: new blocks only), fetching
|
|
75
|
+
* only blocks that hold txs, then follows the head. Resolves, with the next height to scan, once
|
|
76
|
+
* `signal` aborts; throws `HistoryPrunedError` when the node no longer holds a block it needs
|
|
77
|
+
* (unless `skipPruned`), and whatever `onPayment` or `onProgress` throws. Other errors (the node
|
|
78
|
+
* down, rate limited) are retried with a growing pause, and reported to `onError`.
|
|
79
|
+
*
|
|
80
|
+
* Only transfers count. Other ways FANG reaches an account (a hunt's award, an unshield, staking
|
|
81
|
+
* payouts) aren't payments here: read them from `client.history`.
|
|
82
|
+
*/
|
|
83
|
+
export declare function watchPayments(client: Client, account: string, opts: WatchOptions): Promise<number | undefined>;
|
package/dist/payments.js
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
// Watching an account for incoming payments (DEV-4): finalized, successful transfers whose `to`
|
|
2
|
+
// is the account, block by block, from a height you store. Blocks execute only once they're
|
|
3
|
+
// finalized, so a payment seen here is final: there are no reorgs to wait out.
|
|
4
|
+
//
|
|
5
|
+
// const stop = new AbortController();
|
|
6
|
+
// await watchPayments(client, "boo1…", {
|
|
7
|
+
// fromHeight: Number(await db.get("next") ?? 0) || undefined,
|
|
8
|
+
// onPayment: async (p) => { await db.creditOrder(p.memo, p.amount, p.id); },
|
|
9
|
+
// onProgress: async (next) => { await db.set("next", next); },
|
|
10
|
+
// signal: stop.signal,
|
|
11
|
+
// });
|
|
12
|
+
//
|
|
13
|
+
// Payments are delivered in chain order, each block's before the next block's. If `onPayment`
|
|
14
|
+
// throws, the watch stops there; resuming from the last stored `next` delivers that block's
|
|
15
|
+
// payments again, so credit each payment once by its `id`.
|
|
16
|
+
import { bech32m } from "@scure/base";
|
|
17
|
+
/** The node no longer holds a block the watch needs (it was pruned, or the node fast-synced past it). */
|
|
18
|
+
export class HistoryPrunedError extends Error {
|
|
19
|
+
rpc;
|
|
20
|
+
from;
|
|
21
|
+
historyFrom;
|
|
22
|
+
constructor(rpc, from, historyFrom) {
|
|
23
|
+
super(`${rpc} no longer holds block #${from}: its history starts at #${historyFrom}, so payments in between can't be read from it. Ask a node that keeps every block (blockula join --archive), or pass skipPruned to go on from #${historyFrom}`);
|
|
24
|
+
this.rpc = rpc;
|
|
25
|
+
this.from = from;
|
|
26
|
+
this.historyFrom = historyFrom;
|
|
27
|
+
this.name = "HistoryPrunedError";
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
const KNOWN_PREFIXES = ["boo", "tboo"];
|
|
31
|
+
/** The prefix of a canonical account address, or `undefined`. */
|
|
32
|
+
function prefixOf(s) {
|
|
33
|
+
if (typeof s !== "string" || s !== s.toLowerCase())
|
|
34
|
+
return undefined;
|
|
35
|
+
try {
|
|
36
|
+
const { prefix, words } = bech32m.decode(s);
|
|
37
|
+
return KNOWN_PREFIXES.includes(prefix) && bech32m.fromWords(words).length === 32 ? prefix : undefined;
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
const obj = (v) => (v && typeof v === "object" && !Array.isArray(v) ? v : {});
|
|
44
|
+
const addressOf = (v) => (typeof v === "string" ? v : String(obj(v).address ?? ""));
|
|
45
|
+
/**
|
|
46
|
+
* The payments to `account` in a block as `/api/block/{height}` serves it: transfers that ran
|
|
47
|
+
* without an error and paid `account` (the tx's `to`, which for a tx signed to a name is the
|
|
48
|
+
* account the name resolved to when it ran; never the action's `to`, which can be the name).
|
|
49
|
+
*/
|
|
50
|
+
export function paymentsIn(block, account) {
|
|
51
|
+
const b = obj(block);
|
|
52
|
+
const txs = Array.isArray(b.txs) ? b.txs : [];
|
|
53
|
+
const out = [];
|
|
54
|
+
txs.forEach((t0, index) => {
|
|
55
|
+
const t = obj(t0);
|
|
56
|
+
if (t.kind !== "transfer" || t.status !== "finalized" || (t.error !== null && t.error !== undefined))
|
|
57
|
+
return;
|
|
58
|
+
if (addressOf(t.to) !== account || typeof t.amount !== "string" || !/^\d+$/.test(t.amount))
|
|
59
|
+
return;
|
|
60
|
+
out.push({
|
|
61
|
+
id: String(t.id),
|
|
62
|
+
height: typeof t.height === "number" ? t.height : Number(b.height),
|
|
63
|
+
index,
|
|
64
|
+
timestamp_ms: typeof b.timestamp_ms === "number" ? b.timestamp_ms : undefined,
|
|
65
|
+
from: addressOf(t.from),
|
|
66
|
+
from_name: typeof obj(t.from).name === "string" ? obj(t.from).name : null,
|
|
67
|
+
to: account,
|
|
68
|
+
to_name: typeof t.to_name === "string" ? t.to_name : null,
|
|
69
|
+
amount: BigInt(t.amount),
|
|
70
|
+
amount_display: String(t.amount_display ?? ""),
|
|
71
|
+
memo: typeof t.memo === "string" ? t.memo : "",
|
|
72
|
+
tx: t0,
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
/** Waits `ms`, or less if `signal` aborts. */
|
|
78
|
+
function pause(ms, signal) {
|
|
79
|
+
return new Promise((resolve) => {
|
|
80
|
+
if (signal?.aborted)
|
|
81
|
+
return resolve();
|
|
82
|
+
const done = () => {
|
|
83
|
+
clearTimeout(timer);
|
|
84
|
+
signal?.removeEventListener("abort", done);
|
|
85
|
+
resolve();
|
|
86
|
+
};
|
|
87
|
+
const timer = setTimeout(done, ms);
|
|
88
|
+
signal?.addEventListener("abort", done, { once: true });
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
/** Errors worth trying again: the node unreachable or slow, rate limited, or failing for a moment. */
|
|
92
|
+
function transient(e) {
|
|
93
|
+
const err = e;
|
|
94
|
+
if (e instanceof TypeError)
|
|
95
|
+
return true; // fetch failed: unreachable
|
|
96
|
+
if (err?.name === "RequestTimeoutError")
|
|
97
|
+
return true;
|
|
98
|
+
if (err?.name === "ApiError" && typeof err.status === "number")
|
|
99
|
+
return err.status === 429 || err.status === 408 || err.status >= 500 || err.status === 200;
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
const isNotFound = (e) => e?.name === "ApiError" && e.status === 404;
|
|
103
|
+
const isPruned = (e) => e?.name === "ApiError" && e.status === 410;
|
|
104
|
+
/** An error thrown by the caller's own callback: passed on as it is, never retried. */
|
|
105
|
+
class Yours {
|
|
106
|
+
error;
|
|
107
|
+
constructor(error) {
|
|
108
|
+
this.error = error;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
async function yours(f) {
|
|
112
|
+
try {
|
|
113
|
+
await f();
|
|
114
|
+
}
|
|
115
|
+
catch (e) {
|
|
116
|
+
throw new Yours(e);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
/** Headers fetched per request (`/api/blocks` serves at most 100). */
|
|
120
|
+
const WINDOW = 100;
|
|
121
|
+
/**
|
|
122
|
+
* Watches `account` (an address: names expire and change hands, so watch the address they
|
|
123
|
+
* pointed to) for payments: finalized transfers that paid it without an error, delivered to
|
|
124
|
+
* `onPayment` in chain order. It scans from `fromHeight` (default: new blocks only), fetching
|
|
125
|
+
* only blocks that hold txs, then follows the head. Resolves, with the next height to scan, once
|
|
126
|
+
* `signal` aborts; throws `HistoryPrunedError` when the node no longer holds a block it needs
|
|
127
|
+
* (unless `skipPruned`), and whatever `onPayment` or `onProgress` throws. Other errors (the node
|
|
128
|
+
* down, rate limited) are retried with a growing pause, and reported to `onError`.
|
|
129
|
+
*
|
|
130
|
+
* Only transfers count. Other ways FANG reaches an account (a hunt's award, an unshield, staking
|
|
131
|
+
* payouts) aren't payments here: read them from `client.history`.
|
|
132
|
+
*/
|
|
133
|
+
export async function watchPayments(client, account, opts) {
|
|
134
|
+
const accountPrefix = prefixOf(account);
|
|
135
|
+
if (accountPrefix === undefined)
|
|
136
|
+
throw new Error(`${account} isn't an address: watch the address (a name can expire and change hands)`);
|
|
137
|
+
if (typeof opts.onPayment !== "function")
|
|
138
|
+
throw new Error("watchPayments needs onPayment");
|
|
139
|
+
const pollMs = opts.pollMs ?? 1_000;
|
|
140
|
+
if (!Number.isSafeInteger(pollMs) || pollMs < 1)
|
|
141
|
+
throw new Error("pollMs must be a whole number of milliseconds");
|
|
142
|
+
if (opts.fromHeight !== undefined && (!Number.isSafeInteger(opts.fromHeight) || opts.fromHeight < 0))
|
|
143
|
+
throw new Error("fromHeight must be a whole block height");
|
|
144
|
+
const { signal } = opts;
|
|
145
|
+
let next = opts.fromHeight === undefined ? undefined : Math.max(1, opts.fromHeight);
|
|
146
|
+
let backoff = pollMs;
|
|
147
|
+
while (!signal?.aborted) {
|
|
148
|
+
try {
|
|
149
|
+
const status = await client.status();
|
|
150
|
+
if (typeof status.address_prefix === "string" && status.address_prefix !== accountPrefix) {
|
|
151
|
+
throw new Error(`${account} is a ${accountPrefix}1… address, but ${client.rpc}'s chain uses ${status.address_prefix}1… addresses`);
|
|
152
|
+
}
|
|
153
|
+
const height = Number(status.height);
|
|
154
|
+
const finalized = Number(status.finalized_height ?? height);
|
|
155
|
+
const head = Math.min(height, Number.isSafeInteger(finalized) ? finalized : height);
|
|
156
|
+
const historyFrom = Number(status.history_from ?? 1) || 1;
|
|
157
|
+
if (next === undefined)
|
|
158
|
+
next = head + 1;
|
|
159
|
+
if (next < historyFrom) {
|
|
160
|
+
if (!opts.skipPruned)
|
|
161
|
+
throw new HistoryPrunedError(client.rpc, next, historyFrom);
|
|
162
|
+
opts.onError?.(new HistoryPrunedError(client.rpc, next, historyFrom));
|
|
163
|
+
next = historyFrom;
|
|
164
|
+
}
|
|
165
|
+
if (next > head) {
|
|
166
|
+
backoff = pollMs;
|
|
167
|
+
await pause(pollMs, signal);
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
const end = Math.min(head, next + WINDOW - 1);
|
|
171
|
+
const headers = await client.blocks({ limit: end - next + 1, before: end + 1 });
|
|
172
|
+
const byHeight = new Map(headers.map((h) => [Number(h?.height), h]));
|
|
173
|
+
let stalled = false;
|
|
174
|
+
for (let h = next; h <= end; h++) {
|
|
175
|
+
if (signal?.aborted)
|
|
176
|
+
return next;
|
|
177
|
+
const header = byHeight.get(h);
|
|
178
|
+
if (!header) {
|
|
179
|
+
// Pruned since the status was read, or not served yet (a node behind a load balancer
|
|
180
|
+
// whose machines differ): never pass over a block unread. Try again in a while.
|
|
181
|
+
const now = await client.status();
|
|
182
|
+
if (h < Number(now.history_from ?? 1)) {
|
|
183
|
+
if (!opts.skipPruned)
|
|
184
|
+
throw new HistoryPrunedError(client.rpc, h, Number(now.history_from));
|
|
185
|
+
break;
|
|
186
|
+
}
|
|
187
|
+
opts.onError?.(new Error(`${client.rpc} didn't serve block #${h}'s header yet`));
|
|
188
|
+
stalled = true;
|
|
189
|
+
break;
|
|
190
|
+
}
|
|
191
|
+
if (Number(header.tx_count) > 0) {
|
|
192
|
+
let block;
|
|
193
|
+
try {
|
|
194
|
+
block = await client.block(h);
|
|
195
|
+
}
|
|
196
|
+
catch (e) {
|
|
197
|
+
if (isNotFound(e)) {
|
|
198
|
+
opts.onError?.(e);
|
|
199
|
+
stalled = true;
|
|
200
|
+
break;
|
|
201
|
+
}
|
|
202
|
+
if (!isPruned(e))
|
|
203
|
+
throw e;
|
|
204
|
+
const now = await client.status().catch(() => null);
|
|
205
|
+
if (!opts.skipPruned)
|
|
206
|
+
throw new HistoryPrunedError(client.rpc, h, Number(now?.history_from ?? h + 1));
|
|
207
|
+
break;
|
|
208
|
+
}
|
|
209
|
+
if (Number(block?.height) !== h)
|
|
210
|
+
throw new Error(`${client.rpc} answered block #${h} with block #${block?.height}`);
|
|
211
|
+
const payments = paymentsIn(block, account);
|
|
212
|
+
for (const p of payments)
|
|
213
|
+
await yours(() => opts.onPayment(p));
|
|
214
|
+
next = h + 1;
|
|
215
|
+
if (payments.length)
|
|
216
|
+
await yours(() => opts.onProgress?.(h + 1));
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
next = h + 1;
|
|
220
|
+
}
|
|
221
|
+
await yours(() => opts.onProgress?.(next));
|
|
222
|
+
if (stalled) {
|
|
223
|
+
await pause(backoff, signal);
|
|
224
|
+
backoff = Math.min(backoff * 2, 30_000);
|
|
225
|
+
}
|
|
226
|
+
else
|
|
227
|
+
backoff = pollMs;
|
|
228
|
+
}
|
|
229
|
+
catch (e) {
|
|
230
|
+
if (e instanceof Yours)
|
|
231
|
+
throw e.error;
|
|
232
|
+
if (!transient(e))
|
|
233
|
+
throw e;
|
|
234
|
+
opts.onError?.(e);
|
|
235
|
+
await pause(backoff, signal);
|
|
236
|
+
backoff = Math.min(backoff * 2, 30_000);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return next;
|
|
240
|
+
}
|