ts-procedures 4.0.0 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,8 +1,9 @@
1
+ /* eslint-disable @typescript-eslint/no-empty-object-type, @typescript-eslint/explicit-function-return-type */
1
2
  import { describe, expect, test, vi, beforeEach } from 'vitest'
2
3
  import { Hono } from 'hono'
3
4
  import { v } from 'suretype'
4
5
  import { Procedures } from '../../../index.js'
5
- import { HonoStreamAppBuilder } from './index.js'
6
+ import { HonoStreamAppBuilder, sse } from './index.js'
6
7
  import { RPCConfig } from '../../types.js'
7
8
 
8
9
  /**
@@ -20,9 +21,9 @@ describe('HonoStreamAppBuilder', () => {
20
21
  const builder = new HonoStreamAppBuilder()
21
22
  const RPC = Procedures<{ userId: string }, RPCConfig>()
22
23
 
23
- RPC.CreateStream('StreamMessages', { scope: 'messages', version: 1 }, async function* (ctx) {
24
+ RPC.CreateStream('StreamMessages', { scope: 'messages', version: 1 }, async function* () {
24
25
  yield { message: 'hello' }
25
- yield { data: { message: 'world' } }
26
+ yield { message: 'world' }
26
27
  })
27
28
 
28
29
  builder.register(RPC, () => ({ userId: '123' }))
@@ -44,7 +45,7 @@ describe('HonoStreamAppBuilder', () => {
44
45
  const RPC = Procedures<{ userId: string }, RPCConfig>()
45
46
 
46
47
  RPC.CreateStream('StreamData', { scope: 'data', version: 1 }, async function* () {
47
- yield { data: { data: 1 } }
48
+ yield { data: 1 }
48
49
  })
49
50
 
50
51
  builder.register(RPC, () => ({ userId: '123' }))
@@ -83,9 +84,9 @@ describe('HonoStreamAppBuilder', () => {
83
84
  const RPC = Procedures<{}, RPCConfig>()
84
85
 
85
86
  RPC.CreateStream('Counter', { scope: 'counter', version: 1 }, async function* () {
86
- yield { data: { count: 1 } }
87
- yield { data: { count: 2 } }
88
- yield { data: { count: 3 } }
87
+ yield { count: 1 }
88
+ yield { count: 2 }
89
+ yield { count: 3 }
89
90
  })
90
91
 
91
92
  builder.register(RPC, () => ({}))
@@ -110,7 +111,7 @@ describe('HonoStreamAppBuilder', () => {
110
111
  const RPC = Procedures<{}, RPCConfig>()
111
112
 
112
113
  RPC.CreateStream('Test', { scope: 'test', version: 1 }, async function* () {
113
- yield { data: { ok: true } }
114
+ yield { ok: true }
114
115
  })
115
116
 
116
117
  builder.register(RPC, () => ({}))
@@ -125,7 +126,7 @@ describe('HonoStreamAppBuilder', () => {
125
126
  const RPC = Procedures<{}, RPCConfig>()
126
127
 
127
128
  RPC.CreateStream('Test', { scope: 'test', version: 1 }, async function* () {
128
- yield { data: { ok: true } }
129
+ yield { ok: true }
129
130
  })
130
131
 
131
132
  builder.register(RPC, () => ({}))
@@ -190,7 +191,7 @@ describe('HonoStreamAppBuilder', () => {
190
191
  const RPC = Procedures<{}, RPCConfig>()
191
192
 
192
193
  RPC.CreateStream('Test', { scope: 'test', version: 1 }, async function* () {
193
- yield { data: { method: 'works' } }
194
+ yield { method: 'works' }
194
195
  })
195
196
 
196
197
  builder.register(RPC, () => ({}))
@@ -205,7 +206,7 @@ describe('HonoStreamAppBuilder', () => {
205
206
  const RPC = Procedures<{}, RPCConfig>()
206
207
 
207
208
  RPC.CreateStream('Test', { scope: 'test', version: 1 }, async function* () {
208
- yield { data: { method: 'works' } }
209
+ yield { method: 'works' }
209
210
  })
210
211
 
211
212
  builder.register(RPC, () => ({}))
@@ -267,7 +268,7 @@ describe('HonoStreamAppBuilder', () => {
267
268
  const RPC = Procedures<{}, RPCConfig>()
268
269
 
269
270
  RPC.CreateStream('Test', { scope: 'test', version: 1 }, async function* () {
270
- yield { data: { ok: true } }
271
+ yield { ok: true }
271
272
  })
272
273
 
273
274
  builder.register(RPC, () => ({}))
@@ -282,7 +283,7 @@ describe('HonoStreamAppBuilder', () => {
282
283
  const RPC = Procedures<{}, RPCConfig>()
283
284
 
284
285
  RPC.CreateStream('Test', { scope: 'test', version: 1 }, async function* () {
285
- yield { data: { ok: true } }
286
+ yield { ok: true }
286
287
  })
287
288
 
288
289
  builder.register(RPC, () => ({}))
@@ -297,7 +298,7 @@ describe('HonoStreamAppBuilder', () => {
297
298
  const RPC = Procedures<{}, RPCConfig>()
298
299
 
299
300
  RPC.CreateStream('Test', { scope: 'test', version: 1 }, async function* () {
300
- yield { data: {} }
301
+ yield {}
301
302
  })
302
303
 
303
304
  builder.register(RPC, () => ({}))
@@ -317,7 +318,7 @@ describe('HonoStreamAppBuilder', () => {
317
318
  const RPC = Procedures<{}, RPCConfig>()
318
319
 
319
320
  RPC.CreateStream('Test', { scope: 'test', version: 1 }, async function* () {
320
- yield { data: { ok: true } }
321
+ yield { ok: true }
321
322
  })
322
323
 
323
324
  builder.register(RPC, () => ({}))
@@ -335,7 +336,7 @@ describe('HonoStreamAppBuilder', () => {
335
336
  const RPC = Procedures<{}, RPCConfig>()
336
337
 
337
338
  RPC.CreateStream('Test', { scope: 'test', version: 1 }, async function* () {
338
- yield { data: { ok: true } }
339
+ yield { ok: true }
339
340
  })
340
341
 
341
342
  builder.register(RPC, () => ({}))
@@ -354,7 +355,7 @@ describe('HonoStreamAppBuilder', () => {
354
355
  const RPC = Procedures<{}, RPCConfig>()
355
356
 
356
357
  RPC.CreateStream('Test', { scope: 'test', version: 1 }, async function* () {
357
- yield { data: { ok: true } }
358
+ yield { ok: true }
358
359
  })
359
360
 
360
361
  builder.register(RPC, () => ({}))
@@ -372,7 +373,7 @@ describe('HonoStreamAppBuilder', () => {
372
373
  const RPC = Procedures<{}, RPCConfig>()
373
374
 
374
375
  RPC.CreateStream('Test', { scope: 'test', version: 1 }, async function* () {
375
- yield { data: { ok: true } }
376
+ yield { ok: true }
376
377
  })
377
378
 
378
379
  builder.register(RPC, () => ({}))
@@ -399,7 +400,7 @@ describe('HonoStreamAppBuilder', () => {
399
400
 
400
401
  RPC.CreateStream('Test', { scope: 'test', version: 1 }, async function* () {
401
402
  order.push('handler')
402
- yield { data: { ok: true } }
403
+ yield { ok: true }
403
404
  })
404
405
 
405
406
  builder.register(RPC, () => ({}))
@@ -447,7 +448,7 @@ describe('HonoStreamAppBuilder', () => {
447
448
  },
448
449
  },
449
450
  async function* (ctx, params) {
450
- yield { data: { count: params.count } }
451
+ yield { count: params.count }
451
452
  }
452
453
  )
453
454
 
@@ -470,7 +471,7 @@ describe('HonoStreamAppBuilder', () => {
470
471
  const RPC = Procedures<{}, RPCConfig>()
471
472
 
472
473
  RPC.CreateStream('ErrorStream', { scope: 'error', version: 1 }, async function* () {
473
- yield { data: { count: 1 } }
474
+ yield { count: 1 }
474
475
  throw new Error('Stream error')
475
476
  })
476
477
 
@@ -520,7 +521,7 @@ describe('HonoStreamAppBuilder', () => {
520
521
  },
521
522
  },
522
523
  async function* (ctx, params) {
523
- yield { data: { count: params.count } }
524
+ yield { count: params.count }
524
525
  }
525
526
  )
526
527
 
@@ -557,7 +558,7 @@ describe('HonoStreamAppBuilder', () => {
557
558
  },
558
559
  },
559
560
  async function* (ctx, params) {
560
- yield { data: { count: params.count } }
561
+ yield { count: params.count }
561
562
  }
562
563
  )
563
564
 
@@ -583,7 +584,7 @@ describe('HonoStreamAppBuilder', () => {
583
584
  const RPC = Procedures<{ userId: string }, RPCConfig>()
584
585
 
585
586
  RPC.CreateStream('SecureStream', { scope: 'secure', version: 1 }, async function* (ctx) {
586
- yield { data: { userId: ctx.userId } }
587
+ yield { userId: ctx.userId }
587
588
  })
588
589
 
589
590
  builder.register(RPC, () => {
@@ -617,7 +618,7 @@ describe('HonoStreamAppBuilder', () => {
617
618
  const RPC = Procedures<{}, RPCConfig>()
618
619
 
619
620
  RPC.CreateStream('ErrorStream', { scope: 'error', version: 1 }, async function* () {
620
- yield { data: { type: 'data', value: 1 } }
621
+ yield { type: 'data', value: 1 }
621
622
  throw new Error('Something broke')
622
623
  })
623
624
 
@@ -791,8 +792,94 @@ describe('HonoStreamAppBuilder', () => {
791
792
  expect(doc.methods).toEqual(['get', 'post'])
792
793
  expect(doc.streamMode).toBe('sse')
793
794
  expect(doc.jsonSchema.params).toBeDefined()
794
- expect(doc.jsonSchema.yieldType).toBeDefined()
795
795
  expect(doc.jsonSchema.returnType).toBeDefined()
796
+
797
+ // yieldType is nested under SSE envelope's data property
798
+ const yt = doc.jsonSchema.yieldType as Record<string, any>
799
+ expect(yt.description).toBe('SSE message envelope. The data field contains the procedure yield value.')
800
+ expect(yt.required).toEqual(['data', 'event', 'id'])
801
+ expect(yt.properties.event).toEqual({ type: 'string' })
802
+ expect(yt.properties.id).toEqual({ type: 'string' })
803
+ expect(yt.properties.retry).toEqual({ type: 'number' })
804
+ // Developer's yieldType is nested under data
805
+ expect(yt.properties.data.properties.message).toBeDefined()
806
+ })
807
+
808
+ test('SSE mode generates SSE envelope even when no yieldType is defined', () => {
809
+ const builder = new HonoStreamAppBuilder()
810
+ const RPC = Procedures<{}, RPCConfig>()
811
+
812
+ RPC.CreateStream('NoYield', { scope: 'test', version: 1 }, async function* () {
813
+ yield {}
814
+ })
815
+
816
+ builder.register(RPC, () => ({}))
817
+ builder.build()
818
+
819
+ const yt = builder.docs[0]!.jsonSchema.yieldType as Record<string, any>
820
+ expect(yt).toBeDefined()
821
+ expect(yt.type).toBe('object')
822
+ expect(yt.description).toBe('SSE message envelope. The data field contains the procedure yield value.')
823
+ expect(yt.required).toEqual(['data', 'event', 'id'])
824
+ // data is empty schema when no yieldType defined
825
+ expect(yt.properties.data).toEqual({})
826
+ expect(yt.properties.event).toEqual({ type: 'string' })
827
+ expect(yt.properties.id).toEqual({ type: 'string' })
828
+ expect(yt.properties.retry).toEqual({ type: 'number' })
829
+ })
830
+
831
+ test('text mode passes yieldType through as-is', () => {
832
+ const yieldSchema = v.object({ chunk: v.string() })
833
+ const builder = new HonoStreamAppBuilder({ defaultStreamMode: 'text' })
834
+ const RPC = Procedures<{}, RPCConfig>()
835
+
836
+ RPC.CreateStream(
837
+ 'TextStream',
838
+ { scope: 'test', version: 1, schema: { yieldType: yieldSchema } },
839
+ async function* () {
840
+ yield { chunk: 'hi' }
841
+ }
842
+ )
843
+
844
+ builder.register(RPC, () => ({}))
845
+ builder.build()
846
+
847
+ const yt = builder.docs[0]!.jsonSchema.yieldType as Record<string, any>
848
+ expect(yt).toBeDefined()
849
+ // Text mode should NOT have SSE envelope fields injected
850
+ expect(yt.properties?.event).toBeUndefined()
851
+ expect(yt.properties?.id).toBeUndefined()
852
+ expect(yt.properties?.retry).toBeUndefined()
853
+ })
854
+
855
+ test('yieldType with id property does not collide with SSE id field', () => {
856
+ // User's yieldType has an `id` field (number) — this should be nested under
857
+ // the SSE envelope's `data` property, not collide with the SSE `id` (string)
858
+ const yieldSchema = v.object({
859
+ id: v.number(),
860
+ message: v.string(),
861
+ })
862
+ const builder = new HonoStreamAppBuilder()
863
+ const RPC = Procedures<{}, RPCConfig>()
864
+
865
+ RPC.CreateStream(
866
+ 'Notifications',
867
+ { scope: 'test', version: 1, schema: { yieldType: yieldSchema } },
868
+ async function* () {
869
+ yield { id: 42, message: 'hello' }
870
+ }
871
+ )
872
+
873
+ builder.register(RPC, () => ({}))
874
+ builder.build()
875
+
876
+ const yt = builder.docs[0]!.jsonSchema.yieldType as Record<string, any>
877
+ expect(yt.required).toEqual(['data', 'event', 'id'])
878
+ // SSE envelope id is a string
879
+ expect(yt.properties.id).toEqual({ type: 'string' })
880
+ // User's id (number) is safely nested under data
881
+ expect(yt.properties.data.properties.id.type).toBe('number')
882
+ expect(yt.properties.data.properties.message.type).toBe('string')
796
883
  })
797
884
 
798
885
  test('streamMode is recorded in docs', () => {
@@ -839,7 +926,7 @@ describe('HonoStreamAppBuilder', () => {
839
926
 
840
927
  // Streaming procedure (should be registered)
841
928
  RPC.CreateStream('Stream', { scope: 'stream', version: 1 }, async function* () {
842
- yield { data: { ok: true } }
929
+ yield { ok: true }
843
930
  })
844
931
 
845
932
  builder.register(RPC, () => ({}))
@@ -868,11 +955,11 @@ describe('HonoStreamAppBuilder', () => {
868
955
  const RPC = Procedures<{}, RPCConfig>()
869
956
 
870
957
  RPC.CreateStream('StreamEvents', { scope: 'events', version: 1 }, async function* () {
871
- yield { data: {} }
958
+ yield {}
872
959
  })
873
960
 
874
961
  builder.register(RPC, () => ({}), {
875
- extendProcedureDoc: ({ base, procedure }) => ({
962
+ extendProcedureDoc: ({ procedure }) => ({
876
963
  summary: `Stream events endpoint`,
877
964
  tags: ['events'],
878
965
  operationId: procedure.name,
@@ -891,7 +978,7 @@ describe('HonoStreamAppBuilder', () => {
891
978
  const RPC = Procedures<{}, RPCConfig>()
892
979
 
893
980
  RPC.CreateStream('Test', { scope: 'test', version: 1 }, async function* () {
894
- yield { data: {} }
981
+ yield {}
895
982
  })
896
983
 
897
984
  builder.register(RPC, () => ({}), {
@@ -962,7 +1049,7 @@ describe('HonoStreamAppBuilder', () => {
962
1049
  const TextRPC = Procedures<{}, RPCConfig>()
963
1050
 
964
1051
  SSERPC.CreateStream('SSEStream', { scope: 'sse', version: 1 }, async function* () {
965
- yield { data: { mode: 'sse' } }
1052
+ yield { mode: 'sse' }
966
1053
  })
967
1054
 
968
1055
  TextRPC.CreateStream('TextStream', { scope: 'text', version: 1 }, async function* () {
@@ -1026,7 +1113,7 @@ describe('HonoStreamAppBuilder', () => {
1026
1113
 
1027
1114
  RPC.CreateStream('CheckPrevalidated', { scope: 'check', version: 1 }, async function* (ctx) {
1028
1115
  receivedIsPrevalidated = ctx.isPrevalidated
1029
- yield { data: { ok: true } }
1116
+ yield { ok: true }
1030
1117
  })
1031
1118
 
1032
1119
  builder.register(RPC, () => ({}))
@@ -1112,13 +1199,13 @@ describe('HonoStreamAppBuilder', () => {
1112
1199
  // SSE Yield Shape Tests
1113
1200
  // --------------------------------------------------------------------------
1114
1201
  describe('SSE yield shape', () => {
1115
- test('custom event names in yields', async () => {
1202
+ test('custom event names via sse() helper', async () => {
1116
1203
  const builder = new HonoStreamAppBuilder()
1117
1204
  const RPC = Procedures<{}, RPCConfig>()
1118
1205
 
1119
1206
  RPC.CreateStream('Events', { scope: 'events', version: 1 }, async function* () {
1120
- yield { data: { type: 'user_joined' }, event: 'join' }
1121
- yield { data: { type: 'message' }, event: 'chat' }
1207
+ yield sse({ type: 'user_joined' }, { event: 'join' })
1208
+ yield sse({ type: 'message' }, { event: 'chat' })
1122
1209
  })
1123
1210
 
1124
1211
  builder.register(RPC, () => ({}))
@@ -1132,13 +1219,13 @@ describe('HonoStreamAppBuilder', () => {
1132
1219
  expect(text).not.toContain('event: Events')
1133
1220
  })
1134
1221
 
1135
- test('custom id in yields', async () => {
1222
+ test('custom id via sse() helper', async () => {
1136
1223
  const builder = new HonoStreamAppBuilder()
1137
1224
  const RPC = Procedures<{}, RPCConfig>()
1138
1225
 
1139
1226
  RPC.CreateStream('Events', { scope: 'events', version: 1 }, async function* () {
1140
- yield { data: { msg: 'first' }, id: 'msg-001' }
1141
- yield { data: { msg: 'second' }, id: 'msg-002' }
1227
+ yield sse({ msg: 'first' }, { id: 'msg-001' })
1228
+ yield sse({ msg: 'second' }, { id: 'msg-002' })
1142
1229
  })
1143
1230
 
1144
1231
  builder.register(RPC, () => ({}))
@@ -1156,8 +1243,8 @@ describe('HonoStreamAppBuilder', () => {
1156
1243
  const RPC = Procedures<{}, RPCConfig>()
1157
1244
 
1158
1245
  RPC.CreateStream('Events', { scope: 'events', version: 1 }, async function* () {
1159
- yield { data: 'already a string' }
1160
- yield { data: { needs: 'stringify' } }
1246
+ yield 'already a string'
1247
+ yield { needs: 'stringify' }
1161
1248
  })
1162
1249
 
1163
1250
  builder.register(RPC, () => ({}))
@@ -1177,9 +1264,9 @@ describe('HonoStreamAppBuilder', () => {
1177
1264
  const RPC = Procedures<{}, RPCConfig>()
1178
1265
 
1179
1266
  RPC.CreateStream('MyProcedure', { scope: 'test', version: 1 }, async function* () {
1180
- yield { data: { value: 1 } }
1181
- yield { data: { value: 2 }, event: 'custom' }
1182
- yield { data: { value: 3 } }
1267
+ yield { value: 1 }
1268
+ yield sse({ value: 2 }, { event: 'custom' })
1269
+ yield { value: 3 }
1183
1270
  })
1184
1271
 
1185
1272
  builder.register(RPC, () => ({}))
@@ -1200,6 +1287,106 @@ describe('HonoStreamAppBuilder', () => {
1200
1287
  })
1201
1288
  })
1202
1289
 
1290
+ // --------------------------------------------------------------------------
1291
+ // sse() Helper Tests
1292
+ // --------------------------------------------------------------------------
1293
+ describe('sse() helper', () => {
1294
+ test('tagged yields with custom event/id/retry', async () => {
1295
+ const builder = new HonoStreamAppBuilder()
1296
+ const RPC = Procedures<{}, RPCConfig>()
1297
+
1298
+ RPC.CreateStream('Tagged', { scope: 'tagged', version: 1 }, async function* () {
1299
+ yield sse({ count: 1 }, { event: 'tick', id: 'evt-1', retry: 5000 })
1300
+ })
1301
+
1302
+ builder.register(RPC, () => ({}))
1303
+ const app = builder.build()
1304
+
1305
+ const res = await app.request('/tagged/tagged/1')
1306
+ const text = await res.text()
1307
+
1308
+ expect(text).toContain('event: tick')
1309
+ expect(text).toContain('id: evt-1')
1310
+ expect(text).toContain('retry: 5000')
1311
+ expect(text).toContain('data: {"count":1}')
1312
+ })
1313
+
1314
+ test('plain domain objects use procedure name and auto-incremented id', async () => {
1315
+ const builder = new HonoStreamAppBuilder()
1316
+ const RPC = Procedures<{}, RPCConfig>()
1317
+
1318
+ RPC.CreateStream('Plain', { scope: 'plain', version: 1 }, async function* () {
1319
+ yield { a: 1 }
1320
+ yield { a: 2 }
1321
+ })
1322
+
1323
+ builder.register(RPC, () => ({}))
1324
+ const app = builder.build()
1325
+
1326
+ const res = await app.request('/plain/plain/1')
1327
+ const text = await res.text()
1328
+
1329
+ const messages = text.split('\n\n').filter(Boolean)
1330
+ expect(messages[0]).toContain('event: Plain')
1331
+ expect(messages[0]).toContain('id: 0')
1332
+ expect(messages[0]).toContain('data: {"a":1}')
1333
+ expect(messages[1]).toContain('event: Plain')
1334
+ expect(messages[1]).toContain('id: 1')
1335
+ expect(messages[1]).toContain('data: {"a":2}')
1336
+ })
1337
+
1338
+ test('sse() metadata is invisible in text mode', async () => {
1339
+ const builder = new HonoStreamAppBuilder({ defaultStreamMode: 'text' })
1340
+ const RPC = Procedures<{}, RPCConfig>()
1341
+
1342
+ RPC.CreateStream('TextTagged', { scope: 'text', version: 1 }, async function* () {
1343
+ yield sse({ count: 1 }, { event: 'tick' })
1344
+ yield { count: 2 }
1345
+ })
1346
+
1347
+ builder.register(RPC, () => ({}))
1348
+ const app = builder.build()
1349
+
1350
+ const res = await app.request('/text/text-tagged/1')
1351
+ const text = await res.text()
1352
+ const lines = text.trim().split('\n')
1353
+
1354
+ // Text mode just JSON-stringifies — sse() metadata is not visible
1355
+ expect(JSON.parse(lines[0]!)).toEqual({ count: 1 })
1356
+ expect(JSON.parse(lines[1]!)).toEqual({ count: 2 })
1357
+ })
1358
+
1359
+ test('sse() with partial options', async () => {
1360
+ const builder = new HonoStreamAppBuilder()
1361
+ const RPC = Procedures<{}, RPCConfig>()
1362
+
1363
+ RPC.CreateStream('Partial', { scope: 'partial', version: 1 }, async function* () {
1364
+ yield sse({ v: 1 }, { event: 'custom' })
1365
+ yield sse({ v: 2 }, { id: 'my-id' })
1366
+ yield sse({ v: 3 })
1367
+ })
1368
+
1369
+ builder.register(RPC, () => ({}))
1370
+ const app = builder.build()
1371
+
1372
+ const res = await app.request('/partial/partial/1')
1373
+ const text = await res.text()
1374
+ const messages = text.split('\n\n').filter(Boolean)
1375
+
1376
+ // First: custom event, auto id
1377
+ expect(messages[0]).toContain('event: custom')
1378
+ expect(messages[0]).toContain('id: 0')
1379
+
1380
+ // Second: default event, custom id
1381
+ expect(messages[1]).toContain('event: Partial')
1382
+ expect(messages[1]).toContain('id: my-id')
1383
+
1384
+ // Third: sse() with no options — same as plain object (defaults)
1385
+ expect(messages[2]).toContain('event: Partial')
1386
+ expect(messages[2]).toContain('id: 2')
1387
+ })
1388
+ })
1389
+
1203
1390
  // --------------------------------------------------------------------------
1204
1391
  // Integration Test
1205
1392
  // --------------------------------------------------------------------------
@@ -1242,8 +1429,8 @@ describe('HonoStreamAppBuilder', () => {
1242
1429
  defaultStreamMode: 'text',
1243
1430
  onRequestStart: () => events.push('request-start'),
1244
1431
  onRequestEnd: () => events.push('request-end'),
1245
- onStreamStart: (proc) => events.push(`stream-start:${proc.name}`),
1246
- onStreamEnd: (proc) => events.push(`stream-end:${proc.name}`),
1432
+ onStreamStart: () => events.push('stream-start'),
1433
+ onStreamEnd: () => events.push('stream-end'),
1247
1434
  })
1248
1435
 
1249
1436
  builder.register(RPC, (c) => ({
@@ -1272,8 +1459,8 @@ describe('HonoStreamAppBuilder', () => {
1272
1459
 
1273
1460
  // Verify hooks were called
1274
1461
  expect(events).toContain('request-start')
1275
- expect(events).toContain('stream-start:WatchNotifications')
1276
- expect(events).toContain('stream-end:WatchNotifications')
1462
+ expect(events).toContain('stream-start')
1463
+ expect(events).toContain('stream-end')
1277
1464
  expect(events).toContain('request-end')
1278
1465
  })
1279
1466
  })
@@ -9,16 +9,29 @@ import { ProcedureValidationError } from '../../../errors.js'
9
9
 
10
10
  export type { StreamHttpRouteDoc, StreamMode }
11
11
 
12
- export type SSEYield = {
13
- data: string | unknown
12
+ export type SSEOptions = {
14
13
  event?: string
15
14
  id?: string
16
15
  retry?: number
17
16
  }
18
17
 
18
+ const sseMetadata = new WeakMap<object, SSEOptions>()
19
+
20
+ export function sse<T extends object>(data: T, options?: SSEOptions): T {
21
+ sseMetadata.set(data, options ?? {})
22
+ return data
23
+ }
24
+
25
+ function getSSEMeta(value: unknown): SSEOptions | undefined {
26
+ if (typeof value === 'object' && value !== null) {
27
+ return sseMetadata.get(value)
28
+ }
29
+ return undefined
30
+ }
31
+
19
32
  /**
20
33
  * Result from onMidStreamError callback.
21
- * @property data - The data to write to the stream (should match yieldType schema)
34
+ * @property data - The data to write as the SSE `data:` field content (should match yieldType schema)
22
35
  * @property event - Optional SSE event name (defaults to procedure name if data provided, 'error' otherwise)
23
36
  * @property id - Optional SSE event id (auto-incremented if not provided)
24
37
  * @property closeStream - Whether to close the stream after writing (defaults to true)
@@ -96,7 +109,6 @@ export class HonoStreamAppBuilder {
96
109
  await next()
97
110
  })
98
111
  }
99
-
100
112
  }
101
113
 
102
114
  /**
@@ -242,11 +254,20 @@ export class HonoStreamAppBuilder {
242
254
  try {
243
255
  for await (const value of generator) {
244
256
  const currentId = eventId++
257
+ const meta = getSSEMeta(value)
258
+
259
+ const data =
260
+ typeof value === 'string'
261
+ ? value
262
+ : value != null
263
+ ? JSON.stringify(value)
264
+ : ''
265
+
245
266
  await stream.writeSSE({
246
- data: typeof value.data === 'string' ? value.data : JSON.stringify(value.data),
247
- event: value.event ?? procedure.name,
248
- id: value.id ?? String(currentId),
249
- ...(value.retry !== undefined && { retry: value.retry }),
267
+ data,
268
+ event: meta?.event ?? procedure.name,
269
+ id: meta?.id ?? String(currentId),
270
+ ...(meta?.retry !== undefined && { retry: meta.retry }),
250
271
  })
251
272
  }
252
273
  } catch (error) {
@@ -369,7 +390,20 @@ export class HonoStreamAppBuilder {
369
390
  if (config.schema?.params) {
370
391
  jsonSchema.params = config.schema.params
371
392
  }
372
- if (config.schema?.yieldType) {
393
+ if (streamMode === 'sse') {
394
+ jsonSchema.yieldType = {
395
+ type: 'object',
396
+ description: 'SSE message envelope. The data field contains the procedure yield value.',
397
+ required: ['data', 'event', 'id'],
398
+ properties: {
399
+ data: config.schema?.yieldType ?? {},
400
+ event: { type: 'string' },
401
+ id: { type: 'string' },
402
+ retry: { type: 'number' },
403
+ },
404
+ }
405
+ } else if (config.schema?.yieldType) {
406
+ // Text mode: pass through as-is
373
407
  jsonSchema.yieldType = config.schema.yieldType
374
408
  }
375
409
  if (config.schema?.returnType) {