ts-procedures 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,459 @@
1
+ # ts-procedures
2
+
3
+ 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.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install ts-procedures
9
+ ```
10
+
11
+ ## Quick Start
12
+
13
+ ```typescript
14
+ import { Procedures } from 'ts-procedures'
15
+ import { Type } from 'typebox'
16
+
17
+ // Create a procedures factory
18
+ const { Create } = Procedures()
19
+
20
+ // Define a procedure with schema validation
21
+ const { GetUser, procedure, info } = Create(
22
+ 'GetUser',
23
+ {
24
+ description: 'Fetches a user by ID',
25
+ schema: {
26
+ params: Type.Object({ userId: Type.String() }),
27
+ returnType: Type.Object({ id: Type.String(), name: Type.String() }),
28
+ },
29
+ },
30
+ async (ctx, params /* typed as { userId: string } */) => {
31
+
32
+ // returnType is inferred as { id: string; name: string }
33
+ return { id: params.userId, name: 'John Doe' }
34
+ },
35
+ )
36
+
37
+ // Call the procedure directly
38
+ const user = await GetUser({}, { userId: '123' })
39
+ // Or use the generic reference
40
+ const user2 = await procedure({}, { userId: '456' })
41
+ ```
42
+
43
+ ## Core Concepts
44
+
45
+ ### Procedures Factory
46
+
47
+ The `Procedures()` function creates a factory for defining procedures. It accepts two generic type parameters:
48
+
49
+ ```typescript
50
+ Procedures<TContext, TExtendedConfig>(builder?: {
51
+ onCreate?: (procedure: TProcedureRegistration<TContext, TExtendedConfig>) => void
52
+ })
53
+ ```
54
+
55
+ | Parameter | Description |
56
+ |-----------|----------------------------------------------------------------------------|
57
+ | `TContext` | The base context type passed to all handlers as the first parameter |
58
+ | `TExtendedConfig` | Additional configuration properties for all procedures `config` properties |
59
+ | `builder.onCreate` | Optional callback invoked when each procedure is registered (runtime) |
60
+
61
+ ### Create Function
62
+
63
+ The `Create` function defines individual procedures:
64
+
65
+ ```typescript
66
+ Create(name, config, handler)
67
+ ```
68
+
69
+ **Returns:**
70
+ - `{ [name]: handler }` - Named export for the handler
71
+ - `procedure` - Generic reference to the handler
72
+ - `info` - Procedure meta (name, description, schema, `TExtendedConfig` properties, etc.)
73
+
74
+ ## Using Generics
75
+
76
+ ### Base Context
77
+
78
+ Define a shared context type for all procedures in your application:
79
+
80
+ ```typescript
81
+ interface AppContext {
82
+ authToken: string
83
+ requestId: string
84
+ logger: Logger
85
+ }
86
+
87
+ const { Create } = Procedures<AppContext>()
88
+
89
+ const { SecureEndpoint } = Create(
90
+ 'SecureEndpoint',
91
+ {},
92
+ async (ctx, params) => {
93
+ // ctx.authToken is typed as string
94
+ // ctx.requestId is typed as string
95
+ // ctx.logger is typed as Logger
96
+ return { token: ctx.authToken }
97
+ },
98
+ )
99
+
100
+ // When calling, you must provide the context
101
+ await SecureEndpoint({ authToken: 'abc', requestId: '123', logger: myLogger }, {})
102
+ ```
103
+
104
+ ### Extended Configuration
105
+
106
+ Add custom properties to all procedure configs:
107
+
108
+ ```typescript
109
+ interface ExtendedConfig {
110
+ permissions: string[]
111
+ rateLimit?: number
112
+ cacheTTL?: number
113
+ }
114
+
115
+ const { Create } = Procedures<AppContext, ExtendedConfig>()
116
+
117
+ const { AdminOnly } = Create(
118
+ 'AdminOnly',
119
+ {
120
+ permissions: ['admin'], // Required by ExtendedConfig
121
+ rateLimit: 100, // Optional
122
+ description: 'Admin-only endpoint',
123
+ },
124
+ async (ctx, params) => {
125
+ return { admin: true }
126
+ },
127
+ )
128
+
129
+ // Access extended config via info
130
+ console.log(AdminOnly.info.permissions) // ['admin']
131
+ ```
132
+
133
+ ### Combined Example
134
+
135
+ ```typescript
136
+ interface CustomContext {
137
+ authToken: string
138
+ tenantId: string
139
+ }
140
+
141
+ interface ExtendedConfig {
142
+ requiresAuth: boolean
143
+ auditLog?: boolean
144
+ }
145
+
146
+ const { Create, getProcedures } = Procedures<CustomContext, ExtendedConfig>({
147
+ onCreate: (procedure) => {
148
+ // Register with your framework
149
+ console.log(`Registered: ${procedure.name}`)
150
+ console.log(`Requires Auth: ${procedure.config.requiresAuth}`)
151
+ },
152
+ })
153
+
154
+ const { CreateUser } = Create(
155
+ 'CreateUser',
156
+ {
157
+ requiresAuth: true,
158
+ auditLog: true,
159
+ description: 'Creates a new user',
160
+ schema: {
161
+ params: Type.Object({
162
+ email: Type.String(),
163
+ name: Type.String(),
164
+ }),
165
+ returnType: Type.Object({ id: Type.String() }),
166
+ },
167
+ },
168
+ async (ctx, params) => {
169
+ // Both context and params are fully typed
170
+ return { id: 'user-123' }
171
+ },
172
+ )
173
+ ```
174
+
175
+ ## Schema Validation
176
+
177
+ ### Suretype
178
+
179
+ ```typescript
180
+ import { v } from 'suretype'
181
+
182
+ Create(
183
+ 'CreatePost',
184
+ {
185
+ schema: {
186
+ params: Type.Object({
187
+ title: Type.String(),
188
+ content: Type.String(),
189
+ tags: Type.array(Type.String()),
190
+ }),
191
+ returnType: Type.Object({
192
+ id: Type.String(),
193
+ createdAt: Type.String(),
194
+ }),
195
+ },
196
+ },
197
+ async (ctx, params) => {
198
+ // params typed as { title: string, content: string, tags?: string[] }
199
+ return { id: '1', createdAt: new Date().toISOString() }
200
+ },
201
+ )
202
+ ```
203
+
204
+ ### TypeBox
205
+
206
+ ```typescript
207
+ import { Type } from 'typebox'
208
+
209
+ Create(
210
+ 'CreatePost',
211
+ {
212
+ schema: {
213
+ params: Type.Object({
214
+ title: Type.String(),
215
+ content: Type.String(),
216
+ tags: Type.Optional(Type.Array(Type.String())),
217
+ }),
218
+ returnType: Type.Object({
219
+ id: Type.String(),
220
+ createdAt: Type.String(),
221
+ }),
222
+ },
223
+ },
224
+ async (ctx, params) => {
225
+ // params typed as { title: string, content: string, tags?: string[] }
226
+ return { id: '1', createdAt: new Date().toISOString() }
227
+ },
228
+ )
229
+ ```
230
+
231
+ ### Validation Behavior
232
+
233
+ AJV is configured with:
234
+ - `allErrors: true` - Report all validation errors
235
+ - `coerceTypes: true` - Automatically coerce types when possible
236
+ - `removeAdditional: true` - Strip properties not in schema
237
+
238
+ **Note:** `schema.params` is validated at runtime. `schema.returnType` is for documentation/introspection only.
239
+
240
+ ## Error Handling
241
+
242
+ ### Using ctx.error()
243
+
244
+ The `error()` function is injected into both hooks and handlers:
245
+
246
+ ```typescript
247
+ Create(
248
+ 'GetResource',
249
+ {},
250
+ async (ctx, params) => {
251
+ const resource = await db.find(params.id)
252
+ if (!resource) {
253
+ throw ctx.error(404, 'Resource not found', { id: params.id })
254
+ }
255
+ return resource
256
+ },
257
+ )
258
+ ```
259
+
260
+ ### Error Handling
261
+
262
+ | Error Class | Trigger |
263
+ |-------------|---------|
264
+ | ProcedureError | `ctx.error()` in handlers |
265
+ | ProcedureValidationError | Schema validation failure |
266
+ | ProcedureRegistrationError | Invalid schema at registration |
267
+
268
+ ### Error Properties
269
+
270
+ ```typescript
271
+ try {
272
+ await MyProcedure(ctx, params)
273
+ } catch (e) {
274
+ if (e instanceof ProcedureError) {
275
+ console.log(e.procedureName) // 'MyProcedure'
276
+ console.log(e.message) // 'Resource not found'
277
+ console.log(e.meta) // { id: '123' }
278
+ }
279
+ }
280
+ ```
281
+
282
+ ## Framework Integration
283
+
284
+ ### onCreate Callback
285
+
286
+ Register procedures with your framework (Express, Fastify, etc.):
287
+
288
+ ```typescript
289
+ import express from 'express'
290
+
291
+ const app = express()
292
+ const routes: Map<string, Function> = new Map()
293
+
294
+ const { Create } = Procedures<{ req: Request; res: Response }>({
295
+ onCreate: ({ name, handler, config }) => {
296
+ // Register as Express route
297
+ app.post(`/rpc/${name}`, async (req, res) => {
298
+ try {
299
+ const result = await handler({ req, res }, req.body)
300
+ res.json(result)
301
+ } catch (e) {
302
+ if (e instanceof ProcedureError) {
303
+ res.status(500).json({ error: e.message })
304
+ } else {
305
+ res.status(500).json({ error: 'Internal error' })
306
+ }
307
+ }
308
+ })
309
+ },
310
+ })
311
+
312
+ // Procedures are automatically registered as /rpc/GetUser, /rpc/CreateUser, etc.
313
+ ```
314
+
315
+ ### Introspection with getProcedures()
316
+
317
+ Access all registered procedures for documentation or routing:
318
+
319
+ ```typescript
320
+ const { Create, getProcedures } = Procedures()
321
+
322
+ Create('GetUser', { schema: { params: Type.Object({ id: Type.String() }) } }, async () => {})
323
+ Create('ListUsers', { schema: { params: Type.Object({}) } }, async () => {})
324
+
325
+ // Get all registered procedures
326
+ const procedures = getProcedures()
327
+
328
+ // Generate OpenAPI spec
329
+ for (const [name, { config }] of procedures) {
330
+ console.log(`${name}:`, config.schema)
331
+ }
332
+ ```
333
+
334
+ ## Testing
335
+
336
+ Procedures return handlers that can be called directly in tests:
337
+
338
+ ```typescript
339
+ import { describe, test, expect } from 'vitest'
340
+ import { Procedures } from 'ts-procedures'
341
+ import { Type } from 'typebox'
342
+
343
+ interface MyCustomContext {
344
+ userId?: string
345
+ userName?: string
346
+ }
347
+
348
+ const { Create } = Procedures<MyCustomContext>()
349
+
350
+ const { GetUser, info } = Create(
351
+ 'GetUser',
352
+ {
353
+ schema: {
354
+ params: Type.Object({ hideName: Type.Optional(Type.Boolean()) }),
355
+ returnType: Type.Object({ id: Type.String(), name: Type.String() }),
356
+ },
357
+ },
358
+ async (ctx, params) => {
359
+ if (!params.userName || !ctx.userId) {
360
+ throw ctx.error('User is not authenticated')
361
+ }
362
+
363
+ return {
364
+ id: params.userId,
365
+ name: params?.hideName ? '*******' : params.userName
366
+ }
367
+ },
368
+ )
369
+
370
+ describe('GetUser', () => {
371
+ test('returns user', async () => {
372
+ const result = await GetUser({userId:'123',userName:'Ray'}, { hideName: false })
373
+ expect(result).toEqual({ id: '123', name: 'Ray' })
374
+ })
375
+
376
+ test('hides user name', async () => {
377
+ const result = await GetUser({userId:'123',userName:'Ray'}, { hideName: true })
378
+ expect(result).toEqual({ id: '123', name: '*******' })
379
+ })
380
+
381
+ test('validates params', async () => {
382
+ await expect(GetUser({}, {})).rejects.toThrow(ProcedureValidationError)
383
+ })
384
+
385
+ test('has correct schema', () => {
386
+ expect(info.schema.params).toEqual({
387
+ type: 'object',
388
+ properties: { id: { type: 'string' } },
389
+ required: ['id'],
390
+ })
391
+ })
392
+ })
393
+ ```
394
+
395
+ ## API Reference
396
+
397
+ ### Procedures(builder?)
398
+
399
+ Creates a procedure factory.
400
+
401
+ **Parameters:**
402
+ - `builder.onCreate` - Callback invoked when each procedure is registered
403
+
404
+ **Returns:**
405
+ - `Create` - Function to define procedures
406
+ - `getProcedures()` - Returns `Map` of all registered procedures
407
+
408
+ ### Create(name, config, handler)
409
+
410
+ Defines a procedure.
411
+
412
+ **Parameters:**
413
+ - `name` - Unique procedure name (becomes named export)
414
+ - `config.description` - Optional description
415
+ - `config.schema.params` - Suretype or TypeBox schema for params (validated at runtime)
416
+ - `config.schema.returnType` - Suretype or TypeBox schema for return returnType (documentation only)
417
+ - Additional properties from `TExtendedConfig`
418
+ - `handler` - Async function `(ctx, params) => Promise<returnType>`
419
+
420
+ **Returns:**
421
+ - `{ [name]: handler }` - Named handler export
422
+ - `procedure` - Generic handler reference
423
+ - `info` - Procedure metareturnType
424
+
425
+ ### Type Exports
426
+
427
+ ```typescript
428
+ import {
429
+ // Core
430
+ Procedures,
431
+
432
+ // Errors
433
+ ProcedureError,
434
+ ProcedureValidationError,
435
+ ProcedureRegistrationError,
436
+
437
+ // Types
438
+ TLocalContext,
439
+ TProcedureRegistration,
440
+ TNoContextProvided,
441
+
442
+ // Schema utilities
443
+ extractJsonSchema,
444
+ schemaParser,
445
+ isTypeboxSchema,
446
+ isSuretypeSchema,
447
+
448
+ // Schema types
449
+ TJSONSchema,
450
+ TSchemaLib,
451
+ TSchemaParsed,
452
+ TSchemaValidationError,
453
+ Prettify,
454
+ } from 'ts-procedures'
455
+ ```
456
+
457
+ ## License
458
+
459
+ MIT
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "ts-procedures",
3
+ "version": "1.0.0",
4
+ "description": "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.",
5
+ "main": "dist/exports.js",
6
+ "types": "dist/exports.d.ts",
7
+ "type": "module",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/exports.d.ts",
11
+ "import": "./dist/exports.js"
12
+ },
13
+ "./package.json": "./package.json"
14
+ },
15
+ "scripts": {
16
+ "build": "tsc",
17
+ "lint": "npx eslint src/ --quiet",
18
+ "test": "vitest run"
19
+ },
20
+ "author": "coryrobinson42@gmail.com",
21
+ "license": "MIT",
22
+ "files": [
23
+ "assets",
24
+ "dist",
25
+ "src"
26
+ ],
27
+ "keywords": [
28
+ "typescript",
29
+ "rpc",
30
+ "type-safe",
31
+ "validation",
32
+ "procedures",
33
+ "api",
34
+ "framework"
35
+ ],
36
+ "dependencies": {
37
+ "ajv": "^8.17.1",
38
+ "ajv-formats": "^3.0.1",
39
+ "typebox": "^1.0.30",
40
+ "suretype": "^3.3.1"
41
+ },
42
+ "devDependencies": {
43
+ "@eslint/js": "^9.17.0",
44
+ "eslint": "^9.17.0",
45
+ "suretype": "^3.3.1",
46
+ "typebox": "^1.0.77",
47
+ "typescript-eslint": "^8.53.0",
48
+ "vitest": "^4.0.18"
49
+ }
50
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,39 @@
1
+ import { TSchemaValidationError } from './schema/parser.js'
2
+
3
+ export class ProcedureError extends Error {
4
+ constructor(
5
+ readonly procedureName: string,
6
+ readonly message: string,
7
+ readonly meta?: object,
8
+ ) {
9
+ super(message)
10
+ this.name = 'ProcedureError'
11
+
12
+ // https://www.dannyguo.com/blog/how-to-fix-instanceof-not-working-for-custom-errors-in-typescript/
13
+ Object.setPrototypeOf(this, ProcedureError.prototype)
14
+ }
15
+ }
16
+
17
+ export class ProcedureValidationError extends ProcedureError {
18
+ constructor(
19
+ readonly procedureName: string,
20
+ message: string,
21
+ readonly errors?: TSchemaValidationError[],
22
+ ) {
23
+ super(procedureName, message)
24
+ this.name = 'ProcedureValidationError'
25
+
26
+ // https://www.dannyguo.com/blog/how-to-fix-instanceof-not-working-for-custom-errors-in-typescript/
27
+ Object.setPrototypeOf(this, ProcedureValidationError.prototype)
28
+ }
29
+ }
30
+
31
+ export class ProcedureRegistrationError extends Error {
32
+ constructor(readonly procedureName: string, message: string) {
33
+ super(message)
34
+ this.name = 'ProcedureRegistrationError'
35
+
36
+ // https://www.dannyguo.com/blog/how-to-fix-instanceof-not-working-for-custom-errors-in-typescript/
37
+ Object.setPrototypeOf(this, ProcedureRegistrationError.prototype)
38
+ }
39
+ }
package/src/exports.ts ADDED
@@ -0,0 +1,6 @@
1
+ export * from './index.js'
2
+ export * from './errors.js'
3
+ export * from './schema/extract-json-schema.js'
4
+ export * from './schema/parser.js'
5
+ export * from './schema/resolve-schema-lib.js'
6
+ export * from './schema/types.js'