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.
- package/build/implementations/http/express-rpc/index.js.map +1 -1
- package/build/implementations/http/hono-rpc/index.js.map +1 -1
- package/build/implementations/http/hono-stream/index.d.ts +17 -18
- package/build/implementations/http/hono-stream/index.js +38 -37
- package/build/implementations/http/hono-stream/index.js.map +1 -1
- package/build/implementations/http/hono-stream/index.test.js +306 -61
- package/build/implementations/http/hono-stream/index.test.js.map +1 -1
- package/build/implementations/http/hono-stream/types.d.ts +3 -3
- package/build/implementations/types.d.ts +5 -5
- package/package.json +1 -1
- package/src/implementations/http/express-rpc/index.ts +1 -1
- package/src/implementations/http/hono-rpc/index.ts +1 -1
- package/src/implementations/http/hono-stream/README.md +151 -67
- package/src/implementations/http/hono-stream/index.test.ts +374 -66
- package/src/implementations/http/hono-stream/index.ts +62 -58
- package/src/implementations/http/hono-stream/types.ts +3 -3
- package/src/implementations/types.ts +5 -5
|
@@ -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 {
|
|
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:
|
|
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 {
|
|
72
|
-
yield {
|
|
73
|
-
yield {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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
|
-
//
|
|
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
|
|
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('
|
|
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
|
-
|
|
659
|
-
|
|
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('
|
|
665
|
-
|
|
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
|
-
//
|
|
673
|
-
expect(yt.properties.
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
expect(yt.properties.
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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
|
|
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 {
|
|
923
|
-
yield {
|
|
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
|
|
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 {
|
|
938
|
-
yield {
|
|
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
|
|
952
|
-
yield {
|
|
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 {
|
|
968
|
-
yield {
|
|
969
|
-
yield {
|
|
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: (
|
|
1014
|
-
onStreamEnd: (
|
|
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
|
|
1037
|
-
expect(events).toContain('stream-end
|
|
1281
|
+
expect(events).toContain('stream-start');
|
|
1282
|
+
expect(events).toContain('stream-end');
|
|
1038
1283
|
expect(events).toContain('request-end');
|
|
1039
1284
|
});
|
|
1040
1285
|
});
|