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