JSPM

  • Created
  • Published
  • Downloads 647
  • Score
    100M100P100Q101665F
  • License MIT

A TypeScript RPC framework that creates type-safe, schema-validated procedure calls with a single function definition. Define your procedures once and get full type inference, runtime validation, and framework integration hooks.

Package Exports

  • ts-procedures
  • ts-procedures/express-rpc
  • ts-procedures/hono-rpc
  • ts-procedures/http-rpc
  • ts-procedures/package.json

Readme

ts-procedures

A TypeScript RPC framework that creates type-safe, schema-validated procedure calls with a single function definition. Define your procedures once and get full type inference, runtime validation and procedure documentation/configuration.

Installation

npm install ts-procedures

Quick Start

import { Procedures } from 'ts-procedures'
import { Type } from 'typebox'

// Create a procedures factory
const { Create } = Procedures()

// Define a procedure with schema validation
const { GetUser, procedure, info } = Create(
  'GetUser',
  {
    description: 'Fetches a user by ID',
    schema: {
      params: Type.Object({ userId: Type.String() }),
      returnType: Type.Object({ id: Type.String(), name: Type.String() }),
    },
  },
  async (ctx, params /* typed as { userId: string } */) => {
    
    // returnType is inferred as { id: string; name: string }
    return { id: params.userId, name: 'John Doe' }
  },
)

// Call the procedure directly
const user = await GetUser({}, { userId: '123' })
// Or use the generic reference
const user2 = await procedure({}, { userId: '456' })

Core Concepts

Procedures Factory

The Procedures() function creates a factory for defining procedures. It accepts two generic type parameters:

Procedures<TContext, TExtendedConfig>(builder?: {
    onCreate?: (procedure: TProcedureRegistration<TContext, TExtendedConfig>) => void
})
Parameter Description
TContext The base context type passed to all handlers as the first parameter
TExtendedConfig Additional configuration properties for all procedures config properties
builder.onCreate Optional callback invoked when each procedure is registered (runtime)

Create Function

The Create function defines individual procedures:

Create(name, config, handler)

Returns:

  • { [name]: handler } - Named export for the handler
  • procedure - Generic reference to the handler
  • info - Procedure meta (name, description, schema, TExtendedConfig properties, etc.)

Using Generics

Base Context

Define a shared context type for all procedures in your application:

interface AppContext {
  authToken: string
  requestId: string
  logger: Logger
}

const { Create } = Procedures<AppContext>()

const { SecureEndpoint } = Create(
  'SecureEndpoint',
  {},
  async (ctx, params) => {
    // ctx.authToken is typed as string
    // ctx.requestId is typed as string
    // ctx.logger is typed as Logger
    return { token: ctx.authToken }
  },
)

// When calling, you must provide the context
await SecureEndpoint({ authToken: 'abc', requestId: '123', logger: myLogger }, {})

Extended Configuration

Add custom properties to all procedure configs:

interface ExtendedConfig {
  permissions: string[]
  rateLimit?: number
  cacheTTL?: number
}

const { Create } = Procedures<AppContext, ExtendedConfig>()

const { AdminOnly } = Create(
  'AdminOnly',
  {
    permissions: ['admin'],  // Required by ExtendedConfig
    rateLimit: 100,          // Optional
    description: 'Admin-only endpoint',
  },
  async (ctx, params) => {
    return { admin: true }
  },
)

// Access extended config via info
console.log(AdminOnly.info.permissions) // ['admin']

Combined Example

interface CustomContext {
  authToken: string
  tenantId: string
}

interface ExtendedConfig {
  requiresAuth: boolean
  auditLog?: boolean
}

const { Create, getProcedures } = Procedures<CustomContext, ExtendedConfig>({
  onCreate: (procedure) => {
    // Register with your framework
    console.log(`Registered: ${procedure.name}`)
    console.log(`Requires Auth: ${procedure.config.requiresAuth}`)
  },
})

const { CreateUser } = Create(
  'CreateUser',
  {
    requiresAuth: true,
    auditLog: true,
    description: 'Creates a new user',
    schema: {
      params: Type.Object({
        email: Type.String(),
        name: Type.String(),
      }),
      returnType: Type.Object({ id: Type.String() }),
    },
  },
  async (ctx, params) => {
    // Both context and params are fully typed
    return { id: 'user-123' }
  },
)

Schema Validation

Suretype

import { v } from 'suretype'

Create(
  'CreatePost',
  {
    schema: {
      params: Type.Object({
        title: Type.String(),
        content: Type.String(),
        tags: Type.array(Type.String()),
      }),
      returnType: Type.Object({
        id: Type.String(),
        createdAt: Type.String(),
      }),
    },
  },
  async (ctx, params) => {
    // params typed as { title: string, content: string, tags?: string[] }
    return { id: '1', createdAt: new Date().toISOString() }
  },
)

TypeBox

import { Type } from 'typebox'

Create(
  'CreatePost',
  {
    schema: {
      params: Type.Object({
        title: Type.String(),
        content: Type.String(),
        tags: Type.Optional(Type.Array(Type.String())),
      }),
      returnType: Type.Object({
        id: Type.String(),
        createdAt: Type.String(),
      }),
    },
  },
  async (ctx, params) => {
    // params typed as { title: string, content: string, tags?: string[] }
    return { id: '1', createdAt: new Date().toISOString() }
  },
)

Validation Behavior

AJV is configured with:

  • allErrors: true - Report all validation errors
  • coerceTypes: true - Automatically coerce types when possible
  • removeAdditional: true - Strip properties not in schema

Note: schema.params is validated at runtime. schema.returnType is for documentation/introspection only.

Error Handling

Using ctx.error()

The error() function is injected into both hooks and handlers:

Create(
  'GetResource',
  {},
  async (ctx, params) => {
    const resource = await db.find(params.id)
    if (!resource) {
      throw ctx.error(404, 'Resource not found', { id: params.id })
    }
    return resource
  },
)

Error Handling

Error Class Trigger
ProcedureError ctx.error() in handlers
ProcedureValidationError Schema validation failure
ProcedureRegistrationError Invalid schema at registration

Error Properties

try {
  await MyProcedure(ctx, params)
} catch (e) {
  if (e instanceof ProcedureError) {
    console.log(e.procedureName) // 'MyProcedure'
    console.log(e.message)       // 'Resource not found'
    console.log(e.meta)          // { id: '123' }
  }
}

Framework Integration

onCreate Callback

Register procedures with your framework (Express, Fastify, etc.):

import express from 'express'

const app = express()
const routes: Map<string, Function> = new Map()

const { Create } = Procedures<{ req: Request; res: Response }>({
  onCreate: ({ name, handler, config }) => {
    // Register as Express route
    app.post(`/rpc/${name}`, async (req, res) => {
      try {
        const result = await handler({ req, res }, req.body)
        res.json(result)
      } catch (e) {
        if (e instanceof ProcedureError) {
          res.status(500).json({ error: e.message })
        } else {
          res.status(500).json({ error: 'Internal error' })
        }
      }
    })
  },
})

// Procedures are automatically registered as /rpc/GetUser, /rpc/CreateUser, etc.

Express RPC Integration

ts-procedures includes an RPC-style HTTP integration for Express that creates POST routes at /rpc/{name}/{version} paths with automatic JSON schema documentation.

import { ExpressRPCAppBuilder, RPCConfig } from 'ts-procedures/express-rpc'

// Create procedure factory with RPC config
const RPC = Procedures<AppContext, RPCConfig>()

// Define procedures with name and version
RPC.Create(
  'GetUser',
  {
    name: ['users', 'get'],
    version: 1,
    schema: {
      params: Type.Object({ id: Type.String() }),
      returnType: Type.Object({ id: Type.String(), name: Type.String() }),
    },
  },
  async (ctx, params) => {
    return { id: params.id, name: 'John Doe' }
  }
)

// Build Express app with registered procedures
const app = new ExpressRPCAppBuilder()
  .register(RPC, (req) => ({ userId: req.headers['x-user-id'] as string }))
  .build()

app.listen(3000)
// Route created: POST /rpc/users/get/1

See Express RPC Integration Guide for complete setup instructions including lifecycle hooks, error handling, and route documentation.

Introspection with getProcedures()

Access all registered procedures for documentation or routing:

const { Create, getProcedures } = Procedures()

Create('GetUser', { schema: { params: Type.Object({ id: Type.String() }) } }, async () => {})
Create('ListUsers', { schema: { params: Type.Object({}) } }, async () => {})

// Get all registered procedures
const procedures = getProcedures()

// Generate OpenAPI spec
for (const config of procedures) {
  console.log(`${config.name}:`, config.schema)
}

Testing

Procedures return handlers that can be called directly in tests:

import { describe, test, expect } from 'vitest'
import { Procedures } from 'ts-procedures'
import { Type } from 'typebox'

interface MyCustomContext {
  userId?: string
  userName?: string
}

const { Create } = Procedures<MyCustomContext>()

const { GetUser, info } = Create(
  'GetUser',
  {
    schema: {
      params: Type.Object({ hideName: Type.Optional(Type.Boolean()) }),
      returnType: Type.Object({ id: Type.String(), name: Type.String() }),
    },
  },
  async (ctx, params) => {
    if (!params.userName || !ctx.userId) {
      throw ctx.error('User is not authenticated')
    }
    
    return { 
      id: params.userId, 
      name: params?.hideName ? '*******' : params.userName 
    }
  },
)

describe('GetUser', () => {
  test('returns user', async () => {
    const result = await GetUser({userId:'123',userName:'Ray'}, { hideName: false })
    expect(result).toEqual({ id: '123', name: 'Ray' })
  })
  
  test('hides user name', async () => {
    const result = await GetUser({userId:'123',userName:'Ray'}, { hideName: true })
    expect(result).toEqual({ id: '123', name: '*******' })
  })

  test('validates params', async () => {
    await expect(GetUser({}, {})).rejects.toThrow(ProcedureValidationError)
  })

  test('has correct schema', () => {
    expect(info.schema.params).toEqual({
      type: 'object',
      properties: { id: { type: 'string' } },
      required: ['id'],
    })
  })
})

API Reference

Procedures(builder?)

Creates a procedure factory.

Parameters:

  • builder.onCreate - Callback invoked when each procedure is registered

Returns:

  • Create - Function to define procedures
  • getProcedures() - Returns Array of all registered procedures

Create(name, config, handler)

Defines a procedure.

Parameters:

  • name - Unique procedure name (becomes named export)
  • config.description - Optional description
  • config.schema.params - Suretype or TypeBox schema for params (validated at runtime)
  • config.schema.returnType - Suretype or TypeBox schema for return returnType (documentation only)
  • Additional properties from TExtendedConfig
  • handler - Async function (ctx, params) => Promise<returnType>

Returns:

  • { [name]: handler } - Named handler export
  • procedure - Generic handler reference
  • info - Procedure metareturnType

Type Exports

import {
  // Core
  Procedures,

  // Errors
  ProcedureError,
  ProcedureValidationError,
  ProcedureRegistrationError,
  
  // Types
  TLocalContext,
  TProcedureRegistration,
  TNoContextProvided,

  // Schema utilities
  extractJsonSchema,
  schemaParser,
  isTypeboxSchema,
  isSuretypeSchema,

  // Schema types
  TJSONSchema,
  TSchemaLib,
  TSchemaParsed,
  TSchemaValidationError,
  Prettify,
} from 'ts-procedures'

License

MIT