From b66b8f6cc1886e14e1b0c918b0addafd61391ade Mon Sep 17 00:00:00 2001 From: Casey Collier Date: Mon, 1 Dec 2025 18:05:33 -0500 Subject: [PATCH] Add package readiness notes and metadata tests --- README.md | 13 ++++++ src/core/body-utils.ts | 2 +- src/core/errors.ts | 27 ++++++----- src/core/typed-fetch.ts | 74 +++++++++++++++++++++++++++---- src/index.ts | 2 +- tests/entrypoint-smoke.test.ts | 16 +++++++ tests/package-config.test.ts | 34 ++++++++++++++ tests/typed-fetch-request.test.ts | 43 ++++++++++++++++++ 8 files changed, 189 insertions(+), 22 deletions(-) create mode 100644 tests/entrypoint-smoke.test.ts create mode 100644 tests/package-config.test.ts create mode 100644 tests/typed-fetch-request.test.ts diff --git a/README.md b/README.md index 823d86c..d6c58da 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/core/body-utils.ts b/src/core/body-utils.ts index 8342f2c..76ca0f4 100644 --- a/src/core/body-utils.ts +++ b/src/core/body-utils.ts @@ -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 diff --git a/src/core/errors.ts b/src/core/errors.ts index 1af081d..c656683 100644 --- a/src/core/errors.ts +++ b/src/core/errors.ts @@ -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 } diff --git a/src/core/typed-fetch.ts b/src/core/typed-fetch.ts index 2531e96..403ea9e 100644 --- a/src/core/typed-fetch.ts +++ b/src/core/typed-fetch.ts @@ -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 { // 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(fullUrl, processedOptions, url, method) }) diff --git a/src/index.ts b/src/index.ts index 6d83d04..0291b61 100644 --- a/src/index.ts +++ b/src/index.ts @@ -51,4 +51,4 @@ export type { ResourceInstance, ResourceMethodConfig } from './core/resource-builder.js' -export type { TypeSnapshotOptions } from './discovery/type-generator.js' \ No newline at end of file +export type { TypeSnapshotOptions } from './discovery/type-generator.js' diff --git a/tests/entrypoint-smoke.test.ts b/tests/entrypoint-smoke.test.ts new file mode 100644 index 0000000..6d73a92 --- /dev/null +++ b/tests/entrypoint-smoke.test.ts @@ -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() + }) +}) diff --git a/tests/package-config.test.ts b/tests/package-config.test.ts new file mode 100644 index 0000000..ebbdb28 --- /dev/null +++ b/tests/package-config.test.ts @@ -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') + }) +}) diff --git a/tests/typed-fetch-request.test.ts b/tests/typed-fetch-request.test.ts new file mode 100644 index 0000000..6716529 --- /dev/null +++ b/tests/typed-fetch-request.test.ts @@ -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) + }) +})