Merge pull request #6 from ccollier86/codex/review-typed-fetch-refactor-progress
Add default JSON headers, safer deduping, and export smoke tests
This commit is contained in:
commit
4d80767c53
5 changed files with 135 additions and 20 deletions
|
|
@ -40,7 +40,7 @@ const hasFormData = typeof FormData !== 'undefined'
|
|||
const hasURLSearchParams = typeof URLSearchParams !== 'undefined'
|
||||
const hasReadableStream = typeof ReadableStream !== 'undefined'
|
||||
|
||||
function isBinaryBody(value: unknown): value is BodyInit {
|
||||
export function isBinaryBody(value: unknown): value is BodyInit {
|
||||
if (value === null || value === undefined) return false
|
||||
if (typeof value === 'string') return false
|
||||
|
||||
|
|
|
|||
|
|
@ -104,18 +104,21 @@ export function enhanceError(error: any, url: string, context?: ErrorContext): T
|
|||
error.code === 'ETIMEDOUT' ? 'Request timed out - server may be slow' : null
|
||||
].filter(Boolean) as string[]
|
||||
|
||||
enhanced.debug = () => {
|
||||
console.group(`🚨 Network Error Debug`)
|
||||
console.log('URL:', url)
|
||||
console.log('Method:', enhanced.method)
|
||||
console.log('Error:', error.message)
|
||||
console.log('Error Code:', error.code)
|
||||
console.log('Timestamp:', new Date(enhanced.timestamp!).toISOString())
|
||||
if (enhanced.attempt) console.log('Attempt:', enhanced.attempt)
|
||||
if (enhanced.duration) console.log('Duration:', `${enhanced.duration}ms`)
|
||||
console.log('Stack:', error.stack)
|
||||
console.groupEnd()
|
||||
}
|
||||
Object.defineProperty(enhanced, 'debug', {
|
||||
enumerable: false,
|
||||
value: () => {
|
||||
console.group(`🚨 Network Error Debug`)
|
||||
console.log('URL:', url)
|
||||
console.log('Method:', enhanced.method)
|
||||
console.log('Error:', error.message)
|
||||
console.log('Error Code:', error.code)
|
||||
console.log('Timestamp:', new Date(enhanced.timestamp!).toISOString())
|
||||
if (enhanced.attempt) console.log('Attempt:', enhanced.attempt)
|
||||
if (enhanced.duration) console.log('Duration:', `${enhanced.duration}ms`)
|
||||
console.log('Stack:', error.stack)
|
||||
console.groupEnd()
|
||||
}
|
||||
})
|
||||
|
||||
return enhanced
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ 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, resolveBodyArgs } from './body-utils.js'
|
||||
import { isBinaryBody, prepareBodyPayload, resolveBodyArgs } from './body-utils.js'
|
||||
import {
|
||||
createResource,
|
||||
type ResourceBuilderOptions,
|
||||
|
|
@ -282,6 +282,58 @@ export class RevolutionaryTypedFetch<TEndpoints extends EndpointTypeMap = Endpoi
|
|||
}
|
||||
}
|
||||
|
||||
private applyDefaultJsonContentType(
|
||||
headers: Headers,
|
||||
body?: BodyInit | null,
|
||||
bodySample?: unknown
|
||||
): void {
|
||||
if (headers.has('Content-Type')) return
|
||||
if (!this.shouldSendJsonContentType(body, bodySample)) return
|
||||
headers.set('Content-Type', 'application/json')
|
||||
}
|
||||
|
||||
private shouldSendJsonContentType(body?: BodyInit | null, bodySample?: unknown): boolean {
|
||||
const candidate = bodySample ?? body
|
||||
if (candidate === undefined || candidate === null) return false
|
||||
if (typeof candidate === 'string') return true
|
||||
if (isBinaryBody(candidate)) return false
|
||||
if (typeof candidate === 'object' && (candidate as any)[Symbol.asyncIterator]) return false
|
||||
return true
|
||||
}
|
||||
|
||||
private buildDedupKey(
|
||||
method: string,
|
||||
url: string,
|
||||
bodySample?: unknown,
|
||||
body?: BodyInit | null
|
||||
): string {
|
||||
if (method === 'GET') {
|
||||
return `${method}:${url}`
|
||||
}
|
||||
|
||||
return `${method}:${url}:${this.stringifyDedupBody(bodySample ?? body)}`
|
||||
}
|
||||
|
||||
private stringifyDedupBody(body: unknown): string {
|
||||
if (body === undefined || body === null) return 'no-body'
|
||||
if (typeof body === 'string') return `string:${body}`
|
||||
if (isBinaryBody(body)) return 'binary'
|
||||
|
||||
if (typeof body === 'object') {
|
||||
if ((body as any)[Symbol.asyncIterator]) {
|
||||
return 'stream'
|
||||
}
|
||||
|
||||
try {
|
||||
return `json:${JSON.stringify(body)}`
|
||||
} catch {
|
||||
return 'opaque-body'
|
||||
}
|
||||
}
|
||||
|
||||
return String(body)
|
||||
}
|
||||
|
||||
async request<
|
||||
Method extends string,
|
||||
Url extends string,
|
||||
|
|
@ -324,11 +376,10 @@ export class RevolutionaryTypedFetch<TEndpoints extends EndpointTypeMap = Endpoi
|
|||
...(options.headers || {})
|
||||
}
|
||||
}
|
||||
|
||||
// Only set Content-Type for JSON bodies
|
||||
if (options.body && typeof options.body === 'string') {
|
||||
(requestOptions.headers as any)['Content-Type'] = 'application/json'
|
||||
}
|
||||
|
||||
const requestHeaders = new Headers(requestOptions.headers as HeadersInit | undefined)
|
||||
this.applyDefaultJsonContentType(requestHeaders, requestOptions.body, bodySample)
|
||||
requestOptions.headers = Object.fromEntries(requestHeaders.entries())
|
||||
|
||||
// Add timeout if configured
|
||||
if (this.config.request.timeout && !requestOptions.signal) {
|
||||
|
|
@ -369,9 +420,11 @@ export class RevolutionaryTypedFetch<TEndpoints extends EndpointTypeMap = Endpoi
|
|||
}
|
||||
|
||||
// Handle offline requests
|
||||
const dedupeKey = this.buildDedupKey(method, fullUrl, bodySample, processedOptions.body)
|
||||
|
||||
const result = await this.offlineHandler.handleRequest(fullUrl, processedOptions, async () => {
|
||||
// Deduplicate identical requests
|
||||
return this.deduplicator.dedupe(cacheKey, async () => {
|
||||
return this.deduplicator.dedupe(dedupeKey, async () => {
|
||||
// Execute with circuit breaker and retry logic
|
||||
return this.executeWithRetry<Method, Url, TResponse>(fullUrl, processedOptions, url, method)
|
||||
})
|
||||
|
|
|
|||
16
tests/entrypoint-smoke.test.ts
Normal file
16
tests/entrypoint-smoke.test.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import * as entry from '../src/index.js'
|
||||
|
||||
describe('package entrypoint exports', () => {
|
||||
it('exposes the primary factory and singleton', () => {
|
||||
expect(entry.tf).toBeDefined()
|
||||
expect(typeof entry.createTypedFetch).toBe('function')
|
||||
})
|
||||
|
||||
it('exposes key utilities for advanced users', () => {
|
||||
expect(entry.RuntimeTypeInference).toBeDefined()
|
||||
expect(entry.WTinyLFUCache).toBeDefined()
|
||||
expect(entry.TypeDeclarationGenerator).toBeDefined()
|
||||
})
|
||||
})
|
||||
43
tests/typed-fetch-request.test.ts
Normal file
43
tests/typed-fetch-request.test.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { RevolutionaryTypedFetch } from '../src/core/typed-fetch.js'
|
||||
|
||||
const jsonResponse = (body: any) =>
|
||||
new Response(JSON.stringify(body), {
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
|
||||
describe('RevolutionaryTypedFetch request polish', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('applies a JSON content-type automatically for plain object bodies', async () => {
|
||||
const fetchSpy = vi.fn(async (_url, init) => {
|
||||
const headers = new Headers(init?.headers as HeadersInit)
|
||||
expect(headers.get('content-type')).toBe('application/json')
|
||||
return jsonResponse({ ok: true })
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchSpy)
|
||||
|
||||
const client = new RevolutionaryTypedFetch({ request: { baseURL: 'https://api.test' } })
|
||||
await client.post('/items', { name: 'test' })
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not dedupe concurrent requests when bodies differ', async () => {
|
||||
const fetchSpy = vi.fn(async (_url, init) => {
|
||||
return jsonResponse({ echoed: init?.body })
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchSpy)
|
||||
|
||||
const client = new RevolutionaryTypedFetch({ request: { baseURL: 'https://api.test' } })
|
||||
await Promise.all([
|
||||
client.post('/items', { name: 'one' }),
|
||||
client.post('/items', { name: 'two' })
|
||||
])
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue