Compare commits
No commits in common. "9426e8e66c2687b3d09ec65224fcdee4bd415fdc" and "a833e5334adc241ac6c5909f7c8593ef51b24546" have entirely different histories.
9426e8e66c
...
a833e5334a
23 changed files with 135 additions and 2229 deletions
|
|
@ -2,11 +2,6 @@
|
|||
|
||||
All notable changes to TypedFetch will be documented in this file.
|
||||
|
||||
# [Unreleased]
|
||||
|
||||
### Added
|
||||
- Built-in mocking helpers (`tf.mock`, `tf.mockOnce`, `tf.clearMocks`, `tf.enableMocking`, `tf.disableMocking`) to short-circuit requests for tests, storybooks, and offline workflows while still feeding the type registry.
|
||||
|
||||
## [0.2.0] - 2025-01-20
|
||||
|
||||
### Added
|
||||
|
|
@ -33,7 +28,6 @@ All notable changes to TypedFetch will be documented in this file.
|
|||
### Fixed
|
||||
- Fixed streaming methods to properly resolve relative URLs with baseURL
|
||||
- Fixed TypeScript strict mode compatibility issues
|
||||
- Ensured `throttled()` enforces bandwidth limits for streamed responses instead of being a no-op
|
||||
|
||||
## [0.1.3] - 2025-01-19
|
||||
|
||||
|
|
|
|||
180
README.md
180
README.md
|
|
@ -30,45 +30,13 @@ console.log(data.name) // Full response data
|
|||
import { createTypedFetch } from '@catalystlabs/typedfetch'
|
||||
|
||||
const client = createTypedFetch({
|
||||
request: {
|
||||
baseURL: 'https://api.example.com',
|
||||
headers: {
|
||||
'Authorization': 'Bearer token'
|
||||
}
|
||||
baseURL: 'https://api.example.com',
|
||||
headers: {
|
||||
'Authorization': 'Bearer token'
|
||||
}
|
||||
})
|
||||
|
||||
const { data, response } = await client.get('/users/123')
|
||||
|
||||
// Apply presets on the fly
|
||||
import { presets } from '@catalystlabs/typedfetch'
|
||||
|
||||
tf
|
||||
.use(presets.browser(), presets.auth.bearer('token-123'))
|
||||
.get('/profile')
|
||||
|
||||
// Bring your generated endpoint map for zero-effort typing
|
||||
import type { TypedFetchGeneratedEndpoints } from './typedfetch.generated'
|
||||
|
||||
const typed = createTypedFetch<TypedFetchGeneratedEndpoints>({
|
||||
request: { baseURL: 'https://api.example.com' }
|
||||
})
|
||||
|
||||
const profile = await typed.get('/me')
|
||||
// profile.data is strongly typed based on your schema/runtime samples
|
||||
```
|
||||
|
||||
## 🧩 Install & runtime support
|
||||
|
||||
- Published as zero-dependency ESM with an accompanying CommonJS build for straightforward `npm install @catalystlabs/typedfetch` integration across bundlers, Node 16+, and modern browsers.
|
||||
- Ships type declarations (`dist/index.d.ts`) and a CLI entrypoint (`typedfetch`) out of the box.
|
||||
|
||||
```ts
|
||||
// ESM / TypeScript
|
||||
import { tf } from '@catalystlabs/typedfetch'
|
||||
|
||||
// CommonJS
|
||||
const { tf: tfCjs } = require('@catalystlabs/typedfetch')
|
||||
```
|
||||
|
||||
## ✨ Features
|
||||
|
|
@ -77,7 +45,6 @@ const { tf: tfCjs } = require('@catalystlabs/typedfetch')
|
|||
- TypeScript inference for response data
|
||||
- No manual type casting needed
|
||||
- Type-safe error handling
|
||||
- Generate `.d.ts` snapshots from runtime data or OpenAPI discovery via `tf.exportTypes()` or the `typedfetch sync` CLI
|
||||
|
||||
### 🛡️ Built-in Resilience
|
||||
- Automatic retries with exponential backoff
|
||||
|
|
@ -90,7 +57,6 @@ const { tf: tfCjs } = require('@catalystlabs/typedfetch')
|
|||
- Standard HTTP methods: get(), post(), put(), delete()
|
||||
- Consistent response format
|
||||
- Zero boilerplate
|
||||
- Declarative `resource()` builder for human-friendly endpoint modules
|
||||
|
||||
### ⚡ Performance
|
||||
- <15KB gzipped bundle
|
||||
|
|
@ -108,32 +74,18 @@ import { tf } from '@catalystlabs/typedfetch'
|
|||
// GET request
|
||||
const { data, response } = await tf.get('https://api.example.com/users')
|
||||
|
||||
// POST request (fetch-style RequestInit object)
|
||||
// POST request
|
||||
const { data, response } = await tf.post('https://api.example.com/users', {
|
||||
body: {
|
||||
name: 'John Doe',
|
||||
email: 'john@example.com'
|
||||
},
|
||||
headers: {
|
||||
'X-Demo': 'docs'
|
||||
}
|
||||
})
|
||||
|
||||
// PUT request (pass the body directly and optional init as third arg)
|
||||
const { data: updatedUser } = await tf.put('https://api.example.com/users/123',
|
||||
{ name: 'Jane Doe' },
|
||||
{ headers: { 'X-Docs': '1' } }
|
||||
)
|
||||
|
||||
// PATCH request (body only)
|
||||
const { data } = await tf.patch('https://api.example.com/users/123', {
|
||||
title: 'Director of Engineering'
|
||||
})
|
||||
|
||||
// PATCH request
|
||||
const { data } = await tf.patch('https://api.example.com/users/123', {
|
||||
// PUT request
|
||||
const { data, response } = await tf.put('https://api.example.com/users/123', {
|
||||
body: {
|
||||
title: 'Director of Engineering'
|
||||
name: 'Jane Doe'
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -141,35 +93,32 @@ const { data } = await tf.patch('https://api.example.com/users/123', {
|
|||
const { data, response } = await tf.delete('https://api.example.com/users/123')
|
||||
```
|
||||
|
||||
`post`, `put`, and `patch` accept either a Fetch-style `RequestInit` object (with `body`, `headers`, etc.) or the raw body as the second argument plus an optional third `RequestInit` for headers/signals.
|
||||
|
||||
### Configuration
|
||||
|
||||
```typescript
|
||||
import { createTypedFetch } from '@catalystlabs/typedfetch'
|
||||
|
||||
const client = createTypedFetch({
|
||||
request: {
|
||||
baseURL: 'https://api.example.com',
|
||||
headers: {
|
||||
'Authorization': 'Bearer token',
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
timeout: 30000
|
||||
baseURL: 'https://api.example.com',
|
||||
headers: {
|
||||
'Authorization': 'Bearer token',
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
|
||||
timeout: 30000,
|
||||
|
||||
// Retry configuration
|
||||
retry: {
|
||||
maxAttempts: 3,
|
||||
delays: [100, 250, 500, 1000],
|
||||
retryableStatuses: [408, 429, 500, 502, 503, 504]
|
||||
attempts: 3,
|
||||
delay: 1000,
|
||||
maxDelay: 10000,
|
||||
backoff: 'exponential'
|
||||
},
|
||||
|
||||
|
||||
// Cache configuration
|
||||
cache: {
|
||||
enabled: true,
|
||||
ttl: 300000, // 5 minutes
|
||||
maxSize: 500
|
||||
storage: 'memory' // or 'indexeddb'
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -177,96 +126,13 @@ const client = createTypedFetch({
|
|||
import { tf } from '@catalystlabs/typedfetch'
|
||||
|
||||
tf.configure({
|
||||
request: {
|
||||
baseURL: 'https://api.example.com',
|
||||
headers: {
|
||||
'Authorization': 'Bearer token'
|
||||
}
|
||||
baseURL: 'https://api.example.com',
|
||||
headers: {
|
||||
'Authorization': 'Bearer token'
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### Opinionated presets
|
||||
|
||||
```typescript
|
||||
import { tf, presets } from '@catalystlabs/typedfetch'
|
||||
|
||||
tf.use(
|
||||
presets.browser(),
|
||||
presets.resilient(),
|
||||
presets.auth.bearer('my-token')
|
||||
)
|
||||
|
||||
// All subsequent calls inherit the composed behavior
|
||||
await tf.get('/me')
|
||||
```
|
||||
|
||||
Presets are just functions that emit config so you can compose them freely or build your own: `const edge = () => ({ cache: { ttl: 1000 } })`.
|
||||
|
||||
### Declarative resources
|
||||
|
||||
```typescript
|
||||
import { tf } from '@catalystlabs/typedfetch'
|
||||
|
||||
const users = tf.resource('/users/:id', {
|
||||
show: {
|
||||
method: 'GET'
|
||||
},
|
||||
update: {
|
||||
method: 'PATCH',
|
||||
json: true
|
||||
}
|
||||
})
|
||||
|
||||
const { data } = await users.show({ params: { id: '42' } })
|
||||
await users.update({ params: { id: '42' }, body: { name: 'Nova' } })
|
||||
```
|
||||
|
||||
Resources automatically expand `:params`, merge query objects, and keep returning the standard `{ data, response }` tuple.
|
||||
|
||||
### Type snapshot export
|
||||
|
||||
```typescript
|
||||
import { tf } from '@catalystlabs/typedfetch'
|
||||
|
||||
await tf.discover('https://api.example.com')
|
||||
const code = await tf.exportTypes({ outFile: 'typedfetch.generated.d.ts', banner: 'Example API' })
|
||||
|
||||
console.log('Types written to disk!')
|
||||
```
|
||||
|
||||
### CLI-powered type generation
|
||||
|
||||
Prefer a single command? Install (or `npx`) the bundled CLI:
|
||||
|
||||
```bash
|
||||
npx typedfetch sync --base https://api.example.com \
|
||||
--out src/generated/typedfetch.generated.d.ts \
|
||||
--namespace API
|
||||
```
|
||||
|
||||
The CLI will:
|
||||
|
||||
1. Instantiate a `RevolutionaryTypedFetch` client using your optional `--config` JSON file
|
||||
2. Run schema discovery (`tf.discover`) against the provided base URL
|
||||
3. Emit a type snapshot to `--out` (or stdout if omitted)
|
||||
|
||||
Use the emitted types to get end-to-end inference:
|
||||
|
||||
```typescript
|
||||
import type { TypedFetchGeneratedEndpoints } from './src/generated/typedfetch.generated'
|
||||
import { createTypedFetch } from '@catalystlabs/typedfetch'
|
||||
|
||||
const client = createTypedFetch<TypedFetchGeneratedEndpoints>({
|
||||
request: { baseURL: 'https://api.example.com' }
|
||||
})
|
||||
|
||||
// Response + request body types are wired up automatically
|
||||
const { data } = await client.get('/users/:id')
|
||||
```
|
||||
|
||||
`tf.exportTypes()` serializes everything the registry knows (OpenAPI + runtime samples) into a `.d.ts` file, which you can then import for fully typed API clients.
|
||||
|
||||
### Response Format
|
||||
|
||||
All methods return a consistent response format:
|
||||
|
|
@ -375,4 +241,4 @@ MIT License - see [LICENSE](LICENSE) for details.
|
|||
|
||||
---
|
||||
|
||||
**TypedFetch**: Because life's too short for complex HTTP clients. 🚀
|
||||
**TypedFetch**: Because life's too short for complex HTTP clients. 🚀
|
||||
|
|
@ -6,9 +6,6 @@
|
|||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"bin": {
|
||||
"typedfetch": "./dist/cli.js"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.js",
|
||||
|
|
@ -22,14 +19,12 @@
|
|||
"LICENSE"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "bun run build:clean && bun run build:esm && bun run build:cjs && bun run build:cli && bun run build:types",
|
||||
"build": "bun run build:clean && bun run build:esm && bun run build:cjs && bun run build:types",
|
||||
"build:clean": "rm -rf dist && mkdir dist",
|
||||
"build:esm": "bun build src/index.ts --outdir dist --target browser --format esm",
|
||||
"build:cjs": "esbuild src/index.ts --bundle --outfile=dist/index.cjs --target=node16 --format=cjs --platform=node",
|
||||
"build:cli": "bun build src/cli.ts --outfile dist/cli.js --target node --format esm",
|
||||
"build:types": "tsc --emitDeclarationOnly --outDir dist",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "bun x vitest run",
|
||||
"sync-version": "node scripts/sync-version.js",
|
||||
"prepublishOnly": "bun run build && bun run typecheck"
|
||||
},
|
||||
|
|
|
|||
163
src/cli.ts
163
src/cli.ts
|
|
@ -1,163 +0,0 @@
|
|||
#!/usr/bin/env node
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
import { RevolutionaryTypedFetch } from './core/typed-fetch.js'
|
||||
import type { TypedFetchConfig } from './types/config.js'
|
||||
import type { TypeSnapshotOptions } from './discovery/type-generator.js'
|
||||
|
||||
interface ParsedArgs {
|
||||
command: string
|
||||
base?: string
|
||||
out?: string
|
||||
namespace?: string
|
||||
banner?: string
|
||||
config?: string
|
||||
verbose?: boolean
|
||||
}
|
||||
|
||||
const SHORT_FLAGS: Record<string, keyof ParsedArgs> = {
|
||||
b: 'base',
|
||||
o: 'out',
|
||||
n: 'namespace',
|
||||
c: 'config'
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const parsed = parseArgs(process.argv.slice(2))
|
||||
const command = parsed.command || 'sync'
|
||||
|
||||
switch (command) {
|
||||
case 'sync':
|
||||
await handleSync(parsed)
|
||||
break
|
||||
case 'help':
|
||||
case '--help':
|
||||
printHelp()
|
||||
break
|
||||
default:
|
||||
console.error(`Unknown command: ${command}`)
|
||||
printHelp()
|
||||
process.exitCode = 1
|
||||
}
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): ParsedArgs {
|
||||
const args: ParsedArgs = { command: '' }
|
||||
const positionals: string[] = []
|
||||
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const token = argv[i]!
|
||||
if (!token.startsWith('-')) {
|
||||
positionals.push(token)
|
||||
continue
|
||||
}
|
||||
|
||||
if (token.startsWith('--')) {
|
||||
const segment = token.slice(2)
|
||||
const [rawFlag, maybeValue] = segment.split('=', 2)
|
||||
const flag = rawFlag ?? ''
|
||||
if (!flag) continue
|
||||
if (flag.startsWith('no-')) {
|
||||
;(args as any)[flag.slice(3)] = false
|
||||
continue
|
||||
}
|
||||
|
||||
if (maybeValue !== undefined) {
|
||||
;(args as any)[flag] = maybeValue
|
||||
continue
|
||||
}
|
||||
|
||||
const next = argv[i + 1]
|
||||
if (!next || next.startsWith('-')) {
|
||||
;(args as any)[flag] = true
|
||||
} else {
|
||||
;(args as any)[flag] = next
|
||||
i++
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
const short = token.replace(/^-+/, '')
|
||||
const mapped = SHORT_FLAGS[short]
|
||||
if (mapped) {
|
||||
const next = argv[i + 1]
|
||||
if (!next || next.startsWith('-')) {
|
||||
throw new Error(`Flag -${short} requires a value`)
|
||||
}
|
||||
;(args as any)[mapped] = next
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
args.command = positionals[0] || args.command || 'sync'
|
||||
if (!args.base && positionals[1]) {
|
||||
args.base = positionals[1]
|
||||
}
|
||||
if (!args.out && positionals[2]) {
|
||||
args.out = positionals[2]
|
||||
}
|
||||
|
||||
return args
|
||||
}
|
||||
|
||||
async function handleSync(args: ParsedArgs): Promise<void> {
|
||||
const config = await loadConfig(args.config)
|
||||
const client = new RevolutionaryTypedFetch(config)
|
||||
|
||||
const baseURL = args.base || config?.request?.baseURL
|
||||
if (!baseURL) {
|
||||
throw new Error('A base URL is required. Pass --base or set request.baseURL in your config file.')
|
||||
}
|
||||
|
||||
if (args.verbose) {
|
||||
console.log(`🔍 Discovering schema from ${baseURL}`)
|
||||
}
|
||||
|
||||
await client.discover(baseURL)
|
||||
|
||||
const snapshotOptions: TypeSnapshotOptions = {}
|
||||
if (args.out) snapshotOptions.outFile = args.out
|
||||
if (args.namespace) snapshotOptions.namespace = args.namespace
|
||||
if (args.banner) snapshotOptions.banner = args.banner
|
||||
|
||||
const code = await client.exportTypes(snapshotOptions)
|
||||
if (args.out) {
|
||||
if (args.verbose) {
|
||||
console.log(`📄 Type snapshot written to ${resolve(args.out)}`)
|
||||
} else {
|
||||
console.log(`📄 Wrote ${args.out}`)
|
||||
}
|
||||
} else {
|
||||
process.stdout.write(code)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadConfig(path?: string): Promise<TypedFetchConfig | undefined> {
|
||||
if (!path) return undefined
|
||||
const resolved = resolve(path)
|
||||
const raw = await readFile(resolved, 'utf8')
|
||||
return JSON.parse(raw)
|
||||
}
|
||||
|
||||
function printHelp(): void {
|
||||
console.log(`typedfetch <command> [options]
|
||||
|
||||
Commands:
|
||||
sync [--base <url>] [--out <file>] Discover the API and emit TypeScript definitions
|
||||
help Show this help message
|
||||
|
||||
Options:
|
||||
--base, -b Base URL to discover (falls back to config request.baseURL)
|
||||
--out, -o File path for the generated declaration file (prints to stdout if omitted)
|
||||
--namespace, -n Wrap declarations in a namespace
|
||||
--banner Add a custom banner comment to the snapshot
|
||||
--config, -c Path to a JSON config file matching TypedFetchConfig
|
||||
--verbose Print progress information
|
||||
`)
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : error)
|
||||
process.exitCode = 1
|
||||
})
|
||||
|
|
@ -1,152 +0,0 @@
|
|||
import type { TypedFetchConfig } from '../types/config.js'
|
||||
|
||||
export interface BodyPreparationOptions {
|
||||
/**
|
||||
* Force JSON serialization (default: true). Set to false when passing binary/FormData bodies
|
||||
*/
|
||||
json?: boolean
|
||||
}
|
||||
|
||||
export interface PreparedBody {
|
||||
bodyInit: BodyInit | null
|
||||
sample?: unknown
|
||||
}
|
||||
|
||||
export interface ResolvedBodyArgs<TBody> {
|
||||
body?: TBody
|
||||
init: RequestInit
|
||||
}
|
||||
|
||||
const REQUEST_INIT_KEYS = new Set([
|
||||
'body',
|
||||
'headers',
|
||||
'method',
|
||||
'mode',
|
||||
'credentials',
|
||||
'cache',
|
||||
'redirect',
|
||||
'referrer',
|
||||
'referrerPolicy',
|
||||
'integrity',
|
||||
'keepalive',
|
||||
'signal',
|
||||
'priority',
|
||||
'window',
|
||||
'duplex'
|
||||
])
|
||||
|
||||
const hasBlob = typeof Blob !== 'undefined'
|
||||
const hasFormData = typeof FormData !== 'undefined'
|
||||
const hasURLSearchParams = typeof URLSearchParams !== 'undefined'
|
||||
const hasReadableStream = typeof ReadableStream !== 'undefined'
|
||||
|
||||
export function isBinaryBody(value: unknown): value is BodyInit {
|
||||
if (value === null || value === undefined) return false
|
||||
if (typeof value === 'string') return false
|
||||
|
||||
if (typeof ArrayBuffer !== 'undefined' && value instanceof ArrayBuffer) return true
|
||||
if (typeof ArrayBuffer !== 'undefined' && ArrayBuffer.isView(value)) return true
|
||||
if (hasBlob && value instanceof Blob) return true
|
||||
if (hasFormData && value instanceof FormData) return true
|
||||
if (hasURLSearchParams && value instanceof URLSearchParams) return true
|
||||
if (hasReadableStream && value instanceof ReadableStream) return true
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function tryParseJSON(value: string): unknown {
|
||||
try {
|
||||
return JSON.parse(value)
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export function prepareBodyPayload(body: unknown, options: BodyPreparationOptions = {}): PreparedBody {
|
||||
const { json = true } = options
|
||||
|
||||
if (body === undefined) {
|
||||
return { bodyInit: null }
|
||||
}
|
||||
|
||||
if (!json) {
|
||||
return { bodyInit: body as BodyInit }
|
||||
}
|
||||
|
||||
if (typeof body === 'string') {
|
||||
return { bodyInit: body, sample: tryParseJSON(body) }
|
||||
}
|
||||
|
||||
if (body === null || typeof body === 'number' || typeof body === 'boolean') {
|
||||
return { bodyInit: JSON.stringify(body), sample: body }
|
||||
}
|
||||
|
||||
if (isBinaryBody(body)) {
|
||||
return { bodyInit: body }
|
||||
}
|
||||
|
||||
if (typeof body === 'object') {
|
||||
return { bodyInit: JSON.stringify(body), sample: body }
|
||||
}
|
||||
|
||||
return { bodyInit: body as BodyInit }
|
||||
}
|
||||
|
||||
export function mergePartialConfig(
|
||||
target: TypedFetchConfig = {},
|
||||
source: TypedFetchConfig = {}
|
||||
): TypedFetchConfig {
|
||||
const result: TypedFetchConfig = { ...target }
|
||||
|
||||
for (const key in source) {
|
||||
const value = source[key as keyof TypedFetchConfig]
|
||||
if (value === undefined) continue
|
||||
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||
result[key as keyof TypedFetchConfig] = {
|
||||
...(result[key as keyof TypedFetchConfig] as Record<string, unknown> | undefined),
|
||||
...value
|
||||
} as any
|
||||
} else {
|
||||
result[key as keyof TypedFetchConfig] = value as any
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export function isRequestInitLike(value: unknown): value is RequestInit {
|
||||
if (!value || typeof value !== 'object') return false
|
||||
for (const key of REQUEST_INIT_KEYS) {
|
||||
if (key in (value as Record<string, unknown>)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export function resolveBodyArgs<TBody>(
|
||||
bodyOrInit?: TBody | RequestInit,
|
||||
maybeInit?: RequestInit
|
||||
): ResolvedBodyArgs<TBody> {
|
||||
if (bodyOrInit === undefined) {
|
||||
return { init: maybeInit ? { ...maybeInit } : {} }
|
||||
}
|
||||
|
||||
if (isRequestInitLike(bodyOrInit)) {
|
||||
const { body, ...rest } = bodyOrInit as RequestInit & Record<string, unknown>
|
||||
const init = rest as RequestInit
|
||||
if (body === undefined) {
|
||||
return { init }
|
||||
}
|
||||
return {
|
||||
body: body as TBody,
|
||||
init
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
body: bodyOrInit as TBody,
|
||||
init: maybeInit ? { ...maybeInit } : {}
|
||||
}
|
||||
}
|
||||
|
|
@ -104,21 +104,18 @@ export function enhanceError(error: any, url: string, context?: ErrorContext): T
|
|||
error.code === 'ETIMEDOUT' ? 'Request timed out - server may be slow' : null
|
||||
].filter(Boolean) as string[]
|
||||
|
||||
Object.defineProperty(enhanced, 'debug', {
|
||||
enumerable: false,
|
||||
value: () => {
|
||||
console.group(`🚨 Network Error Debug`)
|
||||
console.log('URL:', url)
|
||||
console.log('Method:', enhanced.method)
|
||||
console.log('Error:', error.message)
|
||||
console.log('Error Code:', error.code)
|
||||
console.log('Timestamp:', new Date(enhanced.timestamp!).toISOString())
|
||||
if (enhanced.attempt) console.log('Attempt:', enhanced.attempt)
|
||||
if (enhanced.duration) console.log('Duration:', `${enhanced.duration}ms`)
|
||||
console.log('Stack:', error.stack)
|
||||
console.groupEnd()
|
||||
}
|
||||
})
|
||||
enhanced.debug = () => {
|
||||
console.group(`🚨 Network Error Debug`)
|
||||
console.log('URL:', url)
|
||||
console.log('Method:', enhanced.method)
|
||||
console.log('Error:', error.message)
|
||||
console.log('Error Code:', error.code)
|
||||
console.log('Timestamp:', new Date(enhanced.timestamp!).toISOString())
|
||||
if (enhanced.attempt) console.log('Attempt:', enhanced.attempt)
|
||||
if (enhanced.duration) console.log('Duration:', `${enhanced.duration}ms`)
|
||||
console.log('Stack:', error.stack)
|
||||
console.groupEnd()
|
||||
}
|
||||
|
||||
return enhanced
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,415 +0,0 @@
|
|||
export interface MockMatcherResult {
|
||||
params?: Record<string, string>
|
||||
query?: Record<string, string>
|
||||
searchParams?: URLSearchParams
|
||||
}
|
||||
|
||||
export type MockMatcher = (url: URL) => boolean | MockMatcherResult | null | undefined
|
||||
|
||||
export interface MockResponse<TData = unknown> {
|
||||
data?: TData
|
||||
status?: number
|
||||
headers?: HeadersInit
|
||||
body?: BodyInit | null
|
||||
delay?: number
|
||||
response?: Response
|
||||
}
|
||||
|
||||
export type MockHandlerResult<TData = unknown> = MockResponse<TData> | Response | TData | void
|
||||
|
||||
export interface MockHandlerContext {
|
||||
url: string
|
||||
method: string
|
||||
params: Record<string, string>
|
||||
query: Record<string, string>
|
||||
searchParams: URLSearchParams
|
||||
headers: Record<string, string>
|
||||
body?: unknown
|
||||
request: RequestInit
|
||||
}
|
||||
|
||||
export type MockHandler<TData = unknown> = (ctx: MockHandlerContext) => Promise<MockHandlerResult<TData>> | MockHandlerResult<TData>
|
||||
|
||||
export type MockRouteMatcher = string | RegExp | MockMatcher
|
||||
|
||||
export interface MockRouteDefinition<TData = unknown> {
|
||||
method?: string | string[]
|
||||
url?: MockRouteMatcher
|
||||
once?: boolean
|
||||
priority?: number
|
||||
handler?: MockHandler<TData>
|
||||
response?: MockResponse<TData>
|
||||
}
|
||||
|
||||
interface MockMatchInternal {
|
||||
route: InternalMockRoute
|
||||
params: Record<string, string>
|
||||
query: Record<string, string>
|
||||
searchParams: URLSearchParams
|
||||
}
|
||||
|
||||
interface InternalMockRoute extends Required<Pick<MockRouteDefinition, 'once'>> {
|
||||
id: number
|
||||
methods: string[] | null
|
||||
priority: number
|
||||
matcher: (url: URL) => MatcherShape | null
|
||||
handler: MockHandler | undefined
|
||||
response: MockResponse | undefined
|
||||
}
|
||||
|
||||
interface MatcherShape {
|
||||
params: Record<string, string>
|
||||
query: Record<string, string>
|
||||
searchParams: URLSearchParams
|
||||
}
|
||||
|
||||
interface ExecuteContext {
|
||||
url: string
|
||||
method: string
|
||||
request: RequestInit
|
||||
bodySample?: unknown
|
||||
delay: (ms: number) => Promise<void>
|
||||
}
|
||||
|
||||
interface NormalizedMockResult<T = unknown> {
|
||||
data: T
|
||||
response: Response
|
||||
}
|
||||
|
||||
export class MockController {
|
||||
private routes: InternalMockRoute[] = []
|
||||
private enabled = true
|
||||
private counter = 0
|
||||
|
||||
register<TData = unknown>(definition: MockRouteDefinition<TData>): () => void {
|
||||
const route: InternalMockRoute = {
|
||||
id: ++this.counter,
|
||||
once: Boolean(definition.once),
|
||||
priority: definition.priority ?? 0,
|
||||
methods: this.normalizeMethods(definition.method),
|
||||
matcher: this.createMatcher(definition.url),
|
||||
handler: definition.handler as MockHandler | undefined,
|
||||
response: definition.response
|
||||
}
|
||||
|
||||
this.routes.push(route)
|
||||
this.routes.sort((a, b) => (b.priority - a.priority) || (a.id - b.id))
|
||||
|
||||
return () => {
|
||||
this.routes = this.routes.filter(entry => entry.id !== route.id)
|
||||
}
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.routes = []
|
||||
}
|
||||
|
||||
enable(): void {
|
||||
this.enabled = true
|
||||
}
|
||||
|
||||
disable(): void {
|
||||
this.enabled = false
|
||||
}
|
||||
|
||||
hasHandlers(): boolean {
|
||||
return this.routes.length > 0
|
||||
}
|
||||
|
||||
match(method: string, url: string): MockMatchInternal | null {
|
||||
if (!this.enabled || !this.routes.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const normalizedMethod = method.toUpperCase()
|
||||
const parsed = new URL(url)
|
||||
|
||||
for (const route of this.routes) {
|
||||
if (route.methods && !route.methods.includes(normalizedMethod) && !route.methods.includes('ANY')) {
|
||||
continue
|
||||
}
|
||||
|
||||
const match = route.matcher(parsed)
|
||||
if (match) {
|
||||
return {
|
||||
route,
|
||||
...match
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
async execute<T = unknown>(match: MockMatchInternal, context: ExecuteContext): Promise<NormalizedMockResult<T>> {
|
||||
if (match.route.once) {
|
||||
this.routes = this.routes.filter(route => route.id !== match.route.id)
|
||||
}
|
||||
|
||||
const handler = match.route.handler
|
||||
const fallback = match.route.response
|
||||
const headersRecord = this.toHeaderRecord(context.request.headers)
|
||||
const body = context.bodySample !== undefined ? context.bodySample : this.tryDecodeBody(context.request.body)
|
||||
const handlerContext: MockHandlerContext = {
|
||||
url: context.url,
|
||||
method: context.method,
|
||||
params: match.params,
|
||||
query: match.query,
|
||||
searchParams: match.searchParams,
|
||||
headers: headersRecord,
|
||||
body,
|
||||
request: context.request
|
||||
}
|
||||
|
||||
const output = handler ? await handler(handlerContext) : undefined
|
||||
const resolved = output ?? fallback
|
||||
|
||||
if (!resolved) {
|
||||
return this.normalizeResult({ data: undefined }) as NormalizedMockResult<T>
|
||||
}
|
||||
|
||||
if (typeof resolved === 'object' && resolved !== null && 'delay' in resolved && typeof resolved.delay === 'number') {
|
||||
await context.delay(resolved.delay)
|
||||
}
|
||||
|
||||
return this.normalizeResult<T>(resolved)
|
||||
}
|
||||
|
||||
private normalizeMethods(method?: string | string[]): string[] | null {
|
||||
if (!method) return null
|
||||
const list = Array.isArray(method) ? method : [method]
|
||||
if (!list.length) return null
|
||||
return list.map(m => m.toUpperCase())
|
||||
}
|
||||
|
||||
private createMatcher(source?: MockRouteMatcher): (url: URL) => MatcherShape | null {
|
||||
if (!source) {
|
||||
return (url) => this.buildMatch(url)
|
||||
}
|
||||
|
||||
if (typeof source === 'string') {
|
||||
return this.compileStringMatcher(source)
|
||||
}
|
||||
|
||||
if (source instanceof RegExp) {
|
||||
return (url) => (source.test(url.href) ? this.buildMatch(url) : null)
|
||||
}
|
||||
|
||||
return (url) => {
|
||||
const result = source(url)
|
||||
if (result === true) return this.buildMatch(url)
|
||||
if (!result) return null
|
||||
const params = result.params ?? {}
|
||||
const query = result.query ?? this.buildQuery(url)
|
||||
const searchParams = result.searchParams ?? new URLSearchParams(url.search)
|
||||
return {
|
||||
params,
|
||||
query,
|
||||
searchParams
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private compileStringMatcher(pattern: string): (url: URL) => MatcherShape | null {
|
||||
const isAbsolute = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(pattern)
|
||||
const parsedPattern = isAbsolute ? new URL(pattern) : null
|
||||
const [rawPath, rawQuery] = pattern.split('?')
|
||||
const targetPath = parsedPattern ? parsedPattern.pathname : (rawPath || '/')
|
||||
const patternSegments = this.splitSegments(targetPath)
|
||||
const wildcardIndex = patternSegments.indexOf('*')
|
||||
const hasWildcard = wildcardIndex !== -1 && wildcardIndex === patternSegments.length - 1
|
||||
const queryConstraints = parsedPattern
|
||||
? parsedPattern.searchParams
|
||||
: rawQuery
|
||||
? new URLSearchParams(rawQuery)
|
||||
: new URLSearchParams()
|
||||
|
||||
return (url) => {
|
||||
if (parsedPattern && parsedPattern.origin !== url.origin) {
|
||||
return null
|
||||
}
|
||||
|
||||
const urlSegments = this.splitSegments(url.pathname)
|
||||
|
||||
if (!hasWildcard && patternSegments.length !== urlSegments.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (hasWildcard && urlSegments.length < wildcardIndex) {
|
||||
return null
|
||||
}
|
||||
|
||||
const params: Record<string, string> = {}
|
||||
for (let i = 0; i < patternSegments.length; i++) {
|
||||
const segment = patternSegments[i]
|
||||
if (!segment) {
|
||||
return null
|
||||
}
|
||||
if (segment === '*') {
|
||||
break
|
||||
}
|
||||
const value = urlSegments[i]
|
||||
if (segment.startsWith(':')) {
|
||||
if (value === undefined) return null
|
||||
params[segment.slice(1)] = decodeURIComponent(value)
|
||||
continue
|
||||
}
|
||||
if (segment !== value) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, value] of queryConstraints.entries()) {
|
||||
if (url.searchParams.get(key) !== value) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
return this.buildMatch(url, params)
|
||||
}
|
||||
}
|
||||
|
||||
private buildMatch(url: URL, params: Record<string, string> = {}): MatcherShape {
|
||||
return {
|
||||
params,
|
||||
query: this.buildQuery(url),
|
||||
searchParams: new URLSearchParams(url.search)
|
||||
}
|
||||
}
|
||||
|
||||
private buildQuery(url: URL): Record<string, string> {
|
||||
const query: Record<string, string> = {}
|
||||
for (const [key, value] of url.searchParams.entries()) {
|
||||
query[key] = value
|
||||
}
|
||||
return query
|
||||
}
|
||||
|
||||
private splitSegments(path: string): string[] {
|
||||
return path.split('/').filter(Boolean)
|
||||
}
|
||||
|
||||
private normalizeResult<T>(output: MockHandlerResult<T>): NormalizedMockResult<T> {
|
||||
if (output instanceof Response) {
|
||||
return { data: undefined as T, response: output }
|
||||
}
|
||||
|
||||
const candidate = typeof output === 'object' && output !== null && (
|
||||
'data' in output || 'status' in output || 'headers' in output || 'body' in output || 'response' in output
|
||||
)
|
||||
? output as MockResponse<T>
|
||||
: { data: output } as MockResponse<T>
|
||||
|
||||
if (candidate.response instanceof Response) {
|
||||
return { data: candidate.data as T, response: candidate.response }
|
||||
}
|
||||
|
||||
const bodySource = candidate.body ?? candidate.data
|
||||
const serialized = this.serializeBody(bodySource)
|
||||
const headers = new Headers(candidate.headers || {})
|
||||
if (serialized.kind === 'json' && !headers.has('Content-Type')) {
|
||||
headers.set('Content-Type', 'application/json')
|
||||
}
|
||||
|
||||
const response = new Response(serialized.body ?? '', {
|
||||
status: candidate.status ?? 200,
|
||||
headers
|
||||
})
|
||||
|
||||
return {
|
||||
data: candidate.data as T,
|
||||
response
|
||||
}
|
||||
}
|
||||
|
||||
private serializeBody(source: unknown): { body: BodyInit | null; kind: 'json' | 'raw' } {
|
||||
if (source === undefined || source === null) {
|
||||
return { body: null, kind: 'raw' }
|
||||
}
|
||||
|
||||
if (typeof source === 'string') {
|
||||
return { body: source, kind: 'raw' }
|
||||
}
|
||||
|
||||
if (typeof Blob !== 'undefined' && source instanceof Blob) {
|
||||
return { body: source, kind: 'raw' }
|
||||
}
|
||||
|
||||
if (typeof FormData !== 'undefined' && source instanceof FormData) {
|
||||
return { body: source, kind: 'raw' }
|
||||
}
|
||||
|
||||
if (source instanceof URLSearchParams) {
|
||||
return { body: source, kind: 'raw' }
|
||||
}
|
||||
|
||||
if (typeof ReadableStream !== 'undefined' && source instanceof ReadableStream) {
|
||||
return { body: source, kind: 'raw' }
|
||||
}
|
||||
|
||||
if (typeof ArrayBuffer !== 'undefined' && source instanceof ArrayBuffer) {
|
||||
return { body: source, kind: 'raw' }
|
||||
}
|
||||
|
||||
if (typeof ArrayBuffer !== 'undefined' && ArrayBuffer.isView(source)) {
|
||||
return { body: source as unknown as BodyInit, kind: 'raw' }
|
||||
}
|
||||
|
||||
return { body: JSON.stringify(source), kind: 'json' }
|
||||
}
|
||||
|
||||
private tryDecodeBody(body: RequestInit['body']): unknown {
|
||||
if (!body) return undefined
|
||||
|
||||
if (typeof body === 'string') {
|
||||
try {
|
||||
return JSON.parse(body)
|
||||
} catch {
|
||||
return body
|
||||
}
|
||||
}
|
||||
|
||||
if (body instanceof URLSearchParams) {
|
||||
return Object.fromEntries(body.entries())
|
||||
}
|
||||
|
||||
if (typeof FormData !== 'undefined' && body instanceof FormData) {
|
||||
const result: Record<string, FormDataEntryValue | FormDataEntryValue[]> = {}
|
||||
for (const [key, raw] of body.entries()) {
|
||||
const value = raw as FormDataEntryValue
|
||||
if (key in result) {
|
||||
const existing = result[key]
|
||||
if (Array.isArray(existing)) {
|
||||
existing.push(value)
|
||||
} else {
|
||||
result[key] = [existing as FormDataEntryValue, value]
|
||||
}
|
||||
} else {
|
||||
result[key] = value
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
if (
|
||||
(typeof Blob !== 'undefined' && body instanceof Blob) ||
|
||||
(typeof ArrayBuffer !== 'undefined' && body instanceof ArrayBuffer) ||
|
||||
(typeof ArrayBuffer !== 'undefined' && ArrayBuffer.isView(body)) ||
|
||||
(typeof ReadableStream !== 'undefined' && body instanceof ReadableStream)
|
||||
) {
|
||||
return body
|
||||
}
|
||||
|
||||
return body
|
||||
}
|
||||
|
||||
private toHeaderRecord(headers?: HeadersInit): Record<string, string> {
|
||||
const record: Record<string, string> = {}
|
||||
if (!headers) return record
|
||||
const normalized = new Headers(headers)
|
||||
normalized.forEach((value, key) => {
|
||||
record[key] = value
|
||||
})
|
||||
return record
|
||||
}
|
||||
}
|
||||
|
|
@ -1,73 +0,0 @@
|
|||
import type { TypedFetchConfig } from '../types/config.js'
|
||||
import { mergeConfig } from '../types/config.js'
|
||||
import { mergePartialConfig } from './body-utils.js'
|
||||
|
||||
export type TypedFetchPreset =
|
||||
| TypedFetchConfig
|
||||
| ((current: Required<TypedFetchConfig>) => TypedFetchConfig)
|
||||
|
||||
export function applyPresetChain(
|
||||
current: Required<TypedFetchConfig>,
|
||||
presets: TypedFetchPreset[]
|
||||
): { overrides: TypedFetchConfig; preview: Required<TypedFetchConfig> } {
|
||||
let preview = current
|
||||
let overrides: TypedFetchConfig = {}
|
||||
|
||||
for (const preset of presets) {
|
||||
const patch = typeof preset === 'function' ? preset(preview) : preset
|
||||
overrides = mergePartialConfig(overrides, patch)
|
||||
preview = mergeConfig(preview, patch)
|
||||
}
|
||||
|
||||
return { overrides, preview }
|
||||
}
|
||||
|
||||
export const presets = {
|
||||
browser(): TypedFetchPreset {
|
||||
return () => ({
|
||||
cache: { ttl: 60000, maxSize: 200, enabled: true },
|
||||
retry: { maxAttempts: 2, delays: [100, 300, 600] },
|
||||
request: {
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
resilient(): TypedFetchPreset {
|
||||
return () => ({
|
||||
retry: { maxAttempts: 5, delays: [100, 250, 500, 1000, 2000] },
|
||||
circuit: { threshold: 3, timeout: 45000, enabled: true },
|
||||
metrics: { enabled: true }
|
||||
})
|
||||
},
|
||||
offlineFirst(): TypedFetchPreset {
|
||||
return () => ({
|
||||
cache: { ttl: 5 * 60 * 1000, maxSize: 1000, enabled: true },
|
||||
retry: { maxAttempts: 4, delays: [100, 250, 500, 1000] },
|
||||
debug: { verbose: false }
|
||||
})
|
||||
},
|
||||
jsonApi(): TypedFetchPreset {
|
||||
return () => ({
|
||||
request: {
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
auth: {
|
||||
bearer(token: string): TypedFetchPreset {
|
||||
return () => ({
|
||||
request: {
|
||||
headers: {
|
||||
Authorization: token.startsWith('Bearer') ? token : `Bearer ${token}`
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,205 +0,0 @@
|
|||
import { prepareBodyPayload } from './body-utils.js'
|
||||
|
||||
export type HttpMethod =
|
||||
| 'GET'
|
||||
| 'POST'
|
||||
| 'PUT'
|
||||
| 'PATCH'
|
||||
| 'DELETE'
|
||||
| 'OPTIONS'
|
||||
| 'HEAD'
|
||||
|
||||
export interface ResourceMethodConfig<
|
||||
TResponse = unknown,
|
||||
TBody = undefined,
|
||||
TParams extends Record<string, string | number> = Record<string, string | number>,
|
||||
TQuery extends Record<string, unknown> = Record<string, unknown>
|
||||
> {
|
||||
method?: HttpMethod
|
||||
path?: string
|
||||
query?: Partial<TQuery>
|
||||
headers?: Record<string, string>
|
||||
json?: boolean
|
||||
serializeBody?: (body: TBody) => BodyInit | undefined
|
||||
transformResponse?: (payload: any, response: Response) => TResponse
|
||||
description?: string
|
||||
summary?: string
|
||||
}
|
||||
|
||||
export type ResourceDefinition = Record<string, ResourceMethodConfig>
|
||||
|
||||
export interface ResourceCallArgs<
|
||||
TBody,
|
||||
TParams extends Record<string, string | number>,
|
||||
TQuery extends Record<string, unknown>
|
||||
> {
|
||||
body?: TBody
|
||||
params?: TParams
|
||||
query?: TQuery
|
||||
init?: RequestInit
|
||||
}
|
||||
|
||||
export type ResourceMethodInvoker<Config extends ResourceMethodConfig> = (
|
||||
args?: ResourceCallArgs<
|
||||
Config extends ResourceMethodConfig<any, infer TBody, any, any> ? TBody : never,
|
||||
Config extends ResourceMethodConfig<any, any, infer TParams, any>
|
||||
? TParams
|
||||
: Record<string, string | number>,
|
||||
Config extends ResourceMethodConfig<any, any, any, infer TQuery>
|
||||
? TQuery
|
||||
: Record<string, unknown>
|
||||
>
|
||||
) => Promise<{
|
||||
data: Config extends ResourceMethodConfig<infer TResponse, any, any, any> ? TResponse : unknown
|
||||
response: Response
|
||||
}>
|
||||
|
||||
export type ResourceInstance<TDefinition extends ResourceDefinition> = {
|
||||
[K in keyof TDefinition]: ResourceMethodInvoker<TDefinition[K]>
|
||||
} & {
|
||||
/** Build the full URL for this resource */
|
||||
$path(
|
||||
params?: Record<string, string | number>,
|
||||
query?: Record<string, unknown>
|
||||
): string
|
||||
}
|
||||
|
||||
export interface ResourceBuilderOptions {
|
||||
trailingSlash?: boolean
|
||||
query?: Record<string, unknown>
|
||||
}
|
||||
|
||||
interface ResourceRequester {
|
||||
request<T>(method: string, url: string, init?: RequestInit, bodySample?: unknown): Promise<{
|
||||
data: T
|
||||
response: Response
|
||||
}>
|
||||
}
|
||||
|
||||
export function createResource<TDefinition extends ResourceDefinition>(
|
||||
requester: ResourceRequester,
|
||||
basePath: string,
|
||||
definition: TDefinition,
|
||||
options: ResourceBuilderOptions = {}
|
||||
): ResourceInstance<TDefinition> {
|
||||
const normalizedBase = normalizePath(basePath, options.trailingSlash)
|
||||
const resource: Record<string, any> = {}
|
||||
|
||||
for (const name of Object.keys(definition) as Array<keyof TDefinition>) {
|
||||
type Config = TDefinition[typeof name]
|
||||
type BodyType = Config extends ResourceMethodConfig<any, infer TBody, any, any> ? TBody : never
|
||||
type ParamsType = Config extends ResourceMethodConfig<any, any, infer TParams, any>
|
||||
? TParams
|
||||
: Record<string, string | number>
|
||||
type QueryType = Config extends ResourceMethodConfig<any, any, any, infer TQuery>
|
||||
? TQuery
|
||||
: Record<string, unknown>
|
||||
type ResponseType = Config extends ResourceMethodConfig<infer TResponse, any, any, any>
|
||||
? TResponse
|
||||
: unknown
|
||||
const config = definition[name] as Config
|
||||
|
||||
resource[name as string] = async (args: ResourceCallArgs<BodyType, ParamsType, QueryType> = {}) => {
|
||||
const method = (config.method || 'GET').toUpperCase()
|
||||
const path = joinPaths(normalizedBase, config.path, options.trailingSlash)
|
||||
const resolvedPath = applyParams(path, args.params)
|
||||
const query = buildQuery({
|
||||
...(options.query ?? {}),
|
||||
...(config.query ?? {}),
|
||||
...(args.query ?? {})
|
||||
})
|
||||
const finalUrl = `${resolvedPath}${query}`
|
||||
|
||||
const shouldUseJson = config.json ?? true
|
||||
const serializer = config.serializeBody as ((body: BodyType) => BodyInit | undefined) | undefined
|
||||
const hasBody = args.body !== undefined
|
||||
const prepared = hasBody
|
||||
? serializer
|
||||
? { bodyInit: serializer(args.body as BodyType), sample: args.body }
|
||||
: prepareBodyPayload(args.body as BodyType, { json: shouldUseJson })
|
||||
: undefined
|
||||
|
||||
const init: RequestInit = {
|
||||
...(args.init || {}),
|
||||
headers: {
|
||||
...config.headers,
|
||||
...(args.init?.headers || {})
|
||||
}
|
||||
}
|
||||
|
||||
if (hasBody) {
|
||||
init.body = prepared?.bodyInit ?? null
|
||||
}
|
||||
|
||||
const result = await requester.request<ResponseType>(
|
||||
method,
|
||||
finalUrl,
|
||||
init,
|
||||
hasBody ? prepared?.sample : undefined
|
||||
)
|
||||
if (config.transformResponse) {
|
||||
return {
|
||||
data: config.transformResponse(result.data, result.response),
|
||||
response: result.response
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
resource.$path = (
|
||||
params?: Record<string, string | number>,
|
||||
query?: Record<string, unknown>
|
||||
) => {
|
||||
const resolved = maybeApplyTrailing(applyParams(normalizedBase, params), options.trailingSlash)
|
||||
return `${resolved}${buildQuery({ ...(options.query ?? {}), ...(query ?? {}) })}`
|
||||
}
|
||||
|
||||
return resource as ResourceInstance<TDefinition>
|
||||
}
|
||||
|
||||
function normalizePath(path: string, trailingSlash?: boolean): string {
|
||||
if (!path) return trailingSlash ? '/' : '/'
|
||||
if (path.startsWith('http')) return maybeApplyTrailing(path, trailingSlash)
|
||||
const normalized = path.startsWith('/') ? path : `/${path}`
|
||||
return maybeApplyTrailing(normalized, trailingSlash)
|
||||
}
|
||||
|
||||
function joinPaths(base: string, extra?: string, trailingSlash?: boolean): string {
|
||||
if (!extra) return maybeApplyTrailing(base, trailingSlash)
|
||||
if (extra.startsWith('http')) return maybeApplyTrailing(extra, trailingSlash)
|
||||
const combined = `${base.replace(/\/$/, '')}/${extra.replace(/^\//, '')}`
|
||||
return maybeApplyTrailing(combined.replace(/\/+/g, '/'), trailingSlash)
|
||||
}
|
||||
|
||||
function maybeApplyTrailing(path: string, trailingSlash?: boolean): string {
|
||||
if (!path) return trailingSlash ? '/' : ''
|
||||
if (trailingSlash === undefined) return path
|
||||
return trailingSlash ? path.replace(/\/?$/, '/') : path.replace(/\/$/, '')
|
||||
}
|
||||
|
||||
function applyParams(path: string, params?: Record<string, string | number>): string {
|
||||
if (!params) return path
|
||||
return path.replace(/:([A-Za-z0-9_]+)/g, (_, key) => {
|
||||
const value = params[key]
|
||||
if (value === undefined) {
|
||||
throw new Error(`Missing value for URL parameter :${key}`)
|
||||
}
|
||||
return encodeURIComponent(String(value))
|
||||
})
|
||||
}
|
||||
|
||||
function buildQuery(query?: Record<string, unknown>): string {
|
||||
if (!query) return ''
|
||||
const search = new URLSearchParams()
|
||||
for (const [key, value] of Object.entries(query)) {
|
||||
if (value === undefined || value === null) continue
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach(item => search.append(key, String(item)))
|
||||
} else {
|
||||
search.append(key, String(value))
|
||||
}
|
||||
}
|
||||
const result = search.toString()
|
||||
return result ? `?${result}` : ''
|
||||
}
|
||||
|
|
@ -12,29 +12,15 @@ import { InterceptorChain } from './interceptors.js'
|
|||
import { RequestMetrics } from './metrics.js'
|
||||
import { OfflineHandler } from './offline-handler.js'
|
||||
import { createHttpError, enhanceError, type ErrorContext } from './errors.js'
|
||||
import { isBinaryBody, prepareBodyPayload, resolveBodyArgs } from './body-utils.js'
|
||||
import {
|
||||
createResource,
|
||||
type ResourceBuilderOptions,
|
||||
type ResourceDefinition,
|
||||
type ResourceInstance
|
||||
} from './resource-builder.js'
|
||||
import { applyPresetChain, type TypedFetchPreset } from './presets.js'
|
||||
import { MockController, type MockRouteDefinition } from './mock-controller.js'
|
||||
import { TypeDeclarationGenerator, type TypeSnapshotOptions } from '../discovery/type-generator.js'
|
||||
import type { TypeRegistry, TypedError } from '../types/index.js'
|
||||
import type { EndpointRequest, EndpointResponse, EndpointTypeMap } from '../types/endpoint-types.js'
|
||||
import type { TypedFetchConfig } from '../types/config.js'
|
||||
import { DEFAULT_CONFIG, mergeConfig } from '../types/config.js'
|
||||
import { inferTypeDescriptor } from '../types/type-descriptor.js'
|
||||
|
||||
// Re-export configuration types for convenience
|
||||
export type { TypedFetchConfig } from '../types/config.js'
|
||||
export { DEFAULT_CONFIG, mergeConfig } from '../types/config.js'
|
||||
|
||||
const MAX_TYPE_SAMPLES = 5
|
||||
|
||||
export class RevolutionaryTypedFetch<TEndpoints extends EndpointTypeMap = EndpointTypeMap> {
|
||||
export class RevolutionaryTypedFetch {
|
||||
private config: Required<TypedFetchConfig>
|
||||
private cache: WTinyLFUCache
|
||||
private deduplicator = new RequestDeduplicator()
|
||||
|
|
@ -46,7 +32,6 @@ export class RevolutionaryTypedFetch<TEndpoints extends EndpointTypeMap = Endpoi
|
|||
private metrics = new RequestMetrics()
|
||||
private offlineHandler = new OfflineHandler()
|
||||
private baseURL = ''
|
||||
private mockController = new MockController()
|
||||
|
||||
constructor(config: TypedFetchConfig = {}) {
|
||||
this.config = mergeConfig(DEFAULT_CONFIG, config)
|
||||
|
|
@ -79,85 +64,28 @@ export class RevolutionaryTypedFetch<TEndpoints extends EndpointTypeMap = Endpoi
|
|||
// Always update baseURL from config
|
||||
this.baseURL = this.config.request.baseURL || ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply preset configuration stacks for simple ergonomics
|
||||
*/
|
||||
use(...presetsInput: Array<TypedFetchPreset | TypedFetchPreset[]>): this {
|
||||
const flat = presetsInput.flat().filter(Boolean) as TypedFetchPreset[]
|
||||
if (!flat.length) return this
|
||||
const { overrides } = applyPresetChain(this.config, flat)
|
||||
if (Object.keys(overrides).length > 0) {
|
||||
this.configure(overrides)
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new instance with custom configuration
|
||||
*/
|
||||
create(config: TypedFetchConfig): RevolutionaryTypedFetch<TEndpoints> {
|
||||
create(config: TypedFetchConfig): RevolutionaryTypedFetch {
|
||||
const mergedConfig = mergeConfig(this.config, config)
|
||||
return new RevolutionaryTypedFetch<TEndpoints>(mergedConfig)
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock controller helpers
|
||||
*/
|
||||
mock<TData = unknown>(definition: MockRouteDefinition<TData>): () => void {
|
||||
return this.mockController.register(definition)
|
||||
}
|
||||
|
||||
mockOnce<TData = unknown>(definition: MockRouteDefinition<TData>): () => void {
|
||||
return this.mockController.register({ ...definition, once: true })
|
||||
}
|
||||
|
||||
clearMocks(): void {
|
||||
this.mockController.clear()
|
||||
}
|
||||
|
||||
enableMocks(): void {
|
||||
this.mockController.enable()
|
||||
}
|
||||
|
||||
disableMocks(): void {
|
||||
this.mockController.disable()
|
||||
return new RevolutionaryTypedFetch(mergedConfig)
|
||||
}
|
||||
|
||||
// REAL runtime type tracking
|
||||
private recordRequest(endpoint: string, method: string, body: unknown): void {
|
||||
if (body === undefined) return
|
||||
const key = this.buildRegistryKey(method, endpoint)
|
||||
const entry = this.ensureRegistryEntry(key, method)
|
||||
entry.request = inferTypeDescriptor(body)
|
||||
entry.lastSeen = Date.now()
|
||||
}
|
||||
|
||||
private recordResponse(endpoint: string, method: string, data: any): void {
|
||||
const key = this.buildRegistryKey(method, endpoint)
|
||||
const key = `${method.toUpperCase()} ${endpoint}`
|
||||
this.typeInference.addSample(key, data)
|
||||
const entry = this.ensureRegistryEntry(key, method)
|
||||
const inferred = this.typeInference.inferType(key)
|
||||
if (inferred) {
|
||||
entry.response = inferred
|
||||
|
||||
// Update registry with inferred type
|
||||
this.typeRegistry[key] = {
|
||||
request: this.typeRegistry[key]?.request,
|
||||
response: this.typeInference.inferType(key),
|
||||
method: method.toUpperCase(),
|
||||
lastSeen: Date.now(),
|
||||
samples: [data]
|
||||
}
|
||||
entry.lastSeen = Date.now()
|
||||
entry.samples = [...entry.samples, data].slice(-MAX_TYPE_SAMPLES)
|
||||
}
|
||||
|
||||
private buildRegistryKey(method: string, endpoint: string): string {
|
||||
return `${method.toUpperCase()} ${endpoint}`
|
||||
}
|
||||
|
||||
private ensureRegistryEntry(key: string, method: string) {
|
||||
if (!this.typeRegistry[key]) {
|
||||
this.typeRegistry[key] = {
|
||||
method: method.toUpperCase(),
|
||||
lastSeen: Date.now(),
|
||||
samples: []
|
||||
}
|
||||
}
|
||||
return this.typeRegistry[key]!
|
||||
}
|
||||
|
||||
// REAL auto-discovery implementation
|
||||
|
|
@ -207,67 +135,22 @@ export class RevolutionaryTypedFetch<TEndpoints extends EndpointTypeMap = Endpoi
|
|||
}
|
||||
|
||||
// REAL HTTP methods with full type safety
|
||||
async get<Url extends string>(
|
||||
url: Url,
|
||||
options: RequestInit = {}
|
||||
): Promise<{ data: EndpointResponse<TEndpoints, 'GET', Url>; response: Response }> {
|
||||
return this.request<'GET', Url>('GET', url, options)
|
||||
async get<T = unknown>(url: string, options: RequestInit = {}): Promise<{ data: T; response: Response }> {
|
||||
return this.request<T>('GET', url, options)
|
||||
}
|
||||
|
||||
async post<Url extends string, TBody = EndpointRequest<TEndpoints, 'POST', Url>>(
|
||||
url: Url,
|
||||
bodyOrInit?: TBody | RequestInit,
|
||||
maybeInit?: RequestInit
|
||||
): Promise<{ data: EndpointResponse<TEndpoints, 'POST', Url>; response: Response }> {
|
||||
const { body, init } = resolveBodyArgs<TBody>(bodyOrInit, maybeInit)
|
||||
if (body === undefined) {
|
||||
return this.request<'POST', Url>('POST', url, init)
|
||||
}
|
||||
const prepared = prepareBodyPayload(body)
|
||||
return this.request<'POST', Url>('POST', url, { ...init, body: prepared.bodyInit }, prepared.sample)
|
||||
|
||||
async post<T = unknown>(url: string, body?: any, options: RequestInit = {}): Promise<{ data: T; response: Response }> {
|
||||
return this.request<T>('POST', url, { ...options, body: JSON.stringify(body) })
|
||||
}
|
||||
|
||||
async put<Url extends string, TBody = EndpointRequest<TEndpoints, 'PUT', Url>>(
|
||||
url: Url,
|
||||
bodyOrInit?: TBody | RequestInit,
|
||||
maybeInit?: RequestInit
|
||||
): Promise<{ data: EndpointResponse<TEndpoints, 'PUT', Url>; response: Response }> {
|
||||
const { body, init } = resolveBodyArgs<TBody>(bodyOrInit, maybeInit)
|
||||
if (body === undefined) {
|
||||
return this.request<'PUT', Url>('PUT', url, init)
|
||||
}
|
||||
const prepared = prepareBodyPayload(body)
|
||||
return this.request<'PUT', Url>('PUT', url, { ...init, body: prepared.bodyInit }, prepared.sample)
|
||||
|
||||
async put<T = unknown>(url: string, body?: any, options: RequestInit = {}): Promise<{ data: T; response: Response }> {
|
||||
return this.request<T>('PUT', url, { ...options, body: JSON.stringify(body) })
|
||||
}
|
||||
|
||||
async patch<Url extends string, TBody = EndpointRequest<TEndpoints, 'PATCH', Url>>(
|
||||
url: Url,
|
||||
bodyOrInit?: TBody | RequestInit,
|
||||
maybeInit?: RequestInit
|
||||
): Promise<{ data: EndpointResponse<TEndpoints, 'PATCH', Url>; response: Response }> {
|
||||
const { body, init } = resolveBodyArgs<TBody>(bodyOrInit, maybeInit)
|
||||
if (body === undefined) {
|
||||
return this.request<'PATCH', Url>('PATCH', url, init)
|
||||
}
|
||||
const prepared = prepareBodyPayload(body)
|
||||
return this.request<'PATCH', Url>('PATCH', url, { ...init, body: prepared.bodyInit }, prepared.sample)
|
||||
|
||||
async delete<T = unknown>(url: string, options: RequestInit = {}): Promise<{ data: T; response: Response }> {
|
||||
return this.request<T>('DELETE', url, options)
|
||||
}
|
||||
|
||||
async delete<Url extends string>(
|
||||
url: Url,
|
||||
options: RequestInit = {}
|
||||
): Promise<{ data: EndpointResponse<TEndpoints, 'DELETE', Url>; response: Response }> {
|
||||
return this.request<'DELETE', Url>('DELETE', url, options)
|
||||
}
|
||||
|
||||
resource<TDefinition extends ResourceDefinition>(
|
||||
path: string,
|
||||
definition: TDefinition,
|
||||
options?: ResourceBuilderOptions
|
||||
): ResourceInstance<TDefinition> {
|
||||
return createResource(this, path, definition, options)
|
||||
}
|
||||
|
||||
|
||||
private resolveUrl(url: string): string {
|
||||
// Use baseURL from config or instance
|
||||
const baseURL = this.config.request.baseURL || this.baseURL
|
||||
|
|
@ -282,71 +165,7 @@ export class RevolutionaryTypedFetch<TEndpoints extends EndpointTypeMap = Endpoi
|
|||
}
|
||||
}
|
||||
|
||||
private applyDefaultJsonContentType(
|
||||
headers: Headers,
|
||||
body?: BodyInit | null,
|
||||
bodySample?: unknown
|
||||
): void {
|
||||
if (headers.has('Content-Type')) return
|
||||
if (!this.shouldSendJsonContentType(body, bodySample)) return
|
||||
headers.set('Content-Type', 'application/json')
|
||||
}
|
||||
|
||||
private shouldSendJsonContentType(body?: BodyInit | null, bodySample?: unknown): boolean {
|
||||
const candidate = bodySample ?? body
|
||||
if (candidate === undefined || candidate === null) return false
|
||||
if (typeof candidate === 'string') return true
|
||||
if (isBinaryBody(candidate)) return false
|
||||
if (typeof candidate === 'object' && (candidate as any)[Symbol.asyncIterator]) return false
|
||||
return true
|
||||
}
|
||||
|
||||
private buildDedupKey(
|
||||
method: string,
|
||||
url: string,
|
||||
bodySample?: unknown,
|
||||
body?: BodyInit | null
|
||||
): string {
|
||||
if (method === 'GET') {
|
||||
return `${method}:${url}`
|
||||
}
|
||||
|
||||
return `${method}:${url}:${this.stringifyDedupBody(bodySample ?? body)}`
|
||||
}
|
||||
|
||||
private stringifyDedupBody(body: unknown): string {
|
||||
if (body === undefined || body === null) return 'no-body'
|
||||
if (typeof body === 'string') return `string:${body}`
|
||||
if (isBinaryBody(body)) return 'binary'
|
||||
|
||||
if (typeof body === 'object') {
|
||||
if ((body as any)[Symbol.asyncIterator]) {
|
||||
return 'stream'
|
||||
}
|
||||
|
||||
try {
|
||||
return `json:${JSON.stringify(body)}`
|
||||
} catch {
|
||||
return 'opaque-body'
|
||||
}
|
||||
}
|
||||
|
||||
return String(body)
|
||||
}
|
||||
|
||||
async request<
|
||||
Method extends string,
|
||||
Url extends string,
|
||||
TResponse = EndpointResponse<TEndpoints, Method, Url>
|
||||
>(
|
||||
method: Method,
|
||||
url: Url,
|
||||
options: RequestInit = {},
|
||||
bodySample?: unknown
|
||||
): Promise<{
|
||||
data: TResponse
|
||||
response: Response
|
||||
}> {
|
||||
private async request<T>(method: string, url: string, options: RequestInit = {}): Promise<{ data: T; response: Response }> {
|
||||
const fullUrl = this.resolveUrl(url)
|
||||
const cacheKey = `${method}:${fullUrl}`
|
||||
const startTime = performance.now()
|
||||
|
|
@ -363,7 +182,7 @@ export class RevolutionaryTypedFetch<TEndpoints extends EndpointTypeMap = Endpoi
|
|||
if (this.config.metrics.enabled) {
|
||||
this.metrics.recordRequest(fullUrl, duration, cached)
|
||||
}
|
||||
return { data: cachedData as TResponse, response: new Response('cached') }
|
||||
return { data: cachedData as T, response: new Response('cached') }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -376,10 +195,11 @@ export class RevolutionaryTypedFetch<TEndpoints extends EndpointTypeMap = Endpoi
|
|||
...(options.headers || {})
|
||||
}
|
||||
}
|
||||
|
||||
const requestHeaders = new Headers(requestOptions.headers as HeadersInit | undefined)
|
||||
this.applyDefaultJsonContentType(requestHeaders, requestOptions.body, bodySample)
|
||||
requestOptions.headers = Object.fromEntries(requestHeaders.entries())
|
||||
|
||||
// Only set Content-Type for JSON bodies
|
||||
if (options.body && typeof options.body === 'string') {
|
||||
(requestOptions.headers as any)['Content-Type'] = 'application/json'
|
||||
}
|
||||
|
||||
// Add timeout if configured
|
||||
if (this.config.request.timeout && !requestOptions.signal) {
|
||||
|
|
@ -390,43 +210,13 @@ export class RevolutionaryTypedFetch<TEndpoints extends EndpointTypeMap = Endpoi
|
|||
|
||||
// Process through interceptors
|
||||
const processedOptions = await this.interceptors.processRequest(requestOptions)
|
||||
|
||||
if (bodySample !== undefined) {
|
||||
this.recordRequest(url, method, bodySample)
|
||||
}
|
||||
|
||||
// Short-circuit with mock controller if available
|
||||
const mockMatch = this.mockController.match(method, fullUrl)
|
||||
if (mockMatch) {
|
||||
const mockResult = await this.mockController.execute<TResponse>(mockMatch, {
|
||||
url: fullUrl,
|
||||
method,
|
||||
request: processedOptions,
|
||||
bodySample,
|
||||
delay: (ms) => this.delay(ms)
|
||||
})
|
||||
|
||||
this.recordResponse(url, method, mockResult.data)
|
||||
|
||||
const processedResponse = await this.interceptors.processResponse(mockResult)
|
||||
const duration = performance.now() - startTime
|
||||
if (this.config.metrics.enabled) {
|
||||
this.metrics.recordRequest(fullUrl, duration, cached, error)
|
||||
}
|
||||
if (this.config.debug.logSuccess) {
|
||||
console.log(`✅ Mock response: ${method} ${fullUrl} (${duration.toFixed(0)}ms)`)
|
||||
}
|
||||
return processedResponse as { data: TResponse; response: Response }
|
||||
}
|
||||
|
||||
// Handle offline requests
|
||||
const dedupeKey = this.buildDedupKey(method, fullUrl, bodySample, processedOptions.body)
|
||||
|
||||
const result = await this.offlineHandler.handleRequest(fullUrl, processedOptions, async () => {
|
||||
// Deduplicate identical requests
|
||||
return this.deduplicator.dedupe(dedupeKey, async () => {
|
||||
return this.deduplicator.dedupe(cacheKey, async () => {
|
||||
// Execute with circuit breaker and retry logic
|
||||
return this.executeWithRetry<Method, Url, TResponse>(fullUrl, processedOptions, url, method)
|
||||
return this.executeWithRetry<T>(fullUrl, processedOptions, url, method)
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -464,16 +254,7 @@ export class RevolutionaryTypedFetch<TEndpoints extends EndpointTypeMap = Endpoi
|
|||
}
|
||||
}
|
||||
|
||||
private async executeWithRetry<
|
||||
Method extends string,
|
||||
Url extends string,
|
||||
TResponse = EndpointResponse<TEndpoints, Method, Url>
|
||||
>(
|
||||
fullUrl: string,
|
||||
options: any,
|
||||
originalUrl: Url,
|
||||
method: Method
|
||||
): Promise<{ data: TResponse; response: Response }> {
|
||||
private async executeWithRetry<T>(fullUrl: string, options: any, originalUrl: string, method: string): Promise<{ data: T; response: Response }> {
|
||||
let lastError: any
|
||||
const maxAttempts = method === 'GET' ? (this.config.retry.maxAttempts || 1) : 1
|
||||
const startTime = performance.now()
|
||||
|
|
@ -507,8 +288,8 @@ export class RevolutionaryTypedFetch<TEndpoints extends EndpointTypeMap = Endpoi
|
|||
|
||||
// Process through response interceptors
|
||||
const processedResponse = await this.interceptors.processResponse({ data, response })
|
||||
|
||||
return processedResponse as { data: TResponse; response: Response }
|
||||
|
||||
return processedResponse
|
||||
}
|
||||
|
||||
// Execute with or without circuit breaker
|
||||
|
|
@ -580,14 +361,6 @@ export class RevolutionaryTypedFetch<TEndpoints extends EndpointTypeMap = Endpoi
|
|||
getAllTypes(): TypeRegistry {
|
||||
return { ...this.typeRegistry }
|
||||
}
|
||||
|
||||
async exportTypes(options: TypeSnapshotOptions = {}): Promise<string> {
|
||||
const generator = new TypeDeclarationGenerator(this.typeRegistry)
|
||||
if (options.outFile) {
|
||||
return generator.writeToFile(options)
|
||||
}
|
||||
return generator.generate(options)
|
||||
}
|
||||
|
||||
getInferenceConfidence(endpoint: string): number {
|
||||
return this.typeInference.getConfidence(endpoint)
|
||||
|
|
@ -739,14 +512,14 @@ export class RevolutionaryTypedFetch<TEndpoints extends EndpointTypeMap = Endpoi
|
|||
const chunkPromises = chunk.map(async (req) => {
|
||||
try {
|
||||
const method = req.method || 'GET'
|
||||
const init: RequestInit = { headers: req.headers || {} }
|
||||
let sample: unknown
|
||||
if (req.body !== undefined) {
|
||||
const prepared = prepareBodyPayload(req.body)
|
||||
init.body = prepared.bodyInit
|
||||
sample = prepared.sample
|
||||
}
|
||||
const result = await this.request<string, string, T>(method, req.url, init, sample)
|
||||
const result = await this.request<T>(
|
||||
method,
|
||||
req.url,
|
||||
{
|
||||
body: req.body ? JSON.stringify(req.body) : null,
|
||||
headers: req.headers || {}
|
||||
}
|
||||
)
|
||||
return { data: result.data, response: result.response }
|
||||
} catch (error) {
|
||||
if (throwOnError) throw error
|
||||
|
|
@ -889,14 +662,14 @@ export class RevolutionaryTypedFetch<TEndpoints extends EndpointTypeMap = Endpoi
|
|||
|
||||
const promises = requests.map(async (req, index) => {
|
||||
const method = req.method || 'GET'
|
||||
const init: RequestInit = { headers: req.headers || {} }
|
||||
let sample: unknown
|
||||
if (req.body !== undefined) {
|
||||
const prepared = prepareBodyPayload(req.body)
|
||||
init.body = prepared.bodyInit
|
||||
sample = prepared.sample
|
||||
}
|
||||
const result = await this.request<string, string, T>(method, req.url, init, sample)
|
||||
const result = await this.request<T>(
|
||||
method,
|
||||
req.url,
|
||||
{
|
||||
body: req.body ? JSON.stringify(req.body) : null,
|
||||
headers: req.headers || {}
|
||||
}
|
||||
)
|
||||
return { ...result, winner: index }
|
||||
})
|
||||
|
||||
|
|
@ -938,14 +711,14 @@ export class RevolutionaryTypedFetch<TEndpoints extends EndpointTypeMap = Endpoi
|
|||
requests.map(async (req) => {
|
||||
try {
|
||||
const method = req.method || 'GET'
|
||||
const init: RequestInit = { headers: req.headers || {} }
|
||||
let sample: unknown
|
||||
if (req.body !== undefined) {
|
||||
const prepared = prepareBodyPayload(req.body)
|
||||
init.body = prepared.bodyInit
|
||||
sample = prepared.sample
|
||||
}
|
||||
const result = await this.request<string, string, T>(method, req.url, init, sample)
|
||||
const result = await this.request<T>(
|
||||
method,
|
||||
req.url,
|
||||
{
|
||||
body: req.body ? JSON.stringify(req.body) : null,
|
||||
headers: req.headers || {}
|
||||
}
|
||||
)
|
||||
return { data: result.data, response: result.response }
|
||||
} catch (error) {
|
||||
return { error }
|
||||
|
|
@ -1231,178 +1004,46 @@ export class RevolutionaryTypedFetch<TEndpoints extends EndpointTypeMap = Endpoi
|
|||
options: {
|
||||
bandwidth?: number | string // e.g., 1048576 (1MB/s) or '1MB/s'
|
||||
burst?: number // Allow burst up to this many bytes
|
||||
sleep?: (ms: number) => Promise<void>
|
||||
now?: () => number
|
||||
} = {}
|
||||
): Promise<T> {
|
||||
const {
|
||||
bandwidth = '1MB/s',
|
||||
burst,
|
||||
sleep = (ms: number) => this.delay(ms),
|
||||
now = () => Date.now()
|
||||
} = options
|
||||
const { bandwidth = '1MB/s', burst = 0 } = options
|
||||
|
||||
const bytesPerSecond =
|
||||
typeof bandwidth === 'string' ? this.parseBandwidth(bandwidth) : bandwidth
|
||||
// Parse bandwidth string
|
||||
const bytesPerSecond = typeof bandwidth === 'string'
|
||||
? this.parseBandwidth(bandwidth)
|
||||
: bandwidth
|
||||
|
||||
if (!bytesPerSecond || !isFinite(bytesPerSecond) || bytesPerSecond <= 0) {
|
||||
return fn()
|
||||
}
|
||||
|
||||
const capacity = burst && burst > 0 ? burst : bytesPerSecond
|
||||
// Token bucket algorithm
|
||||
const bucket = {
|
||||
tokens: capacity,
|
||||
capacity,
|
||||
lastRefill: now()
|
||||
tokens: burst || bytesPerSecond,
|
||||
lastRefill: Date.now(),
|
||||
capacity: burst || bytesPerSecond
|
||||
}
|
||||
|
||||
// Refill tokens
|
||||
const refill = () => {
|
||||
const current = now()
|
||||
const elapsedSeconds = (current - bucket.lastRefill) / 1000
|
||||
if (elapsedSeconds <= 0) return
|
||||
bucket.tokens = Math.min(
|
||||
bucket.capacity,
|
||||
bucket.tokens + elapsedSeconds * bytesPerSecond
|
||||
)
|
||||
bucket.lastRefill = current
|
||||
const now = Date.now()
|
||||
const elapsed = (now - bucket.lastRefill) / 1000
|
||||
const tokensToAdd = elapsed * bytesPerSecond
|
||||
bucket.tokens = Math.min(bucket.capacity, bucket.tokens + tokensToAdd)
|
||||
bucket.lastRefill = now
|
||||
}
|
||||
|
||||
const consume = async (bytes: number) => {
|
||||
if (!bytes || bytes <= 0) return
|
||||
let remaining = bytes
|
||||
while (remaining > 0) {
|
||||
// Wait for tokens
|
||||
const waitForTokens = async (needed: number) => {
|
||||
refill()
|
||||
while (bucket.tokens < needed) {
|
||||
const deficit = needed - bucket.tokens
|
||||
const waitTime = (deficit / bytesPerSecond) * 1000
|
||||
await this.delay(Math.min(waitTime, 100)) // Check every 100ms
|
||||
refill()
|
||||
if (bucket.tokens > 0) {
|
||||
const take = Math.min(bucket.tokens, remaining)
|
||||
bucket.tokens -= take
|
||||
remaining -= take
|
||||
if (remaining <= 0) break
|
||||
}
|
||||
const waitBytes = remaining - bucket.tokens
|
||||
const waitMs = Math.max((waitBytes / bytesPerSecond) * 1000, 0)
|
||||
await sleep(waitMs)
|
||||
}
|
||||
bucket.tokens -= needed
|
||||
}
|
||||
|
||||
const wrapReader = (reader: any) => {
|
||||
if (!reader || typeof reader.read !== 'function') return reader
|
||||
const wrapped: Record<string, any> = {}
|
||||
const proto = Object.getPrototypeOf(reader) || {}
|
||||
Object.setPrototypeOf(wrapped, proto)
|
||||
Object.assign(wrapped, reader)
|
||||
|
||||
wrapped.read = async (...args: any[]) => {
|
||||
const chunk = await reader.read(...args)
|
||||
if (!chunk?.done) {
|
||||
await consume(getChunkSize(chunk.value))
|
||||
}
|
||||
return chunk
|
||||
}
|
||||
if (typeof reader.cancel === 'function') {
|
||||
wrapped.cancel = (...args: any[]) => reader.cancel!(...args)
|
||||
}
|
||||
if (typeof reader.releaseLock === 'function') {
|
||||
wrapped.releaseLock = (...args: any[]) => reader.releaseLock!(...args)
|
||||
}
|
||||
Object.defineProperty(wrapped, 'closed', {
|
||||
get: () => (reader as any).closed,
|
||||
configurable: true
|
||||
})
|
||||
return wrapped
|
||||
}
|
||||
|
||||
const wrapStreamLike = (stream: any) => {
|
||||
if (!stream) return stream
|
||||
if (typeof ReadableStream !== 'undefined' && stream instanceof ReadableStream) {
|
||||
const original = stream
|
||||
return new ReadableStream({
|
||||
start: controller => {
|
||||
const reader = original.getReader()
|
||||
const pump = async (): Promise<void> => {
|
||||
try {
|
||||
const chunk = await reader.read()
|
||||
if (chunk.done) {
|
||||
controller.close()
|
||||
return
|
||||
}
|
||||
await consume(getChunkSize(chunk.value))
|
||||
controller.enqueue(chunk.value)
|
||||
pump()
|
||||
} catch (error) {
|
||||
controller.error(error)
|
||||
}
|
||||
}
|
||||
pump()
|
||||
},
|
||||
cancel: reason => original.cancel?.(reason)
|
||||
})
|
||||
}
|
||||
|
||||
if (typeof stream.getReader === 'function') {
|
||||
const clone = Object.create(Object.getPrototypeOf(stream) || Object.prototype)
|
||||
Object.assign(clone, stream)
|
||||
clone.getReader = (...args: any[]) => {
|
||||
const reader = stream.getReader(...args)
|
||||
return wrapReader(reader)
|
||||
}
|
||||
return clone
|
||||
}
|
||||
|
||||
return stream
|
||||
}
|
||||
|
||||
const wrapResponseLike = (response: any) => {
|
||||
if (!response || typeof response !== 'object') return response
|
||||
if (!('body' in response) || !response.body) return response
|
||||
const throttledBody = wrapStreamLike(response.body)
|
||||
if (throttledBody === response.body) return response
|
||||
|
||||
if (typeof Response !== 'undefined' && response instanceof Response) {
|
||||
return new Proxy(response, {
|
||||
get(target, prop, receiver) {
|
||||
if (prop === 'body') return throttledBody
|
||||
if (prop === 'clone') {
|
||||
return () => wrapResponseLike(target.clone())
|
||||
}
|
||||
return Reflect.get(target, prop, receiver)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
...response,
|
||||
body: throttledBody
|
||||
}
|
||||
}
|
||||
|
||||
const applyThrottling = <U>(value: U): U => {
|
||||
if (!value) return value
|
||||
if (typeof ReadableStream !== 'undefined' && value instanceof ReadableStream) {
|
||||
return wrapStreamLike(value) as U
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
const candidate = value as Record<string, any>
|
||||
if (typeof candidate.getReader === 'function') {
|
||||
return wrapStreamLike(candidate) as U
|
||||
}
|
||||
if ('response' in candidate) {
|
||||
const wrappedResponse = wrapResponseLike(candidate.response)
|
||||
if (wrappedResponse !== candidate.response) {
|
||||
return { ...candidate, response: wrappedResponse } as U
|
||||
}
|
||||
}
|
||||
if ('body' in candidate) {
|
||||
const wrappedBody = wrapStreamLike(candidate.body)
|
||||
if (wrappedBody !== candidate.body) {
|
||||
return { ...candidate, body: wrappedBody } as U
|
||||
}
|
||||
}
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
const output = await fn()
|
||||
return applyThrottling(output)
|
||||
// TODO: Implement actual throttling for the response stream
|
||||
// For now, just execute the function
|
||||
return fn()
|
||||
}
|
||||
|
||||
private parseBandwidth(bandwidth: string): number {
|
||||
|
|
@ -1421,27 +1062,4 @@ export class RevolutionaryTypedFetch<TEndpoints extends EndpointTypeMap = Endpoi
|
|||
|
||||
return value * (multipliers[unit] || 1)
|
||||
}
|
||||
}
|
||||
|
||||
function getChunkSize(chunk: unknown): number {
|
||||
if (!chunk) return 0
|
||||
if (typeof chunk === 'string') return chunk.length
|
||||
if (typeof ArrayBuffer !== 'undefined' && chunk instanceof ArrayBuffer) {
|
||||
return chunk.byteLength
|
||||
}
|
||||
if (typeof ArrayBuffer !== 'undefined' && ArrayBuffer.isView && ArrayBuffer.isView(chunk as ArrayBufferView)) {
|
||||
return (chunk as ArrayBufferView).byteLength
|
||||
}
|
||||
if (typeof chunk === 'object') {
|
||||
if (typeof (chunk as { byteLength?: number }).byteLength === 'number') {
|
||||
return (chunk as { byteLength: number }).byteLength
|
||||
}
|
||||
if (typeof (chunk as { length?: number }).length === 'number') {
|
||||
return (chunk as { length: number }).length
|
||||
}
|
||||
if (typeof (chunk as { size?: number }).size === 'number') {
|
||||
return (chunk as { size: number }).size
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
|
@ -1,94 +0,0 @@
|
|||
import { mkdir, writeFile } from 'node:fs/promises'
|
||||
import { dirname } from 'node:path'
|
||||
|
||||
import type { TypeRegistry } from '../types/index.js'
|
||||
import { typeDescriptorToString } from '../types/type-descriptor.js'
|
||||
|
||||
export interface TypeSnapshotOptions {
|
||||
outFile?: string
|
||||
namespace?: string
|
||||
banner?: string
|
||||
}
|
||||
|
||||
export class TypeDeclarationGenerator {
|
||||
constructor(private readonly registry: TypeRegistry) {}
|
||||
|
||||
generate(options: TypeSnapshotOptions = {}): string {
|
||||
const { namespace, banner } = options
|
||||
const lines: string[] = []
|
||||
lines.push('// -----------------------------------------------------------------------------')
|
||||
lines.push('// TypedFetch type snapshot - generated from runtime + schema discovery')
|
||||
lines.push('// -----------------------------------------------------------------------------')
|
||||
if (banner) {
|
||||
lines.push(`// ${banner}`)
|
||||
}
|
||||
lines.push('')
|
||||
|
||||
const declarations: string[] = []
|
||||
const endpoints: string[] = []
|
||||
|
||||
const entries = Object.entries(this.registry)
|
||||
if (entries.length === 0) {
|
||||
declarations.push('// No endpoints recorded yet. Make a request or run tf.discover().')
|
||||
}
|
||||
|
||||
for (const [key, entry] of entries) {
|
||||
const [rawMethod = 'GET', ...pathParts] = key.split(' ')
|
||||
const path = pathParts.join(' ') || '/'
|
||||
const method = rawMethod || 'GET'
|
||||
const typeBase = formatName(method, path)
|
||||
const requestTypeName = `${typeBase}Request`
|
||||
const responseTypeName = `${typeBase}Response`
|
||||
const requestDescriptor = entry.request || { type: 'unknown' }
|
||||
const responseDescriptor = entry.response || { type: 'unknown' }
|
||||
|
||||
declarations.push(`export type ${requestTypeName} = ${typeDescriptorToString(requestDescriptor)}`)
|
||||
declarations.push(`export type ${responseTypeName} = ${typeDescriptorToString(responseDescriptor)}`)
|
||||
declarations.push('')
|
||||
|
||||
endpoints.push(
|
||||
` '${method} ${path}': { request: ${requestTypeName}; response: ${responseTypeName}; method: '${method}'; path: '${path}' }`
|
||||
)
|
||||
}
|
||||
|
||||
if (endpoints.length) {
|
||||
declarations.push('export interface TypedFetchGeneratedEndpoints {')
|
||||
declarations.push(...endpoints)
|
||||
declarations.push('}')
|
||||
}
|
||||
|
||||
const content = [...lines, ...declarations].join('\n')
|
||||
if (!namespace) {
|
||||
return content
|
||||
}
|
||||
|
||||
const namespaced = [`declare namespace ${namespace} {`, ...indent(declarations), '}']
|
||||
return [...lines, ...namespaced].join('\n')
|
||||
}
|
||||
|
||||
async writeToFile(options: TypeSnapshotOptions): Promise<string> {
|
||||
const code = this.generate(options)
|
||||
if (options.outFile) {
|
||||
await mkdir(dirname(options.outFile), { recursive: true })
|
||||
await writeFile(options.outFile, code, 'utf8')
|
||||
}
|
||||
return code
|
||||
}
|
||||
}
|
||||
|
||||
function formatName(method: string, path: string): string {
|
||||
const methodName = method.toLowerCase().replace(/(^|[-_\s])(\w)/g, (_, __, char: string) => char.toUpperCase())
|
||||
const parts = path
|
||||
.split('/')
|
||||
.filter(Boolean)
|
||||
.map((segment) => segment.replace(/[:{}]/g, ''))
|
||||
.map((segment) => segment.replace(/(^|[-_\s])(\w)/g, (_, __, char: string) => char.toUpperCase()))
|
||||
|
||||
const suffix = parts.join('') || 'Root'
|
||||
return `${methodName}${suffix}`
|
||||
}
|
||||
|
||||
function indent(lines: string[], spaces = 2): string[] {
|
||||
const pad = ' '.repeat(spaces)
|
||||
return lines.map(line => (line ? `${pad}${line}` : line))
|
||||
}
|
||||
|
|
@ -5,11 +5,11 @@
|
|||
import type { RevolutionaryTypedFetch } from '../core/typed-fetch.js'
|
||||
|
||||
export class TypedAPIProxy {
|
||||
private client: RevolutionaryTypedFetch<any>
|
||||
private client: RevolutionaryTypedFetch
|
||||
private baseURL: string
|
||||
private path: string[]
|
||||
|
||||
constructor(client: RevolutionaryTypedFetch<any>, baseURL: string, path: string[] = []) {
|
||||
constructor(client: RevolutionaryTypedFetch, baseURL: string, path: string[] = []) {
|
||||
this.client = client
|
||||
this.baseURL = baseURL
|
||||
this.path = path
|
||||
|
|
|
|||
21
src/index.ts
21
src/index.ts
|
|
@ -15,22 +15,18 @@
|
|||
// Main client
|
||||
import { RevolutionaryTypedFetch } from './core/typed-fetch.js'
|
||||
import type { TypedFetchConfig } from './types/config.js'
|
||||
import type { EndpointTypeMap } from './types/endpoint-types.js'
|
||||
|
||||
// Export main instances
|
||||
export const tf = new RevolutionaryTypedFetch()
|
||||
|
||||
export function createTypedFetch<TEndpoints extends EndpointTypeMap = EndpointTypeMap>(
|
||||
config?: TypedFetchConfig
|
||||
): RevolutionaryTypedFetch<TEndpoints> {
|
||||
return new RevolutionaryTypedFetch<TEndpoints>(config)
|
||||
export function createTypedFetch(config?: TypedFetchConfig): RevolutionaryTypedFetch {
|
||||
return new RevolutionaryTypedFetch(config)
|
||||
}
|
||||
|
||||
// Export types for advanced usage
|
||||
export type { TypeRegistry, InferFromJSON, TypedError } from './types/index.js'
|
||||
export type { TypedFetchConfig } from './types/config.js'
|
||||
export type { TypeDescriptor } from './types/type-descriptor.js'
|
||||
export type { EndpointTypeEntry, EndpointTypeMap } from './types/endpoint-types.js'
|
||||
|
||||
// Export core classes for advanced usage
|
||||
export { RuntimeTypeInference } from './types/runtime-inference.js'
|
||||
|
|
@ -40,15 +36,4 @@ export { CircuitBreaker } from './core/circuit-breaker.js'
|
|||
export { InterceptorChain } from './core/interceptors.js'
|
||||
export { RequestMetrics } from './core/metrics.js'
|
||||
export { OfflineHandler } from './core/offline-handler.js'
|
||||
export { RequestDeduplicator } from './cache/deduplicator.js'
|
||||
export { createResource } from './core/resource-builder.js'
|
||||
export { TypeDeclarationGenerator } from './discovery/type-generator.js'
|
||||
export { presets } from './core/presets.js'
|
||||
export type { TypedFetchPreset } from './core/presets.js'
|
||||
export type {
|
||||
ResourceBuilderOptions,
|
||||
ResourceDefinition,
|
||||
ResourceInstance,
|
||||
ResourceMethodConfig
|
||||
} from './core/resource-builder.js'
|
||||
export type { TypeSnapshotOptions } from './discovery/type-generator.js'
|
||||
export { RequestDeduplicator } from './cache/deduplicator.js'
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
export interface EndpointTypeEntry {
|
||||
request?: unknown
|
||||
response?: unknown
|
||||
}
|
||||
|
||||
export type EndpointTypeMap = Record<string, EndpointTypeEntry>
|
||||
|
||||
export type NormalizePath<Path extends string> = Path extends ''
|
||||
? '/'
|
||||
: Path extends `/${string}`
|
||||
? Path
|
||||
: Path extends `${string}://${string}`
|
||||
? Path
|
||||
: `/${Path}`
|
||||
|
||||
export type EndpointKey<Method extends string, Path extends string> = `${Uppercase<Method>} ${NormalizePath<Path>}`
|
||||
|
||||
export type EndpointResponse<
|
||||
Map extends EndpointTypeMap,
|
||||
Method extends string,
|
||||
Path extends string
|
||||
> = EndpointKey<Method, Path> extends keyof Map
|
||||
? Map[EndpointKey<Method, Path>] extends { response?: infer R }
|
||||
? R
|
||||
: unknown
|
||||
: unknown
|
||||
|
||||
export type EndpointRequest<
|
||||
Map extends EndpointTypeMap,
|
||||
Method extends string,
|
||||
Path extends string
|
||||
> = EndpointKey<Method, Path> extends keyof Map
|
||||
? Map[EndpointKey<Method, Path>] extends { request?: infer R }
|
||||
? R
|
||||
: unknown
|
||||
: unknown
|
||||
|
|
@ -2,8 +2,6 @@
|
|||
* TypedFetch - Type System and Core Types
|
||||
*/
|
||||
|
||||
import type { TypeDescriptor } from './type-descriptor.js'
|
||||
|
||||
// Advanced TypeScript utilities for runtime type inference
|
||||
export type InferFromJSON<T> = T extends string
|
||||
? string
|
||||
|
|
@ -24,16 +22,14 @@ export type DeepPartial<T> = {
|
|||
}
|
||||
|
||||
// Runtime type storage for discovered APIs
|
||||
export interface TypeRegistryEntry {
|
||||
request?: TypeDescriptor
|
||||
response?: TypeDescriptor
|
||||
method: string
|
||||
lastSeen: number
|
||||
samples: unknown[]
|
||||
}
|
||||
|
||||
export interface TypeRegistry {
|
||||
[endpoint: string]: TypeRegistryEntry
|
||||
[endpoint: string]: {
|
||||
request: any
|
||||
response: any
|
||||
method: string
|
||||
lastSeen: number
|
||||
samples: any[]
|
||||
}
|
||||
}
|
||||
|
||||
// Enhanced error types
|
||||
|
|
|
|||
|
|
@ -1,53 +0,0 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { mergePartialConfig, prepareBodyPayload, resolveBodyArgs } from '../src/core/body-utils.js'
|
||||
import type { TypedFetchConfig } from '../src/types/config.js'
|
||||
|
||||
describe('body utils', () => {
|
||||
it('serializes plain objects and preserves samples', () => {
|
||||
const payload = { id: 1, name: 'Ada' }
|
||||
const prepared = prepareBodyPayload(payload)
|
||||
expect(prepared.bodyInit).toBe(JSON.stringify(payload))
|
||||
expect(prepared.sample).toEqual(payload)
|
||||
})
|
||||
|
||||
it('merges nested config objects deeply', () => {
|
||||
const base: TypedFetchConfig = {
|
||||
request: { headers: { Authorization: 'token' } }
|
||||
}
|
||||
const override: TypedFetchConfig = {
|
||||
request: { headers: { 'X-Test': '1' } },
|
||||
cache: { ttl: 42 }
|
||||
}
|
||||
|
||||
const merged = mergePartialConfig(base, override)
|
||||
expect(merged.request?.headers).toEqual({ 'X-Test': '1' })
|
||||
expect(merged.cache?.ttl).toBe(42)
|
||||
})
|
||||
|
||||
it('normalizes fetch-style init objects and plucks body', () => {
|
||||
const init: RequestInit = {
|
||||
body: JSON.stringify({ id: 1 }),
|
||||
headers: { 'X-Custom': '1' }
|
||||
}
|
||||
|
||||
const resolved = resolveBodyArgs(init)
|
||||
expect(resolved.body).toEqual(JSON.stringify({ id: 1 }))
|
||||
expect(resolved.init).toEqual({ headers: { 'X-Custom': '1' } })
|
||||
})
|
||||
|
||||
it('treats plain payloads as body values', () => {
|
||||
const payload = { title: 'Hello' }
|
||||
const resolved = resolveBodyArgs(payload)
|
||||
expect(resolved.body).toBe(payload)
|
||||
expect(resolved.init).toEqual({})
|
||||
})
|
||||
|
||||
it('copies the third argument when body is separate', () => {
|
||||
const payload = { title: 'Hello' }
|
||||
const init: RequestInit = { headers: { Accept: 'application/json' } }
|
||||
const resolved = resolveBodyArgs(payload, init)
|
||||
expect(resolved.body).toBe(payload)
|
||||
expect(resolved.init).not.toBe(init)
|
||||
expect(resolved.init).toEqual({ headers: { Accept: 'application/json' } })
|
||||
})
|
||||
})
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import * as entry from '../src/index.js'
|
||||
|
||||
describe('package entrypoint exports', () => {
|
||||
it('exposes the primary factory and singleton', () => {
|
||||
expect(entry.tf).toBeDefined()
|
||||
expect(typeof entry.createTypedFetch).toBe('function')
|
||||
})
|
||||
|
||||
it('exposes key utilities for advanced users', () => {
|
||||
expect(entry.RuntimeTypeInference).toBeDefined()
|
||||
expect(entry.WTinyLFUCache).toBeDefined()
|
||||
expect(entry.TypeDeclarationGenerator).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
|
@ -1,107 +0,0 @@
|
|||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { RevolutionaryTypedFetch } from '../src/core/typed-fetch.js'
|
||||
|
||||
describe('mock controller integration', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('intercepts matching requests and skips fetch', async () => {
|
||||
const fetchSpy = vi.fn(() => Promise.reject(new Error('network should not be called')))
|
||||
vi.stubGlobal('fetch', fetchSpy)
|
||||
|
||||
const client = new RevolutionaryTypedFetch({ request: { baseURL: 'https://api.test' } })
|
||||
client.mock({
|
||||
method: 'GET',
|
||||
url: '/users/:id',
|
||||
response: {
|
||||
data: { id: '123', name: 'Mock User' },
|
||||
status: 200
|
||||
}
|
||||
})
|
||||
|
||||
const result = await client.get('/users/123')
|
||||
expect(result.data).toEqual({ id: '123', name: 'Mock User' })
|
||||
expect(result.response.status).toBe(200)
|
||||
expect(fetchSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('passes params, query, headers, and body to handlers', async () => {
|
||||
const fetchSpy = vi.fn(() => Promise.reject(new Error('network should not be called')))
|
||||
vi.stubGlobal('fetch', fetchSpy)
|
||||
|
||||
const client = new RevolutionaryTypedFetch({ request: { baseURL: 'https://api.test' } })
|
||||
client.mock({
|
||||
method: 'PATCH',
|
||||
url: '/users/:id',
|
||||
handler: ({ params, query, headers, body }) => {
|
||||
expect(params.id).toBe('42')
|
||||
expect(query.filter).toBe('active')
|
||||
expect(headers['x-test']).toBe('1')
|
||||
expect((body as any).title).toBe('Director')
|
||||
return {
|
||||
data: {
|
||||
id: params.id,
|
||||
filter: query.filter,
|
||||
seenHeader: headers['x-test']
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const { data } = await client.patch(
|
||||
'/users/42?filter=active',
|
||||
{ title: 'Director' },
|
||||
{ headers: { 'X-Test': '1' } }
|
||||
)
|
||||
|
||||
expect(data).toEqual({ id: '42', filter: 'active', seenHeader: '1' })
|
||||
expect(fetchSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('mockOnce only intercepts the first call', async () => {
|
||||
const fetchSpy = vi.fn(async () =>
|
||||
new Response(JSON.stringify({ ok: true }), {
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
)
|
||||
vi.stubGlobal('fetch', fetchSpy)
|
||||
|
||||
const client = new RevolutionaryTypedFetch({ request: { baseURL: 'https://api.test' } })
|
||||
client.mockOnce({
|
||||
method: 'GET',
|
||||
url: '/ping',
|
||||
response: { data: { mocked: true } }
|
||||
})
|
||||
|
||||
const first = await client.get('/ping')
|
||||
expect(first.data).toEqual({ mocked: true })
|
||||
|
||||
const second = await client.get('/ping')
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1)
|
||||
expect(second.data).toEqual({ ok: true })
|
||||
})
|
||||
|
||||
it('clearMocks removes registered routes', async () => {
|
||||
const fetchSpy = vi.fn(async () =>
|
||||
new Response(JSON.stringify({ ok: true }), {
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
)
|
||||
vi.stubGlobal('fetch', fetchSpy)
|
||||
|
||||
const client = new RevolutionaryTypedFetch({ request: { baseURL: 'https://api.test' } })
|
||||
client.mock({
|
||||
method: 'GET',
|
||||
url: '/users',
|
||||
response: { data: [{ id: 1 }] }
|
||||
})
|
||||
|
||||
client.clearMocks()
|
||||
|
||||
const result = await client.get('/users')
|
||||
expect(result.data).toEqual({ ok: true })
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
import { readFileSync } from 'node:fs'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(resolve(__dirname, '..', 'package.json'), 'utf8')
|
||||
)
|
||||
|
||||
describe('package metadata', () => {
|
||||
it('exposes dual-build entrypoints for easy installation', () => {
|
||||
expect(pkg.main).toBe('./dist/index.js')
|
||||
expect(pkg.module).toBe('./dist/index.js')
|
||||
expect(pkg.types).toBe('./dist/index.d.ts')
|
||||
expect(pkg.exports?.['.']?.import).toBe('./dist/index.js')
|
||||
expect(pkg.exports?.['.']?.require).toBe('./dist/index.cjs')
|
||||
expect(pkg.exports?.['.']?.types).toBe('./dist/index.d.ts')
|
||||
})
|
||||
|
||||
it('ships distributable files and documentation', () => {
|
||||
expect(pkg.files).toEqual(
|
||||
expect.arrayContaining(['dist', 'README.md', 'LICENSE'])
|
||||
)
|
||||
})
|
||||
|
||||
it('includes the CLI entrypoint', () => {
|
||||
expect(pkg.bin?.typedfetch).toBe('./dist/cli.js')
|
||||
})
|
||||
|
||||
it('is versioned for publishing', () => {
|
||||
expect(pkg.version).not.toBe('0.0.0')
|
||||
})
|
||||
})
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { createResource, type ResourceDefinition } from '../src/core/resource-builder.js'
|
||||
|
||||
describe('resource builder', () => {
|
||||
const response = new Response('{}')
|
||||
|
||||
it('expands params and merges query defaults', async () => {
|
||||
const calls: Array<{ method: string; url: string; bodySample?: unknown }> = []
|
||||
const requester = {
|
||||
async request<T>(method: string, url: string, init?: RequestInit, bodySample?: unknown) {
|
||||
calls.push({ method, url, bodySample })
|
||||
return { data: { ok: true } as T, response }
|
||||
}
|
||||
}
|
||||
|
||||
const definition: ResourceDefinition = {
|
||||
show: { method: 'GET', path: '', query: { expand: 'profile' } }
|
||||
}
|
||||
|
||||
const resource = createResource(requester, '/users/:id', definition, { query: { locale: 'en' } })
|
||||
await resource.show({ params: { id: 42 }, query: { expand: 'posts' } })
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]!.url).toBe('/users/42?locale=en&expand=posts')
|
||||
expect(resource.$path({ id: 42 }, { search: 'text' })).toBe('/users/42?locale=en&search=text')
|
||||
})
|
||||
})
|
||||
|
|
@ -1,88 +0,0 @@
|
|||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { RevolutionaryTypedFetch } from '../src/core/typed-fetch.js'
|
||||
|
||||
const createChunkedStream = (chunks: number[]) => {
|
||||
let index = 0
|
||||
return {
|
||||
getReader() {
|
||||
return {
|
||||
async read() {
|
||||
if (index >= chunks.length) {
|
||||
return { done: true as const, value: undefined }
|
||||
}
|
||||
const size = chunks[index++] ?? 0
|
||||
return { done: false as const, value: new Uint8Array(size || 0) }
|
||||
},
|
||||
async cancel() {
|
||||
/* noop */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('throttled helper', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('throttles response streams before delivering subsequent chunks', async () => {
|
||||
const tf = new RevolutionaryTypedFetch()
|
||||
let current = 0
|
||||
const sleep = vi.fn(async (ms: number) => {
|
||||
current += ms
|
||||
})
|
||||
|
||||
const stream = createChunkedStream([1024, 1024])
|
||||
const throttled = await tf.throttled(() => Promise.resolve(stream), {
|
||||
bandwidth: '1KB/s',
|
||||
sleep,
|
||||
now: () => current
|
||||
})
|
||||
expect(typeof throttled.getReader).toBe('function')
|
||||
|
||||
const reader = throttled.getReader()
|
||||
const first = await reader.read()
|
||||
expect(first.value?.length).toBe(1024)
|
||||
|
||||
const second = await reader.read()
|
||||
expect(second.value?.length).toBe(1024)
|
||||
expect(sleep).toHaveBeenCalled()
|
||||
|
||||
const final = await reader.read()
|
||||
expect(final.done).toBe(true)
|
||||
})
|
||||
|
||||
it('wraps nested response objects without touching data payloads', async () => {
|
||||
const tf = new RevolutionaryTypedFetch()
|
||||
let current = 0
|
||||
const sleep = vi.fn(async (ms: number) => {
|
||||
current += ms
|
||||
})
|
||||
|
||||
const original = {
|
||||
body: createChunkedStream([2048, 2048]),
|
||||
headers: new Headers({ 'X-Test': '1' }),
|
||||
status: 202,
|
||||
statusText: 'Accepted'
|
||||
}
|
||||
const payload = { data: { ok: true }, response: original }
|
||||
|
||||
const result = await tf.throttled(() => Promise.resolve(payload), {
|
||||
bandwidth: '1KB/s',
|
||||
sleep,
|
||||
now: () => current
|
||||
})
|
||||
|
||||
expect(result.data).toEqual({ ok: true })
|
||||
expect(result.response).not.toBe(original)
|
||||
expect(result.response.status).toBe(202)
|
||||
|
||||
const reader = result.response.body!.getReader()
|
||||
await reader.read()
|
||||
await reader.read()
|
||||
const tail = await reader.read()
|
||||
expect(tail.done).toBe(true)
|
||||
})
|
||||
})
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { TypeDeclarationGenerator } from '../src/discovery/type-generator.js'
|
||||
import type { TypeRegistry } from '../src/types/index.js'
|
||||
|
||||
const registry: TypeRegistry = {
|
||||
'GET /users': {
|
||||
method: 'GET',
|
||||
lastSeen: Date.now(),
|
||||
samples: [],
|
||||
request: {
|
||||
type: 'object',
|
||||
properties: { id: { type: 'string' } },
|
||||
required: ['id']
|
||||
},
|
||||
response: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string' },
|
||||
email: { type: 'string' }
|
||||
},
|
||||
required: ['id']
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('type generator', () => {
|
||||
it('emits request and response aliases', () => {
|
||||
const generator = new TypeDeclarationGenerator(registry)
|
||||
const snapshot = generator.generate({ namespace: 'API' })
|
||||
expect(snapshot).toContain('export type GetUsersRequest')
|
||||
expect(snapshot).toContain('declare namespace API')
|
||||
expect(snapshot).toContain("'GET /users': { request: GetUsersRequest; response: GetUsersResponse; method: 'GET'; path: '/users' }")
|
||||
})
|
||||
})
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { RevolutionaryTypedFetch } from '../src/core/typed-fetch.js'
|
||||
|
||||
const jsonResponse = (body: any) =>
|
||||
new Response(JSON.stringify(body), {
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
|
||||
describe('RevolutionaryTypedFetch request polish', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('applies a JSON content-type automatically for plain object bodies', async () => {
|
||||
const fetchSpy = vi.fn(async (_url, init) => {
|
||||
const headers = new Headers(init?.headers as HeadersInit)
|
||||
expect(headers.get('content-type')).toBe('application/json')
|
||||
return jsonResponse({ ok: true })
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchSpy)
|
||||
|
||||
const client = new RevolutionaryTypedFetch({ request: { baseURL: 'https://api.test' } })
|
||||
await client.post('/items', { name: 'test' })
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not dedupe concurrent requests when bodies differ', async () => {
|
||||
const fetchSpy = vi.fn(async (_url, init) => {
|
||||
return jsonResponse({ echoed: init?.body })
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchSpy)
|
||||
|
||||
const client = new RevolutionaryTypedFetch({ request: { baseURL: 'https://api.test' } })
|
||||
await Promise.all([
|
||||
client.post('/items', { name: 'one' }),
|
||||
client.post('/items', { name: 'two' })
|
||||
])
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue