Merge origin/main into branch

This commit is contained in:
Casey Collier 2025-11-13 01:27:59 -05:00
commit 034070481c
5 changed files with 238 additions and 198 deletions

View file

@ -78,7 +78,6 @@ const profile = await typed.get('/me')
- Consistent response format
- Zero boilerplate
- Declarative `resource()` builder for human-friendly endpoint modules
- Inline request mocking utilities for tests, demos, and offline work
### ⚡ Performance
- <15KB gzipped bundle
@ -118,6 +117,13 @@ 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', {
body: {
title: 'Director of Engineering'
}
})
// DELETE request
const { data, response } = await tf.delete('https://api.example.com/users/123')
```
@ -205,37 +211,6 @@ await users.update({ params: { id: '42' }, body: { name: 'Nova' } })
Resources automatically expand `:params`, merge query objects, and keep returning the standard `{ data, response }` tuple.
### Built-in mocking for tests & demos
TypedFetch now includes a zero-dependency mock adapter so you can short-circuit requests without swapping clients:
```typescript
import { tf } from '@catalystlabs/typedfetch'
// Match colon params or even RegExp/function matchers
tf.mock({
method: 'GET',
url: '/users/:id',
handler: ({ params, query }) => ({
data: { id: params.id, name: 'Mocked User', filter: query.filter }
})
})
// Only intercept the next matching call
tf.mockOnce({
method: 'POST',
url: '/users',
response: { status: 201, data: { id: 'temp-id' } }
})
// Toggle or reset mocks globally
tf.disableMocking()
tf.enableMocking()
tf.clearMocks()
```
Handlers receive params, query, headers, and even parsed bodies, and their `data` payloads still feed the type registry—perfect for storybooks, tests, or rapid prototyping without standing up a server.
### Type snapshot export
```typescript
@ -387,4 +362,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. 🚀

View file

@ -70,8 +70,8 @@ export interface ResourceBuilderOptions {
}
interface ResourceRequester {
request(method: string, url: string, init?: RequestInit, bodySample?: unknown): Promise<{
data: any
request<T>(method: string, url: string, init?: RequestInit, bodySample?: unknown): Promise<{
data: T
response: Response
}>
}
@ -85,10 +85,21 @@ export function createResource<TDefinition extends ResourceDefinition>(
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>> = {}
) => {
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)
@ -100,12 +111,12 @@ export function createResource<TDefinition extends ResourceDefinition>(
const finalUrl = `${resolvedPath}${query}`
const shouldUseJson = config.json ?? true
const serializer = config.serializeBody
const serializer = config.serializeBody as ((body: BodyType) => BodyInit | undefined) | undefined
const hasBody = args.body !== undefined
const prepared = hasBody
? serializer
? { bodyInit: serializer(args.body), sample: args.body }
: prepareBodyPayload(args.body, { json: shouldUseJson })
? { bodyInit: serializer(args.body as BodyType), sample: args.body }
: prepareBodyPayload(args.body as BodyType, { json: shouldUseJson })
: undefined
const init: RequestInit = {
@ -120,7 +131,7 @@ export function createResource<TDefinition extends ResourceDefinition>(
init.body = prepared?.bodyInit ?? null
}
const result = await requester.request(
const result = await requester.request<ResponseType>(
method,
finalUrl,
init,
@ -132,7 +143,7 @@ export function createResource<TDefinition extends ResourceDefinition>(
response: result.response
}
}
return result as { data: unknown; response: Response }
return result
}
}

View file

@ -15,8 +15,8 @@ import { createHttpError, enhanceError, type ErrorContext } from './errors.js'
import { 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 { TypeDeclarationGenerator, type TypeSnapshotOptions } from '../discovery/type-generator.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'
@ -93,7 +93,30 @@ export class RevolutionaryTypedFetch<TEndpoints extends EndpointTypeMap = Endpoi
*/
create(config: TypedFetchConfig): RevolutionaryTypedFetch<TEndpoints> {
const mergedConfig = mergeConfig(this.config, config)
return new RevolutionaryTypedFetch(mergedConfig)
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()
}
// REAL runtime type tracking
@ -239,7 +262,7 @@ export class RevolutionaryTypedFetch<TEndpoints extends EndpointTypeMap = Endpoi
): 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
@ -315,27 +338,29 @@ export class RevolutionaryTypedFetch<TEndpoints extends EndpointTypeMap = Endpoi
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 mocked = await this.mockController.execute<TResponse>(mockMatch, {
const mockResult = await this.mockController.execute<TResponse>(mockMatch, {
url: fullUrl,
method,
request: processedOptions,
bodySample,
delay: (ms) => this.delay(ms)
})
if (mocked.data !== undefined) {
this.recordResponse(url, method, mocked.data)
}
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)
this.metrics.recordRequest(fullUrl, duration, cached, error)
}
if (this.config.debug.logSuccess) {
console.log(`✅ Mocked request: ${method} ${fullUrl} (${duration.toFixed(0)}ms)`)
console.log(`✅ Mock response: ${method} ${fullUrl} (${duration.toFixed(0)}ms)`)
}
return mocked
return processedResponse as { data: TResponse; response: Response }
}
// Handle offline requests
@ -741,26 +766,6 @@ export class RevolutionaryTypedFetch<TEndpoints extends EndpointTypeMap = Endpoi
variables
})
}
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()
}
enableMocking(): void {
this.mockController.enable()
}
disableMocking(): void {
this.mockController.disable()
}
// Circuit breaker control
resetCircuitBreaker(): void {
@ -1174,137 +1179,172 @@ export class RevolutionaryTypedFetch<TEndpoints extends EndpointTypeMap = Endpoi
): Promise<T> {
const {
bandwidth = '1MB/s',
burst = 0,
sleep: sleepOverride,
now: nowOverride
burst,
sleep = (ms: number) => this.delay(ms),
now = () => Date.now()
} = options
const sleep = sleepOverride ?? ((ms: number) => this.delay(ms))
const now = nowOverride ?? (() => Date.now())
// Parse bandwidth string
const bytesPerSecond = typeof bandwidth === 'string'
? this.parseBandwidth(bandwidth)
: bandwidth
// Token bucket algorithm
const capacity = burst || bytesPerSecond
const bytesPerSecond =
typeof bandwidth === 'string' ? this.parseBandwidth(bandwidth) : bandwidth
if (!bytesPerSecond || !isFinite(bytesPerSecond) || bytesPerSecond <= 0) {
return fn()
}
const capacity = burst && burst > 0 ? burst : bytesPerSecond
const bucket = {
tokens: capacity,
lastRefill: now(),
capacity
capacity,
lastRefill: now()
}
const refill = () => {
const current = now()
const elapsed = (current - bucket.lastRefill) / 1000
const tokensToAdd = elapsed * bytesPerSecond
bucket.tokens = Math.min(bucket.capacity, bucket.tokens + tokensToAdd)
const elapsedSeconds = (current - bucket.lastRefill) / 1000
if (elapsedSeconds <= 0) return
bucket.tokens = Math.min(
bucket.capacity,
bucket.tokens + elapsedSeconds * bytesPerSecond
)
bucket.lastRefill = current
}
const waitForTokens = async (needed: number) => {
refill()
while (bucket.tokens < needed) {
const deficit = needed - bucket.tokens
const waitTime = (deficit / bytesPerSecond) * 1000
await sleep(Math.min(waitTime, 100))
const consume = async (bytes: number) => {
if (!bytes || bytes <= 0) return
let remaining = bytes
while (remaining > 0) {
refill()
}
bucket.tokens -= needed
}
type StreamLike = ReadableStream<Uint8Array> | { getReader: () => {
read: () => Promise<{ value?: Uint8Array; done: boolean }>
cancel?: (reason?: unknown) => Promise<void> | void
} }
const isStreamLike = (candidate: any): candidate is StreamLike => {
return Boolean(candidate && typeof candidate.getReader === 'function')
}
const throttleStream = (stream: StreamLike): ReadableStream<Uint8Array> => {
const reader = stream.getReader()
return new ReadableStream<Uint8Array>({
pull: async (controller) => {
const { value, done } = await reader.read()
if (done) {
controller.close()
return
}
const chunkSize = value?.length ?? 0
if (chunkSize > 0) {
await waitForTokens(chunkSize)
}
controller.enqueue(value)
},
cancel: (reason) => {
if (typeof reader.cancel === 'function') {
const result = reader.cancel(reason)
if (result && typeof (result as Promise<unknown>).then === 'function') {
;(result as Promise<unknown>).catch(() => {})
}
}
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)
}
}
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 cloneResponse = (response: any) => {
if (!response?.body || !isStreamLike(response.body)) {
return response
}
if (typeof Response !== 'undefined' && response instanceof Response) {
const throttledBody = throttleStream(response.body)
const cloned = new Response(throttledBody, {
headers: new Headers(response.headers),
status: response.status,
statusText: response.statusText
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)
})
const copyProps: Array<keyof Response | 'url' | 'type' | 'redirected'> = ['url', 'type', 'redirected']
for (const prop of copyProps) {
try {
Object.defineProperty(cloned, prop, {
configurable: true,
value: (response as any)[prop]
})
} catch {
// Ignore if the environment doesn't allow redefining these properties
}
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
}
}
return cloned
}
return { ...response, body: throttleStream(response.body) }
}
const maybeThrottle = (value: any): any => {
if (!value) return value
if (typeof Response !== 'undefined' && value instanceof Response) {
return cloneResponse(value)
}
if (isStreamLike(value)) {
return throttleStream(value)
}
if (typeof value === 'object') {
if ('response' in value && value.response) {
return { ...value, response: cloneResponse(value.response) }
}
if ('body' in value && isStreamLike(value.body)) {
return { ...value, body: throttleStream(value.body) }
if ('body' in candidate) {
const wrappedBody = wrapStreamLike(candidate.body)
if (wrappedBody !== candidate.body) {
return { ...candidate, body: wrappedBody } as U
}
}
}
return value
}
const result = await fn()
return maybeThrottle(result)
const output = await fn()
return applyThrottling(output)
}
private parseBandwidth(bandwidth: string): number {
@ -1323,4 +1363,27 @@ 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
}

View file

@ -44,7 +44,6 @@ 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 { MockController } from './core/mock-controller.js'
export type { TypedFetchPreset } from './core/presets.js'
export type {
ResourceBuilderOptions,
@ -52,11 +51,4 @@ export type {
ResourceInstance,
ResourceMethodConfig
} from './core/resource-builder.js'
export type { TypeSnapshotOptions } from './discovery/type-generator.js'
export type {
MockRouteDefinition,
MockHandler,
MockResponse,
MockMatcher,
MockMatcherResult
} from './core/mock-controller.js'
export type { TypeSnapshotOptions } from './discovery/type-generator.js'

View file

@ -1,5 +1,4 @@
import { describe, expect, it } from 'vitest'
import { mergePartialConfig, prepareBodyPayload, resolveBodyArgs } from '../src/core/body-utils.js'
import type { TypedFetchConfig } from '../src/types/config.js'