ts-procedures 4.0.1 → 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.
- package/build/implementations/http/hono-stream/index.d.ts +3 -3
- package/build/implementations/http/hono-stream/index.js +31 -32
- package/build/implementations/http/hono-stream/index.js.map +1 -1
- package/build/implementations/http/hono-stream/index.test.js +140 -59
- package/build/implementations/http/hono-stream/index.test.js.map +1 -1
- package/package.json +1 -1
- package/src/implementations/http/hono-stream/README.md +58 -31
- package/src/implementations/http/hono-stream/index.test.ts +162 -63
- package/src/implementations/http/hono-stream/index.ts +39 -36
|
@@ -3,7 +3,7 @@ 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
7
|
import { RPCConfig } from '../../types.js'
|
|
8
8
|
|
|
9
9
|
/**
|
|
@@ -23,7 +23,7 @@ describe('HonoStreamAppBuilder', () => {
|
|
|
23
23
|
|
|
24
24
|
RPC.CreateStream('StreamMessages', { scope: 'messages', version: 1 }, async function* () {
|
|
25
25
|
yield { message: 'hello' }
|
|
26
|
-
yield {
|
|
26
|
+
yield { message: 'world' }
|
|
27
27
|
})
|
|
28
28
|
|
|
29
29
|
builder.register(RPC, () => ({ userId: '123' }))
|
|
@@ -45,7 +45,7 @@ describe('HonoStreamAppBuilder', () => {
|
|
|
45
45
|
const RPC = Procedures<{ userId: string }, RPCConfig>()
|
|
46
46
|
|
|
47
47
|
RPC.CreateStream('StreamData', { scope: 'data', version: 1 }, async function* () {
|
|
48
|
-
yield { data:
|
|
48
|
+
yield { data: 1 }
|
|
49
49
|
})
|
|
50
50
|
|
|
51
51
|
builder.register(RPC, () => ({ userId: '123' }))
|
|
@@ -84,9 +84,9 @@ describe('HonoStreamAppBuilder', () => {
|
|
|
84
84
|
const RPC = Procedures<{}, RPCConfig>()
|
|
85
85
|
|
|
86
86
|
RPC.CreateStream('Counter', { scope: 'counter', version: 1 }, async function* () {
|
|
87
|
-
yield {
|
|
88
|
-
yield {
|
|
89
|
-
yield {
|
|
87
|
+
yield { count: 1 }
|
|
88
|
+
yield { count: 2 }
|
|
89
|
+
yield { count: 3 }
|
|
90
90
|
})
|
|
91
91
|
|
|
92
92
|
builder.register(RPC, () => ({}))
|
|
@@ -111,7 +111,7 @@ describe('HonoStreamAppBuilder', () => {
|
|
|
111
111
|
const RPC = Procedures<{}, RPCConfig>()
|
|
112
112
|
|
|
113
113
|
RPC.CreateStream('Test', { scope: 'test', version: 1 }, async function* () {
|
|
114
|
-
yield {
|
|
114
|
+
yield { ok: true }
|
|
115
115
|
})
|
|
116
116
|
|
|
117
117
|
builder.register(RPC, () => ({}))
|
|
@@ -126,7 +126,7 @@ describe('HonoStreamAppBuilder', () => {
|
|
|
126
126
|
const RPC = Procedures<{}, RPCConfig>()
|
|
127
127
|
|
|
128
128
|
RPC.CreateStream('Test', { scope: 'test', version: 1 }, async function* () {
|
|
129
|
-
yield {
|
|
129
|
+
yield { ok: true }
|
|
130
130
|
})
|
|
131
131
|
|
|
132
132
|
builder.register(RPC, () => ({}))
|
|
@@ -191,7 +191,7 @@ describe('HonoStreamAppBuilder', () => {
|
|
|
191
191
|
const RPC = Procedures<{}, RPCConfig>()
|
|
192
192
|
|
|
193
193
|
RPC.CreateStream('Test', { scope: 'test', version: 1 }, async function* () {
|
|
194
|
-
yield {
|
|
194
|
+
yield { method: 'works' }
|
|
195
195
|
})
|
|
196
196
|
|
|
197
197
|
builder.register(RPC, () => ({}))
|
|
@@ -206,7 +206,7 @@ describe('HonoStreamAppBuilder', () => {
|
|
|
206
206
|
const RPC = Procedures<{}, RPCConfig>()
|
|
207
207
|
|
|
208
208
|
RPC.CreateStream('Test', { scope: 'test', version: 1 }, async function* () {
|
|
209
|
-
yield {
|
|
209
|
+
yield { method: 'works' }
|
|
210
210
|
})
|
|
211
211
|
|
|
212
212
|
builder.register(RPC, () => ({}))
|
|
@@ -268,7 +268,7 @@ describe('HonoStreamAppBuilder', () => {
|
|
|
268
268
|
const RPC = Procedures<{}, RPCConfig>()
|
|
269
269
|
|
|
270
270
|
RPC.CreateStream('Test', { scope: 'test', version: 1 }, async function* () {
|
|
271
|
-
yield {
|
|
271
|
+
yield { ok: true }
|
|
272
272
|
})
|
|
273
273
|
|
|
274
274
|
builder.register(RPC, () => ({}))
|
|
@@ -283,7 +283,7 @@ describe('HonoStreamAppBuilder', () => {
|
|
|
283
283
|
const RPC = Procedures<{}, RPCConfig>()
|
|
284
284
|
|
|
285
285
|
RPC.CreateStream('Test', { scope: 'test', version: 1 }, async function* () {
|
|
286
|
-
yield {
|
|
286
|
+
yield { ok: true }
|
|
287
287
|
})
|
|
288
288
|
|
|
289
289
|
builder.register(RPC, () => ({}))
|
|
@@ -298,7 +298,7 @@ describe('HonoStreamAppBuilder', () => {
|
|
|
298
298
|
const RPC = Procedures<{}, RPCConfig>()
|
|
299
299
|
|
|
300
300
|
RPC.CreateStream('Test', { scope: 'test', version: 1 }, async function* () {
|
|
301
|
-
yield {
|
|
301
|
+
yield {}
|
|
302
302
|
})
|
|
303
303
|
|
|
304
304
|
builder.register(RPC, () => ({}))
|
|
@@ -318,7 +318,7 @@ describe('HonoStreamAppBuilder', () => {
|
|
|
318
318
|
const RPC = Procedures<{}, RPCConfig>()
|
|
319
319
|
|
|
320
320
|
RPC.CreateStream('Test', { scope: 'test', version: 1 }, async function* () {
|
|
321
|
-
yield {
|
|
321
|
+
yield { ok: true }
|
|
322
322
|
})
|
|
323
323
|
|
|
324
324
|
builder.register(RPC, () => ({}))
|
|
@@ -336,7 +336,7 @@ describe('HonoStreamAppBuilder', () => {
|
|
|
336
336
|
const RPC = Procedures<{}, RPCConfig>()
|
|
337
337
|
|
|
338
338
|
RPC.CreateStream('Test', { scope: 'test', version: 1 }, async function* () {
|
|
339
|
-
yield {
|
|
339
|
+
yield { ok: true }
|
|
340
340
|
})
|
|
341
341
|
|
|
342
342
|
builder.register(RPC, () => ({}))
|
|
@@ -355,7 +355,7 @@ describe('HonoStreamAppBuilder', () => {
|
|
|
355
355
|
const RPC = Procedures<{}, RPCConfig>()
|
|
356
356
|
|
|
357
357
|
RPC.CreateStream('Test', { scope: 'test', version: 1 }, async function* () {
|
|
358
|
-
yield {
|
|
358
|
+
yield { ok: true }
|
|
359
359
|
})
|
|
360
360
|
|
|
361
361
|
builder.register(RPC, () => ({}))
|
|
@@ -373,7 +373,7 @@ describe('HonoStreamAppBuilder', () => {
|
|
|
373
373
|
const RPC = Procedures<{}, RPCConfig>()
|
|
374
374
|
|
|
375
375
|
RPC.CreateStream('Test', { scope: 'test', version: 1 }, async function* () {
|
|
376
|
-
yield {
|
|
376
|
+
yield { ok: true }
|
|
377
377
|
})
|
|
378
378
|
|
|
379
379
|
builder.register(RPC, () => ({}))
|
|
@@ -400,7 +400,7 @@ describe('HonoStreamAppBuilder', () => {
|
|
|
400
400
|
|
|
401
401
|
RPC.CreateStream('Test', { scope: 'test', version: 1 }, async function* () {
|
|
402
402
|
order.push('handler')
|
|
403
|
-
yield {
|
|
403
|
+
yield { ok: true }
|
|
404
404
|
})
|
|
405
405
|
|
|
406
406
|
builder.register(RPC, () => ({}))
|
|
@@ -448,7 +448,7 @@ describe('HonoStreamAppBuilder', () => {
|
|
|
448
448
|
},
|
|
449
449
|
},
|
|
450
450
|
async function* (ctx, params) {
|
|
451
|
-
yield {
|
|
451
|
+
yield { count: params.count }
|
|
452
452
|
}
|
|
453
453
|
)
|
|
454
454
|
|
|
@@ -471,7 +471,7 @@ describe('HonoStreamAppBuilder', () => {
|
|
|
471
471
|
const RPC = Procedures<{}, RPCConfig>()
|
|
472
472
|
|
|
473
473
|
RPC.CreateStream('ErrorStream', { scope: 'error', version: 1 }, async function* () {
|
|
474
|
-
yield {
|
|
474
|
+
yield { count: 1 }
|
|
475
475
|
throw new Error('Stream error')
|
|
476
476
|
})
|
|
477
477
|
|
|
@@ -521,7 +521,7 @@ describe('HonoStreamAppBuilder', () => {
|
|
|
521
521
|
},
|
|
522
522
|
},
|
|
523
523
|
async function* (ctx, params) {
|
|
524
|
-
yield {
|
|
524
|
+
yield { count: params.count }
|
|
525
525
|
}
|
|
526
526
|
)
|
|
527
527
|
|
|
@@ -558,7 +558,7 @@ describe('HonoStreamAppBuilder', () => {
|
|
|
558
558
|
},
|
|
559
559
|
},
|
|
560
560
|
async function* (ctx, params) {
|
|
561
|
-
yield {
|
|
561
|
+
yield { count: params.count }
|
|
562
562
|
}
|
|
563
563
|
)
|
|
564
564
|
|
|
@@ -584,7 +584,7 @@ describe('HonoStreamAppBuilder', () => {
|
|
|
584
584
|
const RPC = Procedures<{ userId: string }, RPCConfig>()
|
|
585
585
|
|
|
586
586
|
RPC.CreateStream('SecureStream', { scope: 'secure', version: 1 }, async function* (ctx) {
|
|
587
|
-
yield {
|
|
587
|
+
yield { userId: ctx.userId }
|
|
588
588
|
})
|
|
589
589
|
|
|
590
590
|
builder.register(RPC, () => {
|
|
@@ -618,7 +618,7 @@ describe('HonoStreamAppBuilder', () => {
|
|
|
618
618
|
const RPC = Procedures<{}, RPCConfig>()
|
|
619
619
|
|
|
620
620
|
RPC.CreateStream('ErrorStream', { scope: 'error', version: 1 }, async function* () {
|
|
621
|
-
yield {
|
|
621
|
+
yield { type: 'data', value: 1 }
|
|
622
622
|
throw new Error('Something broke')
|
|
623
623
|
})
|
|
624
624
|
|
|
@@ -794,14 +794,15 @@ describe('HonoStreamAppBuilder', () => {
|
|
|
794
794
|
expect(doc.jsonSchema.params).toBeDefined()
|
|
795
795
|
expect(doc.jsonSchema.returnType).toBeDefined()
|
|
796
796
|
|
|
797
|
-
//
|
|
797
|
+
// yieldType is nested under SSE envelope's data property
|
|
798
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.')
|
|
799
800
|
expect(yt.required).toEqual(['data', 'event', 'id'])
|
|
800
801
|
expect(yt.properties.event).toEqual({ type: 'string' })
|
|
801
802
|
expect(yt.properties.id).toEqual({ type: 'string' })
|
|
802
803
|
expect(yt.properties.retry).toEqual({ type: 'number' })
|
|
803
|
-
// Developer's
|
|
804
|
-
expect(yt.properties.message).toBeDefined()
|
|
804
|
+
// Developer's yieldType is nested under data
|
|
805
|
+
expect(yt.properties.data.properties.message).toBeDefined()
|
|
805
806
|
})
|
|
806
807
|
|
|
807
808
|
test('SSE mode generates SSE envelope even when no yieldType is defined', () => {
|
|
@@ -818,7 +819,9 @@ describe('HonoStreamAppBuilder', () => {
|
|
|
818
819
|
const yt = builder.docs[0]!.jsonSchema.yieldType as Record<string, any>
|
|
819
820
|
expect(yt).toBeDefined()
|
|
820
821
|
expect(yt.type).toBe('object')
|
|
822
|
+
expect(yt.description).toBe('SSE message envelope. The data field contains the procedure yield value.')
|
|
821
823
|
expect(yt.required).toEqual(['data', 'event', 'id'])
|
|
824
|
+
// data is empty schema when no yieldType defined
|
|
822
825
|
expect(yt.properties.data).toEqual({})
|
|
823
826
|
expect(yt.properties.event).toEqual({ type: 'string' })
|
|
824
827
|
expect(yt.properties.id).toEqual({ type: 'string' })
|
|
@@ -849,25 +852,21 @@ describe('HonoStreamAppBuilder', () => {
|
|
|
849
852
|
expect(yt.properties?.retry).toBeUndefined()
|
|
850
853
|
})
|
|
851
854
|
|
|
852
|
-
test('
|
|
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)
|
|
853
858
|
const yieldSchema = v.object({
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
id: v.string().const('fixed-id'),
|
|
859
|
+
id: v.number(),
|
|
860
|
+
message: v.string(),
|
|
857
861
|
})
|
|
858
862
|
const builder = new HonoStreamAppBuilder()
|
|
859
863
|
const RPC = Procedures<{}, RPCConfig>()
|
|
860
864
|
|
|
861
865
|
RPC.CreateStream(
|
|
862
|
-
'
|
|
866
|
+
'Notifications',
|
|
863
867
|
{ scope: 'test', version: 1, schema: { yieldType: yieldSchema } },
|
|
864
868
|
async function* () {
|
|
865
|
-
|
|
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
|
-
}
|
|
869
|
+
yield { id: 42, message: 'hello' }
|
|
871
870
|
}
|
|
872
871
|
)
|
|
873
872
|
|
|
@@ -876,11 +875,11 @@ describe('HonoStreamAppBuilder', () => {
|
|
|
876
875
|
|
|
877
876
|
const yt = builder.docs[0]!.jsonSchema.yieldType as Record<string, any>
|
|
878
877
|
expect(yt.required).toEqual(['data', 'event', 'id'])
|
|
879
|
-
//
|
|
880
|
-
expect(yt.properties.
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
expect(yt.properties.
|
|
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')
|
|
884
883
|
})
|
|
885
884
|
|
|
886
885
|
test('streamMode is recorded in docs', () => {
|
|
@@ -927,7 +926,7 @@ describe('HonoStreamAppBuilder', () => {
|
|
|
927
926
|
|
|
928
927
|
// Streaming procedure (should be registered)
|
|
929
928
|
RPC.CreateStream('Stream', { scope: 'stream', version: 1 }, async function* () {
|
|
930
|
-
yield {
|
|
929
|
+
yield { ok: true }
|
|
931
930
|
})
|
|
932
931
|
|
|
933
932
|
builder.register(RPC, () => ({}))
|
|
@@ -956,7 +955,7 @@ describe('HonoStreamAppBuilder', () => {
|
|
|
956
955
|
const RPC = Procedures<{}, RPCConfig>()
|
|
957
956
|
|
|
958
957
|
RPC.CreateStream('StreamEvents', { scope: 'events', version: 1 }, async function* () {
|
|
959
|
-
yield {
|
|
958
|
+
yield {}
|
|
960
959
|
})
|
|
961
960
|
|
|
962
961
|
builder.register(RPC, () => ({}), {
|
|
@@ -979,7 +978,7 @@ describe('HonoStreamAppBuilder', () => {
|
|
|
979
978
|
const RPC = Procedures<{}, RPCConfig>()
|
|
980
979
|
|
|
981
980
|
RPC.CreateStream('Test', { scope: 'test', version: 1 }, async function* () {
|
|
982
|
-
yield {
|
|
981
|
+
yield {}
|
|
983
982
|
})
|
|
984
983
|
|
|
985
984
|
builder.register(RPC, () => ({}), {
|
|
@@ -1050,7 +1049,7 @@ describe('HonoStreamAppBuilder', () => {
|
|
|
1050
1049
|
const TextRPC = Procedures<{}, RPCConfig>()
|
|
1051
1050
|
|
|
1052
1051
|
SSERPC.CreateStream('SSEStream', { scope: 'sse', version: 1 }, async function* () {
|
|
1053
|
-
yield {
|
|
1052
|
+
yield { mode: 'sse' }
|
|
1054
1053
|
})
|
|
1055
1054
|
|
|
1056
1055
|
TextRPC.CreateStream('TextStream', { scope: 'text', version: 1 }, async function* () {
|
|
@@ -1114,7 +1113,7 @@ describe('HonoStreamAppBuilder', () => {
|
|
|
1114
1113
|
|
|
1115
1114
|
RPC.CreateStream('CheckPrevalidated', { scope: 'check', version: 1 }, async function* (ctx) {
|
|
1116
1115
|
receivedIsPrevalidated = ctx.isPrevalidated
|
|
1117
|
-
yield {
|
|
1116
|
+
yield { ok: true }
|
|
1118
1117
|
})
|
|
1119
1118
|
|
|
1120
1119
|
builder.register(RPC, () => ({}))
|
|
@@ -1200,13 +1199,13 @@ describe('HonoStreamAppBuilder', () => {
|
|
|
1200
1199
|
// SSE Yield Shape Tests
|
|
1201
1200
|
// --------------------------------------------------------------------------
|
|
1202
1201
|
describe('SSE yield shape', () => {
|
|
1203
|
-
test('custom event names
|
|
1202
|
+
test('custom event names via sse() helper', async () => {
|
|
1204
1203
|
const builder = new HonoStreamAppBuilder()
|
|
1205
1204
|
const RPC = Procedures<{}, RPCConfig>()
|
|
1206
1205
|
|
|
1207
1206
|
RPC.CreateStream('Events', { scope: 'events', version: 1 }, async function* () {
|
|
1208
|
-
yield {
|
|
1209
|
-
yield {
|
|
1207
|
+
yield sse({ type: 'user_joined' }, { event: 'join' })
|
|
1208
|
+
yield sse({ type: 'message' }, { event: 'chat' })
|
|
1210
1209
|
})
|
|
1211
1210
|
|
|
1212
1211
|
builder.register(RPC, () => ({}))
|
|
@@ -1220,13 +1219,13 @@ describe('HonoStreamAppBuilder', () => {
|
|
|
1220
1219
|
expect(text).not.toContain('event: Events')
|
|
1221
1220
|
})
|
|
1222
1221
|
|
|
1223
|
-
test('custom id
|
|
1222
|
+
test('custom id via sse() helper', async () => {
|
|
1224
1223
|
const builder = new HonoStreamAppBuilder()
|
|
1225
1224
|
const RPC = Procedures<{}, RPCConfig>()
|
|
1226
1225
|
|
|
1227
1226
|
RPC.CreateStream('Events', { scope: 'events', version: 1 }, async function* () {
|
|
1228
|
-
yield {
|
|
1229
|
-
yield {
|
|
1227
|
+
yield sse({ msg: 'first' }, { id: 'msg-001' })
|
|
1228
|
+
yield sse({ msg: 'second' }, { id: 'msg-002' })
|
|
1230
1229
|
})
|
|
1231
1230
|
|
|
1232
1231
|
builder.register(RPC, () => ({}))
|
|
@@ -1244,8 +1243,8 @@ describe('HonoStreamAppBuilder', () => {
|
|
|
1244
1243
|
const RPC = Procedures<{}, RPCConfig>()
|
|
1245
1244
|
|
|
1246
1245
|
RPC.CreateStream('Events', { scope: 'events', version: 1 }, async function* () {
|
|
1247
|
-
yield
|
|
1248
|
-
yield {
|
|
1246
|
+
yield 'already a string'
|
|
1247
|
+
yield { needs: 'stringify' }
|
|
1249
1248
|
})
|
|
1250
1249
|
|
|
1251
1250
|
builder.register(RPC, () => ({}))
|
|
@@ -1265,9 +1264,9 @@ describe('HonoStreamAppBuilder', () => {
|
|
|
1265
1264
|
const RPC = Procedures<{}, RPCConfig>()
|
|
1266
1265
|
|
|
1267
1266
|
RPC.CreateStream('MyProcedure', { scope: 'test', version: 1 }, async function* () {
|
|
1268
|
-
yield {
|
|
1269
|
-
yield {
|
|
1270
|
-
yield {
|
|
1267
|
+
yield { value: 1 }
|
|
1268
|
+
yield sse({ value: 2 }, { event: 'custom' })
|
|
1269
|
+
yield { value: 3 }
|
|
1271
1270
|
})
|
|
1272
1271
|
|
|
1273
1272
|
builder.register(RPC, () => ({}))
|
|
@@ -1288,6 +1287,106 @@ describe('HonoStreamAppBuilder', () => {
|
|
|
1288
1287
|
})
|
|
1289
1288
|
})
|
|
1290
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
|
+
|
|
1291
1390
|
// --------------------------------------------------------------------------
|
|
1292
1391
|
// Integration Test
|
|
1293
1392
|
// --------------------------------------------------------------------------
|
|
@@ -1330,8 +1429,8 @@ describe('HonoStreamAppBuilder', () => {
|
|
|
1330
1429
|
defaultStreamMode: 'text',
|
|
1331
1430
|
onRequestStart: () => events.push('request-start'),
|
|
1332
1431
|
onRequestEnd: () => events.push('request-end'),
|
|
1333
|
-
onStreamStart: (
|
|
1334
|
-
onStreamEnd: (
|
|
1432
|
+
onStreamStart: () => events.push('stream-start'),
|
|
1433
|
+
onStreamEnd: () => events.push('stream-end'),
|
|
1335
1434
|
})
|
|
1336
1435
|
|
|
1337
1436
|
builder.register(RPC, (c) => ({
|
|
@@ -1360,8 +1459,8 @@ describe('HonoStreamAppBuilder', () => {
|
|
|
1360
1459
|
|
|
1361
1460
|
// Verify hooks were called
|
|
1362
1461
|
expect(events).toContain('request-start')
|
|
1363
|
-
expect(events).toContain('stream-start
|
|
1364
|
-
expect(events).toContain('stream-end
|
|
1462
|
+
expect(events).toContain('stream-start')
|
|
1463
|
+
expect(events).toContain('stream-end')
|
|
1365
1464
|
expect(events).toContain('request-end')
|
|
1366
1465
|
})
|
|
1367
1466
|
})
|
|
@@ -9,16 +9,29 @@ import { ProcedureValidationError } from '../../../errors.js'
|
|
|
9
9
|
|
|
10
10
|
export type { StreamHttpRouteDoc, StreamMode }
|
|
11
11
|
|
|
12
|
-
export type
|
|
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
|
|
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
|
|
247
|
-
event:
|
|
248
|
-
id:
|
|
249
|
-
...(
|
|
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) {
|
|
@@ -370,34 +391,16 @@ export class HonoStreamAppBuilder {
|
|
|
370
391
|
jsonSchema.params = config.schema.params
|
|
371
392
|
}
|
|
372
393
|
if (streamMode === 'sse') {
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
jsonSchema.yieldType = {
|
|
384
|
-
...userSchema,
|
|
385
|
-
required: ['data', 'event', 'id'],
|
|
386
|
-
properties: {
|
|
387
|
-
...sseBaseProperties,
|
|
388
|
-
...(userSchema.properties ?? {}),
|
|
389
|
-
},
|
|
390
|
-
}
|
|
391
|
-
} else {
|
|
392
|
-
// No yieldType defined — generate a complete SSE envelope schema
|
|
393
|
-
jsonSchema.yieldType = {
|
|
394
|
-
type: 'object',
|
|
395
|
-
required: ['data', 'event', 'id'],
|
|
396
|
-
properties: {
|
|
397
|
-
data: {},
|
|
398
|
-
...sseBaseProperties,
|
|
399
|
-
},
|
|
400
|
-
}
|
|
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
|
+
},
|
|
401
404
|
}
|
|
402
405
|
} else if (config.schema?.yieldType) {
|
|
403
406
|
// Text mode: pass through as-is
|