ts-procedures 5.0.0 → 5.1.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.
@@ -3,8 +3,9 @@ import { describe, expect, test, vi, beforeEach } from 'vitest'
3
3
  import { Hono } from 'hono'
4
4
  import { v } from 'suretype'
5
5
  import { Procedures } from '../../../index.js'
6
- import { HonoStreamAppBuilder, sse } from './index.js'
7
- import { RPCConfig } from '../../types.js'
6
+ import { HonoStreamAppBuilder, sse, MidStreamErrorResult } from './index.js'
7
+ import { RPCConfig, StreamMode } from '../../types.js'
8
+ import { ProcedureValidationError } from '../../../errors.js'
8
9
 
9
10
  /**
10
11
  * HonoStreamAppBuilder Test Suite
@@ -349,7 +350,7 @@ describe('HonoStreamAppBuilder', () => {
349
350
  expect(onRequestEnd.mock.calls[0]![0]).toHaveProperty('req')
350
351
  })
351
352
 
352
- test('onStreamStart is called before streaming begins', async () => {
353
+ test('onStreamStart is called before streaming begins with streamMode', async () => {
353
354
  const onStreamStart = vi.fn()
354
355
  const builder = new HonoStreamAppBuilder({ onStreamStart })
355
356
  const RPC = Procedures<{}, RPCConfig>()
@@ -365,9 +366,10 @@ describe('HonoStreamAppBuilder', () => {
365
366
 
366
367
  expect(onStreamStart).toHaveBeenCalledTimes(1)
367
368
  expect(onStreamStart.mock.calls[0]![0]).toHaveProperty('name', 'Test')
369
+ expect(onStreamStart.mock.calls[0]![2]).toBe('sse')
368
370
  })
369
371
 
370
- test('onStreamEnd is called after stream completes', async () => {
372
+ test('onStreamEnd is called after stream completes with streamMode', async () => {
371
373
  const onStreamEnd = vi.fn()
372
374
  const builder = new HonoStreamAppBuilder({ onStreamEnd })
373
375
  const RPC = Procedures<{}, RPCConfig>()
@@ -385,6 +387,7 @@ describe('HonoStreamAppBuilder', () => {
385
387
 
386
388
  expect(onStreamEnd).toHaveBeenCalledTimes(1)
387
389
  expect(onStreamEnd.mock.calls[0]![0]).toHaveProperty('name', 'Test')
390
+ expect(onStreamEnd.mock.calls[0]![2]).toBe('sse')
388
391
  })
389
392
 
390
393
  test('hooks execute in correct order', async () => {
@@ -1387,6 +1390,212 @@ describe('HonoStreamAppBuilder', () => {
1387
1390
  })
1388
1391
  })
1389
1392
 
1393
+ // --------------------------------------------------------------------------
1394
+ // streamMode in Lifecycle Hooks
1395
+ // --------------------------------------------------------------------------
1396
+ describe('streamMode in lifecycle hooks', () => {
1397
+ test('onStreamStart receives sse streamMode', async () => {
1398
+ const onStreamStart = vi.fn()
1399
+ const builder = new HonoStreamAppBuilder({ onStreamStart })
1400
+ const RPC = Procedures<{}, RPCConfig>()
1401
+
1402
+ RPC.CreateStream('Test', { scope: 'test', version: 1 }, async function* () {
1403
+ yield { ok: true }
1404
+ })
1405
+
1406
+ builder.register(RPC, () => ({}))
1407
+ const app = builder.build()
1408
+
1409
+ await app.request('/test/test/1')
1410
+
1411
+ expect(onStreamStart).toHaveBeenCalledTimes(1)
1412
+ const [, , streamMode] = onStreamStart.mock.calls[0]!
1413
+ expect(streamMode).toBe('sse')
1414
+ })
1415
+
1416
+ test('onStreamEnd receives text streamMode', async () => {
1417
+ const onStreamEnd = vi.fn()
1418
+ const builder = new HonoStreamAppBuilder({ defaultStreamMode: 'text', onStreamEnd })
1419
+ const RPC = Procedures<{}, RPCConfig>()
1420
+
1421
+ RPC.CreateStream('Test', { scope: 'test', version: 1 }, async function* () {
1422
+ yield { ok: true }
1423
+ })
1424
+
1425
+ builder.register(RPC, () => ({}))
1426
+ const app = builder.build()
1427
+
1428
+ const res = await app.request('/test/test/1')
1429
+ await res.text()
1430
+
1431
+ expect(onStreamEnd).toHaveBeenCalledTimes(1)
1432
+ const [, , streamMode] = onStreamEnd.mock.calls[0]!
1433
+ expect(streamMode).toBe('text')
1434
+ })
1435
+
1436
+ test('onStreamStart and onStreamEnd receive matching streamMode', async () => {
1437
+ const modes: { start?: StreamMode; end?: StreamMode } = {}
1438
+ const builder = new HonoStreamAppBuilder({
1439
+ defaultStreamMode: 'text',
1440
+ onStreamStart: (_proc, _c, mode) => { modes.start = mode },
1441
+ onStreamEnd: (_proc, _c, mode) => { modes.end = mode },
1442
+ })
1443
+ const RPC = Procedures<{}, RPCConfig>()
1444
+
1445
+ RPC.CreateStream('Test', { scope: 'test', version: 1 }, async function* () {
1446
+ yield { ok: true }
1447
+ })
1448
+
1449
+ builder.register(RPC, () => ({}))
1450
+ const app = builder.build()
1451
+
1452
+ const res = await app.request('/test/test/1')
1453
+ await res.text()
1454
+
1455
+ expect(modes.start).toBe('text')
1456
+ expect(modes.end).toBe('text')
1457
+ })
1458
+ })
1459
+
1460
+ // --------------------------------------------------------------------------
1461
+ // sse() in onMidStreamError
1462
+ // --------------------------------------------------------------------------
1463
+ describe('sse() in onMidStreamError', () => {
1464
+ test('sse() wraps error data with custom event and id', async () => {
1465
+ const builder = new HonoStreamAppBuilder({
1466
+ onMidStreamError: (procedure, c, error) => {
1467
+ return {
1468
+ data: sse(
1469
+ { type: 'error', message: error.message },
1470
+ { event: 'custom-error', id: 'err-1' }
1471
+ ),
1472
+ }
1473
+ },
1474
+ })
1475
+ const RPC = Procedures<{}, RPCConfig>()
1476
+
1477
+ RPC.CreateStream('ErrorStream', { scope: 'error', version: 1 }, async function* () {
1478
+ yield { type: 'data', value: 1 }
1479
+ throw new Error('Something broke')
1480
+ })
1481
+
1482
+ builder.register(RPC, () => ({}))
1483
+ const app = builder.build()
1484
+
1485
+ const res = await app.request('/error/error-stream/1')
1486
+ const text = await res.text()
1487
+
1488
+ // Normal yield
1489
+ expect(text).toContain('data: {"type":"data","value":1}')
1490
+ // Error yield with sse() metadata
1491
+ expect(text).toContain('event: custom-error')
1492
+ expect(text).toContain('id: err-1')
1493
+ expect(text).toContain('"type":"error"')
1494
+ })
1495
+
1496
+ test('string error data without sse() uses default event and id', async () => {
1497
+ const builder = new HonoStreamAppBuilder({
1498
+ onMidStreamError: () => {
1499
+ return { data: 'plain error string' }
1500
+ },
1501
+ })
1502
+ const RPC = Procedures<{}, RPCConfig>()
1503
+
1504
+ RPC.CreateStream('ErrorStream', { scope: 'error', version: 1 }, async function* () {
1505
+ yield { count: 1 }
1506
+ throw new Error('fail')
1507
+ })
1508
+
1509
+ builder.register(RPC, () => ({}))
1510
+ const app = builder.build()
1511
+
1512
+ const res = await app.request('/error/error-stream/1')
1513
+ const text = await res.text()
1514
+
1515
+ // String data can't use sse() (not an object), so defaults apply
1516
+ expect(text).toContain('data: plain error string')
1517
+ // event defaults to procedure name when data is provided
1518
+ expect(text).toContain('event: ErrorStream')
1519
+ })
1520
+ })
1521
+
1522
+ // --------------------------------------------------------------------------
1523
+ // Generic TErrorData
1524
+ // --------------------------------------------------------------------------
1525
+ describe('generic TErrorData', () => {
1526
+ test('typed builder constrains onMidStreamError return type', async () => {
1527
+ type ErrorPayload = { type: 'error'; code: string; message: string }
1528
+
1529
+ const builder = new HonoStreamAppBuilder<ErrorPayload>({
1530
+ onMidStreamError: (_procedure, _c, error) => {
1531
+ // This satisfies MidStreamErrorResult<ErrorPayload>
1532
+ return {
1533
+ data: { type: 'error', code: 'STREAM_FAILED', message: error.message },
1534
+ }
1535
+ },
1536
+ })
1537
+ const RPC = Procedures<{}, RPCConfig>()
1538
+
1539
+ RPC.CreateStream('ErrorStream', { scope: 'error', version: 1 }, async function* () {
1540
+ yield { value: 1 }
1541
+ throw new Error('typed error')
1542
+ })
1543
+
1544
+ builder.register(RPC, () => ({}))
1545
+ const app = builder.build()
1546
+
1547
+ const res = await app.request('/error/error-stream/1')
1548
+ const text = await res.text()
1549
+
1550
+ expect(text).toContain('"code":"STREAM_FAILED"')
1551
+ // Error message may be wrapped by Procedures with prefix
1552
+ expect(text).toContain('typed error')
1553
+ })
1554
+ })
1555
+
1556
+ // --------------------------------------------------------------------------
1557
+ // ProcedureValidationError narrowing in onPreStreamError
1558
+ // --------------------------------------------------------------------------
1559
+ describe('ProcedureValidationError narrowing', () => {
1560
+ test('instanceof check works in onPreStreamError', async () => {
1561
+ let wasValidationError = false
1562
+
1563
+ const builder = new HonoStreamAppBuilder({
1564
+ onPreStreamError: (procedure, c, error) => {
1565
+ if (error instanceof ProcedureValidationError) {
1566
+ wasValidationError = true
1567
+ return c.json({ validation: true, errors: error.errors }, 422)
1568
+ }
1569
+ return c.json({ error: error.message }, 500)
1570
+ },
1571
+ })
1572
+ const RPC = Procedures<{}, RPCConfig>()
1573
+
1574
+ RPC.CreateStream(
1575
+ 'Validated',
1576
+ {
1577
+ scope: 'validated',
1578
+ version: 1,
1579
+ schema: { params: v.object({ count: v.number() }) },
1580
+ },
1581
+ async function* (ctx, params) {
1582
+ yield { count: params.count }
1583
+ }
1584
+ )
1585
+
1586
+ builder.register(RPC, () => ({}))
1587
+ const app = builder.build()
1588
+
1589
+ const res = await app.request('/validated/validated/1?count=not-a-number')
1590
+
1591
+ expect(res.status).toBe(422)
1592
+ expect(wasValidationError).toBe(true)
1593
+ const body = await res.json()
1594
+ expect(body.validation).toBe(true)
1595
+ expect(body.errors).toBeDefined()
1596
+ })
1597
+ })
1598
+
1390
1599
  // --------------------------------------------------------------------------
1391
1600
  // Integration Test
1392
1601
  // --------------------------------------------------------------------------
@@ -32,18 +32,14 @@ function getSSEMeta(value: unknown): SSEOptions | undefined {
32
32
  /**
33
33
  * Result from onMidStreamError callback.
34
34
  * @property data - The data to write as the SSE `data:` field content (should match yieldType schema)
35
- * @property event - Optional SSE event name (defaults to procedure name if data provided, 'error' otherwise)
36
- * @property id - Optional SSE event id (auto-incremented if not provided)
37
35
  * @property closeStream - Whether to close the stream after writing (defaults to true)
38
36
  */
39
- export type MidStreamErrorResult = {
40
- data: unknown
41
- event?: string
42
- id?: string
37
+ export type MidStreamErrorResult<TErrorData = unknown> = {
38
+ data: TErrorData
43
39
  closeStream?: boolean
44
40
  }
45
41
 
46
- export type HonoStreamAppBuilderConfig = {
42
+ export type HonoStreamAppBuilderConfig<TErrorData = unknown> = {
47
43
  /**
48
44
  * An existing Hono application instance to use.
49
45
  * If not provided, a new instance will be created.
@@ -55,8 +51,8 @@ export type HonoStreamAppBuilderConfig = {
55
51
  defaultStreamMode?: StreamMode
56
52
  onRequestStart?: (c: Context) => void
57
53
  onRequestEnd?: (c: Context) => void
58
- onStreamStart?: (procedure: TStreamProcedureRegistration, c: Context) => void
59
- onStreamEnd?: (procedure: TStreamProcedureRegistration, c: Context) => void
54
+ onStreamStart?: (procedure: TStreamProcedureRegistration, c: Context, streamMode: StreamMode) => void
55
+ onStreamEnd?: (procedure: TStreamProcedureRegistration, c: Context, streamMode: StreamMode) => void
60
56
  /**
61
57
  * Called for errors BEFORE streaming starts (validation, auth, context resolution).
62
58
  * Return value IS used as the HTTP response.
@@ -64,7 +60,7 @@ export type HonoStreamAppBuilderConfig = {
64
60
  onPreStreamError?: (
65
61
  procedure: TStreamProcedureRegistration,
66
62
  c: Context,
67
- error: Error
63
+ error: ProcedureValidationError | Error
68
64
  ) => Response | Promise<Response>
69
65
  /**
70
66
  * Called for errors DURING streaming (generator throws).
@@ -72,13 +68,15 @@ export type HonoStreamAppBuilderConfig = {
72
68
  * Should return a value matching your yieldType schema (e.g., error variant of a union).
73
69
  * Return undefined to use default behavior (writes { error: message }).
74
70
  *
75
- * @returns { data, event?, id?, closeStream? } - data to yield, optional SSE fields, whether to close after (default true)
71
+ * Use sse() to attach SSE metadata (event, id, retry) to the error data object.
72
+ *
73
+ * @returns { data, closeStream? } - data to yield, whether to close after (default true)
76
74
  */
77
75
  onMidStreamError?: (
78
76
  procedure: TStreamProcedureRegistration,
79
77
  c: Context,
80
78
  error: Error
81
- ) => MidStreamErrorResult | undefined
79
+ ) => MidStreamErrorResult<TErrorData> | undefined
82
80
  }
83
81
 
84
82
  /**
@@ -94,11 +92,11 @@ export type HonoStreamAppBuilderConfig = {
94
92
  * const app = streamApp.app; // Hono application
95
93
  * const docs = streamApp.docs; // Stream route documentation
96
94
  */
97
- export class HonoStreamAppBuilder {
95
+ export class HonoStreamAppBuilder<TErrorData = unknown> {
98
96
  /**
99
97
  * Constructor for HonoStreamAppBuilder.
100
98
  */
101
- constructor(readonly config?: HonoStreamAppBuilderConfig) {
99
+ constructor(readonly config?: HonoStreamAppBuilderConfig<TErrorData>) {
102
100
  if (config?.app) {
103
101
  this._app = config.app
104
102
  }
@@ -215,7 +213,7 @@ export class HonoStreamAppBuilder {
215
213
  }
216
214
 
217
215
  if (this.config?.onStreamStart) {
218
- this.config.onStreamStart(procedure, c)
216
+ this.config.onStreamStart(procedure, c, streamMode)
219
217
  }
220
218
 
221
219
  if (streamMode === 'sse') {
@@ -272,7 +270,7 @@ export class HonoStreamAppBuilder {
272
270
  }
273
271
  } catch (error) {
274
272
  // Get error yield value from callback (onMidStreamError)
275
- let errorResult: MidStreamErrorResult | undefined
273
+ let errorResult: MidStreamErrorResult<TErrorData> | undefined
276
274
 
277
275
  if (this.config?.onMidStreamError) {
278
276
  errorResult = this.config.onMidStreamError(procedure, c, error as Error)
@@ -280,17 +278,20 @@ export class HonoStreamAppBuilder {
280
278
 
281
279
  // Write error value to stream
282
280
  const errorData = errorResult?.data ?? { error: (error as Error).message }
281
+ const sseMeta = getSSEMeta(errorData)
282
+
283
283
  await stream.writeSSE({
284
284
  data: typeof errorData === 'string' ? errorData : JSON.stringify(errorData),
285
- event: errorResult?.event ?? (errorResult?.data !== undefined ? procedure.name : 'error'),
286
- id: errorResult?.id ?? String(eventId++),
285
+ event: sseMeta?.event ?? (errorResult?.data !== undefined ? procedure.name : 'error'),
286
+ id: sseMeta?.id ?? String(eventId++),
287
+ ...(sseMeta?.retry !== undefined && { retry: sseMeta.retry }),
287
288
  })
288
289
 
289
290
  // closeStream defaults to true if not specified
290
291
  // (stream closes naturally after this handler completes)
291
292
  } finally {
292
293
  if (this.config?.onStreamEnd) {
293
- this.config.onStreamEnd(procedure, c)
294
+ this.config.onStreamEnd(procedure, c, 'sse')
294
295
  }
295
296
  if (this.config?.onRequestEnd) {
296
297
  this.config.onRequestEnd(c)
@@ -322,7 +323,7 @@ export class HonoStreamAppBuilder {
322
323
  }
323
324
  } catch (error) {
324
325
  // Get error yield value from callback (onMidStreamError)
325
- let errorResult: MidStreamErrorResult | undefined
326
+ let errorResult: MidStreamErrorResult<TErrorData> | undefined
326
327
 
327
328
  if (this.config?.onMidStreamError) {
328
329
  errorResult = this.config.onMidStreamError(procedure, c, error as Error)
@@ -333,7 +334,7 @@ export class HonoStreamAppBuilder {
333
334
  await stream.writeln(JSON.stringify(errorData))
334
335
  } finally {
335
336
  if (this.config?.onStreamEnd) {
336
- this.config.onStreamEnd(procedure, c)
337
+ this.config.onStreamEnd(procedure, c, 'text')
337
338
  }
338
339
  if (this.config?.onRequestEnd) {
339
340
  this.config.onRequestEnd(c)
@@ -385,7 +386,7 @@ export class HonoStreamAppBuilder {
385
386
  prefix: this.config?.pathPrefix,
386
387
  })
387
388
  const methods = ['get', 'post'] as const
388
- const jsonSchema: { params?: object; yieldType?: object; returnType?: object } = {}
389
+ const jsonSchema: { params?: Record<string, unknown>; yieldType?: Record<string, unknown>; returnType?: Record<string, unknown> } = {}
389
390
 
390
391
  if (config.schema?.params) {
391
392
  jsonSchema.params = config.schema.params
@@ -10,9 +10,9 @@ export interface StreamHttpRouteDoc extends RPCConfig {
10
10
  methods: ('get' | 'post')[]
11
11
  streamMode: StreamMode
12
12
  jsonSchema: {
13
- params?: object
14
- yieldType?: object
15
- returnType?: object
13
+ params?: Record<string, unknown>
14
+ yieldType?: Record<string, unknown>
15
+ returnType?: Record<string, unknown>
16
16
  }
17
17
  }
18
18
 
@@ -16,8 +16,8 @@ export interface RPCHttpRouteDoc extends RPCConfig {
16
16
  path: string
17
17
  method: 'post'
18
18
  jsonSchema: {
19
- body?: object
20
- response?: object
19
+ body?: Record<string, unknown>
20
+ response?: Record<string, unknown>
21
21
  }
22
22
  }
23
23
 
@@ -29,9 +29,9 @@ export interface StreamHttpRouteDoc extends RPCConfig {
29
29
  methods: ('get' | 'post')[]
30
30
  streamMode: StreamMode
31
31
  jsonSchema: {
32
- params?: object // Query params (GET) or body (POST)
33
- yieldType?: object // Schema for each streamed value
34
- returnType?: object // Final return (optional)
32
+ params?: Record<string, unknown> // Query params (GET) or body (POST)
33
+ yieldType?: Record<string, unknown> // Schema for each streamed value
34
+ returnType?: Record<string, unknown> // Final return (optional)
35
35
  }
36
36
  }
37
37