@feralfile/cli 2.10.0 → 3.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 +16 -11
- package/config.json.example +5 -0
- package/dist/package.json +2 -2
- package/dist/src/commands/build.js +40 -0
- package/dist/src/commands/config.js +1 -0
- package/dist/src/commands/device.js +1 -1
- package/dist/src/commands/enrich.js +16 -21
- package/dist/src/commands/find.js +94 -81
- package/dist/src/commands/helpers/prompt.js +85 -10
- package/dist/src/commands/play.js +138 -73
- package/dist/src/commands/setup.js +81 -6
- package/dist/src/commands/status.js +4 -1
- package/dist/src/config.js +94 -0
- package/dist/src/main.js +14 -5
- package/dist/src/utilities/device-lookup.js +36 -11
- package/dist/src/utilities/feral-file-artwork.js +2 -2
- package/dist/src/utilities/index.js +270 -259
- package/dist/src/utilities/media-url.js +117 -0
- package/dist/src/utilities/nft-indexer.js +12 -376
- package/dist/src/utilities/playlist-builder.js +10 -1
- package/dist/src/utilities/playlist-source.js +222 -33
- package/dist/src/utilities/raster-client.js +100 -13
- package/dist/src/utilities/token-item.js +428 -0
- package/dist/src/utilities/token-source-contract.js +55 -0
- package/dist/src/utilities/token-source-indexer.js +99 -0
- package/dist/src/utilities/token-source-raster.js +378 -0
- package/dist/src/utilities/token-source.js +48 -0
- package/docs/CONFIGURATION.md +63 -2
- package/docs/EXAMPLES.md +5 -2
- package/docs/PROJECT_SPEC.md +23 -17
- package/docs/README.md +41 -16
- package/package.json +2 -2
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.PlaylistUrlLoadError = void 0;
|
|
3
4
|
exports.isPlaylistSourceUrl = isPlaylistSourceUrl;
|
|
4
5
|
exports.loadPlaylistSource = loadPlaylistSource;
|
|
6
|
+
exports.buildMediaPlaySource = buildMediaPlaySource;
|
|
7
|
+
exports.refusalForLoadFailure = refusalForLoadFailure;
|
|
8
|
+
exports.checkWebPageUrl = checkWebPageUrl;
|
|
5
9
|
exports.resolvePlaySource = resolvePlaySource;
|
|
6
10
|
const http_1 = require("./http");
|
|
7
11
|
const fs_1 = require("fs");
|
|
@@ -27,12 +31,87 @@ function isPlaylistSourceUrl(source) {
|
|
|
27
31
|
return false;
|
|
28
32
|
}
|
|
29
33
|
}
|
|
34
|
+
/**
|
|
35
|
+
* PlaylistUrlLoadError is a failure to load an http(s) URL as a playlist,
|
|
36
|
+
* tagged with how it failed so `resolvePlaySource` can tell a missing playlist
|
|
37
|
+
* from a URL that is simply not a playlist.
|
|
38
|
+
*
|
|
39
|
+
* - `status`: the server answered with a non-OK HTTP status (`status` is set).
|
|
40
|
+
* - `network`: no answer at all (DNS, refused connection, timeout); `code` is
|
|
41
|
+
* the underlying system error code when fetch exposes one.
|
|
42
|
+
* - `read`: an OK answer whose body was cut off (timeout or dropped connection
|
|
43
|
+
* mid-download). Nothing was learned about the URL, so it is not "not a
|
|
44
|
+
* playlist" either.
|
|
45
|
+
* - `json`: an OK answer, read in full, that is not JSON. This is what every
|
|
46
|
+
* ordinary web page and media file produces.
|
|
47
|
+
*/
|
|
48
|
+
class PlaylistUrlLoadError extends Error {
|
|
49
|
+
kind;
|
|
50
|
+
status;
|
|
51
|
+
code;
|
|
52
|
+
constructor(message, detail) {
|
|
53
|
+
super(message);
|
|
54
|
+
this.name = 'PlaylistUrlLoadError';
|
|
55
|
+
this.kind = detail.kind;
|
|
56
|
+
this.status = detail.status;
|
|
57
|
+
this.code = detail.code;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
exports.PlaylistUrlLoadError = PlaylistUrlLoadError;
|
|
61
|
+
/**
|
|
62
|
+
* networkErrorCode digs the system error code (ENOTFOUND, ECONNREFUSED, …)
|
|
63
|
+
* out of a fetch failure. Node's fetch throws `TypeError('fetch failed')` and
|
|
64
|
+
* puts the real error on `cause`.
|
|
65
|
+
*
|
|
66
|
+
* @param {unknown} error - What fetch threw
|
|
67
|
+
* @returns {string | undefined} The code, when there is one
|
|
68
|
+
*/
|
|
69
|
+
function networkErrorCode(error) {
|
|
70
|
+
const cause = error?.cause;
|
|
71
|
+
return typeof cause?.code === 'string' ? cause.code : undefined;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* loadPlaylistUrl fetches an http(s) URL and parses it as a playlist.
|
|
75
|
+
*
|
|
76
|
+
* @param {string} url - Trimmed http(s) URL
|
|
77
|
+
* @returns {Promise<LoadedPlaylist>} The parsed document
|
|
78
|
+
* @throws {PlaylistUrlLoadError} On any failure, tagged with its kind
|
|
79
|
+
*/
|
|
80
|
+
async function loadPlaylistUrl(url) {
|
|
81
|
+
let response;
|
|
82
|
+
try {
|
|
83
|
+
response = await (0, http_1.fetchWithTimeout)(url);
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
throw new PlaylistUrlLoadError(`Could not reach ${url}: ${error.message}`, {
|
|
87
|
+
kind: 'network',
|
|
88
|
+
code: networkErrorCode(error),
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
if (!response.ok) {
|
|
92
|
+
throw new PlaylistUrlLoadError(`Failed to fetch playlist URL: ${response.status} ${response.statusText}`, { kind: 'status', status: response.status });
|
|
93
|
+
}
|
|
94
|
+
let playlistText;
|
|
95
|
+
try {
|
|
96
|
+
playlistText = await response.text();
|
|
97
|
+
}
|
|
98
|
+
catch (error) {
|
|
99
|
+
throw new PlaylistUrlLoadError(`Failed to read playlist response from ${url}: ${error.message}`, { kind: 'read' });
|
|
100
|
+
}
|
|
101
|
+
try {
|
|
102
|
+
return { playlist: JSON.parse(playlistText), source: url, sourceType: 'url' };
|
|
103
|
+
}
|
|
104
|
+
catch (error) {
|
|
105
|
+
throw new PlaylistUrlLoadError(`Invalid JSON from playlist URL ${url}: ${error.message}`, { kind: 'json' });
|
|
106
|
+
}
|
|
107
|
+
}
|
|
30
108
|
/**
|
|
31
109
|
* Load a DP-1 playlist from a local file or hosted URL.
|
|
32
110
|
*
|
|
33
111
|
* @param {string} source - Playlist file path or URL
|
|
34
112
|
* @returns {Promise<LoadedPlaylist>} Loaded playlist payload with source metadata
|
|
35
|
-
* @throws {Error} When source is empty, cannot be loaded, or JSON is invalid
|
|
113
|
+
* @throws {Error} When source is empty, cannot be loaded, or JSON is invalid; a URL
|
|
114
|
+
* failure is a PlaylistUrlLoadError
|
|
36
115
|
*/
|
|
37
116
|
async function loadPlaylistSource(source) {
|
|
38
117
|
const trimmedSource = source.trim();
|
|
@@ -40,27 +119,7 @@ async function loadPlaylistSource(source) {
|
|
|
40
119
|
throw new Error('Playlist source is required');
|
|
41
120
|
}
|
|
42
121
|
if (isPlaylistSourceUrl(trimmedSource)) {
|
|
43
|
-
|
|
44
|
-
if (!response.ok) {
|
|
45
|
-
throw new Error(`Failed to fetch playlist URL: ${response.status} ${response.statusText}`);
|
|
46
|
-
}
|
|
47
|
-
let playlistText;
|
|
48
|
-
try {
|
|
49
|
-
playlistText = await response.text();
|
|
50
|
-
}
|
|
51
|
-
catch (error) {
|
|
52
|
-
throw new Error(`Failed to read playlist response from ${trimmedSource}: ${error.message}`);
|
|
53
|
-
}
|
|
54
|
-
try {
|
|
55
|
-
return {
|
|
56
|
-
playlist: JSON.parse(playlistText),
|
|
57
|
-
source: trimmedSource,
|
|
58
|
-
sourceType: 'url',
|
|
59
|
-
};
|
|
60
|
-
}
|
|
61
|
-
catch (error) {
|
|
62
|
-
throw new Error(`Invalid JSON from playlist URL ${trimmedSource}: ${error.message}`);
|
|
63
|
-
}
|
|
122
|
+
return loadPlaylistUrl(trimmedSource);
|
|
64
123
|
}
|
|
65
124
|
let fileText;
|
|
66
125
|
try {
|
|
@@ -80,25 +139,150 @@ async function loadPlaylistSource(source) {
|
|
|
80
139
|
throw new Error(`Invalid JSON in ${trimmedSource}: ${error.message}`);
|
|
81
140
|
}
|
|
82
141
|
}
|
|
142
|
+
/**
|
|
143
|
+
* buildMediaPlaySource wraps a URL in a synthesized single-item playlist, the
|
|
144
|
+
* shape `play` sends for a direct media file or web page.
|
|
145
|
+
*
|
|
146
|
+
* @param {string} url - Trimmed http(s) URL
|
|
147
|
+
* @param {Object} [options] - Item options
|
|
148
|
+
* @param {number} [options.duration] - Explicit display seconds; omit for auto timing
|
|
149
|
+
* @param {boolean} [options.asWebPage] - Time the item as a page whatever the URL looks like
|
|
150
|
+
* @param {string} [options.note] - Carried to the caller (see PlaySource.note)
|
|
151
|
+
* @returns {Promise<PlaySource>} A `media` play source
|
|
152
|
+
*/
|
|
153
|
+
async function buildMediaPlaySource(url, options = {}) {
|
|
154
|
+
const item = buildUrlItem(url, options.duration, { asWebPage: options.asWebPage });
|
|
155
|
+
const playlist = (await buildDP1Playlist({ items: [item], title: item.title }));
|
|
156
|
+
return options.note
|
|
157
|
+
? { kind: 'media', playlist, source: url, note: options.note }
|
|
158
|
+
: { kind: 'media', playlist, source: url };
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* looksLikePlaylistUrl reports whether a URL names a JSON document by its
|
|
162
|
+
* path. A `.json` URL that fails to load is a broken playlist link, never a
|
|
163
|
+
* page someone meant to show on a wall.
|
|
164
|
+
*
|
|
165
|
+
* @param {string} url - http(s) URL
|
|
166
|
+
* @returns {boolean} True when the path ends in `.json`
|
|
167
|
+
*/
|
|
168
|
+
function looksLikePlaylistUrl(url) {
|
|
169
|
+
try {
|
|
170
|
+
return new URL(url).pathname.toLowerCase().endsWith('.json');
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
return false;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* refusalForLoadFailure returns the error `play` must stop with when a URL it
|
|
178
|
+
* tried to load is known not to exist, or null when it may still be sent.
|
|
179
|
+
*
|
|
180
|
+
* Wrapping a URL that does not exist sends the device an error page and
|
|
181
|
+
* reports "Playing", the silent wrong result an agent trusting exit codes
|
|
182
|
+
* cannot catch. These are refused:
|
|
183
|
+
* - HTTP 404 or 410: nothing is there.
|
|
184
|
+
* - DNS failure (ENOTFOUND): the host does not exist. A temporary resolver
|
|
185
|
+
* failure (EAI_AGAIN) is not refused.
|
|
186
|
+
* - A `.json` URL that cannot be loaded or parsed: a broken playlist link.
|
|
187
|
+
* Real marketplace pages answer 200 to a command-line client (checked against
|
|
188
|
+
* OpenSea, Art Blocks, objkt, SuperRare, Feral File, Neort, Verse), so this
|
|
189
|
+
* rule is safe on discovery hosts too.
|
|
190
|
+
*
|
|
191
|
+
* @param {string} url - The URL that failed to load
|
|
192
|
+
* @param {unknown} failure - What loading threw
|
|
193
|
+
* @returns {Error | null} The refusal, or null
|
|
194
|
+
*/
|
|
195
|
+
function refusalForLoadFailure(url, failure) {
|
|
196
|
+
const loadFailure = failure;
|
|
197
|
+
if (loadFailure?.status === 404 || loadFailure?.status === 410) {
|
|
198
|
+
return new Error(`Nothing at ${url} (HTTP ${loadFailure.status}). Check the URL; nothing was sent to the device.`);
|
|
199
|
+
}
|
|
200
|
+
if (loadFailure?.code === 'ENOTFOUND') {
|
|
201
|
+
return new Error(`No such host for ${url}. Check the URL; nothing was sent to the device.`);
|
|
202
|
+
}
|
|
203
|
+
if (looksLikePlaylistUrl(url)) {
|
|
204
|
+
return new Error(`${url} looks like a playlist (.json) but could not be loaded: ${failure?.message}`);
|
|
205
|
+
}
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* noteForLoadFailure returns the line `play` prints when it sends a URL it
|
|
210
|
+
* could not read itself, or undefined when the URL was read in full and is
|
|
211
|
+
* simply not a playlist (an ordinary page or media file).
|
|
212
|
+
*
|
|
213
|
+
* @param {unknown} failure - What loading threw
|
|
214
|
+
* @returns {string | undefined} The note, if any
|
|
215
|
+
*/
|
|
216
|
+
function noteForLoadFailure(failure) {
|
|
217
|
+
const loadFailure = failure;
|
|
218
|
+
if (loadFailure?.kind === 'json') {
|
|
219
|
+
return undefined;
|
|
220
|
+
}
|
|
221
|
+
return `ff-cli could not read this URL itself (${failure?.message}); sending it to the device anyway.`;
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* checkWebPageUrl fetches a URL that `play --web` is about to send, refusing
|
|
225
|
+
* one that does not exist and returning a note when it could not be read.
|
|
226
|
+
*
|
|
227
|
+
* `--web` skips the playlist probe, so without this check a typo or a dead
|
|
228
|
+
* link handed to `--web` (the hint `play` prints for marketplace pages) would
|
|
229
|
+
* be cast as an error page with a successful exit. Only the status line is
|
|
230
|
+
* needed; the body is cancelled unread. The `.json` rule does not apply: the
|
|
231
|
+
* caller said this is a page.
|
|
232
|
+
*
|
|
233
|
+
* @param {string} url - Trimmed http(s) URL
|
|
234
|
+
* @returns {Promise<string | undefined>} A note when the URL could not be read
|
|
235
|
+
* @throws {Error} When the URL answers 404/410 or its host does not resolve
|
|
236
|
+
*/
|
|
237
|
+
async function checkWebPageUrl(url) {
|
|
238
|
+
let failure;
|
|
239
|
+
try {
|
|
240
|
+
const response = await (0, http_1.fetchWithTimeout)(url);
|
|
241
|
+
await response.body?.cancel().catch(() => undefined);
|
|
242
|
+
if (response.ok) {
|
|
243
|
+
return undefined;
|
|
244
|
+
}
|
|
245
|
+
failure = new PlaylistUrlLoadError(`HTTP ${response.status} ${response.statusText}`.trim(), {
|
|
246
|
+
kind: 'status',
|
|
247
|
+
status: response.status,
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
catch (error) {
|
|
251
|
+
failure = new PlaylistUrlLoadError(`Could not reach ${url}: ${error.message}`, {
|
|
252
|
+
kind: 'network',
|
|
253
|
+
code: networkErrorCode(error),
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
if (failure.status === 404 || failure.status === 410 || failure.code === 'ENOTFOUND') {
|
|
257
|
+
throw refusalForLoadFailure(url, failure);
|
|
258
|
+
}
|
|
259
|
+
return noteForLoadFailure(failure);
|
|
260
|
+
}
|
|
83
261
|
/**
|
|
84
262
|
* Resolve a `play` command argument to a playable source.
|
|
85
263
|
*
|
|
86
264
|
* Files are always treated as playlists. URLs are tried as playlists first;
|
|
87
|
-
*
|
|
88
|
-
* synthesized single-item playlist so direct media
|
|
265
|
+
* a URL whose body is not a playlist (an ordinary web page, a video, an
|
|
266
|
+
* image) is wrapped in a synthesized single-item playlist so direct media
|
|
267
|
+
* URLs and web pages still work.
|
|
89
268
|
*
|
|
90
269
|
* The "URL → playlist or media" path uses throw-and-fallback because there
|
|
91
270
|
* is no cheap way to distinguish a 200-OK media file from a malformed
|
|
92
|
-
* playlist response without trying to parse it.
|
|
93
|
-
*
|
|
94
|
-
*
|
|
271
|
+
* playlist response without trying to parse it.
|
|
272
|
+
*
|
|
273
|
+
* Not every failure falls back: a URL that does not exist is refused (see
|
|
274
|
+
* refusalForLoadFailure). Other failures (403 from bot protection, 5xx,
|
|
275
|
+
* timeouts, a body cut off mid-download) still fall back, with a `note`,
|
|
276
|
+
* because the device fetches media itself and often succeeds where a
|
|
277
|
+
* command-line client is refused.
|
|
95
278
|
*
|
|
96
279
|
* @param source - User-supplied path or URL
|
|
97
280
|
* @param defaultDuration - Explicit duration (seconds) for the synthesized
|
|
98
281
|
* media item; omit for auto timing (video/audio URLs play their natural
|
|
99
282
|
* length per DP-1 §4.1, static media uses the configured default)
|
|
100
283
|
* @returns Resolved playlist + metadata describing how it was loaded
|
|
101
|
-
* @throws Error When `source` is empty,
|
|
284
|
+
* @throws Error When `source` is empty, is a non-URL file path that cannot be loaded,
|
|
285
|
+
* or is a URL that does not exist (see above)
|
|
102
286
|
*/
|
|
103
287
|
async function resolvePlaySource(source, defaultDuration) {
|
|
104
288
|
const trimmed = source.trim();
|
|
@@ -115,7 +299,7 @@ async function resolvePlaySource(source, defaultDuration) {
|
|
|
115
299
|
};
|
|
116
300
|
}
|
|
117
301
|
try {
|
|
118
|
-
const loaded = await
|
|
302
|
+
const loaded = await loadPlaylistUrl(trimmed);
|
|
119
303
|
return {
|
|
120
304
|
kind: 'playlist',
|
|
121
305
|
playlist: loaded.playlist,
|
|
@@ -123,9 +307,14 @@ async function resolvePlaySource(source, defaultDuration) {
|
|
|
123
307
|
source: loaded.source,
|
|
124
308
|
};
|
|
125
309
|
}
|
|
126
|
-
catch {
|
|
127
|
-
const
|
|
128
|
-
|
|
129
|
-
|
|
310
|
+
catch (error) {
|
|
311
|
+
const refusal = refusalForLoadFailure(trimmed, error);
|
|
312
|
+
if (refusal) {
|
|
313
|
+
throw refusal;
|
|
314
|
+
}
|
|
315
|
+
return buildMediaPlaySource(trimmed, {
|
|
316
|
+
duration: defaultDuration,
|
|
317
|
+
note: noteForLoadFailure(error),
|
|
318
|
+
});
|
|
130
319
|
}
|
|
131
320
|
}
|
|
@@ -3,12 +3,12 @@
|
|
|
3
3
|
* Raster API client (api.raster.art — public GraphQL API).
|
|
4
4
|
*
|
|
5
5
|
* Resolves on-chain coordinates and artist addresses into Raster artworks
|
|
6
|
-
* (series) and artists (catalog)
|
|
7
|
-
*
|
|
6
|
+
* (series) and artists (catalog) for `ff-cli find`, and fetches the full token
|
|
7
|
+
* rows the Raster token source (token-source-raster.ts) turns into DP-1 items.
|
|
8
8
|
*
|
|
9
9
|
* This module is pure data: it does not construct DP-1 items, send anything
|
|
10
|
-
* to devices, or build playlists.
|
|
11
|
-
*
|
|
10
|
+
* to devices, or build playlists. Mapping a RasterToken to the vendor-neutral
|
|
11
|
+
* token shape happens in token-source-raster.ts.
|
|
12
12
|
*
|
|
13
13
|
* Chain filter: the FF indexer only supports Ethereum mainnet and Tezos
|
|
14
14
|
* mainnet. Token lists are filtered here at the CAIP level and a
|
|
@@ -57,10 +57,14 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
57
57
|
};
|
|
58
58
|
})();
|
|
59
59
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
60
|
-
exports.RasterUnreachableError = void 0;
|
|
60
|
+
exports.RASTER_TOKEN_BATCH_SIZE = exports.TOKEN_FIELDS = exports.RasterUnreachableError = void 0;
|
|
61
|
+
exports.rasterQuery = rasterQuery;
|
|
61
62
|
exports.resolveTokenToArtwork = resolveTokenToArtwork;
|
|
62
63
|
exports.resolveSlugToArtwork = resolveSlugToArtwork;
|
|
63
64
|
exports.resolveAddressToArtist = resolveAddressToArtist;
|
|
65
|
+
exports.buildTokenByRefBatchQuery = buildTokenByRefBatchQuery;
|
|
66
|
+
exports.fetchTokensByRef = fetchTokensByRef;
|
|
67
|
+
exports.fetchTokensOwned = fetchTokensOwned;
|
|
64
68
|
exports.listArtworkTokens = listArtworkTokens;
|
|
65
69
|
exports.listArtistArtworks = listArtistArtworks;
|
|
66
70
|
exports.formatSummaryLine = formatSummaryLine;
|
|
@@ -241,6 +245,93 @@ async function resolveAddressToArtist(address) {
|
|
|
241
245
|
const artist = data.address?.artists?.[0];
|
|
242
246
|
return artist ? toRasterArtist(artist) : null;
|
|
243
247
|
}
|
|
248
|
+
/**
|
|
249
|
+
* TOKEN_FIELDS is the one selection every token-reading query shares, so the
|
|
250
|
+
* three entry points (by reference, by owner, by artwork) feed the same mapper.
|
|
251
|
+
*/
|
|
252
|
+
exports.TOKEN_FIELDS = `fragment TokenFields on Token {
|
|
253
|
+
chainId contractAddress tokenId tokenStandard name description
|
|
254
|
+
media { previewHash previewType contentUrl normalizedContentUrl contentType }
|
|
255
|
+
artworks { id slug title description platform { id name } artists { id name slug } }
|
|
256
|
+
artists { id name slug }
|
|
257
|
+
}`;
|
|
258
|
+
/**
|
|
259
|
+
* RASTER_TOKEN_BATCH_SIZE caps aliased `tokenByRef` lookups per request.
|
|
260
|
+
* 40 is the size probed on feral-file#3546 (one request, 2.9 s); larger
|
|
261
|
+
* batches are unmeasured, so raise it only with a new measurement.
|
|
262
|
+
*/
|
|
263
|
+
exports.RASTER_TOKEN_BATCH_SIZE = 40;
|
|
264
|
+
/**
|
|
265
|
+
* buildTokenByRefBatchQuery builds one query with `count` aliased lookups.
|
|
266
|
+
*
|
|
267
|
+
* Refs travel as numbered variables (`$r0`…), never interpolated into the
|
|
268
|
+
* text, so token ids above 2^53 (every Feral File id) arrive as strings and
|
|
269
|
+
* no caller-supplied value can alter the document.
|
|
270
|
+
*/
|
|
271
|
+
function buildTokenByRefBatchQuery(count) {
|
|
272
|
+
const indices = Array.from({ length: count }, (_, i) => i);
|
|
273
|
+
const params = indices.map((i) => `$r${i}: TokenRefInput!`).join(', ');
|
|
274
|
+
const fields = indices.map((i) => ` t${i}: tokenByRef(ref: $r${i}) { ...TokenFields }`);
|
|
275
|
+
return `query TokensByRef(${params}) {\n${fields.join('\n')}\n}\n${exports.TOKEN_FIELDS}`;
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* fetchTokensByRef looks up one batch of coordinates in a single request.
|
|
279
|
+
*
|
|
280
|
+
* The result is aligned with the input; a token Raster does not cover, or one
|
|
281
|
+
* that was burned, is `null` (Raster answers null rather than an error).
|
|
282
|
+
* Batching across requests, progress, and per-batch failure handling belong to
|
|
283
|
+
* the caller, which is why more than RASTER_TOKEN_BATCH_SIZE refs is refused
|
|
284
|
+
* instead of split here.
|
|
285
|
+
*
|
|
286
|
+
* @throws RasterUnreachableError on network failure; Error on API failure
|
|
287
|
+
*/
|
|
288
|
+
async function fetchTokensByRef(refs) {
|
|
289
|
+
if (refs.length > exports.RASTER_TOKEN_BATCH_SIZE) {
|
|
290
|
+
throw new Error(`fetchTokensByRef takes at most ${exports.RASTER_TOKEN_BATCH_SIZE} refs per call.`);
|
|
291
|
+
}
|
|
292
|
+
if (refs.length === 0) {
|
|
293
|
+
return [];
|
|
294
|
+
}
|
|
295
|
+
const variables = {};
|
|
296
|
+
refs.forEach((ref, i) => {
|
|
297
|
+
variables[`r${i}`] = {
|
|
298
|
+
chainId: INDEXER_CHAIN_TO_CAIP[ref.chain],
|
|
299
|
+
contractAddress: ref.contractAddress,
|
|
300
|
+
tokenId: ref.tokenId,
|
|
301
|
+
};
|
|
302
|
+
});
|
|
303
|
+
const data = await rasterQuery(buildTokenByRefBatchQuery(refs.length), variables);
|
|
304
|
+
return refs.map((_, i) => data[`t${i}`] ?? null);
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* fetchTokensOwned reads one page of `address.tokensOwned`, the wallet walk.
|
|
308
|
+
*
|
|
309
|
+
* Raster resolves any well-formed address and returns an empty connection when
|
|
310
|
+
* it knows no holdings, so an empty page is an answer, not an error. Chain
|
|
311
|
+
* filtering is left to the caller, which owns the skipped-chain report.
|
|
312
|
+
*/
|
|
313
|
+
async function fetchTokensOwned(address, options) {
|
|
314
|
+
const data = await rasterQuery(`query TokensOwned($address: String!, $first: Int, $after: String) {
|
|
315
|
+
address(address: $address) {
|
|
316
|
+
tokensOwned(first: $first, after: $after) {
|
|
317
|
+
totalCount
|
|
318
|
+
nodes { token { ...TokenFields } }
|
|
319
|
+
pageInfo { hasNextPage endCursor }
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
${exports.TOKEN_FIELDS}`, { address: address.trim(), first: options.first, after: options.after ?? null });
|
|
324
|
+
const owned = data.address?.tokensOwned;
|
|
325
|
+
if (!owned) {
|
|
326
|
+
return { tokens: [], nextCursor: null, totalCount: 0 };
|
|
327
|
+
}
|
|
328
|
+
const { hasNextPage, endCursor } = owned.pageInfo;
|
|
329
|
+
return {
|
|
330
|
+
tokens: owned.nodes.map((node) => node.token),
|
|
331
|
+
nextCursor: hasNextPage && endCursor ? endCursor : null,
|
|
332
|
+
totalCount: owned.totalCount,
|
|
333
|
+
};
|
|
334
|
+
}
|
|
244
335
|
/**
|
|
245
336
|
* List tokens in a Raster artwork (series), one page at a time.
|
|
246
337
|
*
|
|
@@ -257,11 +348,12 @@ async function listArtworkTokens(artworkId, options = {}) {
|
|
|
257
348
|
const data = await rasterQuery(`query ArtworkTokens($id: ID!, $first: Int, $after: String) {
|
|
258
349
|
artwork(id: $id) {
|
|
259
350
|
tokens(first: $first, after: $after) {
|
|
260
|
-
nodes {
|
|
351
|
+
nodes { ...TokenFields }
|
|
261
352
|
pageInfo { hasNextPage endCursor }
|
|
262
353
|
}
|
|
263
354
|
}
|
|
264
|
-
}
|
|
355
|
+
}
|
|
356
|
+
${exports.TOKEN_FIELDS}`, { id: artworkId, first: options.pageSize ?? null, after: options.cursor ?? null });
|
|
265
357
|
if (!data.artwork) {
|
|
266
358
|
throw new Error(`Raster: artwork ${artworkId} not found.`);
|
|
267
359
|
}
|
|
@@ -277,12 +369,7 @@ async function listArtworkTokens(artworkId, options = {}) {
|
|
|
277
369
|
chain,
|
|
278
370
|
contractAddress: raw.contractAddress,
|
|
279
371
|
tokenId: raw.tokenId,
|
|
280
|
-
|
|
281
|
-
// Prefer the real MIME (`contentType`); today it is usually empty, so we
|
|
282
|
-
// fall back to `previewType` as a last resort. NOTE: previewType only
|
|
283
|
-
// describes the preview encoding, not the work, so this fallback can
|
|
284
|
-
// misclassify (see the mediaType doc comment above). Tracked for revisit.
|
|
285
|
-
mediaType: raw.media?.contentType || raw.media?.previewType || null,
|
|
372
|
+
token: raw,
|
|
286
373
|
});
|
|
287
374
|
}
|
|
288
375
|
const { hasNextPage, endCursor } = data.artwork.tokens.pageInfo;
|