ts-procedures 2.1.1 → 3.0.1

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 +10 -5
  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 +151 -242
  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,847 @@
1
+ import { describe, expect, test, vi, beforeEach } from 'vitest'
2
+ import { Hono } from 'hono'
3
+ import { v } from 'suretype'
4
+ import { Procedures } from '../../../index.js'
5
+ import { HonoRPCAppBuilder } from './index.js'
6
+ import { RPCConfig } from '../../types.js'
7
+
8
+ /**
9
+ * HonoRPCAppBuilder Test Suite
10
+ *
11
+ * Tests the RPC-style Hono integration for ts-procedures.
12
+ * This builder creates POST routes at `/{name}/{version}` paths (with optional pathPrefix).
13
+ */
14
+ describe('HonoRPCAppBuilder', () => {
15
+ // --------------------------------------------------------------------------
16
+ // Constructor Tests
17
+ // --------------------------------------------------------------------------
18
+ describe('constructor', () => {
19
+ test('creates default Hono app', async () => {
20
+ const builder = new HonoRPCAppBuilder()
21
+ const RPC = Procedures<{ userId: string }, RPCConfig>()
22
+
23
+ RPC.Create(
24
+ 'Echo',
25
+ { scope: 'echo', version: 1, schema: { params: v.object({ message: v.string() }) } },
26
+ async (ctx, params) => params
27
+ )
28
+
29
+ builder.register(RPC, () => ({ userId: '123' }))
30
+ const app = builder.build()
31
+
32
+ // Hono has built-in JSON parsing via c.req.json()
33
+ const res = await app.request('/echo/echo/1', {
34
+ method: 'POST',
35
+ headers: { 'Content-Type': 'application/json' },
36
+ body: JSON.stringify({ message: 'hello' }),
37
+ })
38
+
39
+ expect(res.status).toBe(200)
40
+ const body = await res.json()
41
+ expect(body).toEqual({ message: 'hello' })
42
+ })
43
+
44
+ test('uses provided Hono app', async () => {
45
+ const customApp = new Hono()
46
+ // Add a custom route to verify it's the same app
47
+ customApp.get('/custom', (c) => c.json({ custom: true }))
48
+
49
+ const builder = new HonoRPCAppBuilder({ app: customApp })
50
+ const RPC = Procedures<{ userId: string }, RPCConfig>()
51
+
52
+ RPC.Create(
53
+ 'Echo',
54
+ { scope: 'echo', version: 1, schema: { params: v.object({ message: v.string() }) } },
55
+ async (ctx, params) => ({ received: params })
56
+ )
57
+
58
+ builder.register(RPC, () => ({ userId: '123' }))
59
+ const app = builder.build()
60
+
61
+ // Custom route should still work
62
+ const customRes = await app.request('/custom')
63
+ expect(customRes.status).toBe(200)
64
+ const customBody = await customRes.json()
65
+ expect(customBody).toEqual({ custom: true })
66
+
67
+ // RPC route should also work
68
+ const rpcRes = await app.request('/echo/echo/1', {
69
+ method: 'POST',
70
+ headers: { 'Content-Type': 'application/json' },
71
+ body: JSON.stringify({ message: 'hello' }),
72
+ })
73
+ expect(rpcRes.status).toBe(200)
74
+ })
75
+
76
+ test('handles empty config', () => {
77
+ const builder = new HonoRPCAppBuilder({})
78
+ expect(builder.app).toBeDefined()
79
+ expect(builder.docs).toEqual([])
80
+ })
81
+
82
+ test('handles undefined config', () => {
83
+ const builder = new HonoRPCAppBuilder(undefined)
84
+ expect(builder.app).toBeDefined()
85
+ expect(builder.docs).toEqual([])
86
+ })
87
+ })
88
+
89
+ // --------------------------------------------------------------------------
90
+ // pathPrefix Option Tests
91
+ // --------------------------------------------------------------------------
92
+ describe('pathPrefix option', () => {
93
+ test('uses custom pathPrefix for all routes', async () => {
94
+ const builder = new HonoRPCAppBuilder({ pathPrefix: '/api/v1' })
95
+ const RPC = Procedures<{}, RPCConfig>()
96
+
97
+ RPC.Create('Test', { scope: 'test', version: 1 }, async () => ({ ok: true }))
98
+
99
+ builder.register(RPC, () => ({}))
100
+ const app = builder.build()
101
+
102
+ const res = await app.request('/api/v1/test/test/1', {
103
+ method: 'POST',
104
+ headers: { 'Content-Type': 'application/json' },
105
+ body: JSON.stringify({}),
106
+ })
107
+ expect(res.status).toBe(200)
108
+ const body = await res.json()
109
+ expect(body).toEqual({ ok: true })
110
+ })
111
+
112
+ test('pathPrefix without leading slash gets normalized', async () => {
113
+ const builder = new HonoRPCAppBuilder({ pathPrefix: 'custom' })
114
+ const RPC = Procedures<{}, RPCConfig>()
115
+
116
+ RPC.Create('Test', { scope: 'test', version: 1 }, async () => ({ ok: true }))
117
+
118
+ builder.register(RPC, () => ({}))
119
+ const app = builder.build()
120
+
121
+ const res = await app.request('/custom/test/test/1', {
122
+ method: 'POST',
123
+ headers: { 'Content-Type': 'application/json' },
124
+ body: JSON.stringify({}),
125
+ })
126
+ expect(res.status).toBe(200)
127
+ })
128
+
129
+ test('no prefix when pathPrefix not specified', async () => {
130
+ const builder = new HonoRPCAppBuilder()
131
+ const RPC = Procedures<{}, RPCConfig>()
132
+
133
+ RPC.Create('Test', { scope: 'test', version: 1 }, async () => ({ ok: true }))
134
+
135
+ builder.register(RPC, () => ({}))
136
+ const app = builder.build()
137
+
138
+ const res = await app.request('/test/test/1', {
139
+ method: 'POST',
140
+ headers: { 'Content-Type': 'application/json' },
141
+ body: JSON.stringify({}),
142
+ })
143
+ expect(res.status).toBe(200)
144
+ })
145
+
146
+ test('pathPrefix appears in generated docs', () => {
147
+ const builder = new HonoRPCAppBuilder({ pathPrefix: '/api' })
148
+ const RPC = Procedures<{}, RPCConfig>()
149
+
150
+ RPC.Create('Test', { scope: 'test', version: 1 }, async () => ({}))
151
+
152
+ builder.register(RPC, () => ({}))
153
+ builder.build()
154
+
155
+ expect(builder.docs[0]!.path).toBe('/api/test/test/1')
156
+ })
157
+
158
+ test('pathPrefix /rpc restores original behavior', async () => {
159
+ const builder = new HonoRPCAppBuilder({ pathPrefix: '/rpc' })
160
+ const RPC = Procedures<{}, RPCConfig>()
161
+
162
+ RPC.Create('Users', { scope: 'users', version: 1 }, async () => ({ users: [] }))
163
+
164
+ builder.register(RPC, () => ({}))
165
+ const app = builder.build()
166
+
167
+ const res = await app.request('/rpc/users/users/1', {
168
+ method: 'POST',
169
+ headers: { 'Content-Type': 'application/json' },
170
+ body: JSON.stringify({}),
171
+ })
172
+ expect(res.status).toBe(200)
173
+ expect(builder.docs[0]!.path).toBe('/rpc/users/users/1')
174
+ })
175
+ })
176
+
177
+ // --------------------------------------------------------------------------
178
+ // Lifecycle Hooks Tests
179
+ // --------------------------------------------------------------------------
180
+ describe('lifecycle hooks', () => {
181
+ test('onRequestStart is called with context object', async () => {
182
+ const onRequestStart = vi.fn()
183
+ const builder = new HonoRPCAppBuilder({ onRequestStart })
184
+ const RPC = Procedures<{}, RPCConfig>()
185
+
186
+ RPC.Create('Test', { scope: 'test', version: 1 }, async () => ({ ok: true }))
187
+
188
+ builder.register(RPC, () => ({}))
189
+ const app = builder.build()
190
+
191
+ await app.request('/test/test/1', {
192
+ method: 'POST',
193
+ headers: { 'Content-Type': 'application/json' },
194
+ body: JSON.stringify({}),
195
+ })
196
+
197
+ expect(onRequestStart).toHaveBeenCalledTimes(1)
198
+ expect(onRequestStart.mock.calls[0]![0]).toHaveProperty('req')
199
+ })
200
+
201
+ test('onRequestEnd is called after response', async () => {
202
+ const onRequestEnd = vi.fn()
203
+ const builder = new HonoRPCAppBuilder({ onRequestEnd })
204
+ const RPC = Procedures<{}, RPCConfig>()
205
+
206
+ RPC.Create('Test', { scope: 'test', version: 1 }, async () => ({ ok: true }))
207
+
208
+ builder.register(RPC, () => ({}))
209
+ const app = builder.build()
210
+
211
+ await app.request('/test/test/1', {
212
+ method: 'POST',
213
+ headers: { 'Content-Type': 'application/json' },
214
+ body: JSON.stringify({}),
215
+ })
216
+
217
+ expect(onRequestEnd).toHaveBeenCalledTimes(1)
218
+ expect(onRequestEnd.mock.calls[0]![0]).toHaveProperty('req')
219
+ })
220
+
221
+ test('onSuccess is called on successful procedure execution', async () => {
222
+ const onSuccess = vi.fn()
223
+ const builder = new HonoRPCAppBuilder({ onSuccess })
224
+ const RPC = Procedures<{}, RPCConfig>()
225
+
226
+ RPC.Create('Test', { scope: 'test', version: 1 }, async () => ({ ok: true }))
227
+
228
+ builder.register(RPC, () => ({}))
229
+ const app = builder.build()
230
+
231
+ await app.request('/test/test/1', {
232
+ method: 'POST',
233
+ headers: { 'Content-Type': 'application/json' },
234
+ body: JSON.stringify({}),
235
+ })
236
+
237
+ expect(onSuccess).toHaveBeenCalledTimes(1)
238
+ expect(onSuccess.mock.calls[0]![0]).toHaveProperty('name', 'Test')
239
+ })
240
+
241
+ test('onSuccess is NOT called when procedure throws', async () => {
242
+ const onSuccess = vi.fn()
243
+ const builder = new HonoRPCAppBuilder({ onSuccess })
244
+ const RPC = Procedures<{}, RPCConfig>()
245
+
246
+ RPC.Create('Test', { scope: 'test', version: 1 }, async () => {
247
+ throw new Error('Handler error')
248
+ })
249
+
250
+ builder.register(RPC, () => ({}))
251
+ const app = builder.build()
252
+
253
+ await app.request('/test/test/1', {
254
+ method: 'POST',
255
+ headers: { 'Content-Type': 'application/json' },
256
+ body: JSON.stringify({}),
257
+ })
258
+
259
+ expect(onSuccess).not.toHaveBeenCalled()
260
+ })
261
+
262
+ test('hooks execute in correct order: start → handler → success → end', async () => {
263
+ const order: string[] = []
264
+
265
+ const builder = new HonoRPCAppBuilder({
266
+ onRequestStart: () => order.push('start'),
267
+ onRequestEnd: () => order.push('end'),
268
+ onSuccess: () => order.push('success'),
269
+ })
270
+ const RPC = Procedures<{}, RPCConfig>()
271
+
272
+ RPC.Create('Test', { scope: 'test', version: 1 }, async () => {
273
+ order.push('handler')
274
+ return { ok: true }
275
+ })
276
+
277
+ builder.register(RPC, () => ({}))
278
+ const app = builder.build()
279
+
280
+ await app.request('/test/test/1', {
281
+ method: 'POST',
282
+ headers: { 'Content-Type': 'application/json' },
283
+ body: JSON.stringify({}),
284
+ })
285
+
286
+ expect(order).toEqual(['start', 'handler', 'success', 'end'])
287
+ })
288
+ })
289
+
290
+ // --------------------------------------------------------------------------
291
+ // Error Handling Tests
292
+ // --------------------------------------------------------------------------
293
+ describe('error handling', () => {
294
+ test('custom error handler receives procedure, context, and error', async () => {
295
+ const errorHandler = vi.fn((procedure, c, error) => {
296
+ return c.json({ customError: error.message }, 400)
297
+ })
298
+
299
+ const builder = new HonoRPCAppBuilder({ error: errorHandler })
300
+ const RPC = Procedures<{}, RPCConfig>()
301
+
302
+ RPC.Create('Test', { scope: 'test', version: 1 }, async () => {
303
+ throw new Error('Test error')
304
+ })
305
+
306
+ builder.register(RPC, () => ({}))
307
+ const app = builder.build()
308
+
309
+ const res = await app.request('/test/test/1', {
310
+ method: 'POST',
311
+ headers: { 'Content-Type': 'application/json' },
312
+ body: JSON.stringify({}),
313
+ })
314
+
315
+ expect(errorHandler).toHaveBeenCalledTimes(1)
316
+ expect(errorHandler.mock.calls[0]![0]).toHaveProperty('name', 'Test')
317
+ expect(errorHandler.mock.calls[0]![2]).toBeInstanceOf(Error)
318
+ expect(res.status).toBe(400)
319
+ const body = await res.json()
320
+ // Error is wrapped by Procedures with "Error in handler for {name}" prefix
321
+ expect(body.customError).toContain('Test error')
322
+ })
323
+
324
+ test('default error handling returns error message in response', async () => {
325
+ const builder = new HonoRPCAppBuilder()
326
+ const RPC = Procedures<{}, RPCConfig>()
327
+
328
+ RPC.Create('Test', { scope: 'test', version: 1 }, async () => {
329
+ throw new Error('Something went wrong')
330
+ })
331
+
332
+ builder.register(RPC, () => ({}))
333
+ const app = builder.build()
334
+
335
+ const res = await app.request('/test/test/1', {
336
+ method: 'POST',
337
+ headers: { 'Content-Type': 'application/json' },
338
+ body: JSON.stringify({}),
339
+ })
340
+
341
+ expect(res.status).toBe(500)
342
+ const body = await res.json()
343
+ // Default error handler returns error message in JSON body
344
+ expect(body).toHaveProperty('error')
345
+ expect(body.error).toContain('Something went wrong')
346
+ })
347
+
348
+ test('catches unhandled exceptions in handler', async () => {
349
+ const builder = new HonoRPCAppBuilder()
350
+ const RPC = Procedures<{}, RPCConfig>()
351
+
352
+ RPC.Create('Test', { scope: 'test', version: 1 }, async () => {
353
+ // Simulate unhandled exception
354
+ const obj: any = null
355
+ return obj.property // This will throw
356
+ })
357
+
358
+ builder.register(RPC, () => ({}))
359
+ const app = builder.build()
360
+
361
+ const res = await app.request('/test/test/1', {
362
+ method: 'POST',
363
+ headers: { 'Content-Type': 'application/json' },
364
+ body: JSON.stringify({}),
365
+ })
366
+
367
+ // Unhandled exceptions are caught and returned as error response
368
+ const body = await res.json()
369
+ expect(body).toHaveProperty('error')
370
+ })
371
+ })
372
+
373
+ // --------------------------------------------------------------------------
374
+ // register() Method Tests
375
+ // --------------------------------------------------------------------------
376
+ describe('register() method', () => {
377
+ test('returns this for method chaining', () => {
378
+ const builder = new HonoRPCAppBuilder()
379
+ const RPC1 = Procedures<{}, RPCConfig>()
380
+ const RPC2 = Procedures<{}, RPCConfig>()
381
+
382
+ const result = builder.register(RPC1, () => ({}))
383
+ expect(result).toBe(builder)
384
+
385
+ // Chain multiple registrations
386
+ const chainResult = builder.register(RPC1, () => ({})).register(RPC2, () => ({}))
387
+
388
+ expect(chainResult).toBe(builder)
389
+ })
390
+
391
+ test('supports registering multiple factories', async () => {
392
+ const builder = new HonoRPCAppBuilder()
393
+
394
+ const PublicRPC = Procedures<{ public: true }, RPCConfig>()
395
+ const PrivateRPC = Procedures<{ private: true }, RPCConfig>()
396
+
397
+ PublicRPC.Create('PublicMethod', { scope: 'public', version: 1 }, async (ctx) => ({
398
+ isPublic: ctx.public,
399
+ }))
400
+
401
+ PrivateRPC.Create('PrivateMethod', { scope: 'private', version: 1 }, async (ctx) => ({
402
+ isPrivate: ctx.private,
403
+ }))
404
+
405
+ builder
406
+ .register(PublicRPC, () => ({ public: true as const }))
407
+ .register(PrivateRPC, () => ({ private: true as const }))
408
+
409
+ const app = builder.build()
410
+
411
+ const publicRes = await app.request('/public/public-method/1', {
412
+ method: 'POST',
413
+ headers: { 'Content-Type': 'application/json' },
414
+ body: JSON.stringify({}),
415
+ })
416
+ const privateRes = await app.request('/private/private-method/1', {
417
+ method: 'POST',
418
+ headers: { 'Content-Type': 'application/json' },
419
+ body: JSON.stringify({}),
420
+ })
421
+
422
+ const publicBody = await publicRes.json()
423
+ const privateBody = await privateRes.json()
424
+
425
+ expect(publicBody).toEqual({ isPublic: true })
426
+ expect(privateBody).toEqual({ isPrivate: true })
427
+ })
428
+
429
+ test('context can be a static object', async () => {
430
+ const factoryContext = { requestId: 'req-123' }
431
+
432
+ const builder = new HonoRPCAppBuilder()
433
+ const RPC = Procedures<{ requestId: string }, RPCConfig>()
434
+
435
+ RPC.Create('GetRequestId', { scope: 'get-request-id', version: 1 }, async (ctx) => ({
436
+ id: ctx.requestId,
437
+ }))
438
+
439
+ builder.register(RPC, factoryContext)
440
+ const app = builder.build()
441
+
442
+ const res = await app.request('/get-request-id/get-request-id/1', {
443
+ method: 'POST',
444
+ headers: { 'Content-Type': 'application/json' },
445
+ body: JSON.stringify({}),
446
+ })
447
+
448
+ const body = await res.json()
449
+ expect(body).toEqual({ id: 'req-123' })
450
+ })
451
+
452
+ test('factoryContext can be async function', async () => {
453
+ const factoryContext = vi.fn(async () => {
454
+ return { requestId: 'req-456' }
455
+ })
456
+
457
+ const builder = new HonoRPCAppBuilder()
458
+ const RPC = Procedures<{ requestId: string }, RPCConfig>()
459
+
460
+ RPC.Create('GetRequestId', { scope: 'get-request-id', version: 1 }, async (ctx) => ({
461
+ id: ctx.requestId,
462
+ }))
463
+
464
+ builder.register(RPC, factoryContext)
465
+ const app = builder.build()
466
+
467
+ await app.request('/get-request-id/get-request-id/1', {
468
+ method: 'POST',
469
+ headers: { 'Content-Type': 'application/json' },
470
+ body: JSON.stringify({}),
471
+ })
472
+
473
+ expect(factoryContext).toHaveBeenCalledTimes(1)
474
+ })
475
+
476
+ test('factoryContext function receives Hono context object', async () => {
477
+ const factoryContext = vi.fn((c) => ({
478
+ authHeader: c.req.header('authorization'),
479
+ }))
480
+
481
+ const builder = new HonoRPCAppBuilder()
482
+ const RPC = Procedures<{ authHeader?: string }, RPCConfig>()
483
+
484
+ RPC.Create('GetAuth', { scope: 'get-auth', version: 1 }, async (ctx) => ({
485
+ auth: ctx.authHeader,
486
+ }))
487
+
488
+ builder.register(RPC, factoryContext)
489
+ const app = builder.build()
490
+
491
+ const res = await app.request('/get-auth/get-auth/1', {
492
+ method: 'POST',
493
+ headers: {
494
+ 'Content-Type': 'application/json',
495
+ Authorization: 'Bearer token123',
496
+ },
497
+ body: JSON.stringify({}),
498
+ })
499
+
500
+ expect(factoryContext).toHaveBeenCalledTimes(1)
501
+ expect(factoryContext.mock.calls[0]![0]).toHaveProperty('req')
502
+ const body = await res.json()
503
+ expect(body).toEqual({ auth: 'Bearer token123' })
504
+ })
505
+ })
506
+
507
+ // --------------------------------------------------------------------------
508
+ // build() Method Tests
509
+ // --------------------------------------------------------------------------
510
+ describe('build() method', () => {
511
+ test('creates POST routes for all procedures', async () => {
512
+ const builder = new HonoRPCAppBuilder()
513
+ const RPC = Procedures<{}, RPCConfig>()
514
+
515
+ RPC.Create('MethodOne', { scope: 'method-one', version: 1 }, async () => ({ m: 1 }))
516
+ RPC.Create('MethodTwo', { scope: 'method-two', version: 2 }, async () => ({ m: 2 }))
517
+
518
+ builder.register(RPC, () => ({}))
519
+ const app = builder.build()
520
+
521
+ const res1 = await app.request('/method-one/method-one/1', {
522
+ method: 'POST',
523
+ headers: { 'Content-Type': 'application/json' },
524
+ body: JSON.stringify({}),
525
+ })
526
+ const res2 = await app.request('/method-two/method-two/2', {
527
+ method: 'POST',
528
+ headers: { 'Content-Type': 'application/json' },
529
+ body: JSON.stringify({}),
530
+ })
531
+
532
+ expect(res1.status).toBe(200)
533
+ expect(res2.status).toBe(200)
534
+ const body1 = await res1.json()
535
+ const body2 = await res2.json()
536
+ expect(body1).toEqual({ m: 1 })
537
+ expect(body2).toEqual({ m: 2 })
538
+ })
539
+
540
+ test('returns the Hono application', () => {
541
+ const builder = new HonoRPCAppBuilder()
542
+ const app = builder.build()
543
+
544
+ expect(app).toBe(builder.app)
545
+ expect(typeof app.request).toBe('function')
546
+ })
547
+
548
+ test('populates docs array after build', () => {
549
+ const builder = new HonoRPCAppBuilder()
550
+ const RPC = Procedures<{}, RPCConfig>()
551
+
552
+ RPC.Create('MethodOne', { scope: 'method-one', version: 1 }, async () => ({}))
553
+ RPC.Create('MethodTwo', { scope: ['nested', 'method'], version: 2 }, async () => ({}))
554
+
555
+ expect(builder.docs).toHaveLength(0)
556
+
557
+ builder.register(RPC, () => ({}))
558
+ builder.build()
559
+
560
+ expect(builder.docs).toHaveLength(2)
561
+ expect(builder.docs[0]!.path).toBe('/method-one/method-one/1')
562
+ expect(builder.docs[1]!.path).toBe('/nested/method/method-two/2')
563
+ })
564
+
565
+ test('passes request body to handler as params', async () => {
566
+ const builder = new HonoRPCAppBuilder()
567
+ const RPC = Procedures<{}, RPCConfig>()
568
+
569
+ RPC.Create(
570
+ 'Echo',
571
+ { scope: 'echo', version: 1, schema: { params: v.object({ data: v.string() }) } },
572
+ async (ctx, params) => ({ received: params.data })
573
+ )
574
+
575
+ builder.register(RPC, () => ({}))
576
+ const app = builder.build()
577
+
578
+ const res = await app.request('/echo/echo/1', {
579
+ method: 'POST',
580
+ headers: { 'Content-Type': 'application/json' },
581
+ body: JSON.stringify({ data: 'test-data' }),
582
+ })
583
+
584
+ const body = await res.json()
585
+ expect(body).toEqual({ received: 'test-data' })
586
+ })
587
+
588
+ test('GET requests return 404 (RPC uses POST only)', async () => {
589
+ const builder = new HonoRPCAppBuilder()
590
+ const RPC = Procedures<{}, RPCConfig>()
591
+
592
+ RPC.Create('Test', { scope: 'test', version: 1 }, async () => ({ ok: true }))
593
+
594
+ builder.register(RPC, () => ({}))
595
+ const app = builder.build()
596
+
597
+ const res = await app.request('/test/test/1')
598
+
599
+ expect(res.status).toBe(404)
600
+ })
601
+ })
602
+
603
+ // --------------------------------------------------------------------------
604
+ // Path Generation Tests (makeRPCHttpRoutePath)
605
+ // --------------------------------------------------------------------------
606
+ describe('makeRPCHttpRoutePath', () => {
607
+ let builder: HonoRPCAppBuilder
608
+
609
+ beforeEach(() => {
610
+ builder = new HonoRPCAppBuilder()
611
+ })
612
+
613
+ test("simple scope with procedure name: 'users' + 'GetUser' → /users/get-user/1", () => {
614
+ const path = builder.makeRPCHttpRoutePath('GetUser', { scope: 'users', version: 1 })
615
+ expect(path).toBe('/users/get-user/1')
616
+ })
617
+
618
+ test("array scope with procedure name: ['users', 'profile'] + 'GetById' → /users/profile/get-by-id/1", () => {
619
+ const path = builder.makeRPCHttpRoutePath('GetById', { scope: ['users', 'profile'], version: 1 })
620
+ expect(path).toBe('/users/profile/get-by-id/1')
621
+ })
622
+
623
+ test("camelCase procedure name: 'users' + 'getProfile' → /users/get-profile/1", () => {
624
+ const path = builder.makeRPCHttpRoutePath('getProfile', { scope: 'users', version: 1 })
625
+ expect(path).toBe('/users/get-profile/1')
626
+ })
627
+
628
+ test("PascalCase procedure name: 'users' + 'UpdateProfile' → /users/update-profile/1", () => {
629
+ const path = builder.makeRPCHttpRoutePath('UpdateProfile', { scope: 'users', version: 1 })
630
+ expect(path).toBe('/users/update-profile/1')
631
+ })
632
+
633
+ test('version number included in path', () => {
634
+ const pathV1 = builder.makeRPCHttpRoutePath('Test', { scope: 'test', version: 1 })
635
+ const pathV2 = builder.makeRPCHttpRoutePath('Test', { scope: 'test', version: 2 })
636
+ const pathV99 = builder.makeRPCHttpRoutePath('Test', { scope: 'test', version: 99 })
637
+
638
+ expect(pathV1).toBe('/test/test/1')
639
+ expect(pathV2).toBe('/test/test/2')
640
+ expect(pathV99).toBe('/test/test/99')
641
+ })
642
+
643
+ test('handles mixed case in array segments', () => {
644
+ const path = builder.makeRPCHttpRoutePath('ListUsers', {
645
+ scope: ['UserModule', 'getActiveUsers'],
646
+ version: 1,
647
+ })
648
+ expect(path).toBe('/user-module/get-active-users/list-users/1')
649
+ })
650
+ })
651
+
652
+ // --------------------------------------------------------------------------
653
+ // Route Documentation Tests (buildRpcHttpRouteDoc)
654
+ // --------------------------------------------------------------------------
655
+ describe('buildRpcHttpRouteDoc', () => {
656
+ let builder: HonoRPCAppBuilder
657
+
658
+ beforeEach(() => {
659
+ builder = new HonoRPCAppBuilder()
660
+ })
661
+
662
+ test('generates complete route documentation', () => {
663
+ const paramsSchema = v.object({ id: v.string() })
664
+ const returnSchema = v.object({ name: v.string() })
665
+
666
+ const RPC = Procedures<{}, RPCConfig>()
667
+ RPC.Create(
668
+ 'GetUser',
669
+ { scope: 'users', version: 1, schema: { params: paramsSchema, returnType: returnSchema } },
670
+ async () => ({ name: 'test' })
671
+ )
672
+
673
+ builder.register(RPC, () => ({}))
674
+ builder.build()
675
+
676
+ const doc = builder.docs[0]!
677
+ expect(doc.path).toBe('/users/get-user/1')
678
+ expect(doc.method).toBe('post')
679
+ expect(doc.jsonSchema.body).toBeDefined()
680
+ expect(doc.jsonSchema.response).toBeDefined()
681
+ })
682
+
683
+ test('omits body schema when no params defined', () => {
684
+ const RPC = Procedures<{}, RPCConfig>()
685
+ RPC.Create('NoParams', { scope: 'no-params', version: 1 }, async () => ({ ok: true }))
686
+
687
+ builder.register(RPC, () => ({}))
688
+ builder.build()
689
+
690
+ const doc = builder.docs[0]!
691
+ expect(doc.jsonSchema.body).toBeUndefined()
692
+ })
693
+
694
+ test('omits response schema when no returnType defined', () => {
695
+ const RPC = Procedures<{}, RPCConfig>()
696
+ RPC.Create(
697
+ 'NoReturn',
698
+ { scope: 'no-return', version: 1, schema: { params: v.object({ x: v.number() }) } },
699
+ async () => ({})
700
+ )
701
+
702
+ builder.register(RPC, () => ({}))
703
+ builder.build()
704
+
705
+ const doc = builder.docs[0]!
706
+ expect(doc.jsonSchema.body).toBeDefined()
707
+ expect(doc.jsonSchema.response).toBeUndefined()
708
+ })
709
+
710
+ test("method is always 'post'", () => {
711
+ const RPC = Procedures<{}, RPCConfig>()
712
+ RPC.Create('Test1', { scope: 't1', version: 1 }, async () => ({}))
713
+ RPC.Create('Test2', { scope: 't2', version: 2 }, async () => ({}))
714
+
715
+ builder.register(RPC, () => ({}))
716
+ builder.build()
717
+
718
+ builder.docs.forEach((doc) => {
719
+ expect(doc.method).toBe('post')
720
+ })
721
+ })
722
+ })
723
+
724
+ // --------------------------------------------------------------------------
725
+ // Integration Test
726
+ // --------------------------------------------------------------------------
727
+ describe('integration', () => {
728
+ test('full workflow with multiple procedure factories and different contexts', async () => {
729
+ // Define context types
730
+ type PublicContext = { source: 'public' }
731
+ type AuthContext = { source: 'auth'; userId: string }
732
+
733
+ // Create factories
734
+ const PublicRPC = Procedures<PublicContext, RPCConfig>()
735
+ const AuthRPC = Procedures<AuthContext, RPCConfig>()
736
+
737
+ // Create public procedures
738
+ PublicRPC.Create('GetVersion', { scope: ['system', 'version'], version: 1 }, async () => ({
739
+ version: '1.0.0',
740
+ }))
741
+
742
+ PublicRPC.Create('HealthCheck', { scope: 'health', version: 1 }, async () => ({
743
+ status: 'ok',
744
+ }))
745
+
746
+ // Create authenticated procedures
747
+ AuthRPC.Create(
748
+ 'GetProfile',
749
+ {
750
+ scope: ['users', 'profile'],
751
+ version: 1,
752
+ schema: { returnType: v.object({ userId: v.string(), source: v.string() }) },
753
+ },
754
+ async (ctx) => ({ userId: ctx.userId, source: ctx.source })
755
+ )
756
+
757
+ AuthRPC.Create(
758
+ 'UpdateProfile',
759
+ {
760
+ scope: ['users', 'profile'],
761
+ version: 2,
762
+ schema: { params: v.object({ name: v.string() }) },
763
+ },
764
+ async (ctx, params) => ({ userId: ctx.userId, name: params.name })
765
+ )
766
+
767
+ // Build app with lifecycle hooks
768
+ const events: string[] = []
769
+
770
+ const builder = new HonoRPCAppBuilder({
771
+ onRequestStart: () => events.push('request-start'),
772
+ onRequestEnd: () => events.push('request-end'),
773
+ onSuccess: (proc) => events.push(`success:${proc.name}`),
774
+ })
775
+
776
+ builder
777
+ .register(PublicRPC, () => ({ source: 'public' as const }))
778
+ .register(AuthRPC, (c) => ({
779
+ source: 'auth' as const,
780
+ userId: c.req.header('x-user-id') || 'anonymous',
781
+ }))
782
+
783
+ const app = builder.build()
784
+
785
+ // Test public endpoints
786
+ const versionRes = await app.request('/system/version/get-version/1', {
787
+ method: 'POST',
788
+ headers: { 'Content-Type': 'application/json' },
789
+ body: JSON.stringify({}),
790
+ })
791
+ expect(versionRes.status).toBe(200)
792
+ const versionBody = await versionRes.json()
793
+ expect(versionBody).toEqual({ version: '1.0.0' })
794
+
795
+ const healthRes = await app.request('/health/health-check/1', {
796
+ method: 'POST',
797
+ headers: { 'Content-Type': 'application/json' },
798
+ body: JSON.stringify({}),
799
+ })
800
+ expect(healthRes.status).toBe(200)
801
+ const healthBody = await healthRes.json()
802
+ expect(healthBody).toEqual({ status: 'ok' })
803
+
804
+ // Test authenticated endpoints
805
+ const profileRes = await app.request('/users/profile/get-profile/1', {
806
+ method: 'POST',
807
+ headers: {
808
+ 'Content-Type': 'application/json',
809
+ 'X-User-Id': 'user-123',
810
+ },
811
+ body: JSON.stringify({}),
812
+ })
813
+ expect(profileRes.status).toBe(200)
814
+ const profileBody = await profileRes.json()
815
+ expect(profileBody).toEqual({ userId: 'user-123', source: 'auth' })
816
+
817
+ const updateRes = await app.request('/users/profile/update-profile/2', {
818
+ method: 'POST',
819
+ headers: {
820
+ 'Content-Type': 'application/json',
821
+ 'X-User-Id': 'user-456',
822
+ },
823
+ body: JSON.stringify({ name: 'John Doe' }),
824
+ })
825
+ expect(updateRes.status).toBe(200)
826
+ const updateBody = await updateRes.json()
827
+ expect(updateBody).toEqual({ userId: 'user-456', name: 'John Doe' })
828
+
829
+ // Verify documentation
830
+ expect(builder.docs).toHaveLength(4)
831
+
832
+ const paths = builder.docs.map((d) => d.path)
833
+ expect(paths).toContain('/system/version/get-version/1')
834
+ expect(paths).toContain('/health/health-check/1')
835
+ expect(paths).toContain('/users/profile/get-profile/1')
836
+ expect(paths).toContain('/users/profile/update-profile/2')
837
+
838
+ // Verify hooks were called
839
+ expect(events).toContain('request-start')
840
+ expect(events).toContain('success:GetVersion')
841
+ expect(events).toContain('success:HealthCheck')
842
+ expect(events).toContain('success:GetProfile')
843
+ expect(events).toContain('success:UpdateProfile')
844
+ expect(events).toContain('request-end')
845
+ })
846
+ })
847
+ })