Writing a driver
A driver translates the call grammar sdk.[driver].[resource].[method](payload?, onEvent?, extra?) into a protocol. This guide covers the contract, defineDriver, and typing.
The runtime contract
Section titled “The runtime contract”createSdk builds a proxy per driver. A call to sdk.my.users.get(payload, onEvent, extra) runs this on the driver object:
driver.get('users', payload, mergedHandler, extra)Every method has the same signature:
(resourceName: string, payload?: unknown, onEvent?: SdkEventHandler, extra?: unknown) => unknownmergedHandler combines the global notify and the per-call onEvent. A driver implements two members:
interface SdkDriver { readonly name: string bindResource(resourceName: string, resource: AnyResource): void}createSdk calls bindResource for every resource declared under the driver’s key.
The event vocabulary
Section titled “The event vocabulary”| Event | When |
|---|---|
request |
Before the call, with { resource, payload } |
success |
On success, with the result |
error |
On failure |
data |
A reactive push (subscription payload) |
connected |
A reactive transport opened |
disconnected |
A reactive transport closed |
Throw, do not emit. request, success and error are emitted by the core, once per call. On any failure a method throws; it never returns an empty value instead. A reactive method emits its own data, connected, disconnected and complete.
Authoring with defineDriver
Section titled “Authoring with defineDriver”defineDriver supplies the resource map, bindResource and the request, success and error events. You write the protocol translation.
import { createSdk, defineDriver, resolveHeaders } from 'nexdk'
interface MyConfig { baseUrl: string headers?: Record<string, string>}
const createMyDriver = defineDriver({ name: 'my', setup: (config: MyConfig) => ({ baseUrl: config.baseUrl.replace(/\/$/, ''), buildHeaders: async () => ({ 'Content-Type': 'application/json', ...(await resolveHeaders(config.headers)), }), }), methods: { get: async (ctx) => { const response = await fetch(`${ctx.baseUrl}/${ctx.resourceName}`, { headers: await ctx.buildHeaders(), }) if (!response.ok) throw new Error(`HTTP ${response.status}`) return response.json() }, },})
const sdk = createSdk({ drivers: { my: createMyDriver({ baseUrl: 'https://api.example.com' }) },})
const { data, error } = await sdk.my.users.get({}, (event) => console.log(event))Every method receives a ctx, then the payload and extra:
resourceName: the name used in the call ("users");resource: the bound resource, orundefined;emit: the merged handler, fordata,connectedand the like;- everything returned by
setup.
The wrapper does this for each method:
- emits
requestwith{ resource, payload }; - if the body returns a Promise, awaits it, emits
successand returns the value; - if the body throws, emits
errorand rethrows. An error with a numericstatus(anSdkError, such as theHttpErrorof@nexdk/http) gives{ message, status, errors }, anything else{ message }; - if the body returns a function, returns it as is. It is the unsubscribe handle of a reactive method, with no
successorerrorenvelope.
Headers
Section titled “Headers”headers is a record or a function called before every request, so a refreshed token is always used:
type HeadersResolver = | Record<string, string> | (() => Record<string, string> | Promise<Record<string, string>>)resolveHeaders is exported from nexdk and from @nexdk/http.
Authoring by hand
Section titled “Authoring by hand”You can implement SdkDriver yourself, as @nexdk/driver-lomkit and @nexdk/driver-supabase do. You then own the resource map, and wrap each method in traceCall({ emit: onEvent, resource, payload }, () => send()) from nexdk so the core emits the events. Prefer defineDriver unless you need a shape it cannot express. @nexdk/driver-nest is the reference.
End-to-end typing
Section titled “End-to-end typing”Typing is opt-in, through a registry in nexdk.
Declare the per-resource API, with T the inferred record type and TThrow the throw option of createSdk:
import type { SdkEventHandler, SdkReturn } from 'nexdk'
export interface MyResourceApi<T, TThrow extends boolean = false> { get(query?: object, onEvent?: SdkEventHandler): Promise<SdkReturn<TThrow, T[]>>}Register both modes by declaration merging:
declare module 'nexdk' { interface ResourceApiKinds<T> { my: MyResourceApi<T> } interface ThrowingResourceApiKinds<T> { my: MyResourceApi<T, true> }}Advertise the key on the driver with __apiUri. With defineDriver, return the driver from a factory:
export function createTypedDriver(config: MyConfig): ReturnType<typeof createMyDriver> & { __apiUri: 'my' } { return createMyDriver(config) as ReturnType<typeof createMyDriver> & { __apiUri: 'my' }}By hand, add declare readonly __apiUri: 'my' to the class. Without __apiUri, calls still work through the untyped proxy.
Conventions
Section titled “Conventions”- Official drivers:
@nexdk/driver-*. Community drivers:nexdk-driver-*. nexdkgoes inpeerDependencieswith an explicit 0.x range (>=0.0.1 <1), plus aworkspace:*devDependencyso the package builds alone. Do the same for@nexdk/http.- Import the event types from
nexdk. Do not depend on another driver. - A library the user already owns or must pick the version of (
@nexdk/http,@supabase/supabase-js) is apeerDependencyplus adevDependency. Only a private implementation detail goes independencies.
{ "peerDependencies": { "nexdk": ">=0.0.1 <1" }, "devDependencies": { "nexdk": "workspace:*" }}Which drivers use what
Section titled “Which drivers use what”@nexdk/driver-lomkit and @nexdk/driver-supabase are hand-written. @nexdk/driver-nest, symfony, pocketbase, strapi, firebase and nhost use defineDriver.