ts-procedures 4.0.1 → 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 } 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
@@ -23,7 +24,7 @@ describe('HonoStreamAppBuilder', () => {
23
24
 
24
25
  RPC.CreateStream('StreamMessages', { scope: 'messages', version: 1 }, async function* () {
25
26
  yield { message: 'hello' }
26
- yield { data: { message: 'world' } }
27
+ yield { message: 'world' }
27
28
  })
28
29
 
29
30
  builder.register(RPC, () => ({ userId: '123' }))
@@ -45,7 +46,7 @@ describe('HonoStreamAppBuilder', () => {
45
46
  const RPC = Procedures<{ userId: string }, RPCConfig>()
46
47
 
47
48
  RPC.CreateStream('StreamData', { scope: 'data', version: 1 }, async function* () {
48
- yield { data: { data: 1 } }
49
+ yield { data: 1 }
49
50
  })
50
51
 
51
52
  builder.register(RPC, () => ({ userId: '123' }))
@@ -84,9 +85,9 @@ describe('HonoStreamAppBuilder', () => {
84
85
  const RPC = Procedures<{}, RPCConfig>()
85
86
 
86
87
  RPC.CreateStream('Counter', { scope: 'counter', version: 1 }, async function* () {
87
- yield { data: { count: 1 } }
88
- yield { data: { count: 2 } }
89
- yield { data: { count: 3 } }
88
+ yield { count: 1 }
89
+ yield { count: 2 }
90
+ yield { count: 3 }
90
91
  })
91
92
 
92
93
  builder.register(RPC, () => ({}))
@@ -111,7 +112,7 @@ describe('HonoStreamAppBuilder', () => {
111
112
  const RPC = Procedures<{}, RPCConfig>()
112
113
 
113
114
  RPC.CreateStream('Test', { scope: 'test', version: 1 }, async function* () {
114
- yield { data: { ok: true } }
115
+ yield { ok: true }
115
116
  })
116
117
 
117
118
  builder.register(RPC, () => ({}))
@@ -126,7 +127,7 @@ describe('HonoStreamAppBuilder', () => {
126
127
  const RPC = Procedures<{}, RPCConfig>()
127
128
 
128
129
  RPC.CreateStream('Test', { scope: 'test', version: 1 }, async function* () {
129
- yield { data: { ok: true } }
130
+ yield { ok: true }
130
131
  })
131
132
 
132
133
  builder.register(RPC, () => ({}))
@@ -191,7 +192,7 @@ describe('HonoStreamAppBuilder', () => {
191
192
  const RPC = Procedures<{}, RPCConfig>()
192
193
 
193
194
  RPC.CreateStream('Test', { scope: 'test', version: 1 }, async function* () {
194
- yield { data: { method: 'works' } }
195
+ yield { method: 'works' }
195
196
  })
196
197
 
197
198
  builder.register(RPC, () => ({}))
@@ -206,7 +207,7 @@ describe('HonoStreamAppBuilder', () => {
206
207
  const RPC = Procedures<{}, RPCConfig>()
207
208
 
208
209
  RPC.CreateStream('Test', { scope: 'test', version: 1 }, async function* () {
209
- yield { data: { method: 'works' } }
210
+ yield { method: 'works' }
210
211
  })
211
212
 
212
213
  builder.register(RPC, () => ({}))
@@ -268,7 +269,7 @@ describe('HonoStreamAppBuilder', () => {
268
269
  const RPC = Procedures<{}, RPCConfig>()
269
270
 
270
271
  RPC.CreateStream('Test', { scope: 'test', version: 1 }, async function* () {
271
- yield { data: { ok: true } }
272
+ yield { ok: true }
272
273
  })
273
274
 
274
275
  builder.register(RPC, () => ({}))
@@ -283,7 +284,7 @@ describe('HonoStreamAppBuilder', () => {
283
284
  const RPC = Procedures<{}, RPCConfig>()
284
285
 
285
286
  RPC.CreateStream('Test', { scope: 'test', version: 1 }, async function* () {
286
- yield { data: { ok: true } }
287
+ yield { ok: true }
287
288
  })
288
289
 
289
290
  builder.register(RPC, () => ({}))
@@ -298,7 +299,7 @@ describe('HonoStreamAppBuilder', () => {
298
299
  const RPC = Procedures<{}, RPCConfig>()
299
300
 
300
301
  RPC.CreateStream('Test', { scope: 'test', version: 1 }, async function* () {
301
- yield { data: {} }
302
+ yield {}
302
303
  })
303
304
 
304
305
  builder.register(RPC, () => ({}))
@@ -318,7 +319,7 @@ describe('HonoStreamAppBuilder', () => {
318
319
  const RPC = Procedures<{}, RPCConfig>()
319
320
 
320
321
  RPC.CreateStream('Test', { scope: 'test', version: 1 }, async function* () {
321
- yield { data: { ok: true } }
322
+ yield { ok: true }
322
323
  })
323
324
 
324
325
  builder.register(RPC, () => ({}))
@@ -336,7 +337,7 @@ describe('HonoStreamAppBuilder', () => {
336
337
  const RPC = Procedures<{}, RPCConfig>()
337
338
 
338
339
  RPC.CreateStream('Test', { scope: 'test', version: 1 }, async function* () {
339
- yield { data: { ok: true } }
340
+ yield { ok: true }
340
341
  })
341
342
 
342
343
  builder.register(RPC, () => ({}))
@@ -349,13 +350,13 @@ 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>()
356
357
 
357
358
  RPC.CreateStream('Test', { scope: 'test', version: 1 }, async function* () {
358
- yield { data: { ok: true } }
359
+ yield { ok: true }
359
360
  })
360
361
 
361
362
  builder.register(RPC, () => ({}))
@@ -365,15 +366,16 @@ 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>()
374
376
 
375
377
  RPC.CreateStream('Test', { scope: 'test', version: 1 }, async function* () {
376
- yield { data: { ok: true } }
378
+ yield { ok: true }
377
379
  })
378
380
 
379
381
  builder.register(RPC, () => ({}))
@@ -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 () => {
@@ -400,7 +403,7 @@ describe('HonoStreamAppBuilder', () => {
400
403
 
401
404
  RPC.CreateStream('Test', { scope: 'test', version: 1 }, async function* () {
402
405
  order.push('handler')
403
- yield { data: { ok: true } }
406
+ yield { ok: true }
404
407
  })
405
408
 
406
409
  builder.register(RPC, () => ({}))
@@ -448,7 +451,7 @@ describe('HonoStreamAppBuilder', () => {
448
451
  },
449
452
  },
450
453
  async function* (ctx, params) {
451
- yield { data: { count: params.count } }
454
+ yield { count: params.count }
452
455
  }
453
456
  )
454
457
 
@@ -471,7 +474,7 @@ describe('HonoStreamAppBuilder', () => {
471
474
  const RPC = Procedures<{}, RPCConfig>()
472
475
 
473
476
  RPC.CreateStream('ErrorStream', { scope: 'error', version: 1 }, async function* () {
474
- yield { data: { count: 1 } }
477
+ yield { count: 1 }
475
478
  throw new Error('Stream error')
476
479
  })
477
480
 
@@ -521,7 +524,7 @@ describe('HonoStreamAppBuilder', () => {
521
524
  },
522
525
  },
523
526
  async function* (ctx, params) {
524
- yield { data: { count: params.count } }
527
+ yield { count: params.count }
525
528
  }
526
529
  )
527
530
 
@@ -558,7 +561,7 @@ describe('HonoStreamAppBuilder', () => {
558
561
  },
559
562
  },
560
563
  async function* (ctx, params) {
561
- yield { data: { count: params.count } }
564
+ yield { count: params.count }
562
565
  }
563
566
  )
564
567
 
@@ -584,7 +587,7 @@ describe('HonoStreamAppBuilder', () => {
584
587
  const RPC = Procedures<{ userId: string }, RPCConfig>()
585
588
 
586
589
  RPC.CreateStream('SecureStream', { scope: 'secure', version: 1 }, async function* (ctx) {
587
- yield { data: { userId: ctx.userId } }
590
+ yield { userId: ctx.userId }
588
591
  })
589
592
 
590
593
  builder.register(RPC, () => {
@@ -618,7 +621,7 @@ describe('HonoStreamAppBuilder', () => {
618
621
  const RPC = Procedures<{}, RPCConfig>()
619
622
 
620
623
  RPC.CreateStream('ErrorStream', { scope: 'error', version: 1 }, async function* () {
621
- yield { data: { type: 'data', value: 1 } }
624
+ yield { type: 'data', value: 1 }
622
625
  throw new Error('Something broke')
623
626
  })
624
627
 
@@ -794,14 +797,15 @@ describe('HonoStreamAppBuilder', () => {
794
797
  expect(doc.jsonSchema.params).toBeDefined()
795
798
  expect(doc.jsonSchema.returnType).toBeDefined()
796
799
 
797
- // SSE envelope fields are merged into yieldType
800
+ // yieldType is nested under SSE envelope's data property
798
801
  const yt = doc.jsonSchema.yieldType as Record<string, any>
802
+ expect(yt.description).toBe('SSE message envelope. The data field contains the procedure yield value.')
799
803
  expect(yt.required).toEqual(['data', 'event', 'id'])
800
804
  expect(yt.properties.event).toEqual({ type: 'string' })
801
805
  expect(yt.properties.id).toEqual({ type: 'string' })
802
806
  expect(yt.properties.retry).toEqual({ type: 'number' })
803
- // Developer's data property is preserved
804
- expect(yt.properties.message).toBeDefined()
807
+ // Developer's yieldType is nested under data
808
+ expect(yt.properties.data.properties.message).toBeDefined()
805
809
  })
806
810
 
807
811
  test('SSE mode generates SSE envelope even when no yieldType is defined', () => {
@@ -818,7 +822,9 @@ describe('HonoStreamAppBuilder', () => {
818
822
  const yt = builder.docs[0]!.jsonSchema.yieldType as Record<string, any>
819
823
  expect(yt).toBeDefined()
820
824
  expect(yt.type).toBe('object')
825
+ expect(yt.description).toBe('SSE message envelope. The data field contains the procedure yield value.')
821
826
  expect(yt.required).toEqual(['data', 'event', 'id'])
827
+ // data is empty schema when no yieldType defined
822
828
  expect(yt.properties.data).toEqual({})
823
829
  expect(yt.properties.event).toEqual({ type: 'string' })
824
830
  expect(yt.properties.id).toEqual({ type: 'string' })
@@ -849,25 +855,21 @@ describe('HonoStreamAppBuilder', () => {
849
855
  expect(yt.properties?.retry).toBeUndefined()
850
856
  })
851
857
 
852
- test('developer-defined event/id in yieldType are preserved', () => {
858
+ test('yieldType with id property does not collide with SSE id field', () => {
859
+ // User's yieldType has an `id` field (number) — this should be nested under
860
+ // the SSE envelope's `data` property, not collide with the SSE `id` (string)
853
861
  const yieldSchema = v.object({
854
- data: v.object({ msg: v.string() }),
855
- event: v.string().const('custom-event'),
856
- id: v.string().const('fixed-id'),
862
+ id: v.number(),
863
+ message: v.string(),
857
864
  })
858
865
  const builder = new HonoStreamAppBuilder()
859
866
  const RPC = Procedures<{}, RPCConfig>()
860
867
 
861
868
  RPC.CreateStream(
862
- 'CustomSSE',
869
+ 'Notifications',
863
870
  { scope: 'test', version: 1, schema: { yieldType: yieldSchema } },
864
871
  async function* () {
865
- // suretype sucks, we have to assert this as the exact type to satisfy the const assertions in the schema
866
- yield { data: { msg: 'hi' }, event: 'custom-event', id: 'fixed-id' } as {
867
- data: { msg: string }
868
- event: 'custom-event'
869
- id: 'fixed-id'
870
- }
872
+ yield { id: 42, message: 'hello' }
871
873
  }
872
874
  )
873
875
 
@@ -876,11 +878,11 @@ describe('HonoStreamAppBuilder', () => {
876
878
 
877
879
  const yt = builder.docs[0]!.jsonSchema.yieldType as Record<string, any>
878
880
  expect(yt.required).toEqual(['data', 'event', 'id'])
879
- // Developer's custom event/id definitions take precedence
880
- expect(yt.properties.event.const).toBe('custom-event')
881
- expect(yt.properties.id.const).toBe('fixed-id')
882
- // retry is still added as a base default
883
- expect(yt.properties.retry).toEqual({ type: 'number' })
881
+ // SSE envelope id is a string
882
+ expect(yt.properties.id).toEqual({ type: 'string' })
883
+ // User's id (number) is safely nested under data
884
+ expect(yt.properties.data.properties.id.type).toBe('number')
885
+ expect(yt.properties.data.properties.message.type).toBe('string')
884
886
  })
885
887
 
886
888
  test('streamMode is recorded in docs', () => {
@@ -927,7 +929,7 @@ describe('HonoStreamAppBuilder', () => {
927
929
 
928
930
  // Streaming procedure (should be registered)
929
931
  RPC.CreateStream('Stream', { scope: 'stream', version: 1 }, async function* () {
930
- yield { data: { ok: true } }
932
+ yield { ok: true }
931
933
  })
932
934
 
933
935
  builder.register(RPC, () => ({}))
@@ -956,7 +958,7 @@ describe('HonoStreamAppBuilder', () => {
956
958
  const RPC = Procedures<{}, RPCConfig>()
957
959
 
958
960
  RPC.CreateStream('StreamEvents', { scope: 'events', version: 1 }, async function* () {
959
- yield { data: {} }
961
+ yield {}
960
962
  })
961
963
 
962
964
  builder.register(RPC, () => ({}), {
@@ -979,7 +981,7 @@ describe('HonoStreamAppBuilder', () => {
979
981
  const RPC = Procedures<{}, RPCConfig>()
980
982
 
981
983
  RPC.CreateStream('Test', { scope: 'test', version: 1 }, async function* () {
982
- yield { data: {} }
984
+ yield {}
983
985
  })
984
986
 
985
987
  builder.register(RPC, () => ({}), {
@@ -1050,7 +1052,7 @@ describe('HonoStreamAppBuilder', () => {
1050
1052
  const TextRPC = Procedures<{}, RPCConfig>()
1051
1053
 
1052
1054
  SSERPC.CreateStream('SSEStream', { scope: 'sse', version: 1 }, async function* () {
1053
- yield { data: { mode: 'sse' } }
1055
+ yield { mode: 'sse' }
1054
1056
  })
1055
1057
 
1056
1058
  TextRPC.CreateStream('TextStream', { scope: 'text', version: 1 }, async function* () {
@@ -1114,7 +1116,7 @@ describe('HonoStreamAppBuilder', () => {
1114
1116
 
1115
1117
  RPC.CreateStream('CheckPrevalidated', { scope: 'check', version: 1 }, async function* (ctx) {
1116
1118
  receivedIsPrevalidated = ctx.isPrevalidated
1117
- yield { data: { ok: true } }
1119
+ yield { ok: true }
1118
1120
  })
1119
1121
 
1120
1122
  builder.register(RPC, () => ({}))
@@ -1200,13 +1202,13 @@ describe('HonoStreamAppBuilder', () => {
1200
1202
  // SSE Yield Shape Tests
1201
1203
  // --------------------------------------------------------------------------
1202
1204
  describe('SSE yield shape', () => {
1203
- test('custom event names in yields', async () => {
1205
+ test('custom event names via sse() helper', async () => {
1204
1206
  const builder = new HonoStreamAppBuilder()
1205
1207
  const RPC = Procedures<{}, RPCConfig>()
1206
1208
 
1207
1209
  RPC.CreateStream('Events', { scope: 'events', version: 1 }, async function* () {
1208
- yield { data: { type: 'user_joined' }, event: 'join' }
1209
- yield { data: { type: 'message' }, event: 'chat' }
1210
+ yield sse({ type: 'user_joined' }, { event: 'join' })
1211
+ yield sse({ type: 'message' }, { event: 'chat' })
1210
1212
  })
1211
1213
 
1212
1214
  builder.register(RPC, () => ({}))
@@ -1220,13 +1222,13 @@ describe('HonoStreamAppBuilder', () => {
1220
1222
  expect(text).not.toContain('event: Events')
1221
1223
  })
1222
1224
 
1223
- test('custom id in yields', async () => {
1225
+ test('custom id via sse() helper', async () => {
1224
1226
  const builder = new HonoStreamAppBuilder()
1225
1227
  const RPC = Procedures<{}, RPCConfig>()
1226
1228
 
1227
1229
  RPC.CreateStream('Events', { scope: 'events', version: 1 }, async function* () {
1228
- yield { data: { msg: 'first' }, id: 'msg-001' }
1229
- yield { data: { msg: 'second' }, id: 'msg-002' }
1230
+ yield sse({ msg: 'first' }, { id: 'msg-001' })
1231
+ yield sse({ msg: 'second' }, { id: 'msg-002' })
1230
1232
  })
1231
1233
 
1232
1234
  builder.register(RPC, () => ({}))
@@ -1244,8 +1246,8 @@ describe('HonoStreamAppBuilder', () => {
1244
1246
  const RPC = Procedures<{}, RPCConfig>()
1245
1247
 
1246
1248
  RPC.CreateStream('Events', { scope: 'events', version: 1 }, async function* () {
1247
- yield { data: 'already a string' }
1248
- yield { data: { needs: 'stringify' } }
1249
+ yield 'already a string'
1250
+ yield { needs: 'stringify' }
1249
1251
  })
1250
1252
 
1251
1253
  builder.register(RPC, () => ({}))
@@ -1265,9 +1267,9 @@ describe('HonoStreamAppBuilder', () => {
1265
1267
  const RPC = Procedures<{}, RPCConfig>()
1266
1268
 
1267
1269
  RPC.CreateStream('MyProcedure', { scope: 'test', version: 1 }, async function* () {
1268
- yield { data: { value: 1 } }
1269
- yield { data: { value: 2 }, event: 'custom' }
1270
- yield { data: { value: 3 } }
1270
+ yield { value: 1 }
1271
+ yield sse({ value: 2 }, { event: 'custom' })
1272
+ yield { value: 3 }
1271
1273
  })
1272
1274
 
1273
1275
  builder.register(RPC, () => ({}))
@@ -1288,6 +1290,312 @@ describe('HonoStreamAppBuilder', () => {
1288
1290
  })
1289
1291
  })
1290
1292
 
1293
+ // --------------------------------------------------------------------------
1294
+ // sse() Helper Tests
1295
+ // --------------------------------------------------------------------------
1296
+ describe('sse() helper', () => {
1297
+ test('tagged yields with custom event/id/retry', async () => {
1298
+ const builder = new HonoStreamAppBuilder()
1299
+ const RPC = Procedures<{}, RPCConfig>()
1300
+
1301
+ RPC.CreateStream('Tagged', { scope: 'tagged', version: 1 }, async function* () {
1302
+ yield sse({ count: 1 }, { event: 'tick', id: 'evt-1', retry: 5000 })
1303
+ })
1304
+
1305
+ builder.register(RPC, () => ({}))
1306
+ const app = builder.build()
1307
+
1308
+ const res = await app.request('/tagged/tagged/1')
1309
+ const text = await res.text()
1310
+
1311
+ expect(text).toContain('event: tick')
1312
+ expect(text).toContain('id: evt-1')
1313
+ expect(text).toContain('retry: 5000')
1314
+ expect(text).toContain('data: {"count":1}')
1315
+ })
1316
+
1317
+ test('plain domain objects use procedure name and auto-incremented id', async () => {
1318
+ const builder = new HonoStreamAppBuilder()
1319
+ const RPC = Procedures<{}, RPCConfig>()
1320
+
1321
+ RPC.CreateStream('Plain', { scope: 'plain', version: 1 }, async function* () {
1322
+ yield { a: 1 }
1323
+ yield { a: 2 }
1324
+ })
1325
+
1326
+ builder.register(RPC, () => ({}))
1327
+ const app = builder.build()
1328
+
1329
+ const res = await app.request('/plain/plain/1')
1330
+ const text = await res.text()
1331
+
1332
+ const messages = text.split('\n\n').filter(Boolean)
1333
+ expect(messages[0]).toContain('event: Plain')
1334
+ expect(messages[0]).toContain('id: 0')
1335
+ expect(messages[0]).toContain('data: {"a":1}')
1336
+ expect(messages[1]).toContain('event: Plain')
1337
+ expect(messages[1]).toContain('id: 1')
1338
+ expect(messages[1]).toContain('data: {"a":2}')
1339
+ })
1340
+
1341
+ test('sse() metadata is invisible in text mode', async () => {
1342
+ const builder = new HonoStreamAppBuilder({ defaultStreamMode: 'text' })
1343
+ const RPC = Procedures<{}, RPCConfig>()
1344
+
1345
+ RPC.CreateStream('TextTagged', { scope: 'text', version: 1 }, async function* () {
1346
+ yield sse({ count: 1 }, { event: 'tick' })
1347
+ yield { count: 2 }
1348
+ })
1349
+
1350
+ builder.register(RPC, () => ({}))
1351
+ const app = builder.build()
1352
+
1353
+ const res = await app.request('/text/text-tagged/1')
1354
+ const text = await res.text()
1355
+ const lines = text.trim().split('\n')
1356
+
1357
+ // Text mode just JSON-stringifies — sse() metadata is not visible
1358
+ expect(JSON.parse(lines[0]!)).toEqual({ count: 1 })
1359
+ expect(JSON.parse(lines[1]!)).toEqual({ count: 2 })
1360
+ })
1361
+
1362
+ test('sse() with partial options', async () => {
1363
+ const builder = new HonoStreamAppBuilder()
1364
+ const RPC = Procedures<{}, RPCConfig>()
1365
+
1366
+ RPC.CreateStream('Partial', { scope: 'partial', version: 1 }, async function* () {
1367
+ yield sse({ v: 1 }, { event: 'custom' })
1368
+ yield sse({ v: 2 }, { id: 'my-id' })
1369
+ yield sse({ v: 3 })
1370
+ })
1371
+
1372
+ builder.register(RPC, () => ({}))
1373
+ const app = builder.build()
1374
+
1375
+ const res = await app.request('/partial/partial/1')
1376
+ const text = await res.text()
1377
+ const messages = text.split('\n\n').filter(Boolean)
1378
+
1379
+ // First: custom event, auto id
1380
+ expect(messages[0]).toContain('event: custom')
1381
+ expect(messages[0]).toContain('id: 0')
1382
+
1383
+ // Second: default event, custom id
1384
+ expect(messages[1]).toContain('event: Partial')
1385
+ expect(messages[1]).toContain('id: my-id')
1386
+
1387
+ // Third: sse() with no options — same as plain object (defaults)
1388
+ expect(messages[2]).toContain('event: Partial')
1389
+ expect(messages[2]).toContain('id: 2')
1390
+ })
1391
+ })
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
+
1291
1599
  // --------------------------------------------------------------------------
1292
1600
  // Integration Test
1293
1601
  // --------------------------------------------------------------------------
@@ -1330,8 +1638,8 @@ describe('HonoStreamAppBuilder', () => {
1330
1638
  defaultStreamMode: 'text',
1331
1639
  onRequestStart: () => events.push('request-start'),
1332
1640
  onRequestEnd: () => events.push('request-end'),
1333
- onStreamStart: (proc) => events.push(`stream-start:${proc.name}`),
1334
- onStreamEnd: (proc) => events.push(`stream-end:${proc.name}`),
1641
+ onStreamStart: () => events.push('stream-start'),
1642
+ onStreamEnd: () => events.push('stream-end'),
1335
1643
  })
1336
1644
 
1337
1645
  builder.register(RPC, (c) => ({
@@ -1360,8 +1668,8 @@ describe('HonoStreamAppBuilder', () => {
1360
1668
 
1361
1669
  // Verify hooks were called
1362
1670
  expect(events).toContain('request-start')
1363
- expect(events).toContain('stream-start:WatchNotifications')
1364
- expect(events).toContain('stream-end:WatchNotifications')
1671
+ expect(events).toContain('stream-start')
1672
+ expect(events).toContain('stream-end')
1365
1673
  expect(events).toContain('request-end')
1366
1674
  })
1367
1675
  })