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,7 +3,8 @@ 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';
6
+ import { HonoStreamAppBuilder, sse } from './index.js';
7
+ import { ProcedureValidationError } from '../../../errors.js';
7
8
  /**
8
9
  * HonoStreamAppBuilder Test Suite
9
10
  *
@@ -20,7 +21,7 @@ describe('HonoStreamAppBuilder', () => {
20
21
  const RPC = Procedures();
21
22
  RPC.CreateStream('StreamMessages', { scope: 'messages', version: 1 }, async function* () {
22
23
  yield { message: 'hello' };
23
- yield { data: { message: 'world' } };
24
+ yield { message: 'world' };
24
25
  });
25
26
  builder.register(RPC, () => ({ userId: '123' }));
26
27
  const app = builder.build();
@@ -36,7 +37,7 @@ describe('HonoStreamAppBuilder', () => {
36
37
  const builder = new HonoStreamAppBuilder({ app: customApp });
37
38
  const RPC = Procedures();
38
39
  RPC.CreateStream('StreamData', { scope: 'data', version: 1 }, async function* () {
39
- yield { data: { data: 1 } };
40
+ yield { data: 1 };
40
41
  });
41
42
  builder.register(RPC, () => ({ userId: '123' }));
42
43
  const app = builder.build();
@@ -68,9 +69,9 @@ describe('HonoStreamAppBuilder', () => {
68
69
  const builder = new HonoStreamAppBuilder();
69
70
  const RPC = Procedures();
70
71
  RPC.CreateStream('Counter', { scope: 'counter', version: 1 }, async function* () {
71
- yield { data: { count: 1 } };
72
- yield { data: { count: 2 } };
73
- yield { data: { count: 3 } };
72
+ yield { count: 1 };
73
+ yield { count: 2 };
74
+ yield { count: 3 };
74
75
  });
75
76
  builder.register(RPC, () => ({}));
76
77
  const app = builder.build();
@@ -90,7 +91,7 @@ describe('HonoStreamAppBuilder', () => {
90
91
  const builder = new HonoStreamAppBuilder();
91
92
  const RPC = Procedures();
92
93
  RPC.CreateStream('Test', { scope: 'test', version: 1 }, async function* () {
93
- yield { data: { ok: true } };
94
+ yield { ok: true };
94
95
  });
95
96
  builder.register(RPC, () => ({}));
96
97
  const app = builder.build();
@@ -101,7 +102,7 @@ describe('HonoStreamAppBuilder', () => {
101
102
  const builder = new HonoStreamAppBuilder({ defaultStreamMode: 'sse' });
102
103
  const RPC = Procedures();
103
104
  RPC.CreateStream('Test', { scope: 'test', version: 1 }, async function* () {
104
- yield { data: { ok: true } };
105
+ yield { ok: true };
105
106
  });
106
107
  builder.register(RPC, () => ({}));
107
108
  const app = builder.build();
@@ -153,7 +154,7 @@ describe('HonoStreamAppBuilder', () => {
153
154
  const builder = new HonoStreamAppBuilder();
154
155
  const RPC = Procedures();
155
156
  RPC.CreateStream('Test', { scope: 'test', version: 1 }, async function* () {
156
- yield { data: { method: 'works' } };
157
+ yield { method: 'works' };
157
158
  });
158
159
  builder.register(RPC, () => ({}));
159
160
  const app = builder.build();
@@ -164,7 +165,7 @@ describe('HonoStreamAppBuilder', () => {
164
165
  const builder = new HonoStreamAppBuilder();
165
166
  const RPC = Procedures();
166
167
  RPC.CreateStream('Test', { scope: 'test', version: 1 }, async function* () {
167
- yield { data: { method: 'works' } };
168
+ yield { method: 'works' };
168
169
  });
169
170
  builder.register(RPC, () => ({}));
170
171
  const app = builder.build();
@@ -214,7 +215,7 @@ describe('HonoStreamAppBuilder', () => {
214
215
  const builder = new HonoStreamAppBuilder({ pathPrefix: '/api/v1' });
215
216
  const RPC = Procedures();
216
217
  RPC.CreateStream('Test', { scope: 'test', version: 1 }, async function* () {
217
- yield { data: { ok: true } };
218
+ yield { ok: true };
218
219
  });
219
220
  builder.register(RPC, () => ({}));
220
221
  const app = builder.build();
@@ -225,7 +226,7 @@ describe('HonoStreamAppBuilder', () => {
225
226
  const builder = new HonoStreamAppBuilder({ pathPrefix: 'custom' });
226
227
  const RPC = Procedures();
227
228
  RPC.CreateStream('Test', { scope: 'test', version: 1 }, async function* () {
228
- yield { data: { ok: true } };
229
+ yield { ok: true };
229
230
  });
230
231
  builder.register(RPC, () => ({}));
231
232
  const app = builder.build();
@@ -236,7 +237,7 @@ describe('HonoStreamAppBuilder', () => {
236
237
  const builder = new HonoStreamAppBuilder({ pathPrefix: '/api' });
237
238
  const RPC = Procedures();
238
239
  RPC.CreateStream('Test', { scope: 'test', version: 1 }, async function* () {
239
- yield { data: {} };
240
+ yield {};
240
241
  });
241
242
  builder.register(RPC, () => ({}));
242
243
  builder.build();
@@ -252,7 +253,7 @@ describe('HonoStreamAppBuilder', () => {
252
253
  const builder = new HonoStreamAppBuilder({ onRequestStart });
253
254
  const RPC = Procedures();
254
255
  RPC.CreateStream('Test', { scope: 'test', version: 1 }, async function* () {
255
- yield { data: { ok: true } };
256
+ yield { ok: true };
256
257
  });
257
258
  builder.register(RPC, () => ({}));
258
259
  const app = builder.build();
@@ -265,7 +266,7 @@ describe('HonoStreamAppBuilder', () => {
265
266
  const builder = new HonoStreamAppBuilder({ onRequestEnd });
266
267
  const RPC = Procedures();
267
268
  RPC.CreateStream('Test', { scope: 'test', version: 1 }, async function* () {
268
- yield { data: { ok: true } };
269
+ yield { ok: true };
269
270
  });
270
271
  builder.register(RPC, () => ({}));
271
272
  const app = builder.build();
@@ -274,25 +275,26 @@ describe('HonoStreamAppBuilder', () => {
274
275
  expect(onRequestEnd).toHaveBeenCalledTimes(1);
275
276
  expect(onRequestEnd.mock.calls[0][0]).toHaveProperty('req');
276
277
  });
277
- test('onStreamStart is called before streaming begins', async () => {
278
+ test('onStreamStart is called before streaming begins with streamMode', async () => {
278
279
  const onStreamStart = vi.fn();
279
280
  const builder = new HonoStreamAppBuilder({ onStreamStart });
280
281
  const RPC = Procedures();
281
282
  RPC.CreateStream('Test', { scope: 'test', version: 1 }, async function* () {
282
- yield { data: { ok: true } };
283
+ yield { ok: true };
283
284
  });
284
285
  builder.register(RPC, () => ({}));
285
286
  const app = builder.build();
286
287
  await app.request('/test/test/1');
287
288
  expect(onStreamStart).toHaveBeenCalledTimes(1);
288
289
  expect(onStreamStart.mock.calls[0][0]).toHaveProperty('name', 'Test');
290
+ expect(onStreamStart.mock.calls[0][2]).toBe('sse');
289
291
  });
290
- test('onStreamEnd is called after stream completes', async () => {
292
+ test('onStreamEnd is called after stream completes with streamMode', async () => {
291
293
  const onStreamEnd = vi.fn();
292
294
  const builder = new HonoStreamAppBuilder({ onStreamEnd });
293
295
  const RPC = Procedures();
294
296
  RPC.CreateStream('Test', { scope: 'test', version: 1 }, async function* () {
295
- yield { data: { ok: true } };
297
+ yield { ok: true };
296
298
  });
297
299
  builder.register(RPC, () => ({}));
298
300
  const app = builder.build();
@@ -301,6 +303,7 @@ describe('HonoStreamAppBuilder', () => {
301
303
  await res.text();
302
304
  expect(onStreamEnd).toHaveBeenCalledTimes(1);
303
305
  expect(onStreamEnd.mock.calls[0][0]).toHaveProperty('name', 'Test');
306
+ expect(onStreamEnd.mock.calls[0][2]).toBe('sse');
304
307
  });
305
308
  test('hooks execute in correct order', async () => {
306
309
  const order = [];
@@ -313,7 +316,7 @@ describe('HonoStreamAppBuilder', () => {
313
316
  const RPC = Procedures();
314
317
  RPC.CreateStream('Test', { scope: 'test', version: 1 }, async function* () {
315
318
  order.push('handler');
316
- yield { data: { ok: true } };
319
+ yield { ok: true };
317
320
  });
318
321
  builder.register(RPC, () => ({}));
319
322
  const app = builder.build();
@@ -352,7 +355,7 @@ describe('HonoStreamAppBuilder', () => {
352
355
  params: v.object({ count: v.number() }),
353
356
  },
354
357
  }, async function* (ctx, params) {
355
- yield { data: { count: params.count } };
358
+ yield { count: params.count };
356
359
  });
357
360
  builder.register(RPC, () => ({}));
358
361
  const app = builder.build();
@@ -368,7 +371,7 @@ describe('HonoStreamAppBuilder', () => {
368
371
  const builder = new HonoStreamAppBuilder();
369
372
  const RPC = Procedures();
370
373
  RPC.CreateStream('ErrorStream', { scope: 'error', version: 1 }, async function* () {
371
- yield { data: { count: 1 } };
374
+ yield { count: 1 };
372
375
  throw new Error('Stream error');
373
376
  });
374
377
  builder.register(RPC, () => ({}));
@@ -405,7 +408,7 @@ describe('HonoStreamAppBuilder', () => {
405
408
  params: v.object({ count: v.number() }),
406
409
  },
407
410
  }, async function* (ctx, params) {
408
- yield { data: { count: params.count } };
411
+ yield { count: params.count };
409
412
  });
410
413
  builder.register(RPC, () => ({}));
411
414
  const app = builder.build();
@@ -429,7 +432,7 @@ describe('HonoStreamAppBuilder', () => {
429
432
  params: v.object({ count: v.number() }),
430
433
  },
431
434
  }, async function* (ctx, params) {
432
- yield { data: { count: params.count } };
435
+ yield { count: params.count };
433
436
  });
434
437
  builder.register(RPC, () => ({}));
435
438
  const app = builder.build();
@@ -448,7 +451,7 @@ describe('HonoStreamAppBuilder', () => {
448
451
  const builder = new HonoStreamAppBuilder({ onPreStreamError });
449
452
  const RPC = Procedures();
450
453
  RPC.CreateStream('SecureStream', { scope: 'secure', version: 1 }, async function* (ctx) {
451
- yield { data: { userId: ctx.userId } };
454
+ yield { userId: ctx.userId };
452
455
  });
453
456
  builder.register(RPC, () => {
454
457
  throw new Error('Authentication required');
@@ -475,7 +478,7 @@ describe('HonoStreamAppBuilder', () => {
475
478
  const builder = new HonoStreamAppBuilder({ onMidStreamError });
476
479
  const RPC = Procedures();
477
480
  RPC.CreateStream('ErrorStream', { scope: 'error', version: 1 }, async function* () {
478
- yield { data: { type: 'data', value: 1 } };
481
+ yield { type: 'data', value: 1 };
479
482
  throw new Error('Something broke');
480
483
  });
481
484
  builder.register(RPC, () => ({}));
@@ -611,14 +614,15 @@ describe('HonoStreamAppBuilder', () => {
611
614
  expect(doc.streamMode).toBe('sse');
612
615
  expect(doc.jsonSchema.params).toBeDefined();
613
616
  expect(doc.jsonSchema.returnType).toBeDefined();
614
- // SSE envelope fields are merged into yieldType
617
+ // yieldType is nested under SSE envelope's data property
615
618
  const yt = doc.jsonSchema.yieldType;
619
+ expect(yt.description).toBe('SSE message envelope. The data field contains the procedure yield value.');
616
620
  expect(yt.required).toEqual(['data', 'event', 'id']);
617
621
  expect(yt.properties.event).toEqual({ type: 'string' });
618
622
  expect(yt.properties.id).toEqual({ type: 'string' });
619
623
  expect(yt.properties.retry).toEqual({ type: 'number' });
620
- // Developer's data property is preserved
621
- expect(yt.properties.message).toBeDefined();
624
+ // Developer's yieldType is nested under data
625
+ expect(yt.properties.data.properties.message).toBeDefined();
622
626
  });
623
627
  test('SSE mode generates SSE envelope even when no yieldType is defined', () => {
624
628
  const builder = new HonoStreamAppBuilder();
@@ -631,7 +635,9 @@ describe('HonoStreamAppBuilder', () => {
631
635
  const yt = builder.docs[0].jsonSchema.yieldType;
632
636
  expect(yt).toBeDefined();
633
637
  expect(yt.type).toBe('object');
638
+ expect(yt.description).toBe('SSE message envelope. The data field contains the procedure yield value.');
634
639
  expect(yt.required).toEqual(['data', 'event', 'id']);
640
+ // data is empty schema when no yieldType defined
635
641
  expect(yt.properties.data).toEqual({});
636
642
  expect(yt.properties.event).toEqual({ type: 'string' });
637
643
  expect(yt.properties.id).toEqual({ type: 'string' });
@@ -653,27 +659,27 @@ describe('HonoStreamAppBuilder', () => {
653
659
  expect(yt.properties?.id).toBeUndefined();
654
660
  expect(yt.properties?.retry).toBeUndefined();
655
661
  });
656
- test('developer-defined event/id in yieldType are preserved', () => {
662
+ test('yieldType with id property does not collide with SSE id field', () => {
663
+ // User's yieldType has an `id` field (number) — this should be nested under
664
+ // the SSE envelope's `data` property, not collide with the SSE `id` (string)
657
665
  const yieldSchema = v.object({
658
- data: v.object({ msg: v.string() }),
659
- event: v.string().const('custom-event'),
660
- id: v.string().const('fixed-id'),
666
+ id: v.number(),
667
+ message: v.string(),
661
668
  });
662
669
  const builder = new HonoStreamAppBuilder();
663
670
  const RPC = Procedures();
664
- RPC.CreateStream('CustomSSE', { scope: 'test', version: 1, schema: { yieldType: yieldSchema } }, async function* () {
665
- // suretype sucks, we have to assert this as the exact type to satisfy the const assertions in the schema
666
- yield { data: { msg: 'hi' }, event: 'custom-event', id: 'fixed-id' };
671
+ RPC.CreateStream('Notifications', { scope: 'test', version: 1, schema: { yieldType: yieldSchema } }, async function* () {
672
+ yield { id: 42, message: 'hello' };
667
673
  });
668
674
  builder.register(RPC, () => ({}));
669
675
  builder.build();
670
676
  const yt = builder.docs[0].jsonSchema.yieldType;
671
677
  expect(yt.required).toEqual(['data', 'event', 'id']);
672
- // Developer's custom event/id definitions take precedence
673
- expect(yt.properties.event.const).toBe('custom-event');
674
- expect(yt.properties.id.const).toBe('fixed-id');
675
- // retry is still added as a base default
676
- expect(yt.properties.retry).toEqual({ type: 'number' });
678
+ // SSE envelope id is a string
679
+ expect(yt.properties.id).toEqual({ type: 'string' });
680
+ // User's id (number) is safely nested under data
681
+ expect(yt.properties.data.properties.id.type).toBe('number');
682
+ expect(yt.properties.data.properties.message.type).toBe('string');
677
683
  });
678
684
  test('streamMode is recorded in docs', () => {
679
685
  const builder = new HonoStreamAppBuilder({ defaultStreamMode: 'text' });
@@ -709,7 +715,7 @@ describe('HonoStreamAppBuilder', () => {
709
715
  }));
710
716
  // Streaming procedure (should be registered)
711
717
  RPC.CreateStream('Stream', { scope: 'stream', version: 1 }, async function* () {
712
- yield { data: { ok: true } };
718
+ yield { ok: true };
713
719
  });
714
720
  builder.register(RPC, () => ({}));
715
721
  const app = builder.build();
@@ -732,7 +738,7 @@ describe('HonoStreamAppBuilder', () => {
732
738
  const builder = new HonoStreamAppBuilder();
733
739
  const RPC = Procedures();
734
740
  RPC.CreateStream('StreamEvents', { scope: 'events', version: 1 }, async function* () {
735
- yield { data: {} };
741
+ yield {};
736
742
  });
737
743
  builder.register(RPC, () => ({}), {
738
744
  extendProcedureDoc: ({ procedure }) => ({
@@ -751,7 +757,7 @@ describe('HonoStreamAppBuilder', () => {
751
757
  const builder = new HonoStreamAppBuilder();
752
758
  const RPC = Procedures();
753
759
  RPC.CreateStream('Test', { scope: 'test', version: 1 }, async function* () {
754
- yield { data: {} };
760
+ yield {};
755
761
  });
756
762
  builder.register(RPC, () => ({}), {
757
763
  extendProcedureDoc: () => ({
@@ -801,7 +807,7 @@ describe('HonoStreamAppBuilder', () => {
801
807
  const SSERPC = Procedures();
802
808
  const TextRPC = Procedures();
803
809
  SSERPC.CreateStream('SSEStream', { scope: 'sse', version: 1 }, async function* () {
804
- yield { data: { mode: 'sse' } };
810
+ yield { mode: 'sse' };
805
811
  });
806
812
  TextRPC.CreateStream('TextStream', { scope: 'text', version: 1 }, async function* () {
807
813
  yield { mode: 'text' };
@@ -852,7 +858,7 @@ describe('HonoStreamAppBuilder', () => {
852
858
  const RPC = Procedures();
853
859
  RPC.CreateStream('CheckPrevalidated', { scope: 'check', version: 1 }, async function* (ctx) {
854
860
  receivedIsPrevalidated = ctx.isPrevalidated;
855
- yield { data: { ok: true } };
861
+ yield { ok: true };
856
862
  });
857
863
  builder.register(RPC, () => ({}));
858
864
  const app = builder.build();
@@ -915,12 +921,12 @@ describe('HonoStreamAppBuilder', () => {
915
921
  // SSE Yield Shape Tests
916
922
  // --------------------------------------------------------------------------
917
923
  describe('SSE yield shape', () => {
918
- test('custom event names in yields', async () => {
924
+ test('custom event names via sse() helper', async () => {
919
925
  const builder = new HonoStreamAppBuilder();
920
926
  const RPC = Procedures();
921
927
  RPC.CreateStream('Events', { scope: 'events', version: 1 }, async function* () {
922
- yield { data: { type: 'user_joined' }, event: 'join' };
923
- yield { data: { type: 'message' }, event: 'chat' };
928
+ yield sse({ type: 'user_joined' }, { event: 'join' });
929
+ yield sse({ type: 'message' }, { event: 'chat' });
924
930
  });
925
931
  builder.register(RPC, () => ({}));
926
932
  const app = builder.build();
@@ -930,12 +936,12 @@ describe('HonoStreamAppBuilder', () => {
930
936
  expect(text).toContain('event: chat');
931
937
  expect(text).not.toContain('event: Events');
932
938
  });
933
- test('custom id in yields', async () => {
939
+ test('custom id via sse() helper', async () => {
934
940
  const builder = new HonoStreamAppBuilder();
935
941
  const RPC = Procedures();
936
942
  RPC.CreateStream('Events', { scope: 'events', version: 1 }, async function* () {
937
- yield { data: { msg: 'first' }, id: 'msg-001' };
938
- yield { data: { msg: 'second' }, id: 'msg-002' };
943
+ yield sse({ msg: 'first' }, { id: 'msg-001' });
944
+ yield sse({ msg: 'second' }, { id: 'msg-002' });
939
945
  });
940
946
  builder.register(RPC, () => ({}));
941
947
  const app = builder.build();
@@ -948,8 +954,8 @@ describe('HonoStreamAppBuilder', () => {
948
954
  const builder = new HonoStreamAppBuilder();
949
955
  const RPC = Procedures();
950
956
  RPC.CreateStream('Events', { scope: 'events', version: 1 }, async function* () {
951
- yield { data: 'already a string' };
952
- yield { data: { needs: 'stringify' } };
957
+ yield 'already a string';
958
+ yield { needs: 'stringify' };
953
959
  });
954
960
  builder.register(RPC, () => ({}));
955
961
  const app = builder.build();
@@ -964,9 +970,9 @@ describe('HonoStreamAppBuilder', () => {
964
970
  const builder = new HonoStreamAppBuilder();
965
971
  const RPC = Procedures();
966
972
  RPC.CreateStream('MyProcedure', { scope: 'test', version: 1 }, async function* () {
967
- yield { data: { value: 1 } };
968
- yield { data: { value: 2 }, event: 'custom' };
969
- yield { data: { value: 3 } };
973
+ yield { value: 1 };
974
+ yield sse({ value: 2 }, { event: 'custom' });
975
+ yield { value: 3 };
970
976
  });
971
977
  builder.register(RPC, () => ({}));
972
978
  const app = builder.build();
@@ -983,6 +989,245 @@ describe('HonoStreamAppBuilder', () => {
983
989
  });
984
990
  });
985
991
  // --------------------------------------------------------------------------
992
+ // sse() Helper Tests
993
+ // --------------------------------------------------------------------------
994
+ describe('sse() helper', () => {
995
+ test('tagged yields with custom event/id/retry', async () => {
996
+ const builder = new HonoStreamAppBuilder();
997
+ const RPC = Procedures();
998
+ RPC.CreateStream('Tagged', { scope: 'tagged', version: 1 }, async function* () {
999
+ yield sse({ count: 1 }, { event: 'tick', id: 'evt-1', retry: 5000 });
1000
+ });
1001
+ builder.register(RPC, () => ({}));
1002
+ const app = builder.build();
1003
+ const res = await app.request('/tagged/tagged/1');
1004
+ const text = await res.text();
1005
+ expect(text).toContain('event: tick');
1006
+ expect(text).toContain('id: evt-1');
1007
+ expect(text).toContain('retry: 5000');
1008
+ expect(text).toContain('data: {"count":1}');
1009
+ });
1010
+ test('plain domain objects use procedure name and auto-incremented id', async () => {
1011
+ const builder = new HonoStreamAppBuilder();
1012
+ const RPC = Procedures();
1013
+ RPC.CreateStream('Plain', { scope: 'plain', version: 1 }, async function* () {
1014
+ yield { a: 1 };
1015
+ yield { a: 2 };
1016
+ });
1017
+ builder.register(RPC, () => ({}));
1018
+ const app = builder.build();
1019
+ const res = await app.request('/plain/plain/1');
1020
+ const text = await res.text();
1021
+ const messages = text.split('\n\n').filter(Boolean);
1022
+ expect(messages[0]).toContain('event: Plain');
1023
+ expect(messages[0]).toContain('id: 0');
1024
+ expect(messages[0]).toContain('data: {"a":1}');
1025
+ expect(messages[1]).toContain('event: Plain');
1026
+ expect(messages[1]).toContain('id: 1');
1027
+ expect(messages[1]).toContain('data: {"a":2}');
1028
+ });
1029
+ test('sse() metadata is invisible in text mode', async () => {
1030
+ const builder = new HonoStreamAppBuilder({ defaultStreamMode: 'text' });
1031
+ const RPC = Procedures();
1032
+ RPC.CreateStream('TextTagged', { scope: 'text', version: 1 }, async function* () {
1033
+ yield sse({ count: 1 }, { event: 'tick' });
1034
+ yield { count: 2 };
1035
+ });
1036
+ builder.register(RPC, () => ({}));
1037
+ const app = builder.build();
1038
+ const res = await app.request('/text/text-tagged/1');
1039
+ const text = await res.text();
1040
+ const lines = text.trim().split('\n');
1041
+ // Text mode just JSON-stringifies — sse() metadata is not visible
1042
+ expect(JSON.parse(lines[0])).toEqual({ count: 1 });
1043
+ expect(JSON.parse(lines[1])).toEqual({ count: 2 });
1044
+ });
1045
+ test('sse() with partial options', async () => {
1046
+ const builder = new HonoStreamAppBuilder();
1047
+ const RPC = Procedures();
1048
+ RPC.CreateStream('Partial', { scope: 'partial', version: 1 }, async function* () {
1049
+ yield sse({ v: 1 }, { event: 'custom' });
1050
+ yield sse({ v: 2 }, { id: 'my-id' });
1051
+ yield sse({ v: 3 });
1052
+ });
1053
+ builder.register(RPC, () => ({}));
1054
+ const app = builder.build();
1055
+ const res = await app.request('/partial/partial/1');
1056
+ const text = await res.text();
1057
+ const messages = text.split('\n\n').filter(Boolean);
1058
+ // First: custom event, auto id
1059
+ expect(messages[0]).toContain('event: custom');
1060
+ expect(messages[0]).toContain('id: 0');
1061
+ // Second: default event, custom id
1062
+ expect(messages[1]).toContain('event: Partial');
1063
+ expect(messages[1]).toContain('id: my-id');
1064
+ // Third: sse() with no options — same as plain object (defaults)
1065
+ expect(messages[2]).toContain('event: Partial');
1066
+ expect(messages[2]).toContain('id: 2');
1067
+ });
1068
+ });
1069
+ // --------------------------------------------------------------------------
1070
+ // streamMode in Lifecycle Hooks
1071
+ // --------------------------------------------------------------------------
1072
+ describe('streamMode in lifecycle hooks', () => {
1073
+ test('onStreamStart receives sse streamMode', async () => {
1074
+ const onStreamStart = vi.fn();
1075
+ const builder = new HonoStreamAppBuilder({ onStreamStart });
1076
+ const RPC = Procedures();
1077
+ RPC.CreateStream('Test', { scope: 'test', version: 1 }, async function* () {
1078
+ yield { ok: true };
1079
+ });
1080
+ builder.register(RPC, () => ({}));
1081
+ const app = builder.build();
1082
+ await app.request('/test/test/1');
1083
+ expect(onStreamStart).toHaveBeenCalledTimes(1);
1084
+ const [, , streamMode] = onStreamStart.mock.calls[0];
1085
+ expect(streamMode).toBe('sse');
1086
+ });
1087
+ test('onStreamEnd receives text streamMode', async () => {
1088
+ const onStreamEnd = vi.fn();
1089
+ const builder = new HonoStreamAppBuilder({ defaultStreamMode: 'text', onStreamEnd });
1090
+ const RPC = Procedures();
1091
+ RPC.CreateStream('Test', { scope: 'test', version: 1 }, async function* () {
1092
+ yield { ok: true };
1093
+ });
1094
+ builder.register(RPC, () => ({}));
1095
+ const app = builder.build();
1096
+ const res = await app.request('/test/test/1');
1097
+ await res.text();
1098
+ expect(onStreamEnd).toHaveBeenCalledTimes(1);
1099
+ const [, , streamMode] = onStreamEnd.mock.calls[0];
1100
+ expect(streamMode).toBe('text');
1101
+ });
1102
+ test('onStreamStart and onStreamEnd receive matching streamMode', async () => {
1103
+ const modes = {};
1104
+ const builder = new HonoStreamAppBuilder({
1105
+ defaultStreamMode: 'text',
1106
+ onStreamStart: (_proc, _c, mode) => { modes.start = mode; },
1107
+ onStreamEnd: (_proc, _c, mode) => { modes.end = mode; },
1108
+ });
1109
+ const RPC = Procedures();
1110
+ RPC.CreateStream('Test', { scope: 'test', version: 1 }, async function* () {
1111
+ yield { ok: true };
1112
+ });
1113
+ builder.register(RPC, () => ({}));
1114
+ const app = builder.build();
1115
+ const res = await app.request('/test/test/1');
1116
+ await res.text();
1117
+ expect(modes.start).toBe('text');
1118
+ expect(modes.end).toBe('text');
1119
+ });
1120
+ });
1121
+ // --------------------------------------------------------------------------
1122
+ // sse() in onMidStreamError
1123
+ // --------------------------------------------------------------------------
1124
+ describe('sse() in onMidStreamError', () => {
1125
+ test('sse() wraps error data with custom event and id', async () => {
1126
+ const builder = new HonoStreamAppBuilder({
1127
+ onMidStreamError: (procedure, c, error) => {
1128
+ return {
1129
+ data: sse({ type: 'error', message: error.message }, { event: 'custom-error', id: 'err-1' }),
1130
+ };
1131
+ },
1132
+ });
1133
+ const RPC = Procedures();
1134
+ RPC.CreateStream('ErrorStream', { scope: 'error', version: 1 }, async function* () {
1135
+ yield { type: 'data', value: 1 };
1136
+ throw new Error('Something broke');
1137
+ });
1138
+ builder.register(RPC, () => ({}));
1139
+ const app = builder.build();
1140
+ const res = await app.request('/error/error-stream/1');
1141
+ const text = await res.text();
1142
+ // Normal yield
1143
+ expect(text).toContain('data: {"type":"data","value":1}');
1144
+ // Error yield with sse() metadata
1145
+ expect(text).toContain('event: custom-error');
1146
+ expect(text).toContain('id: err-1');
1147
+ expect(text).toContain('"type":"error"');
1148
+ });
1149
+ test('string error data without sse() uses default event and id', async () => {
1150
+ const builder = new HonoStreamAppBuilder({
1151
+ onMidStreamError: () => {
1152
+ return { data: 'plain error string' };
1153
+ },
1154
+ });
1155
+ const RPC = Procedures();
1156
+ RPC.CreateStream('ErrorStream', { scope: 'error', version: 1 }, async function* () {
1157
+ yield { count: 1 };
1158
+ throw new Error('fail');
1159
+ });
1160
+ builder.register(RPC, () => ({}));
1161
+ const app = builder.build();
1162
+ const res = await app.request('/error/error-stream/1');
1163
+ const text = await res.text();
1164
+ // String data can't use sse() (not an object), so defaults apply
1165
+ expect(text).toContain('data: plain error string');
1166
+ // event defaults to procedure name when data is provided
1167
+ expect(text).toContain('event: ErrorStream');
1168
+ });
1169
+ });
1170
+ // --------------------------------------------------------------------------
1171
+ // Generic TErrorData
1172
+ // --------------------------------------------------------------------------
1173
+ describe('generic TErrorData', () => {
1174
+ test('typed builder constrains onMidStreamError return type', async () => {
1175
+ const builder = new HonoStreamAppBuilder({
1176
+ onMidStreamError: (_procedure, _c, error) => {
1177
+ // This satisfies MidStreamErrorResult<ErrorPayload>
1178
+ return {
1179
+ data: { type: 'error', code: 'STREAM_FAILED', message: error.message },
1180
+ };
1181
+ },
1182
+ });
1183
+ const RPC = Procedures();
1184
+ RPC.CreateStream('ErrorStream', { scope: 'error', version: 1 }, async function* () {
1185
+ yield { value: 1 };
1186
+ throw new Error('typed error');
1187
+ });
1188
+ builder.register(RPC, () => ({}));
1189
+ const app = builder.build();
1190
+ const res = await app.request('/error/error-stream/1');
1191
+ const text = await res.text();
1192
+ expect(text).toContain('"code":"STREAM_FAILED"');
1193
+ // Error message may be wrapped by Procedures with prefix
1194
+ expect(text).toContain('typed error');
1195
+ });
1196
+ });
1197
+ // --------------------------------------------------------------------------
1198
+ // ProcedureValidationError narrowing in onPreStreamError
1199
+ // --------------------------------------------------------------------------
1200
+ describe('ProcedureValidationError narrowing', () => {
1201
+ test('instanceof check works in onPreStreamError', async () => {
1202
+ let wasValidationError = false;
1203
+ const builder = new HonoStreamAppBuilder({
1204
+ onPreStreamError: (procedure, c, error) => {
1205
+ if (error instanceof ProcedureValidationError) {
1206
+ wasValidationError = true;
1207
+ return c.json({ validation: true, errors: error.errors }, 422);
1208
+ }
1209
+ return c.json({ error: error.message }, 500);
1210
+ },
1211
+ });
1212
+ const RPC = Procedures();
1213
+ RPC.CreateStream('Validated', {
1214
+ scope: 'validated',
1215
+ version: 1,
1216
+ schema: { params: v.object({ count: v.number() }) },
1217
+ }, async function* (ctx, params) {
1218
+ yield { count: params.count };
1219
+ });
1220
+ builder.register(RPC, () => ({}));
1221
+ const app = builder.build();
1222
+ const res = await app.request('/validated/validated/1?count=not-a-number');
1223
+ expect(res.status).toBe(422);
1224
+ expect(wasValidationError).toBe(true);
1225
+ const body = await res.json();
1226
+ expect(body.validation).toBe(true);
1227
+ expect(body.errors).toBeDefined();
1228
+ });
1229
+ });
1230
+ // --------------------------------------------------------------------------
986
1231
  // Integration Test
987
1232
  // --------------------------------------------------------------------------
988
1233
  describe('integration', () => {
@@ -1010,8 +1255,8 @@ describe('HonoStreamAppBuilder', () => {
1010
1255
  defaultStreamMode: 'text',
1011
1256
  onRequestStart: () => events.push('request-start'),
1012
1257
  onRequestEnd: () => events.push('request-end'),
1013
- onStreamStart: (proc) => events.push(`stream-start:${proc.name}`),
1014
- onStreamEnd: (proc) => events.push(`stream-end:${proc.name}`),
1258
+ onStreamStart: () => events.push('stream-start'),
1259
+ onStreamEnd: () => events.push('stream-end'),
1015
1260
  });
1016
1261
  builder.register(RPC, (c) => ({
1017
1262
  userId: c.req.header('x-user-id') || 'anonymous',
@@ -1033,8 +1278,8 @@ describe('HonoStreamAppBuilder', () => {
1033
1278
  expect(JSON.parse(lines[1])).toEqual({ id: 2, message: 'Notification 2 for user-123' });
1034
1279
  // Verify hooks were called
1035
1280
  expect(events).toContain('request-start');
1036
- expect(events).toContain('stream-start:WatchNotifications');
1037
- expect(events).toContain('stream-end:WatchNotifications');
1281
+ expect(events).toContain('stream-start');
1282
+ expect(events).toContain('stream-end');
1038
1283
  expect(events).toContain('request-end');
1039
1284
  });
1040
1285
  });