Skip to content

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.

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) => unknown

mergedHandler 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.

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.

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, or undefined;
  • emit: the merged handler, for data, connected and the like;
  • everything returned by setup.

The wrapper does this for each method:

  1. emits request with { resource, payload };
  2. if the body returns a Promise, awaits it, emits success and returns the value;
  3. if the body throws, emits error and rethrows. An error with a numeric status (an SdkError, such as the HttpError of @nexdk/http) gives { message, status, errors }, anything else { message };
  4. if the body returns a function, returns it as is. It is the unsubscribe handle of a reactive method, with no success or error envelope.

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.

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.

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.

  • Official drivers: @nexdk/driver-*. Community drivers: nexdk-driver-*.
  • nexdk goes in peerDependencies with an explicit 0.x range (>=0.0.1 <1), plus a workspace:* devDependency so 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 a peerDependency plus a devDependency. Only a private implementation detail goes in dependencies.
{
"peerDependencies": { "nexdk": ">=0.0.1 <1" },
"devDependencies": { "nexdk": "workspace:*" }
}

@nexdk/driver-lomkit and @nexdk/driver-supabase are hand-written. @nexdk/driver-nest, symfony, pocketbase, strapi, firebase and nhost use defineDriver.