Stop rewriting the same API contract in five different places. @hulla/api lets you define a call once, keep its types attached to the handler, and reuse it everywhere your app needs it.
What is @hulla/api? A tiny API/RPC manager for TypeScript π
- Organize API, server action, database, queue, or local calls in one typed place β
- Fix backend changes once, at the route definition, instead of chasing every caller π οΈ
- Works on the client, server, serverless, or anywhere TypeScript runs π
- Framework agnostic, with optional Query, SWR, and OpenAPI integrations π§©
pnpm add @hulla/api
# works also with bun, yarn, npm, deno, etc...Optional integrations:
pnpm add @hulla/api-query # @tanstack/query
pnpm add @hulla/api-swr # swr
pnpm add @hulla/api-openapi # openapi/swagger -> api (gen)Start with an API instance and a procedure:
import { init } from '@hulla/api'
const api = init()
const ping = api.procedure.handler(() => 'pong')
ping.call() // "pong"Add schemas when you want runtime parsing and inferred TypeScript:
import { z } from 'zod'
const double = api.procedure
.input(z.number())
.output(z.number())
.handler(({ input }) => input * 2)
double.call(21) // 42
double.call(null)
// TS error: expected type 'number', got 'null'
// Runtime error through zod validationGroup named procedures with routers:
const users = api.router('users').define(({ procedure }) => ({
all: procedure.handler(() => [
{ id: 1, name: 'Samuel' },
{ id: 2, name: 'Jane' },
]),
byId: procedure
.input(z.number())
.handler(({ input }) => ({
id: input,
name: 'Samuel',
})),
}))
users.all.call()
users.byId.call(1)
users.all.key.root // "users/all"
users.byId.key.full(1) // ["users/byId", 1]Note
The route logic stays out of your transport layer, so the same procedure can wrap fetch, a database query, a server action, a queue job, or a local or a server function.
Middleware is declared once and selected where it applies:
type Session = { userId: string }
type AdminPermissions = { canDeleteUsers: boolean }
async function getSession(): Promise<Session> {
return fetch('/api/session').then((res) => res.json())
}
async function getAdminPermissions(): Promise<AdminPermissions> {
const permissions = await fetch('/api/admin-permissions').then(
(res) => res.json() as Promise<AdminPermissions>
)
if (!permissions.canDeleteUsers) {
throw new Error('Admin access required')
}
return permissions
}
const api = init({
middleware: {
session: getSession,
admin: getAdminPermissions,
},
})
const account = api
.router('account')
.use('session')
.define(({ procedure }) => ({
me: procedure.handler(async ({ getContext }) => {
const { session } = await getContext()
return { id: session.userId }
}),
deleteUser: procedure
.use('admin')
.input(z.string())
.handler(async ({ input, getContext }) => {
const { session, admin } = await getContext()
return {
deletedBy: session.userId,
canDelete: admin.canDeleteUsers,
id: input,
}
}),
}))Routers pass their middleware to every procedure. Procedure-level .use(...) adds to that selection, and duplicate middleware keys are deduped.
In most apps, create one configured instance and export it as api:
// src/api.ts
import { init } from '@hulla/api'
import { query } from '@hulla/api-query'
type Session = { userId: string }
async function getSession(): Promise<Session> {
return fetch('/api/session').then((res) => res.json())
}
export const api = init({
middleware: {
session: getSession,
},
plugins: [query()],
})Then import that configured instance wherever routes live:
// src/routes/users.ts
import { z } from 'zod'
import { api } from '../api'
export const users = api
.router('users')
.use('session')
.define(({ procedure }) => ({
list: procedure.handler(async ({ getContext }) => {
const { session } = await getContext()
return fetch(`/api/users?viewer=${session.userId}`).then((res) =>
res.json()
)
}),
byId: procedure
.input(z.string().uuid())
.output(
z.object({
id: z.string(),
name: z.string(),
})
)
.handler(async ({ input }) => {
const response = await fetch(`/api/users/${input}`)
return response.json()
}),
}))The finalized procedure is the runtime value:
const user = await users.byId.call(
'2f2f0f0c-0f0f-4f0f-8f0f-0f0f0f0f0f0f'
)
users.byId.key.root // "users/byId"
users.byId.key.full('user_123') // ["users/byId", "user_123"]
users.byId.query.options('user_123') // TanStack Query optionsUse API-level .use(...) when a whole group of routes shares middleware.
init() declares the middleware that exists, and .use(...) returns a scoped
API instance where that middleware runs for every procedure and router:
// src/api.ts
import { init } from '@hulla/api'
type Session = { userId: string }
type AdminPermissions = { canDeleteUsers: boolean }
async function getSession(): Promise<Session> {
return fetch('/api/session').then((res) => res.json())
}
async function getAdminPermissions(): Promise<AdminPermissions> {
const permissions = await fetch('/api/admin-permissions').then(
(res) => res.json() as Promise<AdminPermissions>
)
if (!permissions.canDeleteUsers) {
throw new Error('Admin access required')
}
return permissions
}
const api = init({
middleware: {
session: getSession,
admin: getAdminPermissions,
},
})
export const publicApi = api
export const protectedApi = api.use('session')
export const adminApi = api.use('session', 'admin')Protected routes can now define procedures directly. The procedure passed into
the router keeps the API-level selection, so getContext() is typed from the
selected middleware:
// src/routes/account.ts
import { z } from 'zod'
import { protectedApi } from '../api'
export const account = protectedApi.router('account').define(({ procedure }) => ({
me: procedure.handler(async ({ getContext }) => {
const { session } = await getContext()
return { id: session.userId }
}),
rename: procedure
.input(z.string().min(1))
.handler(async ({ input, getContext }) => {
const { session } = await getContext()
return { id: session.userId, name: input }
}),
}))And public routes stay visibly public:
// src/routes/health.ts
import { publicApi } from '../api'
export const health = publicApi.router('health').define(({ procedure }) => ({
check: procedure.handler(() => ({ ok: true })),
}))The same pattern works for standalone procedures:
// src/actions/viewer.ts
import { protectedApi } from '../api'
export const viewer = protectedApi.procedure.handler(({ getContext }) => getContext())Router-level and procedure-level .use(...) still work on scoped APIs, so you
can add more middleware for a specific router or procedure. publicApi,
protectedApi, and adminApi are just project-level names. @hulla/api only cares
about the selected middleware keys, so you can use authed, internal,
tenant, or whatever matches your app.
@hulla/api-query adds query.options(...) and mutation.options(...) helpers designed for TanStack Query.
import { init } from '@hulla/api'
import { query } from '@hulla/api-query'
import { z } from 'zod'
const api = init({
plugins: [query()],
})
const users = api.router('users').define(({ procedure }) => ({
all: procedure.handler(() =>
fetch('/api/users').then((res) => res.json())
),
byId: procedure
.input(z.number())
.handler(({ input }) =>
fetch(`/api/users/${input}`).then((res) => res.json())
),
}))
const listOptions = users.all.query.options()
const boundUserOptions = users.byId.query.options(1)
const lazyUserOptions = users.byId.query.options()
const mutationOptions = users.byId.mutation.options()
// useQuery(listOptions)
// useQuery(boundUserOptions)
// lazyUserOptions.queryFn(1)
// useMutation(mutationOptions)For input procedures, calling .options(input) binds the input into the query key and query function. Calling .options() returns the root key and a function that accepts the input later.
@hulla/api-swr exposes query.options(...) and mutation.options(...) helpers as SWR tuples.
import { init } from '@hulla/api'
import { swr } from '@hulla/api-swr'
import { z } from 'zod'
const api = init({
plugins: [swr()],
})
const users = api.router('users').define(({ procedure }) => ({
byId: procedure
.input(z.number())
.handler(({ input }) =>
fetch(`/api/users/${input}`).then((res) => res.json())
),
}))
const [key, fetcher] = users.byId.query.options(1)
const [mutationKey, mutate] = users.byId.mutation.options()
// useSWR(key, fetcher)
// useSWRMutation(mutationKey, mutate)@hulla/api-openapi generates @hulla/api client factories from OpenAPI documents.
bunx @hulla/api-openapi ./openapi.json --output ./src/api.generated.tsYou can derive procedure names from paths instead of operationId:
bunx @hulla/api-openapi ./openapi.json --output ./src/generated-api --names pathThe generated client accepts your transport function:
import { createOpenAPIClient } from './api.generated'
const client = createOpenAPIClient((request) => {
return fetch(request.path, {
method: request.method,
body: request.body === undefined ? undefined : JSON.stringify(request.body),
}).then((res) => res.json())
})
const user = await client.users.getUsersId.call({
params: { id: 'user_123' },
})By default, output schemas parse the resolved handler value, so async handlers work with plain schemas:
const user = api.procedure.output(z.string()).handler(async () => 'Samuel')
await user.call() // "Samuel"If you want output schemas to validate the exact unawaited return value instead, set output to raw.
const api = init({
settings: {
output: 'raw',
},
})- Install dependencies with
bun install - Run checks with
bun run lint,bun run fmt,bun run test, andbun run build
Plugins are injected automatically by default. You can make a plugin opt-in, select it on a router or procedure, or alias exposed members.
const api = init({
plugins: [query()],
settings: {
plugins: {
query: {
inject: 'opt-in',
aliases: {
procedure: {
query: 'rq',
},
},
},
},
},
})
const users = api
.router('users')
.plugin('query')
.define(({ procedure }) => ({
byId: procedure.input(z.number()).handler(({ input }) => input),
}))
users.byId.rq.options(1)