@emeryld/rrroutes-contract
v2.6.6
Published
TypeScript contract definitions for RRRoutes
Readme
@emeryld/rrroutes-contract
Type-safe contract toolkit for RRRoutes. Ship the HTTP route DSL (resource + withCrud), registry/finalization helpers, cache-key builder used by the client package, and shared socket event contracts.
Looking for the docs/playground UI? It now lives in
@emeryld/rrroutes-openapi.
Installation
pnpm add @emeryld/rrroutes-contract
# or
npm install @emeryld/rrroutes-contractzod ships as a dependency—nothing extra to install.
Quick start (build + consume a registry)
import {
buildCacheKey,
compilePath,
finalize,
InferOutput,
InferParams,
InferQuery,
resource,
} from '@emeryld/rrroutes-contract'
import { z } from 'zod'
// 1) Describe your API
const leaves = resource('/v1')
.sub(
resource('users')
.get({
querySchema: z.object({
search: z.string().optional(),
limit: z.coerce.number().min(1).max(50).default(20),
}),
outputSchema: z.array(
z.object({ id: z.string().uuid(), email: z.string().email() }),
),
description: 'Find users',
})
.sub(
resource(':userId', undefined, z.string().uuid())
.patch({
bodySchema: z.object({ name: z.string().min(1) }),
outputSchema: z.object({ ok: z.literal(true) }),
})
.done(),
)
.done(),
)
.done()
// 2) Freeze it into a registry for typed lookups
export const registry = finalize(leaves)
// 3) Consume a leaf with full types
const leaf = registry.byKey['PATCH /v1/users/:userId']
type Params = InferParams<typeof leaf> // { userId: string }
type Query = InferQuery<typeof leaf> // never (no query)
type Output = InferOutput<typeof leaf> // { ok: true }
// 4) Build URLs + cache keys (React Query friendly)
const url = compilePath(leaf.path, {
userId: 'f2b2e72a-7f6d-4c3f-9c6f-7f0d8f3ac9e2',
})
const key = buildCacheKey({
leaf,
params: { userId: 'f2b2e72a-7f6d-4c3f-9c6f-7f0d8f3ac9e2' },
})
// key => ['patch', 'v1', 'users', ['f2b2e72a-7f6d-4c3f-9c6f-7f0d8f3ac9e2'], {}]Detailed usage
Fluent route builder
import { resource } from '@emeryld/rrroutes-contract'
import { z } from 'zod'
const leaves = resource('/api')
.sub(
resource('projects')
.get({
feed: true, // infinite/feed for clients
querySchema: z.object({
cursor: z.string().optional(),
limit: z.coerce.number().default(25),
}),
outputSchema: z.object({
items: z.array(z.object({ id: z.string(), name: z.string() })),
nextCursor: z.string().optional(),
}),
})
.post({
bodySchema: z.object({ name: z.string().min(1) }),
outputSchema: z.object({ id: z.string(), name: z.string() }),
description: 'Create a project',
})
.sub(
resource(':projectId', undefined, z.string().uuid())
.get({ outputSchema: z.object({ id: z.string(), name: z.string() }) })
.patch({
bodySchema: z.object({ name: z.string().min(1) }),
outputSchema: z.object({ id: z.string(), name: z.string() }),
})
.sub(
resource('avatar')
.put({
bodyFiles: [{ name: 'avatar', maxCount: 1 }], // signals multipart upload
// bodySchema is for non-file fields only
bodySchema: z.object({ note: z.string().optional() }),
outputSchema: z.object({ ok: z.literal(true) }),
})
.done(),
)
.done(),
)
.done(),
)
.done()resource(segment, nodeCfg?, idSchema?)scopes a branch. Pass a segment name (e.g.'projects',':projectId') plus optional per-node config.idSchemais strict-mode: only valid with a dynamic base segment (':param'or'*param') and required for those dynamic segments. Dynamic segments are only allowed in the first segment ofresource(...).sub(childA, childB, ...)mounts one or more child resources built elsewhere viaresource(...).get(...).done(). Call it once per branch; pass multiple children at once when needed.- Methods (
get/post/put/patch/delete) merge the active param schema unless you override viaparamsSchema. done()closes a branch and returns the collected readonly tuple of leaves.
Registry helpers, URL building, and typing
import {
buildCacheKey,
compilePath,
finalize,
InferBody,
InferOutput,
SubsetRoutes,
} from '@emeryld/rrroutes-contract'
const registry = finalize(leaves)
const leaf = registry.byKey['PATCH /api/projects/:projectId']
// TypeScript helpers
type Body = InferBody<typeof leaf> // { name: string }
type Output = InferOutput<typeof leaf> // { id: string; name: string }
// Runtime helpers
const url = compilePath(leaf.path, { projectId: '123' }) // "/api/projects/123"
const cacheKey = buildCacheKey({ leaf, params: { projectId: '123' } })
// cacheKey => ['patch', 'api', 'projects', ['123'], {}]
// Typed subsets for routers/microfrontends
type ProjectRoutes = SubsetRoutes<typeof registry.all, '/api/projects'>finalize(leaves)freezes the tuple and providesbyKey['METHOD /path'],all, andlog(logger).compilePaththrows if required params are missing; wrap user-provided values in try/catch.buildCacheKeyproduces the deterministic tuple the client package uses for React Query; reuse it for manual invalidation.
CRUD helper (withCrud / resourceWithCrud)
import {
CrudDefaultPagination,
finalize,
resource,
withCrud,
} from '@emeryld/rrroutes-contract'
import { z } from 'zod'
const r = withCrud(resource('/v1'))
const leaves = r
.crud(
'articles',
{
paramSchema: z.string().uuid(), // value schema; becomes :articlesId
itemOutputSchema: z.object({
id: z.string().uuid(),
title: z.string(),
body: z.string(),
}),
list: { querySchema: CrudDefaultPagination },
create: { bodySchema: z.object({ title: z.string(), body: z.string() }) },
update: {
bodySchema: z.object({
title: z.string().optional(),
body: z.string().optional(),
}),
},
enable: { remove: false }, // opt out of DELETE
},
({ collection }) =>
collection
.sub(
resource('stats')
.get({
outputSchema: z.object({ total: z.number() }),
description: 'Extra endpoint alongside CRUD',
})
.done(),
)
.done(),
)
.done()
const registry = finalize(leaves)
// registry.byKey now includes the CRUD + extras routes with full types- Generated routes (unless disabled): GET feed list, POST create (requires
create.bodySchema), GET item, PATCH update (requiresupdate.bodySchema), DELETE remove. - Defaults: list output
{ items: Item[], nextCursor?: string }, remove output{ ok: true }. - Pass
paramSchemaas a value schema; a compatiblez.object({ <name>Id: schema })also works at runtime. resourceWithCrud('/v1', {})is a convenience wrapper if you want the.crudmethod available immediately.
Socket event contracts
Share a typed event map between client and server.
import { defineSocketEvents, Payload } from '@emeryld/rrroutes-contract'
import { z } from 'zod'
const { config, events } = defineSocketEvents(
{
joinMetaMessage: z.object({ room: z.string() }),
leaveMetaMessage: z.object({ room: z.string() }),
pingPayload: z.object({ clientEcho: z.object({ sentAt: z.string() }) }),
pongPayload: z.object({
clientEcho: z.object({ sentAt: z.string() }).optional(),
sinceMs: z.number().optional(),
}),
},
{
'chat:message': {
message: z.object({
roomId: z.string(),
text: z.string(),
userId: z.string(),
}),
},
'typing:update': {
message: z.object({
roomId: z.string(),
userId: z.string(),
typing: z.boolean(),
}),
},
},
)
// Typed payload extraction
type ChatPayload = Payload<typeof events, 'chat:message'>
// ChatPayload -> { roomId: string; text: string; userId: string }
// Server-side guard example
function onChatMessage(raw: unknown) {
const parsed = events['chat:message'].message.parse(raw)
// parsed is strongly typed; safe to broadcast
}config mirrors the system events used by the socket client/server packages; events holds your app-specific payload schemas.
Common patterns/recipes
- Module-per-area: export leaf tuples per domain (
usersLeaves,projectsLeaves), then spread beforefinalize([...usersLeaves, ...projectsLeaves]). - Shared defaults: wrap
resource('/api')in your own helper that immediately calls.with(...)for cross-cutting flags you add via declaration merging (e.g., auth tags, tracing hints). - React Query integration: use
buildCacheKey+queryClient.invalidateQueries({ queryKey })to keep caches in sync, or rely on the client package’s helpers which wrap the same logic. - Error handling at boundaries: catch
compilePatherrors when interpolating user-provided params; surface a 400 instead of crashing your handler.
Edge cases and notes
paramsSchemaon a method overrides the merged schema from parent segments—useful when you need stricter validation per verb.bodyFilesmarks a route as multipart; servers can attach upload middleware, and clients should sendFormData.- Client input keys for files are
file${name}(and${name}remains accepted for backward compatibility). Example:bodyFiles: [{ name: 'avatar', maxCount: 1 }]->{ fileavatar: File }. - Keep file fields out of
bodySchema; usebodySchemafor non-file fields (e.g. captions, notes, flags). - CRUD helper only emits create/update routes when the matching
bodySchemais provided; delete can be disabled viaenable.remove: false. - Feed-only behavior (
feed: true) is intended for GET endpoints; clients treat them as infinite queries.
Scripts (monorepo)
Run from the repo root:
pnpm --filter @emeryld/rrroutes-contract build # tsup + d.ts
pnpm --filter @emeryld/rrroutes-contract typecheck
pnpm --filter @emeryld/rrroutes-contract test # optional Jest suite