ts-procedures 3.1.0 → 3.3.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 (54) hide show
  1. package/README.md +222 -2
  2. package/build/errors.d.ts +19 -3
  3. package/build/errors.js +54 -5
  4. package/build/errors.js.map +1 -1
  5. package/build/errors.test.js +82 -0
  6. package/build/errors.test.js.map +1 -1
  7. package/build/exports.d.ts +1 -0
  8. package/build/exports.js +1 -0
  9. package/build/exports.js.map +1 -1
  10. package/build/implementations/http/hono-stream/index.d.ts +92 -0
  11. package/build/implementations/http/hono-stream/index.js +229 -0
  12. package/build/implementations/http/hono-stream/index.js.map +1 -0
  13. package/build/implementations/http/hono-stream/index.test.d.ts +1 -0
  14. package/build/implementations/http/hono-stream/index.test.js +681 -0
  15. package/build/implementations/http/hono-stream/index.test.js.map +1 -0
  16. package/build/implementations/http/hono-stream/types.d.ts +24 -0
  17. package/build/implementations/http/hono-stream/types.js +2 -0
  18. package/build/implementations/http/hono-stream/types.js.map +1 -0
  19. package/build/implementations/types.d.ts +15 -1
  20. package/build/index.d.ts +62 -3
  21. package/build/index.js +111 -6
  22. package/build/index.js.map +1 -1
  23. package/build/index.test.js +385 -2
  24. package/build/index.test.js.map +1 -1
  25. package/build/schema/compute-schema.d.ts +9 -2
  26. package/build/schema/compute-schema.js +9 -3
  27. package/build/schema/compute-schema.js.map +1 -1
  28. package/build/schema/parser.d.ts +6 -0
  29. package/build/schema/parser.js +42 -0
  30. package/build/schema/parser.js.map +1 -1
  31. package/build/schema/types.d.ts +1 -0
  32. package/build/stack-utils.d.ts +25 -0
  33. package/build/stack-utils.js +95 -0
  34. package/build/stack-utils.js.map +1 -0
  35. package/build/stack-utils.test.d.ts +1 -0
  36. package/build/stack-utils.test.js +80 -0
  37. package/build/stack-utils.test.js.map +1 -0
  38. package/package.json +1 -1
  39. package/src/errors.test.ts +110 -0
  40. package/src/errors.ts +65 -3
  41. package/src/exports.ts +1 -0
  42. package/src/implementations/http/README.md +87 -55
  43. package/src/implementations/http/hono-stream/README.md +261 -0
  44. package/src/implementations/http/hono-stream/index.test.ts +1009 -0
  45. package/src/implementations/http/hono-stream/index.ts +327 -0
  46. package/src/implementations/http/hono-stream/types.ts +29 -0
  47. package/src/implementations/types.ts +17 -1
  48. package/src/index.test.ts +525 -41
  49. package/src/index.ts +210 -8
  50. package/src/schema/compute-schema.ts +15 -3
  51. package/src/schema/parser.ts +55 -4
  52. package/src/schema/types.ts +4 -0
  53. package/src/stack-utils.test.ts +94 -0
  54. package/src/stack-utils.ts +129 -0
@@ -0,0 +1,1009 @@
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 { HonoStreamAppBuilder } from './index.js'
6
+ import { RPCConfig } from '../../types.js'
7
+
8
+ /**
9
+ * HonoStreamAppBuilder Test Suite
10
+ *
11
+ * Tests the streaming Hono integration for ts-procedures.
12
+ * This builder creates GET and POST routes for streaming procedures (SSE and text modes).
13
+ */
14
+ describe('HonoStreamAppBuilder', () => {
15
+ // --------------------------------------------------------------------------
16
+ // Constructor Tests
17
+ // --------------------------------------------------------------------------
18
+ describe('constructor', () => {
19
+ test('creates default Hono app', async () => {
20
+ const builder = new HonoStreamAppBuilder()
21
+ const RPC = Procedures<{ userId: string }, RPCConfig>()
22
+
23
+ RPC.CreateStream(
24
+ 'StreamMessages',
25
+ { scope: 'messages', version: 1 },
26
+ async function* (ctx) {
27
+ yield { message: 'hello' }
28
+ yield { message: 'world' }
29
+ }
30
+ )
31
+
32
+ builder.register(RPC, () => ({ userId: '123' }))
33
+ const app = builder.build()
34
+
35
+ const res = await app.request('/messages/stream-messages/1', {
36
+ method: 'GET',
37
+ })
38
+
39
+ expect(res.status).toBe(200)
40
+ expect(res.headers.get('content-type')).toContain('text/event-stream')
41
+ })
42
+
43
+ test('uses provided Hono app', async () => {
44
+ const customApp = new Hono()
45
+ customApp.get('/custom', (c) => c.json({ custom: true }))
46
+
47
+ const builder = new HonoStreamAppBuilder({ app: customApp })
48
+ const RPC = Procedures<{ userId: string }, RPCConfig>()
49
+
50
+ RPC.CreateStream(
51
+ 'StreamData',
52
+ { scope: 'data', version: 1 },
53
+ async function* () {
54
+ yield { data: 1 }
55
+ }
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
+ // Stream route should also work
68
+ const streamRes = await app.request('/data/stream-data/1')
69
+ expect(streamRes.status).toBe(200)
70
+ })
71
+
72
+ test('handles empty config', () => {
73
+ const builder = new HonoStreamAppBuilder({})
74
+ expect(builder.app).toBeDefined()
75
+ expect(builder.docs).toEqual([])
76
+ })
77
+
78
+ test('handles undefined config', () => {
79
+ const builder = new HonoStreamAppBuilder(undefined)
80
+ expect(builder.app).toBeDefined()
81
+ expect(builder.docs).toEqual([])
82
+ })
83
+ })
84
+
85
+ // --------------------------------------------------------------------------
86
+ // SSE Streaming Tests
87
+ // --------------------------------------------------------------------------
88
+ describe('SSE streaming mode', () => {
89
+ test('streams multiple values as SSE events', async () => {
90
+ const builder = new HonoStreamAppBuilder()
91
+ const RPC = Procedures<{}, RPCConfig>()
92
+
93
+ RPC.CreateStream(
94
+ 'Counter',
95
+ { scope: 'counter', version: 1 },
96
+ async function* () {
97
+ yield { count: 1 }
98
+ yield { count: 2 }
99
+ yield { count: 3 }
100
+ }
101
+ )
102
+
103
+ builder.register(RPC, () => ({}))
104
+ const app = builder.build()
105
+
106
+ const res = await app.request('/counter/counter/1')
107
+ expect(res.status).toBe(200)
108
+ expect(res.headers.get('content-type')).toContain('text/event-stream')
109
+
110
+ const text = await res.text()
111
+ expect(text).toContain('event: Counter')
112
+ expect(text).toContain('data: {"count":1}')
113
+ expect(text).toContain('data: {"count":2}')
114
+ expect(text).toContain('data: {"count":3}')
115
+ expect(text).toContain('id: 0')
116
+ expect(text).toContain('id: 1')
117
+ expect(text).toContain('id: 2')
118
+ })
119
+
120
+ test('uses SSE mode by default', async () => {
121
+ const builder = new HonoStreamAppBuilder()
122
+ const RPC = Procedures<{}, RPCConfig>()
123
+
124
+ RPC.CreateStream(
125
+ 'Test',
126
+ { scope: 'test', version: 1 },
127
+ async function* () {
128
+ yield { ok: true }
129
+ }
130
+ )
131
+
132
+ builder.register(RPC, () => ({}))
133
+ const app = builder.build()
134
+
135
+ const res = await app.request('/test/test/1')
136
+ expect(res.headers.get('content-type')).toContain('text/event-stream')
137
+ })
138
+
139
+ test('explicitly set SSE mode works', async () => {
140
+ const builder = new HonoStreamAppBuilder({ defaultStreamMode: 'sse' })
141
+ const RPC = Procedures<{}, RPCConfig>()
142
+
143
+ RPC.CreateStream(
144
+ 'Test',
145
+ { scope: 'test', version: 1 },
146
+ async function* () {
147
+ yield { ok: true }
148
+ }
149
+ )
150
+
151
+ builder.register(RPC, () => ({}))
152
+ const app = builder.build()
153
+
154
+ const res = await app.request('/test/test/1')
155
+ expect(res.headers.get('content-type')).toContain('text/event-stream')
156
+ })
157
+ })
158
+
159
+ // --------------------------------------------------------------------------
160
+ // Text Streaming Tests
161
+ // --------------------------------------------------------------------------
162
+ describe('text streaming mode', () => {
163
+ test('streams multiple values as newline-delimited JSON', async () => {
164
+ const builder = new HonoStreamAppBuilder({ defaultStreamMode: 'text' })
165
+ const RPC = Procedures<{}, RPCConfig>()
166
+
167
+ RPC.CreateStream(
168
+ 'Counter',
169
+ { scope: 'counter', version: 1 },
170
+ async function* () {
171
+ yield { count: 1 }
172
+ yield { count: 2 }
173
+ yield { count: 3 }
174
+ }
175
+ )
176
+
177
+ builder.register(RPC, () => ({}))
178
+ const app = builder.build()
179
+
180
+ const res = await app.request('/counter/counter/1')
181
+ expect(res.status).toBe(200)
182
+ expect(res.headers.get('content-type')).toContain('text/plain')
183
+
184
+ const text = await res.text()
185
+ const lines = text.trim().split('\n')
186
+ expect(lines).toHaveLength(3)
187
+ expect(JSON.parse(lines[0]!)).toEqual({ count: 1 })
188
+ expect(JSON.parse(lines[1]!)).toEqual({ count: 2 })
189
+ expect(JSON.parse(lines[2]!)).toEqual({ count: 3 })
190
+ })
191
+
192
+ test('per-factory streamMode overrides default', async () => {
193
+ const builder = new HonoStreamAppBuilder({ defaultStreamMode: 'sse' })
194
+ const RPC = Procedures<{}, RPCConfig>()
195
+
196
+ RPC.CreateStream(
197
+ 'Test',
198
+ { scope: 'test', version: 1 },
199
+ async function* () {
200
+ yield { ok: true }
201
+ }
202
+ )
203
+
204
+ builder.register(RPC, () => ({}), { streamMode: 'text' })
205
+ const app = builder.build()
206
+
207
+ const res = await app.request('/test/test/1')
208
+ expect(res.headers.get('content-type')).toContain('text/plain')
209
+ })
210
+ })
211
+
212
+ // --------------------------------------------------------------------------
213
+ // HTTP Method Tests (GET and POST)
214
+ // --------------------------------------------------------------------------
215
+ describe('HTTP methods', () => {
216
+ test('GET request works', async () => {
217
+ const builder = new HonoStreamAppBuilder()
218
+ const RPC = Procedures<{}, RPCConfig>()
219
+
220
+ RPC.CreateStream(
221
+ 'Test',
222
+ { scope: 'test', version: 1 },
223
+ async function* () {
224
+ yield { method: 'works' }
225
+ }
226
+ )
227
+
228
+ builder.register(RPC, () => ({}))
229
+ const app = builder.build()
230
+
231
+ const res = await app.request('/test/test/1', { method: 'GET' })
232
+ expect(res.status).toBe(200)
233
+ })
234
+
235
+ test('POST request works', async () => {
236
+ const builder = new HonoStreamAppBuilder()
237
+ const RPC = Procedures<{}, RPCConfig>()
238
+
239
+ RPC.CreateStream(
240
+ 'Test',
241
+ { scope: 'test', version: 1 },
242
+ async function* () {
243
+ yield { method: 'works' }
244
+ }
245
+ )
246
+
247
+ builder.register(RPC, () => ({}))
248
+ const app = builder.build()
249
+
250
+ const res = await app.request('/test/test/1', {
251
+ method: 'POST',
252
+ headers: { 'Content-Type': 'application/json' },
253
+ body: JSON.stringify({}),
254
+ })
255
+ expect(res.status).toBe(200)
256
+ })
257
+
258
+ test('GET request passes query params to handler', async () => {
259
+ const builder = new HonoStreamAppBuilder({ defaultStreamMode: 'text' })
260
+ const RPC = Procedures<{}, RPCConfig>()
261
+
262
+ RPC.CreateStream(
263
+ 'Echo',
264
+ { scope: 'echo', version: 1 },
265
+ async function* (ctx, params) {
266
+ yield { received: params }
267
+ }
268
+ )
269
+
270
+ builder.register(RPC, () => ({}))
271
+ const app = builder.build()
272
+
273
+ const res = await app.request('/echo/echo/1?foo=bar&baz=qux')
274
+ const text = await res.text()
275
+ const data = JSON.parse(text.trim())
276
+ expect(data.received).toEqual({ foo: 'bar', baz: 'qux' })
277
+ })
278
+
279
+ test('POST request passes JSON body to handler', async () => {
280
+ const builder = new HonoStreamAppBuilder({ defaultStreamMode: 'text' })
281
+ const RPC = Procedures<{}, RPCConfig>()
282
+
283
+ RPC.CreateStream(
284
+ 'Echo',
285
+ { scope: 'echo', version: 1 },
286
+ async function* (ctx, params) {
287
+ yield { received: params }
288
+ }
289
+ )
290
+
291
+ builder.register(RPC, () => ({}))
292
+ const app = builder.build()
293
+
294
+ const res = await app.request('/echo/echo/1', {
295
+ method: 'POST',
296
+ headers: { 'Content-Type': 'application/json' },
297
+ body: JSON.stringify({ complex: { nested: 'data' }, array: [1, 2, 3] }),
298
+ })
299
+ const text = await res.text()
300
+ const data = JSON.parse(text.trim())
301
+ expect(data.received).toEqual({ complex: { nested: 'data' }, array: [1, 2, 3] })
302
+ })
303
+ })
304
+
305
+ // --------------------------------------------------------------------------
306
+ // pathPrefix Option Tests
307
+ // --------------------------------------------------------------------------
308
+ describe('pathPrefix option', () => {
309
+ test('uses custom pathPrefix for all routes', async () => {
310
+ const builder = new HonoStreamAppBuilder({ pathPrefix: '/api/v1' })
311
+ const RPC = Procedures<{}, RPCConfig>()
312
+
313
+ RPC.CreateStream(
314
+ 'Test',
315
+ { scope: 'test', version: 1 },
316
+ async function* () {
317
+ yield { ok: true }
318
+ }
319
+ )
320
+
321
+ builder.register(RPC, () => ({}))
322
+ const app = builder.build()
323
+
324
+ const res = await app.request('/api/v1/test/test/1')
325
+ expect(res.status).toBe(200)
326
+ })
327
+
328
+ test('pathPrefix without leading slash gets normalized', async () => {
329
+ const builder = new HonoStreamAppBuilder({ pathPrefix: 'custom' })
330
+ const RPC = Procedures<{}, RPCConfig>()
331
+
332
+ RPC.CreateStream(
333
+ 'Test',
334
+ { scope: 'test', version: 1 },
335
+ async function* () {
336
+ yield { ok: true }
337
+ }
338
+ )
339
+
340
+ builder.register(RPC, () => ({}))
341
+ const app = builder.build()
342
+
343
+ const res = await app.request('/custom/test/test/1')
344
+ expect(res.status).toBe(200)
345
+ })
346
+
347
+ test('pathPrefix appears in generated docs', () => {
348
+ const builder = new HonoStreamAppBuilder({ pathPrefix: '/api' })
349
+ const RPC = Procedures<{}, RPCConfig>()
350
+
351
+ RPC.CreateStream(
352
+ 'Test',
353
+ { scope: 'test', version: 1 },
354
+ async function* () {
355
+ yield {}
356
+ }
357
+ )
358
+
359
+ builder.register(RPC, () => ({}))
360
+ builder.build()
361
+
362
+ expect(builder.docs[0]!.path).toBe('/api/test/test/1')
363
+ })
364
+ })
365
+
366
+ // --------------------------------------------------------------------------
367
+ // Lifecycle Hooks Tests
368
+ // --------------------------------------------------------------------------
369
+ describe('lifecycle hooks', () => {
370
+ test('onRequestStart is called with context object', async () => {
371
+ const onRequestStart = vi.fn()
372
+ const builder = new HonoStreamAppBuilder({ onRequestStart })
373
+ const RPC = Procedures<{}, RPCConfig>()
374
+
375
+ RPC.CreateStream(
376
+ 'Test',
377
+ { scope: 'test', version: 1 },
378
+ async function* () {
379
+ yield { ok: true }
380
+ }
381
+ )
382
+
383
+ builder.register(RPC, () => ({}))
384
+ const app = builder.build()
385
+
386
+ await app.request('/test/test/1')
387
+
388
+ expect(onRequestStart).toHaveBeenCalledTimes(1)
389
+ expect(onRequestStart.mock.calls[0]![0]).toHaveProperty('req')
390
+ })
391
+
392
+ test('onRequestEnd is called after response', async () => {
393
+ const onRequestEnd = vi.fn()
394
+ const builder = new HonoStreamAppBuilder({ onRequestEnd })
395
+ const RPC = Procedures<{}, RPCConfig>()
396
+
397
+ RPC.CreateStream(
398
+ 'Test',
399
+ { scope: 'test', version: 1 },
400
+ async function* () {
401
+ yield { ok: true }
402
+ }
403
+ )
404
+
405
+ builder.register(RPC, () => ({}))
406
+ const app = builder.build()
407
+
408
+ await app.request('/test/test/1')
409
+
410
+ expect(onRequestEnd).toHaveBeenCalledTimes(1)
411
+ expect(onRequestEnd.mock.calls[0]![0]).toHaveProperty('req')
412
+ })
413
+
414
+ test('onStreamStart is called before streaming begins', async () => {
415
+ const onStreamStart = vi.fn()
416
+ const builder = new HonoStreamAppBuilder({ onStreamStart })
417
+ const RPC = Procedures<{}, RPCConfig>()
418
+
419
+ RPC.CreateStream(
420
+ 'Test',
421
+ { scope: 'test', version: 1 },
422
+ async function* () {
423
+ yield { ok: true }
424
+ }
425
+ )
426
+
427
+ builder.register(RPC, () => ({}))
428
+ const app = builder.build()
429
+
430
+ await app.request('/test/test/1')
431
+
432
+ expect(onStreamStart).toHaveBeenCalledTimes(1)
433
+ expect(onStreamStart.mock.calls[0]![0]).toHaveProperty('name', 'Test')
434
+ })
435
+
436
+ test('onStreamEnd is called after stream completes', async () => {
437
+ const onStreamEnd = vi.fn()
438
+ const builder = new HonoStreamAppBuilder({ onStreamEnd })
439
+ const RPC = Procedures<{}, RPCConfig>()
440
+
441
+ RPC.CreateStream(
442
+ 'Test',
443
+ { scope: 'test', version: 1 },
444
+ async function* () {
445
+ yield { ok: true }
446
+ }
447
+ )
448
+
449
+ builder.register(RPC, () => ({}))
450
+ const app = builder.build()
451
+
452
+ const res = await app.request('/test/test/1')
453
+ // Consume the stream to ensure it completes
454
+ await res.text()
455
+
456
+ expect(onStreamEnd).toHaveBeenCalledTimes(1)
457
+ expect(onStreamEnd.mock.calls[0]![0]).toHaveProperty('name', 'Test')
458
+ })
459
+
460
+ test('hooks execute in correct order', async () => {
461
+ const order: string[] = []
462
+
463
+ const builder = new HonoStreamAppBuilder({
464
+ onRequestStart: () => order.push('request-start'),
465
+ onRequestEnd: () => order.push('request-end'),
466
+ onStreamStart: () => order.push('stream-start'),
467
+ onStreamEnd: () => order.push('stream-end'),
468
+ })
469
+ const RPC = Procedures<{}, RPCConfig>()
470
+
471
+ RPC.CreateStream(
472
+ 'Test',
473
+ { scope: 'test', version: 1 },
474
+ async function* () {
475
+ order.push('handler')
476
+ yield { ok: true }
477
+ }
478
+ )
479
+
480
+ builder.register(RPC, () => ({}))
481
+ const app = builder.build()
482
+
483
+ const res = await app.request('/test/test/1')
484
+ // Consume the stream to ensure it completes
485
+ await res.text()
486
+
487
+ // Note: onRequestEnd middleware runs when response starts (before stream completes)
488
+ // while onStreamEnd runs when the stream finishes
489
+ expect(order).toContain('request-start')
490
+ expect(order).toContain('stream-start')
491
+ expect(order).toContain('handler')
492
+ expect(order).toContain('stream-end')
493
+ expect(order).toContain('request-end')
494
+ // request-start should be first
495
+ expect(order[0]).toBe('request-start')
496
+ // stream-start should be before handler
497
+ expect(order.indexOf('stream-start')).toBeLessThan(order.indexOf('handler'))
498
+ })
499
+ })
500
+
501
+ // --------------------------------------------------------------------------
502
+ // Error Handling Tests
503
+ // --------------------------------------------------------------------------
504
+ describe('error handling', () => {
505
+ test('custom error handler receives procedure, context, and error', async () => {
506
+ const errorHandler = vi.fn((procedure, c, error) => {
507
+ return c.json({ customError: error.message }, 400)
508
+ })
509
+
510
+ const builder = new HonoStreamAppBuilder({ onStreamError: errorHandler })
511
+ const RPC = Procedures<{}, RPCConfig>()
512
+
513
+ // Error during context resolution (before streaming starts)
514
+ builder.register(RPC, () => {
515
+ throw new Error('Context error')
516
+ })
517
+
518
+ const app = builder.build()
519
+
520
+ // Since no streaming procedures were registered, this should 404
521
+ // Let's test with a real streaming procedure that throws during setup
522
+ })
523
+
524
+ test('errors during streaming are sent as error events (SSE mode)', async () => {
525
+ const builder = new HonoStreamAppBuilder()
526
+ const RPC = Procedures<{}, RPCConfig>()
527
+
528
+ RPC.CreateStream(
529
+ 'ErrorStream',
530
+ { scope: 'error', version: 1 },
531
+ async function* () {
532
+ yield { count: 1 }
533
+ throw new Error('Stream error')
534
+ }
535
+ )
536
+
537
+ builder.register(RPC, () => ({}))
538
+ const app = builder.build()
539
+
540
+ const res = await app.request('/error/error-stream/1')
541
+ const text = await res.text()
542
+
543
+ expect(text).toContain('data: {"count":1}')
544
+ expect(text).toContain('event: error')
545
+ expect(text).toContain('Stream error')
546
+ })
547
+
548
+ test('errors during streaming are sent as JSON lines (text mode)', async () => {
549
+ const builder = new HonoStreamAppBuilder({ defaultStreamMode: 'text' })
550
+ const RPC = Procedures<{}, RPCConfig>()
551
+
552
+ RPC.CreateStream(
553
+ 'ErrorStream',
554
+ { scope: 'error', version: 1 },
555
+ async function* () {
556
+ yield { count: 1 }
557
+ throw new Error('Stream error')
558
+ }
559
+ )
560
+
561
+ builder.register(RPC, () => ({}))
562
+ const app = builder.build()
563
+
564
+ const res = await app.request('/error/error-stream/1')
565
+ const text = await res.text()
566
+ const lines = text.trim().split('\n')
567
+
568
+ expect(JSON.parse(lines[0]!)).toEqual({ count: 1 })
569
+ // Error is wrapped by Procedures with "Error in streaming handler for {name}" prefix
570
+ expect(JSON.parse(lines[1]!).error).toContain('Stream error')
571
+ })
572
+ })
573
+
574
+ // --------------------------------------------------------------------------
575
+ // Context Resolution Tests
576
+ // --------------------------------------------------------------------------
577
+ describe('context resolution', () => {
578
+ test('context can be a static object', async () => {
579
+ const builder = new HonoStreamAppBuilder({ defaultStreamMode: 'text' })
580
+ const RPC = Procedures<{ requestId: string }, RPCConfig>()
581
+
582
+ RPC.CreateStream(
583
+ 'GetId',
584
+ { scope: 'get-id', version: 1 },
585
+ async function* (ctx) {
586
+ yield { id: ctx.requestId }
587
+ }
588
+ )
589
+
590
+ builder.register(RPC, { requestId: 'static-123' })
591
+ const app = builder.build()
592
+
593
+ const res = await app.request('/get-id/get-id/1')
594
+ const text = await res.text()
595
+ expect(JSON.parse(text.trim())).toEqual({ id: 'static-123' })
596
+ })
597
+
598
+ test('context can be sync function', async () => {
599
+ const builder = new HonoStreamAppBuilder({ defaultStreamMode: 'text' })
600
+ const RPC = Procedures<{ requestId: string }, RPCConfig>()
601
+
602
+ RPC.CreateStream(
603
+ 'GetId',
604
+ { scope: 'get-id', version: 1 },
605
+ async function* (ctx) {
606
+ yield { id: ctx.requestId }
607
+ }
608
+ )
609
+
610
+ builder.register(RPC, (c) => ({ requestId: c.req.header('x-request-id') || 'unknown' }))
611
+ const app = builder.build()
612
+
613
+ const res = await app.request('/get-id/get-id/1', {
614
+ headers: { 'X-Request-Id': 'req-456' },
615
+ })
616
+ const text = await res.text()
617
+ expect(JSON.parse(text.trim())).toEqual({ id: 'req-456' })
618
+ })
619
+
620
+ test('context can be async function', async () => {
621
+ const builder = new HonoStreamAppBuilder({ defaultStreamMode: 'text' })
622
+ const RPC = Procedures<{ requestId: string }, RPCConfig>()
623
+
624
+ RPC.CreateStream(
625
+ 'GetId',
626
+ { scope: 'get-id', version: 1 },
627
+ async function* (ctx) {
628
+ yield { id: ctx.requestId }
629
+ }
630
+ )
631
+
632
+ builder.register(RPC, async () => {
633
+ await new Promise((r) => setTimeout(r, 10))
634
+ return { requestId: 'async-789' }
635
+ })
636
+ const app = builder.build()
637
+
638
+ const res = await app.request('/get-id/get-id/1')
639
+ const text = await res.text()
640
+ expect(JSON.parse(text.trim())).toEqual({ id: 'async-789' })
641
+ })
642
+ })
643
+
644
+ // --------------------------------------------------------------------------
645
+ // Documentation Tests
646
+ // --------------------------------------------------------------------------
647
+ describe('documentation', () => {
648
+ test('generates complete route documentation', () => {
649
+ const paramsSchema = v.object({ id: v.string() })
650
+ const yieldSchema = v.object({ message: v.string() })
651
+ const returnSchema = v.object({ total: v.number() })
652
+
653
+ const builder = new HonoStreamAppBuilder()
654
+ const RPC = Procedures<{}, RPCConfig>()
655
+
656
+ RPC.CreateStream(
657
+ 'StreamMessages',
658
+ {
659
+ scope: 'messages',
660
+ version: 1,
661
+ schema: { params: paramsSchema, yieldType: yieldSchema, returnType: returnSchema },
662
+ },
663
+ async function* () {
664
+ yield { message: 'test' }
665
+ }
666
+ )
667
+
668
+ builder.register(RPC, () => ({}))
669
+ builder.build()
670
+
671
+ const doc = builder.docs[0]!
672
+ expect(doc.path).toBe('/messages/stream-messages/1')
673
+ expect(doc.methods).toEqual(['get', 'post'])
674
+ expect(doc.streamMode).toBe('sse')
675
+ expect(doc.jsonSchema.params).toBeDefined()
676
+ expect(doc.jsonSchema.yieldType).toBeDefined()
677
+ expect(doc.jsonSchema.returnType).toBeDefined()
678
+ })
679
+
680
+ test('streamMode is recorded in docs', () => {
681
+ const builder = new HonoStreamAppBuilder({ defaultStreamMode: 'text' })
682
+ const RPC = Procedures<{}, RPCConfig>()
683
+
684
+ RPC.CreateStream(
685
+ 'Test',
686
+ { scope: 'test', version: 1 },
687
+ async function* () {
688
+ yield {}
689
+ }
690
+ )
691
+
692
+ builder.register(RPC, () => ({}))
693
+ builder.build()
694
+
695
+ expect(builder.docs[0]!.streamMode).toBe('text')
696
+ })
697
+
698
+ test('per-factory streamMode is recorded in docs', () => {
699
+ const builder = new HonoStreamAppBuilder({ defaultStreamMode: 'sse' })
700
+ const RPC = Procedures<{}, RPCConfig>()
701
+
702
+ RPC.CreateStream(
703
+ 'Test',
704
+ { scope: 'test', version: 1 },
705
+ async function* () {
706
+ yield {}
707
+ }
708
+ )
709
+
710
+ builder.register(RPC, () => ({}), { streamMode: 'text' })
711
+ builder.build()
712
+
713
+ expect(builder.docs[0]!.streamMode).toBe('text')
714
+ })
715
+ })
716
+
717
+ // --------------------------------------------------------------------------
718
+ // Filter Tests (Only Streaming Procedures)
719
+ // --------------------------------------------------------------------------
720
+ describe('procedure filtering', () => {
721
+ test('only registers streaming procedures', async () => {
722
+ const builder = new HonoStreamAppBuilder()
723
+ const RPC = Procedures<{}, RPCConfig>()
724
+
725
+ // Regular procedure (should be ignored)
726
+ RPC.Create('NonStream', { scope: 'non-stream', version: 1 }, async () => ({
727
+ ok: true,
728
+ }))
729
+
730
+ // Streaming procedure (should be registered)
731
+ RPC.CreateStream(
732
+ 'Stream',
733
+ { scope: 'stream', version: 1 },
734
+ async function* () {
735
+ yield { ok: true }
736
+ }
737
+ )
738
+
739
+ builder.register(RPC, () => ({}))
740
+ const app = builder.build()
741
+
742
+ // Only streaming procedure should be in docs
743
+ expect(builder.docs).toHaveLength(1)
744
+ expect(builder.docs[0]!.name).toBe('Stream')
745
+
746
+ // Non-streaming route should 404
747
+ const nonStreamRes = await app.request('/non-stream/non-stream/1', { method: 'POST' })
748
+ expect(nonStreamRes.status).toBe(404)
749
+
750
+ // Streaming route should work
751
+ const streamRes = await app.request('/stream/stream/1')
752
+ expect(streamRes.status).toBe(200)
753
+ })
754
+ })
755
+
756
+ // --------------------------------------------------------------------------
757
+ // extendProcedureDoc Tests
758
+ // --------------------------------------------------------------------------
759
+ describe('extendProcedureDoc', () => {
760
+ test('adds custom properties to generated documentation', () => {
761
+ const builder = new HonoStreamAppBuilder()
762
+ const RPC = Procedures<{}, RPCConfig>()
763
+
764
+ RPC.CreateStream(
765
+ 'StreamEvents',
766
+ { scope: 'events', version: 1 },
767
+ async function* () {
768
+ yield {}
769
+ }
770
+ )
771
+
772
+ builder.register(
773
+ RPC,
774
+ () => ({}),
775
+ {
776
+ extendProcedureDoc: ({ base, procedure }) => ({
777
+ summary: `Stream events endpoint`,
778
+ tags: ['events'],
779
+ operationId: procedure.name,
780
+ }),
781
+ }
782
+ )
783
+ builder.build()
784
+
785
+ const doc = builder.docs[0]!
786
+ expect(doc).toHaveProperty('summary', 'Stream events endpoint')
787
+ expect(doc).toHaveProperty('tags', ['events'])
788
+ expect(doc).toHaveProperty('operationId', 'StreamEvents')
789
+ })
790
+
791
+ test('base properties take precedence over extended properties', () => {
792
+ const builder = new HonoStreamAppBuilder()
793
+ const RPC = Procedures<{}, RPCConfig>()
794
+
795
+ RPC.CreateStream(
796
+ 'Test',
797
+ { scope: 'test', version: 1 },
798
+ async function* () {
799
+ yield {}
800
+ }
801
+ )
802
+
803
+ builder.register(
804
+ RPC,
805
+ () => ({}),
806
+ {
807
+ extendProcedureDoc: () => ({
808
+ name: 'OverriddenName',
809
+ path: '/overridden/path',
810
+ methods: ['put'],
811
+ customField: 'custom-value',
812
+ }),
813
+ }
814
+ )
815
+ builder.build()
816
+
817
+ const doc = builder.docs[0]!
818
+ // Base properties should NOT be overridden
819
+ expect(doc.name).toBe('Test')
820
+ expect(doc.path).toBe('/test/test/1')
821
+ expect(doc.methods).toEqual(['get', 'post'])
822
+ // Custom field should be present
823
+ expect(doc).toHaveProperty('customField', 'custom-value')
824
+ })
825
+ })
826
+
827
+ // --------------------------------------------------------------------------
828
+ // Multiple Factory Tests
829
+ // --------------------------------------------------------------------------
830
+ describe('multiple factories', () => {
831
+ test('supports registering multiple factories', async () => {
832
+ const builder = new HonoStreamAppBuilder({ defaultStreamMode: 'text' })
833
+
834
+ const PublicRPC = Procedures<{ public: true }, RPCConfig>()
835
+ const PrivateRPC = Procedures<{ private: true }, RPCConfig>()
836
+
837
+ PublicRPC.CreateStream(
838
+ 'PublicStream',
839
+ { scope: 'public', version: 1 },
840
+ async function* (ctx) {
841
+ yield { isPublic: ctx.public }
842
+ }
843
+ )
844
+
845
+ PrivateRPC.CreateStream(
846
+ 'PrivateStream',
847
+ { scope: 'private', version: 1 },
848
+ async function* (ctx) {
849
+ yield { isPrivate: ctx.private }
850
+ }
851
+ )
852
+
853
+ builder
854
+ .register(PublicRPC, () => ({ public: true as const }))
855
+ .register(PrivateRPC, () => ({ private: true as const }))
856
+
857
+ const app = builder.build()
858
+
859
+ const publicRes = await app.request('/public/public-stream/1')
860
+ const publicText = await publicRes.text()
861
+ expect(JSON.parse(publicText.trim())).toEqual({ isPublic: true })
862
+
863
+ const privateRes = await app.request('/private/private-stream/1')
864
+ const privateText = await privateRes.text()
865
+ expect(JSON.parse(privateText.trim())).toEqual({ isPrivate: true })
866
+ })
867
+
868
+ test('different factories can have different stream modes', async () => {
869
+ const builder = new HonoStreamAppBuilder()
870
+
871
+ const SSERPC = Procedures<{}, RPCConfig>()
872
+ const TextRPC = Procedures<{}, RPCConfig>()
873
+
874
+ SSERPC.CreateStream(
875
+ 'SSEStream',
876
+ { scope: 'sse', version: 1 },
877
+ async function* () {
878
+ yield { mode: 'sse' }
879
+ }
880
+ )
881
+
882
+ TextRPC.CreateStream(
883
+ 'TextStream',
884
+ { scope: 'text', version: 1 },
885
+ async function* () {
886
+ yield { mode: 'text' }
887
+ }
888
+ )
889
+
890
+ builder
891
+ .register(SSERPC, () => ({}), { streamMode: 'sse' })
892
+ .register(TextRPC, () => ({}), { streamMode: 'text' })
893
+
894
+ const app = builder.build()
895
+
896
+ const sseRes = await app.request('/sse/sse-stream/1')
897
+ expect(sseRes.headers.get('content-type')).toContain('text/event-stream')
898
+
899
+ const textRes = await app.request('/text/text-stream/1')
900
+ expect(textRes.headers.get('content-type')).toContain('text/plain')
901
+ })
902
+ })
903
+
904
+ // --------------------------------------------------------------------------
905
+ // Path Generation Tests
906
+ // --------------------------------------------------------------------------
907
+ describe('makeStreamHttpRoutePath', () => {
908
+ let builder: HonoStreamAppBuilder
909
+
910
+ beforeEach(() => {
911
+ builder = new HonoStreamAppBuilder()
912
+ })
913
+
914
+ test("simple scope: 'events' + 'StreamUpdates' → /events/stream-updates/1", () => {
915
+ const path = builder.makeStreamHttpRoutePath('StreamUpdates', { scope: 'events', version: 1 })
916
+ expect(path).toBe('/events/stream-updates/1')
917
+ })
918
+
919
+ test("array scope: ['events', 'live'] + 'Watch' → /events/live/watch/1", () => {
920
+ const path = builder.makeStreamHttpRoutePath('Watch', {
921
+ scope: ['events', 'live'],
922
+ version: 1,
923
+ })
924
+ expect(path).toBe('/events/live/watch/1')
925
+ })
926
+
927
+ test('version number included in path', () => {
928
+ const pathV1 = builder.makeStreamHttpRoutePath('Test', { scope: 'test', version: 1 })
929
+ const pathV2 = builder.makeStreamHttpRoutePath('Test', { scope: 'test', version: 2 })
930
+
931
+ expect(pathV1).toBe('/test/test/1')
932
+ expect(pathV2).toBe('/test/test/2')
933
+ })
934
+ })
935
+
936
+ // --------------------------------------------------------------------------
937
+ // Integration Test
938
+ // --------------------------------------------------------------------------
939
+ describe('integration', () => {
940
+ test('full workflow with streaming procedures', async () => {
941
+ type StreamContext = { userId: string }
942
+
943
+ const RPC = Procedures<StreamContext, RPCConfig>()
944
+
945
+ RPC.CreateStream(
946
+ 'WatchNotifications',
947
+ {
948
+ scope: ['user', 'notifications'],
949
+ version: 1,
950
+ schema: {
951
+ params: v.object({ limit: v.number() }),
952
+ yieldType: v.object({ id: v.number(), message: v.string() }),
953
+ },
954
+ },
955
+ async function* (ctx, params) {
956
+ const limit = params?.limit ?? 3
957
+ for (let i = 1; i <= limit; i++) {
958
+ yield { id: i, message: `Notification ${i} for ${ctx.userId}` }
959
+ }
960
+ }
961
+ )
962
+
963
+ // Also create a non-streaming procedure to ensure it's filtered out
964
+ RPC.Create('GetNotificationCount', { scope: ['user', 'notifications'], version: 1 }, async () => ({
965
+ count: 10,
966
+ }))
967
+
968
+ const events: string[] = []
969
+
970
+ const builder = new HonoStreamAppBuilder({
971
+ defaultStreamMode: 'text',
972
+ onRequestStart: () => events.push('request-start'),
973
+ onRequestEnd: () => events.push('request-end'),
974
+ onStreamStart: (proc) => events.push(`stream-start:${proc.name}`),
975
+ onStreamEnd: (proc) => events.push(`stream-end:${proc.name}`),
976
+ })
977
+
978
+ builder.register(RPC, (c) => ({
979
+ userId: c.req.header('x-user-id') || 'anonymous',
980
+ }))
981
+
982
+ const app = builder.build()
983
+
984
+ // Only streaming procedure should be registered
985
+ expect(builder.docs).toHaveLength(1)
986
+ expect(builder.docs[0]!.name).toBe('WatchNotifications')
987
+ expect(builder.docs[0]!.methods).toEqual(['get', 'post'])
988
+
989
+ // Test streaming
990
+ const res = await app.request('/user/notifications/watch-notifications/1?limit=2', {
991
+ headers: { 'X-User-Id': 'user-123' },
992
+ })
993
+
994
+ expect(res.status).toBe(200)
995
+
996
+ const text = await res.text()
997
+ const lines = text.trim().split('\n')
998
+ expect(lines).toHaveLength(2)
999
+ expect(JSON.parse(lines[0]!)).toEqual({ id: 1, message: 'Notification 1 for user-123' })
1000
+ expect(JSON.parse(lines[1]!)).toEqual({ id: 2, message: 'Notification 2 for user-123' })
1001
+
1002
+ // Verify hooks were called
1003
+ expect(events).toContain('request-start')
1004
+ expect(events).toContain('stream-start:WatchNotifications')
1005
+ expect(events).toContain('stream-end:WatchNotifications')
1006
+ expect(events).toContain('request-end')
1007
+ })
1008
+ })
1009
+ })