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: 5 additions & 4 deletions src/builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
paramToScVal,
validateContext,
} from './batch-tx.js';
import { OperationAbortedError } from './errors.js';
import type { BatchTransactionContext, BuiltBatchTransaction, ScValType } from './batch-tx.js';

export interface SubmitOptions {
Expand Down Expand Up @@ -312,7 +313,7 @@ export class StreamBuilder {

const { signal } = options;
if (signal?.aborted) {
throw new DOMException('Aborted', 'AbortError');
throw new OperationAbortedError('submit');
}

// Backpressure: reject if queue is full
Expand Down Expand Up @@ -340,7 +341,7 @@ export class StreamBuilder {
}

if (signal?.aborted) {
throw new DOMException('Aborted', 'AbortError');
throw new OperationAbortedError('submit');
}

try {
Expand All @@ -352,7 +353,7 @@ export class StreamBuilder {
return result;
} catch (err) {
if (signal?.aborted) {
throw new DOMException('Aborted', 'AbortError');
throw new OperationAbortedError('submit');
}
lastError = err;
attempt++;
Expand All @@ -371,7 +372,7 @@ export class StreamBuilder {
clearTimeout(timer);
this.activeTimers.delete(timer);
signal.removeEventListener('abort', onAbort);
reject(new DOMException('Aborted', 'AbortError'));
reject(new OperationAbortedError('submit'));
};
signal.addEventListener('abort', onAbort, { once: true });
}
Expand Down
73 changes: 73 additions & 0 deletions src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,16 @@ export class UnsupportedChainError extends Error {
}
}

/**
* The root error for any failure that originated from a Conduit smart
* contract. The `contract` + `code` pair uniquely identifies the failure,
* and `isKnown` tells you whether the SDK recognises the code in its
* catalogue for that contract.
*
* Use {@link isConduitError} when you need a type guard instead of an
* `instanceof` check (e.g. across bundle boundaries or when the error may
* have been serialized).
*/
export class ConduitError extends Error {
readonly contract: ConduitContract;
readonly code: number;
Expand Down Expand Up @@ -447,3 +457,66 @@ export class IndexerTimeoutError extends Error {
Object.setPrototypeOf(this, new.target.prototype);
}
}

// ── Operation aborted ─────────────────────────────────────────────────────────

/**
* Thrown when an in-flight SDK operation is cancelled by the caller through
* an `AbortSignal`. This gives consumers a typed, SDK-native way to
* distinguish "the user cancelled" from network, contract, or indexer errors,
* instead of relying on the generic DOM `AbortError`.
*
* @example
* ```ts
* const controller = new AbortController();
* const promise = client.streams.create({ ... }, { signal: controller.signal });
* controller.abort();
* try { await promise; } catch (err) {
* if (err instanceof OperationAbortedError) {
console.log('Cancelled by user:', err.operation);
}
}
```
*/
export class OperationAbortedError extends Error {
/** Human-readable name of the operation that was aborted (e.g. "submitBatch"). */
readonly operation: string;

constructor(operation: string) {
super(`Operation aborted: ${operation}`);
this.name = 'OperationAbortedError';
this.operation = operation;
Object.setPrototypeOf(this, new.target.prototype);
}
}

// ── Type guard ────────────────────────────────────────────────────────────────

/**
* Returns `true` when `value` is a Conduit SDK error instance. Useful in
* catch blocks, logging, and transport boundaries where you cannot rely on
* `instanceof` across realms or bundled chunks.
*
* Recognised error classes:
* - {@link ConduitError}
* - {@link UnsupportedChainError}
* - {@link StreamFiNetworkError}
* - {@link InsufficientBalanceError}
* - {@link RateLimitError}
* - {@link RpcServiceUnavailableError}
* - {@link IndexerTimeoutError}
* - {@link OperationAbortedError}
*/
export function isConduitError(value: unknown): value is Error {
if (!(value instanceof Error)) return false;
return [
'ConduitError',
'UnsupportedChainError',
'StreamFiNetworkError',
'InsufficientBalanceError',
'RateLimitError',
'RpcServiceUnavailableError',
'IndexerTimeoutError',
'OperationAbortedError',
].includes(value.name);
}
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ export {
RateLimitError,
RpcServiceUnavailableError,
IndexerTimeoutError,
OperationAbortedError,
isConduitError,
SUPPORTED_NETWORKS,
CAIP2_TO_NETWORK,
UNKNOWN_CONTRACT_ERROR_CODE,
Expand Down
109 changes: 97 additions & 12 deletions src/indexer.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { ConduitError, UNKNOWN_CONTRACT_ERROR_CODE } from './errors.js';
import { IndexerTimeoutError } from './errors.js';
import { IndexerTimeoutError, OperationAbortedError } from './errors.js';

export interface GraphQLQueryOptions {
query: string;
Expand All @@ -20,6 +20,14 @@ export interface GraphQLQueryOptions {
* `Infinity` to disable the SDK timeout entirely.
*/
timeoutMs?: number;
/**
* Whether to use Automatic Persisted Queries (APQ). When `true` (default),
* the client first sends a SHA-256 hash of the query and only falls back
* to sending the full query string if the server reports
* `PERSISTED_QUERY_NOT_FOUND`. Set to `false` to always send the full
* query, e.g. for one-off queries the server has never seen.
*/
persist?: boolean;
}

export interface GraphQLSubscriptionOptions {
Expand All @@ -44,6 +52,17 @@ export interface IndexerSubscription {
* hands back `any`, which would silently defeat `noImplicitAny` for every
* property read below.
*/
/**
* APQ extension payload sent in place of the full query string when the
* indexer supports Automatic Persisted Queries.
*/
interface PersistedQueryExtensions {
persistedQuery: {
version: 1;
sha256Hash: string;
};
}

interface GraphQLServerMessage {
type?: unknown;
payload?: unknown;
Expand All @@ -57,6 +76,37 @@ interface GraphQLServerMessage {
* `dashboard/transaction-history.ts` and `examples/dashboard/.../apollo-client.ts`).
*/
export const DEFAULT_INDEXER_TIMEOUT_MS = 15_000;
/**
* Computes the SHA-256 hex digest of `input` using the Web Crypto API,
* falling back to Node's `crypto` module when `crypto.subtle` is not
* available. Required for APQ hash generation.
*/
async function sha256hex(input: string): Promise<string> {
const data = new TextEncoder().encode(input);
if (typeof crypto !== 'undefined' && crypto.subtle) {
const buffer = await crypto.subtle.digest('SHA-256', data);
return Array.from(new Uint8Array(buffer))
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
}
// Node.js < 19 fallback: the dynamic import is only evaluated when the
// Web Crypto API is absent, so bundlers targeting modern browsers can elide it.
const { createHash } = await import('node:crypto');
return createHash('sha256').update(input).digest('hex');
}

function isPersistedQueryNotFound(body: unknown): boolean {
if (!body || typeof body !== 'object') return false;
const errors = (body as { errors?: unknown }).errors;
if (!Array.isArray(errors)) return false;
return errors.some(
(error) =>
error &&
typeof error === 'object' &&
((error as { message?: string }).message ?? '').includes('PERSISTED_QUERY_NOT_FOUND'),
);
}


export class GraphQLIndexer {
private endpoint: string;
Expand Down Expand Up @@ -98,24 +148,31 @@ export class GraphQLIndexer {
throw new Error('Fetch API is not available in the current environment');
}

const response = await this.fetchWithTimeout(
const persist = options.persist !== false;
const extensions: PersistedQueryExtensions | undefined = persist
? { persistedQuery: { version: 1, sha256Hash: await sha256hex(options.query) } }
: undefined;

let body = await this.executeGraphQLRequest(
fetchFn,
this.endpoint,
headers,
JSON.stringify({ query: options.query, variables }),
{ query: persist ? null : options.query, variables, extensions },
options.timeoutMs,
options.signal,
);

if (!response.ok) {
throw new Error(`GraphQL query failed with status ${response.status}: ${response.statusText}`);
// APQ fallback: the server has not seen this query hash yet, so replay
// with the full query string plus the hash so the server can cache it.
if (persist && isPersistedQueryNotFound(body)) {
body = await this.executeGraphQLRequest(
fetchFn,
headers,
{ query: options.query, variables, extensions },
options.timeoutMs,
options.signal,
);
}

const body = (await response.json()) as {
data?: unknown;
errors?: unknown[];
};

if (Array.isArray(body?.errors) && body.errors.length > 0) {
const messages = body.errors
.map((error) => {
Expand All @@ -134,6 +191,34 @@ export class GraphQLIndexer {
}

/**
* Issues a single GraphQL POST and parses the JSON response. Extracted so
* APQ can retry with the full query string without duplicating fetch,
* timeout, and error-handling logic.
*/
private async executeGraphQLRequest(
fetchFn: typeof fetch,
headers: Record<string, string>,
payload: { query: string | null; variables: Record<string, unknown>; extensions?: PersistedQueryExtensions },
timeoutMs: number | undefined,
callerSignal?: AbortSignal,
): Promise<{ data?: unknown; errors?: unknown[] }> {
const response = await this.fetchWithTimeout(
fetchFn,
this.endpoint,
headers,
JSON.stringify(payload),
timeoutMs,
callerSignal,
);

if (!response.ok) {
throw new Error(`GraphQL query failed with status ${response.status}: ${response.statusText}`);
}

return (await response.json()) as { data?: unknown; errors?: unknown[] };
}

/**
* Runs a single GraphQL POST against the indexer, combining the SDK's
* default timeout (or the caller's `timeoutMs`) with any caller-supplied
* `AbortSignal`. If the request does not complete within the time window
Expand All @@ -160,7 +245,7 @@ export class GraphQLIndexer {

// If the caller already aborted, fail fast without issuing the request.
if (callerSignal?.aborted) {
throw new DOMException('The operation was aborted.', 'AbortError');
throw new OperationAbortedError('GraphQLIndexer.query');
}

const controller = new AbortController();
Expand Down
73 changes: 73 additions & 0 deletions src/tests/indexer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { GraphQLIndexer } from '../indexer.js';

describe('GraphQLIndexer APQ', () => {
const endpoint = 'https://indexer.example/graphql';
let indexer: GraphQLIndexer;

beforeEach(() => {
indexer = new GraphQLIndexer(endpoint);
});

afterEach(() => {
indexer.cleanup();
});

it('sends a persisted query hash on the first request when persist is enabled', async () => {
const fetchFn = vi.fn();
fetchFn.mockResolvedValueOnce({
ok: true,
json: async () => ({ data: { streamCount: 5 } }),
});

// Expose the private executeGraphQLRequest by calling query and stubbing fetch globally.
globalThis.fetch = fetchFn as unknown as typeof fetch;
const result = await indexer.query({ query: 'query { streamCount }' });

expect(result).toEqual({ streamCount: 5 });
expect(fetchFn).toHaveBeenCalledTimes(1);
const body = JSON.parse(fetchFn.mock.calls[0][1].body);
expect(body.query).toBeNull();
expect(body.extensions).toMatchObject({
persistedQuery: { version: 1, sha256Hash: expect.stringMatching(/^[a-f0-9]{64}$/) },
});
});

it('falls back to the full query when the server reports PERSISTED_QUERY_NOT_FOUND', async () => {
const fetchFn = vi.fn();
fetchFn.mockResolvedValueOnce({
ok: true,
json: async () => ({
errors: [{ message: 'PERSISTED_QUERY_NOT_FOUND' }],
}),
});
fetchFn.mockResolvedValueOnce({
ok: true,
json: async () => ({ data: { streamCount: 3 } }),
});

globalThis.fetch = fetchFn as unknown as typeof fetch;
const result = await indexer.query({ query: 'query { streamCount }' });

expect(result).toEqual({ streamCount: 3 });
expect(fetchFn).toHaveBeenCalledTimes(2);
const fallbackBody = JSON.parse(fetchFn.mock.calls[1][1].body);
expect(fallbackBody.query).toBe('query { streamCount }');
expect(fallbackBody.extensions).toBeDefined();
});

it('skips APQ when persist is false', async () => {
const fetchFn = vi.fn();
fetchFn.mockResolvedValueOnce({
ok: true,
json: async () => ({ data: { streamCount: 1 } }),
});

globalThis.fetch = fetchFn as unknown as typeof fetch;
await indexer.query({ query: 'query { streamCount }', persist: false });

const body = JSON.parse(fetchFn.mock.calls[0][1].body);
expect(body.query).toBe('query { streamCount }');
expect(body.extensions).toBeUndefined();
});
});