@emeryld/rrroutes-contract
v2.8.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-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 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