JSPM

  • Created
  • Published
  • Downloads 647
  • Score
    100M100P100Q101585F
  • 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/client
  • ts-procedures/codegen
  • ts-procedures/express-rpc
  • ts-procedures/hono-api
  • ts-procedures/hono-rpc
  • ts-procedures/hono-stream
  • ts-procedures/http
  • ts-procedures/http-docs
  • 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.)

Structured Input with schema.input

For HTTP APIs and other multi-channel transports, schema.input provides per-channel type safety. Each key is an independently validated input channel:

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

const { UpdateUser } = Create(
  'UpdateUser',
  {
    path: '/users/:id',
    method: 'put',
    schema: {
      input: {
        pathParams: Type.Object({ id: Type.String() }),
        query: Type.Object({ notify: Type.Optional(Type.Boolean()) }),
        body: Type.Object({ name: Type.String(), email: Type.String() }),
      },
      returnType: Type.Object({ ok: Type.Boolean() }),
    },
  },
  async (ctx, { pathParams, query, body }) => {
    // Each channel is independently typed and validated
    await updateUser(pathParams.id, body)
    if (query.notify) await sendNotification(pathParams.id)
    return { ok: true }
  }
)

Rules:

  • schema.input and schema.params are mutually exclusive — defining both throws ProcedureRegistrationError
  • Each channel is validated independently with per-channel error messages
  • Works with both Create and CreateStream

CreateStream Function

The CreateStream function defines streaming procedures that yield values over time using async generators:

CreateStream(name, config, handler)

Config Options:

  • schema.params - Input parameter schema (validated at runtime)
  • schema.yieldType - Schema for each yielded value (validated if validateYields: true)
  • schema.returnType - Schema for final return value (documentation only)
  • validateYields - Enable runtime validation of yielded values (default: false)

Handler Signature:

async function* (ctx, params) => AsyncGenerator<TYield, TReturn | void>

Context Extensions (all handlers):

  • ctx.error(message, meta?) - Create a ProcedureError
  • ctx.signal? - AbortSignal for cancellation support (optional for Create, always present for CreateStream)

When using the built-in HTTP implementations (Hono, Express), ctx.signal is automatically injected from the HTTP request, so handlers can detect client disconnection. For direct usage without a server, signal is undefined unless you pass one in context.

Returns:

  • { [name]: handler } - Named generator export
  • procedure - Generic reference to the generator
  • info - Procedure meta with isStream: true

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.

Streaming Procedures

Streaming procedures use async generators to yield values over time, enabling SSE (Server-Sent Events), HTTP streaming, and real-time data feeds.

Basic Streaming

import { Procedures } from 'ts-procedures'
import { v } from 'suretype'

const { CreateStream } = Procedures<{ userId: string }>()

const { StreamUpdates } = CreateStream(
  'StreamUpdates',
  {
    description: 'Stream real-time updates',
    schema: {
      params: v.object({ topic: v.string().required() }),
      yieldType: v.object({
        id: v.string().required(),
        message: v.string().required(),
        timestamp: v.number().required(),
      }),
    },
  },
  async function* (ctx, params) {
    // Types are inferred from schema:
    // - params.topic: string
    // - yield value must match { id, message, timestamp }
    // - ctx.signal: AbortSignal for cancellation

    let counter = 0
    while (!ctx.signal.aborted) {
      yield {
        id: `${counter++}`,
        message: `Update for ${params.topic}`,
        timestamp: Date.now(),
      }
      await new Promise(r => setTimeout(r, 1000))
    }
  },
)

// Consume the stream
for await (const update of StreamUpdates({ userId: 'user-123' }, { topic: 'news' })) {
  console.log(update.message)
}

Yield Validation

By default, yielded values are not validated for performance. Enable validation with validateYields: true:

const { ValidatedStream } = CreateStream(
  'ValidatedStream',
  {
    schema: {
      yieldType: v.object({ count: v.number().required() }),
    },
    validateYields: true, // Enable runtime validation of each yield
  },
  async function* () {
    yield { count: 1 }  // Valid
    yield { count: 2 }  // Valid
    // yield { count: 'invalid' } // Would throw ProcedureYieldValidationError
  },
)

Abort Signal Integration

Streaming Procedures

The ctx.signal allows stream handlers to detect when consumers stop iterating. After completion, signal.reason indicates why the stream ended:

const { CancellableStream } = CreateStream(
  'CancellableStream',
  {},
  async function* (ctx) {
    try {
      while (!ctx.signal.aborted) {
        yield await fetchNextItem()
      }
    } finally {
      // Distinguish normal completion from client disconnect
      if (ctx.signal.reason === 'stream-completed') {
        // Stream finished normally
      } else {
        // Client disconnected or external abort
      }
      await cleanup()
    }
  },
)

// Consumer can break early - signal.aborted becomes true
for await (const item of CancellableStream({}, {})) {
  if (shouldStop) break // Triggers abort
}

Regular Procedures

For regular procedures, ctx.signal is available when the server implementation provides it. The built-in HTTP integrations (Hono RPC, Express RPC) inject the request's abort signal automatically:

const { Create } = Procedures<{ signal: AbortSignal }>()

const { LongQuery } = Create(
  'LongQuery',
  {},
  async (ctx, params) => {
    // Pass signal to downstream operations
    const result = await fetch('https://api.example.com/data', {
      signal: ctx.signal,
    })
    return result.json()
  },
)

When using the Hono or Express implementations, ctx.signal aborts when the client disconnects, automatically cancelling in-flight fetch() calls, database queries, or any other signal-aware operation.

SSE Integration Example

import express from 'express'
import { Procedures } from 'ts-procedures'

const app = express()

const { CreateStream, getProcedures } = Procedures<{ req: express.Request }>({
  onCreate: (proc) => {
    if (proc.isStream) {
      // Register streaming procedures as SSE endpoints
      app.get(`/stream/${proc.name}`, async (req, res) => {
        res.writeHead(200, {
          'Content-Type': 'text/event-stream',
          'Cache-Control': 'no-cache',
          'Connection': 'keep-alive',
        })

        const generator = proc.handler({ req }, req.query)

        req.on('close', async () => {
          // Client disconnected - stop the generator
          await generator.return(undefined)
        })

        try {
          for await (const data of generator) {
            res.write(`data: ${JSON.stringify(data)}\n\n`)
          }
        } finally {
          res.end()
        }
      })
    }
  },
})

// Define a streaming procedure
CreateStream(
  'LiveFeed',
  {
    schema: {
      params: v.object({ channel: v.string() }),
      yieldType: v.object({ event: v.string(), data: v.any() }),
    },
  },
  async function* (ctx, params) {
    while (!ctx.signal.aborted) {
      const event = await pollForEvent(params.channel)
      yield event
    }
  },
)

app.listen(3000)
// SSE endpoint: GET /stream/LiveFeed?channel=updates

Stream Errors

Streaming procedures support the same error handling as regular procedures:

const { StreamWithErrors } = CreateStream(
  'StreamWithErrors',
  {},
  async function* (ctx) {
    yield { status: 'starting' }

    const data = await fetchData()
    if (!data) {
      throw ctx.error('No data available', { code: 'NO_DATA' })
    }

    yield { status: 'complete', data }
  },
)

try {
  for await (const item of StreamWithErrors({}, {})) {
    console.log(item)
  }
} catch (e) {
  if (e instanceof ProcedureError) {
    console.log(e.message) // 'No data available'
    console.log(e.meta)    // { code: 'NO_DATA' }
  }
}

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 (params)
ProcedureYieldValidationError Yield validation failure (streaming with validateYields: true)
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.

Hono API Integration

ts-procedures includes a REST-style HTTP integration for Hono that routes by HTTP method with per-channel input validation via schema.input.

import { Procedures } from 'ts-procedures'
import { HonoAPIAppBuilder } from 'ts-procedures/hono-api'
import type { APIConfig } from 'ts-procedures/http'
import { Type } from 'typebox'

const API = Procedures<{ userId: string }, APIConfig>()

API.Create('GetUser', {
  path: '/users/:id',
  method: 'get',
  schema: {
    input: {
      pathParams: Type.Object({ id: Type.String() }),
    },
    returnType: Type.Object({ id: Type.String(), name: Type.String() }),
  },
}, async (ctx, { pathParams }) => {
  return await fetchUser(pathParams.id)
})

API.Create('CreateUser', {
  path: '/users',
  method: 'post',
  schema: {
    input: {
      body: Type.Object({ name: Type.String(), email: Type.String() }),
    },
  },
}, async (ctx, { body }) => {
  return await createUser(body)
})

const app = await new HonoAPIAppBuilder({ pathPrefix: '/api' })
  .register(API, (c) => ({ userId: c.req.header('x-user-id') || 'anonymous' }))
  .build()

// Routes:
// GET  /api/users/:id  → 200
// POST /api/users      → 201

See Hono API Integration Guide for complete setup.

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)
}

DocRegistry — Composing Docs from Multiple Builders

Use DocRegistry to compose route documentation from any combination of HTTP builders into a typed envelope:

import { DocRegistry } from 'ts-procedures/http-docs'

const docs = new DocRegistry({
  basePath: '/api',
  headers: [{ name: 'Authorization', description: 'Bearer token', required: false }],
  errors: DocRegistry.defaultErrors(),
})
  .from(rpcBuilder)
  .from(apiBuilder)
  .from(streamBuilder)

app.get('/docs', (c) => c.json(docs.toJSON()))

from() stores a reference — routes are read lazily at toJSON() time, so builders can be registered before or after .build(). Supports optional filter and transform options for customizing output.

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,
  ProcedureYieldValidationError,  // For streaming yield validation

  // Types
  TLocalContext,
  TStreamContext,               // Streaming context (AbortSignal always present)
  TProcedureRegistration,
  TStreamProcedureRegistration, // Streaming procedure registration
  TNoContextProvided,

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

  // Schema types
  TJSONSchema,
  TSchemaLib,
  TSchemaLibGenerator,          // AsyncGenerator type utility
  TSchemaParsed,
  TSchemaValidationError,
  Prettify,
} from 'ts-procedures'

// HTTP types
import type { RPCConfig, RPCHttpRouteDoc, StreamHttpRouteDoc, StreamMode, APIConfig, APIHttpRouteDoc, APIInput, HttpMethod } from 'ts-procedures/http'

// Hono API (REST-style)
import { HonoAPIAppBuilder } from 'ts-procedures/hono-api'
import type { APIConfig, APIHttpRouteDoc, APIInput, HttpMethod, QueryParser } from 'ts-procedures/hono-api'

// Client Runtime
import { createClient, createFetchAdapter } from 'ts-procedures/client'
import type { ClientAdapter, ClientHooks, TypedStream, ClientInstance } from 'ts-procedures/client'

// Code Generation
import { generateClient } from 'ts-procedures/codegen'

Client Code Generation

ts-procedures can generate type-safe client SDKs directly from your server's DocRegistry output. Generated files include TypeScript types and callable functions for every registered procedure, organized by scope — no manual type duplication required.

Quick Start

Step 1 — Serve your docs endpoint:

app.get('/docs', (c) => c.json(docs.toJSON()))

Step 2 — Generate the client:

npx ts-procedures-codegen --url http://localhost:3000/docs --out ./src/generated/api

Step 3 — Use the client:

import { createClient, createFetchAdapter } from 'ts-procedures/client'
import { createScopeBindings } from './generated/api'

const client = createClient({
  adapter: createFetchAdapter(),
  basePath: 'http://localhost:3000',
  scopes: createScopeBindings,
  hooks: {
    onBeforeRequest(ctx) {
      ctx.request.headers = { ...ctx.request.headers, Authorization: `Bearer ${getToken()}` }
      return ctx
    },
  },
})

// Fully typed — params and response inferred from server schemas
const user = await client.users.GetUser({ pathParams: { id: '123' } })

Generated File Structure

Running the codegen command produces one file per scope, plus shared error types and a barrel export:

generated/
  users.ts           # Types + callables for "users" scope
  billing.ts         # Types + callables for "billing" scope
  notifications.ts   # Types + callables for stream procedures
  _errors.ts         # Typed error classes + ProcedureErrorUnion
  index.ts           # Barrel exports + createScopeBindings

CLI Reference

Flag Description Required
--url <url> Fetch DocEnvelope from URL One of --url or --file
--file <path> Read DocEnvelope from JSON file One of --url or --file
--out <dir> Output directory Yes
--watch Poll for changes and regenerate No
--interval <ms> Watch poll interval (default: 3000) No
--dry-run Preview without writing files No
--client-import-path <path> Override import path (default: ts-procedures/client) No
--namespace-types Wrap types in nested TypeScript namespaces per scope/route No
--config <path> Path to config file (default: ts-procedures-codegen.config.json) No
--enum-style <union|enum> TypeScript enum style (requires --namespace-types) No
--depluralize Singularize array item type names (requires --namespace-types) No
--array-item-naming <value> Postfix for array item type names (requires --namespace-types) No
--uncountable-words <list> Comma-separated words to skip singularization (requires --namespace-types) No
--jsdoc Emit JSDoc comments from JSON Schema descriptions (requires --namespace-types) No
--self-contained Emit _types.ts and _client.ts — no runtime dependency on ts-procedures No

Note: ajsc formatting options (--enum-style enum, --depluralize, --array-item-naming, --uncountable-words, --jsdoc) only take effect with --namespace-types. In flat mode, all types are inlined and these options have no effect.

You can also use a ts-procedures-codegen.config.json file in your project root instead of CLI flags. CLI flags override config values.

Adapter Interface

The client requires an adapter that handles the actual HTTP transport. A built-in fetch adapter is included, and you can implement your own for any HTTP library:

import { createFetchAdapter } from 'ts-procedures/client'

// Use the built-in fetch adapter
const adapter = createFetchAdapter({ headers: { 'X-API-Key': 'my-key' } })

// Or implement your own (e.g., for axios)
const axiosAdapter: ClientAdapter = {
  async request({ url, method, headers, body }) {
    const res = await axios({ url, method, headers, data: body })
    return { status: res.status, headers: res.headers, body: res.data }
  },
  async stream({ url, method, headers, body }) {
    // Return AsyncIterable of SSE events
  },
}

Hooks

Hooks let you intercept requests and responses globally or per-procedure call. Global hooks apply to every call made through the client instance; per-procedure hooks override or extend them for a single invocation.

// Global hooks (apply to all calls)
const client = createClient({
  adapter,
  basePath: 'http://localhost:3000',
  scopes: createScopeBindings,
  hooks: {
    onBeforeRequest(ctx) { /* add auth headers */ return ctx },
    onAfterResponse(ctx) { /* handle errors, logging */ },
    onError(ctx) { /* error reporting */ },
  },
})

// Per-procedure hook override
await client.users.GetUser({ pathParams: { id: '123' } }, {
  onAfterResponse(ctx) {
    const rateLimit = ctx.response.headers['x-rate-limit-remaining']
  },
})

Streaming

Stream procedures return a TypedStream — an async iterable for yield values, with a .result promise for the final return value:

const stream = client.events.WatchNotifications({ filter: 'all' })

for await (const event of stream) {
  console.log(event) // typed as WatchNotificationsYield
}

const result = await stream.result // typed as WatchNotificationsReturn

Programmatic API

For build pipelines or custom tooling, generateClient can be called directly without the CLI:

import { generateClient } from 'ts-procedures/codegen'

await generateClient({
  url: 'http://localhost:3000/docs',
  outDir: './src/generated/api',
  clientImportPath: '@my-app/procedures-client', // optional
  namespaceTypes: true, // optional — wrap types in nested namespaces
  selfContained: true, // optional — emit _types.ts + _client.ts (no ts-procedures runtime dep)
  dryRun: false, // optional
  ajsc: { // optional — ajsc TypescriptConverter options
    enumStyle: 'union',
    depluralize: true,
    arrayItemNaming: 'Item',
    uncountableWords: ['criteria'],
  },
})

Self-Contained Mode

With --self-contained, the generated output includes two additional files in the output directory:

  • _types.ts — All client type definitions (ClientInstance, TypedStream, ProcedureCallOptions, hooks, adapters, descriptors)
  • _client.ts — Full client runtime: createClient, createFetchAdapter, hook pipeline, and error classes (ClientRequestError, ClientPathParamError, ClientStreamError)

All generated scope files and index.ts import from ./_types instead of ts-procedures/client, so app consumers can import everything from the generated directory without needing ts-procedures as a runtime dependency. ts-procedures becomes a devDependency only.

// Without --self-contained (default)
import { createClient, createFetchAdapter } from 'ts-procedures/client'

// With --self-contained
import { createClient, createFetchAdapter } from './generated/_client'

AI Agent Setup

ts-procedures ships with built-in AI assistant configuration for Claude Code, Cursor, and GitHub Copilot. This gives AI tools framework-aware context when writing ts-procedures code in your project.

Quick Setup

npx ts-procedures-setup

This installs rules for all supported AI tools. You can also target specific tools:

npx ts-procedures-setup claude       # Claude Code only
npx ts-procedures-setup cursor       # Cursor only
npx ts-procedures-setup copilot      # GitHub Copilot only

What Gets Installed

Tool Files Auto-updates?
Claude Code .claude/rules/ts-procedures.md, .claude/commands/ts-procedures-scaffold.md, .claude/commands/ts-procedures-review.md, .claude/agents/ts-procedures-architect.md Yes
Cursor .cursorrules (marker-based section) Yes
GitHub Copilot .github/copilot-instructions.md (marker-based section) Yes

Auto-Updates

After initial setup, rules are automatically refreshed on every npm install or npm update. When ts-procedures publishes a new version, your AI tools get the latest framework guidance without any manual steps.

Claude Code Features

Once installed, Claude Code gets:

  • Framework reference — auto-loaded rules with core API, schema system, error handling, and decision framework
  • Scaffold command/project:ts-procedures-scaffold <type> <Name> generates procedures, streams, and HTTP setups with correct patterns
  • Review command/project:ts-procedures-review <path> checks code against a 60+ item checklist
  • Architecture agentts-procedures-architect helps plan procedure structure, schema design, and HTTP implementation choices

CLI Options

npx ts-procedures-setup --force      # Overwrite without prompting
npx ts-procedures-setup --dry-run    # Preview what would be created/updated
npx ts-procedures-setup --check      # Exit with code 1 if files are outdated (for CI)

Gitignore

The .claude/ files are auto-generated and regenerated on npm install. You can add them to .gitignore:

# Auto-generated AI agent rules (regenerated on npm install)
.claude/rules/ts-procedures.md
.claude/commands/ts-procedures-*.md
.claude/agents/ts-procedures-*.md

Cursor and Copilot files use marker-based sections that coexist with your own rules, so they should typically be committed.

License

MIT