Merge origin/main into branch

This commit is contained in:
Casey Collier 2025-11-13 00:49:42 -05:00
commit 6d25427319
4 changed files with 203 additions and 128 deletions

View file

@ -117,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')
```
@ -355,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

@ -91,7 +91,7 @@ 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)
}
// REAL runtime type tracking
@ -237,7 +237,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
@ -1130,137 +1130,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 {
@ -1279,4 +1314,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

@ -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'