Import
import { defineGroup } from "@noverax/courier/define"Signature
declare function defineGroup<TEndpoints extends OwnEndpoints>(
config: DefineGroupConfig<TEndpoints>
): DefinedGroup<TEndpoints>
declare function defineGroup<TGroups extends OwnGroups, TEndpoints extends OwnEndpoints>(
config: DefineNestingGroupConfig<TEndpoints, TGroups>
): DefinedNestingGroup<TEndpoints, TGroups>Parameters
DefineGroupConfig
type DefineGroupConfig<E> = GroupOptions & {
endpoints: E
}
type DefineNestingGroupConfig<E, G> = GroupOptions & {
endpoints?: E
groups: G
}
type GroupOptions = {
prefix?: string
pathOptions?: (request?: Request) => Record<string, unknown> | Promise<Record<string, unknown>>
expose?: boolean
fetch?: SourceFetch
webSocket?: SourceWebSocket
dispatcher?: SourceDispatcher
proxy?: string // Bun
tls?: object // Bun
client?: object // Deno
}prefix— URL path segment prepended to all endpoint URLs in this group.pathOptions— async function that returns dynamic key-value pairs (headers, cookies) to inject per request.expose— whether the browser may call the group through the proxy; it overrides the source's, and a group inside another overrides its outer group's.fetch,webSocket,dispatcher— how the group's calls, streams and channels reach their server, over the source's: an undiciAgentholding a client certificate for this group alone, aProxyAgent. A group inside another takes each from the nearest group that sets it, and a call's.with()sets its own over both. Bun'sproxyandtlsand Deno'sclientare taken the same way. One that cannot send — afetchor awebSocketthat is not a function, adispatcherwith nodispatchmethod — throwsConfigurationError("SOURCE_TRANSPORT_INVALID"). See One group.groups— groups inside this one, created withdefineGroup, to any depth: see Groups inside groups. A group made only of groups needs noendpoints.endpoints— record of typed endpoint functions. Any name butoptions, which holds the group's own settings, andraworwith, which every client answers with its own.rawand.with(); an endpoint of one of those names is a type error and throwsConfigurationError("RESERVED_ENDPOINT_NAME"). The same holds for the endpoints given todefineApi, whose groups cannot be namedraworwitheither. A path calledoptionsorrawis fine: name the endpointgetOptionsorgetRaw. See Reserved names.
Basic Usage
import { courier, defineGroup } from "@noverax/courier/define"
const { endpoint } = courier()
type User = { id: string; name: string; email: string }
export const users = defineGroup({
prefix: "users",
endpoints: {
getAll: endpoint.get("").response<User[]>(),
getById: endpoint.get(":id").variables<{ id: string }>().response<User>(),
create: endpoint.post("").variables<{ name: string; email: string }>().response<User>(),
update: endpoint.put(":id").variables<{ id: string; name: string }>().response<User>(),
remove: endpoint.del(":id").variables<{ id: string }>().response<null>(),
},
})Prefix
The prefix is prepended to all endpoint URLs within the group:
const products = defineGroup({
prefix: "api/v2/products",
endpoints: {
list: endpoint.get("").response<Product[]>(),
// → GET {baseURL}/api/v2/products
getById: endpoint.get(":id").variables<{ id: string }>().response<Product>(),
// → GET {baseURL}/api/v2/products/:id
},
})Dynamic Path Options
pathOptions returns key-value pairs for URL parameter substitution:
const tenantUsers = defineGroup({
prefix: ":tenantId/users",
pathOptions: async (req) => {
const tenantId = req?.headers.get("x-tenant-id") || "default"
return { tenantId }
},
endpoints: {
list: endpoint.get("").response<User[]>(),
// When tenantId = "acme": GET {baseURL}/acme/users
},
})Path resolution
Path options from defineApi() are resolved first, then group-level pathOptions are merged on top. Group options take precedence for overlapping keys. A caller's variables never replace a key pathOptions returns.
Groups inside groups
A group may hold groups, as a source does, and those groups may hold groups of their own. A client reaches one by its names in turn, and its address joins every group's prefix in order:
const billing = defineGroup({
prefix: "billing",
endpoints: {
summary: endpoint.get("summary").response<{ total: number }>(),
},
groups: {
invoices: defineGroup({
prefix: "invoices",
endpoints: {
list: endpoint.get("").response<Invoice[]>(),
byId: endpoint.get(":id").variables<{ id: string }>().response<Invoice>(),
},
}),
},
})
const api = defineApi({ baseURL: "https://api.example.com", groups: { billing } })
const client = createCaller(api)
await client.billing.summary() // GET https://api.example.com/billing/summary
await client.billing.invoices.list() // GET https://api.example.com/billing/invoices- A group inside another is named by its path, its names joined with dots:
billing.invoices. The path is what the proxy carries in_g, what Studio, mocks and query keys name the group by —mock("billing.invoices", "list")— and what a boundary lists an endpoint under:"billing.invoices.list". A name with a dot in it would read as two, so a group or an endpoint named with one throwsConfigurationError("DOTTED_NAME"). - Each group's
pathOptionsis resolved in turn, from the source's down, a lower group's values replacing a higher one's.exposeis decided by the nearest group that sets it. - A group may hold an endpoint and a group of the same name: the client's member is both callable, as the endpoint, and readable, as the group.
Describing an Endpoint
Each endpoint is built from one of the heads courier() returns — endpoint, stream or channel — and addressed with a verb, or with .via() when its address is worked out from the variables:
const { endpoint } = courier()
endpoint.get("").response<User[]>()
// No variables — the caller passes nothing
endpoint.get(":id").variables<{ id: string }>().response<User>()
// Variables required — caller must pass { id: string }Streams and Channels
A stream delivers server-sent events and a channel is a WebSocket subscription. Each is either served by the process that defines it, with .implement(), or addressed where another process serves it:
| Method | stream | channel | |
|---|---|---|---|
.variables(schema?) | ✓ | ✓ | What the caller subscribes with |
.response(schema?) | ✓ | ✓ | What each event or message carries |
.send(schema?) | ✓ | What the caller may send; without it the channel only pushes | |
.topic(key) | ✓ | What subscribers share a topic by, when not all of their variables | |
.get(path?, options?) | ✓ | Where another process serves the stream | |
.connect(path?, options?) | ✓ | Where another process serves the channel | |
.via(probe, options?) | ✓ | ✓ | The address worked out from the variables |
.implement(handler, options?) | ✓ | ✓ | Serve it here: a stream's generator of { data } events; a channel's generator of messages, or its onJoin / onMessage / onLeave handlers once it takes .send() |
.use(), .headers(), .timeout(), .retry() and .meta() refine a stream or a channel as they do an endpoint. Streams & Channels shows each one served and consumed.
Endpoint Types
An endpoint is an inert descriptor, not a callable. Three types name one:
import type {
ChannelEndpoint, // ChannelEndpoint<Response, Variables, Send>
HttpEndpoint, // HttpEndpoint<Response, Variables, Kind extends "query" | "mutation">
StreamEndpoint, // StreamEndpoint<Response, Variables>
} from "@noverax/courier/define"Read the types back off an endpoint with the inference helpers, which is how a caller knows what a method takes and returns:
import { courier } from "@noverax/courier/define"
import type { InferResponse, InferVariables } from "@noverax/courier/define"
const { endpoint } = courier()
const getById = endpoint.get(":id").variables<{ id: string }>().response<User>()
type Response = InferResponse<typeof getById> // User
type Variables = InferVariables<typeof getById> // { id: string }Related
- defineApi — composing groups into a source
- Defining APIs Guide — in-depth usage patterns
- Type Safety — how types flow from endpoint definitions