Skip to content
TypeWire

@tahanabavi/typefetch

v2.0.0

A strongly typed TypeScript HTTP client powered by Zod contracts, middleware, retries, mock data, contract testing, CLI tooling, and report generation.

Encryption Middleware

Field-level encryption is available through encryptionMiddleware, which ships as a separate package so the core stays dependency-free:

bash
npm install @tahanabavi/typefetch-encryption

Moved in v2.0.0. This used to be exported from @tahanabavi/typefetch, which meant every consumer installed crypto-js and node-forge whether or not they encrypted anything.

It can:

  • Encrypt selected request body fields

  • Decrypt selected response fields

  • Process deeply nested objects

  • Process arrays

  • Use different encryption methods per field

  • Support custom encryption and decryption handlers

Supported methods:

ts
type EncryptionMethod = 'AES' | 'DES' | 'RSA' | 'Base64' | 'Custom'

Registering the Middleware

ts
import { encryptionMiddleware } from '@tahanabavi/typefetch-encryption'

client.use(encryptionMiddleware, {
keyProvider: async () => ({
type: 'symmetric',
key: 'my-secret-key',
}),
})

Endpoint Encryption Config

ts
const contracts = {
secure: {
createSecret: {
method: 'POST',
path: '/secure',
request: z.object({
body: z.object({
secret: z.string(),
profile: z.object({
pin: z.string(),
}),
}),
}),
response: z.object({
id: z.string(),
token: z.string(),
}),
encryption: {
method: 'AES',
request: {
secret: true,
profile: {
pin: 'Base64',
},
},
response: {
token: true,
},
},
},
},
} as const

Usage:

ts
await api.secure.createSecret({
body: {
secret: 'private-value',
profile: {
pin: '1234',
},
},
})

Before the request is sent:

  • secret is encrypted with AES

  • profile.pin is encoded with Base64

After the response is received:

  • token is decrypted with AES

Separate Request and Response Methods

ts
encryption: {
method: {
request: "RSA",
response: "AES",
},
request: {
password: true,
},
response: {
token: true,
},
}

Custom Encryption

ts
client.use(encryptionMiddleware, {
keyProvider: async () => ({
type: 'symmetric',
key: 'custom-key',
}),
customHandlers: {
encrypt: async (value, key) => {
return `encrypted:${value}`
},
decrypt: async (value, key) => {
return value.replace('encrypted:', '')
},
},
})

Endpoint config:

ts
encryption: {
method: "Custom",
request: {
secret: true,
},
response: {
token: true,
},
}

Fail-Closed Behavior

By default, encryption should fail closed.

That means if request encryption fails, the request is not sent as plaintext.

ts
client.use(encryptionMiddleware, {
keyProvider: async () => ({
type: 'symmetric',
key: 'secret',
}),
failClosed: true,
})

For debugging, you may disable fail-closed behavior:

ts
client.use(encryptionMiddleware, {
keyProvider: async () => ({
type: 'symmetric',
key: 'secret',
}),
failClosed: false,
})

Use failClosed: false carefully.


Encryption Maps

Encryption maps describe which fields should be transformed.

ts
encryption: {
method: "AES",
request: {
password: true,
profile: {
ssn: true,
},
metadata: {
publicValue: false,
},
},
}

Map values:

ValueBehavior
trueEncrypt/decrypt using default method
falseSkip field
"AES"Use AES for this field
"DES"Use DES for this field
"RSA"Use RSA for this field
"Base64"Use Base64 for this field
"Custom"Use custom handler
{ ... }Recursively process object
[ ... ]Process array items

Array example:

ts
encryption: {
method: "AES",
request: {
users: [
{
password: true,
},
],
},
}

This applies the first array map to every item unless an index-specific map exists.


Endpoint-Level Headers

Headers can be defined on the endpoint.

ts
const contracts = {
user: {
createUser: {
method: 'POST',
path: '/users',
request: z.object({
body: z.object({
name: z.string(),
}),
}),
response: z.object({
id: z.string(),
name: z.string(),
}),
headers: {
'X-App': 'typefetch',
},
},
},
} as const

Headers can also be generated from input:

ts
headers: (input) => ({
'X-Tenant': input.headers?.['X-Tenant'] ?? 'default',
})

Per-request headers can be passed through the structured request input:

ts
await api.user.createUser({
headers: {
'X-Request-ID': 'req-123',
},
body: {
name: 'Taha',
},
})

Custom Middleware

A middleware receives:

ts
type Middleware = (
ctx: MiddlewareContext,
next: () => Promise<Response>,
options?: unknown
) => Promise<Response>

Example:

ts
client.use(async (ctx, next) => {
const startedAt = Date.now()

const response = await next()

console.log(`${ctx.init.method} ${ctx.url} took ${Date.now() - startedAt}ms`)

return response
})

With options:

ts
const timingMiddleware = async (ctx, next, options) => {
const response = await next()

if (options?.debug) {
console.log('Timing middleware enabled')
}

return response
}

client.use(timingMiddleware, {
debug: true,
})

Instrumentation & Runtime Overrides

Beyond middleware (which only sees the raw Response), TypeFetch exposes an optional instrumentation layer that reports the parsed input and parsed output of every request and can override a request at runtime. It's designed for tooling — a devtools/inspector or a query layer — and is fully additive: with no hook registered, request handling is unchanged.

Endpoint metadata

Every generated method carries stable, read-only metadata:

ts
const api = client.modules

api.user.getUser.endpointId // "user.getUser"
api.user.getUser.endpoint // the original contract def (schemas, method, path, ...)

Lifecycle events

Register a hook with client.instrument(...); it returns an unsubscribe function.

ts
const stop = client.instrument({
on(event) {
// event.type: "start" | "success" | "error" | "progress"
// start: { requestId, endpointId, method, url, input, timestamp }
// success: { requestId, endpointId, data, durationMs, fromMock }
// error: { requestId, endpointId, status?, error, durationMs }
// progress: { requestId, endpointId, phase, loaded, total?, percent?,
// lengthComputable, durationMs }
console.log(event.type, event.endpointId)
},
})

stop() // detach later

requestId correlates a request's start with its success/error.

progress events are emitted only for requests that were given an onUploadProgress / onDownloadProgress handler. Attaching instrumentation never causes progress tracking on its own — opening devtools must not change which transport a request uses or re-stream a body nobody asked to measure.

Runtime overrides

A hook can resolve a per-request Override to change what a single request does without mutating the contract. The first hook to return an override wins.

ts
client.instrument({
resolveOverride(endpointId, input) {
if (endpointId === 'user.getUser') {
return { mock: { id: 'forced', name: 'Forced User' } }
}
},
})
ts
type Override = {
mock?: unknown | ((input: unknown) => unknown) // force mock, bypass network
error?: { status?: number; code?: string; message?: string; body?: unknown } // simulate failure
latencyMs?: number // inject latency
request?: z.ZodTypeAny // swap request schema at runtime
response?: z.ZodTypeAny // swap response schema at runtime
}
  • mock bypasses the network regardless of mock mode and is still validated against the (possibly overridden) response schema.

  • error throws a RichError and fires onError, like a real failing endpoint.

  • latencyMs is awaited before the request resolves.

  • request / response swap the validation schema for that request only, so you can test structural changes at runtime.

Fields are independent and compose. Full details in docs/releases/v1.7.0.md.