Encryption Middleware
Field-level encryption is available through encryptionMiddleware, which ships
as a separate package so the core stays dependency-free:
npm install @tahanabavi/typefetch-encryptionMoved in v2.0.0. This used to be exported from
@tahanabavi/typefetch, which meant every consumer installedcrypto-jsandnode-forgewhether 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:
type EncryptionMethod = 'AES' | 'DES' | 'RSA' | 'Base64' | 'Custom'Registering the Middleware
import { encryptionMiddleware } from '@tahanabavi/typefetch-encryption'
client.use(encryptionMiddleware, {
keyProvider: async () => ({
type: 'symmetric',
key: 'my-secret-key',
}),
})Endpoint Encryption Config
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 constUsage:
await api.secure.createSecret({
body: {
secret: 'private-value',
profile: {
pin: '1234',
},
},
})Before the request is sent:
secretis encrypted with AESprofile.pinis encoded with Base64
After the response is received:
tokenis decrypted with AES
Separate Request and Response Methods
encryption: {
method: {
request: "RSA",
response: "AES",
},
request: {
password: true,
},
response: {
token: true,
},
}Custom Encryption
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:
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.
client.use(encryptionMiddleware, {
keyProvider: async () => ({
type: 'symmetric',
key: 'secret',
}),
failClosed: true,
})For debugging, you may disable fail-closed behavior:
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.
encryption: {
method: "AES",
request: {
password: true,
profile: {
ssn: true,
},
metadata: {
publicValue: false,
},
},
}Map values:
| Value | Behavior |
|---|---|
true | Encrypt/decrypt using default method |
false | Skip 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:
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.
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 constHeaders can also be generated from input:
headers: (input) => ({
'X-Tenant': input.headers?.['X-Tenant'] ?? 'default',
})Per-request headers can be passed through the structured request input:
await api.user.createUser({
headers: {
'X-Request-ID': 'req-123',
},
body: {
name: 'Taha',
},
})Custom Middleware
A middleware receives:
type Middleware = (
ctx: MiddlewareContext,
next: () => Promise<Response>,
options?: unknown
) => Promise<Response>Example:
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:
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:
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.
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 laterrequestId 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.
client.instrument({
resolveOverride(endpointId, input) {
if (endpointId === 'user.getUser') {
return { mock: { id: 'forced', name: 'Forced User' } }
}
},
})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
}mockbypasses the network regardless of mock mode and is still validated against the (possibly overridden) response schema.errorthrows aRichErrorand firesonError, like a real failing endpoint.latencyMsis awaited before the request resolves.request/responseswap 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.