@emeryld/rrroutes-contract
v2.11.0
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-contractPrerequisites
zod^4.0.0
Keeping zod application-owned ensures the contract, client, and server all
operate on the same runtime schema instance.
Entry points
| Import path | Purpose | Runtime | Status | Additional requirements |
| ------------------------------------- | ------------------------------------------------------------------------------------------------------------ | ------- | ------ | ----------------------- |
| @emeryld/rrroutes-contract | Route builder, registry finalization, resource selection, and socket event contracts in one entry point. | — | stable | — |
| @emeryld/rrroutes-contract/builder | Fluent resource(...) / withCrud(...) builder for composing a tree of leaves. | — | stable | — |
| @emeryld/rrroutes-contract/core | Shared low-profile leaf/config types and helpers the builder, finalizer, and every runtime package build on. | — | stable | — |
| @emeryld/rrroutes-contract/registry | Freezes a leaf tuple into an addressable registry, and combines multiple registries into one. | — | stable | — |
| @emeryld/rrroutes-contract/resource | Selects a stable, named slice of a finalized registry so public endpoint names survive route key changes. | — | stable | — |
| @emeryld/rrroutes-contract/schema | Walks a leaf's Zod schemas into JSON-safe, framework-agnostic field descriptions. | — | stable | — |
| @emeryld/rrroutes-contract/sockets | Defines a typed Socket.IO event map shared between server and client packages. | — | stable | — |
Compatibility
Ships ESM + CJS builds with per-subpath type declarations. Requires
TypeScript's strict mode to get the full benefit of the route builder's
literal-type inference. No runtime dependency beyond zod.
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
Type server context and client errors directly
Context and error types are ordinary generics on the runtime that consumes them. No phantom value or shared generic carrier is required.
export type ApiContext = { requestId: string; userId?: string }
export type ApiError = {
code: 'UNAUTHORIZED' | 'NOT_FOUND' | 'INTERNAL'
message: string
}Use createRRRoute<ApiContext, ApiError>(...) on the server and
createRouteClient<ApiError>(...) on clients.
Define a shared route resource
defineRouteResource selects a named slice of a finalized registry once for
both server and client consumption. Public endpoint names remain stable even
when the underlying METHOD /path key changes.
import { defineRouteResource } from '@emeryld/rrroutes-contract/resource'
import { sockets } from './sockets'
export const usersResource = defineRouteResource({
routes: registry.byKey,
keys: {
listUsers: 'GET /v1/users',
getUser: 'GET /v1/users/:userId',
updateUser: 'PATCH /v1/users/:userId',
},
sockets,
})
usersResource.endpoints.listUsers // exact GET leaf, addressed by public name
usersResource.byKey['GET /v1/users'] // selected registry lookup
usersResource.all // selected leaves in insertion order
usersResource.keys.updateUser // "PATCH /v1/users/:userId"
usersResource.sockets.events // exact shared socket event mapThe returned RouteResource deliberately has two views:
endpointsis consumed bycreateClientBindingsand uses the public names.byKeyplusallsatisfy RRRoutes server registry helpers directly.sockets, when provided, drives server and client socket construction.
Duplicate route keys may have multiple public names in endpoints; all
contains the selected leaf only once. Keep resources in application-shared
code so server and platform clients select from the same contract source. The
legacy defineRouteResource(routes, keys) form remains available for
route-only resources; a third { sockets } argument can attach sockets without
switching to object form.
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.
Schema introspection (@emeryld/rrroutes-contract/schema)
Walk a leaf's Zod schemas into JSON-safe descriptions:
import {
flattenLeafSchemas,
introspectSchema,
serializeLeafContract,
} from '@emeryld/rrroutes-contract/schema'
const fields = flattenLeafSchemas(routes.byKey['POST /posts'])
// { 'body.title': { type: 'string', optional: false, nullable: false }, ... }These live on a dedicated subpath rather than the package root so the main entry point stays lean for client and server bundles.
They previously lived in @emeryld/rrroutes-export, which still re-exports them
unchanged. Moving them here lets browser packages — schema-to-UI components in
@emeryld/rrroutes-react, the inspector — do schema introspection without
depending on the TypeScript compiler that the export package's AST source
extraction requires.
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.
Full-stack guide
This package's chapters of the full-stack guide — one posts resource built from the contract outwards. The blocks below are the source the guide is assembled from; read them here, or read the whole thing in order on the documentation site.
A resource tree, closed with done() and frozen with finalize(). The feed
leaf is the interesting one: feed: true marks it as an infinite endpoint,
queryExtensionSchema declares the pagination fields the client will send, and
outputMetaSchema declares the cursor metadata the server will answer with.
Both are required for a feed — the default meta is an optional string and
would reject a pagination object.
import { finalize, resource } from '@emeryld/rrroutes-contract'
import { z } from 'zod'
export const Post = z.object({
id: z.string(),
authorId: z.string(),
title: z.string(),
body: z.string(),
createdAt: z.string(),
})
export const PostBody = z.object({
title: z.string().min(1).max(140),
body: z.string().min(1),
})
/**
* The pagination fields a feed accepts. Named exactly as the feed engine in
* `@emeryld/rrroutes-server-data` reads them, so the HTTP query maps onto
* `getFeed` with no translation layer in between.
*/
const FeedPagination = z.object({
cursor: z.string().optional(),
direction: z.enum(['previous', 'next']).optional(),
anchorItemId: z.string().optional(),
limit: z.coerce.number().int().min(1).max(50).optional(),
sortBy: z.enum(['recent']).optional(),
})
/** What a feed page answers with, alongside its rows. */
const FeedMeta = z.object({
total: z.number(),
hasMore: z.boolean(),
hasNext: z.boolean(),
hasPrevious: z.boolean(),
nextCursor: z.string().optional(),
previousCursor: z.string().optional(),
anchorItemId: z.string().optional(),
})
export const registry = finalize(
resource('/v1')
.sub(
resource('posts')
.post({ bodySchema: PostBody, outputSchema: Post })
.sub(
resource('feed')
.get({
feed: true,
querySchema: z.object({ tag: z.string().optional() }),
queryExtensionSchema: FeedPagination,
outputSchema: z.array(Post),
outputMetaSchema: FeedMeta,
})
.done(),
resource(':postId', undefined, z.string())
.get({ outputSchema: Post })
.patch({ bodySchema: PostBody.partial(), outputSchema: Post })
.delete({ outputSchema: z.object({ id: z.string() }) })
.done(),
)
.done(),
)
.done(),
)
export type PostsRegistry = typeof registryNote — Responses are an envelope Every leaf's output schema is wrapped as
{ out, meta }at build time.outputSchemadescribesout;outputMetaSchemadescribesmeta. That is why client code readsdata.outand why feed pages carry their cursors inmetarather than beside the rows.
Note — Dynamic segments carry their schema
resource(':postId', undefined, z.string())— the third argument is required on a dynamic segment and is what typesparams.postIdin the handler, in the client call, and in the inspector.
Note — Keep feed queries flat The builder warns when a
feed: trueleaf has nested objects in its query schema, because a cursor round-trips through the URL. Flat keys only.
Reference —
@emeryld/rrroutes-contract/builder,@emeryld/rrroutes-contract/registry
The same treatment for realtime. defineSocketEvents returns two things:
config, the system events the client and server runtimes use for join/leave
and heartbeat, and events, your application payloads. The server validates
outbound envelopes against these schemas and the client validates inbound ones
— from this single declaration.
import { defineSocketEvents } from '@emeryld/rrroutes-contract/sockets'
import { z } from 'zod'
import { Post } from './posts.routes'
export const sockets = defineSocketEvents(
{
joinMetaMessage: z.object({ rooms: z.array(z.string()) }),
leaveMetaMessage: z.object({ rooms: z.array(z.string()) }),
pingPayload: z.object({ sentAt: z.string() }),
pongPayload: z.object({ sentAt: z.string(), sinceMs: z.number() }),
},
{
'post:created': { message: Post },
'post:updated': { message: Post },
// `socketDelete` is the tombstone the built-in cache reducers understand:
// one event shape can insert, update, and remove.
'post:deleted': {
message: Post.partial().extend({
id: z.string(),
socketDelete: z.literal(true),
}),
},
},
)
export const { config, events } = sockets
export type PostEvents = typeof eventsNote — Rooms are strings, and they are yours Nothing here names a room. Room naming is a convention you pick once and use on both sides — this guide uses
posts:allandpost:<id>, derived on the server in step 5.5 and joined on the client in step 2.3 from the same identifiers.
Reference —
@emeryld/rrroutes-contract/sockets
defineRouteResource selects a slice of the registry and gives every leaf a
stable public name. This is the seam that keeps a path change from rippling:
the server registers byKey, the clients build from endpoints, and both
address listPosts rather than GET /v1/posts/feed. Attaching the socket
contract here means one import carries both halves of the resource.
import { defineRouteResource } from '@emeryld/rrroutes-contract/resource'
import { sockets } from './posts.events'
import { registry } from './posts.routes'
export const postsResource = defineRouteResource({
id: 'posts',
routes: registry.byKey,
keys: {
createPost: 'POST /v1/posts',
getPost: 'GET /v1/posts/:postId',
updatePost: 'PATCH /v1/posts/:postId',
deletePost: 'DELETE /v1/posts/:postId',
listPosts: 'GET /v1/posts/feed',
},
sockets,
})
export type PostsResource = typeof postsResourceNote — Two views of the same selection
endpointsis keyed by public name and is whatcreateClientBindingsconsumes.byKeyandallkeep the exactMETHOD /pathkeys and are what the server'sregisterResourceconsumes. Neither view can drift from the other — they are built from onekeysmap.
Reference —
@emeryld/rrroutes-contract/resource
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 suiteFinalized Leaves Export (JSON)
You can export finalized leaves (plus flattened schema paths) to strict JSON.
What --module means
--module is the path to a JS/TS module file that exports your leaves or registry.
- The file can export:
- a finalized registry (
finalize(leaves)) - or a leaf array/tuple (the output of
.done())
- a finalized registry (
- Use
--exportto select which exported symbol to read from that module.
CLI usage
From the repo root:
pnpm --filter @emeryld/rrroutes-export export:finalized-leaves -- \
--module ./path/to/contract-module.ts \
--export registry \
--out ./finalized-leaves.export.json \
--with-sourceArguments:
--modulerequired path to the module that exports your data.--exportoptional export name (default:leaves).--outoptional output file path (default:finalized-leaves.export.json).--with-sourceoptional flag to enrich leaves with AST definition/schema source metadata.--tsconfigoptional tsconfig path used for AST analysis (default: firsttsconfig.jsonfound from cwd).
Published package CLI (no ts-node wiring needed):
npx rrroutes-export-finalized-leaves \
--module ./path/to/contract-module.ts \
--export registry \
--out ./finalized-leaves.export.json \
--with-sourceExample module shapes
Registry export:
import { finalize, resource } from '@emeryld/rrroutes-contract'
import { z } from 'zod'
const leaves = resource('/v1')
.get({ outputSchema: z.object({ ok: z.literal(true) }) })
.done()
export const registry = finalize(leaves)Leaves export:
import { resource } from '@emeryld/rrroutes-contract'
import { z } from 'zod'
export const leaves = resource('/v1')
.get({ outputSchema: z.object({ ok: z.literal(true) }) })
.done()Runtime API
If you want to run this in code instead of CLI:
import { exportFinalizedLeaves } from '@emeryld/rrroutes-export'
const payload = await exportFinalizedLeaves(registry, {
outFile: './finalized-leaves.export.json',
htmlFile: './finalized-leaves-viewer.baked.html',
openOnFinish: true,
includeSource: true,
sourceModulePath: './path/to/contract-module.ts',
sourceExportName: 'registry',
tsconfigPath: './tsconfig.json',
})payload contains:
_meta: export/documentation metadata- when source extraction is enabled,
_meta.sourceExtractionalso includes diagnostics (reason,stats)
- when source extraction is enabled,
leaves: contract-native serialized leavesschemaFlatByLeaf: flattened schema map per leafsourceByLeaf(whenincludeSourceis true): AST-derived definition + schema source metadata keyed byMETHOD path
htmlFile writes a self-contained viewer HTML with the export payload baked in (no file picker needed).
viewerTemplateFile optionally points to a custom viewer HTML template instead of the default bundled viewer.
openOnFinish opens the generated htmlFile in your default browser after write completes.
Custom viewerTemplateFile
Use viewerTemplateFile when you want your own branded/layout HTML while still baking export data directly into the page.
Behavior:
- If omitted, RRRoutes uses the bundled
finalized-leaves-viewer.htmltemplate. - Resolution order: package-bundled viewer, then local repo paths (
tools/...,packages/export/tools/...), then built-in string fallback. - If provided, RRRoutes reads your template and injects a script like:
window.__FINALIZED_LEAVES_PAYLOAD = {...}
- If your template contains this marker comment, payload is injected exactly there:
<!--__FINALIZED_LEAVES_BAKED_PAYLOAD__-->
- If marker is missing, payload script is inserted before
</body>(or prepended if no</body>exists).
Minimal custom template example:
<!doctype html>
<html>
<head>
<meta charset="UTF-8" />
<title>My Leaves Viewer</title>
</head>
<body>
<h1>My API Routes</h1>
<div id="app"></div>
<!--__FINALIZED_LEAVES_BAKED_PAYLOAD__-->
<script>
const payload = window.__FINALIZED_LEAVES_PAYLOAD
document.getElementById('app').textContent = payload
? `Loaded ${payload.leaves.length} leaves`
: 'No baked payload found'
</script>
</body>
</html>Runtime usage with custom template:
await exportFinalizedLeaves(registry, {
htmlFile: './dist/leaves-viewer.html',
viewerTemplateFile: './tools/my-viewer-template.html',
openOnFinish: true,
})Viewer HTML (searchable UI)
A simple local viewer is included at:
packages/export/tools/finalized-leaves-viewer.html
How to use:
- Generate an export JSON with
export:finalized-leaves. - Open the HTML file in your browser.
- Load the JSON file using the file picker.
- Use the search box, quick filter toggles, grouped field chips, and advanced filter panel to filter routes.
Each result is rendered as a collapsible block with title METHOD path.
To access it from your project:
- Quick local use: open the HTML file directly.
- Team/shared use: serve it as a static file (Express example):
import express from 'express'
import path from 'node:path'
const app = express()
app.use(
'/tools/finalized-leaves-viewer',
express.static(path.resolve(process.cwd(), 'packages/export/tools')),
)
app.listen(3000)
// open http://localhost:3000/tools/finalized-leaves-viewer/finalized-leaves-viewer.html