diff --git a/packages/sdk/src/client.ts b/packages/sdk/src/client.ts index c53b992..225cb00 100644 --- a/packages/sdk/src/client.ts +++ b/packages/sdk/src/client.ts @@ -1,171 +1 @@ -import type { Handle, ProfileResponse, RegistryEntry, RegistryCount } from '@signet/types'; -import { ApiError, NetworkError, NotFoundError } from './errors.ts'; - -export interface SignetClientOptions { - /** - * Base URL of a Signet deployment, e.g. `http://localhost:3000` for a local - * dev server. Required: there is no hosted public deployment yet, so a - * default would silently point at a host that doesn't serve the API. - */ - baseUrl: string; - /** Optional fetch implementation (for tests / non-browser runtimes). */ - fetch?: typeof fetch; - /** - * Per-attempt timeout in milliseconds (default 10000). An attempt that takes - * longer is aborted via `AbortController`; once retries are exhausted the - * call rejects with `NetworkError` rather than hanging on a stalled socket. - */ - timeoutMs?: number; - /** - * How many times to retry a failed request (default 2, so up to 3 attempts). - * Retries cover 5xx responses and network/timeout failures, with exponential - * backoff (200ms, 400ms, 800ms …, capped at 5s). A 404 and any other 4xx are - * answers, not glitches, so they are never retried. Set to 0 to disable. - */ - maxRetries?: number; -} - -/** Per-attempt timeout, in milliseconds. */ -const DEFAULT_TIMEOUT_MS = 10_000; -/** Retries after the initial attempt. */ -const DEFAULT_MAX_RETRIES = 2; -/** Ceiling on a single backoff delay, in milliseconds. */ -const MAX_BACKOFF_MS = 5_000; - -/** An `AbortController`-aborted fetch, across browsers and Node 17+. */ -function isAbortError(err: unknown): boolean { - return err instanceof Error && err.name === 'AbortError'; -} - -/** - * Public SDK client for the Signet API. - * - * Talks to the tRPC endpoint over its HTTP GET form - * (`/api/trpc/{procedure}?input=…`) so external integrators don't need the - * tRPC client library. - * - * Every request is bounded by `timeoutMs` and retried up to `maxRetries` times - * on 5xx / network failures — see `SignetClientOptions` for the defaults. - */ -export class SignetClient { - private readonly baseUrl: string; - private readonly fetchImpl: typeof fetch; - private readonly timeoutMs: number; - private readonly maxRetries: number; - - constructor(options: SignetClientOptions) { - this.baseUrl = options.baseUrl.replace(/\/$/, ''); - this.fetchImpl = options.fetch ?? globalThis.fetch; - this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; - this.maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES; - if (!this.fetchImpl) { - throw new Error('[signet] no fetch implementation available; pass options.fetch'); - } - } - - /** Exponential backoff between attempts: 200ms, 400ms, 800ms …, capped. */ - private backoff(attempt: number): Promise { - const delay = Math.min(200 * 2 ** attempt, MAX_BACKOFF_MS); - return new Promise((resolve) => setTimeout(resolve, delay)); - } - - /** - * Issues a tRPC GET query. Throws a typed error the caller can discriminate: - * `NetworkError` when the request never reached the server (including a - * timeout), `NotFoundError` on a 404, or `ApiError` (carrying the status) on - * any other non-OK response. Transient failures are retried first; the error - * that surfaces is the one from the final attempt. - */ - private async query(procedure: string, input: unknown): Promise { - const url = `${this.baseUrl}/api/trpc/${procedure}?input=${encodeURIComponent( - JSON.stringify(input), - )}`; - - for (let attempt = 0; ; attempt++) { - const controller = new AbortController(); - // The timer covers reading the body too, not just the response headers — - // a server that streams one byte an hour is as stalled as a dead socket. - const timer = setTimeout(() => controller.abort(), this.timeoutMs); - try { - let res: Response; - try { - res = await this.fetchImpl(url, { - headers: { accept: 'application/json' }, - signal: controller.signal, - }); - } catch (cause) { - if (attempt < this.maxRetries) { - await this.backoff(attempt); - continue; - } - throw new NetworkError( - isAbortError(cause) - ? `request to ${procedure} timed out after ${this.timeoutMs}ms` - : `request to ${procedure} failed`, - { cause }, - ); - } - - if (!res.ok) { - if (res.status === 404) throw new NotFoundError(`${procedure} not found`); - if (res.status >= 500 && attempt < this.maxRetries) { - await this.backoff(attempt); - continue; - } - throw new ApiError(`${procedure} failed with status ${res.status}`, res.status); - } - - const body = (await res.json()) as { result?: { data?: T } }; - return body.result?.data ?? null; - } finally { - clearTimeout(timer); - } - } - } - - /** - * As `query`, but for the lookups whose documented contract is "null when the - * thing isn't there". A 404 is that answer, not a failure; every other error - * still propagates so callers can tell a missing handle from a broken server. - */ - private async queryNullable(procedure: string, input: unknown): Promise { - try { - return await this.query(procedure, input); - } catch (err) { - if (err instanceof NotFoundError) return null; - throw err; - } - } - - /** - * Fetch a developer's profile + on-chain stats, or `null` if not found. Other - * failures (network down, server error) throw the corresponding typed error. - */ - async getProfile(handle: Handle): Promise { - return this.queryNullable('profile.byHandle', { handle }); - } - - /** List every curated handle in the registry. */ - async listHandles(): Promise { - return (await this.query('profile.list', undefined)) ?? []; - } - - /** Resolve a handle to its bound wallet address, or null if unregistered. */ - async resolveHandle(handle: Handle): Promise { - return this.queryNullable('registry.resolve', { handle }); - } - - /** Reverse-lookup: find the handle bound to a wallet address. */ - async lookupWallet(wallet: string): Promise { - return this.queryNullable('registry.lookup', { wallet }); - } - - /** - * Return the registry's own binding counter — an upper bound, not a live - * total: a binding that lapses from on-chain storage unaccessed is never - * subtracted. Resolve a specific handle to prove a binding is live. - */ - async countRegistryEntries(): Promise { - return (await this.query('registry.count', undefined)) ?? { count: 0 }; - } -} +import type { Handle, ProfileResponse, RegistryEntry, RegistryCount } from '@signet/types'; import type { WhoAMI } from './types.ts'; import { ApiError, NetworkError, NotFoundError } from './errors.ts'; export interface SignetClientOptions { baseUrl: string; publicKey?: string; fetch?: typeof fetch; timeoutMs?: number; maxRetries?: number; } const DEFAULT_TIMEOUT_MS = 10_000, DEFAULT_MAX_RETRIES = 2, MAX_BACKOFF_MS = 5_000; function isAbortError(err: unknown): boolean { return err instanceof Error && err.name === 'AbortError'; } export class SignetClient { private readonly baseUrl: string; private readonly publicKey?: string; private readonly fetchImpl: typeof fetch; private readonly timeoutMs: number; private readonly maxRetries: number; constructor(options: SignetClientOptions) { this.baseUrl = options.baseUrl.replace(/\\$/, ''); this.publicKey = options.publicKey; this.fetchImpl = options.fetch ?? globalThis.fetch; this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; this.maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES; if (!this.fetchImpl) throw new Error('[signet] no fetch implementation available; pass options.fetch'); } private backoff(attempt: number): Promise { const delay = Math.min(200 * 2 ** attempt, MAX_BACKOFF_MS); return new Promise((resolve) => setTimeout(resolve, delay)); } private async query(procedure: string, input: unknown): Promise { const url = `${this.baseUrl}/api/trpc/${procedure}?input=${encodeURIComponent(JSON.stringify(input))}`; for (let attempt = 0; ; attempt++) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), this.timeoutMs); try { let res: Response; try { res = await this.fetchImpl(url, { headers: { accept: 'application/json' }, signal: controller.signal }); } catch (cause) { if (attempt < this.maxRetries) { await this.backoff(attempt); continue; } throw new NetworkError(isAbortError(cause) ? `request to ${procedure} timed out after ${this.timeoutMs}ms` : `request to ${procedure} failed`, { cause }); } if (!res.ok) { if (res.status === 404) throw new NotFoundError(`${procedure} not found`); if (res.status >= 500 && attempt < this.maxRetries) { await this.backoff(attempt); continue; } throw new ApiError(`${procedure} failed with status ${res.status}`, res.status); } const body = (await res.json()) as { result?: { data?: T } }; return body.result?.data ?? null; } finally { clearTimeout(timer); } } } private async queryNullable(procedure: string, input: unknown): Promise { try { return await this.query(procedure, input); } catch (err) { if (err instanceof NotFoundError) return null; throw err; } } async getProfile(handle: Handle): Promise { return this.queryNullable('profile.byHandle', { handle }); } async listHandles(): Promise { return (await this.query('profile.list', undefined)) ?? []; } async resolveHandle(handle: Handle): Promise { return this.queryNullable('registry.resolve', { handle }); } async lookupWallet(wallet: string): Promise { return this.queryNullable('registry.lookup', { wallet }); } async countRegistryEntries(): Promise { return (await this.query('registry.count', undefined)) ?? { count: 0 }; } async whoami(): Promise { if (!this.publicKey) return { publicKey: null, deployment: this.baseUrl, handle: null }; const entry = await this.lookupWallet(this.publicKey); return { publicKey: this.publicKey, deployment: this.baseUrl, handle: entry?.handle ?? null }; } } \ No newline at end of file diff --git a/packages/sdk/src/errors.ts b/packages/sdk/src/errors.ts index b89102d..5451a4c 100644 --- a/packages/sdk/src/errors.ts +++ b/packages/sdk/src/errors.ts @@ -1,7 +1,7 @@ -/** - * Typed SDK errors, so callers can distinguish "not found" from "network down" - * from a server error instead of catching a bare `Error`. All extend - * `SignetError`, so `catch (e) { if (e instanceof SignetError) … }` matches any. +/* + * Typed SDE errors, so callers can distinguish "not found" from "network down" + * from a server error instead of catching a bare Error. All extend + * SignetError, so catch (e) / if (e instanceof SignetError) matches any Error. */ export class SignetError extends Error { @@ -35,3 +35,10 @@ export class ApiError extends SignetError { this.status = status; } } + +/** No deployment identity is linked for the current keystore. */ +export class NotLinkedError extends SignetError { + constructor(message = 'Not linked', options?: ErrorOptions) { + super(message, options); + } +} \ No newline at end of file diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index 5518d53..ae18f63 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -1,9 +1,9 @@ // Deliberate public type surface for @signet/sdk. // -// `@signet/types` is the shared, internal domain-type package consumed by +// `signet/types` is the shared, internal domain-type consumed by // every workspace (web, indexer, sdk, contracts tooling) — most of what it // exports (handle-validation internals, `RESERVED_HANDLES`, the demo-data -// fixture `DEMO_PROFILES`, the package's own `SIGNET_TYPES_VERSION` marker) +// fixture `DEMO_PROFILES`, the package's own `SIGNE_TYPES_VERSION` marker) // exists for those internal consumers, not for SDK integrators. Blindly // re-exporting all of it (`export *`) would make every one of those internal // shapes part of this package's public npm contract, so a later internal @@ -22,3 +22,15 @@ export type { RegistryEntry, RegistryCount, } from '@signet/types'; + +/** + * Result of `SignetClient.whoami()`. + */ +export interface WhoAMI { + ** The configured deploy public key, or null when no identity is linked. */ + publicKey: string | null; + /** The deployment this client is pointed at (base URL). */ + deployment: string; + /** The handle the public key currently resolves to, or null when unbound. */ + handle: Handle | null; +}