Add CLI and wire types into HTTP helpers

This commit is contained in:
Casey Collier 2025-11-12 21:56:37 -05:00
parent a833e5334a
commit 47c17ef7cb
15 changed files with 1059 additions and 65 deletions

107
README.md
View file

@ -37,6 +37,23 @@ const client = createTypedFetch({
})
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
```
## ✨ Features
@ -45,6 +62,7 @@ const { data, response } = await client.get('/users/123')
- 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
@ -57,6 +75,7 @@ const { data, response } = await client.get('/users/123')
- Standard HTTP methods: get(), post(), put(), delete()
- Consistent response format
- Zero boilerplate
- Declarative `resource()` builder for human-friendly endpoint modules
### ⚡ Performance
- <15KB gzipped bundle
@ -89,6 +108,13 @@ const { data, response } = await tf.put('https://api.example.com/users/123', {
}
})
// PATCH request
const { data } = await tf.patch('https://api.example.com/users/123', {
body: {
title: 'Director of Engineering'
}
})
// DELETE request
const { data, response } = await tf.delete('https://api.example.com/users/123')
```
@ -133,6 +159,87 @@ tf.configure({
})
```
### 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:

View file

@ -6,6 +6,9 @@
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"bin": {
"typedfetch": "./dist/cli.js"
},
"exports": {
".": {
"import": "./dist/index.js",
@ -19,12 +22,14 @@
"LICENSE"
],
"scripts": {
"build": "bun run build:clean && bun run build:esm && bun run build:cjs && bun run build:types",
"build": "bun run build:clean && bun run build:esm && bun run build:cjs && bun run build:cli && 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 Normal file
View file

@ -0,0 +1,163 @@
#!/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
})

93
src/core/body-utils.ts Normal file
View file

@ -0,0 +1,93 @@
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
}
const hasBlob = typeof Blob !== 'undefined'
const hasFormData = typeof FormData !== 'undefined'
const hasURLSearchParams = typeof URLSearchParams !== 'undefined'
const hasReadableStream = typeof ReadableStream !== 'undefined'
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
}

73
src/core/presets.ts Normal file
View file

@ -0,0 +1,73 @@
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}`
}
}
})
}
}
}

View file

@ -0,0 +1,194 @@
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(method: string, url: string, init?: RequestInit, bodySample?: unknown): Promise<{
data: any
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, config] of Object.entries(definition)) {
resource[name] = async (
args: ResourceCallArgs<any, Record<string, string | number>, Record<string, unknown>> = {}
) => {
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
const hasBody = args.body !== undefined
const prepared = hasBody
? serializer
? { bodyInit: serializer(args.body), sample: args.body }
: prepareBodyPayload(args.body, { 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(
method,
finalUrl,
init,
hasBody ? prepared?.sample : undefined
)
if (config.transformResponse) {
return {
data: config.transformResponse(result.data, result.response),
response: result.response
}
}
return result as { data: unknown; response: Response }
}
}
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}` : ''
}

View file

@ -12,15 +12,23 @@ 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 { prepareBodyPayload } from './body-utils.js'
import { createResource, type ResourceBuilderOptions, type ResourceDefinition, type ResourceInstance } from './resource-builder.js'
import { applyPresetChain, type TypedFetchPreset } from './presets.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'
export class RevolutionaryTypedFetch {
const MAX_TYPE_SAMPLES = 5
export class RevolutionaryTypedFetch<TEndpoints extends EndpointTypeMap = EndpointTypeMap> {
private config: Required<TypedFetchConfig>
private cache: WTinyLFUCache
private deduplicator = new RequestDeduplicator()
@ -64,28 +72,62 @@ export class RevolutionaryTypedFetch {
// 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 {
create(config: TypedFetchConfig): RevolutionaryTypedFetch<TEndpoints> {
const mergedConfig = mergeConfig(this.config, config)
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 = `${method.toUpperCase()} ${endpoint}`
const key = this.buildRegistryKey(method, endpoint)
this.typeInference.addSample(key, data)
// 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]
const entry = this.ensureRegistryEntry(key, method)
const inferred = this.typeInference.inferType(key)
if (inferred) {
entry.response = inferred
}
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
@ -135,20 +177,62 @@ export class RevolutionaryTypedFetch {
}
// REAL HTTP methods with full type safety
async get<T = unknown>(url: string, options: RequestInit = {}): Promise<{ data: T; response: Response }> {
return this.request<T>('GET', url, options)
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 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 post<Url extends string, TBody = EndpointRequest<TEndpoints, 'POST', Url>>(
url: Url,
body?: TBody,
options: RequestInit = {}
): Promise<{ data: EndpointResponse<TEndpoints, 'POST', Url>; response: Response }> {
if (body === undefined) {
return this.request<'POST', Url>('POST', url, options)
}
const prepared = prepareBodyPayload(body)
return this.request<'POST', Url>('POST', url, { ...options, 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 put<Url extends string, TBody = EndpointRequest<TEndpoints, 'PUT', Url>>(
url: Url,
body?: TBody,
options: RequestInit = {}
): Promise<{ data: EndpointResponse<TEndpoints, 'PUT', Url>; response: Response }> {
if (body === undefined) {
return this.request<'PUT', Url>('PUT', url, options)
}
const prepared = prepareBodyPayload(body)
return this.request<'PUT', Url>('PUT', url, { ...options, 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 patch<Url extends string, TBody = EndpointRequest<TEndpoints, 'PATCH', Url>>(
url: Url,
body?: TBody,
options: RequestInit = {}
): Promise<{ data: EndpointResponse<TEndpoints, 'PATCH', Url>; response: Response }> {
if (body === undefined) {
return this.request<'PATCH', Url>('PATCH', url, options)
}
const prepared = prepareBodyPayload(body)
return this.request<'PATCH', Url>('PATCH', url, { ...options, body: prepared.bodyInit }, prepared.sample)
}
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 {
@ -165,7 +249,19 @@ export class RevolutionaryTypedFetch {
}
}
private async request<T>(method: string, url: string, options: RequestInit = {}): Promise<{ data: T; response: Response }> {
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
}> {
const fullUrl = this.resolveUrl(url)
const cacheKey = `${method}:${fullUrl}`
const startTime = performance.now()
@ -182,7 +278,7 @@ export class RevolutionaryTypedFetch {
if (this.config.metrics.enabled) {
this.metrics.recordRequest(fullUrl, duration, cached)
}
return { data: cachedData as T, response: new Response('cached') }
return { data: cachedData as TResponse, response: new Response('cached') }
}
}
@ -210,13 +306,17 @@ export class RevolutionaryTypedFetch {
// Process through interceptors
const processedOptions = await this.interceptors.processRequest(requestOptions)
if (bodySample !== undefined) {
this.recordRequest(url, method, bodySample)
}
// Handle offline requests
const result = await this.offlineHandler.handleRequest(fullUrl, processedOptions, async () => {
// Deduplicate identical requests
return this.deduplicator.dedupe(cacheKey, async () => {
// Execute with circuit breaker and retry logic
return this.executeWithRetry<T>(fullUrl, processedOptions, url, method)
return this.executeWithRetry<Method, Url, TResponse>(fullUrl, processedOptions, url, method)
})
})
@ -254,7 +354,16 @@ export class RevolutionaryTypedFetch {
}
}
private async executeWithRetry<T>(fullUrl: string, options: any, originalUrl: string, method: string): Promise<{ data: T; response: Response }> {
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 }> {
let lastError: any
const maxAttempts = method === 'GET' ? (this.config.retry.maxAttempts || 1) : 1
const startTime = performance.now()
@ -288,8 +397,8 @@ export class RevolutionaryTypedFetch {
// Process through response interceptors
const processedResponse = await this.interceptors.processResponse({ data, response })
return processedResponse
return processedResponse as { data: TResponse; response: Response }
}
// Execute with or without circuit breaker
@ -361,6 +470,14 @@ export class RevolutionaryTypedFetch {
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)
@ -512,14 +629,14 @@ export class RevolutionaryTypedFetch {
const chunkPromises = chunk.map(async (req) => {
try {
const method = req.method || 'GET'
const result = await this.request<T>(
method,
req.url,
{
body: req.body ? JSON.stringify(req.body) : null,
headers: req.headers || {}
}
)
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)
return { data: result.data, response: result.response }
} catch (error) {
if (throwOnError) throw error
@ -662,14 +779,14 @@ export class RevolutionaryTypedFetch {
const promises = requests.map(async (req, index) => {
const method = req.method || 'GET'
const result = await this.request<T>(
method,
req.url,
{
body: req.body ? JSON.stringify(req.body) : null,
headers: req.headers || {}
}
)
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)
return { ...result, winner: index }
})
@ -711,14 +828,14 @@ export class RevolutionaryTypedFetch {
requests.map(async (req) => {
try {
const method = req.method || 'GET'
const result = await this.request<T>(
method,
req.url,
{
body: req.body ? JSON.stringify(req.body) : null,
headers: req.headers || {}
}
)
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)
return { data: result.data, response: result.response }
} catch (error) {
return { error }

View file

@ -0,0 +1,94 @@
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))
}

View file

@ -5,11 +5,11 @@
import type { RevolutionaryTypedFetch } from '../core/typed-fetch.js'
export class TypedAPIProxy {
private client: RevolutionaryTypedFetch
private client: RevolutionaryTypedFetch<any>
private baseURL: string
private path: string[]
constructor(client: RevolutionaryTypedFetch, baseURL: string, path: string[] = []) {
constructor(client: RevolutionaryTypedFetch<any>, baseURL: string, path: string[] = []) {
this.client = client
this.baseURL = baseURL
this.path = path

View file

@ -15,18 +15,22 @@
// 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(config?: TypedFetchConfig): RevolutionaryTypedFetch {
return new RevolutionaryTypedFetch(config)
export function createTypedFetch<TEndpoints extends EndpointTypeMap = EndpointTypeMap>(
config?: TypedFetchConfig
): RevolutionaryTypedFetch<TEndpoints> {
return new RevolutionaryTypedFetch<TEndpoints>(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'
@ -36,4 +40,15 @@ 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 { 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'

View file

@ -0,0 +1,36 @@
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

View file

@ -2,6 +2,8 @@
* 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
@ -22,14 +24,16 @@ 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]: {
request: any
response: any
method: string
lastSeen: number
samples: any[]
}
[endpoint: string]: TypeRegistryEntry
}
// Enhanced error types

27
tests/body-utils.test.ts Normal file
View file

@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest'
import { mergePartialConfig, prepareBodyPayload } 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)
})
})

View file

@ -0,0 +1,28 @@
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')
})
})

View file

@ -0,0 +1,38 @@
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' }")
})
})