ts-procedures 2.1.1 → 3.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.
Files changed (46) hide show
  1. package/build/errors.d.ts +2 -1
  2. package/build/errors.js +3 -2
  3. package/build/errors.js.map +1 -1
  4. package/build/errors.test.d.ts +1 -0
  5. package/build/errors.test.js +40 -0
  6. package/build/errors.test.js.map +1 -0
  7. package/build/implementations/http/express-rpc/index.d.ts +3 -2
  8. package/build/implementations/http/express-rpc/index.js +6 -6
  9. package/build/implementations/http/express-rpc/index.js.map +1 -1
  10. package/build/implementations/http/express-rpc/index.test.js +93 -93
  11. package/build/implementations/http/express-rpc/index.test.js.map +1 -1
  12. package/build/implementations/http/hono-rpc/index.d.ts +83 -0
  13. package/build/implementations/http/hono-rpc/index.js +148 -0
  14. package/build/implementations/http/hono-rpc/index.js.map +1 -0
  15. package/build/implementations/http/hono-rpc/index.test.d.ts +1 -0
  16. package/build/implementations/http/hono-rpc/index.test.js +647 -0
  17. package/build/implementations/http/hono-rpc/index.test.js.map +1 -0
  18. package/build/implementations/http/hono-rpc/types.d.ts +28 -0
  19. package/build/implementations/http/hono-rpc/types.js +2 -0
  20. package/build/implementations/http/hono-rpc/types.js.map +1 -0
  21. package/build/implementations/types.d.ts +1 -1
  22. package/build/index.d.ts +12 -0
  23. package/build/index.js +29 -7
  24. package/build/index.js.map +1 -1
  25. package/build/index.test.js +65 -0
  26. package/build/index.test.js.map +1 -1
  27. package/build/schema/parser.js +3 -0
  28. package/build/schema/parser.js.map +1 -1
  29. package/build/schema/parser.test.js +18 -0
  30. package/build/schema/parser.test.js.map +1 -1
  31. package/package.json +8 -2
  32. package/src/errors.test.ts +53 -0
  33. package/src/errors.ts +4 -2
  34. package/src/implementations/http/README.md +172 -0
  35. package/src/implementations/http/express-rpc/README.md +152 -243
  36. package/src/implementations/http/express-rpc/index.test.ts +93 -93
  37. package/src/implementations/http/express-rpc/index.ts +15 -7
  38. package/src/implementations/http/hono-rpc/README.md +293 -0
  39. package/src/implementations/http/hono-rpc/index.test.ts +847 -0
  40. package/src/implementations/http/hono-rpc/index.ts +202 -0
  41. package/src/implementations/http/hono-rpc/types.ts +33 -0
  42. package/src/implementations/types.ts +2 -1
  43. package/src/index.test.ts +83 -0
  44. package/src/index.ts +34 -8
  45. package/src/schema/parser.test.ts +26 -0
  46. package/src/schema/parser.ts +5 -1
@@ -0,0 +1,202 @@
1
+ import { Hono, Context } from 'hono'
2
+ import { kebabCase } from 'es-toolkit/string'
3
+ import { Procedures, TProcedureRegistration } from '../../../index.js'
4
+ import { RPCConfig, RPCHttpRouteDoc } from '../../types.js'
5
+ import { castArray } from 'es-toolkit/compat'
6
+ import { HonoFactoryItem, ExtractContext, ProceduresFactory } from './types.js'
7
+
8
+ export type { RPCConfig, RPCHttpRouteDoc }
9
+
10
+ export type HonoRPCAppBuilderConfig = {
11
+ /**
12
+ * An existing Hono application instance to use.
13
+ * If not provided, a new instance will be created.
14
+ */
15
+ app?: Hono
16
+ /** Optional path prefix for all RPC routes. */
17
+ pathPrefix?: string
18
+ onRequestStart?: (c: Context) => void
19
+ onRequestEnd?: (c: Context) => void
20
+ onSuccess?: (procedure: TProcedureRegistration, c: Context) => void
21
+ error?: (
22
+ procedure: TProcedureRegistration,
23
+ c: Context,
24
+ error: Error
25
+ ) => Response | Promise<Response>
26
+ }
27
+
28
+ /**
29
+ * Builder class for creating a Hono application with RPC routes.
30
+ *
31
+ * Usage:
32
+ * const PublicRPC = Procedures<PublicRPCContext, RPCConfig>()
33
+ * const ProtectedRPC = Procedures<ProtectedRPCContext, RPCConfig>()
34
+ *
35
+ * const rpcApp = new HonoRPCAppBuilder()
36
+ * .register(PublicRPC, (c): Promise<PublicRPCContext> => { /* context resolution logic * / })
37
+ * .register(ProtectedRPC, (c): Promise<ProtectedRPCContext> => { /* context resolution logic * / })
38
+ * .build();
39
+ *
40
+ * const app = rpcApp.app; // Hono application
41
+ * const docs = rpcApp.docs; // RPC route documentation
42
+ */
43
+ export class HonoRPCAppBuilder {
44
+ /**
45
+ * Constructor for HonoRPCAppBuilder.
46
+ *
47
+ * @param config
48
+ */
49
+ constructor(readonly config?: HonoRPCAppBuilderConfig) {
50
+ if (config?.app) {
51
+ this._app = config.app
52
+ }
53
+
54
+ if (config?.onRequestStart) {
55
+ this._app.use('*', async (c, next) => {
56
+ config.onRequestStart!(c)
57
+ await next()
58
+ })
59
+ }
60
+
61
+ if (config?.onRequestEnd) {
62
+ this._app.use('*', async (c, next) => {
63
+ await next()
64
+ config.onRequestEnd!(c)
65
+ })
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Generates the RPC route path based on the RPC configuration.
71
+ * The RPCConfig name can be a string or an array of strings to form nested paths.
72
+ *
73
+ * Example
74
+ * name: ['string', 'string-string', 'string']
75
+ * path: /string/string-string/string/version
76
+ * @param config
77
+ */
78
+ static makeRPCHttpRoutePath({
79
+ name,
80
+ config,
81
+ prefix,
82
+ }: {
83
+ name: string
84
+ prefix?: string
85
+ config: RPCConfig
86
+ }) {
87
+ const normalizedPrefix = prefix ? (prefix.startsWith('/') ? prefix : `/${prefix}`) : ''
88
+
89
+ return `${normalizedPrefix}/${castArray(config.scope).map(kebabCase).join('/')}/${kebabCase(name)}/${String(config.version).trim()}`
90
+ }
91
+
92
+ /**
93
+ * Instance method wrapper for makeRPCHttpRoutePath that uses the builder's pathPrefix.
94
+ * @param config - The RPC configuration
95
+ */
96
+ makeRPCHttpRoutePath(name: string, config: RPCConfig): string {
97
+ return HonoRPCAppBuilder.makeRPCHttpRoutePath({
98
+ name,
99
+ config,
100
+ prefix: this.config?.pathPrefix,
101
+ })
102
+ }
103
+
104
+ private factories: HonoFactoryItem<any>[] = []
105
+
106
+ private _app: Hono = new Hono()
107
+ private _docs: RPCHttpRouteDoc[] = []
108
+
109
+ get app(): Hono {
110
+ return this._app
111
+ }
112
+
113
+ get docs(): RPCHttpRouteDoc[] {
114
+ return this._docs
115
+ }
116
+
117
+ /**
118
+ * Registers a procedure factory with its context.
119
+ * @param factory - The procedure factory created by Procedures<Context, RPCConfig>()
120
+ * @param factoryContext - The context for procedure handlers. Can be a direct value,
121
+ * a sync function (c) => Context, or an async function (c) => Promise<Context>
122
+ */
123
+ register<TFactory extends ProceduresFactory>(
124
+ factory: TFactory,
125
+ factoryContext:
126
+ | ExtractContext<TFactory>
127
+ | ((c: Context) => ExtractContext<TFactory> | Promise<ExtractContext<TFactory>>)
128
+ ): this {
129
+ this.factories.push({ factory, factoryContext } as HonoFactoryItem<any>)
130
+ return this
131
+ }
132
+
133
+ /**
134
+ * Builds and returns the Hono application with registered RPC routes.
135
+ * @return Hono
136
+ */
137
+ build(): Hono {
138
+ this.factories.forEach(({ factory, factoryContext }) => {
139
+ factory.getProcedures().map((procedure: TProcedureRegistration<any, RPCConfig>) => {
140
+ const route = this.buildRpcHttpRouteDoc(procedure)
141
+
142
+ this._docs.push(route)
143
+
144
+ this._app.post(route.path, async (c) => {
145
+ try {
146
+ const context =
147
+ typeof factoryContext === 'function'
148
+ ? await factoryContext(c)
149
+ : (factoryContext as ExtractContext<typeof factory>)
150
+
151
+ // Hono uses c.req.json() for body parsing
152
+ const body = await c.req.json().catch(() => ({}))
153
+ const result = await procedure.handler(context, body)
154
+
155
+ if (this.config?.onSuccess) {
156
+ this.config.onSuccess(procedure, c)
157
+ }
158
+
159
+ // Hono returns Response objects via c.json()
160
+ return c.json(result)
161
+ } catch (error) {
162
+ if (this.config?.error) {
163
+ return this.config.error(procedure, c, error as Error)
164
+ }
165
+ // Default error handling
166
+ return c.json({ error: (error as Error).message }, 500)
167
+ }
168
+ })
169
+ })
170
+ })
171
+
172
+ return this._app
173
+ }
174
+
175
+ /**
176
+ * Generates the RPC HTTP route for the given procedure.
177
+ * @param procedure
178
+ */
179
+ private buildRpcHttpRouteDoc(procedure: TProcedureRegistration<any, RPCConfig>): RPCHttpRouteDoc {
180
+ const { config } = procedure
181
+ const path = HonoRPCAppBuilder.makeRPCHttpRoutePath({
182
+ name: procedure.name,
183
+ config,
184
+ prefix: this.config?.pathPrefix,
185
+ })
186
+ const method = 'post' // RPCs use POST method
187
+ const jsonSchema: { body?: object; response?: object } = {}
188
+
189
+ if (config.schema?.params) {
190
+ jsonSchema.body = config.schema.params
191
+ }
192
+ if (config.schema?.returnType) {
193
+ jsonSchema.response = config.schema.returnType
194
+ }
195
+
196
+ return {
197
+ path,
198
+ method,
199
+ jsonSchema,
200
+ }
201
+ }
202
+ }
@@ -0,0 +1,33 @@
1
+ import { RPCConfig } from '../../types.js'
2
+ import { Procedures } from '../../../index.js'
3
+ import { Context } from 'hono'
4
+
5
+ /**
6
+ * Extracts the TContext type from a Procedures factory return type.
7
+ * Uses the first parameter of the handler function to infer the context type.
8
+ */
9
+ export type ExtractContext<TFactory> = TFactory extends {
10
+ getProcedures: () => Array<{ handler: (ctx: infer TContext, ...args: any[]) => any }>
11
+ }
12
+ ? TContext
13
+ : never
14
+
15
+ /**
16
+ * Minimal structural type for a Procedures factory.
17
+ * Uses explicit `any` types to avoid variance issues with generic constraints.
18
+ */
19
+ export type ProceduresFactory = {
20
+ getProcedures: () => Array<{
21
+ name: string
22
+ config: any
23
+ handler: (ctx: any, params?: any) => Promise<any>
24
+ }>
25
+ Create: (...args: any[]) => any
26
+ }
27
+
28
+ export type HonoFactoryItem<TFactory = ReturnType<typeof Procedures<any, RPCConfig>>> = {
29
+ factory: TFactory
30
+ factoryContext:
31
+ | ExtractContext<TFactory>
32
+ | ((c: Context) => ExtractContext<TFactory> | Promise<ExtractContext<TFactory>>)
33
+ }
@@ -1,7 +1,8 @@
1
1
  import { Procedures } from '../index.js'
2
2
 
3
3
  export interface RPCConfig {
4
- name: string | string[]
4
+ // Scope or scopes (scope segments) required to access the RPC
5
+ scope: string | string[]
5
6
  version: number
6
7
  }
7
8
 
package/src/index.test.ts CHANGED
@@ -363,4 +363,87 @@ describe('Procedures', () => {
363
363
  CheckIsAuthenticated({ authToken: 'not-valid-token' }, {}),
364
364
  ).rejects.toThrowError(ProcedureError)
365
365
  })
366
+
367
+ test('Procedures - duplicate registration throws before schema computation', () => {
368
+ const { Create } = Procedures()
369
+
370
+ Create('DuplicateTest', {}, async () => 'first')
371
+
372
+ expect(() => {
373
+ Create('DuplicateTest', {}, async () => 'second')
374
+ }).toThrow('Procedure with name DuplicateTest is already registered')
375
+ })
376
+
377
+ test('Procedures - wrapped errors preserve cause', async () => {
378
+ const { Create } = Procedures()
379
+ const originalError = new Error('Database connection failed')
380
+ ;(originalError as any).code = 'ECONNREFUSED'
381
+
382
+ const { TestCause } = Create('TestCause', {}, async () => {
383
+ throw originalError
384
+ })
385
+
386
+ try {
387
+ await TestCause({}, {})
388
+ } catch (e: any) {
389
+ expect(e).toBeInstanceOf(ProcedureError)
390
+ expect(e.cause).toBe(originalError)
391
+ expect(e.cause.code).toBe('ECONNREFUSED')
392
+ }
393
+ })
394
+
395
+ test('Procedures - getProcedure returns specific procedure', () => {
396
+ const { Create, getProcedure } = Procedures()
397
+
398
+ Create('FindMe', {}, async () => 'found')
399
+
400
+ const proc = getProcedure('FindMe')
401
+ expect(proc).toBeDefined()
402
+ expect(proc?.name).toBe('FindMe')
403
+
404
+ expect(getProcedure('NotFound')).toBeUndefined()
405
+ })
406
+
407
+ test('Procedures - removeProcedure allows re-registration', () => {
408
+ const { Create, removeProcedure, getProcedure } = Procedures()
409
+
410
+ Create('Removable', {}, async () => 'v1')
411
+ expect(getProcedure('Removable')).toBeDefined()
412
+
413
+ const removed = removeProcedure('Removable')
414
+ expect(removed).toBe(true)
415
+ expect(getProcedure('Removable')).toBeUndefined()
416
+
417
+ // Can now re-register
418
+ Create('Removable', {}, async () => 'v2')
419
+ expect(getProcedure('Removable')).toBeDefined()
420
+ })
421
+
422
+ test('Procedures - clear removes all procedures', () => {
423
+ const { Create, getProcedures, clear } = Procedures()
424
+
425
+ Create('One', {}, async () => '1')
426
+ Create('Two', {}, async () => '2')
427
+ expect(getProcedures().length).toBe(2)
428
+
429
+ clear()
430
+ expect(getProcedures().length).toBe(0)
431
+ })
432
+
433
+ test('Procedures - ctx.error still works after optimization', async () => {
434
+ const { Create } = Procedures()
435
+
436
+ const { ErrorTest } = Create('ErrorTest', {}, async (ctx) => {
437
+ throw ctx.error('Custom error message', { code: 'ERR_001' })
438
+ })
439
+
440
+ try {
441
+ await ErrorTest({}, {})
442
+ } catch (e: any) {
443
+ expect(e).toBeInstanceOf(ProcedureError)
444
+ expect(e.message).toBe('Custom error message')
445
+ expect(e.procedureName).toBe('ErrorTest')
446
+ expect(e.meta).toEqual({ code: 'ERR_001' })
447
+ }
448
+ })
366
449
  })
package/src/index.ts CHANGED
@@ -68,8 +68,18 @@ export function Procedures<TContext = TNoContextProvided, TExtendedConfig = unkn
68
68
  params: TSchemaLib<TParams>
69
69
  ) => Promise<TSchemaLib<TReturnType>>
70
70
  ) {
71
+ // BEFORE computeSchema - fail fast on duplicate
72
+ if (procedures.has(name)) {
73
+ throw new Error(`Procedure with name ${name} is already registered`)
74
+ }
75
+
71
76
  const { jsonSchema, validations } = computeSchema(name, config.schema)
72
77
 
78
+ // Create error factory once at registration time (outside handler)
79
+ const errorFactory = (message: string, meta?: object) => {
80
+ return new ProcedureError(name, message, meta)
81
+ }
82
+
73
83
  const registeredProcedure = {
74
84
  name,
75
85
  config: {
@@ -97,12 +107,10 @@ export function Procedures<TContext = TNoContextProvided, TExtendedConfig = unkn
97
107
  }
98
108
 
99
109
  const localCtx: TLocalContext = {
100
- error: (message: string, meta?: object) => {
101
- return new ProcedureError(name, message, meta)
102
- },
110
+ error: errorFactory, // Reuse pre-created factory
103
111
  }
104
112
 
105
- return handler(
113
+ return await handler(
106
114
  {
107
115
  ...ctx,
108
116
  ...localCtx,
@@ -114,6 +122,7 @@ export function Procedures<TContext = TNoContextProvided, TExtendedConfig = unkn
114
122
  throw error
115
123
  } else {
116
124
  const err = new ProcedureError(name, `Error in handler for ${name} - ${error?.message}`)
125
+ err.cause = error // Preserve original error
117
126
  err.stack = error.stack
118
127
  throw err
119
128
  }
@@ -121,10 +130,6 @@ export function Procedures<TContext = TNoContextProvided, TExtendedConfig = unkn
121
130
  },
122
131
  }
123
132
 
124
- if (procedures.has(name)) {
125
- throw new Error(`Procedure with name ${name} is already registered`)
126
- }
127
-
128
133
  procedures.set(name, registeredProcedure)
129
134
 
130
135
  if (builder?.onCreate) {
@@ -167,6 +172,27 @@ export function Procedures<TContext = TNoContextProvided, TExtendedConfig = unkn
167
172
  return Array.from(procedures.values())
168
173
  },
169
174
 
175
+ /**
176
+ * Get a specific procedure by name
177
+ */
178
+ getProcedure: (name: string) => {
179
+ return procedures.get(name)
180
+ },
181
+
182
+ /**
183
+ * Remove a procedure by name
184
+ */
185
+ removeProcedure: (name: string) => {
186
+ return procedures.delete(name)
187
+ },
188
+
189
+ /**
190
+ * Clear all registered procedures
191
+ */
192
+ clear: () => {
193
+ procedures.clear()
194
+ },
195
+
170
196
  Create,
171
197
  }
172
198
  }
@@ -153,4 +153,30 @@ describe('schemaParser', () => {
153
153
  ).toMatchObject({})
154
154
  expect(schema.validation.params?.({}).errors?.[0]?.message).toMatch(/must have required property 'a'/)
155
155
  })
156
+
157
+ test('validation returns error if validator fails to initialize', async () => {
158
+ // Create a schema that will pass extraction but creates a validation function
159
+ // that handles the uninitialized case gracefully
160
+ let parseErrorCalled = false
161
+
162
+ const result = schemaParser(
163
+ {
164
+ // Using a valid schema to get through extraction, but we'll test the guard
165
+ params: Type.Object({ name: Type.String() }),
166
+ },
167
+ () => {
168
+ parseErrorCalled = true
169
+ },
170
+ )
171
+
172
+ // The validator should be initialized for valid schemas
173
+ expect(result.validation.params).toBeDefined()
174
+
175
+ // Test that the validation function works correctly
176
+ const validResult = result.validation.params?.({ name: 'test' })
177
+ expect(validResult?.errors).toBeUndefined()
178
+
179
+ const invalidResult = result.validation.params?.({})
180
+ expect(invalidResult?.errors).toBeDefined()
181
+ })
156
182
  })
@@ -45,7 +45,7 @@ export function schemaParser(
45
45
  params: `Error extracting json schema schema.params - schema.params might be empty or it is not a valid suretype or typebox type`,
46
46
  })
47
47
  } else {
48
- let paramsValidator: AJV.ValidateFunction
48
+ let paramsValidator: AJV.ValidateFunction | undefined
49
49
 
50
50
  try {
51
51
  paramsValidator = ajv.compile(jsonSchema.params as TJSONSchema)
@@ -56,6 +56,10 @@ export function schemaParser(
56
56
  }
57
57
 
58
58
  validation.params = (params: any) => {
59
+ if (!paramsValidator) {
60
+ return { errors: [{ message: 'Validator not initialized', keyword: 'internal' } as TSchemaValidationError] }
61
+ }
62
+
59
63
  const valid = paramsValidator(params)
60
64
 
61
65
  if (!valid) {