1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207 | import { apiError, configError, timeoutError, transportError } from './errors.js';
/** Percent-encode one URL component, using %20 (never +) for spaces. */
export const encode = (value) => encodeURIComponent(value).replace(/[!'()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`);
/** Build a query string with its leading `?`, or '' when nothing is set. */
export const queryString = (query) => {
if (!query)
return '';
const pairs = Object.entries(query)
.filter(([, value]) => value !== undefined && value !== null)
.map(([key, value]) => `${encode(key)}=${encode(String(value))}`);
return pairs.length === 0 ? '' : `?${pairs.join('&')}`;
};
const fillPath = (template, params) => template.replace(/\{([^}]+)\}/g, (_, name) => {
const value = params?.[name];
if (value === undefined || value === null) {
throw configError(`Missing path parameter {${name}} for ${template}`, { template, params });
}
return encode(String(value));
});
const baseHeaders = (token, jsonBody) => ({
Accept: 'application/json',
...(token?.trim() ? { Authorization: `Bearer ${token}` } : {}),
...(jsonBody ? { 'Content-Type': 'application/json' } : {}),
});
/** Build the request for an API call without sending it. */
export const buildRequest = (client, method, path, options = {}) => {
const hasBody = options.body !== undefined;
return {
method: method.toUpperCase(),
url: `${client.apiUrl}${fillPath(path, options.params)}${queryString(options.query)}`,
headers: { ...baseHeaders(client.apiKey, hasBody), ...client.headers },
...(hasBody ? { body: JSON.stringify(options.body) } : {}),
};
};
const envelope = (parsed) => typeof parsed === 'object' && parsed !== null ? parsed.error : undefined;
/** The ird API envelope is {error:{code,message}}; the node-hosted mailbox uses a bare {error:"code"}. */
const errorMessage = (parsed, text) => {
const error = envelope(parsed);
if (typeof error === 'string')
return error;
return error?.message ?? text;
};
const errorCode = (parsed) => {
const error = envelope(parsed);
if (typeof error === 'string')
return error;
return error?.code ?? null;
};
/**
* Turn a raw status + body into the parsed JSON value, throwing an api error
* on non-2xx statuses and a transport error on unparseable success bodies.
* An empty 2xx body decodes to `{}`.
*/
export const decodeResponse = (status, body) => {
const ok = status >= 200 && status <= 299;
const text = body.trim();
if (text === '') {
if (ok)
return {};
throw apiError('Empty error response', null, status, null);
}
let parsed;
let parsable = true;
try {
parsed = JSON.parse(text);
}
catch (e) {
if (ok)
throw transportError('Failed to parse JSON response', text, e);
parsable = false;
}
if (!ok) {
const response = parsable && typeof parsed === 'object' ? parsed : null;
throw apiError(errorMessage(response, text), errorCode(response), status, response);
}
return parsed;
};
const describe = (e) => (e instanceof Error ? e.message : String(e));
const isStream = (body) => typeof ReadableStream !== 'undefined' && body instanceof ReadableStream;
const send = async (fetchFn, req, timeoutMs) => {
const init = {
method: req.method,
headers: req.headers,
...(req.body === undefined ? {} : { body: req.body }),
...(timeoutMs ? { signal: AbortSignal.timeout(timeoutMs) } : {}),
...(isStream(req.body) ? { duplex: 'half' } : {}),
};
let response;
let text;
try {
response = await fetchFn(req.url, init);
text = await response.text();
}
catch (e) {
throw transportError(`Request failed: ${describe(e)}`, { url: req.url }, e);
}
return decodeResponse(response.status, text);
};
/**
* Execute an API request. METHOD and PATH are spec keys, so the options and
* the result are typed from the OpenAPI document:
*
* await request(client, 'get', '/domains/check', { query: { domain: 'x.ro' } })
*
* Throws an api error on non-2xx responses and a transport error on
* connection/parse failures.
*/
export const request = (client, method, path, options) => send(client.fetch, buildRequest(client, method, path, options), client.timeoutMs);
export const get = (client, path, options) => request(client, 'get', path, options);
export const post = (client, path, options) => request(client, 'post', path, options);
export const put = (client, path, options) => request(client, 'put', path, options);
export const patch = (client, path, options) => request(client, 'patch', path, options);
export const del = (client, path, options) => request(client, 'delete', path, options);
/**
* Execute a request against an absolute URL rather than the API base — for
* the node-hosted endpoints the API hands out (an IPFS upload grant, a p2p
* app's mailbox). Throws like `request`.
*/
export const requestUrl = (method, url, options = {}) => send(options.fetch ?? globalThis.fetch, {
method,
url: `${url}${queryString(options.query)}`,
headers: { ...baseHeaders(options.token, false), ...options.headers },
...(options.body === undefined ? {} : { body: options.body }),
}, options.timeoutMs);
/**
* Follow cursor pagination for a list endpoint, collecting every item under
* KEY (e.g. 'registrations'). `cursor` is advanced from each response's
* `next_cursor` until it is absent.
*/
export const paginate = async (client, path, key, options = {}) => {
const items = [];
let cursor;
do {
const query = { ...options.query, ...(cursor ? { cursor } : {}) };
const page = (await get(client, path, { params: options.params, query }));
items.push(...(page[key] ?? []));
cursor = page.next_cursor ?? undefined;
} while (cursor);
return items;
};
/**
* Follow offset pagination for a list endpoint, collecting every item under
* KEY (e.g. 'pins'). The page `limit` defaults to 100; `offset` advances by
* the rows each page actually returns and paging stops on the first short
* or empty page. The response's `total` is deliberately ignored — it can
* move while the pages are walked.
*/
export const paginateOffset = async (client, path, key, options = {}) => {
const base = (options.query ?? {});
const limit = typeof base.limit === 'number' ? base.limit : 100;
const items = [];
let offset = typeof base.offset === 'number' ? base.offset : 0;
for (;;) {
const query = { ...base, limit, offset };
const page = (await get(client, path, { params: options.params, query }));
const rows = page[key] ?? [];
items.push(...rows);
if (rows.length < limit)
return items;
offset += rows.length;
}
};
const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
/**
* Poll STEP until DONE holds for its result, sleeping between attempts with
* exponential backoff (the interval doubles up to `maxIntervalMs`). Returns
* the first result that satisfies DONE; throws a timeout error (carrying the
* last result) when the deadline passes first.
*/
export const pollUntil = async (step, done, options = {}) => {
const { intervalMs = 1000, maxIntervalMs = 8000, timeoutMs = 120000, sleep = defaultSleep } = options;
const deadline = Date.now() + timeoutMs;
let interval = intervalMs;
for (;;) {
const result = await step();
if (done(result))
return result;
if (Date.now() >= deadline)
throw timeoutError(options.message ?? 'Timed out polling', result);
await sleep(interval);
interval = Math.min(maxIntervalMs, interval * 2);
}
};
const isUploadSource = (source) => source instanceof Uint8Array ||
source instanceof ArrayBuffer ||
(typeof Blob !== 'undefined' && source instanceof Blob) ||
(typeof ReadableStream !== 'undefined' && source instanceof ReadableStream);
/**
* POST raw bytes to an IPFS upload-grant URL (from `ipfs.startUpload`). The
* token is single-use and may be null once consumed. `format: 'car'`
* appends ?format=car for a client-packed CAR archive.
*/
export const uploadBytes = (url, token, source, options = {}) => {
if (!isUploadSource(source)) {
throw transportError('Unsupported upload source; expected bytes, an ArrayBuffer, a Blob, or a ReadableStream', {
source: typeof source,
});
}
return requestUrl('POST', url, {
token,
query: options.format === 'car' ? { format: 'car' } : undefined,
headers: { 'Content-Type': 'application/octet-stream' },
body: source,
fetch: options.fetch,
timeoutMs: options.timeoutMs,
});
};
|