Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,16 @@ process.on('warning', (warning) => {
});

import 'dotenv/config';
import { setDefaultAutoSelectFamilyAttemptTimeout } from 'net';
import { Command } from 'commander';

// Node's happy-eyeballs gives each address-family connect attempt 250 ms by
// default. That is shorter than a single round trip to a far-away API host
// (e.g. api.raster.art from Asia is ~250 ms RTT), so on networks with broken
// IPv6 the IPv4 attempt gets cut off mid-handshake, the IPv6 fallback has no
// route, and fetch fails or stalls intermittently (#97). 2.5 s per attempt is
// still fast-failing but no longer races the speed of light.
setDefaultAutoSelectFamilyAttemptTimeout(2500);
import { readFileSync } from 'fs';
import { resolve, dirname } from 'path';
import { setupCommand } from './src/commands/setup';
Expand Down
36 changes: 32 additions & 4 deletions src/commands/find.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
listArtistArtworks,
resolveAddressToArtist,
formatSummaryLine,
RasterUnreachableError,
} from '../utilities/raster-client';
import type { RasterArtworkSummary, RasterArtworkRow } from '../utilities/raster-client';
import { castPlaylist } from '../utilities/playlist-cast';
Expand Down Expand Up @@ -284,14 +285,26 @@ async function resolveTarget(
return resolveCoords(parsed.coords);
}
if (parsed.kind === 'ff-url') {
if (parsed.urlKind === 'show') {
// Shows and series both resolve through the source-resolver's Feral File
// site module, which enumerates exactly the tokens the page describes
// (series slug → /api/series → /api/artworks?seriesID). The old series
// path resolved ONE token via ff-marketplace and then asked Raster, which
// expands to the token's parent artwork — so a single-edition series page
// (e.g. a Display Edition) surprisingly built the sibling collection's
// full token list. A series URL means the user pointed at a specific
// series; build exactly that. Artwork URLs keep the coords→Raster path:
// a single-token page carries no series intent to preserve.
if (parsed.urlKind === 'show' || parsed.urlKind === 'series') {
const label = parsed.urlKind === 'show' ? 'show' : 'series';
const target = await resolveTokenListInput(
input,
resolverLimitFromOption(limitOption),
`Feral File show ${parsed.identifier}`
`Feral File ${label} ${parsed.identifier}`
);
if (target === null) {
throw new Error(`Feral File: no supported tokens found for show "${parsed.identifier}".`);
throw new Error(
`Feral File: no supported tokens found for ${label} "${parsed.identifier}".`
);
}
return target;
}
Expand Down Expand Up @@ -410,9 +423,24 @@ async function resolveTokenListInput(
/**
* Resolve on-chain coords to a target. If Raster doesn't index this token,
* fall back to single-token mode so we still build something playable.
*
* Raster being unreachable gets the same fallback: it only enriches the find
* (series enumeration, artist labels) — the coords in hand are enough to
* build a playable single-token playlist, and a network blip on one optional
* dependency must not kill the whole command (#97).
*/
async function resolveCoords(coords: TokenCoords): Promise<ResolvedTarget> {
const summary = await resolveTokenToArtwork(coords.chain, coords.contract, coords.tokenId);
let summary: RasterArtworkSummary | null;
try {
summary = await resolveTokenToArtwork(coords.chain, coords.contract, coords.tokenId);
} catch (error) {
if (!(error instanceof RasterUnreachableError)) {
throw error;
}
console.log(chalk.yellow(` ${error.message}`));
console.log(chalk.dim(' Continuing without Raster — building a one-item playlist.'));
return { kind: 'single', coords };
}
if (summary === null) {
return { kind: 'single', coords };
}
Expand Down
66 changes: 59 additions & 7 deletions src/utilities/raster-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,15 @@ import { USER_AGENT } from './user-agent';

const DEFAULT_RASTER_GRAPHQL_URL = 'https://api.raster.art/graphql';

/**
* Hard ceiling on a single Raster round trip. Without it, a stalled TCP
* connect or unanswered request leaves `find` hanging with no output (#97) —
* the failure mode agents can least diagnose. 20 s is generous for a
* transcontinental GraphQL query and still turns "silent hang" into a fast,
* actionable error.
*/
const RASTER_REQUEST_TIMEOUT_MS = 20_000;

// CAIP-2 chain IDs for the chains the FF indexer supports.
// Tezos mainnet's CAIP form uses the genesis block hash (NetXdQprcVkpaWU),
// not the human-readable "tezos:mainnet" — Raster returns the hash form on
Expand All @@ -44,6 +53,19 @@ const INDEXER_CHAIN_TO_CAIP: Record<IndexerChain, string> = {

export type IndexerChain = 'ethereum' | 'tezos';

/**
* Raster could not be reached at the network level (connect failure, broken
* IPv6 path, timeout). Distinct from API-level errors so callers that hold
* usable token coordinates can degrade gracefully instead of dying — Raster
* only enriches a find, it is not required to build a playable playlist.
*/
export class RasterUnreachableError extends Error {
constructor(message: string) {
super(message);
this.name = 'RasterUnreachableError';
}
}

export interface RasterArtist {
id: string;
name: string;
Expand Down Expand Up @@ -118,16 +140,46 @@ async function rasterQuery<T>(query: string, variables: Record<string, unknown>)
if (apiKey) {
headers['x-api-key'] = apiKey;
}
const response = await fetch(endpoint, {
method: 'POST',
headers,
body: JSON.stringify({ query, variables }),
});
let response: Response;
try {
response = await fetch(endpoint, {
method: 'POST',
headers,
body: JSON.stringify({ query, variables }),
signal: AbortSignal.timeout(RASTER_REQUEST_TIMEOUT_MS),
});
} catch (error) {
// Undici's "fetch failed" carries no endpoint and no cause in its
// message — name the host and the real failure so the error is
// actionable, and let callers detect network failure by type.
const cause = (error as { cause?: { message?: string } }).cause;
const detail = cause?.message ?? (error as Error).message;
throw new RasterUnreachableError(`Raster API unreachable (${endpoint}): ${detail}`);
}
if (!response.ok) {
const body = await response.text();
// Body text is best-effort context; the HTTP status alone is the error.
// A body read that rejects here must not mask the status — and an HTTP
// error is an API-level failure, deliberately NOT RasterUnreachableError.
const body = await response.text().catch(() => '');
throw new Error(`Raster API ${response.status} ${response.statusText}: ${body.slice(0, 200)}`);
}
const payload = (await response.json()) as { data?: T; errors?: Array<{ message: string }> };
// The network-error boundary extends through body consumption: a response
// whose stream stalls past the headers or terminates mid-body rejects
// HERE, not in fetch() above. Those must still surface as
// RasterUnreachableError or resolveCoords cannot perform its promised
// single-token fallback. (A 200 whose body cannot be read or parsed is
// unusable either way — falling back is right even for server-side
// garbage.)
let payload: { data?: T; errors?: Array<{ message: string }> };
try {
payload = (await response.json()) as { data?: T; errors?: Array<{ message: string }> };
} catch (error) {
const cause = (error as { cause?: { message?: string } }).cause;
const detail = cause?.message ?? (error as Error).message;
throw new RasterUnreachableError(
`Raster API unreachable (${endpoint}): response body failed: ${detail}`
);
}
if (payload.errors && payload.errors.length > 0) {
throw new Error(`Raster API error: ${payload.errors.map((e) => e.message).join('; ')}`);
}
Expand Down
93 changes: 92 additions & 1 deletion tests/find-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,13 @@ before(async () => {
variables: Record<string, unknown>;
};
res.setHeader('Content-Type', 'application/json');
if (destroyResponseBody) {
// Headers out, body terminated mid-stream: fetch() resolves, the
// body read rejects — the #98-review failure mode.
res.write('{"data": {');
res.destroy();
return;
}
res.end(JSON.stringify(handler(query, variables ?? {})));
});
});
Expand All @@ -64,8 +71,11 @@ after(() => {
server.close();
});

let destroyResponseBody = false;

beforeEach(() => {
handler = () => ({ errors: [{ message: 'no handler installed for this test' }] });
destroyResponseBody = false;
});

interface RunResult {
Expand Down Expand Up @@ -278,6 +288,19 @@ describe('find command — Raster series flow (mock GraphQL server)', () => {
assert.match(result.stdout, /Build playlist with 1 token\?/);
assert.match(result.stdout, /Cancelled\./);
});

test('Raster body terminating mid-stream → warns and degrades to one-item playlist (#98 review)', async () => {
// fetch() succeeds (headers arrive), the body read rejects. The typed
// RasterUnreachableError must survive to resolveCoords so the find flow
// degrades instead of dying — the whole point of the boundary fix.
destroyResponseBody = true;
const result = await runFind([`ethereum:${VALID_ETH_CONTRACT}:1`], { stdin: 'n\n' });
assert.equal(result.code, 0);
assert.match(result.stdout, /Raster API unreachable/);
assert.match(result.stdout, /Continuing without Raster — building a one-item playlist/);
assert.match(result.stdout, /Build playlist with 1 token\?/);
assert.match(result.stdout, /Cancelled\./);
});
});

describe('find command — resolver-backed token-list flow', () => {
Expand All @@ -286,11 +309,12 @@ describe('find command — resolver-backed token-list flow', () => {
const superRareCollectionUrl =
'https://superrare.com/collection/0x1234567890123456789012345678901234567890';
const feralFileShowUrl = 'https://feralfile.com/exhibitions/shows/mock-show';
const feralFileSeriesUrl = 'https://feralfile.com/exhibitions/series/mock-series';
const dir = mkdtempSync(join(tmpdir(), 'ff-find-token-list-'));
const originalCwd = process.cwd();
const resolverCalls: Array<{ input: string; options: { limit: number } }> = [];
const promptQuestions: string[] = [];
const promptAnswers = ['yes', 's', 'yes', 's', 'yes', 's'];
const promptAnswers = ['yes', 's', 'yes', 's', 'yes', 's', 'yes', 's'];
const indexedBatches: unknown[][] = [];
const stdoutWrites: string[] = [];
const stderrWrites: string[] = [];
Expand Down Expand Up @@ -321,6 +345,23 @@ describe('find command — resolver-backed token-list flow', () => {
reason: 'mock resolver miss',
};
}
// Series pages resolve to exactly the tokens the page describes —
// one here, mirroring a single-edition series (the case the old
// ff-marketplace→Raster path expanded to a sibling collection).
if (input === feralFileSeriesUrl) {
return {
kind: 'tokens',
title: 'Mock Feral File Series',
coords: [
{
chain: 'ethereum',
contract: '0x1234567890123456789012345678901234567890',
tokenId: '21',
},
],
hasMore: false,
};
}
const isSuperRareCollection = input === superRareCollectionUrl;
const isFeralFileShow = input === feralFileShowUrl;
return {
Expand Down Expand Up @@ -537,6 +578,56 @@ describe('find command — resolver-backed token-list flow', () => {
stderrWrites.join(''),
/Feral File: no supported tokens found for show "mock-show"/
);

// Series URLs route through the same resolver token-list path as shows
// (never ff-marketplace→Raster, which expanded a single-edition series
// to its parent artwork's full token list). The mock Raster server
// seeing zero traffic is asserted implicitly: a Raster call would hit
// this test's GraphQL handler and fail the deepEqual on indexedBatches.
resolverCalls.length = 0;
promptQuestions.length = 0;
indexedBatches.length = 0;
stdoutWrites.length = 0;
stderrWrites.length = 0;
resolverMissInputs.delete(feralFileShowUrl);

await findCommand.parseAsync(['node', 'find', feralFileSeriesUrl, '--limit', '1'], {
from: 'node',
});

assert.deepEqual(resolverCalls, [
{
input: feralFileSeriesUrl,
options: { limit: 1 },
},
]);
assert.deepEqual(indexedBatches, [
[
{
chain: 'ethereum',
contractAddress: '0x1234567890123456789012345678901234567890',
tokenId: '21',
},
],
]);
assert.match(stdoutWrites.join(''), /Mock Feral File Series/);

resolverCalls.length = 0;
stderrWrites.length = 0;
resolverMissInputs.add(feralFileSeriesUrl);

await assert.rejects(
() =>
findCommand.parseAsync(['node', 'find', feralFileSeriesUrl, '--limit', '1'], {
from: 'node',
}),
/process\.exit\(1\)/
);

assert.match(
stderrWrites.join(''),
/Feral File: no supported tokens found for series "mock-series"/
);
} finally {
process.stdout.write = originalStdoutWrite;
process.stderr.write = originalStderrWrite;
Expand Down
Loading
Loading