From 7c09c6bc7897d82d45def8daa014319ea24ee11a Mon Sep 17 00:00:00 2001 From: Atharva0506 Date: Mon, 13 Jul 2026 17:30:19 +0530 Subject: [PATCH 1/3] feat: add build tooling and project configuration Set up the TypeScript SDK build infrastructure, aligned with AOSSIE npm package standards (IndexedDB-Import-Export reference): - package.json: dual ESM/CJS, type:module, publishConfig for npm, bugs/homepage URLs, GPL-3.0 license, eslint + prettier deps - tsconfig.json: strict mode with noUncheckedIndexedAccess, noUnusedLocals, noUnusedParameters - tsup.config.ts: ESM + CJS output, sourcemaps, ES2020 target - eslint.config.js: flat config with typescript-eslint - .prettierrc: AOSSIE standard formatting rules - vitest.config.ts: test runner configuration --- .prettierrc | 8 ++++++ eslint.config.js | 21 ++++++++++++++++ package.json | 63 ++++++++++++++++++++++++++++++++++++++++++++++++ tsconfig.json | 31 ++++++++++++++++++++++++ tsup.config.ts | 12 +++++++++ vitest.config.ts | 10 ++++++++ 6 files changed, 145 insertions(+) create mode 100644 .prettierrc create mode 100644 eslint.config.js create mode 100644 package.json create mode 100644 tsconfig.json create mode 100644 tsup.config.ts create mode 100644 vitest.config.ts diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..ef2237c --- /dev/null +++ b/.prettierrc @@ -0,0 +1,8 @@ +{ + "semi": true, + "singleQuote": true, + "trailingComma": "all", + "printWidth": 100, + "tabWidth": 2, + "endOfLine": "lf" +} diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..ecb5174 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,21 @@ +import eslint from '@eslint/js'; +import tseslint from 'typescript-eslint'; + +export default tseslint.config( + eslint.configs.recommended, + ...tseslint.configs.recommended, + { + ignores: ['dist/', 'node_modules/', 'coverage/'], + }, + { + files: ['src/**/*.ts', 'tests/**/*.ts'], + rules: { + '@typescript-eslint/no-unused-vars': [ + 'error', + { argsIgnorePattern: '^_' }, + ], + '@typescript-eslint/explicit-function-return-type': 'off', + '@typescript-eslint/no-explicit-any': 'error', + }, + }, +); diff --git a/package.json b/package.json new file mode 100644 index 0000000..05e9efb --- /dev/null +++ b/package.json @@ -0,0 +1,63 @@ +{ + "name": "@aossie/thrubox-client", + "version": "0.1.0", + "description": "TypeScript SDK for ThruBox Server — a simple, self-hostable encrypted message relay", + "type": "module", + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsup", + "test": "vitest run", + "test:watch": "vitest", + "lint": "eslint src/ tests/", + "format": "prettier --write \"src/**/*.ts\" \"tests/**/*.ts\"", + "format:check": "prettier --check \"src/**/*.ts\" \"tests/**/*.ts\"", + "prepublishOnly": "npm run build" + }, + "keywords": [ + "relay", + "encrypted", + "messaging", + "thrubox", + "aossie", + "sdk", + "mailbox" + ], + "author": "AOSSIE", + "license": "GPL-3.0", + "repository": { + "type": "git", + "url": "git+https://github.com/AOSSIE-Org/ThruBox-Client.git" + }, + "bugs": { + "url": "https://github.com/AOSSIE-Org/ThruBox-Client/issues" + }, + "homepage": "https://github.com/AOSSIE-Org/ThruBox-Client#readme", + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "engines": { + "node": ">=18.0.0" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "eslint": "^10.5.0", + "prettier": "^3.5.0", + "tsup": "^8.4.0", + "typescript": "^5.7.0", + "typescript-eslint": "^8.61.1", + "vitest": "^3.2.0" + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..a4f2410 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,31 @@ +{ + "compilerOptions": { + /* Language & Environment */ + "target": "ES2020", + "lib": ["ES2020", "DOM"], + "module": "ESNext", + "moduleResolution": "bundler", + + /* Strict Type-Checking */ + "strict": true, + "noUncheckedIndexedAccess": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "exactOptionalPropertyTypes": false, + "forceConsistentCasingInFileNames": true, + + /* Emit */ + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "dist", + + /* Interop */ + "esModuleInterop": true, + "isolatedModules": true, + "skipLibCheck": true, + "resolveJsonModule": true + }, + "include": ["src"], + "exclude": ["node_modules", "dist", "tests"] +} diff --git a/tsup.config.ts b/tsup.config.ts new file mode 100644 index 0000000..9f8b20f --- /dev/null +++ b/tsup.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: ['src/index.ts'], + format: ['esm', 'cjs'], + dts: true, + clean: true, + sourcemap: true, + outDir: 'dist', + target: 'es2020', + splitting: false, +}); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..2fa77b3 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['tests/**/*.test.ts'], + coverage: { + reporter: ['text', 'lcov'], + }, + }, +}); From c58d70aeace9057821b6399975355bdd2b7a79b1 Mon Sep 17 00:00:00 2001 From: Atharva0506 Date: Mon, 13 Jul 2026 17:30:34 +0530 Subject: [PATCH 2/3] feat: add TypeScript SDK source code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the core SDK implementation — a zero-dependency TypeScript client for the ThruBox Server relay: - src/client.ts: RelayClient class with send, receive, delete, poll, health. Automatic retry with exponential backoff on 5xx/network errors. Configurable timeout via AbortController. - src/types.ts: Message, CreateMessageParams, ClientOptions, HealthResponse type definitions. - src/errors.ts: Typed error hierarchy (RelayError, NetworkError, HttpError, RateLimitError, PayloadTooLargeError, NotFoundError). - src/index.ts: Public API barrel export. --- src/client.ts | 262 ++++++++++++++++++++++++++++++++++++++++++++++++++ src/errors.ts | 69 +++++++++++++ src/index.ts | 21 ++++ src/types.ts | 58 +++++++++++ 4 files changed, 410 insertions(+) create mode 100644 src/client.ts create mode 100644 src/errors.ts create mode 100644 src/index.ts create mode 100644 src/types.ts diff --git a/src/client.ts b/src/client.ts new file mode 100644 index 0000000..b1feb42 --- /dev/null +++ b/src/client.ts @@ -0,0 +1,262 @@ +import type { + Message, + SendParams, + ClientOptions, + PollOptions, + HealthResponse, +} from "./types"; +import { + RelayError, + RelayNetworkError, + RelayRateLimitError, + RelayNotFoundError, + RelayPayloadTooLargeError, + RelayUnauthorizedError, +} from "./errors"; + +const DEFAULT_TIMEOUT = 10_000; // 10 seconds +const DEFAULT_RETRIES = 3; +const DEFAULT_POLL_INTERVAL = 10_000; // 10 seconds + +/** + * Client for interacting with an AOSSIE Relay Server. + * + * @example + * ```typescript + * const relay = new RelayClient('https://relay.aossie.org'); + * + * // Send an encrypted message + * await relay.send({ to: '0xRecipient', from: '0xSender', payload: encryptedData }); + * + * // Receive messages + * const messages = await relay.receive('0xMyWallet'); + * + * // Poll for new messages + * const stop = relay.poll('0xMyWallet', (msgs) => console.log(msgs)); + * // Later: stop(); + * ``` + */ +export class RelayClient { + private readonly baseUrl: string; + private readonly apiKey: string | undefined; + private readonly timeout: number; + private readonly retries: number; + + /** + * Create a new RelayClient pointing to any relay server. + * + * @param baseUrl - The URL of the relay server (e.g., 'https://relay.aossie.org') + * @param options - Optional configuration (API key, timeout, retries) + */ + constructor(baseUrl: string, options?: ClientOptions) { + // Strip trailing slash + this.baseUrl = baseUrl.replace(/\/+$/, ""); + this.apiKey = options?.apiKey; + this.timeout = options?.timeout ?? DEFAULT_TIMEOUT; + this.retries = options?.retries ?? DEFAULT_RETRIES; + } + + /** + * Send an encrypted message through the relay. + * + * @param params - The message to send (to, from, payload) + * @returns The stored message with server-generated ID and timestamps + * @throws {RelayPayloadTooLargeError} If the payload exceeds the server's max size + * @throws {RelayRateLimitError} If the rate limit is exceeded + * @throws {RelayUnauthorizedError} If an API key is required but missing/invalid + */ + async send(params: SendParams): Promise { + const response = await this.request("/api/messages", { + method: "POST", + body: JSON.stringify(params), + }); + return response; + } + + /** + * Fetch all messages addressed to a wallet address. + * + * @param address - The recipient wallet address to fetch messages for + * @returns An array of messages (empty array if none) + */ + async receive(address: string): Promise { + return this.request( + `/api/messages/${encodeURIComponent(address)}`, + ); + } + + /** + * Delete a specific message by its ID. + * + * @param id - The UUID of the message to delete + */ + async delete(id: string): Promise { + await this.request(`/api/messages/${encodeURIComponent(id)}`, { + method: "DELETE", + }); + } + + /** + * Check the health of the relay server. + * + * @returns Health status and server timestamp + */ + async health(): Promise { + return this.request("/health"); + } + + /** + * Poll the relay for new messages at a regular interval. + * Returns a stop function that cancels the polling. + * + * @param address - The wallet address to poll for + * @param callback - Called with new messages on each poll cycle + * @param options - Polling configuration (interval) + * @returns A function that stops polling when called + * + * @example + * ```typescript + * const stop = relay.poll('0xMyWallet', (messages) => { + * console.log('New messages:', messages); + * }, { intervalMs: 5000 }); + * + * // Later: stop polling + * stop(); + * ``` + */ + poll( + address: string, + callback: (messages: Message[]) => void, + options?: PollOptions, + ): () => void { + const intervalMs = options?.intervalMs ?? DEFAULT_POLL_INTERVAL; + let stopped = false; + + const doPoll = async (): Promise => { + if (stopped) return; + + try { + const messages = await this.receive(address); + if (!stopped) { + callback(messages); + } + } catch { + // Silently skip poll errors — next cycle will retry + } + }; + + // Run the first poll immediately + doPoll(); + + const intervalId = setInterval(doPoll, intervalMs); + + // Return stop function + return () => { + stopped = true; + clearInterval(intervalId); + }; + } + + /** + * Internal method that performs HTTP requests with retry logic. + * Handles error mapping, timeouts, and exponential backoff. + */ + private async request( + path: string, + init?: RequestInit, + ): Promise { + const url = `${this.baseUrl}${path}`; + + const headers: Record = { + "Content-Type": "application/json", + }; + + if (this.apiKey) { + headers["X-API-Key"] = this.apiKey; + } + + let lastError: Error | undefined; + + for (let attempt = 0; attempt <= this.retries; attempt++) { + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), this.timeout); + + const response = await fetch(url, { + ...init, + headers: { ...headers, ...init?.headers }, + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + // Handle success (2xx) + if (response.ok) { + // 204 No Content (e.g., DELETE) + if (response.status === 204) { + return undefined as T; + } + return (await response.json()) as T; + } + + // Handle specific error codes (no retry) + if (response.status === 401) { + throw new RelayUnauthorizedError(); + } + + if (response.status === 404) { + const text = await response.text(); + throw new RelayNotFoundError(text || "Not found"); + } + + if (response.status === 413) { + throw new RelayPayloadTooLargeError(); + } + + if (response.status === 429) { + const retryAfter = parseInt( + response.headers.get("Retry-After") || "60", + 10, + ); + throw new RelayRateLimitError(retryAfter); + } + + // 4xx errors (no retry) + if (response.status >= 400 && response.status < 500) { + const text = await response.text(); + throw new RelayError(text || `Request failed`, response.status); + } + + // 5xx errors — retry with backoff + lastError = new RelayError( + `Server error: ${response.status}`, + response.status, + ); + } catch (error) { + // If it's one of our typed errors, don't retry (except 5xx which are handled above) + if (error instanceof RelayError && error.statusCode !== 0 && error.statusCode < 500) { + throw error; + } + + // Network error or abort — wrap and potentially retry + if (error instanceof DOMException && error.name === "AbortError") { + lastError = new RelayNetworkError("Request timed out"); + } else if (error instanceof RelayError) { + lastError = error; + } else if (error instanceof Error) { + lastError = new RelayNetworkError(error.message); + } else { + lastError = new RelayNetworkError("Unknown error"); + } + } + + // Exponential backoff before retry (1s, 2s, 4s, ...) + if (attempt < this.retries) { + const delay = Math.pow(2, attempt) * 1000; + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } + + throw lastError ?? new RelayNetworkError("Request failed after retries"); + } +} diff --git a/src/errors.ts b/src/errors.ts new file mode 100644 index 0000000..411c28f --- /dev/null +++ b/src/errors.ts @@ -0,0 +1,69 @@ +/** + * Base error class for all relay SDK errors. + */ +export class RelayError extends Error { + /** HTTP status code from the relay server, if applicable */ + public readonly statusCode: number; + + constructor(message: string, statusCode: number) { + super(message); + this.name = "RelayError"; + this.statusCode = statusCode; + } +} + +/** + * Thrown when a network error occurs (e.g., fetch fails, DNS resolution fails). + * This error is NOT caused by an HTTP response — the request never completed. + */ +export class RelayNetworkError extends RelayError { + constructor(message: string) { + super(message, 0); + this.name = "RelayNetworkError"; + } +} + +/** + * Thrown when the relay returns 429 Too Many Requests. + * Includes the Retry-After value if provided by the server. + */ +export class RelayRateLimitError extends RelayError { + /** Number of seconds to wait before retrying, from the Retry-After header */ + public readonly retryAfter: number; + + constructor(retryAfter: number) { + super(`Rate limit exceeded. Retry after ${retryAfter} seconds.`, 429); + this.name = "RelayRateLimitError"; + this.retryAfter = retryAfter; + } +} + +/** + * Thrown when the relay returns 404 Not Found. + */ +export class RelayNotFoundError extends RelayError { + constructor(message: string) { + super(message, 404); + this.name = "RelayNotFoundError"; + } +} + +/** + * Thrown when the relay returns 413 Payload Too Large. + */ +export class RelayPayloadTooLargeError extends RelayError { + constructor() { + super("Payload too large", 413); + this.name = "RelayPayloadTooLargeError"; + } +} + +/** + * Thrown when the relay returns 401 Unauthorized (invalid or missing API key). + */ +export class RelayUnauthorizedError extends RelayError { + constructor() { + super("Unauthorized: invalid or missing API key", 401); + this.name = "RelayUnauthorizedError"; + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..db23048 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,21 @@ +// AOSSIE Relay SDK +// A zero-dependency TypeScript client for any AOSSIE Relay Server. + +export { RelayClient } from "./client"; + +export type { + Message, + SendParams, + ClientOptions, + PollOptions, + HealthResponse, +} from "./types"; + +export { + RelayError, + RelayNetworkError, + RelayRateLimitError, + RelayNotFoundError, + RelayPayloadTooLargeError, + RelayUnauthorizedError, +} from "./errors"; diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..eb48930 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,58 @@ +/** + * Represents an encrypted message stored in the relay. + * The payload is opaque to the relay — all encryption/decryption happens client-side. + */ +export interface Message { + /** UUID, auto-generated by the relay server */ + id: string; + /** Recipient wallet address */ + to: string; + /** Sender wallet address */ + from: string; + /** Encrypted data (base64 string, opaque to server) */ + payload: string; + /** ISO 8601 timestamp when message was stored */ + createdAt: string; + /** ISO 8601 timestamp when message will be auto-purged, or null if stored forever */ + expiresAt: string | null; +} + +/** + * Parameters for sending a message via the relay. + */ +export interface SendParams { + /** Recipient wallet address */ + to: string; + /** Sender wallet address */ + from: string; + /** Encrypted payload (base64 string) */ + payload: string; +} + +/** + * Configuration options for the RelayClient. + */ +export interface ClientOptions { + /** API key for authenticated relay servers. Sent as X-API-Key header. */ + apiKey?: string; + /** Request timeout in milliseconds. Default: 10000 (10 seconds) */ + timeout?: number; + /** Number of retry attempts on network errors / 5xx responses. Default: 3 */ + retries?: number; +} + +/** + * Options for the poll() method. + */ +export interface PollOptions { + /** Polling interval in milliseconds. Default: 10000 (10 seconds) */ + intervalMs?: number; +} + +/** + * Health check response from the relay server. + */ +export interface HealthResponse { + status: string; + timestamp: string; +} From 6cda706094f30a7cc66a9933c0ea0f8dad8e98dd Mon Sep 17 00:00:00 2001 From: Atharva0506 Date: Mon, 13 Jul 2026 17:47:54 +0530 Subject: [PATCH 3/3] fix(sdk): address CodeRabbit review feedback - client.ts: Validated and normalized baseUrl using URL parser - client.ts: Rejected invalid timeout, retry, and polling values - client.ts: Added onError callback to PollOptions to prevent swallowing errors - client.ts: Used recursive setTimeout for polling to prevent overlapping requests - client.ts: Fixed headers to only send application/json for POST/PUT/PATCH - client.ts: Disabled automatic retries for non-idempotent POST requests - client.ts: Wrapped fetch in try/finally to ensure timeout is cleared during body consumption - client.ts: Added proper HTTP-date parsing for Retry-After headers - vitest.config.ts / package.json: Added @vitest/coverage-v8 provider and coverage script --- package.json | 4 +- src/client.ts | 151 +++++++++++++++++++++++++++++------------------ src/types.ts | 2 + vitest.config.ts | 1 + 4 files changed, 101 insertions(+), 57 deletions(-) diff --git a/package.json b/package.json index 05e9efb..683b236 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "build": "tsup", "test": "vitest run", "test:watch": "vitest", + "coverage": "vitest run --coverage", "lint": "eslint src/ tests/", "format": "prettier --write \"src/**/*.ts\" \"tests/**/*.ts\"", "format:check": "prettier --check \"src/**/*.ts\" \"tests/**/*.ts\"", @@ -58,6 +59,7 @@ "tsup": "^8.4.0", "typescript": "^5.7.0", "typescript-eslint": "^8.61.1", - "vitest": "^3.2.0" + "vitest": "^3.2.0", + "@vitest/coverage-v8": "^3.2.0" } } diff --git a/src/client.ts b/src/client.ts index b1feb42..e1ea6fc 100644 --- a/src/client.ts +++ b/src/client.ts @@ -49,11 +49,26 @@ export class RelayClient { * @param options - Optional configuration (API key, timeout, retries) */ constructor(baseUrl: string, options?: ClientOptions) { - // Strip trailing slash - this.baseUrl = baseUrl.replace(/\/+$/, ""); + try { + const parsedUrl = new URL(baseUrl); + if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") { + throw new Error("Invalid base URL protocol. Must be http: or https:"); + } + this.baseUrl = parsedUrl.toString().replace(/\/+$/, ""); + } catch (err) { + throw new Error(`Invalid base URL: ${err instanceof Error ? err.message : String(err)}`); + } + this.apiKey = options?.apiKey; this.timeout = options?.timeout ?? DEFAULT_TIMEOUT; this.retries = options?.retries ?? DEFAULT_RETRIES; + + if (this.timeout <= 0 || !Number.isFinite(this.timeout)) { + throw new Error("Timeout must be a positive finite number"); + } + if (this.retries < 0 || !Number.isInteger(this.retries)) { + throw new Error("Retries must be a non-negative integer"); + } } /** @@ -130,30 +145,41 @@ export class RelayClient { options?: PollOptions, ): () => void { const intervalMs = options?.intervalMs ?? DEFAULT_POLL_INTERVAL; + if (intervalMs <= 0 || !Number.isFinite(intervalMs)) { + throw new Error("Polling interval must be a positive finite number"); + } + let stopped = false; + let timeoutId: ReturnType | undefined; const doPoll = async (): Promise => { if (stopped) return; + let messages: Message[] | undefined; try { - const messages = await this.receive(address); - if (!stopped) { - callback(messages); + messages = await this.receive(address); + } catch (err) { + if (!stopped && options?.onError) { + options.onError(err instanceof Error ? err : new Error(String(err))); } - } catch { - // Silently skip poll errors — next cycle will retry + } + + if (!stopped && messages) { + callback(messages); + } + + if (!stopped) { + timeoutId = setTimeout(doPoll, intervalMs); } }; // Run the first poll immediately doPoll(); - const intervalId = setInterval(doPoll, intervalMs); - // Return stop function return () => { stopped = true; - clearInterval(intervalId); + if (timeoutId) clearTimeout(timeoutId); }; } @@ -167,71 +193,84 @@ export class RelayClient { ): Promise { const url = `${this.baseUrl}${path}`; - const headers: Record = { - "Content-Type": "application/json", - }; + const headers: Record = {}; + if (init?.method === "POST" || init?.method === "PUT" || init?.method === "PATCH") { + headers["Content-Type"] = "application/json"; + } if (this.apiKey) { headers["X-API-Key"] = this.apiKey; } + const maxRetries = init?.method === "POST" ? 0 : this.retries; let lastError: Error | undefined; - for (let attempt = 0; attempt <= this.retries; attempt++) { + for (let attempt = 0; attempt <= maxRetries; attempt++) { try { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), this.timeout); - const response = await fetch(url, { - ...init, - headers: { ...headers, ...init?.headers }, - signal: controller.signal, - }); + try { + const response = await fetch(url, { + ...init, + headers: { ...headers, ...init?.headers }, + signal: controller.signal, + }); - clearTimeout(timeoutId); + // Handle success (2xx) + if (response.ok) { + // 204 No Content (e.g., DELETE) + if (response.status === 204) { + return undefined as T; + } + return (await response.json()) as T; + } - // Handle success (2xx) - if (response.ok) { - // 204 No Content (e.g., DELETE) - if (response.status === 204) { - return undefined as T; + // Handle specific error codes (no retry) + if (response.status === 401) { + throw new RelayUnauthorizedError(); } - return (await response.json()) as T; - } - // Handle specific error codes (no retry) - if (response.status === 401) { - throw new RelayUnauthorizedError(); - } + if (response.status === 404) { + const text = await response.text(); + throw new RelayNotFoundError(text || "Not found"); + } - if (response.status === 404) { - const text = await response.text(); - throw new RelayNotFoundError(text || "Not found"); - } + if (response.status === 413) { + throw new RelayPayloadTooLargeError(); + } - if (response.status === 413) { - throw new RelayPayloadTooLargeError(); - } + if (response.status === 429) { + const retryAfterHeader = response.headers.get("Retry-After"); + let retryAfter = 60; + if (retryAfterHeader) { + const parsedInt = parseInt(retryAfterHeader, 10); + if (!isNaN(parsedInt)) { + retryAfter = parsedInt; + } else { + const date = new Date(retryAfterHeader).getTime(); + if (!isNaN(date)) { + retryAfter = Math.max(0, Math.ceil((date - Date.now()) / 1000)); + } + } + } + throw new RelayRateLimitError(retryAfter); + } - if (response.status === 429) { - const retryAfter = parseInt( - response.headers.get("Retry-After") || "60", - 10, - ); - throw new RelayRateLimitError(retryAfter); - } + // 4xx errors (no retry) + if (response.status >= 400 && response.status < 500) { + const text = await response.text(); + throw new RelayError(text || `Request failed`, response.status); + } - // 4xx errors (no retry) - if (response.status >= 400 && response.status < 500) { - const text = await response.text(); - throw new RelayError(text || `Request failed`, response.status); + // 5xx errors — retry with backoff + lastError = new RelayError( + `Server error: ${response.status}`, + response.status, + ); + } finally { + clearTimeout(timeoutId); } - - // 5xx errors — retry with backoff - lastError = new RelayError( - `Server error: ${response.status}`, - response.status, - ); } catch (error) { // If it's one of our typed errors, don't retry (except 5xx which are handled above) if (error instanceof RelayError && error.statusCode !== 0 && error.statusCode < 500) { @@ -251,7 +290,7 @@ export class RelayClient { } // Exponential backoff before retry (1s, 2s, 4s, ...) - if (attempt < this.retries) { + if (attempt < maxRetries) { const delay = Math.pow(2, attempt) * 1000; await new Promise((resolve) => setTimeout(resolve, delay)); } diff --git a/src/types.ts b/src/types.ts index eb48930..6b84bb1 100644 --- a/src/types.ts +++ b/src/types.ts @@ -47,6 +47,8 @@ export interface ClientOptions { export interface PollOptions { /** Polling interval in milliseconds. Default: 10000 (10 seconds) */ intervalMs?: number; + /** Optional callback for handling polling or network errors */ + onError?: (error: Error) => void; } /** diff --git a/vitest.config.ts b/vitest.config.ts index 2fa77b3..e2ef99f 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -4,6 +4,7 @@ export default defineConfig({ test: { include: ['tests/**/*.test.ts'], coverage: { + provider: 'v8', reporter: ['text', 'lcov'], }, },