Merge origin/main into branch
This commit is contained in:
commit
6d25427319
4 changed files with 203 additions and 128 deletions
|
|
@ -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. 🚀
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue