diff --git a/README.md b/README.md index c6f775f..823d86c 100644 --- a/README.md +++ b/README.md @@ -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. 🚀 \ No newline at end of file +**TypedFetch**: Because life's too short for complex HTTP clients. 🚀 diff --git a/src/core/resource-builder.ts b/src/core/resource-builder.ts index 6706f37..0b7aa64 100644 --- a/src/core/resource-builder.ts +++ b/src/core/resource-builder.ts @@ -70,8 +70,8 @@ export interface ResourceBuilderOptions { } interface ResourceRequester { - request(method: string, url: string, init?: RequestInit, bodySample?: unknown): Promise<{ - data: any + request(method: string, url: string, init?: RequestInit, bodySample?: unknown): Promise<{ + data: T response: Response }> } @@ -85,10 +85,21 @@ export function createResource( const normalizedBase = normalizePath(basePath, options.trailingSlash) const resource: Record = {} - for (const [name, config] of Object.entries(definition)) { - resource[name] = async ( - args: ResourceCallArgs, Record> = {} - ) => { + for (const name of Object.keys(definition) as Array) { + type Config = TDefinition[typeof name] + type BodyType = Config extends ResourceMethodConfig ? TBody : never + type ParamsType = Config extends ResourceMethodConfig + ? TParams + : Record + type QueryType = Config extends ResourceMethodConfig + ? TQuery + : Record + type ResponseType = Config extends ResourceMethodConfig + ? TResponse + : unknown + const config = definition[name] as Config + + resource[name as string] = async (args: ResourceCallArgs = {}) => { 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( 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( init.body = prepared?.bodyInit ?? null } - const result = await requester.request( + const result = await requester.request( method, finalUrl, init, @@ -132,7 +143,7 @@ export function createResource( response: result.response } } - return result as { data: unknown; response: Response } + return result } } diff --git a/src/core/typed-fetch.ts b/src/core/typed-fetch.ts index c820ca9..e8e3e73 100644 --- a/src/core/typed-fetch.ts +++ b/src/core/typed-fetch.ts @@ -91,7 +91,7 @@ export class RevolutionaryTypedFetch { const mergedConfig = mergeConfig(this.config, config) - return new RevolutionaryTypedFetch(mergedConfig) + return new RevolutionaryTypedFetch(mergedConfig) } // REAL runtime type tracking @@ -237,7 +237,7 @@ export class RevolutionaryTypedFetch { 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 { 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 | { getReader: () => { - read: () => Promise<{ value?: Uint8Array; done: boolean }> - cancel?: (reason?: unknown) => Promise | void - } } - - const isStreamLike = (candidate: any): candidate is StreamLike => { - return Boolean(candidate && typeof candidate.getReader === 'function') - } - - const throttleStream = (stream: StreamLike): ReadableStream => { - const reader = stream.getReader() - return new ReadableStream({ - 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).then === 'function') { - ;(result as Promise).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 = {} + 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 => { + 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 = ['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 = (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 + 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