@emeryld/rrroutes-contract
v2.4.19
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: z.object({ avatar: z.instanceof(Blob) }),
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. Supplying anidSchemaalong with a:paramsegment wires up the params schema for all descendants.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.- 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