@spectratools/cli-shared 0.1.0 → 0.1.2
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/dist/chunk-74BQLM22.js +136 -0
- package/dist/chunk-OPDNZWSI.js +32 -0
- package/dist/chunk-QEANTXUE.js +49 -0
- package/dist/chunk-TKORXDDX.js +55 -0
- package/dist/index.d.ts +3 -124
- package/dist/index.js +24 -241
- package/dist/middleware/index.d.ts +59 -0
- package/dist/middleware/index.js +19 -0
- package/dist/testing/index.d.ts +24 -0
- package/dist/testing/index.js +6 -0
- package/dist/utils/index.d.ts +47 -0
- package/dist/utils/index.js +20 -0
- package/package.json +9 -9
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import {
|
|
2
|
+
HttpError
|
|
3
|
+
} from "./chunk-TKORXDDX.js";
|
|
4
|
+
|
|
5
|
+
// src/middleware/auth.ts
|
|
6
|
+
var MissingApiKeyError = class extends Error {
|
|
7
|
+
constructor(envVar) {
|
|
8
|
+
super(`Missing required API key. Set the ${envVar} environment variable.`);
|
|
9
|
+
this.name = "MissingApiKeyError";
|
|
10
|
+
}
|
|
11
|
+
};
|
|
12
|
+
function apiKeyAuth(envVar) {
|
|
13
|
+
const apiKey = process.env[envVar];
|
|
14
|
+
if (!apiKey) {
|
|
15
|
+
throw new MissingApiKeyError(envVar);
|
|
16
|
+
}
|
|
17
|
+
return { apiKey };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// src/middleware/pagination.ts
|
|
21
|
+
async function* paginateCursor(options) {
|
|
22
|
+
let cursor = null;
|
|
23
|
+
while (true) {
|
|
24
|
+
const { items, nextCursor } = await options.fetchPage(cursor);
|
|
25
|
+
for (const item of items) {
|
|
26
|
+
yield item;
|
|
27
|
+
}
|
|
28
|
+
if (!nextCursor) break;
|
|
29
|
+
cursor = nextCursor;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
async function* paginateOffset(options) {
|
|
33
|
+
const limit = options.limit ?? 100;
|
|
34
|
+
let offset = 0;
|
|
35
|
+
while (true) {
|
|
36
|
+
const { items, total } = await options.fetchPage(offset, limit);
|
|
37
|
+
for (const item of items) {
|
|
38
|
+
yield item;
|
|
39
|
+
}
|
|
40
|
+
offset += items.length;
|
|
41
|
+
if (offset >= total || items.length === 0) break;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// src/middleware/rate-limit.ts
|
|
46
|
+
function createRateLimiter(options) {
|
|
47
|
+
const { requestsPerSecond } = options;
|
|
48
|
+
const intervalMs = 1e3 / requestsPerSecond;
|
|
49
|
+
const queue = [];
|
|
50
|
+
let tokens = requestsPerSecond;
|
|
51
|
+
let lastRefill = Date.now();
|
|
52
|
+
let processingInterval = null;
|
|
53
|
+
function refill() {
|
|
54
|
+
const now = Date.now();
|
|
55
|
+
const elapsed = now - lastRefill;
|
|
56
|
+
const newTokens = elapsed / 1e3 * requestsPerSecond;
|
|
57
|
+
tokens = Math.min(requestsPerSecond, tokens + newTokens);
|
|
58
|
+
lastRefill = now;
|
|
59
|
+
}
|
|
60
|
+
function processQueue() {
|
|
61
|
+
refill();
|
|
62
|
+
while (queue.length > 0 && tokens >= 1) {
|
|
63
|
+
tokens -= 1;
|
|
64
|
+
const resolve = queue.shift();
|
|
65
|
+
resolve?.();
|
|
66
|
+
}
|
|
67
|
+
if (queue.length === 0 && processingInterval !== null) {
|
|
68
|
+
clearInterval(processingInterval);
|
|
69
|
+
processingInterval = null;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return () => {
|
|
73
|
+
return new Promise((resolve) => {
|
|
74
|
+
refill();
|
|
75
|
+
if (tokens >= 1) {
|
|
76
|
+
tokens -= 1;
|
|
77
|
+
resolve();
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
queue.push(resolve);
|
|
81
|
+
if (processingInterval === null) {
|
|
82
|
+
processingInterval = setInterval(processQueue, intervalMs);
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
function withRateLimit(fn, acquire) {
|
|
88
|
+
return acquire().then(() => fn());
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// src/middleware/retry.ts
|
|
92
|
+
function sleep(ms) {
|
|
93
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
94
|
+
}
|
|
95
|
+
function jitter(ms) {
|
|
96
|
+
return ms * (0.5 + Math.random() * 0.5);
|
|
97
|
+
}
|
|
98
|
+
function parseRetryAfter(headers) {
|
|
99
|
+
const retryAfter = headers.get("Retry-After");
|
|
100
|
+
if (!retryAfter) return null;
|
|
101
|
+
const seconds = Number(retryAfter);
|
|
102
|
+
if (!Number.isNaN(seconds)) return seconds * 1e3;
|
|
103
|
+
const date = Date.parse(retryAfter);
|
|
104
|
+
if (!Number.isNaN(date)) return Math.max(0, date - Date.now());
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
async function withRetry(fn, options) {
|
|
108
|
+
const { maxRetries, baseMs, maxMs } = options;
|
|
109
|
+
let attempt = 0;
|
|
110
|
+
while (true) {
|
|
111
|
+
try {
|
|
112
|
+
return await fn();
|
|
113
|
+
} catch (err) {
|
|
114
|
+
if (attempt >= maxRetries) throw err;
|
|
115
|
+
let delayMs;
|
|
116
|
+
if (err instanceof HttpError && (err.status === 429 || err.status === 503)) {
|
|
117
|
+
const retryAfterMs = parseRetryAfter(err.headers);
|
|
118
|
+
delayMs = retryAfterMs ?? Math.min(baseMs * 2 ** attempt, maxMs);
|
|
119
|
+
} else {
|
|
120
|
+
delayMs = Math.min(baseMs * 2 ** attempt, maxMs);
|
|
121
|
+
}
|
|
122
|
+
await sleep(jitter(delayMs));
|
|
123
|
+
attempt++;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export {
|
|
129
|
+
MissingApiKeyError,
|
|
130
|
+
apiKeyAuth,
|
|
131
|
+
paginateCursor,
|
|
132
|
+
paginateOffset,
|
|
133
|
+
createRateLimiter,
|
|
134
|
+
withRateLimit,
|
|
135
|
+
withRetry
|
|
136
|
+
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// src/utils/format.ts
|
|
2
|
+
import { Address } from "ox";
|
|
3
|
+
var ETH_DECIMALS = 18n;
|
|
4
|
+
var WEI_PER_ETH = 10n ** ETH_DECIMALS;
|
|
5
|
+
function weiToEth(wei, decimals = 6) {
|
|
6
|
+
const weiValue = typeof wei === "string" ? BigInt(wei) : wei;
|
|
7
|
+
const whole = weiValue / WEI_PER_ETH;
|
|
8
|
+
const frac = weiValue % WEI_PER_ETH;
|
|
9
|
+
const fracStr = frac.toString().padStart(18, "0").slice(0, decimals).replace(/0+$/, "");
|
|
10
|
+
return fracStr.length > 0 ? `${whole}.${fracStr}` : `${whole}`;
|
|
11
|
+
}
|
|
12
|
+
function isAddress(address) {
|
|
13
|
+
return Address.validate(address);
|
|
14
|
+
}
|
|
15
|
+
function checksumAddress(address) {
|
|
16
|
+
return Address.checksum(address);
|
|
17
|
+
}
|
|
18
|
+
function formatTimestamp(unixSeconds) {
|
|
19
|
+
return new Date(unixSeconds * 1e3).toISOString();
|
|
20
|
+
}
|
|
21
|
+
function truncate(str, prefixLen = 6, suffixLen = 4) {
|
|
22
|
+
if (str.length <= prefixLen + suffixLen + 3) return str;
|
|
23
|
+
return `${str.slice(0, prefixLen)}...${str.slice(-suffixLen)}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export {
|
|
27
|
+
weiToEth,
|
|
28
|
+
isAddress,
|
|
29
|
+
checksumAddress,
|
|
30
|
+
formatTimestamp,
|
|
31
|
+
truncate
|
|
32
|
+
};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// src/testing/mock-server.ts
|
|
2
|
+
import { createServer } from "http";
|
|
3
|
+
async function createMockServer() {
|
|
4
|
+
const routes = /* @__PURE__ */ new Map();
|
|
5
|
+
const requests = [];
|
|
6
|
+
const server = createServer((req, res) => {
|
|
7
|
+
let body = "";
|
|
8
|
+
req.on("data", (chunk) => {
|
|
9
|
+
body += chunk.toString();
|
|
10
|
+
});
|
|
11
|
+
req.on("end", () => {
|
|
12
|
+
const recorded = {
|
|
13
|
+
method: req.method ?? "GET",
|
|
14
|
+
url: req.url ?? "/",
|
|
15
|
+
headers: req.headers,
|
|
16
|
+
body
|
|
17
|
+
};
|
|
18
|
+
requests.push(recorded);
|
|
19
|
+
const key = `${recorded.method}:${recorded.url.split("?")[0]}`;
|
|
20
|
+
const mock = routes.get(key) ?? routes.get("*");
|
|
21
|
+
const status = mock?.status ?? 200;
|
|
22
|
+
const responseHeaders = mock?.headers ?? { "Content-Type": "application/json" };
|
|
23
|
+
const responseBody = mock?.body !== void 0 ? JSON.stringify(mock.body) : "{}";
|
|
24
|
+
res.writeHead(status, responseHeaders);
|
|
25
|
+
res.end(responseBody);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
29
|
+
const address = server.address();
|
|
30
|
+
if (!address || typeof address === "string") {
|
|
31
|
+
throw new Error("Failed to get server address");
|
|
32
|
+
}
|
|
33
|
+
return {
|
|
34
|
+
url: `http://127.0.0.1:${address.port}`,
|
|
35
|
+
requests,
|
|
36
|
+
addRoute(method, path, response) {
|
|
37
|
+
routes.set(`${method.toUpperCase()}:${path}`, response);
|
|
38
|
+
},
|
|
39
|
+
close() {
|
|
40
|
+
return new Promise((resolve, reject) => {
|
|
41
|
+
server.close((err) => err ? reject(err) : resolve());
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export {
|
|
48
|
+
createMockServer
|
|
49
|
+
};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// src/utils/http.ts
|
|
2
|
+
var HttpError = class extends Error {
|
|
3
|
+
constructor(status, statusText, body, headers = new Headers()) {
|
|
4
|
+
super(`HTTP ${status} ${statusText}: ${body}`);
|
|
5
|
+
this.status = status;
|
|
6
|
+
this.statusText = statusText;
|
|
7
|
+
this.body = body;
|
|
8
|
+
this.headers = headers;
|
|
9
|
+
this.name = "HttpError";
|
|
10
|
+
}
|
|
11
|
+
};
|
|
12
|
+
function serializeQuery(params) {
|
|
13
|
+
const parts = [];
|
|
14
|
+
for (const [key, value] of Object.entries(params)) {
|
|
15
|
+
if (value === void 0 || value === null) continue;
|
|
16
|
+
parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`);
|
|
17
|
+
}
|
|
18
|
+
return parts.length > 0 ? `?${parts.join("&")}` : "";
|
|
19
|
+
}
|
|
20
|
+
function createHttpClient(options) {
|
|
21
|
+
const { baseUrl, defaultHeaders = {} } = options;
|
|
22
|
+
async function request(path, opts = {}) {
|
|
23
|
+
const { method = "GET", headers = {}, query = {}, body } = opts;
|
|
24
|
+
const qs = serializeQuery(query);
|
|
25
|
+
const url = `${baseUrl}${path}${qs}`;
|
|
26
|
+
const init = {
|
|
27
|
+
method
|
|
28
|
+
};
|
|
29
|
+
const mergedHeaders = {
|
|
30
|
+
...defaultHeaders,
|
|
31
|
+
...headers
|
|
32
|
+
};
|
|
33
|
+
if (body !== void 0) {
|
|
34
|
+
mergedHeaders["Content-Type"] ??= "application/json";
|
|
35
|
+
init.body = JSON.stringify(body);
|
|
36
|
+
}
|
|
37
|
+
init.headers = mergedHeaders;
|
|
38
|
+
const res = await fetch(url, init);
|
|
39
|
+
if (!res.ok) {
|
|
40
|
+
const text = await res.text();
|
|
41
|
+
throw new HttpError(res.status, res.statusText, text, res.headers);
|
|
42
|
+
}
|
|
43
|
+
const contentType = res.headers.get("content-type") ?? "";
|
|
44
|
+
if (contentType.includes("application/json")) {
|
|
45
|
+
return res.json();
|
|
46
|
+
}
|
|
47
|
+
return res.text();
|
|
48
|
+
}
|
|
49
|
+
return { request };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export {
|
|
53
|
+
HttpError,
|
|
54
|
+
createHttpClient
|
|
55
|
+
};
|
package/dist/index.d.ts
CHANGED
|
@@ -1,124 +1,3 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
}
|
|
4
|
-
interface ApiKeyAuthContext {
|
|
5
|
-
apiKey: string;
|
|
6
|
-
}
|
|
7
|
-
/**
|
|
8
|
-
* Reads an API key from the environment and returns it.
|
|
9
|
-
* Throws MissingApiKeyError if the variable is not set.
|
|
10
|
-
*/
|
|
11
|
-
declare function apiKeyAuth(envVar: string): ApiKeyAuthContext;
|
|
12
|
-
|
|
13
|
-
interface CursorPaginationOptions<T> {
|
|
14
|
-
fetchPage: (cursor: string | null) => Promise<{
|
|
15
|
-
items: T[];
|
|
16
|
-
nextCursor: string | null;
|
|
17
|
-
}>;
|
|
18
|
-
}
|
|
19
|
-
interface OffsetPaginationOptions<T> {
|
|
20
|
-
fetchPage: (offset: number, limit: number) => Promise<{
|
|
21
|
-
items: T[];
|
|
22
|
-
total: number;
|
|
23
|
-
}>;
|
|
24
|
-
limit?: number;
|
|
25
|
-
}
|
|
26
|
-
/**
|
|
27
|
-
* Async iterator for cursor-based pagination.
|
|
28
|
-
*/
|
|
29
|
-
declare function paginateCursor<T>(options: CursorPaginationOptions<T>): AsyncGenerator<T>;
|
|
30
|
-
/**
|
|
31
|
-
* Async iterator for offset-based pagination.
|
|
32
|
-
*/
|
|
33
|
-
declare function paginateOffset<T>(options: OffsetPaginationOptions<T>): AsyncGenerator<T>;
|
|
34
|
-
|
|
35
|
-
interface RateLimitOptions {
|
|
36
|
-
requestsPerSecond: number;
|
|
37
|
-
}
|
|
38
|
-
/**
|
|
39
|
-
* Token bucket rate limiter. Returns a function that must be called
|
|
40
|
-
* before each request; it resolves when the request is allowed.
|
|
41
|
-
*/
|
|
42
|
-
declare function createRateLimiter(options: RateLimitOptions): () => Promise<void>;
|
|
43
|
-
/**
|
|
44
|
-
* Wraps a fetch-like function with token bucket rate limiting.
|
|
45
|
-
*/
|
|
46
|
-
declare function withRateLimit<T>(fn: () => Promise<T>, acquire: () => Promise<void>): Promise<T>;
|
|
47
|
-
|
|
48
|
-
interface RetryOptions {
|
|
49
|
-
maxRetries: number;
|
|
50
|
-
baseMs: number;
|
|
51
|
-
maxMs: number;
|
|
52
|
-
}
|
|
53
|
-
/**
|
|
54
|
-
* Wraps a fetch-like function with exponential backoff retry logic.
|
|
55
|
-
* Respects Retry-After headers on 429/503 responses.
|
|
56
|
-
*/
|
|
57
|
-
declare function withRetry<T>(fn: () => Promise<T>, options: RetryOptions): Promise<T>;
|
|
58
|
-
|
|
59
|
-
interface RecordedRequest {
|
|
60
|
-
method: string;
|
|
61
|
-
url: string;
|
|
62
|
-
headers: Record<string, string | string[] | undefined>;
|
|
63
|
-
body: string;
|
|
64
|
-
}
|
|
65
|
-
interface MockResponse {
|
|
66
|
-
status?: number;
|
|
67
|
-
headers?: Record<string, string>;
|
|
68
|
-
body?: unknown;
|
|
69
|
-
}
|
|
70
|
-
interface MockServer {
|
|
71
|
-
url: string;
|
|
72
|
-
requests: RecordedRequest[];
|
|
73
|
-
addRoute(method: string, path: string, response: MockResponse): void;
|
|
74
|
-
close(): Promise<void>;
|
|
75
|
-
}
|
|
76
|
-
/**
|
|
77
|
-
* Creates a lightweight HTTP mock server for integration testing.
|
|
78
|
-
* Records all incoming requests and returns configured fixture responses.
|
|
79
|
-
*/
|
|
80
|
-
declare function createMockServer(): Promise<MockServer>;
|
|
81
|
-
|
|
82
|
-
/**
|
|
83
|
-
* Converts wei (as bigint or string) to a human-readable ETH string.
|
|
84
|
-
*/
|
|
85
|
-
declare function weiToEth(wei: bigint | string, decimals?: number): string;
|
|
86
|
-
/**
|
|
87
|
-
* Checksums an Ethereum address using EIP-55.
|
|
88
|
-
* Accepts lowercase or mixed-case hex addresses.
|
|
89
|
-
*/
|
|
90
|
-
declare function checksumAddress(address: string): string;
|
|
91
|
-
/**
|
|
92
|
-
* Formats a Unix timestamp (seconds) to a human-readable string.
|
|
93
|
-
*/
|
|
94
|
-
declare function formatTimestamp(unixSeconds: number): string;
|
|
95
|
-
/**
|
|
96
|
-
* Truncates a string in the middle, e.g. "0x1234...abcd".
|
|
97
|
-
*/
|
|
98
|
-
declare function truncate(str: string, prefixLen?: number, suffixLen?: number): string;
|
|
99
|
-
|
|
100
|
-
interface HttpClientOptions {
|
|
101
|
-
baseUrl: string;
|
|
102
|
-
defaultHeaders?: Record<string, string>;
|
|
103
|
-
}
|
|
104
|
-
interface RequestOptions {
|
|
105
|
-
method?: string;
|
|
106
|
-
headers?: Record<string, string>;
|
|
107
|
-
query?: Record<string, string | number | boolean | undefined | null>;
|
|
108
|
-
body?: unknown;
|
|
109
|
-
}
|
|
110
|
-
declare class HttpError extends Error {
|
|
111
|
-
readonly status: number;
|
|
112
|
-
readonly statusText: string;
|
|
113
|
-
readonly body: string;
|
|
114
|
-
readonly headers: Headers;
|
|
115
|
-
constructor(status: number, statusText: string, body: string, headers?: Headers);
|
|
116
|
-
}
|
|
117
|
-
/**
|
|
118
|
-
* Typed fetch wrapper with base URL, default headers, query serialization, and error handling.
|
|
119
|
-
*/
|
|
120
|
-
declare function createHttpClient(options: HttpClientOptions): {
|
|
121
|
-
request: <T>(path: string, opts?: RequestOptions) => Promise<T>;
|
|
122
|
-
};
|
|
123
|
-
|
|
124
|
-
export { type ApiKeyAuthContext, type CursorPaginationOptions, type HttpClientOptions, HttpError, MissingApiKeyError, type MockResponse, type MockServer, type OffsetPaginationOptions, type RateLimitOptions, type RecordedRequest, type RequestOptions, type RetryOptions, apiKeyAuth, checksumAddress, createHttpClient, createMockServer, createRateLimiter, formatTimestamp, paginateCursor, paginateOffset, truncate, weiToEth, withRateLimit, withRetry };
|
|
1
|
+
export { ApiKeyAuthContext, CursorPaginationOptions, MissingApiKeyError, OffsetPaginationOptions, RateLimitOptions, RetryOptions, apiKeyAuth, createRateLimiter, paginateCursor, paginateOffset, withRateLimit, withRetry } from './middleware/index.js';
|
|
2
|
+
export { MockResponse, MockServer, RecordedRequest, createMockServer } from './testing/index.js';
|
|
3
|
+
export { HttpClientOptions, HttpError, RequestOptions, checksumAddress, createHttpClient, formatTimestamp, isAddress, truncate, weiToEth } from './utils/index.js';
|
package/dist/index.js
CHANGED
|
@@ -1,244 +1,26 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
if (!nextCursor) break;
|
|
25
|
-
cursor = nextCursor;
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
async function* paginateOffset(options) {
|
|
29
|
-
const limit = options.limit ?? 100;
|
|
30
|
-
let offset = 0;
|
|
31
|
-
while (true) {
|
|
32
|
-
const { items, total } = await options.fetchPage(offset, limit);
|
|
33
|
-
for (const item of items) {
|
|
34
|
-
yield item;
|
|
35
|
-
}
|
|
36
|
-
offset += items.length;
|
|
37
|
-
if (offset >= total || items.length === 0) break;
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
// src/middleware/rate-limit.ts
|
|
42
|
-
function createRateLimiter(options) {
|
|
43
|
-
const { requestsPerSecond } = options;
|
|
44
|
-
const intervalMs = 1e3 / requestsPerSecond;
|
|
45
|
-
const queue = [];
|
|
46
|
-
let tokens = requestsPerSecond;
|
|
47
|
-
let lastRefill = Date.now();
|
|
48
|
-
let processingInterval = null;
|
|
49
|
-
function refill() {
|
|
50
|
-
const now = Date.now();
|
|
51
|
-
const elapsed = now - lastRefill;
|
|
52
|
-
const newTokens = elapsed / 1e3 * requestsPerSecond;
|
|
53
|
-
tokens = Math.min(requestsPerSecond, tokens + newTokens);
|
|
54
|
-
lastRefill = now;
|
|
55
|
-
}
|
|
56
|
-
function processQueue() {
|
|
57
|
-
refill();
|
|
58
|
-
while (queue.length > 0 && tokens >= 1) {
|
|
59
|
-
tokens -= 1;
|
|
60
|
-
const resolve = queue.shift();
|
|
61
|
-
resolve?.();
|
|
62
|
-
}
|
|
63
|
-
if (queue.length === 0 && processingInterval !== null) {
|
|
64
|
-
clearInterval(processingInterval);
|
|
65
|
-
processingInterval = null;
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
return () => {
|
|
69
|
-
return new Promise((resolve) => {
|
|
70
|
-
refill();
|
|
71
|
-
if (tokens >= 1) {
|
|
72
|
-
tokens -= 1;
|
|
73
|
-
resolve();
|
|
74
|
-
return;
|
|
75
|
-
}
|
|
76
|
-
queue.push(resolve);
|
|
77
|
-
if (processingInterval === null) {
|
|
78
|
-
processingInterval = setInterval(processQueue, intervalMs);
|
|
79
|
-
}
|
|
80
|
-
});
|
|
81
|
-
};
|
|
82
|
-
}
|
|
83
|
-
function withRateLimit(fn, acquire) {
|
|
84
|
-
return acquire().then(() => fn());
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
// src/utils/http.ts
|
|
88
|
-
var HttpError = class extends Error {
|
|
89
|
-
constructor(status, statusText, body, headers = new Headers()) {
|
|
90
|
-
super(`HTTP ${status} ${statusText}: ${body}`);
|
|
91
|
-
this.status = status;
|
|
92
|
-
this.statusText = statusText;
|
|
93
|
-
this.body = body;
|
|
94
|
-
this.headers = headers;
|
|
95
|
-
this.name = "HttpError";
|
|
96
|
-
}
|
|
97
|
-
};
|
|
98
|
-
function serializeQuery(params) {
|
|
99
|
-
const parts = [];
|
|
100
|
-
for (const [key, value] of Object.entries(params)) {
|
|
101
|
-
if (value === void 0 || value === null) continue;
|
|
102
|
-
parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`);
|
|
103
|
-
}
|
|
104
|
-
return parts.length > 0 ? `?${parts.join("&")}` : "";
|
|
105
|
-
}
|
|
106
|
-
function createHttpClient(options) {
|
|
107
|
-
const { baseUrl, defaultHeaders = {} } = options;
|
|
108
|
-
async function request(path, opts = {}) {
|
|
109
|
-
const { method = "GET", headers = {}, query = {}, body } = opts;
|
|
110
|
-
const qs = serializeQuery(query);
|
|
111
|
-
const url = `${baseUrl}${path}${qs}`;
|
|
112
|
-
const init = {
|
|
113
|
-
method
|
|
114
|
-
};
|
|
115
|
-
const mergedHeaders = {
|
|
116
|
-
...defaultHeaders,
|
|
117
|
-
...headers
|
|
118
|
-
};
|
|
119
|
-
if (body !== void 0) {
|
|
120
|
-
mergedHeaders["Content-Type"] ??= "application/json";
|
|
121
|
-
init.body = JSON.stringify(body);
|
|
122
|
-
}
|
|
123
|
-
init.headers = mergedHeaders;
|
|
124
|
-
const res = await fetch(url, init);
|
|
125
|
-
if (!res.ok) {
|
|
126
|
-
const text = await res.text();
|
|
127
|
-
throw new HttpError(res.status, res.statusText, text, res.headers);
|
|
128
|
-
}
|
|
129
|
-
const contentType = res.headers.get("content-type") ?? "";
|
|
130
|
-
if (contentType.includes("application/json")) {
|
|
131
|
-
return res.json();
|
|
132
|
-
}
|
|
133
|
-
return res.text();
|
|
134
|
-
}
|
|
135
|
-
return { request };
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
// src/middleware/retry.ts
|
|
139
|
-
function sleep(ms) {
|
|
140
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
141
|
-
}
|
|
142
|
-
function jitter(ms) {
|
|
143
|
-
return ms * (0.5 + Math.random() * 0.5);
|
|
144
|
-
}
|
|
145
|
-
function parseRetryAfter(headers) {
|
|
146
|
-
const retryAfter = headers.get("Retry-After");
|
|
147
|
-
if (!retryAfter) return null;
|
|
148
|
-
const seconds = Number(retryAfter);
|
|
149
|
-
if (!Number.isNaN(seconds)) return seconds * 1e3;
|
|
150
|
-
const date = Date.parse(retryAfter);
|
|
151
|
-
if (!Number.isNaN(date)) return Math.max(0, date - Date.now());
|
|
152
|
-
return null;
|
|
153
|
-
}
|
|
154
|
-
async function withRetry(fn, options) {
|
|
155
|
-
const { maxRetries, baseMs, maxMs } = options;
|
|
156
|
-
let attempt = 0;
|
|
157
|
-
while (true) {
|
|
158
|
-
try {
|
|
159
|
-
return await fn();
|
|
160
|
-
} catch (err) {
|
|
161
|
-
if (attempt >= maxRetries) throw err;
|
|
162
|
-
let delayMs;
|
|
163
|
-
if (err instanceof HttpError && (err.status === 429 || err.status === 503)) {
|
|
164
|
-
const retryAfterMs = parseRetryAfter(err.headers);
|
|
165
|
-
delayMs = retryAfterMs ?? Math.min(baseMs * 2 ** attempt, maxMs);
|
|
166
|
-
} else {
|
|
167
|
-
delayMs = Math.min(baseMs * 2 ** attempt, maxMs);
|
|
168
|
-
}
|
|
169
|
-
await sleep(jitter(delayMs));
|
|
170
|
-
attempt++;
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
// src/testing/mock-server.ts
|
|
176
|
-
import { createServer } from "http";
|
|
177
|
-
async function createMockServer() {
|
|
178
|
-
const routes = /* @__PURE__ */ new Map();
|
|
179
|
-
const requests = [];
|
|
180
|
-
const server = createServer((req, res) => {
|
|
181
|
-
let body = "";
|
|
182
|
-
req.on("data", (chunk) => {
|
|
183
|
-
body += chunk.toString();
|
|
184
|
-
});
|
|
185
|
-
req.on("end", () => {
|
|
186
|
-
const recorded = {
|
|
187
|
-
method: req.method ?? "GET",
|
|
188
|
-
url: req.url ?? "/",
|
|
189
|
-
headers: req.headers,
|
|
190
|
-
body
|
|
191
|
-
};
|
|
192
|
-
requests.push(recorded);
|
|
193
|
-
const key = `${recorded.method}:${recorded.url.split("?")[0]}`;
|
|
194
|
-
const mock = routes.get(key) ?? routes.get("*");
|
|
195
|
-
const status = mock?.status ?? 200;
|
|
196
|
-
const responseHeaders = mock?.headers ?? { "Content-Type": "application/json" };
|
|
197
|
-
const responseBody = mock?.body !== void 0 ? JSON.stringify(mock.body) : "{}";
|
|
198
|
-
res.writeHead(status, responseHeaders);
|
|
199
|
-
res.end(responseBody);
|
|
200
|
-
});
|
|
201
|
-
});
|
|
202
|
-
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
203
|
-
const address = server.address();
|
|
204
|
-
if (!address || typeof address === "string") {
|
|
205
|
-
throw new Error("Failed to get server address");
|
|
206
|
-
}
|
|
207
|
-
return {
|
|
208
|
-
url: `http://127.0.0.1:${address.port}`,
|
|
209
|
-
requests,
|
|
210
|
-
addRoute(method, path, response) {
|
|
211
|
-
routes.set(`${method.toUpperCase()}:${path}`, response);
|
|
212
|
-
},
|
|
213
|
-
close() {
|
|
214
|
-
return new Promise((resolve, reject) => {
|
|
215
|
-
server.close((err) => err ? reject(err) : resolve());
|
|
216
|
-
});
|
|
217
|
-
}
|
|
218
|
-
};
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
// src/utils/format.ts
|
|
222
|
-
import { Address } from "ox";
|
|
223
|
-
var ETH_DECIMALS = 18n;
|
|
224
|
-
var WEI_PER_ETH = 10n ** ETH_DECIMALS;
|
|
225
|
-
function weiToEth(wei, decimals = 6) {
|
|
226
|
-
const weiValue = typeof wei === "string" ? BigInt(wei) : wei;
|
|
227
|
-
const whole = weiValue / WEI_PER_ETH;
|
|
228
|
-
const frac = weiValue % WEI_PER_ETH;
|
|
229
|
-
const fracStr = frac.toString().padStart(18, "0").slice(0, decimals).replace(/0+$/, "");
|
|
230
|
-
return fracStr.length > 0 ? `${whole}.${fracStr}` : `${whole}`;
|
|
231
|
-
}
|
|
232
|
-
function checksumAddress(address) {
|
|
233
|
-
return Address.checksum(address);
|
|
234
|
-
}
|
|
235
|
-
function formatTimestamp(unixSeconds) {
|
|
236
|
-
return new Date(unixSeconds * 1e3).toISOString();
|
|
237
|
-
}
|
|
238
|
-
function truncate(str, prefixLen = 6, suffixLen = 4) {
|
|
239
|
-
if (str.length <= prefixLen + suffixLen + 3) return str;
|
|
240
|
-
return `${str.slice(0, prefixLen)}...${str.slice(-suffixLen)}`;
|
|
241
|
-
}
|
|
1
|
+
import {
|
|
2
|
+
MissingApiKeyError,
|
|
3
|
+
apiKeyAuth,
|
|
4
|
+
createRateLimiter,
|
|
5
|
+
paginateCursor,
|
|
6
|
+
paginateOffset,
|
|
7
|
+
withRateLimit,
|
|
8
|
+
withRetry
|
|
9
|
+
} from "./chunk-74BQLM22.js";
|
|
10
|
+
import {
|
|
11
|
+
createMockServer
|
|
12
|
+
} from "./chunk-QEANTXUE.js";
|
|
13
|
+
import {
|
|
14
|
+
checksumAddress,
|
|
15
|
+
formatTimestamp,
|
|
16
|
+
isAddress,
|
|
17
|
+
truncate,
|
|
18
|
+
weiToEth
|
|
19
|
+
} from "./chunk-OPDNZWSI.js";
|
|
20
|
+
import {
|
|
21
|
+
HttpError,
|
|
22
|
+
createHttpClient
|
|
23
|
+
} from "./chunk-TKORXDDX.js";
|
|
242
24
|
export {
|
|
243
25
|
HttpError,
|
|
244
26
|
MissingApiKeyError,
|
|
@@ -248,6 +30,7 @@ export {
|
|
|
248
30
|
createMockServer,
|
|
249
31
|
createRateLimiter,
|
|
250
32
|
formatTimestamp,
|
|
33
|
+
isAddress,
|
|
251
34
|
paginateCursor,
|
|
252
35
|
paginateOffset,
|
|
253
36
|
truncate,
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
declare class MissingApiKeyError extends Error {
|
|
2
|
+
constructor(envVar: string);
|
|
3
|
+
}
|
|
4
|
+
interface ApiKeyAuthContext {
|
|
5
|
+
apiKey: string;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Reads an API key from the environment and returns it.
|
|
9
|
+
* Throws MissingApiKeyError if the variable is not set.
|
|
10
|
+
*/
|
|
11
|
+
declare function apiKeyAuth(envVar: string): ApiKeyAuthContext;
|
|
12
|
+
|
|
13
|
+
interface CursorPaginationOptions<T> {
|
|
14
|
+
fetchPage: (cursor: string | null) => Promise<{
|
|
15
|
+
items: T[];
|
|
16
|
+
nextCursor: string | null;
|
|
17
|
+
}>;
|
|
18
|
+
}
|
|
19
|
+
interface OffsetPaginationOptions<T> {
|
|
20
|
+
fetchPage: (offset: number, limit: number) => Promise<{
|
|
21
|
+
items: T[];
|
|
22
|
+
total: number;
|
|
23
|
+
}>;
|
|
24
|
+
limit?: number;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Async iterator for cursor-based pagination.
|
|
28
|
+
*/
|
|
29
|
+
declare function paginateCursor<T>(options: CursorPaginationOptions<T>): AsyncGenerator<T>;
|
|
30
|
+
/**
|
|
31
|
+
* Async iterator for offset-based pagination.
|
|
32
|
+
*/
|
|
33
|
+
declare function paginateOffset<T>(options: OffsetPaginationOptions<T>): AsyncGenerator<T>;
|
|
34
|
+
|
|
35
|
+
interface RateLimitOptions {
|
|
36
|
+
requestsPerSecond: number;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Token bucket rate limiter. Returns a function that must be called
|
|
40
|
+
* before each request; it resolves when the request is allowed.
|
|
41
|
+
*/
|
|
42
|
+
declare function createRateLimiter(options: RateLimitOptions): () => Promise<void>;
|
|
43
|
+
/**
|
|
44
|
+
* Wraps a fetch-like function with token bucket rate limiting.
|
|
45
|
+
*/
|
|
46
|
+
declare function withRateLimit<T>(fn: () => Promise<T>, acquire: () => Promise<void>): Promise<T>;
|
|
47
|
+
|
|
48
|
+
interface RetryOptions {
|
|
49
|
+
maxRetries: number;
|
|
50
|
+
baseMs: number;
|
|
51
|
+
maxMs: number;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Wraps a fetch-like function with exponential backoff retry logic.
|
|
55
|
+
* Respects Retry-After headers on 429/503 responses.
|
|
56
|
+
*/
|
|
57
|
+
declare function withRetry<T>(fn: () => Promise<T>, options: RetryOptions): Promise<T>;
|
|
58
|
+
|
|
59
|
+
export { type ApiKeyAuthContext, type CursorPaginationOptions, MissingApiKeyError, type OffsetPaginationOptions, type RateLimitOptions, type RetryOptions, apiKeyAuth, createRateLimiter, paginateCursor, paginateOffset, withRateLimit, withRetry };
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import {
|
|
2
|
+
MissingApiKeyError,
|
|
3
|
+
apiKeyAuth,
|
|
4
|
+
createRateLimiter,
|
|
5
|
+
paginateCursor,
|
|
6
|
+
paginateOffset,
|
|
7
|
+
withRateLimit,
|
|
8
|
+
withRetry
|
|
9
|
+
} from "../chunk-74BQLM22.js";
|
|
10
|
+
import "../chunk-TKORXDDX.js";
|
|
11
|
+
export {
|
|
12
|
+
MissingApiKeyError,
|
|
13
|
+
apiKeyAuth,
|
|
14
|
+
createRateLimiter,
|
|
15
|
+
paginateCursor,
|
|
16
|
+
paginateOffset,
|
|
17
|
+
withRateLimit,
|
|
18
|
+
withRetry
|
|
19
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
interface RecordedRequest {
|
|
2
|
+
method: string;
|
|
3
|
+
url: string;
|
|
4
|
+
headers: Record<string, string | string[] | undefined>;
|
|
5
|
+
body: string;
|
|
6
|
+
}
|
|
7
|
+
interface MockResponse {
|
|
8
|
+
status?: number;
|
|
9
|
+
headers?: Record<string, string>;
|
|
10
|
+
body?: unknown;
|
|
11
|
+
}
|
|
12
|
+
interface MockServer {
|
|
13
|
+
url: string;
|
|
14
|
+
requests: RecordedRequest[];
|
|
15
|
+
addRoute(method: string, path: string, response: MockResponse): void;
|
|
16
|
+
close(): Promise<void>;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Creates a lightweight HTTP mock server for integration testing.
|
|
20
|
+
* Records all incoming requests and returns configured fixture responses.
|
|
21
|
+
*/
|
|
22
|
+
declare function createMockServer(): Promise<MockServer>;
|
|
23
|
+
|
|
24
|
+
export { type MockResponse, type MockServer, type RecordedRequest, createMockServer };
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Converts wei (as bigint or string) to a human-readable ETH string.
|
|
3
|
+
*/
|
|
4
|
+
declare function weiToEth(wei: bigint | string, decimals?: number): string;
|
|
5
|
+
/**
|
|
6
|
+
* Returns true if the value is a valid 0x-prefixed 20-byte hex address.
|
|
7
|
+
*/
|
|
8
|
+
declare function isAddress(address: string): boolean;
|
|
9
|
+
/**
|
|
10
|
+
* Checksums an Ethereum address using EIP-55.
|
|
11
|
+
* Accepts lowercase or mixed-case hex addresses.
|
|
12
|
+
*/
|
|
13
|
+
declare function checksumAddress(address: string): string;
|
|
14
|
+
/**
|
|
15
|
+
* Formats a Unix timestamp (seconds) to a human-readable string.
|
|
16
|
+
*/
|
|
17
|
+
declare function formatTimestamp(unixSeconds: number): string;
|
|
18
|
+
/**
|
|
19
|
+
* Truncates a string in the middle, e.g. "0x1234...abcd".
|
|
20
|
+
*/
|
|
21
|
+
declare function truncate(str: string, prefixLen?: number, suffixLen?: number): string;
|
|
22
|
+
|
|
23
|
+
interface HttpClientOptions {
|
|
24
|
+
baseUrl: string;
|
|
25
|
+
defaultHeaders?: Record<string, string>;
|
|
26
|
+
}
|
|
27
|
+
interface RequestOptions {
|
|
28
|
+
method?: string;
|
|
29
|
+
headers?: Record<string, string>;
|
|
30
|
+
query?: Record<string, string | number | boolean | undefined | null>;
|
|
31
|
+
body?: unknown;
|
|
32
|
+
}
|
|
33
|
+
declare class HttpError extends Error {
|
|
34
|
+
readonly status: number;
|
|
35
|
+
readonly statusText: string;
|
|
36
|
+
readonly body: string;
|
|
37
|
+
readonly headers: Headers;
|
|
38
|
+
constructor(status: number, statusText: string, body: string, headers?: Headers);
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Typed fetch wrapper with base URL, default headers, query serialization, and error handling.
|
|
42
|
+
*/
|
|
43
|
+
declare function createHttpClient(options: HttpClientOptions): {
|
|
44
|
+
request: <T>(path: string, opts?: RequestOptions) => Promise<T>;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export { type HttpClientOptions, HttpError, type RequestOptions, checksumAddress, createHttpClient, formatTimestamp, isAddress, truncate, weiToEth };
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import {
|
|
2
|
+
checksumAddress,
|
|
3
|
+
formatTimestamp,
|
|
4
|
+
isAddress,
|
|
5
|
+
truncate,
|
|
6
|
+
weiToEth
|
|
7
|
+
} from "../chunk-OPDNZWSI.js";
|
|
8
|
+
import {
|
|
9
|
+
HttpError,
|
|
10
|
+
createHttpClient
|
|
11
|
+
} from "../chunk-TKORXDDX.js";
|
|
12
|
+
export {
|
|
13
|
+
HttpError,
|
|
14
|
+
checksumAddress,
|
|
15
|
+
createHttpClient,
|
|
16
|
+
formatTimestamp,
|
|
17
|
+
isAddress,
|
|
18
|
+
truncate,
|
|
19
|
+
weiToEth
|
|
20
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@spectratools/cli-shared",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Shared middleware, utilities, and testing helpers for spectra CLI tools",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -10,20 +10,20 @@
|
|
|
10
10
|
},
|
|
11
11
|
"exports": {
|
|
12
12
|
".": {
|
|
13
|
-
"types": "./
|
|
14
|
-
"default": "./
|
|
13
|
+
"types": "./dist/index.d.ts",
|
|
14
|
+
"default": "./dist/index.js"
|
|
15
15
|
},
|
|
16
16
|
"./middleware": {
|
|
17
|
-
"types": "./
|
|
18
|
-
"default": "./
|
|
17
|
+
"types": "./dist/middleware/index.d.ts",
|
|
18
|
+
"default": "./dist/middleware/index.js"
|
|
19
19
|
},
|
|
20
20
|
"./utils": {
|
|
21
|
-
"types": "./
|
|
22
|
-
"default": "./
|
|
21
|
+
"types": "./dist/utils/index.d.ts",
|
|
22
|
+
"default": "./dist/utils/index.js"
|
|
23
23
|
},
|
|
24
24
|
"./testing": {
|
|
25
|
-
"types": "./
|
|
26
|
-
"default": "./
|
|
25
|
+
"types": "./dist/testing/index.d.ts",
|
|
26
|
+
"default": "./dist/testing/index.js"
|
|
27
27
|
}
|
|
28
28
|
},
|
|
29
29
|
"devDependencies": {
|