ts-procedures 5.2.0 → 5.4.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 (49) hide show
  1. package/README.md +150 -0
  2. package/agent_config/bin/postinstall.mjs +105 -0
  3. package/agent_config/bin/setup.mjs +286 -0
  4. package/agent_config/claude-code/.claude-plugin/plugin.json +5 -0
  5. package/agent_config/claude-code/agents/ts-procedures-architect.md +188 -0
  6. package/agent_config/claude-code/skills/guide/SKILL.md +142 -0
  7. package/agent_config/claude-code/skills/guide/anti-patterns.md +608 -0
  8. package/agent_config/claude-code/skills/guide/api-reference.md +696 -0
  9. package/agent_config/claude-code/skills/guide/patterns.md +727 -0
  10. package/agent_config/claude-code/skills/review/SKILL.md +53 -0
  11. package/agent_config/claude-code/skills/review/checklist.md +163 -0
  12. package/agent_config/claude-code/skills/scaffold/SKILL.md +56 -0
  13. package/agent_config/claude-code/skills/scaffold/templates/express-rpc.md +134 -0
  14. package/agent_config/claude-code/skills/scaffold/templates/hono-api.md +169 -0
  15. package/agent_config/claude-code/skills/scaffold/templates/hono-rpc.md +139 -0
  16. package/agent_config/claude-code/skills/scaffold/templates/hono-stream.md +134 -0
  17. package/agent_config/claude-code/skills/scaffold/templates/procedure.md +77 -0
  18. package/agent_config/claude-code/skills/scaffold/templates/stream-procedure.md +113 -0
  19. package/agent_config/copilot/copilot-instructions.md +290 -0
  20. package/agent_config/cursor/cursorrules +290 -0
  21. package/agent_config/lib/install-claude.mjs +109 -0
  22. package/build/implementations/http/hono-api/index.d.ts +102 -0
  23. package/build/implementations/http/hono-api/index.js +339 -0
  24. package/build/implementations/http/hono-api/index.js.map +1 -0
  25. package/build/implementations/http/hono-api/index.test.d.ts +1 -0
  26. package/build/implementations/http/hono-api/index.test.js +983 -0
  27. package/build/implementations/http/hono-api/index.test.js.map +1 -0
  28. package/build/implementations/http/hono-api/types.d.ts +13 -0
  29. package/build/implementations/http/hono-api/types.js +2 -0
  30. package/build/implementations/http/hono-api/types.js.map +1 -0
  31. package/build/implementations/types.d.ts +44 -0
  32. package/build/index.d.ts +28 -6
  33. package/build/index.js +28 -0
  34. package/build/index.js.map +1 -1
  35. package/build/schema/compute-schema.d.ts +5 -0
  36. package/build/schema/compute-schema.js +8 -1
  37. package/build/schema/compute-schema.js.map +1 -1
  38. package/build/schema/parser.d.ts +6 -5
  39. package/build/schema/parser.js +54 -0
  40. package/build/schema/parser.js.map +1 -1
  41. package/package.json +14 -4
  42. package/src/implementations/http/README.md +45 -2
  43. package/src/implementations/http/hono-api/index.test.ts +1328 -0
  44. package/src/implementations/http/hono-api/index.ts +461 -0
  45. package/src/implementations/http/hono-api/types.ts +16 -0
  46. package/src/implementations/types.ts +52 -0
  47. package/src/index.ts +87 -10
  48. package/src/schema/compute-schema.ts +23 -2
  49. package/src/schema/parser.ts +70 -3
@@ -0,0 +1,727 @@
1
+ # ts-procedures Prescribed Patterns
2
+
3
+ Follow these patterns exactly when writing ts-procedures code.
4
+
5
+ > All examples use `import { Type } from 'typebox'` for schema definitions. This import is assumed unless shown explicitly.
6
+
7
+ ---
8
+
9
+ ## Basic Procedure Setup
10
+
11
+ ```typescript
12
+ import { Procedures } from 'ts-procedures'
13
+
14
+ // 1. Define context type
15
+ type AppContext = { userId: string; requestId: string }
16
+
17
+ // 2. Create factory (optionally with extended config)
18
+ const { Create, CreateStream, getProcedures } = Procedures<AppContext>()
19
+
20
+ // 3. Register procedures
21
+ const { GetUser, procedure, info } = Create(
22
+ 'GetUser',
23
+ { description: 'Fetch user by ID' },
24
+ async (ctx, params) => {
25
+ return { id: ctx.userId, name: 'John' }
26
+ }
27
+ )
28
+
29
+ // 4. Call procedure
30
+ const result = await GetUser({ userId: 'u1', requestId: 'r1' }, {})
31
+ // or: await procedure({ userId: 'u1', requestId: 'r1' }, {})
32
+ ```
33
+
34
+ ---
35
+
36
+ ## Schema Validation with TypeBox
37
+
38
+ ```typescript
39
+ import { Type } from 'typebox'
40
+
41
+ const { Create } = Procedures<AppContext>()
42
+
43
+ const { GetUser } = Create(
44
+ 'GetUser',
45
+ {
46
+ schema: {
47
+ params: Type.Object({
48
+ userId: Type.String(),
49
+ }),
50
+ returnType: Type.Object({
51
+ id: Type.String(),
52
+ name: Type.String(),
53
+ email: Type.String(),
54
+ }),
55
+ },
56
+ },
57
+ async (ctx, params) => {
58
+ // params is typed as { userId: string }
59
+ // params.userId is guaranteed to be a string (validated by AJV)
60
+ const user = await fetchUser(params.userId)
61
+ return user
62
+ }
63
+ )
64
+ ```
65
+
66
+ ---
67
+
68
+ ## Error Handling in Handlers
69
+
70
+ Use `ctx.error()` for business logic errors. Never throw raw Error instances.
71
+
72
+ ```typescript
73
+ const { TransferFunds } = Create(
74
+ 'TransferFunds',
75
+ {
76
+ schema: {
77
+ params: Type.Object({
78
+ fromAccountId: Type.String(),
79
+ toAccountId: Type.String(),
80
+ amount: Type.Number(),
81
+ }),
82
+ },
83
+ },
84
+ async (ctx, params) => {
85
+ const account = await getAccount(params.fromAccountId)
86
+
87
+ if (!account) {
88
+ throw ctx.error('Account not found', { code: 'NOT_FOUND', accountId: params.fromAccountId })
89
+ }
90
+
91
+ if (account.balance < params.amount) {
92
+ throw ctx.error('Insufficient funds', {
93
+ code: 'INSUFFICIENT_FUNDS',
94
+ balance: account.balance,
95
+ requested: params.amount,
96
+ })
97
+ }
98
+
99
+ return await executeTransfer(params)
100
+ }
101
+ )
102
+ ```
103
+
104
+ ---
105
+
106
+ ## Stream Procedures
107
+
108
+ ```typescript
109
+ const { StreamNotifications } = CreateStream(
110
+ 'StreamNotifications',
111
+ {
112
+ description: 'Real-time notification feed',
113
+ schema: {
114
+ params: Type.Object({
115
+ userId: Type.String(),
116
+ }),
117
+ yieldType: Type.Object({
118
+ id: Type.String(),
119
+ message: Type.String(),
120
+ timestamp: Type.Number(),
121
+ }),
122
+ },
123
+ },
124
+ async function* (ctx, params) {
125
+ // ctx.signal is always present in stream handlers
126
+ const subscription = subscribeToNotifications(params.userId)
127
+
128
+ try {
129
+ for await (const notification of subscription) {
130
+ if (ctx.signal.aborted) break
131
+ yield notification
132
+ }
133
+ } finally {
134
+ subscription.close()
135
+ }
136
+ }
137
+ )
138
+ ```
139
+
140
+ ---
141
+
142
+ ## AbortSignal in Stream Handlers
143
+
144
+ Always check `ctx.signal` for cancellation. Distinguish normal completion from client disconnect.
145
+
146
+ ```typescript
147
+ const { StreamData } = CreateStream(
148
+ 'StreamData',
149
+ {},
150
+ async function* (ctx) {
151
+ try {
152
+ while (!ctx.signal.aborted) {
153
+ const data = await fetchLatestData({ signal: ctx.signal })
154
+ yield data
155
+ await delay(1000)
156
+ }
157
+ } finally {
158
+ if (ctx.signal.reason === 'stream-completed') {
159
+ // Normal completion — consumer finished reading
160
+ console.log('Stream completed normally')
161
+ } else {
162
+ // Client disconnected or external abort
163
+ console.log('Stream aborted:', ctx.signal.reason)
164
+ }
165
+ }
166
+ }
167
+ )
168
+ ```
169
+
170
+ ---
171
+
172
+ ## AbortSignal in Standard Handlers
173
+
174
+ Pass the signal to downstream async calls for automatic cancellation.
175
+
176
+ ```typescript
177
+ const { LongOperation } = Create(
178
+ 'LongOperation',
179
+ {},
180
+ async (ctx, params) => {
181
+ // ctx.signal is available when HTTP implementation provides it
182
+ const data = await fetch('https://api.example.com/data', {
183
+ signal: ctx.signal,
184
+ })
185
+
186
+ const processed = await processData(data, { signal: ctx.signal })
187
+ return processed
188
+ }
189
+ )
190
+ ```
191
+
192
+ ---
193
+
194
+ ## Extended Config with RPCConfig
195
+
196
+ ```typescript
197
+ import { Procedures } from 'ts-procedures'
198
+ import { Type } from 'typebox'
199
+ import type { RPCConfig } from 'ts-procedures/http'
200
+
201
+ type AppContext = { userId: string }
202
+
203
+ const { Create } = Procedures<AppContext, RPCConfig>()
204
+
205
+ // Every procedure now MUST include scope and version
206
+ const { GetUser } = Create(
207
+ 'GetUser',
208
+ {
209
+ scope: 'users', // URL path segment
210
+ version: 1, // API version
211
+ description: 'Fetch user by ID',
212
+ schema: {
213
+ params: Type.Object({ userId: Type.String() }),
214
+ },
215
+ },
216
+ async (ctx, params) => {
217
+ return await fetchUser(params.userId)
218
+ }
219
+ )
220
+ // Route: POST /users/get-user/1
221
+ ```
222
+
223
+ ---
224
+
225
+ ## Custom Extended Config
226
+
227
+ ```typescript
228
+ interface AppConfig extends RPCConfig {
229
+ permissions?: string[]
230
+ deprecated?: boolean
231
+ }
232
+
233
+ const { Create, getProcedures } = Procedures<AppContext, AppConfig>()
234
+
235
+ const { AdminAction } = Create(
236
+ 'AdminAction',
237
+ {
238
+ scope: 'admin',
239
+ version: 1,
240
+ permissions: ['admin:write'],
241
+ description: 'Perform admin action',
242
+ },
243
+ async (ctx, params) => { /* ... */ }
244
+ )
245
+
246
+ // Use getProcedures() for introspection
247
+ for (const proc of getProcedures()) {
248
+ console.log(proc.name, proc.config.permissions)
249
+ }
250
+ ```
251
+
252
+ ---
253
+
254
+ ## Express RPC Integration
255
+
256
+ ```typescript
257
+ import { Procedures } from 'ts-procedures'
258
+ import { ExpressRPCAppBuilder } from 'ts-procedures/express-rpc'
259
+ import { Type } from 'typebox'
260
+ import type { RPCConfig } from 'ts-procedures/http'
261
+
262
+ // 1. Define context and procedures
263
+ type AppContext = { userId: string; requestId: string }
264
+ const { Create } = Procedures<AppContext, RPCConfig>()
265
+
266
+ const { GetUser } = Create(
267
+ 'GetUser',
268
+ { scope: 'users', version: 1, schema: { params: Type.Object({ id: Type.String() }) } },
269
+ async (ctx, params) => fetchUser(params.id)
270
+ )
271
+
272
+ const { UpdateUser } = Create(
273
+ 'UpdateUser',
274
+ { scope: 'users', version: 1 },
275
+ async (ctx, params) => updateUser(params)
276
+ )
277
+
278
+ // 2. Build Express app
279
+ const app = new ExpressRPCAppBuilder({
280
+ pathPrefix: '/api',
281
+ onError: (procedure, req, res, error) => {
282
+ if (error instanceof ProcedureValidationError) {
283
+ res.status(400).json({ error: error.message, details: error.errors })
284
+ } else {
285
+ res.status(500).json({ error: error.message })
286
+ }
287
+ },
288
+ })
289
+ .register(
290
+ { getProcedures: () => [GetUser.info, UpdateUser.info] }, // Or use the factory directly
291
+ async (req) => ({
292
+ userId: await getUserIdFromToken(req.headers.authorization),
293
+ requestId: req.headers['x-request-id'] || crypto.randomUUID(),
294
+ })
295
+ )
296
+ .build()
297
+
298
+ // Routes created:
299
+ // POST /api/users/get-user/1
300
+ // POST /api/users/update-user/1
301
+ ```
302
+
303
+ ---
304
+
305
+ ## Hono RPC Integration
306
+
307
+ ```typescript
308
+ import { Procedures } from 'ts-procedures'
309
+ import { HonoRPCAppBuilder } from 'ts-procedures/hono-rpc'
310
+ import type { RPCConfig } from 'ts-procedures/http'
311
+
312
+ type AppContext = { userId: string }
313
+ const RPC = Procedures<AppContext, RPCConfig>()
314
+
315
+ RPC.Create('GetUser', { scope: 'users', version: 1 }, async (ctx, params) => {
316
+ return fetchUser(params.id)
317
+ })
318
+
319
+ const app = new HonoRPCAppBuilder({ pathPrefix: '/api' })
320
+ .register(RPC, (c) => ({
321
+ userId: c.req.header('x-user-id') || 'anonymous',
322
+ }))
323
+ .build()
324
+ ```
325
+
326
+ ---
327
+
328
+ ## Hono Streaming Integration
329
+
330
+ ```typescript
331
+ import { Procedures } from 'ts-procedures'
332
+ import { HonoStreamAppBuilder, sse } from 'ts-procedures/hono-stream'
333
+ import { Type } from 'typebox'
334
+ import type { RPCConfig } from 'ts-procedures/http'
335
+
336
+ type StreamContext = { userId: string }
337
+ const StreamRPC = Procedures<StreamContext, RPCConfig>()
338
+
339
+ const { StreamEvents } = StreamRPC.CreateStream(
340
+ 'StreamEvents',
341
+ {
342
+ scope: 'events',
343
+ version: 1,
344
+ schema: {
345
+ params: Type.Object({ channel: Type.String() }),
346
+ yieldType: Type.Object({ type: Type.String(), payload: Type.Any() }),
347
+ },
348
+ },
349
+ async function* (ctx, params) {
350
+ const sub = subscribe(params.channel, { signal: ctx.signal })
351
+ for await (const event of sub) {
352
+ // Attach SSE metadata
353
+ yield sse(event, { event: event.type, id: event.id })
354
+ }
355
+ }
356
+ )
357
+
358
+ const app = new HonoStreamAppBuilder({
359
+ pathPrefix: '/api',
360
+ defaultStreamMode: 'sse',
361
+ onMidStreamError: (procedure, c, error) => ({
362
+ data: { error: error.message },
363
+ closeStream: true,
364
+ }),
365
+ })
366
+ .register(StreamRPC, (c) => ({
367
+ userId: c.req.header('x-user-id') || 'anonymous',
368
+ }))
369
+ .build()
370
+
371
+ // Routes: GET|POST /api/events/stream-events/1
372
+ ```
373
+
374
+ ---
375
+
376
+ ## Text Streaming Mode
377
+
378
+ ```typescript
379
+ const app = new HonoStreamAppBuilder({
380
+ defaultStreamMode: 'text', // Newline-delimited JSON
381
+ })
382
+ .register(StreamRPC, contextFactory)
383
+ .build()
384
+
385
+ // Each yield becomes: {"type":"event","payload":{...}}\n
386
+ ```
387
+
388
+ ---
389
+
390
+ ## Hono API Integration (REST-style)
391
+
392
+ ```typescript
393
+ import { Procedures } from 'ts-procedures'
394
+ import { HonoAPIAppBuilder } from 'ts-procedures/hono-api'
395
+ import type { APIConfig } from 'ts-procedures/http'
396
+ import { Type } from 'typebox'
397
+
398
+ type AppContext = { userId: string }
399
+ const API = Procedures<AppContext, APIConfig>()
400
+
401
+ API.Create('GetUser', {
402
+ path: '/users/:id',
403
+ method: 'get',
404
+ schema: {
405
+ input: {
406
+ pathParams: Type.Object({ id: Type.String() }),
407
+ },
408
+ returnType: Type.Object({ id: Type.String(), name: Type.String() }),
409
+ },
410
+ }, async (ctx, { pathParams }) => {
411
+ return await fetchUser(pathParams.id)
412
+ })
413
+
414
+ API.Create('CreateUser', {
415
+ path: '/users',
416
+ method: 'post',
417
+ schema: {
418
+ input: {
419
+ body: Type.Object({ name: Type.String(), email: Type.String() }),
420
+ },
421
+ },
422
+ }, async (ctx, { body }) => {
423
+ return await createUser(body)
424
+ })
425
+
426
+ API.Create('DeleteUser', {
427
+ path: '/users/:id',
428
+ method: 'delete',
429
+ schema: {
430
+ input: {
431
+ pathParams: Type.Object({ id: Type.String() }),
432
+ },
433
+ },
434
+ }, async (ctx, { pathParams }) => {
435
+ await deleteUser(pathParams.id)
436
+ })
437
+
438
+ const app = await new HonoAPIAppBuilder({
439
+ pathPrefix: '/api',
440
+ onError: (procedure, c, error) => {
441
+ if (error instanceof ProcedureValidationError) {
442
+ return c.json({ error: error.message, details: error.errors }, 400)
443
+ }
444
+ return c.json({ error: error.message }, 500)
445
+ },
446
+ })
447
+ .register(API, (c) => ({
448
+ userId: c.req.header('x-user-id') || 'anonymous',
449
+ }))
450
+ .build()
451
+
452
+ // Routes:
453
+ // GET /api/users/:id → 200
454
+ // POST /api/users → 201
455
+ // DELETE /api/users/:id → 204
456
+ ```
457
+
458
+ ---
459
+
460
+ ## Extended Config with APIConfig
461
+
462
+ ```typescript
463
+ import { Procedures } from 'ts-procedures'
464
+ import type { APIConfig } from 'ts-procedures/http'
465
+
466
+ type AppContext = { userId: string }
467
+
468
+ const { Create } = Procedures<AppContext, APIConfig>()
469
+
470
+ // Every procedure now MUST include path and method
471
+ const { GetUser } = Create(
472
+ 'GetUser',
473
+ {
474
+ path: '/users/:id',
475
+ method: 'get',
476
+ schema: {
477
+ input: {
478
+ pathParams: Type.Object({ id: Type.String() }),
479
+ query: Type.Object({ include: Type.Optional(Type.String()) }),
480
+ },
481
+ },
482
+ },
483
+ async (ctx, { pathParams, query }) => {
484
+ return await fetchUser(pathParams.id, { include: query.include })
485
+ }
486
+ )
487
+ ```
488
+
489
+ ---
490
+
491
+ ## schema.input — Multi-Channel Structured Input
492
+
493
+ Use `schema.input` instead of `schema.params` for structured per-channel validation:
494
+
495
+ ```typescript
496
+ const { Create } = Procedures<AppContext, APIConfig>()
497
+
498
+ Create('UpdateUserField', {
499
+ path: '/users/:id',
500
+ method: 'put',
501
+ schema: {
502
+ input: {
503
+ pathParams: Type.Object({ id: Type.String() }),
504
+ query: Type.Object({ notify: Type.Optional(Type.Boolean()) }),
505
+ body: Type.Object({ field: Type.String(), value: Type.String() }),
506
+ headers: Type.Object({ 'x-idempotency-key': Type.String() }),
507
+ },
508
+ returnType: Type.Object({ ok: Type.Boolean() }),
509
+ },
510
+ }, async (ctx, { pathParams, query, body, headers }) => {
511
+ // Each channel independently typed and validated
512
+ // Validation errors include channel name: "Validation error for UpdateUserField in input.body"
513
+ await updateField(pathParams.id, body.field, body.value, {
514
+ idempotencyKey: headers['x-idempotency-key'],
515
+ })
516
+ if (query.notify) await notifyUser(pathParams.id)
517
+ return { ok: true }
518
+ })
519
+ ```
520
+
521
+ ---
522
+
523
+ ## APIInput Channel Constraint
524
+
525
+ Use `satisfies APIInput` to catch channel name typos at compile time:
526
+
527
+ ```typescript
528
+ import type { APIInput } from 'ts-procedures/hono-api'
529
+
530
+ Create('Search', {
531
+ path: '/search',
532
+ method: 'get',
533
+ schema: {
534
+ input: {
535
+ query: Type.Object({ q: Type.String() }),
536
+ } satisfies APIInput,
537
+ },
538
+ }, async (ctx, { query }) => {
539
+ return await search(query.q)
540
+ })
541
+ ```
542
+
543
+ ---
544
+
545
+ ## onCreate Callback for Framework Integration
546
+
547
+ ```typescript
548
+ const registeredProcedures: TProcedureRegistration[] = []
549
+
550
+ const { Create } = Procedures<AppContext>({
551
+ onCreate: (procedure) => {
552
+ registeredProcedures.push(procedure)
553
+ console.log(`Registered: ${procedure.name}`)
554
+ },
555
+ })
556
+
557
+ // Use for:
558
+ // - Route registration in custom frameworks
559
+ // - OpenAPI spec generation
560
+ // - Logging/monitoring setup
561
+ // - Permission registration
562
+ ```
563
+
564
+ ---
565
+
566
+ ## getProcedures() for Introspection
567
+
568
+ ```typescript
569
+ const { Create, getProcedures } = Procedures<AppContext, RPCConfig>()
570
+
571
+ Create('GetUser', { scope: 'users', version: 1 }, async (ctx) => ({}))
572
+ Create('ListUsers', { scope: 'users', version: 1 }, async (ctx) => [])
573
+
574
+ // Generate documentation
575
+ const docs = getProcedures().map(proc => ({
576
+ name: proc.name,
577
+ path: `/${proc.config.scope}/${kebabCase(proc.name)}/${proc.config.version}`,
578
+ params: proc.config.schema?.params,
579
+ returnType: proc.config.schema?.returnType,
580
+ }))
581
+ ```
582
+
583
+ ---
584
+
585
+ ## Multiple Factories for Access Control
586
+
587
+ ```typescript
588
+ type PublicContext = { requestId: string }
589
+ type AuthContext = { userId: string; requestId: string }
590
+
591
+ const PublicRPC = Procedures<PublicContext, RPCConfig>()
592
+ const AuthRPC = Procedures<AuthContext, RPCConfig>()
593
+
594
+ PublicRPC.Create('HealthCheck', { scope: 'health', version: 1 }, async () => ({ status: 'ok' }))
595
+ AuthRPC.Create('GetProfile', { scope: 'users', version: 1 }, async (ctx) => fetchProfile(ctx.userId))
596
+
597
+ // Register with different context resolvers
598
+ const app = new ExpressRPCAppBuilder({ pathPrefix: '/api' })
599
+ .register(PublicRPC, (req) => ({ requestId: req.headers['x-request-id'] }))
600
+ .register(AuthRPC, async (req) => ({
601
+ userId: await authenticate(req),
602
+ requestId: req.headers['x-request-id'],
603
+ }))
604
+ .build()
605
+ ```
606
+
607
+ ---
608
+
609
+ ## Documentation Generation with extendProcedureDoc
610
+
611
+ ```typescript
612
+ const app = new ExpressRPCAppBuilder({ pathPrefix: '/api' })
613
+ .register(RPC, contextFactory, ({ base, procedure }) => ({
614
+ summary: procedure.config.description,
615
+ tags: [base.scope],
616
+ security: procedure.config.permissions
617
+ ? [{ bearerAuth: [] }]
618
+ : [],
619
+ }))
620
+ .build()
621
+
622
+ // Access generated docs
623
+ const openApiPaths = app.docs.map(doc => ({
624
+ [doc.path]: {
625
+ [doc.method]: {
626
+ summary: doc.summary,
627
+ tags: doc.tags,
628
+ requestBody: doc.jsonSchema.body
629
+ ? { content: { 'application/json': { schema: doc.jsonSchema.body } } }
630
+ : undefined,
631
+ responses: {
632
+ 200: doc.jsonSchema.response
633
+ ? { content: { 'application/json': { schema: doc.jsonSchema.response } } }
634
+ : { description: 'Success' },
635
+ },
636
+ },
637
+ },
638
+ }))
639
+ ```
640
+
641
+ ---
642
+
643
+ ## Testing Procedures
644
+
645
+ ```typescript
646
+ import { describe, test, expect } from 'vitest'
647
+
648
+ describe('GetUser', () => {
649
+ test('returns user for valid params', async () => {
650
+ const result = await GetUser(
651
+ { userId: 'caller-1', requestId: 'test' },
652
+ { id: 'user-123' }
653
+ )
654
+ expect(result).toEqual({ id: 'user-123', name: 'John' })
655
+ })
656
+
657
+ test('throws ProcedureValidationError for invalid params', async () => {
658
+ await expect(
659
+ GetUser({ userId: 'caller-1', requestId: 'test' }, {})
660
+ ).rejects.toThrow(ProcedureValidationError)
661
+ })
662
+
663
+ test('throws ProcedureError for business logic errors', async () => {
664
+ await expect(
665
+ GetUser({ userId: 'caller-1', requestId: 'test' }, { id: 'not-found' })
666
+ ).rejects.toThrow(ProcedureError)
667
+ })
668
+ })
669
+ ```
670
+
671
+ ---
672
+
673
+ ## Testing Stream Procedures
674
+
675
+ ```typescript
676
+ describe('StreamNotifications', () => {
677
+ test('yields notifications', async () => {
678
+ const values = []
679
+ for await (const val of StreamNotifications({ userId: 'u1' }, { userId: 'u1' })) {
680
+ values.push(val)
681
+ if (values.length >= 3) break
682
+ }
683
+ expect(values).toHaveLength(3)
684
+ })
685
+ })
686
+ ```
687
+
688
+ ---
689
+
690
+ ## Testing HTTP Builders
691
+
692
+ ```typescript
693
+ import supertest from 'supertest'
694
+
695
+ describe('Express RPC', () => {
696
+ const app = new ExpressRPCAppBuilder({ pathPrefix: '/api' })
697
+ .register(RPC, { userId: 'test-user', requestId: 'test' })
698
+ .build()
699
+
700
+ test('POST /api/users/get-user/1', async () => {
701
+ const res = await supertest(app)
702
+ .post('/api/users/get-user/1')
703
+ .send({ id: 'user-123' })
704
+ .expect(200)
705
+
706
+ expect(res.body).toEqual({ id: 'user-123', name: 'John' })
707
+ })
708
+ })
709
+ ```
710
+
711
+ ---
712
+
713
+ ## Lifecycle Hook Execution Order
714
+
715
+ ### Standard RPC (Express/Hono)
716
+ ```
717
+ onRequestStart → factoryContext() → handler() → onSuccess → onRequestEnd
718
+ → onError → onRequestEnd
719
+ ```
720
+
721
+ ### Streaming (HonoStreamAppBuilder)
722
+ ```
723
+ onRequestStart → factoryContext() → params validation
724
+ → onPreStreamError (if invalid) → onRequestEnd
725
+ → onStreamStart → handler yields → onStreamEnd → onRequestEnd
726
+ → onMidStreamError (if throw) → onStreamEnd → onRequestEnd
727
+ ```