Add package readiness notes and metadata tests

This commit is contained in:
Casey Collier 2025-12-01 18:05:33 -05:00
parent 034070481c
commit b66b8f6cc1
8 changed files with 189 additions and 22 deletions

View file

@ -58,6 +58,19 @@ const profile = await typed.get('/me')
// profile.data is strongly typed based on your schema/runtime samples
```
## 🧩 Install & runtime support
- Published as zero-dependency ESM with an accompanying CommonJS build for straightforward `npm install @catalystlabs/typedfetch` integration across bundlers, Node 16+, and modern browsers.
- Ships type declarations (`dist/index.d.ts`) and a CLI entrypoint (`typedfetch`) out of the box.
```ts
// ESM / TypeScript
import { tf } from '@catalystlabs/typedfetch'
// CommonJS
const { tf: tfCjs } = require('@catalystlabs/typedfetch')
```
## ✨ Features
### 🔒 Type Safety

View file

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

View file

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

View file

@ -12,8 +12,13 @@ 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 { createResource, type ResourceBuilderOptions, type ResourceDefinition, type ResourceInstance } from './resource-builder.js'
import { isBinaryBody, 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 { MockController, type MockRouteDefinition } from './mock-controller.js'
import { TypeDeclarationGenerator, type TypeSnapshotOptions } from '../discovery/type-generator.js'
@ -277,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,
@ -319,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) {
@ -364,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)
})

View file

@ -51,4 +51,4 @@ export type {
ResourceInstance,
ResourceMethodConfig
} from './core/resource-builder.js'
export type { TypeSnapshotOptions } from './discovery/type-generator.js'
export type { TypeSnapshotOptions } from './discovery/type-generator.js'

View 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()
})
})

View file

@ -0,0 +1,34 @@
import { readFileSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
const __dirname = dirname(fileURLToPath(import.meta.url))
const pkg = JSON.parse(
readFileSync(resolve(__dirname, '..', 'package.json'), 'utf8')
)
describe('package metadata', () => {
it('exposes dual-build entrypoints for easy installation', () => {
expect(pkg.main).toBe('./dist/index.js')
expect(pkg.module).toBe('./dist/index.js')
expect(pkg.types).toBe('./dist/index.d.ts')
expect(pkg.exports?.['.']?.import).toBe('./dist/index.js')
expect(pkg.exports?.['.']?.require).toBe('./dist/index.cjs')
expect(pkg.exports?.['.']?.types).toBe('./dist/index.d.ts')
})
it('ships distributable files and documentation', () => {
expect(pkg.files).toEqual(
expect.arrayContaining(['dist', 'README.md', 'LICENSE'])
)
})
it('includes the CLI entrypoint', () => {
expect(pkg.bin?.typedfetch).toBe('./dist/cli.js')
})
it('is versioned for publishing', () => {
expect(pkg.version).not.toBe('0.0.0')
})
})

View 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)
})
})