Kynetra FX

Documentation

Typed Client

The typed client wraps fetch with a consistent response shape, injectable transport, and full TypeScript alignment with your contract definitions. Call any HTTP method with a single import and get predictable, structured responses every time.

Creating a client#

createFXClient takes a base URL and an optional options object. The base URL is prepended to every path you pass to the individual method calls, so you only configure it once. Import it from @kynetra/fx.

src/client.ts
import { createFXClient } from '@kynetra/fx'
 
const client = createFXClient('https://api.example.com')
 
// With client-level options
const authedClient = createFXClient('https://api.example.com', {
headers: {
Authorization: 'Bearer my-token',
'X-App-Version': '2.0.0',
},
})

Every request made through authedClient will carry the headers you set here. Per-request headers are merged on top of these, so you can override individual fields without creating a new client.

Making requests#

The client exposes GET, POST, PUT, PATCH, and DELETE methods. The first argument is always a path that starts with / — it is appended to the base URL you provided when creating the client.

src/requests.ts
import { createFXClient } from '@kynetra/fx'
 
const client = createFXClient('https://api.example.com', {
headers: { Authorization: 'Bearer token' },
})
 
// GET with query parameters
const list = await client.GET('/users', {
query: { page: '1', limit: '20', role: 'admin' },
})
 
// POST with a JSON body
const created = await client.POST('/users', {
body: { name: 'Alice', email: 'alice@example.com', role: 'member' },
})
 
// PUT — full replacement
const replaced = await client.PUT('/users/42', {
body: { name: 'Alice Smith', email: 'alice@example.com', role: 'admin' },
})
 
// PATCH — partial update
const patched = await client.PATCH('/users/42', {
body: { role: 'admin' },
})
 
// DELETE
const deleted = await client.DELETE('/users/42')

For bodies, the client serialises the value as JSON and sets Content-Type: application/json automatically. You do not need to call JSON.stringify yourself.

Path parameters

Path parameters are interpolated inline — build the path string yourself before passing it in. This keeps the method signature simple and gives TypeScript full visibility of the literal string you are calling.

src/path-params.ts
const userId = '42'
const orgId = 'acme'
 
// Interpolate directly into the path
const user = await client.GET(`/orgs/${orgId}/users/${userId}`)
const memberships = await client.GET(`/orgs/${orgId}/users/${userId}/memberships`, {
query: { status: 'active' },
})

The response shape#

Every method returns a Promise that resolves to a plain object with four fields: ok, status, data, and headers. There is no throwing on non-2xx responses — success and error paths are handled uniformly.

src/response-shape.ts
const res = await client.GET('/users/42')
 
// ok is true when status < 400
if (res.ok) {
// data is parsed JSON when Content-Type is application/json
console.log(res.data) // { id: 42, name: 'Alice', role: 'admin' }
} else {
// data still contains the error body — useful for validation errors
console.error(res.status, res.data)
}
 
// Access response headers directly
const rateLimit = res.headers.get('X-RateLimit-Remaining')
  • okboolean. true when the HTTP status code is below 400.
  • statusnumber. The raw HTTP status code from the server.
  • dataunknown. Parsed JSON when the response has a JSON Content-Type; a plain text string otherwise.
  • headers — the standard Headers object from the underlying fetch response.

Request options#

Pass an options object as the second argument to any method. All fields are optional and can be combined freely.

NameTypeDescription
bodyunknownRequest body. Serialised as JSON and sent with POST, PUT, and PATCH requests. Sets Content-Type: application/json automatically.
queryRecord<string, string | number | boolean | undefined>Key-value pairs appended to the URL as a query string. Undefined values are omitted.
headersRecord<string, string>Per-request headers merged with the client-level headers. Keys in this object override matching client-level header keys.
signalAbortSignalAn AbortSignal for request cancellation. Pass the signal from an AbortController to cancel in-flight requests.

Client-level options#

Options passed to createFXClient apply to every request the client makes. They are the right place for authentication headers and custom transport.

NameTypeDescription
fetchtypeof fetchA custom fetch implementation used in place of the global fetch. Useful for test doubles, Cloudflare Service Bindings, and edge runtimes that expose a scoped fetch.
headersRecord<string, string>Headers sent with every request. Per-request headers are merged on top of these, so individual calls can add or override specific fields.

Injectable fetch#

Because the client accepts a custom fetch function, you can swap out the transport layer without changing any call-site code. This is useful in several situations.

  • Test doubles — replace fetch with a stub that returns fixture data so tests run without hitting the network.
  • Cloudflare Service Bindings — Workers that call another Worker internally receive a scoped fetch on the binding object. Inject it to route requests through the binding rather than the public internet.
  • Edge runtimes — some runtimes expose a fetch variant with extra capabilities such as specifying a datacenter or backend. Inject it without touching the rest of your client code.
src/injectable-fetch.ts
import { createFXClient } from '@kynetra/fx'
 
// --- Test double ---
const mockFetch: typeof fetch = async () => {
return new Response(JSON.stringify({ id: 1, name: 'Alice' }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
}
 
const testClient = createFXClient('https://api.example.com', {
fetch: mockFetch,
})
 
const res = await testClient.GET('/users/1')
console.log(res.data) // { id: 1, name: 'Alice' }
src/service-binding.ts
import { createFXClient } from '@kynetra/fx'
 
// In a Cloudflare Worker, env.USER_SERVICE is a bound Worker
return {
async fetch(request: Request, env: Env) {
const client = createFXClient('https://user-service', {
fetch: env.USER_SERVICE.fetch.bind(env.USER_SERVICE),
headers: { 'X-Internal': '1' },
})
 
const res = await client.GET('/users/me')
return Response.json(res.data)
},
}

Error handling#

When a server returns a 4xx or 5xx status, ok is false but the response is still fully resolved — there is no thrown exception. The data field holds the error body exactly as the server sent it, which lets you surface structured errors without wrapping calls in try/catch.

src/error-handling.ts
import { createFXClient } from '@kynetra/fx'
 
const client = createFXClient('https://api.example.com')
 
const res = await client.POST('/users', {
body: { email: 'not-an-email' }, // intentionally invalid
})
 
if (!res.ok) {
if (res.status === 422) {
// FX_VALIDATION_ERROR from the server
// { code: 'FX_VALIDATION_ERROR', issues: [...] }
console.error('Validation failed:', res.data)
} else if (res.status === 401) {
console.error('Unauthenticated — refresh your token')
} else if (res.status === 403) {
console.error('Forbidden — insufficient permissions')
} else {
console.error(`Unexpected error ${res.status}:`, res.data)
}
} else {
console.log('Created:', res.data)
}

The 422 FX_VALIDATION_ERROR response is emitted automatically by any contract route that has an input or query schema. See Validation for the full error shape and how to narrow the type on the client.

Tip

For cancellable requests — such as search-as-you-type — pass an AbortSignal from an AbortController. When you call controller.abort(), the underlying fetch is cancelled and the promise rejects with an AbortError. Wrap the call in try/catch and check err.name === 'AbortError' to distinguish cancellation from genuine network failures.

With contracts#

Assign a fluent contract chain, then pass typeof app to the client. TypeScript derives valid method/path pairs, body and query fields, concrete path parameters, and response data from the schemas. No code generation is required.

src/with-contracts.ts
import { createFX, createFXClient, fx } from '@kynetra/fx'
 
export const app = createFX().route({
method: 'GET',
path: '/users/:id',
query: fx.object({ includeTeams: fx.optional(fx.boolean()) }),
output: fx.object({ id: fx.string(), name: fx.string() }),
handler: (ctx) => ctx.json({ id: ctx.params.id ?? '', name: 'Ada' }),
})
 
const client = createFXClient<typeof app>('https://api.example.com')
const res = await client.GET('/users/99', {
query: { includeTeams: true },
})
 
res.data.name // string
 
// Compile errors: wrong method, path, query, body, or missing required options.
// client.POST('/users/99')
 
// Escape hatch for endpoints outside the contract set.
await client.request('GET', '/runtime-selected-path')

Note

Type accumulation follows the returned fluent chain. Write const app = createFX().route(...).route(...); mutating a previously declared variable cannot widen that variable's static TypeScript type.

See Contracts for how to define route schemas on the server side, and OpenAPI for generating a machine-readable spec from those same definitions.

Typed Client · Kynetra FX