diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0aa6b70..892fc31 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,5 +19,5 @@ jobs: node-version: ${{ matrix.node-version }} cache: pnpm - run: pnpm install --frozen-lockfile - - run: pnpm build - - run: pnpm test + - run: pnpm -r build + - run: pnpm -r test \ No newline at end of file diff --git a/.npmignore b/.npmignore index 4579979..b4c0077 100644 --- a/.npmignore +++ b/.npmignore @@ -4,7 +4,7 @@ yarn.lock package-lock.json tsconfig.json vitest.config.*.ts -vitest.config.ts +vite.config.ts # Ignore source maps if you don’t want to publish them *.map diff --git a/CHANGELOG.md b/CHANGELOG.md index 37ec1c9..460ac57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,24 +1,124 @@ # Changelog -All notable changes to this project will be documented here. +All notable changes to the **@asouei/safe-fetch** ecosystem will be documented here. We follow [Semantic Versioning](https://semver.org/). -## [0.1.0] - 2025-09-01 -### Added -- First public release of **@asouei/safe-fetch** -- Core features: - - Safe result API (`{ ok: true | false }`) - - Normalized errors (`NetworkError`, `TimeoutError`, `HttpError`, `ValidationError`) - - Per-attempt and total timeouts - - Smart retries with exponential backoff - - Support for `Retry-After` header - - Request/response interceptors - - Validation hook (Zod-ready) - - Zero dependencies, ~3kb size +## [September 2025] - Monorepo & React Query Adapter + +### @asouei/safe-fetch@1.0.0 +**First stable release** +**Minor release - No breaking changes** + +#### Changed +- **Monorepo structure**: Moved to monorepo workspace for better ecosystem management +- **Build system**: Updated to Vite + Vitest for improved development experience +- **CI/CD**: Enhanced GitHub Actions workflow for multiple packages + +#### Internal +- Refactored test suite for monorepo compatibility +- Updated package.json exports for better ESM/CJS compatibility +- Improved TypeScript configuration across packages + +#### Documentation +- Updated README with monorepo structure +- Added ecosystem overview with package comparison table +- Enhanced examples for framework integrations + +### @asouei/safe-fetch-react-query@0.1.0 +**New package - Experimental release** + +#### Added +- **Core adapter functions:** + - `createQueryFn(api)` - Factory for React Query compatible query functions + - `createMutationFn(api)` - Factory for React Query compatible mutation functions + - `rqDefaults()` - Returns `{ retry: false }` for optimal safe-fetch integration + - `unwrap(promise)` - Utility to convert safe results to throws + +- **Features:** + - Seamless integration between safe-fetch and TanStack React Query v5.x + - Preserves safe-fetch's typed error system in React Query error states + - Zero additional dependencies beyond peer requirements + - Full TypeScript support with type inference + +- **Documentation:** + - Complete README with usage examples and best practices + - Troubleshooting guide for common TypeScript discriminated union issues + - Migration examples from direct safe-fetch usage + +--- + +## [September 2025] - Foundation Release + +### @asouei/safe-fetch@0.1.0 +**Initial release** + +#### Added +- **Core safe result API**: `{ ok: true | false }` discriminated unions +- **Normalized error types**: `NetworkError | TimeoutError | HttpError | ValidationError` +- **Smart timeout system**: Per-attempt (`timeoutMs`) + total operation (`totalTimeoutMs`) +- **Intelligent retries**: Exponential backoff with jitter, `Retry-After` header support +- **Request/response interceptors**: `onRequest`, `onResponse`, `onError` hooks +- **Validation integration**: Zod-ready validation without exceptions +- **Zero dependencies**: Tree-shakable, ~3kb bundle size + +#### Features +- HTTP method shortcuts: `get`, `post`, `put`, `patch`, `delete` +- Multiple parsing options: `json`, `text`, `blob`, `arrayBuffer`, `response` +- AbortController support for request cancellation +- Automatic JSON handling with graceful fallbacks +- Configurable base URLs, headers, and query parameters +- Error mapping for domain-specific error transformation + +#### Browser & Runtime Support +- Node.js 18+ (built-in fetch) +- All modern browsers (Chrome 63+, Firefox 57+, Safari 10.1+) +- Cloudflare Workers, Vercel Edge Runtime +- SSR frameworks: Next.js, Nuxt, SvelteKit --- ## Future Roadmap -- React Query / SWR integration helpers -- ESLint plugin for enforcing `{ ok }` checks -- Framework examples (Next.js, Cloudflare Workers, Remix) \ No newline at end of file + +### Planned Releases + +#### @asouei/safe-fetch@1.2.0 +- Performance optimizations for high-frequency requests +- Enhanced error context and stack traces +- Optional request/response logging utilities + +#### @asouei/safe-fetch-react-query@1.0.0 +- Stable API after community feedback +- Custom hooks: `useSafeQuery`, `useSafeMutation` +- React Query DevTools integration + +#### New Packages (TBD) +- **@asouei/safe-fetch-swr** - SWR integration adapter +- **@asouei/eslint-plugin-safe-fetch** - ESLint rules for safe result patterns +- **@asouei/safe-fetch-openapi** - OpenAPI schema integration + +### Community & Ecosystem +- Framework examples repository (Next.js, Remix, Cloudflare Workers) +- VS Code extension for safe-fetch snippets +- Integration guides for popular libraries (Apollo, Relay, etc.) + +--- + +## Contributing + +We welcome contributions to any package in the ecosystem! Please see our [Contributing Guide](CONTRIBUTING.md) for details on: + +- Development setup for monorepo +- Package-specific contribution guidelines +- Release process and versioning strategy +- Testing requirements across packages + +## Migration Guides + +### From @asouei/safe-fetch@1.0.x to 1.1.x +**No breaking changes** - this is a pure infrastructure update: +- All APIs remain identical +- Zero code changes required +- Package exports enhanced for better compatibility + +### From Direct safe-fetch to React Query Adapter +See [@asouei/safe-fetch-react-query README](packages/react-query/README.md#migration-from-direct-safe-fetch) for detailed migration examples. \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ceff4ee..2a33f6c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -16,7 +16,7 @@ Thanks for your interest in contributing! We welcome issues, discussions, and pu ``` 4. **Run tests** to make sure everything works: ```bash - pnpm test + pnpm -r test ``` ## Development Workflow @@ -36,15 +36,11 @@ Thanks for your interest in contributing! We welcome issues, discussions, and pu ### Before Submitting 1. **Run the test suite**: ```bash - pnpm test + pnpm -r test ``` 2. **Check TypeScript compilation**: ```bash - pnpm build - ``` -3. **Ensure code formatting**: - ```bash - pnpm format + pnpm -r build ``` ## Submitting Changes @@ -56,7 +52,7 @@ For significant changes, please [open an issue](../../issues/new) first to discu - API modifications ### Pull Request Process -1. **Create a feature branch** from `main`: +1. **Create a feature branch** from `master`: ```bash git checkout -b feature/your-feature-name ``` @@ -76,20 +72,42 @@ For significant changes, please [open an issue](../../issues/new) first to discu ### CI Requirements All pull requests must pass: -- ✅ TypeScript compilation (`pnpm build`) -- ✅ Test suite (`pnpm test`) -- ✅ Linting (`pnpm lint`) +- ✅ TypeScript compilation (`pnpm -r build`) +- ✅ Test suite (`pnpm -r test`) +- ✅ Linting (`pnpm -r lint`) - ✅ No conflicting dependencies -## Project Structure +## Project Structure (Monorepo) + +``` +packages/ +├── core/ # @asouei/safe-fetch +│ ├── src/ +│ │ ├── index.ts # Main export and createSafeFetch +│ │ ├── types.ts # TypeScript definitions +│ │ ├── errors.ts # Error constructors and utilities +│ │ └── type-guards.ts # Helper functions +│ ├── tests/ # Test files +│ └── package.json +└── react-query/ # @asouei/safe-fetch-react-query + ├── src/ + └── package.json +``` + +## Working on Core Library +```bash +cd packages/core +pnpm build +pnpm test ``` -src/ -├── index.ts # Main export and createSafeFetch -├── types.ts # TypeScript definitions -├── errors.ts # Error constructors and utilities -├── utils.ts # Helper functions -└── __tests__/ # Test files + +## Working on React Query Adapter + +```bash +cd packages/react-query +pnpm build +pnpm test ``` ## Common Contribution Areas diff --git a/README.md b/README.md index 6d468b0..bd21c8d 100644 --- a/README.md +++ b/README.md @@ -3,17 +3,32 @@ [![npm version](https://img.shields.io/npm/v/@asouei/safe-fetch.svg)](https://www.npmjs.com/package/@asouei/safe-fetch) [![CI](https://github.com/asouei/safe-fetch/actions/workflows/ci.yml/badge.svg?branch=master)](https://github.com/asouei/safe-fetch/actions/workflows/ci.yml) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) -[![npm](https://img.shields.io/npm/dm/@asouei/safe-fetch)](https://www.npmjs.com/package/@asouei/safe-fetch) +[![npm downloads](https://img.shields.io/npm/dm/@asouei/safe-fetch)](https://www.npmjs.com/package/@asouei/safe-fetch) +[![TypeScript](https://img.shields.io/badge/TypeScript-Ready-blue.svg)](https://www.typescriptlang.org/) +[![Bundle Size](https://img.shields.io/bundlephobia/minzip/@asouei/safe-fetch)](https://bundlephobia.com/package/@asouei/safe-fetch) +[![Zero Dependencies](https://img.shields.io/badge/dependencies-0-green.svg)](package.json) [![Open in CodeSandbox](https://img.shields.io/badge/Open%20in-CodeSandbox-black?logo=codesandbox)](https://codesandbox.io/p/sandbox/fkw3z5) [![Awesome](https://awesome.re/badge-flat2.svg)](https://github.com/dzharii/awesome-typescript) + *English version | [Русская версия](README.ru.md)* -> **Never write `try/catch` for HTTP requests again.** Zero-deps • No throws • Total timeout • Retry-After support +> **Never write `try/catch` for HTTP requests again.** A complete ecosystem of type-safe HTTP utilities built around safe results and predictable error handling. + +Modern HTTP client ecosystem that eliminates exceptions through discriminated unions, provides intelligent retries, handles timeouts properly, and integrates seamlessly with popular data fetching libraries. + +## 📦 Packages + +| Package | Version | Description | +|---------|---------|-------------| +| **[@asouei/safe-fetch](packages/core)** | [![npm](https://img.shields.io/npm/v/@asouei/safe-fetch.svg)](https://npmjs.com/package/@asouei/safe-fetch) | Core HTTP client with safe results, retries, and timeouts | +| **[@asouei/safe-fetch-react-query](packages/react-query)** | [![npm](https://img.shields.io/npm/v/@asouei/safe-fetch-react-query.svg)](https://npmjs.com/package/@asouei/safe-fetch-react-query) | TanStack Query integration with optimized error handling | -Tiny, typed wrapper around `fetch` that returns safe results, handles timeouts intelligently, and retries with exponential backoff. +## 🚀 Quick Start -📌 Featured in [Awesome TypeScript](https://github.com/dzharii/awesome-typescript). +```bash +npm install @asouei/safe-fetch +``` ```typescript import { safeFetch } from '@asouei/safe-fetch'; @@ -23,110 +38,41 @@ if (result.ok) { // TypeScript knows result.data is { users: User[] } console.log(result.data.users); } else { - // All errors are normalized - no more guessing what went wrong - console.error(result.error.name); // 'NetworkError' | 'TimeoutError' | 'HttpError' | 'ValidationError' + // All errors are normalized and typed + console.error(`${result.error.name}: ${result.error.message}`); } ``` -## What You Get - -- **No throws:** Never write `try/catch` — always get a safe result -- **Typed errors:** `NetworkError | TimeoutError | HttpError | ValidationError` -- **Dual timeouts:** `timeoutMs` per attempt + `totalTimeoutMs` for entire operation -- **Smarter retries:** Only idempotent methods by default + `Retry-After` support -- **Zod-ready validation:** Schema validation without exceptions -- **Zero deps & ~3kb:** Bundle-friendly, tree-shakable, side-effects free - -| Feature | `@asouei/safe-fetch` | `axios` | `ky` | native `fetch` | -|---------|---------------------|---------|------|---------------| -| **Bundle size** | ~3kb | ~13kb* | ~11kb* | 0kb | -| **Dependencies** | 0 | 0* | 0* | 0 | -| **Safe results (no throws)** | ✅ | ❌ | ❌ | ❌ | -| **Discriminated union types** | ✅ | ❌ | ❌ | ❌ | -| **Per-attempt + total timeouts** | ✅ | Per-request only | Per-request only | Manual | -| **Smart retries (idempotent-only)** | ✅ | ✅ (throws) | ✅ (throws) | Manual | -| **Retry-After header support** | ✅ | ❌ | ❌ | Manual | -| **Request/Response interceptors** | ✅ | ✅ | ✅ | Manual | -| **Validation hooks (Zod-ready)** | ✅ | ❌ | ❌ | Manual | -| **TypeScript-first design** | ✅ | Partial | ✅ | ✅ | - -*Bundle size ~gzip; depends on version, environment and bundler settings. -**Axios/Ky throw exceptions on non-2xx by default; no built-in total operation timeout. - -## Installation - -```bash -npm install @asouei/safe-fetch -``` - -### Import styles - -**ESM** -```typescript -import { safeFetch, createSafeFetch } from '@asouei/safe-fetch'; -``` - -**CommonJS** -```javascript -const { safeFetch, createSafeFetch } = require('@asouei/safe-fetch'); -// CommonJS supported via exports.require field -``` - -**CDN (esm.run)** -```html - -``` - -## Quick Demo - -```typescript -type Todo = { id: number; title: string; completed: boolean }; - -const api = createSafeFetch({ - baseURL: 'https://jsonplaceholder.typicode.com', - timeoutMs: 3000, - totalTimeoutMs: 7000, - retries: { retries: 2 }, -}); - -const list = await api.get('/todos', { query: { _limit: 3 } }); -if (list.ok) console.log('todos:', list.data.map(t => t.title)); +## ✨ Why safe-fetch? -const create = await api.post('/todos', { title: 'Learn safe-fetch', completed: false }); -if (!create.ok) console.warn('create failed:', create.error); -``` - -## JSON Parsing & Error Handling +- **🛡️ No Exceptions**: Never write `try/catch` — always get a safe result +- **🔧 Typed Errors**: `NetworkError | TimeoutError | HttpError | ValidationError` +- **⏱️ Smart Timeouts**: Per-attempt + total operation timeouts +- **🔄 Intelligent Retries**: Only retries safe operations + `Retry-After` support +- **📦 Zero Dependencies**: Tree-shakable, ~3kb, works everywhere +- **🧪 Validation Ready**: Built-in Zod integration without exceptions -> **JSON parsing behavior:** -> - `204/205` status codes → `null` -> - If `Content-Type` doesn't contain `json` → `null` -> - Invalid JSON doesn't throw exception, returns `null` +## 📖 Documentation -**Error types you may encounter:** `NetworkError`, `TimeoutError`, `HttpError`, `ValidationError`. -All errors are serializable (plain objects), easy to log and monitor. +- **[Core Library](packages/core/README.md)** - Complete API documentation, examples, and migration guides +- **[React Query Adapter](packages/react-query/README.md)** - TanStack Query integration -**Timeout behavior:** -- `timeoutMs` — per attempt timeout -- `totalTimeoutMs` — total operation timeout (includes all retries) +## 🌟 Core Features -**Tree-shakable, side-effects free** - only imports what you use. +### Safe Results +Every request returns a discriminated union - no more guessing what went wrong: -### Safe by Default -No more `try/catch` blocks. Every request returns a discriminated union: ```typescript type SafeResult = | { ok: true; data: T; response: Response } | { ok: false; error: NormalizedError; response?: Response } ``` -### Normalized Error Types -All errors are consistently typed and structured: +### Normalized Errors +All errors follow the same structure: + ```typescript -// Network issues, connection failures +// Network issues, connection failures type NetworkError = { name: 'NetworkError'; message: string; cause?: unknown } // Request timeouts (per-attempt or total) @@ -135,560 +81,103 @@ type TimeoutError = { name: 'TimeoutError'; message: string; timeoutMs: number } // HTTP 4xx/5xx responses type HttpError = { name: 'HttpError'; message: string; status: number; body?: unknown } -// Schema validation failures +// Schema validation failures type ValidationError = { name: 'ValidationError'; message: string; cause?: unknown } ``` -### Intelligent Timeouts -Two-tier timeout system for maximum control: -```typescript -const api = createSafeFetch({ - timeoutMs: 5000, // 5s per attempt - totalTimeoutMs: 30000 // 30s total (all retries) -}); -``` - -### Smart Retries -Only retries safe operations by default: -- ✅ `GET`, `HEAD` - automatically retried on 5xx, network errors -- ❌ `POST`, `PUT`, `PATCH` - never retried by default (prevents duplication) -- 🎛️ Custom `retryOn` callback for full control - -```typescript -const result = await safeFetch.get('/api/flaky-endpoint', { - retries: { - retries: 3, - baseDelayMs: 300, // Exponential backoff starting at 300ms - retryOn: ({ response, error }) => { - // Custom retry logic - return error?.name === 'NetworkError' || response?.status === 429; - } - } -}); -``` - -### Respects Rate Limits -Automatically handles `429 Too Many Requests` with `Retry-After` header: -```typescript -// Server returns: 429 Too Many Requests, Retry-After: 60 -// safe-fetch waits exactly 60 seconds before retry -const result = await safeFetch.get('/api/rate-limited', { - retries: { retries: 3 } -}); -``` - -## Migrate from Axios - -**Axios (throws exceptions)** -```typescript -try { - const { data } = await axios.get('/users'); - render(data); -} catch (e) { - toast(parseAxiosError(e)); -} -``` - -**safe-fetch (no throws)** -```typescript -const res = await safeFetch.get('/users'); -if (res.ok) render(res.data); -else toast(`${res.error.name}: ${res.error.message}`); -``` - -## Usage Examples - -### Basic Requests - -```typescript -import { safeFetch } from '@asouei/safe-fetch'; - -// GET request with type safety -const users = await safeFetch.get('/api/users'); -if (users.ok) { - users.data.forEach(user => console.log(user.name)); -} - -// POST with JSON body (auto-sets Content-Type) -const newUser = await safeFetch.post('/api/users', { - name: 'Alice', - email: 'alice@example.com' -}); - -// Handle different error types -if (!newUser.ok) { - switch (newUser.error.name) { - case 'HttpError': - // Use type assertion since we know the type from discriminated union - const httpError = newUser.error as { status: number; message: string }; - console.log(`HTTP ${httpError.status}: ${httpError.message}`); - break; - case 'NetworkError': - console.log('Network connection failed'); - break; - case 'TimeoutError': - const timeoutError = newUser.error as { timeoutMs: number }; - console.log(`Request timed out after ${timeoutError.timeoutMs}ms`); - break; - case 'ValidationError': - console.log('Response validation failed'); - break; - } -} -``` - -### Configured Instance +### Smart Configuration ```typescript import { createSafeFetch } from '@asouei/safe-fetch'; const api = createSafeFetch({ baseURL: 'https://api.example.com', - headers: { - 'Authorization': 'Bearer token', - 'User-Agent': 'MyApp/1.0' - }, - timeoutMs: 8000, - totalTimeoutMs: 30000, + timeoutMs: 5000, // Per attempt + totalTimeoutMs: 30000, // Total operation retries: { retries: 2, - baseDelayMs: 500 - } -}); - -// All requests use the base configuration -const result = await api.get('/users'); // GET https://api.example.com/users -``` - -### Response Validation with Zod - -Perfect integration with schema validation libraries: - -```typescript -import { z } from 'zod'; - -const UserSchema = z.object({ - id: z.number(), - name: z.string(), - email: z.string().email() -}); - -const validateWith = (schema: z.ZodSchema) => (raw: unknown) => { - const r = schema.safeParse(raw); - return r.success - ? { success: true as const, data: r.data } - : { success: false as const, error: r.error }; -}; - -const result = await safeFetch.get('/api/user/123', { - validate: validateWith(UserSchema) -}); - -if (result.ok) { - // result.data is fully typed as z.infer - console.log(result.data.email); // TypeScript knows this is a valid email -} -``` - -### Request/Response Interceptors - -```typescript -const api = createSafeFetch({ - interceptors: { - onRequest: (url, init) => { - // Add auth token - const headers = new Headers(init.headers); - headers.set('Authorization', `Bearer ${getToken()}`); - init.headers = headers; - - console.log(`→ ${init.method} ${url}`); - }, - - onResponse: (response) => { - console.log(`← ${response.status} ${response.url}`); - - // Handle global auth errors - if (response.status === 401) { - redirectToLogin(); - } - }, - - onError: (error) => { - // Send errors to monitoring service - analytics.track('http_error', { - error_name: error.name, - message: error.message - }); - } - } -}); -``` - -### Error Mapping - -Transform errors into domain-specific types: - -```typescript -const api = createSafeFetch({ - errorMap: (error) => { - if (error.name === 'HttpError' && error.status === 404) { - return { - name: 'NotFoundError', - message: 'Resource not found', - status: 404 - } as any; // Type assertion needed for extending domain errors - } - - if (error.name === 'HttpError' && error.status === 401) { - return { - name: 'AuthError', - message: 'Authentication required', - status: 401 - } as any; - } - - return error; - } -}); -``` - -### File Uploads & Different Content Types - -```typescript -// JSON (automatic Content-Type) -await safeFetch.post('/api/users', { name: 'John' }); - -// Form data -const formData = new FormData(); -formData.append('file', fileInput.files[0]); -formData.append('description', 'Profile picture'); -await safeFetch.post('/api/upload', formData); - -// Raw text -await safeFetch.post('/api/webhook', 'plain text', { - headers: { 'Content-Type': 'text/plain' } -}); - -// Get different response types -const csv = await safeFetch.get('/api/export.csv', { parseAs: 'text' }); -const blob = await safeFetch.get('/api/image.jpg', { parseAs: 'blob' }); -const raw = await safeFetch.get('/api/stream', { parseAs: 'response' }); -``` - -### AbortController Support - -```typescript -const controller = new AbortController(); - -const promise = safeFetch.get('/api/long-request', { - signal: controller.signal, - timeoutMs: 10000 + baseDelayMs: 300 // Exponential backoff + }, + headers: { Authorization: 'Bearer token' } }); -// Cancel after 5 seconds -setTimeout(() => controller.abort(), 5000); - -const result = await promise; -if (!result.ok && result.error.name === 'NetworkError') { - console.log('Request was cancelled'); -} -``` - -## Utility Helpers - -### Unwrap for Exception-Based Code - -```typescript -import type { SafeResult } from '@asouei/safe-fetch'; - -export const unwrap = async (promise: Promise>): Promise => { - const result = await promise; - if (!result.ok) throw result.error; - return result.data; -}; - -// Use when you want traditional exception handling -try { - const users = await unwrap(safeFetch.get('/api/users')); - console.log(users); // User[] - no need to check result.ok -} catch (error) { - console.error(error); // NormalizedError with consistent structure -} -``` - -### Type Guards for Error Handling - -```typescript -export const isHttpError = (e: unknown): e is { name: 'HttpError'; status: number; statusText: string } => - !!e && typeof e === 'object' && (e as any).name === 'HttpError' && typeof (e as any).status === 'number'; - -export const isNetworkError = (e: unknown): e is { name: 'NetworkError'; message: string } => - !!e && typeof e === 'object' && (e as any).name === 'NetworkError'; - -// Usage -const result = await safeFetch.get('/api/data'); -if (!result.ok) { - if (isHttpError(result.error)) { - console.log(`HTTP ${result.error.status}: ${result.error.statusText}`); - } else if (isNetworkError(result.error)) { - console.log('Network connection failed'); - } -} +const result = await api.get('/users'); ``` -## Limitations (by design) - -- No built-in caching/request deduplication (use SWR/TanStack Query) -- No automatic request/response transformations (keeps behavior predictable) -- Won't retry non-idempotent methods (POST/PUT/PATCH) without explicit `retryOn` - -## Who Is It For? - -- Teams tired of inconsistent `try/catch` blocks and implicit error handling -- Projects with strict SLA requirements needing total timeouts and proper retries -- TypeScript codebases requiring precise error type definitions -- Developers who want fetch's simplicity with production-ready reliability - -## Playground - -Try safe-fetch online with ready-to-run examples: -**CodeSandbox:** [Open Interactive Demo](https://codesandbox.io/p/sandbox/fkw3z5) - -## Roadmap - -- ✅ `Retry-After` (seconds/date) and exponential backoff with jitter -- ✅ Total operation timeout (`totalTimeoutMs`) -- ⏳ React Query/SWR adapter generators (`unwrap*` helpers) -- ⏳ ESLint plugin for `{ ok }` pattern invariants -- ⏳ Framework examples: Next.js, Cloudflare Workers, Remix - -## Browser & Runtime Support - -- **Node.js**: 18+ (uses built-in `fetch`) -- **Bun**: 1.1+ (with `fetch` support) -- **Browsers**: All modern browsers (Chrome 63+, Firefox 57+, Safari 10.1+) -- **SSR**: Next.js, Nuxt, SvelteKit compatible - -### Edge/Workers - -Works in Cloudflare Workers and Vercel Edge Runtime (uses global `fetch`): - -```typescript -// Cloudflare Worker -const isHttpError = (e: unknown): e is { name: 'HttpError'; status: number } => - !!e && typeof e === 'object' && (e as any).name === 'HttpError' && typeof (e as any).status === 'number'; +## 🔮 Ecosystem Roadmap -export default { - async fetch() { - const res = await safeFetch.get<{ ok: boolean }>('https://api.example.com/ping'); - - if (res.ok) { - return new Response(JSON.stringify(res.data), { - headers: { 'content-type': 'application/json' } - }); - } - - const status = isHttpError(res.error) ? res.error.status : 500; - return new Response(res.error.message, { status }); - } -}; -``` +- ✅ **Core Library** - Safe HTTP client with retries and timeouts +- ✅ **React Query Adapter** - Optimized TanStack Query integration +- 📋 **SWR Adapter** - SWR integration helpers +- 🔍 **ESLint Plugin** - Enforce safe result patterns +- 🏗️ **Framework Examples** - Next.js, Remix, Cloudflare Workers -## Recipes - -### Auth refresh (401 → refresh → retry once) +## 📱 Framework Integration +### React Query ```typescript -const api = createSafeFetch({ - interceptors: { - onResponse: async (res) => { - if (res.status === 401) await refreshToken(); - } - } -}); - -const res = await api.get('/me', { - retries: { - retries: 1, - retryOn: ({ response }) => response?.status === 401 - } -}); -``` - -### GraphQL helper - -```typescript -type GQL = { data?: T; errors?: any[] }; - -const gql = (query: string, variables?: any) => - safeFetch.post>('/graphql', { query, variables }, { - validate: v => - v && !v.errors - ? { success: true, data: v.data as T } - : { success: false, error: v?.errors } - }); - -// Usage -const result = await gql('query { user(id: "123") { name email } }'); -``` - -### React Query / SWR Integration +import { createSafeFetch } from '@asouei/safe-fetch'; +import { createQueryFn, rqDefaults } from '@asouei/safe-fetch-react-query'; -```typescript -// React Query -import { useQuery } from '@tanstack/react-query'; -import { safeFetch } from '@asouei/safe-fetch'; +const api = createSafeFetch({ baseURL: '/api' }); +const queryFn = createQueryFn(api); export function useUsers() { return useQuery({ queryKey: ['users'], - queryFn: async () => { - const r = await safeFetch.get('/api/users'); - if (!r.ok) throw r.error; // RQ expects exceptions for error states - return r.data; - }, - retry: false, // rely on safe-fetch internal retries + queryFn: queryFn('/users'), + ...rqDefaults() // { retry: false } - let safe-fetch handle retries }); } - -// SWR -import useSWR from 'swr'; - -const fetcher = async (url: string) => { - const r = await safeFetch.get(url); - if (!r.ok) throw r.error; - return r.data; -}; - -export function UserProfile({ id }: { id: string }) { - const { data, error } = useSWR(`/api/users/${id}`, fetcher); - if (error) return
Error: {error.message}
; - if (!data) return
Loading...
; - return
Hello, {data.name}!
; -} -``` - -### Security & Credentials - -```typescript -const api = createSafeFetch({ - baseURL: 'https://api.example.com', - credentials: 'include', // Cookie-based authentication - timeoutMs: 5000, - totalTimeoutMs: 20000, - headers: { - 'X-Requested-With': 'XMLHttpRequest' - } -}); - -// The library doesn't change CORS/cookie policy; behavior identical to fetch ``` +### Next.js / SSR ```typescript // app/users/page.tsx import { safeFetch } from '@asouei/safe-fetch'; -export default async function Page() { - const res = await safeFetch.get('https://api.example.com/users'); - if (!res.ok) return
Error: {res.error.name}
; - return
    {res.data.map(u =>
  • {u.name}
  • )}
; +export default async function UsersPage() { + const result = await safeFetch.get('/api/users'); + + if (!result.ok) { + return ; + } + + return ; } ``` -## API Reference - -### `createSafeFetch(config?)` - -**Configuration Options:** - -| Option | Type | Default | Description | -|--------|------|---------|-------------| -| `baseURL` | `string` | - | Base URL for all requests | -| `headers` | `Record` | `{}` | Default headers | -| `query` | `Record` | `{}` | Default query parameters | -| `timeoutMs` | `number` | `0` | Per-attempt timeout in milliseconds | -| `totalTimeoutMs` | `number` | `0` | Total timeout for all retry attempts | -| `retries` | `RetryStrategy` | `false` | Retry configuration | -| `parseAs` | `ParseAs` | `'json'` | Default response parsing method | -| `errorMap` | `ErrorMapper` | - | Transform errors before returning | -| `interceptors` | `Interceptors` | - | Request/response/error hooks | - -### Method Signatures - +### Cloudflare Workers ```typescript -// Basic request -safeFetch(url: string, options?: SafeFetchRequest): Promise> - -// HTTP method shortcuts -safeFetch.get(url: string, options?: SafeFetchRequest): Promise> -safeFetch.post(url: string, body?: unknown, options?: SafeFetchRequest): Promise> -safeFetch.put(url: string, body?: unknown, options?: SafeFetchRequest): Promise> -safeFetch.patch(url: string, body?: unknown, options?: SafeFetchRequest): Promise> -safeFetch.delete(url: string, options?: SafeFetchRequest): Promise> -``` - -### Validation Function - -```typescript -validate: (raw: unknown) => { success: true, data: T } | { success: false, error: any } +export default { + async fetch(request: Request) { + const result = await safeFetch.get<{ status: string }>('https://api.service.com/health'); + + return new Response( + result.ok ? JSON.stringify(result.data) : result.error.message, + { status: result.ok ? 200 : 500 } + ); + } +}; ``` -## Why Not Axios/Ky? - -### vs Axios -- **No exceptions:** No "magic" global error handling — explicit `{ ok }` checking -- **Smaller bundle:** ~3kb vs ~13kb* -- **Modern platform:** Native fetch, no abstraction layers -- **Better for TypeScript:** Discriminated unions instead of catching any errors -- **Total timeout:** `totalTimeoutMs` for entire operation, not just single request - -### vs Ky -- **Safe results:** Predictable control flow (no throws) -- **Safer retries:** Won't retry POST by default to prevent side-effect duplication -- **Retry-After support:** Respects server rate-limiting headers -- **Validation hooks:** Zod integration without exceptions - -### vs Native Fetch -- **Normalized errors:** Consistent `Network/Timeout/Http/Validation` structure -- **Retries + backoff + jitter:** No need to reinvent the wheel -- **Dual timeouts:** Per-attempt and total operation timeouts -- **Conveniences:** Method shortcuts, auto JSON handling, interceptors - -## FAQ - -**Why not throw exceptions?** -Explicit control flow through `{ ok }` is easier to read, type, and test than try/catch around every operation. +## 🤝 Contributing -**Can I still throw exceptions if needed?** -Yes - use the `unwrap(result)` helper from the Utilities section. +We welcome contributions! Please see our [Contributing Guide](CONTRIBUTING.md) for details. -**Why don't POST/PUT/PATCH retry by default?** -To prevent duplicating side effects. Enable retries for non-idempotent methods explicitly via `retryOn` callback. - -**Does this work with React Query/SWR?** -Perfectly! Just wrap your safeFetch calls or use the `unwrap` helper. - -## Contributing - -Contributions are welcome! Please read our [Contributing Guide](CONTRIBUTING.md) for details. - -**Development Setup:** +**Quick development setup:** ```bash git clone https://github.com/asouei/safe-fetch.git cd safe-fetch pnpm install -pnpm test -pnpm build +pnpm -r test +pnpm -r build ``` -**Versioning:** We follow [Semantic Versioning](https://semver.org/). See [CHANGELOG.md](CHANGELOG.md) for release history. - -**Support policy:** Minimum environment — Node 18+, modern browsers (ES2017+). We follow Node.js LTS cycles. - -## License +## 📄 License MIT © [Aleksandr Mikhailishin](https://github.com/asouei) --- -**Made with ❤️ for developers who value predictable, type-safe HTTP clients.** \ No newline at end of file +**Built with ❤️ for developers who value predictable, type-safe HTTP clients.** \ No newline at end of file diff --git a/README.ru.md b/README.ru.md index e469117..9d62804 100644 --- a/README.ru.md +++ b/README.ru.md @@ -3,17 +3,31 @@ [![npm version](https://img.shields.io/npm/v/@asouei/safe-fetch.svg)](https://www.npmjs.com/package/@asouei/safe-fetch) [![CI](https://github.com/asouei/safe-fetch/actions/workflows/ci.yml/badge.svg?branch=master)](https://github.com/asouei/safe-fetch/actions/workflows/ci.yml) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) -[![npm](https://img.shields.io/npm/dm/@asouei/safe-fetch)](https://www.npmjs.com/package/@asouei/safe-fetch) +[![npm downloads](https://img.shields.io/npm/dm/@asouei/safe-fetch)](https://www.npmjs.com/package/@asouei/safe-fetch) +[![TypeScript](https://img.shields.io/badge/TypeScript-Ready-blue.svg)](https://www.typescriptlang.org/) +[![Bundle Size](https://img.shields.io/bundlephobia/minzip/@asouei/safe-fetch)](https://bundlephobia.com/package/@asouei/safe-fetch) +[![Zero Dependencies](https://img.shields.io/badge/dependencies-0-green.svg)](package.json) [![Попробовать в CodeSandbox](https://img.shields.io/badge/Open%20in-CodeSandbox-black?logo=codesandbox)](https://codesandbox.io/p/sandbox/fkw3z5) [![Awesome](https://awesome.re/badge-flat2.svg)](https://github.com/dzharii/awesome-typescript) *[English version](README.md) | Русская версия* -> **Никогда больше не пишите `try/catch` для HTTP-запросов.** Ноль зависимостей • Не бросает исключения • Полный таймаут • Поддержка Retry-After +> **Никогда больше не пишите `try/catch` для HTTP-запросов.** Полная экосистема типобезопасных HTTP утилит, построенная на безопасных результатах и предсказуемой обработке ошибок. -Маленькая, типизированная обертка вокруг `fetch`, которая возвращает безопасные результаты, умно обрабатывает таймауты и повторяет запросы с экспоненциальным отступом. +Современная экосистема HTTP клиентов, которая исключает исключения через дискриминированные объединения, обеспечивает умные повторы, правильно обрабатывает таймауты и легко интегрируется с популярными библиотеками для работы с данными. -📌 Библиотека вошла в список [Awesome TypeScript](https://github.com/dzharii/awesome-typescript). +## 📦 Пакеты + +| Пакет | Версия | Описание | +|-------|--------|----------| +| **[@asouei/safe-fetch](packages/core)** | [![npm](https://img.shields.io/npm/v/@asouei/safe-fetch.svg)](https://npmjs.com/package/@asouei/safe-fetch) | Основной HTTP клиент с безопасными результатами, повторами и таймаутами | +| **[@asouei/safe-fetch-react-query](packages/react-query)** | [![npm](https://img.shields.io/npm/v/@asouei/safe-fetch-react-query.svg)](https://npmjs.com/package/@asouei/safe-fetch-react-query) | Интеграция с TanStack Query и оптимизированная обработка ошибок | + +## 🚀 Быстрый старт + +```bash +npm install @asouei/safe-fetch +``` ```typescript import { safeFetch } from '@asouei/safe-fetch'; @@ -23,672 +37,146 @@ if (result.ok) { // TypeScript знает, что result.data это { users: User[] } console.log(result.data.users); } else { - // Все ошибки нормализованы - больше не нужно угадывать что пошло не так - console.error(result.error.name); // 'NetworkError' | 'TimeoutError' | 'HttpError' | 'ValidationError' + // Все ошибки нормализованы и типизированы + console.error(`${result.error.name}: ${result.error.message}`); } ``` -## Что вы получаете - -- **Не бросает исключения:** Никогда не пишите `try/catch` — всегда получайте безопасный результат -- **Типизированные ошибки:** `NetworkError | TimeoutError | HttpError | ValidationError` -- **Двойные таймауты:** `timeoutMs` на попытку + `totalTimeoutMs` для всей операции -- **Умные повторы:** Только идемпотентные методы по умолчанию + поддержка `Retry-After` -- **Готовность к Zod:** Валидация схем без исключений -- **Ноль зависимостей и ~3кб:** Дружелюбен к бандлерам, tree-shakable, без побочных эффектов - -| Функция | `@asouei/safe-fetch` | `axios` | `ky` | нативный `fetch` | -|---------|---------------------|---------|------|------------------| -| **Размер бандла** | ~3кб | ~13кб* | ~11кб* | 0кб | -| **Зависимости** | 0 | 0* | 0* | 0 | -| **Безопасные результаты (без исключений)** | ✅ | ❌ | ❌ | ❌ | -| **Дискриминированные union типы** | ✅ | ❌ | ❌ | ❌ | -| **Per-attempt + полный таймауты** | ✅ | Только на запрос | Только на запрос | Вручную | -| **Умные повторы (только идемпотентные)** | ✅ | ✅ (бросает) | ✅ (бросает) | Вручную | -| **Поддержка заголовка Retry-After** | ✅ | ❌ | ❌ | Вручную | -| **Интерсепторы запроса/ответа** | ✅ | ✅ | ✅ | Вручную | -| **Хуки валидации (готов к Zod)** | ✅ | ❌ | ❌ | Вручную | -| **TypeScript-first дизайн** | ✅ | Частично | ✅ | ✅ | - -*Размер бандла ~gzip; зависит от версии, окружения и настроек бандлера. -**Axios/Ky бросают исключения на non-2xx по умолчанию; нет встроенного полного таймаута операции. - -## Установка - -```bash -npm install @asouei/safe-fetch -``` - -### Стили импорта - -**ESM** -```typescript -import { safeFetch, createSafeFetch } from '@asouei/safe-fetch'; -``` - -**CommonJS** -```javascript -const { safeFetch, createSafeFetch } = require('@asouei/safe-fetch'); -// CommonJS поддерживается через поле exports.require -``` - -**CDN (esm.run)** -```html - -``` - -## Быстрое демо - -```typescript -type Todo = { id: number; title: string; completed: boolean }; - -const api = createSafeFetch({ - baseURL: 'https://jsonplaceholder.typicode.com', - timeoutMs: 3000, - totalTimeoutMs: 7000, - retries: { retries: 2 }, -}); - -const list = await api.get('/todos', { query: { _limit: 3 } }); -if (list.ok) console.log('todos:', list.data.map(t => t.title)); - -const create = await api.post('/todos', { title: 'Изучить safe-fetch', completed: false }); -if (!create.ok) console.warn('создание не удалось:', create.error); -``` +## ✨ Зачем safe-fetch? -## Парсинг JSON и обработка ошибок +- **🛡️ Никаких исключений**: Никогда не пишите `try/catch` — всегда получайте безопасный результат +- **🔧 Типизированные ошибки**: `NetworkError | TimeoutError | HttpError | ValidationError` +- **⏱️ Умные таймауты**: Таймауты на попытку + общий таймаут операции +- **🔄 Интеллектуальные повторы**: Повторяет только безопасные операции + поддержка `Retry-After` +- **📦 Ноль зависимостей**: Tree-shakable, ~3кб, работает везде +- **🧪 Готовность к валидации**: Встроенная интеграция с Zod без исключений -> **Поведение парсинга JSON:** -> - Коды статуса `204/205` → `null` -> - Если `Content-Type` не содержит `json` → `null` -> - Невалидный JSON не бросает исключение, возвращает `null` +## 📖 Документация -**Типы ошибок, которые могут встретиться:** `NetworkError`, `TimeoutError`, `HttpError`, `ValidationError`. -Все ошибки сериализуемы (обычные объекты), легко логировать и мониторить. +- **[Основная библиотека](packages/core/README.md)** - Полная документация API, примеры и руководства по миграции +- **[React Query адаптер](packages/react-query/README.md)** - Интеграция с TanStack Query -**Поведение таймаута:** -- `timeoutMs` — таймаут на попытку -- `totalTimeoutMs` — таймаут всей операции (включает все повторы) +## 🌟 Основные возможности -**Tree-shakable, без побочных эффектов** - импортируете только то, что используете. +### Безопасные результаты +Каждый запрос возвращает дискриминированное объединение — больше не нужно гадать, что пошло не так: -### Безопасно по умолчанию -Больше никаких блоков `try/catch`. Каждый запрос возвращает дискриминированное объединение: ```typescript type SafeResult = | { ok: true; data: T; response: Response } | { ok: false; error: NormalizedError; response?: Response } ``` -### Нормализованные типы ошибок -Все ошибки последовательно типизированы и структурированы: +### Нормализованные ошибки +Все ошибки следуют одинаковой структуре: + ```typescript // Сетевые проблемы, сбои подключения type NetworkError = { name: 'NetworkError'; message: string; cause?: unknown } -// Таймауты запроса (на попытку или полный) +// Таймауты запроса (на попытку или общий) type TimeoutError = { name: 'TimeoutError'; message: string; timeoutMs: number } // HTTP 4xx/5xx ответы type HttpError = { name: 'HttpError'; message: string; status: number; body?: unknown } -// Сбои валидации схемы +// Сбои валидации схемы type ValidationError = { name: 'ValidationError'; message: string; cause?: unknown } ``` -### Умные таймауты -Двухуровневая система таймаутов для максимального контроля: -```typescript -const api = createSafeFetch({ - timeoutMs: 5000, // 5с на попытку - totalTimeoutMs: 30000 // 30с всего (все повторы) -}); -``` - -### Умные повторы -По умолчанию повторяет только безопасные операции: -- ✅ `GET`, `HEAD` - автоматически повторяются на 5xx, сетевых ошибках -- ❌ `POST`, `PUT`, `PATCH` - никогда не повторяются по умолчанию (предотвращает дублирование) -- 🎛️ Кастомный колбек `retryOn` для полного контроля - -```typescript -const result = await safeFetch.get('/api/flaky-endpoint', { - retries: { - retries: 3, - baseDelayMs: 300, // Экспоненциальный отступ начиная с 300мс - retryOn: ({ response, error }) => { - // Кастомная логика повтора - return error?.name === 'NetworkError' || response?.status === 429; - } - } -}); -``` - -### Уважает лимиты скорости -Автоматически обрабатывает `429 Too Many Requests` с заголовком `Retry-After`: -```typescript -// Сервер возвращает: 429 Too Many Requests, Retry-After: 60 -// safe-fetch ждет ровно 60 секунд перед повтором -const result = await safeFetch.get('/api/rate-limited', { - retries: { retries: 3 } -}); -``` - -## Миграция с Axios - -**Axios (бросает исключения)** -```typescript -try { - const { data } = await axios.get('/users'); - render(data); -} catch (e) { - toast(parseAxiosError(e)); -} -``` - -**safe-fetch (не бросает)** -```typescript -const res = await safeFetch.get('/users'); -if (res.ok) render(res.data); -else toast(`${res.error.name}: ${res.error.message}`); -``` - -## Примеры использования - -### Базовые запросы - -```typescript -import { safeFetch } from '@asouei/safe-fetch'; - -// GET запрос с типобезопасностью -const users = await safeFetch.get('/api/users'); -if (users.ok) { - users.data.forEach(user => console.log(user.name)); -} - -// POST с JSON телом (автоматически устанавливает Content-Type) -const newUser = await safeFetch.post('/api/users', { - name: 'Алиса', - email: 'alice@example.com' -}); - -// Обработка разных типов ошибок -if (!newUser.ok) { - switch (newUser.error.name) { - case 'HttpError': - // Используем type assertion, так как знаем тип из дискриминированного объединения - const httpError = newUser.error as { status: number; message: string }; - console.log(`HTTP ${httpError.status}: ${httpError.message}`); - break; - case 'NetworkError': - console.log('Сбой сетевого подключения'); - break; - case 'TimeoutError': - const timeoutError = newUser.error as { timeoutMs: number }; - console.log(`Запрос превысил время ожидания через ${timeoutError.timeoutMs}мс`); - break; - case 'ValidationError': - console.log('Валидация ответа не удалась'); - break; - } -} -``` - -### Настроенный экземпляр +### Умная конфигурация ```typescript import { createSafeFetch } from '@asouei/safe-fetch'; const api = createSafeFetch({ baseURL: 'https://api.example.com', - headers: { - 'Authorization': 'Bearer token', - 'User-Agent': 'MyApp/1.0' - }, - timeoutMs: 8000, - totalTimeoutMs: 30000, + timeoutMs: 5000, // На попытку + totalTimeoutMs: 30000, // Общий таймаут операции retries: { retries: 2, - baseDelayMs: 500 - } -}); - -// Все запросы используют базовую конфигурацию -const result = await api.get('/users'); // GET https://api.example.com/users -``` - -### Валидация ответов с Zod - -Идеальная интеграция с библиотеками валидации схем: - -```typescript -import { z } from 'zod'; - -const UserSchema = z.object({ - id: z.number(), - name: z.string(), - email: z.string().email() -}); - -const validateWith = (schema: z.ZodSchema) => (raw: unknown) => { - const r = schema.safeParse(raw); - return r.success - ? { success: true as const, data: r.data } - : { success: false as const, error: r.error }; -}; - -const result = await safeFetch.get('/api/user/123', { - validate: validateWith(UserSchema) -}); - -if (result.ok) { - // result.data полностью типизирован как z.infer - console.log(result.data.email); // TypeScript знает, что это валидный email -} -``` - -### Интерсепторы запроса/ответа - -```typescript -const api = createSafeFetch({ - interceptors: { - onRequest: (url, init) => { - // Добавляем токен авторизации - const headers = new Headers(init.headers); - headers.set('Authorization', `Bearer ${getToken()}`); - init.headers = headers; - - console.log(`→ ${init.method} ${url}`); - }, - - onResponse: (response) => { - console.log(`← ${response.status} ${response.url}`); - - // Обрабатываем глобальные ошибки авторизации - if (response.status === 401) { - redirectToLogin(); - } - }, - - onError: (error) => { - // Отправляем ошибки в сервис мониторинга - analytics.track('http_error', { - error_name: error.name, - message: error.message - }); - } - } -}); -``` - -### Маппинг ошибок - -Преобразование ошибок в доменно-специфичные типы: - -```typescript -const api = createSafeFetch({ - errorMap: (error) => { - if (error.name === 'HttpError' && error.status === 404) { - return { - name: 'NotFoundError', - message: 'Ресурс не найден', - status: 404 - } as any; // Type assertion нужен для расширения доменных ошибок - } - - if (error.name === 'HttpError' && error.status === 401) { - return { - name: 'AuthError', - message: 'Требуется аутентификация', - status: 401 - } as any; - } - - return error; - } -}); -``` - -### Загрузка файлов и разные типы контента - -```typescript -// JSON (автоматический Content-Type) -await safeFetch.post('/api/users', { name: 'Иван' }); - -// Form data -const formData = new FormData(); -formData.append('file', fileInput.files[0]); -formData.append('description', 'Аватар'); -await safeFetch.post('/api/upload', formData); - -// Простой текст -await safeFetch.post('/api/webhook', 'простой текст', { - headers: { 'Content-Type': 'text/plain' } -}); - -// Получение разных типов ответов -const csv = await safeFetch.get('/api/export.csv', { parseAs: 'text' }); -const blob = await safeFetch.get('/api/image.jpg', { parseAs: 'blob' }); -const raw = await safeFetch.get('/api/stream', { parseAs: 'response' }); -``` - -### Поддержка AbortController - -```typescript -const controller = new AbortController(); - -const promise = safeFetch.get('/api/long-request', { - signal: controller.signal, - timeoutMs: 10000 + baseDelayMs: 300 // Экспоненциальная задержка (backoff) + }, + headers: { Authorization: 'Bearer token' } }); -// Отменяем через 5 секунд -setTimeout(() => controller.abort(), 5000); - -const result = await promise; -if (!result.ok && result.error.name === 'NetworkError') { - console.log('Запрос был отменен'); -} -``` - -## Утилитарные хелперы - -### Unwrap для кода на основе исключений - -```typescript -import type { SafeResult } from '@asouei/safe-fetch'; - -export const unwrap = async (promise: Promise>): Promise => { - const result = await promise; - if (!result.ok) throw result.error; - return result.data; -}; - -// Используйте когда хотите традиционную обработку исключений -try { - const users = await unwrap(safeFetch.get('/api/users')); - console.log(users); // User[] - не нужно проверять result.ok -} catch (error) { - console.error(error); // NormalizedError с последовательной структурой -} -``` - -### Type Guards для обработки ошибок - -```typescript -export const isHttpError = (e: unknown): e is { name: 'HttpError'; status: number; statusText: string } => - !!e && typeof e === 'object' && (e as any).name === 'HttpError' && typeof (e as any).status === 'number'; - -export const isNetworkError = (e: unknown): e is { name: 'NetworkError'; message: string } => - !!e && typeof e === 'object' && (e as any).name === 'NetworkError'; - -// Использование -const result = await safeFetch.get('/api/data'); -if (!result.ok) { - if (isHttpError(result.error)) { - console.log(`HTTP ${result.error.status}: ${result.error.statusText}`); - } else if (isNetworkError(result.error)) { - console.log('Сбой сетевого подключения'); - } -} +const result = await api.get('/users'); ``` -## Ограничения (по дизайну) - -- Нет встроенного кеширования/дедупликации запросов (используйте SWR/TanStack Query) -- Нет автоматических преобразований запроса/ответа (сохраняет предсказуемость поведения) -- Не будет повторять неидемпотентные методы (POST/PUT/PATCH) без явного `retryOn` - -## Для кого это - -- Команды, уставшие от непоследовательных блоков `try/catch` и неявной обработки ошибок -- Проекты со строгими SLA требованиями, нуждающиеся в полных таймаутах и правильных повторах -- TypeScript кодбазы, требующие точных определений типов ошибок -- Разработчики, которые хотят простоту fetch с производственной надежностью - -## Песочница - -Попробуйте safe-fetch онлайн с готовыми примерами: -**CodeSandbox:** [Открыть интерактивное демо](https://codesandbox.io/p/sandbox/fkw3z5) - -## Дорожная карта - -- ✅ `Retry-After` (секунды/дата) и экспоненциальный отступ с джиттером -- ✅ Полный таймаут операции (`totalTimeoutMs`) -- ⏳ Генераторы адаптеров React Query/SWR (`unwrap*` хелперы) -- ⏳ ESLint плагин для инвариантов паттерна `{ ok }` -- ⏳ Примеры фреймворков: Next.js, Cloudflare Workers, Remix - -## Поддержка браузеров и рантайма - -- **Node.js**: 18+ (использует встроенный `fetch`) -- **Bun**: 1.1+ (с поддержкой `fetch`) -- **Браузеры**: Все современные браузеры (Chrome 63+, Firefox 57+, Safari 10.1+) -- **SSR**: Совместим с Next.js, Nuxt, SvelteKit - -### Edge/Workers - -Работает в Cloudflare Workers и Vercel Edge Runtime (использует глобальный `fetch`): - -```typescript -// Cloudflare Worker -const isHttpError = (e: unknown): e is { name: 'HttpError'; status: number } => - !!e && typeof e === 'object' && (e as any).name === 'HttpError' && typeof (e as any).status === 'number'; +## 🔮 Дорожная карта экосистемы -export default { - async fetch() { - const res = await safeFetch.get<{ ok: boolean }>('https://api.example.com/ping'); - - if (res.ok) { - return new Response(JSON.stringify(res.data), { - headers: { 'content-type': 'application/json' } - }); - } - - const status = isHttpError(res.error) ? res.error.status : 500; - return new Response(res.error.message, { status }); - } -}; -``` +- ✅ **Основная библиотека** - Безопасный HTTP клиент с повторами и таймаутами +- ✅ **React Query адаптер** - Оптимизированная интеграция с TanStack Query +- 📋 **SWR адаптер** - Хелперы для интеграции с SWR +- 🔍 **ESLint плагин** - Принуждение к паттернам безопасных результатов +- 🏗️ **Примеры фреймворков** - Next.js, Remix, Cloudflare Workers -## Рецепты - -### Обновление авторизации (401 → refresh → повтор один раз) +## 📱 Интеграция с фреймворками +### React Query ```typescript -const api = createSafeFetch({ - interceptors: { - onResponse: async (res) => { - if (res.status === 401) await refreshToken(); - } - } -}); - -const res = await api.get('/me', { - retries: { - retries: 1, - retryOn: ({ response }) => response?.status === 401 - } -}); -``` - -### GraphQL хелпер - -```typescript -type GQL = { data?: T; errors?: any[] }; - -const gql = (query: string, variables?: any) => - safeFetch.post>('/graphql', { query, variables }, { - validate: v => - v && !v.errors - ? { success: true, data: v.data as T } - : { success: false, error: v?.errors } - }); - -// Использование -const result = await gql('query { user(id: "123") { name email } }'); -``` - -### Интеграция с React Query / SWR +import { createSafeFetch } from '@asouei/safe-fetch'; +import { createQueryFn, rqDefaults } from '@asouei/safe-fetch-react-query'; -```typescript -// React Query -import { useQuery } from '@tanstack/react-query'; -import { safeFetch } from '@asouei/safe-fetch'; +const api = createSafeFetch({ baseURL: '/api' }); +const queryFn = createQueryFn(api); export function useUsers() { return useQuery({ queryKey: ['users'], - queryFn: async () => { - const r = await safeFetch.get('/api/users'); - if (!r.ok) throw r.error; // RQ ожидает исключения для состояний ошибок - return r.data; - }, - retry: false, // полагаемся на внутренние повторы safe-fetch + queryFn: queryFn('/users'), + ...rqDefaults() // { retry: false } - пусть safe-fetch обрабатывает повторы }); } - -// SWR -import useSWR from 'swr'; - -const fetcher = async (url: string) => { - const r = await safeFetch.get(url); - if (!r.ok) throw r.error; - return r.data; -}; - -export function UserProfile({ id }: { id: string }) { - const { data, error } = useSWR(`/api/users/${id}`, fetcher); - if (error) return
Ошибка: {error.message}
; - if (!data) return
Загрузка...
; - return
Привет, {data.name}!
; -} -``` - -### Безопасность и учетные данные - -```typescript -const api = createSafeFetch({ - baseURL: 'https://api.example.com', - credentials: 'include', // Аутентификация на основе cookie - timeoutMs: 5000, - totalTimeoutMs: 20000, - headers: { - 'X-Requested-With': 'XMLHttpRequest' - } -}); - -// Библиотека не изменяет политику CORS/cookie; поведение идентично fetch ``` +### Next.js / SSR ```typescript // app/users/page.tsx import { safeFetch } from '@asouei/safe-fetch'; -export default async function Page() { - const res = await safeFetch.get('https://api.example.com/users'); - if (!res.ok) return
Ошибка: {res.error.name}
; - return
    {res.data.map(u =>
  • {u.name}
  • )}
; +export default async function UsersPage() { + const result = await safeFetch.get('/api/users'); + + if (!result.ok) { + return ; + } + + return ; } ``` -## Справочник API - -### `createSafeFetch(config?)` - -**Опции конфигурации:** - -| Опция | Тип | По умолчанию | Описание | -|-------|-----|-------------|----------| -| `baseURL` | `string` | - | Базовый URL для всех запросов | -| `headers` | `Record` | `{}` | Заголовки по умолчанию | -| `query` | `Record` | `{}` | Параметры запроса по умолчанию | -| `timeoutMs` | `number` | `0` | Таймаут на попытку в миллисекундах | -| `totalTimeoutMs` | `number` | `0` | Общий таймаут для всех попыток повтора | -| `retries` | `RetryStrategy` | `false` | Конфигурация повторов | -| `parseAs` | `ParseAs` | `'json'` | Метод парсинга ответа по умолчанию | -| `errorMap` | `ErrorMapper` | - | Преобразование ошибок перед возвратом | -| `interceptors` | `Interceptors` | - | Хуки запроса/ответа/ошибки | - -### Сигнатуры методов - +### Cloudflare Workers ```typescript -// Базовый запрос -safeFetch(url: string, options?: SafeFetchRequest): Promise> - -// Сокращения HTTP методов -safeFetch.get(url: string, options?: SafeFetchRequest): Promise> -safeFetch.post(url: string, body?: unknown, options?: SafeFetchRequest): Promise> -safeFetch.put(url: string, body?: unknown, options?: SafeFetchRequest): Promise> -safeFetch.patch(url: string, body?: unknown, options?: SafeFetchRequest): Promise> -safeFetch.delete(url: string, options?: SafeFetchRequest): Promise> -``` - -### Функция валидации - -```typescript -validate: (raw: unknown) => { success: true, data: T } | { success: false, error: any } +export default { + async fetch(request: Request) { + const result = await safeFetch.get<{ status: string }>('https://api.service.com/health'); + + return new Response( + result.ok ? JSON.stringify(result.data) : result.error.message, + { status: result.ok ? 200 : 500 } + ); + } +}; ``` -## Почему не Axios/Ky? - -### vs Axios -- **Нет исключений:** Никакой "магической" глобальной обработки ошибок — явная проверка `{ ok }` -- **Меньший бандл:** ~3кб против ~13кб* -- **Современная платформа:** Нативный fetch, никаких слоев абстракции -- **Лучше для TypeScript:** Дискриминированные объединения вместо ловли любых ошибок -- **Полный таймаут:** `totalTimeoutMs` для всей операции, а не только одного запроса - -### vs Ky -- **Безопасные результаты:** Предсказуемый поток управления (не бросает) -- **Безопасные повторы:** Не будет повторять POST по умолчанию для предотвращения дублирования побочных эффектов -- **Поддержка Retry-After:** Уважает заголовки ограничения скорости сервера -- **Хуки валидации:** Интеграция с Zod без исключений - -### vs нативный Fetch -- **Нормализованные ошибки:** Последовательная структура `Network/Timeout/Http/Validation` -- **Повторы + отступ + джиттер:** Не нужно изобретать велосипед -- **Двойные таймауты:** Таймауты на попытку и общей операции -- **Удобства:** Сокращения методов, автоматическая обработка JSON, интерсепторы - -## FAQ - -**Почему не бросать исключения?** -Явный поток управления через `{ ok }` легче читать, типизировать и тестировать, чем try/catch вокруг каждой операции. +## 🤝 Участие в разработке -**Можно ли все же бросать исключения при необходимости?** -Да - используйте хелпер `unwrap(result)` из секции Утилиты. +Мы приветствуем вклад в развитие! Пожалуйста, ознакомьтесь с нашим [Руководством по участию](CONTRIBUTING.md). -**Почему POST/PUT/PATCH не повторяются по умолчанию?** -Чтобы предотвратить дублирование побочных эффектов. Включите повторы для неидемпотентных методов явно через колбек `retryOn`. - -**Работает ли это с React Query/SWR?** -Идеально! Просто оберните ваши вызовы safeFetch или используйте хелпер `unwrap`. - -## Участие в разработке - -Вклады приветствуются! Пожалуйста, прочитайте наш [Гид по участию](CONTRIBUTING.md) для подробностей. - -**Настройка разработки:** +**Быстрая настройка для разработки:** ```bash git clone https://github.com/asouei/safe-fetch.git cd safe-fetch pnpm install -pnpm test -pnpm build +pnpm -r test +pnpm -r build ``` -**Версионирование:** Мы следуем [Семантическому версионированию](https://semver.org/). См. [CHANGELOG.md](CHANGELOG.md) для истории релизов. - -**Политика поддержки:** Минимальное окружение — Node 18+, современные браузеры (ES2017+). Мы следуем циклам Node.js LTS. - -## Лицензия +## 📄 Лицензия MIT © [Aleksandr Mikhailishin](https://github.com/asouei) --- -**Сделано с ❤️ для разработчиков, которые ценят предсказуемые, типобезопасные HTTP клиенты.** \ No newline at end of file +**Создано с ❤️ для разработчиков, которые ценят предсказуемые, типобезопасные HTTP клиенты.** \ No newline at end of file diff --git a/package.json b/package.json index 3f116cf..872cbfd 100644 --- a/package.json +++ b/package.json @@ -1,71 +1,11 @@ { - "name": "@asouei/safe-fetch", - "version": "0.1.0", - "description": "Tiny, typed wrapper around fetch with safe results, normalized errors, timeouts, retries and validation hooks.", - "type": "module", - "main": "dist/index.umd.cjs", - "module": "dist/index.js", - "types": "dist/index.d.ts", - "exports": { - ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.js", - "require": "./dist/index.umd.cjs" - } - }, - "files": [ - "dist", - "README.md", - "LICENSE" + "name": "safe-fetch-workspace", + "private": true, + "workspaces": [ + "packages/*" ], - "sideEffects": false, "scripts": { - "build": "vite build", - "dev": "vite build --watch", - "test": "vitest run", - "test:watch": "vitest", - "clean": "rimraf dist", - "prepublishOnly": "npm run clean && npm run build && npm test", - "lint": "eslint . --ext .ts", - "format": "prettier --write ." - }, - "keywords": [ - "fetch", - "safe", - "typescript", - "http", - "retry", - "timeout", - "error-handling", - "discriminated-union", - "zero-dependencies", - "type-safe" - ], - "author": "Aleksandr Mikhailishin", - "license": "MIT", - "url": "git+https://github.com/asouei/safe-fetch.git", - "repository": { - "type": "git", - "url": "https://github.com/asouei/safe-fetch.git" - }, - "bugs": { - "url": "https://github.com/asouei/safe-fetch/issues" - }, - "homepage": "https://asouei.dev", - "publishConfig": { - "access": "public" - }, - "engines": { - "node": ">=18" - }, - "devDependencies": { - "@types/node": "^22.0.0", - "typescript": "^5.9.0", - "vite": "^6.0.0", - "vite-plugin-dts": "^4.5.4", - "vitest": "^3.2.4", - "jsdom": "^26.1.0", - "rimraf": "^6.0.1" - }, - "peerDependencies": {} + "build": "pnpm -r build", + "test": "pnpm -r test" + } } \ No newline at end of file diff --git a/packages/core/.npmignore b/packages/core/.npmignore new file mode 100644 index 0000000..b4c0077 --- /dev/null +++ b/packages/core/.npmignore @@ -0,0 +1,35 @@ +# Ignore build configs & lockfiles +pnpm-lock.yaml +yarn.lock +package-lock.json +tsconfig.json +vitest.config.*.ts +vite.config.ts + +# Ignore source maps if you don’t want to publish them +*.map + +# Ignore tests & mocks +__tests__/ +test/ +tests/ +*.test.ts +*.spec.ts + +# Ignore coverage output +coverage/ + +# Ignore local dev configs +.eslint* +.prettier* +.editorconfig +.idea/ +.vscode/ +*.log + +# Ignore GitHub stuff +.github/ +.gitignore + +# Ignore misc +.DS_Store diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md new file mode 100644 index 0000000..895e4f9 --- /dev/null +++ b/packages/core/CHANGELOG.md @@ -0,0 +1,164 @@ +# Changelog + +All notable changes to **@asouei/safe-fetch** will be documented here. +We follow [Semantic Versioning](https://semver.org/). + +## [1.0.0] - 2025-09-05 + +### Changed +**First stable release of @asouei/safe-fetch** + +- **Monorepo structure**: Moved to monorepo workspace for better ecosystem management +- **Build system**: Migrated from custom build to Vite for improved development experience +- **Test framework**: Updated from Jest to Vitest for better ESM support and performance +- **CI/CD**: Enhanced GitHub Actions workflow for monorepo compatibility + +### Internal +- Refactored test suite structure for monorepo workspace +- Updated package.json exports for improved ESM/CJS dual compatibility +- Enhanced TypeScript configuration with stricter type checking +- Improved bundling configuration with Vite plugin ecosystem + +### Documentation +- Updated README with monorepo context and ecosystem overview +- Enhanced examples with framework integration patterns +- Added cross-references to React Query adapter package +- Improved migration guides and troubleshooting sections + +### Technical Details +- **No breaking changes**: All public APIs remain identical +- **No runtime changes**: Zero impact on library behavior or performance +- **Compatibility**: Maintains full backward compatibility with 0.1.x +- **Dependencies**: Still zero runtime dependencies + +--- + +## [0.1.0] - 2025-09-01 + +### Added + +#### Core Features +- **Safe result API**: Discriminated unions `{ ok: true | false }` eliminate try/catch +- **Normalized errors**: Consistent `NetworkError | TimeoutError | HttpError | ValidationError` +- **Dual timeout system**: Per-attempt (`timeoutMs`) + total operation (`totalTimeoutMs`) +- **Smart retries**: Exponential backoff with jitter, respects `Retry-After` headers +- **HTTP method shortcuts**: `get`, `post`, `put`, `patch`, `delete` convenience methods +- **Multiple parsing modes**: `json`, `text`, `blob`, `arrayBuffer`, `response` + +#### Advanced Features +- **Request/response interceptors**: `onRequest`, `onResponse`, `onError` hooks +- **Validation integration**: Schema validation without exceptions (Zod-ready) +- **Error mapping**: Transform errors to domain-specific types +- **AbortController support**: Full request cancellation capabilities +- **Configurable instances**: Base URL, headers, query params, timeouts + +#### Technical Excellence +- **Zero dependencies**: No external runtime dependencies +- **Tree-shakable**: ~3kb minified + gzipped +- **TypeScript-first**: Full type inference and safety +- **Side-effect free**: Pure functions, safe for SSR + +#### Browser & Runtime Support +- **Node.js**: 18+ (uses built-in fetch) +- **Browsers**: All modern browsers (Chrome 63+, Firefox 57+, Safari 10.1+) +- **Edge runtimes**: Cloudflare Workers, Vercel Edge Runtime, Deno +- **SSR frameworks**: Next.js, Nuxt, SvelteKit, Remix + +#### Error Handling +- **Network errors**: Connection failures, DNS resolution issues +- **Timeout errors**: Per-attempt and total operation timeouts +- **HTTP errors**: 4xx/5xx responses with status codes and response bodies +- **Validation errors**: Schema validation failures with detailed context + +#### Retry Logic +- **Idempotent-only**: Only retries GET/HEAD by default (prevents side-effect duplication) +- **Exponential backoff**: Configurable base delay with jitter +- **Retry-After support**: Respects server rate-limiting headers +- **Custom retry logic**: `retryOn` callback for advanced scenarios + +### Examples +```typescript +// Basic usage +const result = await safeFetch.get('/api/users'); +if (result.ok) { + console.log(result.data); // User[] +} else { + console.error(result.error.name); // 'NetworkError' | 'TimeoutError' | etc. +} + +// Configured instance +const api = createSafeFetch({ + baseURL: 'https://api.example.com', + timeoutMs: 5000, + retries: { retries: 2, baseDelayMs: 300 } +}); + +// With validation +const result = await safeFetch.get('/user/123', { + validate: (data) => UserSchema.safeParse(data).success + ? { success: true, data } + : { success: false, error: 'Invalid user data' } +}); +``` + +--- + +## Migration Guide + +### From other HTTP clients + +#### From Axios +```typescript +// Before (Axios) +try { + const { data } = await axios.get('/users'); + setUsers(data); +} catch (error) { + setError(parseAxiosError(error)); +} + +// After (safe-fetch) +const result = await safeFetch.get('/users'); +if (result.ok) { + setUsers(result.data); +} else { + setError(result.error.message); +} +``` + +#### From native fetch +```typescript +// Before (native fetch) +try { + const response = await fetch('/users'); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + const users = await response.json(); + setUsers(users); +} catch (error) { + setError(error.message); +} + +// After (safe-fetch) +const result = await safeFetch.get('/users'); +if (result.ok) { + setUsers(result.data); +} else { + setError(result.error.message); +} +``` + +## Future Roadmap + +### Planned Features +- **v1.1.0**: Performance optimizations, enhanced debugging tools +- **v1.2.0**: Built-in request deduplication, advanced caching helpers +- **v2.0.0**: Potential API refinements based on ecosystem feedback + +### Ecosystem Integration +- **React Query adapter**: `@asouei/safe-fetch-react-query` available +- **SWR adapter**: Planned for Q2 2025 +- **ESLint plugin**: Planned for H2 2025 + +For the complete ecosystem roadmap, see the [main repository CHANGELOG](../../CHANGELOG.md). \ No newline at end of file diff --git a/packages/core/CONTRIBUTING.md b/packages/core/CONTRIBUTING.md new file mode 100644 index 0000000..54730a2 --- /dev/null +++ b/packages/core/CONTRIBUTING.md @@ -0,0 +1,128 @@ +# Contributing to @asouei/safe-fetch + +Thanks for your interest in contributing! We welcome issues, discussions, and pull requests. + +## Quick Start + +1. **Fork the repository** on GitHub +2. **Clone your fork**: + ```bash + git clone https://github.com//safe-fetch.git + cd safe-fetch + ``` +3. **Install dependencies**: + ```bash + pnpm install + ``` +4. **Run tests** to make sure everything works: + ```bash + pnpm test + ``` + +## Development Workflow + +### Code Style & Standards +- Use TypeScript and follow the existing code patterns +- Format code with Prettier (runs automatically on commit) +- Follow conventional naming: `camelCase` for functions/variables, `PascalCase` for types +- Keep functions focused and well-documented + +### Testing Requirements +- All new features **must** include tests using [Vitest](https://vitest.dev/) +- Aim for high test coverage of new code paths +- Test both success and error scenarios +- Mock external dependencies appropriately + +### Before Submitting +1. **Run the test suite**: + ```bash + pnpm test + ``` +2. **Check TypeScript compilation**: + ```bash + pnpm build + ``` +3. **Ensure code formatting**: + ```bash + pnpm format + ``` + +## Submitting Changes + +### Issues First +For significant changes, please [open an issue](../../issues/new) first to discuss: +- New features or breaking changes +- Performance improvements +- API modifications + +### Pull Request Process +1. **Create a feature branch** from `main`: + ```bash + git checkout -b feature/your-feature-name + ``` +2. **Make atomic commits** with clear, descriptive messages: + ```bash + git commit -m "feat: add retry delay jitter support" + ``` +3. **Update documentation** if behavior changes: + - README.md for user-facing changes + - JSDoc comments for API changes + - Add examples for new features +4. **Add yourself to CHANGELOG.md** under "Unreleased" if you want credit +5. **Push and create a pull request** with: + - Clear title describing the change + - Description of what changed and why + - Reference any related issues + +### CI Requirements +All pull requests must pass: +- ✅ TypeScript compilation (`pnpm build`) +- ✅ Test suite (`pnpm test`) +- ✅ Linting (`pnpm lint`) +- ✅ No conflicting dependencies + +## Project Structure + +``` +src/ +├── index.ts # Main export and createSafeFetch +├── types.ts # TypeScript definitions +├── errors.ts # Error constructors and utilities +├── type-guards.ts # Helper functions +└── __tests__/ # Test files +``` + +## Common Contribution Areas + +### Bug Fixes +- Check existing [issues](../../issues) first +- Include reproduction steps in your PR +- Add regression tests + +### Feature Additions +- Discuss design in an issue first +- Keep features focused and composable +- Update TypeScript types accordingly +- Add comprehensive tests and docs + +### Documentation +- Fix typos, improve clarity +- Add missing JSDoc comments +- Update README examples +- Improve error messages + +## Getting Help + +- **Questions?** Open a [discussion](../../discussions) +- **Bug reports?** Use our [issue template](../../issues/new) +- **Feature ideas?** Start with a [feature request](../../issues/new) + +## Code of Conduct + +We follow the [Contributor Covenant Code of Conduct](https://www.contributor-covenant.org/version/2/1/code_of_conduct/). + +**TL;DR**: Be respectful, helpful, and constructive. We're all here to build something useful together. + +--- + +**Thank you for contributing!** Every improvement, no matter how small, helps make `safe-fetch` better for everyone. \ No newline at end of file diff --git a/packages/core/LICENSE b/packages/core/LICENSE new file mode 100644 index 0000000..035a122 --- /dev/null +++ b/packages/core/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025–present Aleksandr Mikhailishin + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/packages/core/README.md b/packages/core/README.md new file mode 100644 index 0000000..2f12ae9 --- /dev/null +++ b/packages/core/README.md @@ -0,0 +1,609 @@ +# @asouei/safe-fetch + +[![npm version](https://img.shields.io/npm/v/@asouei/safe-fetch.svg)](https://www.npmjs.com/package/@asouei/safe-fetch) +[![CI](https://github.com/asouei/safe-fetch/actions/workflows/ci.yml/badge.svg?branch=master)](https://github.com/asouei/safe-fetch/actions/workflows/ci.yml) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) +[![npm](https://img.shields.io/npm/dm/@asouei/safe-fetch)](https://www.npmjs.com/package/@asouei/safe-fetch) +[![Open in CodeSandbox](https://img.shields.io/badge/Open%20in-CodeSandbox-black?logo=codesandbox)](https://codesandbox.io/p/sandbox/fkw3z5) +[![Awesome](https://awesome.re/badge-flat2.svg)](https://github.com/dzharii/awesome-typescript) + +*English version | [Русская версия](README.ru.md)* + +> **Never write `try/catch` for HTTP requests again.** Zero-deps • No throws • Total timeout • Retry-After support + +Tiny, typed wrapper around `fetch` that returns safe results, handles timeouts intelligently, and retries with exponential backoff. + +Part of the **[@asouei/safe-fetch ecosystem](../../README.md)** - also available: [React Query adapter](../react-query). + +📌 Featured in [Awesome TypeScript](https://github.com/dzharii/awesome-typescript). + +```typescript +import { safeFetch } from '@asouei/safe-fetch'; + +const result = await safeFetch.get<{ users: User[] }>('/api/users'); +if (result.ok) { + // TypeScript knows result.data is { users: User[] } + console.log(result.data.users); +} else { + // All errors are normalized - no more guessing what went wrong + console.error(result.error.name); // 'NetworkError' | 'TimeoutError' | 'HttpError' | 'ValidationError' +} +``` + +## What You Get + +- **No throws:** Never write `try/catch` — always get a safe result +- **Typed errors:** `NetworkError | TimeoutError | HttpError | ValidationError` +- **Dual timeouts:** `timeoutMs` per attempt + `totalTimeoutMs` for entire operation +- **Smarter retries:** Only idempotent methods by default + `Retry-After` support +- **Zod-ready validation:** Schema validation without exceptions +- **Zero deps & ~3kb:** Bundle-friendly, tree-shakable, side-effects free + +| Feature | `@asouei/safe-fetch` | `axios` | `ky` | native `fetch` | +|---------|---------------------|---------|------|---------------| +| **Bundle size** | ~3kb | ~13kb* | ~11kb* | 0kb | +| **Dependencies** | 0 | 0* | 0* | 0 | +| **Safe results (no throws)** | ✅ | ❌ | ❌ | ❌ | +| **Discriminated union types** | ✅ | ❌ | ❌ | ❌ | +| **Per-attempt + total timeouts** | ✅ | Per-request only | Per-request only | Manual | +| **Smart retries (idempotent-only)** | ✅ | ✅ (throws) | ✅ (throws) | Manual | +| **Retry-After header support** | ✅ | ❌ | ❌ | Manual | +| **Request/Response interceptors** | ✅ | ✅ | ✅ | Manual | +| **Validation hooks (Zod-ready)** | ✅ | ❌ | ❌ | Manual | +| **TypeScript-first design** | ✅ | Partial | ✅ | ✅ | + +*Bundle size ~gzip; depends on version, environment and bundler settings. +**Axios/Ky throw exceptions on non-2xx by default; no built-in total operation timeout. + +## Installation + +```bash +npm install @asouei/safe-fetch +``` + +### Import styles + +**ESM** +```typescript +import { safeFetch, createSafeFetch } from '@asouei/safe-fetch'; +``` + +**CommonJS** +```javascript +const { safeFetch, createSafeFetch } = require('@asouei/safe-fetch'); +// CommonJS supported via exports.require field +``` + +**CDN (esm.run)** +```html + +``` + +## Quick Demo + +```typescript +type Todo = { id: number; title: string; completed: boolean }; + +const api = createSafeFetch({ + baseURL: 'https://jsonplaceholder.typicode.com', + timeoutMs: 3000, + totalTimeoutMs: 7000, + retries: { retries: 2 }, +}); + +const list = await api.get('/todos', { query: { _limit: 3 } }); +if (list.ok) console.log('todos:', list.data.map(t => t.title)); + +const create = await api.post('/todos', { title: 'Learn safe-fetch', completed: false }); +if (!create.ok) console.warn('create failed:', create.error); +``` + +## JSON Parsing & Error Handling + +> **JSON parsing behavior:** +> - `204/205` status codes → `null` +> - If `Content-Type` doesn't contain `json` → `null` +> - Invalid JSON doesn't throw exception, returns `null` + +**Error types you may encounter:** `NetworkError`, `TimeoutError`, `HttpError`, `ValidationError`. +All errors are serializable (plain objects), easy to log and monitor. + +**Timeout behavior:** +- `timeoutMs` — per attempt timeout +- `totalTimeoutMs` — total operation timeout (includes all retries) + +**Tree-shakable, side-effects free** - only imports what you use. + +### Safe by Default +No more `try/catch` blocks. Every request returns a discriminated union: +```typescript +type SafeResult = + | { ok: true; data: T; response: Response } + | { ok: false; error: NormalizedError; response?: Response } +``` + +### Normalized Error Types +All errors are consistently typed and structured: +```typescript +// Network issues, connection failures +type NetworkError = { name: 'NetworkError'; message: string; cause?: unknown } + +// Request timeouts (per-attempt or total) +type TimeoutError = { name: 'TimeoutError'; message: string; timeoutMs: number } + +// HTTP 4xx/5xx responses +type HttpError = { name: 'HttpError'; message: string; status: number; body?: unknown } + +// Schema validation failures +type ValidationError = { name: 'ValidationError'; message: string; cause?: unknown } +``` + +### Intelligent Timeouts +Two-tier timeout system for maximum control: +```typescript +const api = createSafeFetch({ + timeoutMs: 5000, // 5s per attempt + totalTimeoutMs: 30000 // 30s total (all retries) +}); +``` + +### Smart Retries +Only retries safe operations by default: +- ✅ `GET`, `HEAD` - automatically retried on 5xx, network errors +- ❌ `POST`, `PUT`, `PATCH` - never retried by default (prevents duplication) +- 🎛️ Custom `retryOn` callback for full control + +```typescript +const result = await safeFetch.get('/api/flaky-endpoint', { + retries: { + retries: 3, + baseDelayMs: 300, // Exponential backoff starting at 300ms + retryOn: ({ response, error }) => { + // Custom retry logic + return error?.name === 'NetworkError' || response?.status === 429; + } + } +}); +``` + +### Respects Rate Limits +Automatically handles `429 Too Many Requests` with `Retry-After` header: +```typescript +// Server returns: 429 Too Many Requests, Retry-After: 60 +// safe-fetch waits exactly 60 seconds before retry +const result = await safeFetch.get('/api/rate-limited', { + retries: { retries: 3 } +}); +``` + +## Framework Integration + +### React Query + +**Easy integration** with the official adapter: + +```bash +npm install @asouei/safe-fetch-react-query +``` + +```typescript +import { createSafeFetch } from '@asouei/safe-fetch'; +import { createQueryFn, rqDefaults } from '@asouei/safe-fetch-react-query'; + +const api = createSafeFetch({ baseURL: '/api' }); +const queryFn = createQueryFn(api); + +export function useUsers() { + return useQuery({ + queryKey: ['users'], + queryFn: queryFn('/users'), + ...rqDefaults() // { retry: false } - let safe-fetch handle retries + }); +} +``` + +See the **[React Query adapter documentation](../react-query)** for complete integration guide. + +### SWR + +```typescript +import useSWR from 'swr'; + +const fetcher = async (url: string) => { + const result = await safeFetch.get(url); + if (!result.ok) throw result.error; + return result.data; +}; + +export function UserProfile({ id }: { id: string }) { + const { data, error } = useSWR(`/api/users/${id}`, fetcher); + if (error) return
Error: {error.message}
; + if (!data) return
Loading...
; + return
Hello, {data.name}!
; +} +``` + +## Migrate from Axios + +**Axios (throws exceptions)** +```typescript +try { + const { data } = await axios.get('/users'); + render(data); +} catch (e) { + toast(parseAxiosError(e)); +} +``` + +**safe-fetch (no throws)** +```typescript +const res = await safeFetch.get('/users'); +if (res.ok) render(res.data); +else toast(`${res.error.name}: ${res.error.message}`); +``` + +## Usage Examples + +### Basic Requests + +```typescript +import { safeFetch } from '@asouei/safe-fetch'; + +// GET request with type safety +const users = await safeFetch.get('/api/users'); +if (users.ok) { + users.data.forEach(user => console.log(user.name)); +} + +// POST with JSON body (auto-sets Content-Type) +const newUser = await safeFetch.post('/api/users', { + name: 'Alice', + email: 'alice@example.com' +}); + +// Handle different error types +if (!newUser.ok) { + switch (newUser.error.name) { + case 'HttpError': + // Use type assertion since we know the type from discriminated union + const httpError = newUser.error as { status: number; message: string }; + console.log(`HTTP ${httpError.status}: ${httpError.message}`); + break; + case 'NetworkError': + console.log('Network connection failed'); + break; + case 'TimeoutError': + const timeoutError = newUser.error as { timeoutMs: number }; + console.log(`Request timed out after ${timeoutError.timeoutMs}ms`); + break; + case 'ValidationError': + console.log('Response validation failed'); + break; + } +} +``` + +### Configured Instance + +```typescript +import { createSafeFetch } from '@asouei/safe-fetch'; + +const api = createSafeFetch({ + baseURL: 'https://api.example.com', + headers: { + 'Authorization': 'Bearer token', + 'User-Agent': 'MyApp/1.0' + }, + timeoutMs: 8000, + totalTimeoutMs: 30000, + retries: { + retries: 2, + baseDelayMs: 500 + } +}); + +// All requests use the base configuration +const result = await api.get('/users'); // GET https://api.example.com/users +``` + +### Response Validation with Zod + +Perfect integration with schema validation libraries: + +```typescript +import { z } from 'zod'; + +const UserSchema = z.object({ + id: z.number(), + name: z.string(), + email: z.string().email() +}); + +const validateWith = (schema: z.ZodSchema) => (raw: unknown) => { + const r = schema.safeParse(raw); + return r.success + ? { success: true as const, data: r.data } + : { success: false as const, error: r.error }; +}; + +const result = await safeFetch.get('/api/user/123', { + validate: validateWith(UserSchema) +}); + +if (result.ok) { + // result.data is fully typed as z.infer + console.log(result.data.email); // TypeScript knows this is a valid email +} +``` + +### Request/Response Interceptors + +```typescript +const api = createSafeFetch({ + interceptors: { + onRequest: (url, init) => { + // Add auth token + const headers = new Headers(init.headers); + headers.set('Authorization', `Bearer ${getToken()}`); + init.headers = headers; + + console.log(`→ ${init.method} ${url}`); + }, + + onResponse: (response) => { + console.log(`← ${response.status} ${response.url}`); + + // Handle global auth errors + if (response.status === 401) { + redirectToLogin(); + } + }, + + onError: (error) => { + // Send errors to monitoring service + analytics.track('http_error', { + error_name: error.name, + message: error.message + }); + } + } +}); +``` + +### Error Mapping + +Transform errors into domain-specific types: + +```typescript +const api = createSafeFetch({ + errorMap: (error) => { + if (error.name === 'HttpError' && error.status === 404) { + return { + name: 'NotFoundError', + message: 'Resource not found', + status: 404 + } as any; // Type assertion needed for extending domain errors + } + + if (error.name === 'HttpError' && error.status === 401) { + return { + name: 'AuthError', + message: 'Authentication required', + status: 401 + } as any; + } + + return error; + } +}); +``` + +### File Uploads & Different Content Types + +```typescript +// JSON (automatic Content-Type) +await safeFetch.post('/api/users', { name: 'John' }); + +// Form data +const formData = new FormData(); +formData.append('file', fileInput.files[0]); +formData.append('description', 'Profile picture'); +await safeFetch.post('/api/upload', formData); + +// Raw text +await safeFetch.post('/api/webhook', 'plain text', { + headers: { 'Content-Type': 'text/plain' } +}); + +// Get different response types +const csv = await safeFetch.get('/api/export.csv', { parseAs: 'text' }); +const blob = await safeFetch.get('/api/image.jpg', { parseAs: 'blob' }); +const raw = await safeFetch.get('/api/stream', { parseAs: 'response' }); +``` + +### AbortController Support + +```typescript +const controller = new AbortController(); + +const promise = safeFetch.get('/api/long-request', { + signal: controller.signal, + timeoutMs: 10000 +}); + +// Cancel after 5 seconds +setTimeout(() => controller.abort(), 5000); + +const result = await promise; +if (!result.ok && result.error.name === 'NetworkError') { + console.log('Request was cancelled'); +} +``` + +## Utility Helpers + +### Unwrap for Exception-Based Code + +```typescript +import type { SafeResult } from '@asouei/safe-fetch'; + +export const unwrap = async (promise: Promise>): Promise => { + const result = await promise; + if (!result.ok) throw result.error; + return result.data; +}; + +// Use when you want traditional exception handling +try { + const users = await unwrap(safeFetch.get('/api/users')); + console.log(users); // User[] - no need to check result.ok +} catch (error) { + console.error(error); // NormalizedError with consistent structure +} +``` + +### Type Guards for Error Handling + +```typescript +export const isHttpError = (e: unknown): e is { name: 'HttpError'; status: number; statusText: string } => + !!e && typeof e === 'object' && (e as any).name === 'HttpError' && typeof (e as any).status === 'number'; + +export const isNetworkError = (e: unknown): e is { name: 'NetworkError'; message: string } => + !!e && typeof e === 'object' && (e as any).name === 'NetworkError'; + +// Usage +const result = await safeFetch.get('/api/data'); +if (!result.ok) { + if (isHttpError(result.error)) { + console.log(`HTTP ${result.error.status}: ${result.error.statusText}`); + } else if (isNetworkError(result.error)) { + console.log('Network connection failed'); + } +} +``` + +## Limitations (by design) + +- No built-in caching/request deduplication (use SWR/TanStack Query) +- No automatic request/response transformations (keeps behavior predictable) +- Won't retry non-idempotent methods (POST/PUT/PATCH) without explicit `retryOn` + +## Who Is It For? + +- Teams tired of inconsistent `try/catch` blocks and implicit error handling +- Projects with strict SLA requirements needing total timeouts and proper retries +- TypeScript codebases requiring precise error type definitions +- Developers who want fetch's simplicity with production-ready reliability + +## Playground + +Try safe-fetch online with ready-to-run examples: +**CodeSandbox:** [Open Interactive Demo](https://codesandbox.io/p/sandbox/fkw3z5) + +## Browser & Runtime Support + +- **Node.js**: 18+ (uses built-in `fetch`) +- **Bun**: 1.1+ (with `fetch` support) +- **Browsers**: All modern browsers (Chrome 63+, Firefox 57+, Safari 10.1+) +- **SSR**: Next.js, Nuxt, SvelteKit compatible + +### Edge/Workers + +Works in Cloudflare Workers and Vercel Edge Runtime (uses global `fetch`): + +```typescript +// Cloudflare Worker +const isHttpError = (e: unknown): e is { name: 'HttpError'; status: number } => + !!e && typeof e === 'object' && (e as any).name === 'HttpError' && typeof (e as any).status === 'number'; + +export default { + async fetch() { + const res = await safeFetch.get<{ ok: boolean }>('https://api.example.com/ping'); + + if (res.ok) { + return new Response(JSON.stringify(res.data), { + headers: { 'content-type': 'application/json' } + }); + } + + const status = isHttpError(res.error) ? res.error.status : 500; + return new Response(res.error.message, { status }); + } +}; +``` + +## API Reference + +### `createSafeFetch(config?)` + +**Configuration Options:** + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `baseURL` | `string` | - | Base URL for all requests | +| `headers` | `Record` | `{}` | Default headers | +| `query` | `Record` | `{}` | Default query parameters | +| `timeoutMs` | `number` | `0` | Per-attempt timeout in milliseconds | +| `totalTimeoutMs` | `number` | `0` | Total timeout for all retry attempts | +| `retries` | `RetryStrategy` | `false` | Retry configuration | +| `parseAs` | `ParseAs` | `'json'` | Default response parsing method | +| `errorMap` | `ErrorMapper` | - | Transform errors before returning | +| `interceptors` | `Interceptors` | - | Request/response/error hooks | + +### Method Signatures + +```typescript +// Basic request +safeFetch(url: string, options?: SafeFetchRequest): Promise> + +// HTTP method shortcuts +safeFetch.get(url: string, options?: SafeFetchRequest): Promise> +safeFetch.post(url: string, body?: unknown, options?: SafeFetchRequest): Promise> +safeFetch.put(url: string, body?: unknown, options?: SafeFetchRequest): Promise> +safeFetch.patch(url: string, body?: unknown, options?: SafeFetchRequest): Promise> +safeFetch.delete(url: string, options?: SafeFetchRequest): Promise> +``` + +### Validation Function + +```typescript +validate: (raw: unknown) => { success: true, data: T } | { success: false, error: any } +``` + +## FAQ + +**Why not throw exceptions?** +Explicit control flow through `{ ok }` is easier to read, type, and test than try/catch around every operation. + +**Can I still throw exceptions if needed?** +Yes - use the `unwrap(result)` helper from the Utilities section. + +**Why don't POST/PUT/PATCH retry by default?** +To prevent duplicating side effects. Enable retries for non-idempotent methods explicitly via `retryOn` callback. + +**Does this work with React Query/SWR?** +Perfectly! Use our [React Query adapter](../react-query) or wrap your safeFetch calls with the `unwrap` helper. + +## Contributing + +Contributions are welcome! Please see our [Contributing Guide](../../CONTRIBUTING.md) for details. + +**Development Setup:** +```bash +git clone https://github.com/asouei/safe-fetch.git +cd safe-fetch/packages/core +pnpm install +pnpm test +pnpm build +``` + +## License + +MIT © [Aleksandr Mikhailishin](https://github.com/asouei) + +--- + +**Made with ❤️ for developers who value predictable, type-safe HTTP clients.** \ No newline at end of file diff --git a/packages/core/README.ru.md b/packages/core/README.ru.md new file mode 100644 index 0000000..f17c8ab --- /dev/null +++ b/packages/core/README.ru.md @@ -0,0 +1,410 @@ +# @asouei/safe-fetch + +[![npm version](https://img.shields.io/npm/v/@asouei/safe-fetch.svg)](https://www.npmjs.com/package/@asouei/safe-fetch) +[![CI](https://github.com/asouei/safe-fetch/actions/workflows/ci.yml/badge.svg?branch=master)](https://github.com/asouei/safe-fetch/actions/workflows/ci.yml) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) +[![npm downloads](https://img.shields.io/npm/dm/@asouei/safe-fetch)](https://www.npmjs.com/package/@asouei/safe-fetch) +[![TypeScript](https://img.shields.io/badge/TypeScript-Ready-blue.svg)](https://www.typescriptlang.org/) +[![Bundle Size](https://img.shields.io/bundlephobia/minzip/@asouei/safe-fetch)](https://bundlephobia.com/package/@asouei/safe-fetch) +[![Zero Dependencies](https://img.shields.io/badge/dependencies-0-green.svg)](package.json) + +*[English version](README.md) | Русская версия* + +> **Никогда больше не пишите `try/catch` для HTTP-запросов.** Ноль зависимостей • Не бросает исключения • Полный таймаут • Поддержка Retry-After + +Маленькая, типизированная обертка вокруг `fetch`, которая возвращает безопасные результаты, умно обрабатывает таймауты и повторяет запросы с экспоненциальным отступом. + +Часть **[экосистемы @asouei/safe-fetch](../../README.ru.md)** - также доступен: [адаптер React Query](../react-query). + +📌 Библиотека вошла в список [Awesome TypeScript](https://github.com/dzharii/awesome-typescript). + +```typescript +import { safeFetch } from '@asouei/safe-fetch'; + +const result = await safeFetch.get<{ users: User[] }>('/api/users'); +if (result.ok) { + // TypeScript знает, что result.data это { users: User[] } + console.log(result.data.users); +} else { + // Все ошибки нормализованы - больше не нужно угадывать что пошло не так + console.error(result.error.name); // 'NetworkError' | 'TimeoutError' | 'HttpError' | 'ValidationError' +} +``` + +## Что вы получаете + +- **Не бросает исключения:** Никогда не пишите `try/catch` — всегда получайте безопасный результат +- **Типизированные ошибки:** `NetworkError | TimeoutError | HttpError | ValidationError` +- **Двойные таймауты:** `timeoutMs` на попытку + `totalTimeoutMs` для всей операции +- **Умные повторы:** Только идемпотентные методы по умолчанию + поддержка `Retry-After` +- **Готовность к Zod:** Валидация схем без исключений +- **Ноль зависимостей и ~3кб:** Дружелюбен к бандлерам, tree-shakable, без побочных эффектов + +| Функция | `@asouei/safe-fetch` | `axios` | `ky` | нативный `fetch` | +|---------|---------------------|---------|------|------------------| +| **Размер бандла** | ~3кб | ~13кб* | ~11кб* | 0кб | +| **Зависимости** | 0 | 0* | 0* | 0 | +| **Безопасные результаты (без исключений)** | ✅ | ❌ | ❌ | ❌ | +| **Дискриминированные union типы** | ✅ | ❌ | ❌ | ❌ | +| **Per-attempt + полный таймауты** | ✅ | Только на запрос | Только на запрос | Вручную | +| **Умные повторы (только идемпотентные)** | ✅ | ✅ (бросает) | ✅ (бросает) | Вручную | +| **Поддержка заголовка Retry-After** | ✅ | ❌ | ❌ | Вручную | +| **Интерсепторы запроса/ответа** | ✅ | ✅ | ✅ | Вручную | +| **Хуки валидации (готов к Zod)** | ✅ | ❌ | ❌ | Вручную | +| **TypeScript-first дизайн** | ✅ | Частично | ✅ | ✅ | + +*Размер бандла ~gzip; зависит от версии, окружения и настроек бандлера. +**Axios/Ky бросают исключения на non-2xx по умолчанию; нет встроенного полного таймаута операции. + +## Установка + +```bash +npm install @asouei/safe-fetch +``` + +### Стили импорта + +**ESM** +```typescript +import { safeFetch, createSafeFetch } from '@asouei/safe-fetch'; +``` + +**CommonJS** +```javascript +const { safeFetch, createSafeFetch } = require('@asouei/safe-fetch'); +// CommonJS поддерживается через поле exports.require +``` + +**CDN (esm.run)** +```html + +``` + +## Быстрое демо + +```typescript +type Todo = { id: number; title: string; completed: boolean }; + +const api = createSafeFetch({ + baseURL: 'https://jsonplaceholder.typicode.com', + timeoutMs: 3000, + totalTimeoutMs: 7000, + retries: { retries: 2 }, +}); + +const list = await api.get('/todos', { query: { _limit: 3 } }); +if (list.ok) console.log('todos:', list.data.map(t => t.title)); + +const create = await api.post('/todos', { title: 'Изучить safe-fetch', completed: false }); +if (!create.ok) console.warn('создание не удалось:', create.error); +``` + +## Парсинг JSON и обработка ошибок + +> **Поведение парсинга JSON:** +> - Коды статуса `204/205` → `null` +> - Если `Content-Type` не содержит `json` → `null` +> - Невалидный JSON не бросает исключение, возвращает `null` + +**Типы ошибок, которые могут встретиться:** `NetworkError`, `TimeoutError`, `HttpError`, `ValidationError`. +Все ошибки сериализуемы (обычные объекты), легко логировать и мониторить. + +**Поведение таймаута:** +- `timeoutMs` — таймаут на попытку +- `totalTimeoutMs` — таймаут всей операции (включает все повторы) + +**Tree-shakable, без побочных эффектов** - импортируете только то, что используете. + +### Безопасно по умолчанию +Больше никаких блоков `try/catch`. Каждый запрос возвращает дискриминированное объединение: +```typescript +type SafeResult = + | { ok: true; data: T; response: Response } + | { ok: false; error: NormalizedError; response?: Response } +``` + +### Нормализованные типы ошибок +Все ошибки последовательно типизированы и структурированы: +```typescript +// Сетевые проблемы, сбои подключения +type NetworkError = { name: 'NetworkError'; message: string; cause?: unknown } + +// Таймауты запроса (на попытку или полный) +type TimeoutError = { name: 'TimeoutError'; message: string; timeoutMs: number } + +// HTTP 4xx/5xx ответы +type HttpError = { name: 'HttpError'; message: string; status: number; body?: unknown } + +// Сбои валидации схемы +type ValidationError = { name: 'ValidationError'; message: string; cause?: unknown } +``` + +### Умные таймауты +Двухуровневая система таймаутов для максимального контроля: +```typescript +const api = createSafeFetch({ + timeoutMs: 5000, // 5с на попытку + totalTimeoutMs: 30000 // 30с всего (все повторы) +}); +``` + +### Умные повторы +По умолчанию повторяет только безопасные операции: +- ✅ `GET`, `HEAD` - автоматически повторяются на 5xx, сетевых ошибках +- ❌ `POST`, `PUT`, `PATCH` - никогда не повторяются по умолчанию (предотвращает дублирование) +- 🎛️ Кастомный колбек `retryOn` для полного контроля + +```typescript +const result = await safeFetch.get('/api/flaky-endpoint', { + retries: { + retries: 3, + baseDelayMs: 300, // Экспоненциальный отступ начиная с 300мс + retryOn: ({ response, error }) => { + // Кастомная логика повтора + return error?.name === 'NetworkError' || response?.status === 429; + } + } +}); +``` + +### Уважает лимиты скорости +Автоматически обрабатывает `429 Too Many Requests` с заголовком `Retry-After`: +```typescript +// Сервер возвращает: 429 Too Many Requests, Retry-After: 60 +// safe-fetch ждет ровно 60 секунд перед повтором +const result = await safeFetch.get('/api/rate-limited', { + retries: { retries: 3 } +}); +``` + +## Интеграция с фреймворками + +### React Query + +**Простая интеграция** с официальным адаптером: + +```bash +npm install @asouei/safe-fetch-react-query +``` + +```typescript +import { createSafeFetch } from '@asouei/safe-fetch'; +import { createQueryFn, rqDefaults } from '@asouei/safe-fetch-react-query'; + +const api = createSafeFetch({ baseURL: '/api' }); +const queryFn = createQueryFn(api); + +export function useUsers() { + return useQuery({ + queryKey: ['users'], + queryFn: queryFn('/users'), + ...rqDefaults() // { retry: false } - пусть safe-fetch обрабатывает повторы + }); +} +``` + +См. **[документацию адаптера React Query](../react-query)** для полного руководства по интеграции. + +### SWR + +```typescript +import useSWR from 'swr'; + +const fetcher = async (url: string) => { + const result = await safeFetch.get(url); + if (!result.ok) throw result.error; + return result.data; +}; + +export function UserProfile({ id }: { id: string }) { + const { data, error } = useSWR(`/api/users/${id}`, fetcher); + if (error) return
Ошибка: {error.message}
; + if (!data) return
Загрузка...
; + return
Привет, {data.name}!
; +} +``` + +## Миграция с Axios + +**Axios (бросает исключения)** +```typescript +try { + const { data } = await axios.get('/users'); + render(data); +} catch (e) { + toast(parseAxiosError(e)); +} +``` + +**safe-fetch (не бросает)** +```typescript +const res = await safeFetch.get('/users'); +if (res.ok) render(res.data); +else toast(`${res.error.name}: ${res.error.message}`); +``` + +## Примеры использования + +### Базовые запросы + +```typescript +import { safeFetch } from '@asouei/safe-fetch'; + +// GET запрос с типобезопасностью +const users = await safeFetch.get('/api/users'); +if (users.ok) { + users.data.forEach(user => console.log(user.name)); +} + +// POST с JSON телом (автоматически устанавливает Content-Type) +const newUser = await safeFetch.post('/api/users', { + name: 'Алиса', + email: 'alice@example.com' +}); + +// Обработка разных типов ошибок +if (!newUser.ok) { + switch (newUser.error.name) { + case 'HttpError': + // Используем type assertion, так как знаем тип из дискриминированного объединения + const httpError = newUser.error as { status: number; message: string }; + console.log(`HTTP ${httpError.status}: ${httpError.message}`); + break; + case 'NetworkError': + console.log('Сбой сетевого подключения'); + break; + case 'TimeoutError': + const timeoutError = newUser.error as { timeoutMs: number }; + console.log(`Запрос превысил время ожидания через ${timeoutError.timeoutMs}мс`); + break; + case 'ValidationError': + console.log('Валидация ответа не удалась'); + break; + } +} +``` + +### Настроенный экземпляр + +```typescript +import { createSafeFetch } from '@asouei/safe-fetch'; + +const api = createSafeFetch({ + baseURL: 'https://api.example.com', + headers: { + 'Authorization': 'Bearer token', + 'User-Agent': 'MyApp/1.0' + }, + timeoutMs: 8000, + totalTimeoutMs: 30000, + retries: { + retries: 2, + baseDelayMs: 500 + } +}); + +// Все запросы используют базовую конфигурацию +const result = await api.get('/users'); // GET https://api.example.com/users +``` + +### Валидация ответов с Zod + +Идеальная интеграция с библиотеками валидации схем: + +```typescript +import { z } from 'zod'; + +const UserSchema = z.object({ + id: z.number(), + name: z.string(), + email: z.string().email() +}); + +const validateWith = (schema: z.ZodSchema) => (raw: unknown) => { + const r = schema.safeParse(raw); + return r.success + ? { success: true as const, data: r.data } + : { success: false as const, error: r.error }; +}; + +const result = await safeFetch.get('/api/user/123', { + validate: validateWith(UserSchema) +}); + +if (result.ok) { + // result.data полностью типизирован как z.infer + console.log(result.data.email); // TypeScript знает, что это валидный email +} +``` + +### Интерсепторы запроса/ответа + +```typescript +const api = createSafeFetch({ + interceptors: { + onRequest: (url, init) => { + // Добавляем токен авторизации + const headers = new Headers(init.headers); + headers.set('Authorization', `Bearer ${getToken()}`); + init.headers = headers; + + console.log(`→ ${init.method} ${url}`); + }, + + onResponse: (response) => { + console.log(`← ${response.status} ${response.url}`); + + // Обрабатываем глобальные ошибки авторизации + if (response.status === 401) { + redirectToLogin(); + } + }, + + onError: (error) => { + // Отправляем ошибки в сервис мониторинга + analytics.track('http_error', { + error_name: error.name, + message: error.message + }); + } + } +}); +``` + +## FAQ + +**Почему не бросать исключения?** +Явный поток управления через `{ ok }` легче читать, типизировать и тестировать, чем try/catch вокруг каждой операции. + +**Можно ли все же бросать исключения при необходимости?** +Да - используйте хелпер `unwrap(result)` из секции Утилиты. + +**Почему POST/PUT/PATCH не повторяются по умолчанию?** +Чтобы предотвратить дублирование побочных эффектов. Включите повторы для неидемпотентных методов явно через колбек `retryOn`. + +**Работает ли это с React Query/SWR?** +Идеально! Используйте наш [адаптер React Query](../react-query) или оберните ваши вызовы safeFetch хелпером `unwrap`. + +## Участие в разработке + +Вклады приветствуются! Пожалуйста, прочитайте наш [Гид по участию](../../CONTRIBUTING.md) для подробностей. + +**Настройка разработки:** +```bash +git clone https://github.com/asouei/safe-fetch.git +cd safe-fetch/packages/core +pnpm install +pnpm test +pnpm build +``` + +## Лицензия + +MIT © [Aleksandr Mikhailishin](https://github.com/asouei) + +--- + +**Сделано с ❤️ для разработчиков, которые ценят предсказуемые, типобезопасные HTTP клиенты.** \ No newline at end of file diff --git a/packages/core/package.json b/packages/core/package.json new file mode 100644 index 0000000..487213e --- /dev/null +++ b/packages/core/package.json @@ -0,0 +1,70 @@ +{ + "name": "@asouei/safe-fetch", + "version": "1.0.0", + "description": "Tiny, typed wrapper around fetch with safe results, normalized errors, timeouts, retries and validation hooks.", + "type": "module", + "main": "dist/index.umd.cjs", + "module": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.umd.cjs" + } + }, + "files": [ + "dist", + "README.md", + "LICENSE" + ], + "sideEffects": false, + "scripts": { + "build": "vite build", + "dev": "vite build --watch", + "test": "vitest run", + "test:watch": "vitest", + "clean": "rimraf dist", + "prepublishOnly": "npm run clean && npm run build && npm test", + "lint": "echo lint:ok" + }, + "keywords": [ + "fetch", + "safe", + "typescript", + "http", + "retry", + "timeout", + "error-handling", + "discriminated-union", + "zero-dependencies", + "type-safe" + ], + "author": "Aleksandr Mikhailishin", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/asouei/safe-fetch.git", + "directory": "packages/core" + }, + "bugs": { + "url": "https://github.com/asouei/safe-fetch/issues" + }, + "homepage": "https://asouei.dev", + "publishConfig": { + "access": "public" + }, + "engines": { + "node": ">=18" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.9.0", + "vite": "^6.0.0", + "vite-plugin-dts": "^4.5.4", + "vitest": "^3.2.4", + "jsdom": "^26.1.0", + "rimraf": "^6.0.1" + }, + "peerDependencies": {} +} \ No newline at end of file diff --git a/src/errors.ts b/packages/core/src/errors.ts similarity index 100% rename from src/errors.ts rename to packages/core/src/errors.ts diff --git a/src/index.ts b/packages/core/src/index.ts similarity index 100% rename from src/index.ts rename to packages/core/src/index.ts diff --git a/src/types.ts b/packages/core/src/types.ts similarity index 100% rename from src/types.ts rename to packages/core/src/types.ts diff --git a/src/utils.ts b/packages/core/src/utils.ts similarity index 100% rename from src/utils.ts rename to packages/core/src/utils.ts diff --git a/tests/index.test.ts b/packages/core/tests/index.test.ts similarity index 91% rename from tests/index.test.ts rename to packages/core/tests/index.test.ts index dc0165b..c823b6e 100644 --- a/tests/index.test.ts +++ b/packages/core/tests/index.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { createSafeFetch, safeFetch } from '../src'; +import { isError, isSuccess } from './type-guards'; const mockFetch = vi.fn(); vi.stubGlobal('fetch', mockFetch); @@ -26,7 +27,7 @@ describe('safe-fetch', () => { const res = await api.get<{ hello: string }>('/hello'); expect(res.ok).toBe(true); - if (res.ok) { + if (isSuccess(res)) { expect(res.data.hello).toBe('world'); expect(res.response.status).toBe(200); } @@ -41,7 +42,7 @@ describe('safe-fetch', () => { const res = await api.get('/error'); expect(res.ok).toBe(false); - if (!res.ok) { + if (isError(res)) { expect(res.error.name).toBe('HttpError'); expect((res.error as any).status).toBe(500); expect(res.response?.status).toBe(500); @@ -55,7 +56,7 @@ describe('safe-fetch', () => { const res = await api.get('/fail'); expect(res.ok).toBe(false); - if (!res.ok) { + if (isError(res)) { expect(res.error.name).toBe('NetworkError'); expect(res.error.message).toBe('Network request failed'); } @@ -66,7 +67,7 @@ describe('safe-fetch', () => { it('GET method works', async () => { mockFetch.mockResolvedValueOnce(new Response('{"data": "get"}', { status: 200 })); - const res = await safeFetch.get('/test'); + await safeFetch.get('/test'); expect(mockFetch).toHaveBeenCalledWith( expect.stringContaining('/test'), expect.objectContaining({ method: 'GET' }) @@ -77,7 +78,7 @@ describe('safe-fetch', () => { mockFetch.mockResolvedValueOnce(new Response('{"data": "post"}', { status: 200 })); const body = { name: 'test' }; - const res = await safeFetch.post('/test', body); + await safeFetch.post('/test', body); expect(mockFetch).toHaveBeenCalledWith( expect.stringContaining('/test'), @@ -94,7 +95,7 @@ describe('safe-fetch', () => { it('DELETE method works', async () => { mockFetch.mockResolvedValueOnce(new Response(null, { status: 204 })); - const res = await safeFetch.delete('/test/123'); + await safeFetch.delete('/test/123'); expect(mockFetch).toHaveBeenCalledWith( expect.stringContaining('/test/123'), expect.objectContaining({ method: 'DELETE' }) @@ -177,7 +178,7 @@ describe('safe-fetch', () => { const api = createSafeFetch({ retries: { retries: 2, - retryOn: ({ response }) => response?.status === 500 + retryOn: (ctx: any) => ctx.response?.status === 500 } }); const res = await api.post('/test', { data: 'test' }); @@ -186,9 +187,7 @@ describe('safe-fetch', () => { expect(res.ok).toBe(true); }); - // <-- ПЕРЕПИСАННЫЙ ТЕСТ it('respects Retry-After on 429', async () => { - // фейкаем таймеры + Date, чтобы CI не зависел от реального времени vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }); mockFetch @@ -202,9 +201,7 @@ describe('safe-fetch', () => { const promise = api.get('/rate'); await Promise.resolve(); - await vi.advanceTimersByTimeAsync(1000); - await vi.runOnlyPendingTimersAsync(); await Promise.resolve(); @@ -228,7 +225,7 @@ describe('safe-fetch', () => { const res = await promise; expect(res.ok).toBe(false); - if (!res.ok) { + if (isError(res)) { expect(res.error.name).toBe('TimeoutError'); expect(res.error.message).toContain('timed out after 2000 ms'); } @@ -240,14 +237,14 @@ describe('safe-fetch', () => { describe('error handling', () => { it('normalizes different error types', async () => { mockFetch.mockRejectedValueOnce(new TypeError('Failed to fetch')); - let res = await safeFetch.get('/test'); - expect(res.ok).toBe(false); - if (!res.ok) expect(res.error.name).toBe('NetworkError'); + const networkRes = await safeFetch.get('/test'); + expect(networkRes.ok).toBe(false); + if (isError(networkRes)) expect(networkRes.error.name).toBe('NetworkError'); mockFetch.mockResolvedValueOnce(new Response('Not found', { status: 404 })); - res = await safeFetch.get('/test'); - expect(res.ok).toBe(false); - if (!res.ok) expect(res.error.name).toBe('HttpError'); + const httpRes = await safeFetch.get('/test'); + expect(httpRes.ok).toBe(false); + if (isError(httpRes)) expect(httpRes.error.name).toBe('HttpError'); }); }); @@ -266,7 +263,7 @@ describe('safe-fetch', () => { const res = await safeFetch.get('/user', { validate }); expect(res.ok).toBe(true); - if (res.ok) { + if (isSuccess(res)) { expect(res.data.id).toBe(1); expect(res.data.name).toBe('test'); } @@ -286,10 +283,10 @@ describe('safe-fetch', () => { const res = await safeFetch.get('/user', { validate }); expect(res.ok).toBe(false); - if (!res.ok) { + if (isError(res)) { expect(res.error.name).toBe('ValidationError'); expect(res.error.message).toBe('Validation failed'); } }); }); -}); +}); \ No newline at end of file diff --git a/packages/core/tests/type-guards.ts b/packages/core/tests/type-guards.ts new file mode 100644 index 0000000..509a699 --- /dev/null +++ b/packages/core/tests/type-guards.ts @@ -0,0 +1,13 @@ +import type { SafeResult, NormalizedError } from '../src'; + +export function isError( + r: SafeResult +): r is { ok: false; error: NormalizedError; response?: Response } { + return !r.ok; +} + +export function isSuccess( + r: SafeResult +): r is { ok: true; data: T; response: Response } { + return r.ok; +} \ No newline at end of file diff --git a/tsconfig.json b/packages/core/tsconfig.json similarity index 100% rename from tsconfig.json rename to packages/core/tsconfig.json diff --git a/vite.config.ts b/packages/core/vite.config.ts similarity index 51% rename from vite.config.ts rename to packages/core/vite.config.ts index 439f6ec..fee3eef 100644 --- a/vite.config.ts +++ b/packages/core/vite.config.ts @@ -1,20 +1,31 @@ -/// -import { defineConfig } from 'vite'; +import { defineConfig } from 'vitest/config'; import dts from 'vite-plugin-dts'; +import { resolve } from 'node:path'; export default defineConfig({ + resolve: { + alias: { + '@asouei/safe-fetch': resolve(__dirname, 'src'), + }, + }, build: { lib: { entry: 'src/index.ts', name: 'SafeFetch', - fileName: (format) => (format === 'umd' ? 'index.umd.cjs' : 'index.js'), - formats: ['es', 'cjs', 'umd'] + fileName: (format) => { + switch (format) { + case 'es': return 'index.js'; + case 'cjs': return 'index.umd.cjs'; + default: return 'index.js'; + } + }, + formats: ['es', 'cjs'] }, sourcemap: true, outDir: 'dist', emptyOutDir: true, target: 'es2020', - minify: true, + minify: false, rollupOptions: { output: { globals: {} @@ -24,22 +35,25 @@ export default defineConfig({ plugins: [ dts({ include: ['src/**/*'], - exclude: ['**/*.test.*', '**/__tests__/**'] + exclude: ['**/*.test.*', '**/__tests__/**', 'src/**/*.spec.ts'] }) ], test: { - environment: 'jsdom', + environment: 'node', globals: true, - setupFiles: [], testTimeout: 15_000, hookTimeout: 15_000, coverage: { + provider: 'v8', reporter: ['text', 'html'], + reportsDirectory: 'coverage', exclude: [ 'node_modules/', 'tests/', 'dist/', '**/*.d.ts', + '**/*.test.*', + '**/*.spec.*' ] } } diff --git a/packages/react-query/.npmignore b/packages/react-query/.npmignore new file mode 100644 index 0000000..b4c0077 --- /dev/null +++ b/packages/react-query/.npmignore @@ -0,0 +1,35 @@ +# Ignore build configs & lockfiles +pnpm-lock.yaml +yarn.lock +package-lock.json +tsconfig.json +vitest.config.*.ts +vite.config.ts + +# Ignore source maps if you don’t want to publish them +*.map + +# Ignore tests & mocks +__tests__/ +test/ +tests/ +*.test.ts +*.spec.ts + +# Ignore coverage output +coverage/ + +# Ignore local dev configs +.eslint* +.prettier* +.editorconfig +.idea/ +.vscode/ +*.log + +# Ignore GitHub stuff +.github/ +.gitignore + +# Ignore misc +.DS_Store diff --git a/packages/react-query/CHANGELOG.md b/packages/react-query/CHANGELOG.md new file mode 100644 index 0000000..f59677d --- /dev/null +++ b/packages/react-query/CHANGELOG.md @@ -0,0 +1,44 @@ +# Changelog + +All notable changes to **@asouei/safe-fetch-react-query** will be documented here. +We follow [Semantic Versioning](https://semver.org/). + +## [0.1.0] - 2025-09-05 (Initial Release) + +### Added + +**🎉 First experimental release of the React Query adapter** + +- **Core adapter functions:** + - `createQueryFn(api)` - Factory for creating React Query compatible query functions + - `createMutationFn(api)` - Factory for creating React Query compatible mutation functions + - `unwrap(promise)` - Utility to convert safe results to throws (re-exported from core) + - `rqDefaults()` - Returns `{ retry: false }` to let safe-fetch handle retries + +- **Features:** + - ✅ Converts `{ ok: false, error }` → `throw error` for React Query compatibility + - ✅ Preserves safe-fetch's typed error system (`NetworkError`, `HttpError`, etc.) + - ✅ Zero peer dependencies on React (works in any React Query setup) + - ✅ TypeScript-first design with full type inference + - ✅ Minimal bundle size - just thin wrapper functions + +- **Documentation:** + - Complete README with examples and best practices + - Migration guide from direct safe-fetch usage + - Advanced patterns: validation, error handling, infinite queries + - Troubleshooting section for TypeScript discriminated union issues + +### Technical Details + +- **Compatibility**: React Query v5.x +- **Bundle size**: ~500 bytes (minimal wrappers only) +- **Peer dependencies**: `@tanstack/react-query ^5.0.0`, `@asouei/safe-fetch workspace:*` +- **Status**: Experimental (0.x) - API may change based on community feedback + +--- + +## Planned Roadmap + +- **v0.2**: Optional custom hooks (`useSafeQuery`, `useSafeMutation`) for convenience +- **v0.3**: Support for React Query DevTools integration +- **v1.0**: Stable API after community validation and feedback \ No newline at end of file diff --git a/packages/react-query/LICENSE b/packages/react-query/LICENSE new file mode 100644 index 0000000..035a122 --- /dev/null +++ b/packages/react-query/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025–present Aleksandr Mikhailishin + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/packages/react-query/README.md b/packages/react-query/README.md new file mode 100644 index 0000000..85de02d --- /dev/null +++ b/packages/react-query/README.md @@ -0,0 +1,336 @@ +# @asouei/safe-fetch-react-query + +[![npm version](https://img.shields.io/npm/v/@asouei/safe-fetch-react-query.svg)](https://www.npmjs.com/package/@asouei/safe-fetch-react-query) +[![npm downloads](https://img.shields.io/npm/dw/@asouei/safe-fetch-react-query.svg)](https://www.npmjs.com/package/@asouei/safe-fetch-react-query) +[![bundle size](https://img.shields.io/bundlephobia/minzip/@asouei/safe-fetch-react-query)](https://bundlephobia.com/package/@asouei/safe-fetch-react-query) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) + +*English version | [Русская версия](README.ru.md)* + +**Experimental (0.x)** - TanStack React Query adapter for [@asouei/safe-fetch](../core) + +> Converts safe-fetch results to throws and provides sensible defaults for React Query integration. + +## What It Does + +This adapter bridges the gap between `safe-fetch`'s safe result API (`{ ok: true | false }`) and React Query's expectation of thrown errors for failed requests. It provides: + +- **Result conversion**: `{ ok: false, error }` → `throw error` +- **Factory functions**: Ready-made `queryFn` and `mutationFn` creators +- **Sensible defaults**: Recommends `retry: false` to let safe-fetch handle retries + +## Installation + +```bash +npm install @asouei/safe-fetch @asouei/safe-fetch-react-query @tanstack/react-query +# or +pnpm add @asouei/safe-fetch @asouei/safe-fetch-react-query @tanstack/react-query +``` + +## Quick Example + +```typescript +import { createSafeFetch } from '@asouei/safe-fetch'; +import { createQueryFn, createMutationFn, rqDefaults } from '@asouei/safe-fetch-react-query'; +import { useQuery, useMutation } from '@tanstack/react-query'; + +const api = createSafeFetch({ + baseURL: '/api', + retries: { retries: 2 } // Let safe-fetch handle retries +}); + +const queryFn = createQueryFn(api); +const mutationFn = createMutationFn(api); + +export function useUsers() { + return useQuery({ + queryKey: ['users'], + queryFn: queryFn('/users'), + ...rqDefaults(), // Important: { retry: false } + }); +} + +export function useCreateUser() { + return useMutation({ + mutationFn: mutationFn('/users', { method: 'POST' }), + }); +} + +// Usage in component +function UserList() { + const { data: users, error, isLoading } = useUsers(); + const createUser = useCreateUser(); + + if (isLoading) return
Loading...
; + if (error) return
Error: {error.name}
; // Typed error from safe-fetch + + return ( +
+ {users?.map(user =>
{user.name}
)} + +
+ ); +} +``` + +## API Reference + +### `createQueryFn(api)` +Creates a query function factory for React Query. + +```typescript +const queryFn = createQueryFn(api); +const getUsersFn = queryFn('/users', { + headers: { Authorization: 'Bearer token' } +}); + +useQuery({ + queryKey: ['users'], + queryFn: getUsersFn, + ...rqDefaults() +}); +``` + +### `createMutationFn(api)` +Creates a mutation function factory. Defaults to `POST` method. + +```typescript +const mutationFn = createMutationFn(api); +const createUserFn = mutationFn('/users'); // POST by default +const updateUserFn = mutationFn('/users', { method: 'PUT' }); + +useMutation({ + mutationFn: createUserFn // (body) => Promise +}); +``` + +### `rqDefaults()` +Returns recommended React Query defaults. + +```typescript +rqDefaults(); // { retry: false } +``` + +**Why `retry: false`?** Let safe-fetch handle retries with proper exponential backoff, jitter, and `Retry-After` support instead of React Query's simpler retry logic. + +### `unwrap(promise)` +Utility to convert safe results to throws (re-exported from core for convenience). + +```typescript +const result = await unwrap(safeFetch.get('/users')); +// Throws on error, returns data on success +``` + +## Advanced Usage + +### Custom Query Hook with Validation + +```typescript +import { z } from 'zod'; + +const UserSchema = z.array(z.object({ + id: z.number(), + name: z.string(), + email: z.string().email() +})); + +export function useUsers() { + const queryFn = createQueryFn(api); + + return useQuery({ + queryKey: ['users'], + queryFn: queryFn>('/users', { + validate: (raw) => { + const result = UserSchema.safeParse(raw); + return result.success + ? { success: true, data: result.data } + : { success: false, error: result.error }; + } + }), + ...rqDefaults() + }); +} +``` + +### Error Handling with Type Guards + +```typescript +import type { HttpError, NetworkError } from '@asouei/safe-fetch'; + +const isHttpError = (error: any): error is HttpError => + error?.name === 'HttpError'; + +const isNetworkError = (error: any): error is NetworkError => + error?.name === 'NetworkError'; + +function UserList() { + const { data, error } = useUsers(); + + if (error) { + if (isHttpError(error)) { + return
Server error: {error.status} {error.statusText}
; + } + if (isNetworkError(error)) { + return
Network error: Check your connection
; + } + return
Unknown error: {error.message}
; + } + + return
{/* render users */}
; +} +``` + +### Infinite Queries + +```typescript +export function useInfiniteUsers() { + const queryFn = createQueryFn(api); + + return useInfiniteQuery({ + queryKey: ['users', 'infinite'], + queryFn: ({ pageParam = 1 }) => + queryFn<{ users: User[]; nextPage?: number }>('/users', { + query: { page: pageParam, limit: 10 } + })(), + getNextPageParam: (lastPage) => lastPage.nextPage, + ...rqDefaults() + }); +} +``` + +## Best Practices + +### 1. Always use `rqDefaults()` +```typescript +// ✅ Good +useQuery({ + queryKey: ['users'], + queryFn: queryFn('/users'), + ...rqDefaults() +}); + +// ❌ Avoid - React Query will retry with its own logic +useQuery({ + queryKey: ['users'], + queryFn: queryFn('/users') + // missing rqDefaults() +}); +``` + +### 2. Configure retries in safe-fetch, not React Query +```typescript +// ✅ Good +const api = createSafeFetch({ + retries: { + retries: 2, + baseDelayMs: 300 + } +}); + +// ❌ Avoid - double retries +useQuery({ + queryFn: queryFn('/users'), + retry: 3 // Don't do this with safe-fetch +}); +``` + +### 3. Handle loading states properly +```typescript +function UserProfile({ id }: { id: string }) { + const { data: user, isLoading, error } = useQuery({ + queryKey: ['users', id], + queryFn: queryFn(`/users/${id}`), + ...rqDefaults() + }); + + // Handle all states explicitly + if (isLoading) return ; + if (error) return ; + if (!user) return ; // Shouldn't happen, but be safe + + return
{user.name}
; +} +``` + +## Compatibility + +- **React Query**: v5.x +- **SSR/Next.js**: Compatible (pure functions, no runtime React dependency) +- **Bundle size**: Minimal - only thin wrapper functions + +## Why This Approach? + +Instead of providing custom hooks like `useSafeQuery`, this adapter focuses on: + +1. **Minimal API surface**: Just factory functions +2. **No React peer dependency**: Works in any React Query setup +3. **Composable**: Use with existing React Query patterns +4. **Type-safe**: Preserves safe-fetch's error typing + +## Troubleshooting + +### "Query function threw an error" +This is expected! The adapter converts `{ ok: false }` results into thrown errors that React Query can handle. + +### Type errors with query functions +Make sure to specify the expected return type: +```typescript +// ✅ Good +const queryFn = createQueryFn(api); +const getUserFn = queryFn('/user/123'); + +// ❌ Type issues +const getUserFn = queryFn('/user/123'); // unknown return type +``` + +### Retries not working as expected +Remember to use `rqDefaults()` to disable React Query's retries: +```typescript +useQuery({ + queryKey: ['data'], + queryFn: queryFn('/data'), + ...rqDefaults() // This sets retry: false +}); +``` + +## Migration from Direct safe-fetch + +**Before:** +```typescript +function useUsers() { + return useQuery({ + queryKey: ['users'], + queryFn: async () => { + const result = await safeFetch.get('/users'); + if (!result.ok) throw result.error; + return result.data; + }, + retry: false + }); +} +``` + +**After:** +```typescript +const queryFn = createQueryFn(api); + +function useUsers() { + return useQuery({ + queryKey: ['users'], + queryFn: queryFn('/users'), + ...rqDefaults() + }); +} +``` + +## Roadmap + +- **v0.1**: Core adapter functions ✅ **Published** +- **v0.2**: Optional custom hooks (`useSafeQuery`, `useSafeMutation`) +- **v1.0**: Stable production release after community feedback + +## License + +MIT © [Aleksandr Mikhailishin](https://github.com/asouei) \ No newline at end of file diff --git a/packages/react-query/README.ru.md b/packages/react-query/README.ru.md new file mode 100644 index 0000000..cd31573 --- /dev/null +++ b/packages/react-query/README.ru.md @@ -0,0 +1,336 @@ +# @asouei/safe-fetch-react-query + +[![npm version](https://img.shields.io/npm/v/@asouei/safe-fetch-react-query.svg)](https://www.npmjs.com/package/@asouei/safe-fetch-react-query) +[![npm downloads](https://img.shields.io/npm/dw/@asouei/safe-fetch-react-query.svg)](https://www.npmjs.com/package/@asouei/safe-fetch-react-query) +[![bundle size](https://img.shields.io/bundlephobia/minzip/@asouei/safe-fetch-react-query)](https://bundlephobia.com/package/@asouei/safe-fetch-react-query) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) + +*[English version](README.md) | Русская версия* + +**Экспериментальная (0.x)** - адаптер TanStack React Query для [@asouei/safe-fetch](../core) + +> Преобразует результаты safe-fetch в исключения и предоставляет разумные значения по умолчанию для интеграции с React Query. + +## Что это делает + +Этот адаптер соединяет API безопасных результатов `safe-fetch` (`{ ok: true | false }`) с ожиданием React Query получать брошенные ошибки для неудачных запросов. Он предоставляет: + +- **Преобразование результатов**: `{ ok: false, error }` → `throw error` +- **Фабричные функции**: Готовые создатели `queryFn` и `mutationFn` +- **Разумные значения по умолчанию**: Рекомендует `retry: false`, чтобы safe-fetch обрабатывал повторы + +## Установка + +```bash +npm install @asouei/safe-fetch @asouei/safe-fetch-react-query @tanstack/react-query +# или +pnpm add @asouei/safe-fetch @asouei/safe-fetch-react-query @tanstack/react-query +``` + +## Быстрый пример + +```typescript +import { createSafeFetch } from '@asouei/safe-fetch'; +import { createQueryFn, createMutationFn, rqDefaults } from '@asouei/safe-fetch-react-query'; +import { useQuery, useMutation } from '@tanstack/react-query'; + +const api = createSafeFetch({ + baseURL: '/api', + retries: { retries: 2 } // Пусть safe-fetch обрабатывает повторы +}); + +const queryFn = createQueryFn(api); +const mutationFn = createMutationFn(api); + +export function useUsers() { + return useQuery({ + queryKey: ['users'], + queryFn: queryFn('/users'), + ...rqDefaults(), // Важно: { retry: false } + }); +} + +export function useCreateUser() { + return useMutation({ + mutationFn: mutationFn('/users', { method: 'POST' }), + }); +} + +// Использование в компоненте +function UserList() { + const { data: users, error, isLoading } = useUsers(); + const createUser = useCreateUser(); + + if (isLoading) return
Загрузка...
; + if (error) return
Ошибка: {error.name}
; // Типизированная ошибка от safe-fetch + + return ( +
+ {users?.map(user =>
{user.name}
)} + +
+ ); +} +``` + +## Справочник API + +### `createQueryFn(api)` +Создает фабрику функций запросов для React Query. + +```typescript +const queryFn = createQueryFn(api); +const getUsersFn = queryFn('/users', { + headers: { Authorization: 'Bearer token' } +}); + +useQuery({ + queryKey: ['users'], + queryFn: getUsersFn, + ...rqDefaults() +}); +``` + +### `createMutationFn(api)` +Создает фабрику функций мутаций. По умолчанию использует метод `POST`. + +```typescript +const mutationFn = createMutationFn(api); +const createUserFn = mutationFn('/users'); // POST по умолчанию +const updateUserFn = mutationFn('/users', { method: 'PUT' }); + +useMutation({ + mutationFn: createUserFn // (body) => Promise +}); +``` + +### `rqDefaults()` +Возвращает рекомендуемые значения по умолчанию для React Query. + +```typescript +rqDefaults(); // { retry: false } +``` + +**Почему `retry: false`?** Пусть safe-fetch обрабатывает повторы с правильным экспоненциальным отступом, джиттером и поддержкой `Retry-After` вместо более простой логики повторов React Query. + +### `unwrap(promise)` +Утилита для преобразования безопасных результатов в исключения (реэкспортирована из core для удобства). + +```typescript +const result = await unwrap(safeFetch.get('/users')); +// Бросает исключение при ошибке, возвращает данные при успехе +``` + +## Расширенное использование + +### Кастомный хук запроса с валидацией + +```typescript +import { z } from 'zod'; + +const UserSchema = z.array(z.object({ + id: z.number(), + name: z.string(), + email: z.string().email() +})); + +export function useUsers() { + const queryFn = createQueryFn(api); + + return useQuery({ + queryKey: ['users'], + queryFn: queryFn>('/users', { + validate: (raw) => { + const result = UserSchema.safeParse(raw); + return result.success + ? { success: true, data: result.data } + : { success: false, error: result.error }; + } + }), + ...rqDefaults() + }); +} +``` + +### Обработка ошибок с Type Guards + +```typescript +import type { HttpError, NetworkError } from '@asouei/safe-fetch'; + +const isHttpError = (error: any): error is HttpError => + error?.name === 'HttpError'; + +const isNetworkError = (error: any): error is NetworkError => + error?.name === 'NetworkError'; + +function UserList() { + const { data, error } = useUsers(); + + if (error) { + if (isHttpError(error)) { + return
Ошибка сервера: {error.status} {error.statusText}
; + } + if (isNetworkError(error)) { + return
Сетевая ошибка: Проверьте подключение
; + } + return
Неизвестная ошибка: {error.message}
; + } + + return
{/* отрисовка пользователей */}
; +} +``` + +### Бесконечные запросы + +```typescript +export function useInfiniteUsers() { + const queryFn = createQueryFn(api); + + return useInfiniteQuery({ + queryKey: ['users', 'infinite'], + queryFn: ({ pageParam = 1 }) => + queryFn<{ users: User[]; nextPage?: number }>('/users', { + query: { page: pageParam, limit: 10 } + })(), + getNextPageParam: (lastPage) => lastPage.nextPage, + ...rqDefaults() + }); +} +``` + +## Лучшие практики + +### 1. Всегда используйте `rqDefaults()` +```typescript +// ✅ Хорошо +useQuery({ + queryKey: ['users'], + queryFn: queryFn('/users'), + ...rqDefaults() +}); + +// ❌ Избегайте - React Query будет повторять со своей логикой +useQuery({ + queryKey: ['users'], + queryFn: queryFn('/users') + // отсутствует rqDefaults() +}); +``` + +### 2. Настраивайте повторы в safe-fetch, а не в React Query +```typescript +// ✅ Хорошо +const api = createSafeFetch({ + retries: { + retries: 2, + baseDelayMs: 300 + } +}); + +// ❌ Избегайте - двойные повторы +useQuery({ + queryFn: queryFn('/users'), + retry: 3 // Не делайте так с safe-fetch +}); +``` + +### 3. Правильно обрабатывайте состояния загрузки +```typescript +function UserProfile({ id }: { id: string }) { + const { data: user, isLoading, error } = useQuery({ + queryKey: ['users', id], + queryFn: queryFn(`/users/${id}`), + ...rqDefaults() + }); + + // Явно обрабатывайте все состояния + if (isLoading) return ; + if (error) return ; + if (!user) return ; // Не должно произойти, но будьте осторожны + + return
{user.name}
; +} +``` + +## Совместимость + +- **React Query**: v5.x +- **SSR/Next.js**: Совместимо (чистые функции, без runtime зависимости от React) +- **Размер бандла**: Минимальный - только тонкие обертки + +## Почему такой подход? + +Вместо предоставления кастомных хуков типа `useSafeQuery`, этот адаптер фокусируется на: + +1. **Минимальная поверхность API**: Только фабричные функции +2. **Без peer зависимости от React**: Работает в любой настройке React Query +3. **Композабельность**: Используйте с существующими паттернами React Query +4. **Типобезопасность**: Сохраняет типизацию ошибок safe-fetch + +## Устранение неполадок + +### "Query function threw an error" +Это ожидаемо! Адаптер преобразует результаты `{ ok: false }` в брошенные ошибки, которые может обрабатывать React Query. + +### Ошибки типов с функциями запросов +Убедитесь, что указали ожидаемый тип возврата: +```typescript +// ✅ Хорошо +const queryFn = createQueryFn(api); +const getUserFn = queryFn('/user/123'); + +// ❌ Проблемы с типами +const getUserFn = queryFn('/user/123'); // неизвестный тип возврата +``` + +### Повторы работают не как ожидалось +Не забывайте использовать `rqDefaults()` для отключения повторов React Query: +```typescript +useQuery({ + queryKey: ['data'], + queryFn: queryFn('/data'), + ...rqDefaults() // Это устанавливает retry: false +}); +``` + +## Миграция с прямого safe-fetch + +**До:** +```typescript +function useUsers() { + return useQuery({ + queryKey: ['users'], + queryFn: async () => { + const result = await safeFetch.get('/users'); + if (!result.ok) throw result.error; + return result.data; + }, + retry: false + }); +} +``` + +**После:** +```typescript +const queryFn = createQueryFn(api); + +function useUsers() { + return useQuery({ + queryKey: ['users'], + queryFn: queryFn('/users'), + ...rqDefaults() + }); +} +``` + +## Дорожная карта + +- **v0.1**: Основные функции адаптера ✅ **Опубликовано** +- **v0.2**: Опциональные кастомные хуки (`useSafeQuery`, `useSafeMutation`) +- **v1.0**: Стабильный продакшн релиз после отзывов сообщества + +## Лицензия + +MIT © [Aleksandr Mikhailishin](https://github.com/asouei) \ No newline at end of file diff --git a/packages/react-query/package.json b/packages/react-query/package.json new file mode 100644 index 0000000..fe1f841 --- /dev/null +++ b/packages/react-query/package.json @@ -0,0 +1,70 @@ +{ + "name": "@asouei/safe-fetch-react-query", + "version": "0.1.0", + "description": "React Query adapter for @asouei/safe-fetch", + "type": "module", + "main": "./dist/index.cjs", + "module": "./dist/index.mjs", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.mjs", + "require": "./dist/index.cjs" + } + }, + "files": [ + "dist", + "README.md", + "LICENSE" + ], + "sideEffects": false, + "scripts": { + "build": "vite build", + "dev": "vite build --watch", + "test": "vitest run", + "test:watch": "vitest", + "clean": "rimraf dist", + "prepublishOnly": "npm run clean && npm run build && npm test", + "lint": "echo lint:ok" + }, + "keywords": [ + "react-query", + "tanstack", + "safe-fetch", + "http", + "typesafe", + "fetch", + "adapter", + "no-throw" + ], + "author": "Aleksandr Mikhailishin", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/asouei/safe-fetch.git", + "directory": "packages/react-query" + }, + "bugs": { + "url": "https://github.com/asouei/safe-fetch/issues" + }, + "homepage": "https://asouei.dev", + "publishConfig": { + "access": "public" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@tanstack/react-query": "^5.0.0", + "@asouei/safe-fetch": "workspace:*" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.9.0", + "vite": "^6.0.0", + "vite-plugin-dts": "^4.5.4", + "vitest": "^3.2.4", + "rimraf": "^6.0.1" + } +} \ No newline at end of file diff --git a/packages/react-query/src/index.ts b/packages/react-query/src/index.ts new file mode 100644 index 0000000..7182010 --- /dev/null +++ b/packages/react-query/src/index.ts @@ -0,0 +1,31 @@ +import type { SafeFetcher, SafeFetchRequest, SafeResult } from '@asouei/safe-fetch'; + +export const unwrap = async (p: Promise>): Promise => { + const r = await p; + if (!r.ok) throw r.error; + return r.data; +}; + +export const createQueryFn = + (api: SafeFetcher) => + (url: string, init?: SafeFetchRequest) => + async (): Promise => { + return unwrap(api(url, init)); + }; + +export const createMutationFn = + (api: SafeFetcher) => + (url: string, init?: SafeFetchRequest) => + async (body?: unknown): Promise => { + const method = (init?.method ?? 'POST').toUpperCase(); + const nextInit: SafeFetchRequest = { + ...init, + method: method as any, + body: body as any + }; + return unwrap(api(url, nextInit)); + }; + +export const rqDefaults = () => ({ + retry: false as const +}); \ No newline at end of file diff --git a/packages/react-query/tests/index.test.ts b/packages/react-query/tests/index.test.ts new file mode 100644 index 0000000..6c509f2 --- /dev/null +++ b/packages/react-query/tests/index.test.ts @@ -0,0 +1,138 @@ +import { describe, it, expect, vi } from 'vitest'; +import { createQueryFn, createMutationFn, rqDefaults, unwrap } from '../src'; + +describe('@asouei/safe-fetch-react-query', () => { + describe('unwrap', () => { + it('returns data from successful result', async () => { + const result = Promise.resolve({ + ok: true as const, + data: { id: 1, name: 'test' }, + response: new Response() + }); + + const data = await unwrap(result); + expect(data).toEqual({ id: 1, name: 'test' }); + }); + + it('throws error from failed result', async () => { + const error = { name: 'HttpError' as const, message: 'Not found', status: 404 }; + const result = Promise.resolve({ + ok: false as const, + error + }); + + await expect(unwrap(result as any)).rejects.toEqual(error); + }); + }); + + describe('createQueryFn', () => { + it('creates function that calls api and returns data', async () => { + const mockApi = vi.fn().mockResolvedValue({ + ok: true, + data: { users: ['alice', 'bob'] }, + response: new Response() + }); + + const queryFn = createQueryFn(mockApi as any)<{ users: string[] }>('/users'); + const result = await queryFn(); + + expect(result).toEqual({ users: ['alice', 'bob'] }); + expect(mockApi).toHaveBeenCalledWith('/users', undefined); + }); + + it('creates function that throws on api error', async () => { + const error = { name: 'NetworkError' as const, message: 'Failed' }; + const mockApi = vi.fn().mockResolvedValue({ ok: false, error }); + + const queryFn = createQueryFn(mockApi as any)('/users'); + + await expect(queryFn()).rejects.toEqual(error); + }); + + it('passes init options to api', async () => { + const mockApi = vi.fn().mockResolvedValue({ + ok: true, + data: {}, + response: new Response() + }); + + const init = { headers: { 'X-Test': 'value' } }; + const queryFn = createQueryFn(mockApi as any)('/users', init); + await queryFn(); + + expect(mockApi).toHaveBeenCalledWith('/users', init); + }); + }); + + describe('createMutationFn', () => { + it('creates function with POST method by default', async () => { + const mockApi = vi.fn().mockImplementation((url, init) => { + return Promise.resolve({ + ok: true, + data: { method: init?.method, body: init?.body }, + response: new Response() + }); + }); + + const mutationFn = createMutationFn(mockApi as any)<{ method: string; body: any }>('/users'); + const result = await mutationFn({ name: 'John' }); + + expect(result.method).toBe('POST'); + expect(result.body).toEqual({ name: 'John' }); + }); + + it('uses custom method from init', async () => { + const mockApi = vi.fn().mockImplementation((url, init) => { + return Promise.resolve({ + ok: true, + data: { method: init?.method }, + response: new Response() + }); + }); + + const mutationFn = createMutationFn(mockApi as any)<{ method: string }>('/users', { method: 'PUT' }); + const result = await mutationFn({ name: 'Jane' }); + + expect(result.method).toBe('PUT'); + }); + + it('merges init options with method and body', async () => { + const mockApi = vi.fn().mockImplementation((url, init) => { + return Promise.resolve({ + ok: true, + data: init, + response: new Response() + }); + }); + + const customInit = { headers: { 'Authorization': 'Bearer token' } }; + const mutationFn = createMutationFn(mockApi as any)('/users', customInit); + const result = await mutationFn({ id: 123 }); + + expect(result.method).toBe('POST'); + expect(result.body).toEqual({ id: 123 }); + expect(result.headers).toEqual({ 'Authorization': 'Bearer token' }); + }); + + it('throws error on api failure', async () => { + const error = { name: 'ValidationError' as const, message: 'Invalid data' }; + const mockApi = vi.fn().mockResolvedValue({ ok: false, error }); + + const mutationFn = createMutationFn(mockApi as any)('/users'); + + await expect(mutationFn({ invalid: true })).rejects.toEqual(error); + }); + }); + + describe('rqDefaults', () => { + it('returns retry false', () => { + const defaults = rqDefaults(); + expect(defaults).toEqual({ retry: false }); + }); + + it('returns object with const retry property', () => { + const defaults = rqDefaults(); + expect(defaults.retry).toBe(false); + }); + }); +}); \ No newline at end of file diff --git a/packages/react-query/tsconfig.json b/packages/react-query/tsconfig.json new file mode 100644 index 0000000..eec0779 --- /dev/null +++ b/packages/react-query/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2020"], + "declaration": true, + "declarationMap": true, + "outDir": "dist", + "strict": true, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "forceConsistentCasingInFileNames": true, + "exactOptionalPropertyTypes": true, + "noUncheckedIndexedAccess": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src/**/*"], + "exclude": ["dist", "node_modules", "tests", "**/*.test.ts"] +} \ No newline at end of file diff --git a/packages/react-query/vite.config.ts b/packages/react-query/vite.config.ts new file mode 100644 index 0000000..46c71e1 --- /dev/null +++ b/packages/react-query/vite.config.ts @@ -0,0 +1,59 @@ +/// +import { defineConfig } from 'vite'; +import dts from 'vite-plugin-dts'; + +export default defineConfig({ + build: { + lib: { + entry: 'src/index.ts', + name: 'SafeFetchReactQuery', + fileName: (format) => { + switch (format) { + case 'es': return 'index.mjs'; + case 'cjs': return 'index.cjs'; + case 'umd': return 'index.umd.cjs'; + default: return 'index.js'; + } + }, + formats: ['es', 'cjs'] + }, + sourcemap: true, + outDir: 'dist', + emptyOutDir: true, + target: 'es2020', + minify: false, + rollupOptions: { + external: ['@asouei/safe-fetch', '@tanstack/react-query'], + output: { + globals: { + '@asouei/safe-fetch': 'SafeFetch', + '@tanstack/react-query': 'ReactQuery' + } + } + } + }, + plugins: [ + dts({ + include: ['src/**/*'], + exclude: ['**/*.test.*', '**/__tests__/**', 'src/**/*.spec.ts'] + }) + ], + test: { + environment: 'node', + globals: true, + testTimeout: 15_000, + hookTimeout: 15_000, + coverage: { + provider: 'v8', + reporter: ['text', 'html'], + exclude: [ + 'node_modules/', + 'tests/', + 'dist/', + '**/*.d.ts', + '**/*.test.*', + '**/*.spec.*' + ] + } + } +}); \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index da93104..5755380 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,11 +6,13 @@ settings: importers: - .: + .: {} + + packages/core: devDependencies: '@types/node': specifier: ^22.0.0 - version: 22.18.0 + version: 22.18.1 jsdom: specifier: ^26.1.0 version: 26.1.0 @@ -22,13 +24,41 @@ importers: version: 5.9.2 vite: specifier: ^6.0.0 - version: 6.3.5(@types/node@22.18.0) + version: 6.3.5(@types/node@22.18.1) vite-plugin-dts: specifier: ^4.5.4 - version: 4.5.4(@types/node@22.18.0)(rollup@4.50.0)(typescript@5.9.2)(vite@6.3.5(@types/node@22.18.0)) + version: 4.5.4(@types/node@22.18.1)(rollup@4.50.0)(typescript@5.9.2)(vite@6.3.5(@types/node@22.18.1)) vitest: specifier: ^3.2.4 - version: 3.2.4(@types/node@22.18.0)(jsdom@26.1.0) + version: 3.2.4(@types/node@22.18.1)(jsdom@26.1.0) + + packages/react-query: + dependencies: + '@asouei/safe-fetch': + specifier: workspace:* + version: link:../core + '@tanstack/react-query': + specifier: ^5.0.0 + version: 5.86.0(react@19.1.1) + devDependencies: + '@types/node': + specifier: ^22.0.0 + version: 22.18.1 + rimraf: + specifier: ^6.0.1 + version: 6.0.1 + typescript: + specifier: ^5.9.0 + version: 5.9.2 + vite: + specifier: ^6.0.0 + version: 6.3.5(@types/node@22.18.1) + vite-plugin-dts: + specifier: ^4.5.4 + version: 4.5.4(@types/node@22.18.1)(rollup@4.50.0)(typescript@5.9.2)(vite@6.3.5(@types/node@22.18.1)) + vitest: + specifier: ^3.2.4 + version: 3.2.4(@types/node@22.18.1)(jsdom@26.1.0) packages: @@ -400,6 +430,14 @@ packages: '@rushstack/ts-command-line@5.0.2': resolution: {integrity: sha512-+AkJDbu1GFMPIU8Sb7TLVXDv/Q7Mkvx+wAjEl8XiXVVq+p1FmWW6M3LYpJMmoHNckSofeMecgWg5lfMwNAAsEQ==} + '@tanstack/query-core@5.86.0': + resolution: {integrity: sha512-Y6ibQm6BXbw6w1p3a5LrPn8Ae64M0dx7hGmnhrm9P+XAkCCKXOwZN0J5Z1wK/0RdNHtR9o+sWHDXd4veNI60tQ==} + + '@tanstack/react-query@5.86.0': + resolution: {integrity: sha512-jgS/v0oSJkGHucv9zxOS8rL7mjATh1XO3K4eqAV4WMpAly8okcBrGi1YxRZN5S4B59F54x9JFjWrK5vMAvJYqA==} + peerDependencies: + react: ^18 || ^19 + '@types/argparse@1.0.38': resolution: {integrity: sha512-ebDJ9b0e702Yr7pWgB0jzm+CX4Srzz8RcXtLJDJB+BSccqMa36uyH/zUsSYao5+BD1ytv3k3rPYCq4mAE1hsXA==} @@ -412,8 +450,8 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} - '@types/node@22.18.0': - resolution: {integrity: sha512-m5ObIqwsUp6BZzyiy4RdZpzWGub9bqLJMvZDD0QMXhxjqMHMENlj+SqF5QxoUwaQNFe+8kz8XM8ZQhqkQPTgMQ==} + '@types/node@22.18.1': + resolution: {integrity: sha512-rzSDyhn4cYznVG+PCzGe1lwuMYJrcBS1fc3JqSa2PvtABwWo+dZ1ij5OVok3tqfpEBCBoaR4d7upFJk73HRJDw==} '@vitest/expect@3.2.4': resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} @@ -453,11 +491,11 @@ packages: '@volar/typescript@2.4.23': resolution: {integrity: sha512-lAB5zJghWxVPqfcStmAP1ZqQacMpe90UrP5RJ3arDyrhy4aCUQqmxPPLB2PWDKugvylmO41ljK7vZ+t6INMTag==} - '@vue/compiler-core@3.5.20': - resolution: {integrity: sha512-8TWXUyiqFd3GmP4JTX9hbiTFRwYHgVL/vr3cqhr4YQ258+9FADwvj7golk2sWNGHR67QgmCZ8gz80nQcMokhwg==} + '@vue/compiler-core@3.5.21': + resolution: {integrity: sha512-8i+LZ0vf6ZgII5Z9XmUvrCyEzocvWT+TeR2VBUVlzIH6Tyv57E20mPZ1bCS+tbejgUgmjrEh7q/0F0bibskAmw==} - '@vue/compiler-dom@3.5.20': - resolution: {integrity: sha512-whB44M59XKjqUEYOMPYU0ijUV0G+4fdrHVKDe32abNdX/kJe1NUEMqsi4cwzXa9kyM9w5S8WqFsrfo1ogtBZGQ==} + '@vue/compiler-dom@3.5.21': + resolution: {integrity: sha512-jNtbu/u97wiyEBJlJ9kmdw7tAr5Vy0Aj5CgQmo+6pxWNQhXZDPsRr1UWPN4v3Zf82s2H3kF51IbzZ4jMWAgPlQ==} '@vue/compiler-vue2@2.7.16': resolution: {integrity: sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==} @@ -470,8 +508,8 @@ packages: typescript: optional: true - '@vue/shared@3.5.20': - resolution: {integrity: sha512-SoRGP596KU/ig6TfgkCMbXkr4YJ91n/QSdMuqeP5r3hVIYA3CPHUBCc7Skak0EAKV+5lL4KyIh61VA/pK1CIAA==} + '@vue/shared@3.5.21': + resolution: {integrity: sha512-+2k1EQpnYuVuu3N7atWyG3/xoFWIVJZq4Mz8XNOdScFI0etES75fbny/oU4lKWk/577P1zmg0ioYvpGEDZ3DLw==} acorn@8.15.0: resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} @@ -756,8 +794,8 @@ packages: lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} - lru-cache@11.1.0: - resolution: {integrity: sha512-QIXZUBJUx+2zHUdQujWejBkcD9+cs94tLn0+YL8UrCh+D5sCXZ4c7LaEH48pNwRY3MLDgqUFyhlCyjJPf1WP0A==} + lru-cache@11.2.1: + resolution: {integrity: sha512-r8LA6i4LP4EeWOhqBaZZjDWwehd1xUJPCJd9Sv300H0ZmcUER4+JPh7bqqZeqs1o5pgtgvXm+d9UGrB5zZGDiQ==} engines: {node: 20 || >=22} lru-cache@6.0.0: @@ -847,6 +885,10 @@ packages: quansync@0.2.11: resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} + react@19.1.1: + resolution: {integrity: sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==} + engines: {node: '>=0.10.0'} + require-from-string@2.0.2: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} @@ -1294,23 +1336,23 @@ snapshots: '@jridgewell/sourcemap-codec@1.5.5': {} - '@microsoft/api-extractor-model@7.30.7(@types/node@22.18.0)': + '@microsoft/api-extractor-model@7.30.7(@types/node@22.18.1)': dependencies: '@microsoft/tsdoc': 0.15.1 '@microsoft/tsdoc-config': 0.17.1 - '@rushstack/node-core-library': 5.14.0(@types/node@22.18.0) + '@rushstack/node-core-library': 5.14.0(@types/node@22.18.1) transitivePeerDependencies: - '@types/node' - '@microsoft/api-extractor@7.52.11(@types/node@22.18.0)': + '@microsoft/api-extractor@7.52.11(@types/node@22.18.1)': dependencies: - '@microsoft/api-extractor-model': 7.30.7(@types/node@22.18.0) + '@microsoft/api-extractor-model': 7.30.7(@types/node@22.18.1) '@microsoft/tsdoc': 0.15.1 '@microsoft/tsdoc-config': 0.17.1 - '@rushstack/node-core-library': 5.14.0(@types/node@22.18.0) + '@rushstack/node-core-library': 5.14.0(@types/node@22.18.1) '@rushstack/rig-package': 0.5.3 - '@rushstack/terminal': 0.15.4(@types/node@22.18.0) - '@rushstack/ts-command-line': 5.0.2(@types/node@22.18.0) + '@rushstack/terminal': 0.15.4(@types/node@22.18.1) + '@rushstack/ts-command-line': 5.0.2(@types/node@22.18.1) lodash: 4.17.21 minimatch: 10.0.3 resolve: 1.22.10 @@ -1400,7 +1442,7 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.50.0': optional: true - '@rushstack/node-core-library@5.14.0(@types/node@22.18.0)': + '@rushstack/node-core-library@5.14.0(@types/node@22.18.1)': dependencies: ajv: 8.13.0 ajv-draft-04: 1.0.0(ajv@8.13.0) @@ -1411,29 +1453,36 @@ snapshots: resolve: 1.22.10 semver: 7.5.4 optionalDependencies: - '@types/node': 22.18.0 + '@types/node': 22.18.1 '@rushstack/rig-package@0.5.3': dependencies: resolve: 1.22.10 strip-json-comments: 3.1.1 - '@rushstack/terminal@0.15.4(@types/node@22.18.0)': + '@rushstack/terminal@0.15.4(@types/node@22.18.1)': dependencies: - '@rushstack/node-core-library': 5.14.0(@types/node@22.18.0) + '@rushstack/node-core-library': 5.14.0(@types/node@22.18.1) supports-color: 8.1.1 optionalDependencies: - '@types/node': 22.18.0 + '@types/node': 22.18.1 - '@rushstack/ts-command-line@5.0.2(@types/node@22.18.0)': + '@rushstack/ts-command-line@5.0.2(@types/node@22.18.1)': dependencies: - '@rushstack/terminal': 0.15.4(@types/node@22.18.0) + '@rushstack/terminal': 0.15.4(@types/node@22.18.1) '@types/argparse': 1.0.38 argparse: 1.0.10 string-argv: 0.3.2 transitivePeerDependencies: - '@types/node' + '@tanstack/query-core@5.86.0': {} + + '@tanstack/react-query@5.86.0(react@19.1.1)': + dependencies: + '@tanstack/query-core': 5.86.0 + react: 19.1.1 + '@types/argparse@1.0.38': {} '@types/chai@5.2.2': @@ -1444,7 +1493,7 @@ snapshots: '@types/estree@1.0.8': {} - '@types/node@22.18.0': + '@types/node@22.18.1': dependencies: undici-types: 6.21.0 @@ -1456,13 +1505,13 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.4(vite@6.3.5(@types/node@22.18.0))': + '@vitest/mocker@3.2.4(vite@6.3.5(@types/node@22.18.1))': dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.18 optionalDependencies: - vite: 6.3.5(@types/node@22.18.0) + vite: 6.3.5(@types/node@22.18.1) '@vitest/pretty-format@3.2.4': dependencies: @@ -1502,18 +1551,18 @@ snapshots: path-browserify: 1.0.1 vscode-uri: 3.1.0 - '@vue/compiler-core@3.5.20': + '@vue/compiler-core@3.5.21': dependencies: '@babel/parser': 7.28.3 - '@vue/shared': 3.5.20 + '@vue/shared': 3.5.21 entities: 4.5.0 estree-walker: 2.0.2 source-map-js: 1.2.1 - '@vue/compiler-dom@3.5.20': + '@vue/compiler-dom@3.5.21': dependencies: - '@vue/compiler-core': 3.5.20 - '@vue/shared': 3.5.20 + '@vue/compiler-core': 3.5.21 + '@vue/shared': 3.5.21 '@vue/compiler-vue2@2.7.16': dependencies: @@ -1523,9 +1572,9 @@ snapshots: '@vue/language-core@2.2.0(typescript@5.9.2)': dependencies: '@volar/language-core': 2.4.23 - '@vue/compiler-dom': 3.5.20 + '@vue/compiler-dom': 3.5.21 '@vue/compiler-vue2': 2.7.16 - '@vue/shared': 3.5.20 + '@vue/shared': 3.5.21 alien-signals: 0.4.14 minimatch: 9.0.5 muggle-string: 0.4.1 @@ -1533,7 +1582,7 @@ snapshots: optionalDependencies: typescript: 5.9.2 - '@vue/shared@3.5.20': {} + '@vue/shared@3.5.21': {} acorn@8.15.0: {} @@ -1818,7 +1867,7 @@ snapshots: lru-cache@10.4.3: {} - lru-cache@11.1.0: {} + lru-cache@11.2.1: {} lru-cache@6.0.0: dependencies: @@ -1867,7 +1916,7 @@ snapshots: path-scurry@2.0.0: dependencies: - lru-cache: 11.1.0 + lru-cache: 11.2.1 minipass: 7.1.2 pathe@2.0.3: {} @@ -1900,6 +1949,8 @@ snapshots: quansync@0.2.11: {} + react@19.1.1: {} + require-from-string@2.0.2: {} resolve@1.22.10: @@ -2051,13 +2102,13 @@ snapshots: dependencies: punycode: 2.3.1 - vite-node@3.2.4(@types/node@22.18.0): + vite-node@3.2.4(@types/node@22.18.1): dependencies: cac: 6.7.14 debug: 4.4.1 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 6.3.5(@types/node@22.18.0) + vite: 6.3.5(@types/node@22.18.1) transitivePeerDependencies: - '@types/node' - jiti @@ -2072,9 +2123,9 @@ snapshots: - tsx - yaml - vite-plugin-dts@4.5.4(@types/node@22.18.0)(rollup@4.50.0)(typescript@5.9.2)(vite@6.3.5(@types/node@22.18.0)): + vite-plugin-dts@4.5.4(@types/node@22.18.1)(rollup@4.50.0)(typescript@5.9.2)(vite@6.3.5(@types/node@22.18.1)): dependencies: - '@microsoft/api-extractor': 7.52.11(@types/node@22.18.0) + '@microsoft/api-extractor': 7.52.11(@types/node@22.18.1) '@rollup/pluginutils': 5.2.0(rollup@4.50.0) '@volar/typescript': 2.4.23 '@vue/language-core': 2.2.0(typescript@5.9.2) @@ -2085,13 +2136,13 @@ snapshots: magic-string: 0.30.18 typescript: 5.9.2 optionalDependencies: - vite: 6.3.5(@types/node@22.18.0) + vite: 6.3.5(@types/node@22.18.1) transitivePeerDependencies: - '@types/node' - rollup - supports-color - vite@6.3.5(@types/node@22.18.0): + vite@6.3.5(@types/node@22.18.1): dependencies: esbuild: 0.25.9 fdir: 6.5.0(picomatch@4.0.3) @@ -2100,14 +2151,14 @@ snapshots: rollup: 4.50.0 tinyglobby: 0.2.14 optionalDependencies: - '@types/node': 22.18.0 + '@types/node': 22.18.1 fsevents: 2.3.3 - vitest@3.2.4(@types/node@22.18.0)(jsdom@26.1.0): + vitest@3.2.4(@types/node@22.18.1)(jsdom@26.1.0): dependencies: '@types/chai': 5.2.2 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@6.3.5(@types/node@22.18.0)) + '@vitest/mocker': 3.2.4(vite@6.3.5(@types/node@22.18.1)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -2125,11 +2176,11 @@ snapshots: tinyglobby: 0.2.14 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 6.3.5(@types/node@22.18.0) - vite-node: 3.2.4(@types/node@22.18.0) + vite: 6.3.5(@types/node@22.18.1) + vite-node: 3.2.4(@types/node@22.18.1) why-is-node-running: 2.3.0 optionalDependencies: - '@types/node': 22.18.0 + '@types/node': 22.18.1 jsdom: 26.1.0 transitivePeerDependencies: - jiti diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..ccdc80c --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +packages: + - "packages/*" \ No newline at end of file