@deessejs/server is a modern functional-first RPC protocol implementation. It provides type-safe remote procedure calls with a clean, composable API designed for performance and developer experience. Define your procedures once, call them locally or remotely with the same typed interface.
This package is part of a multi-package architecture:
- @deessejs/fp - FP types (
Result,ok(),err(),Maybe,Try, etc.) - see /deesse-fp skill - @deessejs/server - This package: functional RPC protocol implementation
- @deessejs/server/react - React hooks integration
-
Query and Mutation Constructors
query()- Define public read operations, exposed via HTTPmutation()- Define public write operations, exposed via HTTP
-
Internal Operations
internalQuery()- Define private read operations, server-side onlyinternalMutation()- Define private write operations, server-side only
-
Context Management
defineContext<T>()- Define typed context with runtime initializationcreateAPI()- Create API instance with router and plugins
-
Router System
- Hierarchical routing:
api.users.get(),api.posts.create() - Nested routers for organization
- Hierarchical routing:
-
Lifecycle Hooks
beforeInvoke- Run before query/mutation executiononSuccess- Run after successful executiononError- Run after failed execution
-
Cache System
defineCacheKeys()- Create typed cache key registry- Query returns
WithMetadata<T, Keys>with cache keys - Mutation returns invalidation keys
- Full TypeScript support for key autocomplete and type checking
-
Route Handler
createRouteHandler()- Create Next.js route handler for HTTP exposure- Only exposes
queryandmutationoperations internalQueryandinternalMutationremain private
-
Public API
createPublicAPI(api)- Create client-safe API with only public operations- Provides TypeScript safety to prevent calling internal operations from client code
-
Plugin System
- Plugins extend context with additional properties
- Additional plugin features (queries, mutations, events) coming later
-
Middleware System
t.middleware()- Create middleware for intercepting requests- Apply to specific queries/mutations or globally via
createAPI() - Middleware chains for multiple middleware per operation
- Context enhancement and request modification
-
Lifecycle Hooks
beforeInvoke- Run before handler executionafterInvoke- Run after handler execution (always)onSuccess- Run after successful handler executiononError- Run after handler throws or returns error
- @deessejs/fp - Required peer dependency for
Resulttype
- Support new API:
createAPI({ router: t.router(...), plugins: [...] }) - Plugin system with hooks for cache invalidation
- Plugins can extend context with additional properties
- Local executor for in-process calls (server actions)
- Export types for @deessejs/api to use
- Include comprehensive tests
import { ok, err } from "@deessejs/fp" // See /deesse-fp for full documentation
type Result<Success, Error = { code: string; message: string }> =
| { ok: true; value: Success }
| { ok: false; error: Error }
// With cache keys for queries
type WithCacheKeys<T, Keys extends CacheKey[]> = T & { keys: Keys }
type CacheKey = string | Record<string, unknown>
// Helper functions
ok(value, options?) // returns { ok: true, value, keys? }
err(error) // returns { ok: false, error }function defineContext<T, Plugins extends Plugin<T>[], Events extends EventRegistry>(
config: {
context: T
plugins?: Plugins
events?: Events
}
): {
t: QueryBuilder<T>
createAPI: (config: { router: Router }) => API
}EventRegistry provides type safety for events:
type EventRegistry = {
[eventName: string]: {
data?: unknown
response?: unknown
}
}type Plugin<Ctx> = {
name: string
extend: (ctx: Ctx) => Partial<Ctx>
}pnpm add @deessejs/server @deessejs/fp
# or
npm install @deessejs/server @deessejs/fpimport { defineContext } from "@deessejs/server"
type Context = {
db: Database
logger: Logger
userId: string | null
}
const { t, createAPI } = defineContext({
context: {
db: myDatabase,
logger: myLogger,
userId: null,
}
})
// Then create API with router
const api = createAPI({
router: t.router({ ... })
})The handler can return a Result (with ok/err), but for queries that return cache metadata, use withMetadata.
import { z } from "zod"
import { ok, err } from "@deessejs/fp" // See /deesse-fp for Result patterns
import { withMetadata } from "@deessejs/server"
import { keys } from "./cache/keys"
const getUser = t.query({
args: z.object({
id: z.number()
}),
handler: async (ctx, args) => {
const user = await ctx.db.users.find(args.id)
if (!user) {
return err({ code: "NOT_FOUND", message: "User not found" })
}
return withMetadata(user, { keys: [keys.users.byId(args.id)] })
}
})
// Handler can also return plain ok() (Result is optional)
const getUserSimple = t.query({
args: z.object({
id: z.number()
}),
handler: async (ctx, args) => {
return ok(await ctx.db.users.find(args.id))
}
})Internal queries are only callable from server-side code, not exposed via HTTP:
const getAdminStats = t.internalQuery({
// No args needed - omit entirely
handler: async (ctx): Result<AdminStats> => {
// Only accessible from server - safe from HTTP attacks
const totalUsers = await ctx.db.users.count()
const revenue = await ctx.db.orders.sum()
return ok({ totalUsers, revenue })
}
})import { z } from "zod"
import { ok, err } from "@deessejs/fp" // See /deesse-fp for Result patterns
import { withMetadata } from "@deessejs/server"
import { keys } from "./cache/keys"
const createUser = t.mutation({
args: z.object({
name: z.string().min(2),
email: z.string().email()
}),
handler: async (ctx, args) => {
const existing = await ctx.db.users.findByEmail(args.email)
if (existing) {
return err({ code: "DUPLICATE", message: "Email already exists" })
}
const user = await ctx.db.users.create(args)
return withMetadata(user, { invalidate: [keys.users.list(), keys.users.count()] })
}
})// app/actions.ts
"use server"
import { api } from "./server"
async function getUserAction(id: number) {
const result = await api.users.get({ id })
if (result.ok) {
return result.value
}
if (result.error.name === "NOT_FOUND") {
return null
}
throw new Error(result.error.message)
}
async function createUserAction(data: { name: string; email: string }) {
const result = await api.users.create(data)
if (result.ok) {
return result.value
}
throw new Error(result.error.message)
}Internal operations can only be called from server-side code:
// app/admin/page.tsx (Server Component)
import { api } from "@/server/api"
export default async function AdminPage() {
// Internal operations work from server code
const stats = await api.users.getAdminStats({})
const user = await api.users.get({ id: 1 })
return <Dashboard stats={stats} user={user} />
}
// app/actions/admin.ts (Server Action)
"use server"
import { api } from "@/server/api"
async function deleteUserAction(id: number) {
// Internal mutation - only callable from server
const result = await api.users.delete({ id })
if (!result.ok) {
throw new Error(result.error.message)
}
return result.value
}Create a route handler to expose only public operations via HTTP:
// app/(deesse)/api/[...slug]/route.ts
import { createRouteHandler } from "@deessejs/server-next"
import { client } from "@/server/api"
export const POST = createRouteHandler(client)You can combine multiple route handlers:
// app/(deesse)/api/[...slug]/route.ts - @deessejs/server
import { createRouteHandler } from "@deessejs/server-next"
import { client } from "@/server/api"
export const POST = createRouteHandler(client)// app/(deesse)/api/[...route]/route.ts - better-auth
import { auth } from "@/lib/auth"
import { toNextJsHandler } from "better-auth/next-js"
export const { POST, GET } = toNextJsHandler(auth)For TypeScript safety, create a separate client API that only exposes public operations:
import { createPublicAPI } from "@deessejs/server"
// Full API for server usage
const api = createAPI({
router: t.router({
users: t.router({
get: getUser,
create: createUser,
delete: deleteUser, // internal
getAdminStats: getAdminStats, // internal
}),
}),
})
// Client-safe API (only public operations)
const client = createPublicAPI(api)
export { api, client }Server code uses api (full access):
// app/admin/page.tsx
import { api } from "@/server/api"
const stats = await api.users.getAdminStats({}) // ✅ Works
await api.users.delete({ id: 1 }) // ✅ WorksClient code uses client (public only):
// app/components/UserList.tsx
"use client"
import { client } from "@/server/api"
const users = await client.users.get({}) // ✅ Works
await client.users.create({ name: "John" }) // ✅ Works
// TypeScript error - not available on client!
const stats = await client.users.getAdminStats({}) // ❌ TS Error
await client.users.delete({ id: 1 }) // ❌ TS ErrorThis creates HTTP endpoints for all public query and mutation operations. Internal operations are NOT exposed.
Request format:
POST /api/users.get
Content-Type: application/json
{ "args": { "id": 123 } }Response format:
{ "ok": true, "value": { ... } }
// or
{ "ok": false, "error": { "code": "NOT_FOUND", "message": "..." } }import { z } from "zod"
const getUser = t.query({
args: z.object({
id: z.number()
}),
handler: async (ctx, args) => ok({ id: args.id, name: "John" })
})
.beforeInvoke((ctx, args) => {
console.log(`Fetching user ${args.id}`)
})
.onSuccess((ctx, args, data) => {
console.log(`User fetched: ${data.id}`)
})
.onError((ctx, args, error) => {
console.error(`Failed to fetch user: ${error.message}`)
})import { z } from "zod"
// Note: createCacheStream is not implemented yet
// const cacheStream = createCacheStream()
const createUser = t.mutation({
args: z.object({
name: z.string()
}),
handler: async (ctx, args) => {
const user = await ctx.db.users.create(args)
// Note: cacheStream.invalidate() is not implemented yet
return ok(user)
}
})Plugins extend the context with additional properties. Each plugin can add new properties to ctx.
type Plugin<Ctx> = {
name: string
extend: (ctx: Ctx) => Partial<Ctx>
}Example: Auth Plugin
// plugins/auth.ts
import { Plugin } from "@deessejs/server"
export const authPlugin: Plugin<Context> = {
name: "auth",
extend: (ctx) => ({
// Add userId to context
userId: null,
// Add auth helpers
getUserId: () => ctx.userId,
setUserId: (userId: string) => { ctx.userId = userId },
})
}Example: Cache Plugin
// plugins/cache.ts
import { Plugin } from "@deessejs/server"
export const cachePlugin: Plugin<Context> = {
name: "cache",
extend: (ctx) => ({
cache: {
get: (key: string) => { ... },
set: (key: string, value: unknown) => { ... },
delete: (key: string) => { ... },
}
})
}Using Plugins
import { defineContext, Plugin } from "@deessejs/server"
import { authPlugin } from "./plugins/auth"
import { cachePlugin } from "./plugins/cache"
type BaseContext = {
db: Database
logger: Logger
}
// Define context with plugins
const { t, createAPI } = defineContext({
context: {
db: myDatabase,
logger: myLogger,
},
plugins: [
authPlugin,
cachePlugin,
],
})
const api = createAPI({
router: t.router({ ... })
})
// ctx now has: db, logger, userId, getUserId, setUserId, cacheNote: Plugins can only extend context for now. Additional plugin features (queries, mutations, event handlers) will be documented later.
import { createLocalExecutor } from "@deessejs/server"
const executor = createLocalExecutor(api)
// Execute public operations
const result = await executor.execute("users.get", { id: 1 })
// Internal operations can also be executed locally
const stats = await executor.execute("users.getAdminStats", {})@deessejs/fp (FP types: Result, ok, err)
│
▼
@deessejs/server (functional RPC protocol)
│
├── Local Transport (direct function calls)
│
└── HTTP Transport (JSON over HTTP)
Drpc reimagines RPC for the modern stack:
- Functional First - Pure functions as procedures, no classes or configuration objects
- Dual Execution - Same API for local calls (server actions, lambdas, workers) and remote calls (HTTP)
- Type Safety - Full TypeScript inference from schema definition to client call
- Security: Separate public vs internal operations
- Plugin system for extensibility (context, lifecycle hooks)
- Built on @deessejs/fp patterns (Result type with
ok/err) - see /deesse-fp skill
The key insight is that Server Actions in Next.js are not secure - they are exposed via HTTP and can be called by anyone. This package provides a solution:
| Operation Type | Callable via HTTP | Callable from Server |
|---|---|---|
query() |
✅ Yes | ✅ Yes |
mutation() |
✅ Yes | ✅ Yes |
internalQuery() |
❌ No | ✅ Yes |
internalMutation() |
❌ No | ✅ Yes |
This ensures sensitive operations remain protected:
// Public - can be called from client via HTTP
const getPublicData = t.query({ ... })
// Public - can be called from client via HTTP
const createPost = t.mutation({ ... })
// Internal - only server code can call this
const deleteUser = t.internalMutation({ ... })
// Internal - only server code can call this
const getAdminStats = t.internalQuery({ ... })- HTTP adapter (@deessejs/api)
- WebSocket support
- Batch execution optimization
- Built-in validation layer
- Rate limiting
- Request/response logging middleware