Skip to content
Merged
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
57 changes: 52 additions & 5 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 @@ -204,6 +205,17 @@ async function runResolvedTarget(target: ResolvedTarget, options: FindOptions):
chalk.dim(`Indexing ${tokens.length} token${tokens.length === 1 ? '' : 's'} via FF indexer...`)
);

// Previously-unseen tokens make the indexer warm renditions by polling —
// minutes of wall time on a large series. Say so upfront (the --limit
// warming hint used to appear only after a failure) and render per-batch
// progress so a long index never reads as a hang.
if (tokens.length > 10) {
console.log(
chalk.dim(
' First-time tokens can take the indexer a while to warm; use `--limit 5` for a faster first pass.'
)
);
}
// Second positional arg on getNFTTokenInfoBatch is `duration` (DP-1 item
// display seconds), not concurrency — concurrency is hardcoded inside.
// Omit it for auto timing: video/audio items carry no duration and play
Expand All @@ -213,7 +225,15 @@ async function runResolvedTarget(target: ResolvedTarget, options: FindOptions):
chain: t.chain,
contractAddress: t.contract,
tokenId: t.tokenId,
}))
})),
undefined,
// Unconditional: suppressing the done===total call meant a one-batch
// index (including the recommended --limit 5 warm-up) printed nothing
// and multi-batch runs never showed completion. The final N/N line IS
// the "indexing finished" signal.
(done: number, total: number) => {
console.log(chalk.dim(` ${done}/${total} tokens indexed...`));
}
);

// FF-indexer bypass for Raster-minted tokens. The indexer doesn't carry
Expand Down Expand Up @@ -284,14 +304,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 +442,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
3 changes: 2 additions & 1 deletion src/utilities/ab-marketplace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
* direct GraphQL path is the right shape.
*/

import { fetchWithTimeout } from './http';
import * as logger from '../logger';
import type { TokenCoords } from '@feralfile/source-resolver';
import { USER_AGENT } from './user-agent';
Expand Down Expand Up @@ -43,7 +44,7 @@ export async function resolveArtBlocksCollection(slug: string): Promise<TokenCoo
' tokens(limit: 1, order_by: { invocation: asc }) { token_id }' +
' }' +
'}';
const response = await fetch(AB_GRAPHQL, {
const response = await fetchWithTimeout(AB_GRAPHQL, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'User-Agent': USER_AGENT },
body: JSON.stringify({ query, variables: { slug } }),
Expand Down
9 changes: 6 additions & 3 deletions src/utilities/feed-fetcher.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
* API: https://github.com/display-protocol/dp1-feed
*/

const { fetchWithTimeout } = require('./http');
const chalk = require('chalk');
const fuzzysort = require('fuzzysort');
const { getFeedConfig } = require('../config');
Expand Down Expand Up @@ -48,7 +49,9 @@ async function fetchPlaylistsFromFeed(feedUrl, limit = 100) {
try {
// API has a maximum limit of 100
const validLimit = Math.min(limit, 100);
const response = await fetch(`${feedUrl}/playlists?limit=${validLimit}&sort=-created`);
const response = await fetchWithTimeout(
`${feedUrl}/playlists?limit=${validLimit}&sort=-created`
);

if (!response.ok) {
console.log(chalk.yellow(` Feed ${feedUrl} returned ${response.status}`));
Expand Down Expand Up @@ -114,7 +117,7 @@ async function fetchPlaylistsWithPagination(
const currentLimit = Math.min(pageSize, remainingItems, 100);

try {
const response = await fetch(
const response = await fetchWithTimeout(
`${feedUrl}/playlists?limit=${currentLimit}&offset=${offset}&sort=-created`
);
reachable = true;
Expand Down Expand Up @@ -344,7 +347,7 @@ async function getPlaylistById(idOrSlug, feedUrl = null) {
// Try each feed URL until we find the playlist
for (const url of feedUrls) {
try {
const response = await fetch(`${url}/playlists/${idOrSlug}`);
const response = await fetchWithTimeout(`${url}/playlists/${idOrSlug}`);

if (response.ok) {
const playlist = await response.json();
Expand Down
3 changes: 2 additions & 1 deletion src/utilities/feral-file-artwork.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
* endpoint instead of reconstructing contract identity from exhibition internals.
*/

import { defaultDeadlineFetch } from './http';
import { USER_AGENT } from './user-agent';

export interface FeralFileArtworkTokenCoords {
Expand Down Expand Up @@ -37,7 +38,7 @@ interface FeralFileArtworkResponse {
*/
export async function resolveFeralFileArtwork(
artworkId: string,
fetchImpl: typeof fetch = fetch
fetchImpl: typeof fetch = defaultDeadlineFetch
): Promise<FeralFileArtworkTokenCoords> {
const normalizedArtworkId = artworkId.trim();
if (!normalizedArtworkId) {
Expand Down
3 changes: 2 additions & 1 deletion src/utilities/ff-marketplace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
* Show URLs are rejected: a single exhibition spans multiple series, which
* is wider than v1 supports.
*/
import { fetchWithTimeout } from './http';
import * as logger from '../logger';
import type { FeralFileUrlKind, TokenCoords } from '@feralfile/source-resolver';
import { resolveFeralFileArtwork } from './feral-file-artwork';
Expand All @@ -39,7 +40,7 @@ interface ArtworksByQueryResponse {
async function ffFetch<T>(path: string): Promise<T> {
const url = `${FF_API_BASE}${path}`;
logger.debug(`[FF API] GET ${url}`);
const response = await fetch(url, {
const response = await fetchWithTimeout(url, {
headers: { 'User-Agent': USER_AGENT, Accept: 'application/json' },
});
if (!response.ok) {
Expand Down
6 changes: 5 additions & 1 deletion src/utilities/ff1-compatibility.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
* FF1 device compatibility helpers for command preflight checks.
*/

import { fetchWithTimeout } from './http';
import { getFF1DeviceConfig } from '../config';
import { findConfiguredDeviceIndex } from './device-lookup';
import type { FF1Device, FF1DeviceConfig } from '../types';
Expand Down Expand Up @@ -124,7 +125,10 @@ export async function assertFF1CommandCompatibility(
command: FF1Command,
options: CompatibilityCheckOptions = {}
): Promise<FF1CompatibilityResult> {
const fetchFn = options.fetchFn || globalThis.fetch.bind(globalThis);
// Default probe fetch rides the shared deadline: a device that accepts
// the TCP connection but never answers must not hang `ff-cli ssh`.
const fetchFn =
options.fetchFn || ((input: string, init?: RequestInit) => fetchWithTimeout(input, init ?? {}));
const policy = getCommandPolicy(command);

const versionResult = await detectFF1VersionSafely(
Expand Down
5 changes: 4 additions & 1 deletion src/utilities/ff1-device.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
* Handles sending DP1 playlists to FF1 devices via the Relayer API
*/

import { defaultDeadlineFetch } from './http';
import * as logger from '../logger';
import { getFF1RelayerConfig } from '../config';
import type { FF1Device, Playlist } from '../types';
Expand Down Expand Up @@ -193,7 +194,9 @@ export async function sendPlaylistToDevice(
overrides: FF1DeviceDependencies = {}
): Promise<SendPlaylistResult> {
const dependencies: DeliveryDependencies = {
fetchFn: overrides.fetchFn ?? globalThis.fetch.bind(globalThis),
// Production default rides the shared deadline; injected test fetches
// are preserved untouched (#101 review).
fetchFn: overrides.fetchFn ?? defaultDeadlineFetch,
getTopicIdFn: overrides.getTopicIdFn ?? getTopicId,
getRelayerConfigFn: overrides.getRelayerConfigFn ?? getFF1RelayerConfig,
waitFn: overrides.waitFn ?? waitForRetry,
Expand Down
4 changes: 3 additions & 1 deletion src/utilities/ff1-relayer.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { defaultDeadlineFetch } from './http';
import type { Playlist } from '../types';

export interface RelayerCastResult {
Expand Down Expand Up @@ -39,7 +40,8 @@ export async function sendPlaylistViaRelayer(input: {
playlist: Playlist;
fetchFn?: typeof fetch;
}): Promise<RelayerCastResult> {
const fetchFn = input.fetchFn ?? globalThis.fetch.bind(globalThis);
// Production default rides the shared deadline (#101 review).
const fetchFn = input.fetchFn ?? defaultDeadlineFetch;
let url: URL;
try {
url = new URL('/api/cast', input.baseUrl);
Expand Down
5 changes: 3 additions & 2 deletions src/utilities/fxhash-marketplace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
* GraphQL endpoint and surface as "slug not found".
*/

import { fetchWithTimeout } from './http';
import * as logger from '../logger';
import type { TokenCoords } from '@feralfile/source-resolver';
import { USER_AGENT } from './user-agent';
Expand All @@ -40,7 +41,7 @@ interface FxhashObjktResponse {
export async function resolveFxhashIteration(slug: string): Promise<TokenCoords> {
logger.debug(`[fxhash] Resolving iteration slug "${slug}"`);
const query = 'query ($slug: String!) { objkt(slug: $slug) { gentkContractAddress onChainId } }';
const response = await fetch(FXHASH_GRAPHQL, {
const response = await fetchWithTimeout(FXHASH_GRAPHQL, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'User-Agent': USER_AGENT },
body: JSON.stringify({ query, variables: { slug } }),
Expand Down Expand Up @@ -110,7 +111,7 @@ export async function resolveFxhashProject(slug: string): Promise<TokenCoords> {
' }' +
' }' +
'}';
const response = await fetch(FXHASH_GRAPHQL, {
const response = await fetchWithTimeout(FXHASH_GRAPHQL, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'User-Agent': USER_AGENT },
body: JSON.stringify({ query, variables: { slug } }),
Expand Down
4 changes: 3 additions & 1 deletion src/utilities/handoff-client.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { defaultDeadlineFetch } from './http';
import { webcrypto } from 'node:crypto';
import {
HANDOFF_ALGORITHM,
Expand Down Expand Up @@ -109,7 +110,8 @@ export class TopicHandoffReceiver {
fetchFn?: typeof fetch;
} = {}
): Promise<TopicHandoffReceiver> {
const fetchFn = options.fetchFn ?? globalThis.fetch.bind(globalThis);
// Production default rides the shared deadline (#101 review).
const fetchFn = options.fetchFn ?? defaultDeadlineFetch;
const baseUrl = brokerBaseUrl(options.baseUrl);
const keyPair = await generateHandoffKeyPair();
const publicKey = await exportHandoffPublicJwk(keyPair.publicKey);
Expand Down
44 changes: 44 additions & 0 deletions src/utilities/http.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/**
* Shared fetch-with-timeout for every outbound HTTP call in the CLI.
*
* Bare `fetch` has no deadline: a stalled connect or an unanswered request
* hangs the command silently — the failure mode agents can least diagnose,
* and the one behind #97. Every resolver and indexer call goes through here
* so "silent hang" is structurally impossible, not per-call-site discipline.
*
* Callers that pass their own `signal` keep it (composed with the deadline
* via AbortSignal.any), so cancellation semantics are never silently
* replaced by a timeout.
*/

export const DEFAULT_HTTP_TIMEOUT_MS = 20_000;

export function fetchWithTimeout(
input: string | URL | Request,
init: RequestInit = {},
timeoutMs: number = DEFAULT_HTTP_TIMEOUT_MS
): Promise<Response> {
const deadline = AbortSignal.timeout(timeoutMs);
const signals: AbortSignal[] = [deadline];
if (init.signal) {
signals.push(init.signal);
} else if (input instanceof Request && input.signal) {
// A Request input carries its own abort signal; passing only the
// deadline in init would silently REPLACE it (init wins over the
// request's signal in fetch), stripping the caller's cancellation.
signals.push(input.signal);
}
const signal = signals.length > 1 ? AbortSignal.any(signals) : deadline;
return fetch(input, { ...init, signal });
}

/**
* Drop-in `fetch` for production defaults of injectable-fetch modules:
* identical signature, deadline attached. Modules that accept a `fetchFn`
* override default to this instead of bare `globalThis.fetch`, so tests
* keep full injection control while production can never hang.
*/
export const defaultDeadlineFetch: typeof fetch = (
input: string | URL | Request,
init?: RequestInit
) => fetchWithTimeout(input, init ?? {});
3 changes: 2 additions & 1 deletion src/utilities/neort-marketplace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
* reject when `id` is empty.
*/

import { fetchWithTimeout } from './http';
import * as logger from '../logger';
import { USER_AGENT } from './user-agent';

Expand Down Expand Up @@ -58,7 +59,7 @@ export interface NeortArt {
*/
export async function resolveNeortArt(id: string): Promise<NeortArt> {
logger.debug(`[neort] Resolving art id "${id}"`);
const response = await fetch(`${NEORT_API_BASE}/art/${encodeURIComponent(id)}`, {
const response = await fetchWithTimeout(`${NEORT_API_BASE}/art/${encodeURIComponent(id)}`, {
headers: { 'User-Agent': USER_AGENT, Accept: 'application/json' },
});
if (!response.ok) {
Expand Down
Loading
Loading