@richie-rpc/core
v2.0.0
Published
Core contract definitions and type utilities for Richie RPC
Maintainers
Readme
@richie-rpc/core
Core package for defining type-safe API contracts with Zod schemas.
Installation
bun add @richie-rpc/core zod@^4Usage
Defining a Contract
import { defineContract, Status } from '@richie-rpc/core';
import { z } from 'zod';
const contract = defineContract({
getUser: {
method: 'GET',
path: '/users/:id',
params: z.object({ id: z.string() }),
responses: {
[Status.OK]: z.object({ id: z.string(), name: z.string() }),
},
errorResponses: {
[Status.NotFound]: z.object({ error: z.string() }),
},
},
createUser: {
method: 'POST',
path: '/users',
body: z.object({ name: z.string(), email: z.string().email() }),
responses: {
[Status.Created]: z.object({ id: z.string(), name: z.string(), email: z.string() }),
},
errorResponses: {
[Status.BadRequest]: z.object({ error: z.string() }),
},
},
});Endpoint Definition Structure
Each endpoint can have:
method(required): HTTP method (GET,POST,PUT,PATCH,DELETE, etc.)path(required): URL path with optional parameters (:idsyntax)params(optional): Zod schema for path parametersquery(optional): Zod schema for query parametersheaders(optional): Zod schema for request headersbody(optional): Zod schema for request bodycontentType(optional): Request content type ('application/json'or'multipart/form-data')responses(required): Object mapping success status codes to Zod schemaserrorResponses(optional): Object mapping error status codes to Zod schemas. Error responses are thrown asErrorResponseby the client instead of being returned as data, enabling cleanuseSuspenseQuerypatterns wheredatais always the success type.
Streaming Endpoint
For AI-style streaming responses using NDJSON:
const contract = defineContract({
generateText: {
type: 'streaming',
method: 'POST',
path: '/generate',
body: z.object({ prompt: z.string() }),
chunk: z.object({ text: z.string() }),
finalResponse: z.object({ totalTokens: z.number() }),
},
});type: 'streaming'(required): Marks this as a streaming endpointmethod(required): Must be'POST'chunk(required): Zod schema for each streamed chunkfinalResponse(optional): Zod schema for the final response after stream ends
SSE Endpoint
For server-to-client event streaming:
const contract = defineContract({
notifications: {
type: 'sse',
method: 'GET',
path: '/notifications',
query: z.object({ userId: z.string() }),
events: {
message: z.object({ text: z.string(), timestamp: z.string() }),
userJoined: z.object({ userId: z.string() }),
heartbeat: z.object({ timestamp: z.string() }),
},
},
});type: 'sse'(required): Marks this as an SSE endpointmethod(required): Must be'GET'events(required): Object mapping event names to Zod schemas
WebSocket Contract
For bidirectional real-time communication, use defineWebSocketContract:
import { defineWebSocketContract } from '@richie-rpc/core';
const wsContract = defineWebSocketContract({
chat: {
path: '/ws/chat/:roomId',
params: z.object({ roomId: z.string() }),
query: z.object({ token: z.string().optional() }),
clientMessages: {
sendMessage: { payload: z.object({ text: z.string() }) },
typing: { payload: z.object({ isTyping: z.boolean() }) },
},
serverMessages: {
message: { payload: z.object({ userId: z.string(), text: z.string() }) },
userTyping: { payload: z.object({ userId: z.string(), isTyping: z.boolean() }) },
error: { payload: z.object({ code: z.string(), message: z.string() }) },
},
},
});path(required): WebSocket endpoint path with optional parametersparams(optional): Zod schema for path parametersquery(optional): Zod schema for query parametersclientMessages(required): Messages the client can send to the serverserverMessages(required): Messages the server can send to the client
Each message type has a payload field with a Zod schema for validation.
Features
- ✅ Type-safe contract definitions
- ✅ Zod v4+ schema validation
- ✅ HTTP Streaming endpoints (NDJSON)
- ✅ Server-Sent Events (SSE) endpoints
- ✅ WebSocket contracts with typed messages
- ✅ Path parameter parsing and interpolation
- ✅ Query parameter handling
- ✅ Multiple response types per endpoint
- ✅ Full TypeScript inference
- ✅ Status code constants for cleaner code
- ✅ File uploads with
multipart/form-datasupport - ✅ Nested file structures in request bodies
Utilities
Path Parameter Utilities
import { parsePathParams, matchPath, interpolatePath } from '@richie-rpc/core';
// Parse parameter names from path
parsePathParams('/users/:id/posts/:postId');
// => ['id', 'postId']
// Match a path and extract parameters
matchPath('/users/:id', '/users/123');
// => { id: '123' }
// Interpolate parameters into path
interpolatePath('/users/:id', { id: '123' });
// => '/users/123'URL Building
import { buildUrl } from '@richie-rpc/core';
buildUrl('http://api.example.com', '/users', { limit: '10', offset: '0' });
// => 'http://api.example.com/users?limit=10&offset=0'
// With basePath in baseUrl
buildUrl('http://api.example.com/api', '/users');
// => 'http://api.example.com/api/users'The buildUrl function properly concatenates the baseUrl with the path, supporting basePath prefixes in the baseUrl.
File Upload / FormData Utilities
The core package provides utilities for handling multipart/form-data requests with nested file structures.
Defining File Upload Endpoints
Use contentType: 'multipart/form-data' and z.instanceof(File) in your body schema:
import { defineContract, Status } from '@richie-rpc/core';
import { z } from 'zod';
const contract = defineContract({
uploadDocuments: {
method: 'POST',
path: '/upload',
contentType: 'multipart/form-data',
body: z.object({
documents: z.array(
z.object({
file: z.instanceof(File),
name: z.string(),
tags: z.array(z.string()).optional(),
}),
),
category: z.string(),
}),
responses: {
[Status.Created]: z.object({
uploadedCount: z.number(),
filenames: z.array(z.string()),
}),
},
},
});How It Works
FormData is inherently flat, but Richie RPC supports nested structures using a hybrid JSON + Files approach:
Client-side: Files are extracted from the object and replaced with
{ __fileRef__: "path" }placeholders. The JSON structure is sent as__json__and files are sent as separate FormData entries.Server-side: The JSON is parsed, and
__fileRef__placeholders are replaced with actual File objects from the FormData.
Utility Functions
import { objectToFormData, formDataToObject } from '@richie-rpc/core';
// Client-side: Convert object with Files to FormData
const formData = objectToFormData({
documents: [
{ file: file1, name: 'doc1.pdf' },
{ file: file2, name: 'doc2.pdf' },
],
category: 'reports',
});
// Result: FormData with __json__ + files at "documents.0.file", "documents.1.file"
// Server-side: Convert FormData back to object with Files
const obj = formDataToObject(formData);
// Result: { documents: [{ file: File, name: 'doc1.pdf' }, ...], category: 'reports' }Status Codes
Use the Status const object for type-safe status codes:
import { Status } from '@richie-rpc/core';
const contract = defineContract({
getUser: {
method: 'GET',
path: '/users/:id',
params: z.object({ id: z.string() }),
responses: {
[Status.OK]: UserSchema,
},
errorResponses: {
[Status.NotFound]: ErrorSchema,
},
},
});Or in handlers (when imported from @richie-rpc/server):
return { status: Status.OK, body: user };
return { status: Status.NotFound, body: { error: 'Not found' } };Available constants:
- Success:
OK(200),Created(201),Accepted(202),NoContent(204) - Client Errors:
BadRequest(400),Unauthorized(401),Forbidden(403),NotFound(404),Conflict(409),UnprocessableEntity(422),TooManyRequests(429) - Server Errors:
InternalServerError(500),BadGateway(502),ServiceUnavailable(503)
Using custom status codes:
For status codes not in the Status object, use numeric literals in the contract:
const contract = defineContract({
customEndpoint: {
method: 'GET',
path: '/teapot',
responses: {
[Status.OK]: z.object({ message: z.string() }),
418: z.object({ message: z.string() }), // I'm a teapot
451: z.object({ reason: z.string() }), // Unavailable for legal reasons
},
},
});Then use as const in the handler response:
return { status: 418 as const, body: { message: "I'm a teapot" } };Type Inference
The package exports several utility types for extracting types from endpoint definitions:
ExtractParams<T>: Extract path parameters typeExtractQuery<T>: Extract query parameters typeExtractHeaders<T>: Extract headers typeExtractBody<T>: Extract request body typeExtractResponses<T>: Extract all response typesExtractResponse<T, Status>: Extract specific response type by status codeExtractErrorResponses<T>: Extract all error response typesExtractErrorResponse<T, Status>: Extract specific error response type by status code
Links
- npm: https://www.npmjs.com/package/@richie-rpc/core
- Repository: https://github.com/ricsam/richie-rpc
License
MIT
