ts-procedures 5.9.1 → 5.10.2
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/README.md +1 -1
- package/agent_config/bin/postinstall.mjs +3 -3
- package/agent_config/bin/setup.mjs +22 -11
- package/agent_config/claude-code/agents/ts-procedures-architect.md +46 -101
- package/agent_config/claude-code/skills/{guide → ts-procedures}/SKILL.md +50 -35
- package/agent_config/claude-code/skills/{guide → ts-procedures}/anti-patterns.md +6 -5
- package/agent_config/claude-code/skills/{guide → ts-procedures}/api-reference.md +60 -49
- package/agent_config/claude-code/skills/ts-procedures-review/SKILL.md +48 -0
- package/agent_config/claude-code/skills/{scaffold → ts-procedures-scaffold}/SKILL.md +19 -24
- package/agent_config/claude-code/skills/ts-procedures-scaffold/templates/client.md +115 -0
- package/agent_config/lib/install-claude.mjs +35 -87
- package/build/src/client/call.d.ts +14 -0
- package/build/src/client/call.js +47 -0
- package/build/src/client/call.js.map +1 -0
- package/build/src/client/call.test.d.ts +1 -0
- package/build/src/client/call.test.js +124 -0
- package/build/src/client/call.test.js.map +1 -0
- package/build/src/client/errors.d.ts +25 -0
- package/build/src/client/errors.js +33 -0
- package/build/src/client/errors.js.map +1 -0
- package/build/src/client/errors.test.d.ts +1 -0
- package/build/src/client/errors.test.js +41 -0
- package/build/src/client/errors.test.js.map +1 -0
- package/build/src/client/fetch-adapter.d.ts +12 -0
- package/build/src/client/fetch-adapter.js +156 -0
- package/build/src/client/fetch-adapter.js.map +1 -0
- package/build/src/client/fetch-adapter.test.d.ts +1 -0
- package/build/src/client/fetch-adapter.test.js +271 -0
- package/build/src/client/fetch-adapter.test.js.map +1 -0
- package/build/src/client/hooks.d.ts +17 -0
- package/build/src/client/hooks.js +40 -0
- package/build/src/client/hooks.js.map +1 -0
- package/build/src/client/hooks.test.d.ts +1 -0
- package/build/src/client/hooks.test.js +163 -0
- package/build/src/client/hooks.test.js.map +1 -0
- package/build/src/client/index.d.ts +22 -0
- package/build/src/client/index.js +67 -0
- package/build/src/client/index.js.map +1 -0
- package/build/src/client/index.test.d.ts +1 -0
- package/build/src/client/index.test.js +231 -0
- package/build/src/client/index.test.js.map +1 -0
- package/build/src/client/request-builder.d.ts +13 -0
- package/build/src/client/request-builder.js +53 -0
- package/build/src/client/request-builder.js.map +1 -0
- package/build/src/client/request-builder.test.d.ts +1 -0
- package/build/src/client/request-builder.test.js +160 -0
- package/build/src/client/request-builder.test.js.map +1 -0
- package/build/src/client/stream.d.ts +27 -0
- package/build/src/client/stream.js +118 -0
- package/build/src/client/stream.js.map +1 -0
- package/build/src/client/stream.test.d.ts +1 -0
- package/build/src/client/stream.test.js +228 -0
- package/build/src/client/stream.test.js.map +1 -0
- package/build/src/client/types.d.ts +78 -0
- package/build/src/client/types.js +3 -0
- package/build/src/client/types.js.map +1 -0
- package/build/src/codegen/bin/cli.d.ts +45 -0
- package/build/src/codegen/bin/cli.js +246 -0
- package/build/src/codegen/bin/cli.js.map +1 -0
- package/build/src/codegen/bin/cli.test.d.ts +1 -0
- package/build/src/codegen/bin/cli.test.js +220 -0
- package/build/src/codegen/bin/cli.test.js.map +1 -0
- package/build/src/codegen/constants.d.ts +1 -0
- package/build/src/codegen/constants.js +2 -0
- package/build/src/codegen/constants.js.map +1 -0
- package/build/src/codegen/e2e.test.d.ts +1 -0
- package/build/src/codegen/e2e.test.js +464 -0
- package/build/src/codegen/e2e.test.js.map +1 -0
- package/build/src/codegen/emit-client-runtime.d.ts +9 -0
- package/build/src/codegen/emit-client-runtime.js +99 -0
- package/build/src/codegen/emit-client-runtime.js.map +1 -0
- package/build/src/codegen/emit-client-runtime.test.d.ts +1 -0
- package/build/src/codegen/emit-client-runtime.test.js +78 -0
- package/build/src/codegen/emit-client-runtime.test.js.map +1 -0
- package/build/src/codegen/emit-client-types.d.ts +8 -0
- package/build/src/codegen/emit-client-types.js +25 -0
- package/build/src/codegen/emit-client-types.js.map +1 -0
- package/build/src/codegen/emit-client-types.test.d.ts +1 -0
- package/build/src/codegen/emit-client-types.test.js +33 -0
- package/build/src/codegen/emit-client-types.test.js.map +1 -0
- package/build/src/codegen/emit-errors.d.ts +19 -0
- package/build/src/codegen/emit-errors.js +59 -0
- package/build/src/codegen/emit-errors.js.map +1 -0
- package/build/src/codegen/emit-errors.test.d.ts +1 -0
- package/build/src/codegen/emit-errors.test.js +175 -0
- package/build/src/codegen/emit-errors.test.js.map +1 -0
- package/build/src/codegen/emit-index.d.ts +12 -0
- package/build/src/codegen/emit-index.js +41 -0
- package/build/src/codegen/emit-index.js.map +1 -0
- package/build/src/codegen/emit-index.test.d.ts +1 -0
- package/build/src/codegen/emit-index.test.js +106 -0
- package/build/src/codegen/emit-index.test.js.map +1 -0
- package/build/src/codegen/emit-scope.d.ts +15 -0
- package/build/src/codegen/emit-scope.js +299 -0
- package/build/src/codegen/emit-scope.js.map +1 -0
- package/build/src/codegen/emit-scope.test.d.ts +1 -0
- package/build/src/codegen/emit-scope.test.js +559 -0
- package/build/src/codegen/emit-scope.test.js.map +1 -0
- package/build/src/codegen/emit-types.d.ts +43 -0
- package/build/src/codegen/emit-types.js +111 -0
- package/build/src/codegen/emit-types.js.map +1 -0
- package/build/src/codegen/emit-types.test.d.ts +1 -0
- package/build/src/codegen/emit-types.test.js +184 -0
- package/build/src/codegen/emit-types.test.js.map +1 -0
- package/build/src/codegen/group-routes.d.ts +23 -0
- package/build/src/codegen/group-routes.js +46 -0
- package/build/src/codegen/group-routes.js.map +1 -0
- package/build/src/codegen/group-routes.test.d.ts +1 -0
- package/build/src/codegen/group-routes.test.js +131 -0
- package/build/src/codegen/group-routes.test.js.map +1 -0
- package/build/src/codegen/index.d.ts +15 -0
- package/build/src/codegen/index.js +16 -0
- package/build/src/codegen/index.js.map +1 -0
- package/build/src/codegen/naming.d.ts +7 -0
- package/build/src/codegen/naming.js +21 -0
- package/build/src/codegen/naming.js.map +1 -0
- package/build/src/codegen/naming.test.d.ts +1 -0
- package/build/src/codegen/naming.test.js +40 -0
- package/build/src/codegen/naming.test.js.map +1 -0
- package/build/src/codegen/pipeline.d.ts +17 -0
- package/build/src/codegen/pipeline.js +78 -0
- package/build/src/codegen/pipeline.js.map +1 -0
- package/build/src/codegen/pipeline.test.d.ts +1 -0
- package/build/src/codegen/pipeline.test.js +269 -0
- package/build/src/codegen/pipeline.test.js.map +1 -0
- package/build/src/codegen/resolve-envelope.d.ts +7 -0
- package/build/src/codegen/resolve-envelope.js +46 -0
- package/build/src/codegen/resolve-envelope.js.map +1 -0
- package/build/src/codegen/resolve-envelope.test.d.ts +1 -0
- package/build/src/codegen/resolve-envelope.test.js +69 -0
- package/build/src/codegen/resolve-envelope.test.js.map +1 -0
- package/build/src/errors.d.ts +33 -0
- package/build/src/errors.js +91 -0
- package/build/src/errors.js.map +1 -0
- package/build/src/errors.test.d.ts +1 -0
- package/build/src/errors.test.js +122 -0
- package/build/src/errors.test.js.map +1 -0
- package/build/src/exports.d.ts +7 -0
- package/build/src/exports.js +8 -0
- package/build/src/exports.js.map +1 -0
- package/build/src/implementations/http/doc-registry.d.ts +12 -0
- package/build/src/implementations/http/doc-registry.js +114 -0
- package/build/src/implementations/http/doc-registry.js.map +1 -0
- package/build/src/implementations/http/doc-registry.test.d.ts +1 -0
- package/build/src/implementations/http/doc-registry.test.js +347 -0
- package/build/src/implementations/http/doc-registry.test.js.map +1 -0
- package/build/src/implementations/http/express-rpc/index.d.ts +94 -0
- package/build/src/implementations/http/express-rpc/index.js +185 -0
- package/build/src/implementations/http/express-rpc/index.js.map +1 -0
- package/build/src/implementations/http/express-rpc/index.test.d.ts +1 -0
- package/build/src/implementations/http/express-rpc/index.test.js +684 -0
- package/build/src/implementations/http/express-rpc/index.test.js.map +1 -0
- package/build/src/implementations/http/express-rpc/types.d.ts +11 -0
- package/build/src/implementations/http/express-rpc/types.js +2 -0
- package/build/src/implementations/http/express-rpc/types.js.map +1 -0
- package/build/src/implementations/http/hono-api/index.d.ts +102 -0
- package/build/src/implementations/http/hono-api/index.js +341 -0
- package/build/src/implementations/http/hono-api/index.js.map +1 -0
- package/build/src/implementations/http/hono-api/index.test.d.ts +1 -0
- package/build/src/implementations/http/hono-api/index.test.js +992 -0
- package/build/src/implementations/http/hono-api/index.test.js.map +1 -0
- package/build/src/implementations/http/hono-api/types.d.ts +13 -0
- package/build/src/implementations/http/hono-api/types.js +2 -0
- package/build/src/implementations/http/hono-api/types.js.map +1 -0
- package/build/src/implementations/http/hono-rpc/index.d.ts +92 -0
- package/build/src/implementations/http/hono-rpc/index.js +161 -0
- package/build/src/implementations/http/hono-rpc/index.js.map +1 -0
- package/build/src/implementations/http/hono-rpc/index.test.d.ts +1 -0
- package/build/src/implementations/http/hono-rpc/index.test.js +803 -0
- package/build/src/implementations/http/hono-rpc/index.test.js.map +1 -0
- package/build/src/implementations/http/hono-rpc/types.d.ts +11 -0
- package/build/src/implementations/http/hono-rpc/types.js +2 -0
- package/build/src/implementations/http/hono-rpc/types.js.map +1 -0
- package/build/src/implementations/http/hono-stream/index.d.ts +120 -0
- package/build/src/implementations/http/hono-stream/index.js +309 -0
- package/build/src/implementations/http/hono-stream/index.js.map +1 -0
- package/build/src/implementations/http/hono-stream/index.test.d.ts +1 -0
- package/build/src/implementations/http/hono-stream/index.test.js +1356 -0
- package/build/src/implementations/http/hono-stream/index.test.js.map +1 -0
- package/build/src/implementations/http/hono-stream/types.d.ts +15 -0
- package/build/src/implementations/http/hono-stream/types.js +2 -0
- package/build/src/implementations/http/hono-stream/types.js.map +1 -0
- package/build/src/implementations/types.d.ts +142 -0
- package/build/src/implementations/types.js +2 -0
- package/build/src/implementations/types.js.map +1 -0
- package/build/src/index.d.ts +165 -0
- package/build/src/index.js +253 -0
- package/build/src/index.js.map +1 -0
- package/build/src/index.test.d.ts +1 -0
- package/build/src/index.test.js +890 -0
- package/build/src/index.test.js.map +1 -0
- package/build/src/schema/compute-schema.d.ts +35 -0
- package/build/src/schema/compute-schema.js +41 -0
- package/build/src/schema/compute-schema.js.map +1 -0
- package/build/src/schema/compute-schema.test.d.ts +1 -0
- package/build/src/schema/compute-schema.test.js +107 -0
- package/build/src/schema/compute-schema.test.js.map +1 -0
- package/build/src/schema/extract-json-schema.d.ts +2 -0
- package/build/src/schema/extract-json-schema.js +12 -0
- package/build/src/schema/extract-json-schema.js.map +1 -0
- package/build/src/schema/extract-json-schema.test.d.ts +1 -0
- package/build/src/schema/extract-json-schema.test.js +23 -0
- package/build/src/schema/extract-json-schema.test.js.map +1 -0
- package/build/src/schema/parser.d.ts +28 -0
- package/build/src/schema/parser.js +170 -0
- package/build/src/schema/parser.js.map +1 -0
- package/build/src/schema/parser.test.d.ts +1 -0
- package/build/src/schema/parser.test.js +120 -0
- package/build/src/schema/parser.test.js.map +1 -0
- package/build/src/schema/resolve-schema-lib.d.ts +12 -0
- package/build/src/schema/resolve-schema-lib.js +11 -0
- package/build/src/schema/resolve-schema-lib.js.map +1 -0
- package/build/src/schema/resolve-schema-lib.test.d.ts +1 -0
- package/build/src/schema/resolve-schema-lib.test.js +17 -0
- package/build/src/schema/resolve-schema-lib.test.js.map +1 -0
- package/build/src/schema/types.d.ts +8 -0
- package/build/src/schema/types.js +2 -0
- package/build/src/schema/types.js.map +1 -0
- package/build/src/stack-utils.d.ts +25 -0
- package/build/src/stack-utils.js +95 -0
- package/build/src/stack-utils.js.map +1 -0
- package/build/src/stack-utils.test.d.ts +1 -0
- package/build/src/stack-utils.test.js +80 -0
- package/build/src/stack-utils.test.js.map +1 -0
- package/docs/ai-agent-setup.md +7 -6
- package/docs/core.md +5 -9
- package/docs/streaming.md +9 -9
- package/package.json +2 -13
- package/src/client/call.test.ts +162 -0
- package/src/client/errors.test.ts +43 -0
- package/src/client/fetch-adapter.test.ts +340 -0
- package/src/client/hooks.test.ts +191 -0
- package/src/client/index.test.ts +290 -0
- package/src/client/request-builder.test.ts +184 -0
- package/src/client/stream.test.ts +331 -0
- package/src/codegen/bin/cli.test.ts +260 -0
- package/src/codegen/bin/cli.ts +282 -0
- package/src/codegen/constants.ts +1 -0
- package/src/codegen/e2e.test.ts +565 -0
- package/src/codegen/emit-client-runtime.test.ts +93 -0
- package/src/codegen/emit-client-runtime.ts +114 -0
- package/src/codegen/emit-client-types.test.ts +39 -0
- package/src/codegen/emit-client-types.ts +27 -0
- package/src/codegen/emit-errors.test.ts +202 -0
- package/src/codegen/emit-errors.ts +80 -0
- package/src/codegen/emit-index.test.ts +127 -0
- package/src/codegen/emit-index.ts +58 -0
- package/src/codegen/emit-scope.test.ts +624 -0
- package/src/codegen/emit-scope.ts +389 -0
- package/src/codegen/emit-types.test.ts +205 -0
- package/src/codegen/emit-types.ts +158 -0
- package/src/codegen/group-routes.test.ts +159 -0
- package/src/codegen/group-routes.ts +61 -0
- package/src/codegen/index.ts +30 -0
- package/src/codegen/naming.test.ts +50 -0
- package/src/codegen/naming.ts +25 -0
- package/src/codegen/pipeline.test.ts +316 -0
- package/src/codegen/pipeline.ts +108 -0
- package/src/codegen/resolve-envelope.test.ts +76 -0
- package/src/codegen/resolve-envelope.ts +61 -0
- package/src/errors.test.ts +163 -0
- package/src/errors.ts +107 -0
- package/src/exports.ts +7 -0
- package/src/implementations/http/doc-registry.test.ts +415 -0
- package/src/implementations/http/doc-registry.ts +143 -0
- package/src/implementations/http/express-rpc/README.md +6 -6
- package/src/implementations/http/express-rpc/index.test.ts +957 -0
- package/src/implementations/http/express-rpc/index.ts +266 -0
- package/src/implementations/http/express-rpc/types.ts +16 -0
- package/src/implementations/http/hono-api/index.test.ts +1341 -0
- package/src/implementations/http/hono-api/index.ts +463 -0
- package/src/implementations/http/hono-api/types.ts +16 -0
- package/src/implementations/http/hono-rpc/README.md +6 -6
- package/src/implementations/http/hono-rpc/index.test.ts +1075 -0
- package/src/implementations/http/hono-rpc/index.ts +238 -0
- package/src/implementations/http/hono-rpc/types.ts +16 -0
- package/src/implementations/http/hono-stream/README.md +12 -12
- package/src/implementations/http/hono-stream/index.test.ts +1768 -0
- package/src/implementations/http/hono-stream/index.ts +456 -0
- package/src/implementations/http/hono-stream/types.ts +20 -0
- package/src/implementations/types.ts +174 -0
- package/src/index.test.ts +1185 -0
- package/src/index.ts +522 -0
- package/src/schema/compute-schema.test.ts +128 -0
- package/src/schema/compute-schema.ts +88 -0
- package/src/schema/extract-json-schema.test.ts +25 -0
- package/src/schema/extract-json-schema.ts +15 -0
- package/src/schema/parser.test.ts +182 -0
- package/src/schema/parser.ts +215 -0
- package/src/schema/resolve-schema-lib.test.ts +19 -0
- package/src/schema/resolve-schema-lib.ts +29 -0
- package/src/schema/types.ts +20 -0
- package/src/stack-utils.test.ts +94 -0
- package/src/stack-utils.ts +129 -0
- package/agent_config/claude-code/skills/review/SKILL.md +0 -53
- package/docs/superpowers/plans/2026-03-30-client-codegen.md +0 -2833
- package/docs/superpowers/specs/2026-03-30-client-codegen-design.md +0 -632
- /package/agent_config/claude-code/skills/{guide → ts-procedures}/patterns.md +0 -0
- /package/agent_config/claude-code/skills/{review → ts-procedures-review}/checklist.md +0 -0
- /package/agent_config/claude-code/skills/{scaffold → ts-procedures-scaffold}/templates/express-rpc.md +0 -0
- /package/agent_config/claude-code/skills/{scaffold → ts-procedures-scaffold}/templates/hono-api.md +0 -0
- /package/agent_config/claude-code/skills/{scaffold → ts-procedures-scaffold}/templates/hono-rpc.md +0 -0
- /package/agent_config/claude-code/skills/{scaffold → ts-procedures-scaffold}/templates/hono-stream.md +0 -0
- /package/agent_config/claude-code/skills/{scaffold → ts-procedures-scaffold}/templates/procedure.md +0 -0
- /package/agent_config/claude-code/skills/{scaffold → ts-procedures-scaffold}/templates/stream-procedure.md +0 -0
|
@@ -0,0 +1,1768 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-empty-object-type, @typescript-eslint/explicit-function-return-type */
|
|
2
|
+
import { describe, expect, test, vi, beforeEach } from 'vitest'
|
|
3
|
+
import { Hono } from 'hono'
|
|
4
|
+
import { v } from 'suretype'
|
|
5
|
+
import { Procedures } from '../../../index.js'
|
|
6
|
+
import { HonoStreamAppBuilder, sse, MidStreamErrorResult } from './index.js'
|
|
7
|
+
import { RPCConfig, StreamMode } from '../../types.js'
|
|
8
|
+
import { ProcedureValidationError } from '../../../errors.js'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* HonoStreamAppBuilder Test Suite
|
|
12
|
+
*
|
|
13
|
+
* Tests the streaming Hono integration for ts-procedures.
|
|
14
|
+
* This builder creates GET and POST routes for streaming procedures (SSE and text modes).
|
|
15
|
+
*/
|
|
16
|
+
describe('HonoStreamAppBuilder', () => {
|
|
17
|
+
// --------------------------------------------------------------------------
|
|
18
|
+
// Constructor Tests
|
|
19
|
+
// --------------------------------------------------------------------------
|
|
20
|
+
describe('constructor', () => {
|
|
21
|
+
test('creates default Hono app', async () => {
|
|
22
|
+
const builder = new HonoStreamAppBuilder()
|
|
23
|
+
const RPC = Procedures<{ userId: string }, RPCConfig>()
|
|
24
|
+
|
|
25
|
+
RPC.CreateStream('StreamMessages', { scope: 'messages', version: 1 }, async function* () {
|
|
26
|
+
yield { message: 'hello' }
|
|
27
|
+
yield { message: 'world' }
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
builder.register(RPC, () => ({ userId: '123' }))
|
|
31
|
+
const app = builder.build()
|
|
32
|
+
|
|
33
|
+
const res = await app.request('/messages/stream-messages/1', {
|
|
34
|
+
method: 'GET',
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
expect(res.status).toBe(200)
|
|
38
|
+
expect(res.headers.get('content-type')).toContain('text/event-stream')
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
test('uses provided Hono app', async () => {
|
|
42
|
+
const customApp = new Hono()
|
|
43
|
+
customApp.get('/custom', (c) => c.json({ custom: true }))
|
|
44
|
+
|
|
45
|
+
const builder = new HonoStreamAppBuilder({ app: customApp })
|
|
46
|
+
const RPC = Procedures<{ userId: string }, RPCConfig>()
|
|
47
|
+
|
|
48
|
+
RPC.CreateStream('StreamData', { scope: 'data', version: 1 }, async function* () {
|
|
49
|
+
yield { data: 1 }
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
builder.register(RPC, () => ({ userId: '123' }))
|
|
53
|
+
const app = builder.build()
|
|
54
|
+
|
|
55
|
+
// Custom route should still work
|
|
56
|
+
const customRes = await app.request('/custom')
|
|
57
|
+
expect(customRes.status).toBe(200)
|
|
58
|
+
const customBody = await customRes.json()
|
|
59
|
+
expect(customBody).toEqual({ custom: true })
|
|
60
|
+
|
|
61
|
+
// Stream route should also work
|
|
62
|
+
const streamRes = await app.request('/data/stream-data/1')
|
|
63
|
+
expect(streamRes.status).toBe(200)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
test('handles empty config', () => {
|
|
67
|
+
const builder = new HonoStreamAppBuilder({})
|
|
68
|
+
expect(builder.app).toBeDefined()
|
|
69
|
+
expect(builder.docs).toEqual([])
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
test('handles undefined config', () => {
|
|
73
|
+
const builder = new HonoStreamAppBuilder(undefined)
|
|
74
|
+
expect(builder.app).toBeDefined()
|
|
75
|
+
expect(builder.docs).toEqual([])
|
|
76
|
+
})
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
// --------------------------------------------------------------------------
|
|
80
|
+
// SSE Streaming Tests
|
|
81
|
+
// --------------------------------------------------------------------------
|
|
82
|
+
describe('SSE streaming mode', () => {
|
|
83
|
+
test('streams multiple values as SSE events', async () => {
|
|
84
|
+
const builder = new HonoStreamAppBuilder()
|
|
85
|
+
const RPC = Procedures<{}, RPCConfig>()
|
|
86
|
+
|
|
87
|
+
RPC.CreateStream('Counter', { scope: 'counter', version: 1 }, async function* () {
|
|
88
|
+
yield { count: 1 }
|
|
89
|
+
yield { count: 2 }
|
|
90
|
+
yield { count: 3 }
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
builder.register(RPC, () => ({}))
|
|
94
|
+
const app = builder.build()
|
|
95
|
+
|
|
96
|
+
const res = await app.request('/counter/counter/1')
|
|
97
|
+
expect(res.status).toBe(200)
|
|
98
|
+
expect(res.headers.get('content-type')).toContain('text/event-stream')
|
|
99
|
+
|
|
100
|
+
const text = await res.text()
|
|
101
|
+
expect(text).toContain('event: Counter')
|
|
102
|
+
expect(text).toContain('data: {"count":1}')
|
|
103
|
+
expect(text).toContain('data: {"count":2}')
|
|
104
|
+
expect(text).toContain('data: {"count":3}')
|
|
105
|
+
expect(text).toContain('id: 0')
|
|
106
|
+
expect(text).toContain('id: 1')
|
|
107
|
+
expect(text).toContain('id: 2')
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
test('uses SSE mode by default', async () => {
|
|
111
|
+
const builder = new HonoStreamAppBuilder()
|
|
112
|
+
const RPC = Procedures<{}, RPCConfig>()
|
|
113
|
+
|
|
114
|
+
RPC.CreateStream('Test', { scope: 'test', version: 1 }, async function* () {
|
|
115
|
+
yield { ok: true }
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
builder.register(RPC, () => ({}))
|
|
119
|
+
const app = builder.build()
|
|
120
|
+
|
|
121
|
+
const res = await app.request('/test/test/1')
|
|
122
|
+
expect(res.headers.get('content-type')).toContain('text/event-stream')
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
test('explicitly set SSE mode works', async () => {
|
|
126
|
+
const builder = new HonoStreamAppBuilder({ defaultStreamMode: 'sse' })
|
|
127
|
+
const RPC = Procedures<{}, RPCConfig>()
|
|
128
|
+
|
|
129
|
+
RPC.CreateStream('Test', { scope: 'test', version: 1 }, async function* () {
|
|
130
|
+
yield { ok: true }
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
builder.register(RPC, () => ({}))
|
|
134
|
+
const app = builder.build()
|
|
135
|
+
|
|
136
|
+
const res = await app.request('/test/test/1')
|
|
137
|
+
expect(res.headers.get('content-type')).toContain('text/event-stream')
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
test('sends event: return when generator returns a value', async () => {
|
|
141
|
+
const builder = new HonoStreamAppBuilder()
|
|
142
|
+
const RPC = Procedures<{}, RPCConfig>()
|
|
143
|
+
|
|
144
|
+
RPC.CreateStream('Summary', { scope: 'summary', version: 1 }, async function* () {
|
|
145
|
+
yield 'hello'
|
|
146
|
+
yield 'world'
|
|
147
|
+
return { total: 42 }
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
builder.register(RPC, () => ({}))
|
|
151
|
+
const app = builder.build()
|
|
152
|
+
|
|
153
|
+
const res = await app.request('/summary/summary/1', { method: 'POST', body: JSON.stringify({}) })
|
|
154
|
+
expect(res.status).toBe(200)
|
|
155
|
+
|
|
156
|
+
const text = await res.text()
|
|
157
|
+
expect(text).toContain('event: return')
|
|
158
|
+
expect(text).toContain('data: {"total":42}')
|
|
159
|
+
// yield events have id: 0 and id: 1; return event should have id: 2
|
|
160
|
+
expect(text).toContain('id: 2')
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
test('does not send return event when generator returns undefined', async () => {
|
|
164
|
+
const builder = new HonoStreamAppBuilder()
|
|
165
|
+
const RPC = Procedures<{}, RPCConfig>()
|
|
166
|
+
|
|
167
|
+
RPC.CreateStream('NoReturn', { scope: 'no-return', version: 1 }, async function* () {
|
|
168
|
+
yield { item: 1 }
|
|
169
|
+
yield { item: 2 }
|
|
170
|
+
// no explicit return — implicitly returns undefined
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
builder.register(RPC, () => ({}))
|
|
174
|
+
const app = builder.build()
|
|
175
|
+
|
|
176
|
+
const res = await app.request('/no-return/no-return/1')
|
|
177
|
+
expect(res.status).toBe(200)
|
|
178
|
+
|
|
179
|
+
const text = await res.text()
|
|
180
|
+
expect(text).not.toContain('event: return')
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
test('return event works with zero yields', async () => {
|
|
184
|
+
const builder = new HonoStreamAppBuilder()
|
|
185
|
+
const RPC = Procedures<{}, RPCConfig>()
|
|
186
|
+
|
|
187
|
+
// eslint-disable-next-line require-yield
|
|
188
|
+
RPC.CreateStream('ImmediateReturn', { scope: 'immediate', version: 1 }, async function* () {
|
|
189
|
+
return { status: 'done' }
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
builder.register(RPC, () => ({}))
|
|
193
|
+
const app = builder.build()
|
|
194
|
+
|
|
195
|
+
const res = await app.request('/immediate/immediate-return/1')
|
|
196
|
+
expect(res.status).toBe(200)
|
|
197
|
+
|
|
198
|
+
const text = await res.text()
|
|
199
|
+
expect(text).toContain('event: return')
|
|
200
|
+
expect(text).toContain('data: {"status":"done"}')
|
|
201
|
+
// first (and only) event has id: 0
|
|
202
|
+
expect(text).toContain('id: 0')
|
|
203
|
+
})
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
// --------------------------------------------------------------------------
|
|
207
|
+
// Text Streaming Tests
|
|
208
|
+
// --------------------------------------------------------------------------
|
|
209
|
+
describe('text streaming mode', () => {
|
|
210
|
+
test('streams multiple values as newline-delimited JSON', async () => {
|
|
211
|
+
const builder = new HonoStreamAppBuilder({ defaultStreamMode: 'text' })
|
|
212
|
+
const RPC = Procedures<{}, RPCConfig>()
|
|
213
|
+
|
|
214
|
+
RPC.CreateStream('Counter', { scope: 'counter', version: 1 }, async function* () {
|
|
215
|
+
yield { count: 1 }
|
|
216
|
+
yield { count: 2 }
|
|
217
|
+
yield { count: 3 }
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
builder.register(RPC, () => ({}))
|
|
221
|
+
const app = builder.build()
|
|
222
|
+
|
|
223
|
+
const res = await app.request('/counter/counter/1')
|
|
224
|
+
expect(res.status).toBe(200)
|
|
225
|
+
expect(res.headers.get('content-type')).toContain('text/plain')
|
|
226
|
+
|
|
227
|
+
const text = await res.text()
|
|
228
|
+
const lines = text.trim().split('\n')
|
|
229
|
+
expect(lines).toHaveLength(3)
|
|
230
|
+
expect(JSON.parse(lines[0]!)).toEqual({ count: 1 })
|
|
231
|
+
expect(JSON.parse(lines[1]!)).toEqual({ count: 2 })
|
|
232
|
+
expect(JSON.parse(lines[2]!)).toEqual({ count: 3 })
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
test('per-factory streamMode overrides default', async () => {
|
|
236
|
+
const builder = new HonoStreamAppBuilder({ defaultStreamMode: 'sse' })
|
|
237
|
+
const RPC = Procedures<{}, RPCConfig>()
|
|
238
|
+
|
|
239
|
+
RPC.CreateStream('Test', { scope: 'test', version: 1 }, async function* () {
|
|
240
|
+
yield { ok: true }
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
builder.register(RPC, () => ({}), { streamMode: 'text' })
|
|
244
|
+
const app = builder.build()
|
|
245
|
+
|
|
246
|
+
const res = await app.request('/test/test/1')
|
|
247
|
+
expect(res.headers.get('content-type')).toContain('text/plain')
|
|
248
|
+
})
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
// --------------------------------------------------------------------------
|
|
252
|
+
// HTTP Method Tests (GET and POST)
|
|
253
|
+
// --------------------------------------------------------------------------
|
|
254
|
+
describe('HTTP methods', () => {
|
|
255
|
+
test('GET request works', async () => {
|
|
256
|
+
const builder = new HonoStreamAppBuilder()
|
|
257
|
+
const RPC = Procedures<{}, RPCConfig>()
|
|
258
|
+
|
|
259
|
+
RPC.CreateStream('Test', { scope: 'test', version: 1 }, async function* () {
|
|
260
|
+
yield { method: 'works' }
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
builder.register(RPC, () => ({}))
|
|
264
|
+
const app = builder.build()
|
|
265
|
+
|
|
266
|
+
const res = await app.request('/test/test/1', { method: 'GET' })
|
|
267
|
+
expect(res.status).toBe(200)
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
test('POST request works', async () => {
|
|
271
|
+
const builder = new HonoStreamAppBuilder()
|
|
272
|
+
const RPC = Procedures<{}, RPCConfig>()
|
|
273
|
+
|
|
274
|
+
RPC.CreateStream('Test', { scope: 'test', version: 1 }, async function* () {
|
|
275
|
+
yield { method: 'works' }
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
builder.register(RPC, () => ({}))
|
|
279
|
+
const app = builder.build()
|
|
280
|
+
|
|
281
|
+
const res = await app.request('/test/test/1', {
|
|
282
|
+
method: 'POST',
|
|
283
|
+
headers: { 'Content-Type': 'application/json' },
|
|
284
|
+
body: JSON.stringify({}),
|
|
285
|
+
})
|
|
286
|
+
expect(res.status).toBe(200)
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
test('GET request passes query params to handler', async () => {
|
|
290
|
+
const builder = new HonoStreamAppBuilder({ defaultStreamMode: 'text' })
|
|
291
|
+
const RPC = Procedures<{}, RPCConfig>()
|
|
292
|
+
|
|
293
|
+
RPC.CreateStream('Echo', { scope: 'echo', version: 1 }, async function* (ctx, params) {
|
|
294
|
+
yield { received: params }
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
builder.register(RPC, () => ({}))
|
|
298
|
+
const app = builder.build()
|
|
299
|
+
|
|
300
|
+
const res = await app.request('/echo/echo/1?foo=bar&baz=qux')
|
|
301
|
+
const text = await res.text()
|
|
302
|
+
const data = JSON.parse(text.trim())
|
|
303
|
+
expect(data.received).toEqual({ foo: 'bar', baz: 'qux' })
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
test('POST request passes JSON body to handler', async () => {
|
|
307
|
+
const builder = new HonoStreamAppBuilder({ defaultStreamMode: 'text' })
|
|
308
|
+
const RPC = Procedures<{}, RPCConfig>()
|
|
309
|
+
|
|
310
|
+
RPC.CreateStream('Echo', { scope: 'echo', version: 1 }, async function* (ctx, params) {
|
|
311
|
+
yield { received: params }
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
builder.register(RPC, () => ({}))
|
|
315
|
+
const app = builder.build()
|
|
316
|
+
|
|
317
|
+
const res = await app.request('/echo/echo/1', {
|
|
318
|
+
method: 'POST',
|
|
319
|
+
headers: { 'Content-Type': 'application/json' },
|
|
320
|
+
body: JSON.stringify({ complex: { nested: 'data' }, array: [1, 2, 3] }),
|
|
321
|
+
})
|
|
322
|
+
const text = await res.text()
|
|
323
|
+
const data = JSON.parse(text.trim())
|
|
324
|
+
expect(data.received).toEqual({ complex: { nested: 'data' }, array: [1, 2, 3] })
|
|
325
|
+
})
|
|
326
|
+
})
|
|
327
|
+
|
|
328
|
+
// --------------------------------------------------------------------------
|
|
329
|
+
// pathPrefix Option Tests
|
|
330
|
+
// --------------------------------------------------------------------------
|
|
331
|
+
describe('pathPrefix option', () => {
|
|
332
|
+
test('uses custom pathPrefix for all routes', async () => {
|
|
333
|
+
const builder = new HonoStreamAppBuilder({ pathPrefix: '/api/v1' })
|
|
334
|
+
const RPC = Procedures<{}, RPCConfig>()
|
|
335
|
+
|
|
336
|
+
RPC.CreateStream('Test', { scope: 'test', version: 1 }, async function* () {
|
|
337
|
+
yield { ok: true }
|
|
338
|
+
})
|
|
339
|
+
|
|
340
|
+
builder.register(RPC, () => ({}))
|
|
341
|
+
const app = builder.build()
|
|
342
|
+
|
|
343
|
+
const res = await app.request('/api/v1/test/test/1')
|
|
344
|
+
expect(res.status).toBe(200)
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
test('pathPrefix without leading slash gets normalized', async () => {
|
|
348
|
+
const builder = new HonoStreamAppBuilder({ pathPrefix: 'custom' })
|
|
349
|
+
const RPC = Procedures<{}, RPCConfig>()
|
|
350
|
+
|
|
351
|
+
RPC.CreateStream('Test', { scope: 'test', version: 1 }, async function* () {
|
|
352
|
+
yield { ok: true }
|
|
353
|
+
})
|
|
354
|
+
|
|
355
|
+
builder.register(RPC, () => ({}))
|
|
356
|
+
const app = builder.build()
|
|
357
|
+
|
|
358
|
+
const res = await app.request('/custom/test/test/1')
|
|
359
|
+
expect(res.status).toBe(200)
|
|
360
|
+
})
|
|
361
|
+
|
|
362
|
+
test('pathPrefix appears in generated docs', () => {
|
|
363
|
+
const builder = new HonoStreamAppBuilder({ pathPrefix: '/api' })
|
|
364
|
+
const RPC = Procedures<{}, RPCConfig>()
|
|
365
|
+
|
|
366
|
+
RPC.CreateStream('Test', { scope: 'test', version: 1 }, async function* () {
|
|
367
|
+
yield {}
|
|
368
|
+
})
|
|
369
|
+
|
|
370
|
+
builder.register(RPC, () => ({}))
|
|
371
|
+
builder.build()
|
|
372
|
+
|
|
373
|
+
expect(builder.docs[0]!.path).toBe('/api/test/test/1')
|
|
374
|
+
})
|
|
375
|
+
})
|
|
376
|
+
|
|
377
|
+
// --------------------------------------------------------------------------
|
|
378
|
+
// Lifecycle Hooks Tests
|
|
379
|
+
// --------------------------------------------------------------------------
|
|
380
|
+
describe('lifecycle hooks', () => {
|
|
381
|
+
test('onRequestStart is called with context object', async () => {
|
|
382
|
+
const onRequestStart = vi.fn()
|
|
383
|
+
const builder = new HonoStreamAppBuilder({ onRequestStart })
|
|
384
|
+
const RPC = Procedures<{}, RPCConfig>()
|
|
385
|
+
|
|
386
|
+
RPC.CreateStream('Test', { scope: 'test', version: 1 }, async function* () {
|
|
387
|
+
yield { ok: true }
|
|
388
|
+
})
|
|
389
|
+
|
|
390
|
+
builder.register(RPC, () => ({}))
|
|
391
|
+
const app = builder.build()
|
|
392
|
+
|
|
393
|
+
await app.request('/test/test/1')
|
|
394
|
+
|
|
395
|
+
expect(onRequestStart).toHaveBeenCalledTimes(1)
|
|
396
|
+
expect(onRequestStart.mock.calls[0]![0]).toHaveProperty('req')
|
|
397
|
+
})
|
|
398
|
+
|
|
399
|
+
test('onRequestEnd is called after response', async () => {
|
|
400
|
+
const onRequestEnd = vi.fn()
|
|
401
|
+
const builder = new HonoStreamAppBuilder({ onRequestEnd })
|
|
402
|
+
const RPC = Procedures<{}, RPCConfig>()
|
|
403
|
+
|
|
404
|
+
RPC.CreateStream('Test', { scope: 'test', version: 1 }, async function* () {
|
|
405
|
+
yield { ok: true }
|
|
406
|
+
})
|
|
407
|
+
|
|
408
|
+
builder.register(RPC, () => ({}))
|
|
409
|
+
const app = builder.build()
|
|
410
|
+
|
|
411
|
+
const response = await app.request('/test/test/1')
|
|
412
|
+
await response.text() // Consume stream to trigger onRequestEnd
|
|
413
|
+
|
|
414
|
+
expect(onRequestEnd).toHaveBeenCalledTimes(1)
|
|
415
|
+
expect(onRequestEnd.mock.calls[0]![0]).toHaveProperty('req')
|
|
416
|
+
})
|
|
417
|
+
|
|
418
|
+
test('onStreamStart is called before streaming begins with streamMode', async () => {
|
|
419
|
+
const onStreamStart = vi.fn()
|
|
420
|
+
const builder = new HonoStreamAppBuilder({ onStreamStart })
|
|
421
|
+
const RPC = Procedures<{}, RPCConfig>()
|
|
422
|
+
|
|
423
|
+
RPC.CreateStream('Test', { scope: 'test', version: 1 }, async function* () {
|
|
424
|
+
yield { ok: true }
|
|
425
|
+
})
|
|
426
|
+
|
|
427
|
+
builder.register(RPC, () => ({}))
|
|
428
|
+
const app = builder.build()
|
|
429
|
+
|
|
430
|
+
await app.request('/test/test/1')
|
|
431
|
+
|
|
432
|
+
expect(onStreamStart).toHaveBeenCalledTimes(1)
|
|
433
|
+
expect(onStreamStart.mock.calls[0]![0]).toHaveProperty('name', 'Test')
|
|
434
|
+
expect(onStreamStart.mock.calls[0]![2]).toBe('sse')
|
|
435
|
+
})
|
|
436
|
+
|
|
437
|
+
test('onStreamEnd is called after stream completes with streamMode', async () => {
|
|
438
|
+
const onStreamEnd = vi.fn()
|
|
439
|
+
const builder = new HonoStreamAppBuilder({ onStreamEnd })
|
|
440
|
+
const RPC = Procedures<{}, RPCConfig>()
|
|
441
|
+
|
|
442
|
+
RPC.CreateStream('Test', { scope: 'test', version: 1 }, async function* () {
|
|
443
|
+
yield { ok: true }
|
|
444
|
+
})
|
|
445
|
+
|
|
446
|
+
builder.register(RPC, () => ({}))
|
|
447
|
+
const app = builder.build()
|
|
448
|
+
|
|
449
|
+
const res = await app.request('/test/test/1')
|
|
450
|
+
// Consume the stream to ensure it completes
|
|
451
|
+
await res.text()
|
|
452
|
+
|
|
453
|
+
expect(onStreamEnd).toHaveBeenCalledTimes(1)
|
|
454
|
+
expect(onStreamEnd.mock.calls[0]![0]).toHaveProperty('name', 'Test')
|
|
455
|
+
expect(onStreamEnd.mock.calls[0]![2]).toBe('sse')
|
|
456
|
+
})
|
|
457
|
+
|
|
458
|
+
test('hooks execute in correct order', async () => {
|
|
459
|
+
const order: string[] = []
|
|
460
|
+
|
|
461
|
+
const builder = new HonoStreamAppBuilder({
|
|
462
|
+
onRequestStart: () => order.push('request-start'),
|
|
463
|
+
onRequestEnd: () => order.push('request-end'),
|
|
464
|
+
onStreamStart: () => order.push('stream-start'),
|
|
465
|
+
onStreamEnd: () => order.push('stream-end'),
|
|
466
|
+
})
|
|
467
|
+
const RPC = Procedures<{}, RPCConfig>()
|
|
468
|
+
|
|
469
|
+
RPC.CreateStream('Test', { scope: 'test', version: 1 }, async function* () {
|
|
470
|
+
order.push('handler')
|
|
471
|
+
yield { ok: true }
|
|
472
|
+
})
|
|
473
|
+
|
|
474
|
+
builder.register(RPC, () => ({}))
|
|
475
|
+
const app = builder.build()
|
|
476
|
+
|
|
477
|
+
const res = await app.request('/test/test/1')
|
|
478
|
+
// Consume the stream to ensure it completes
|
|
479
|
+
await res.text()
|
|
480
|
+
|
|
481
|
+
// Note: onRequestEnd middleware runs when response starts (before stream completes)
|
|
482
|
+
// while onStreamEnd runs when the stream finishes
|
|
483
|
+
expect(order).toContain('request-start')
|
|
484
|
+
expect(order).toContain('stream-start')
|
|
485
|
+
expect(order).toContain('handler')
|
|
486
|
+
expect(order).toContain('stream-end')
|
|
487
|
+
expect(order).toContain('request-end')
|
|
488
|
+
// request-start should be first
|
|
489
|
+
expect(order[0]).toBe('request-start')
|
|
490
|
+
// stream-start should be before handler
|
|
491
|
+
expect(order.indexOf('stream-start')).toBeLessThan(order.indexOf('handler'))
|
|
492
|
+
// request-end should be last
|
|
493
|
+
expect(order[order.length - 1]).toBe('request-end')
|
|
494
|
+
})
|
|
495
|
+
})
|
|
496
|
+
|
|
497
|
+
// --------------------------------------------------------------------------
|
|
498
|
+
// Error Handling Tests
|
|
499
|
+
// --------------------------------------------------------------------------
|
|
500
|
+
describe('error handling', () => {
|
|
501
|
+
test('custom error handler receives procedure, context, and error', async () => {
|
|
502
|
+
const errorHandler = vi.fn((procedure, c, error) => {
|
|
503
|
+
return c.json({ customError: error.message }, 400)
|
|
504
|
+
})
|
|
505
|
+
|
|
506
|
+
const builder = new HonoStreamAppBuilder({ onPreStreamError: errorHandler })
|
|
507
|
+
const RPC = Procedures<{}, RPCConfig>()
|
|
508
|
+
|
|
509
|
+
RPC.CreateStream(
|
|
510
|
+
'ValidatedStream',
|
|
511
|
+
{
|
|
512
|
+
scope: 'validated',
|
|
513
|
+
version: 1,
|
|
514
|
+
schema: {
|
|
515
|
+
params: v.object({ count: v.number() }),
|
|
516
|
+
},
|
|
517
|
+
},
|
|
518
|
+
async function* (ctx, params) {
|
|
519
|
+
yield { count: params.count }
|
|
520
|
+
}
|
|
521
|
+
)
|
|
522
|
+
|
|
523
|
+
builder.register(RPC, () => ({}))
|
|
524
|
+
const app = builder.build()
|
|
525
|
+
|
|
526
|
+
const res = await app.request('/validated/validated-stream/1?count=not-a-number')
|
|
527
|
+
|
|
528
|
+
expect(res.status).toBe(400)
|
|
529
|
+
const body = await res.json()
|
|
530
|
+
expect(body.customError).toContain('Validation error')
|
|
531
|
+
|
|
532
|
+
expect(errorHandler).toHaveBeenCalledTimes(1)
|
|
533
|
+
expect(errorHandler.mock.calls[0]![0].name).toBe('ValidatedStream')
|
|
534
|
+
expect(errorHandler.mock.calls[0]![2].message).toContain('Validation error')
|
|
535
|
+
})
|
|
536
|
+
|
|
537
|
+
test('errors during streaming are sent as error events (SSE mode)', async () => {
|
|
538
|
+
const builder = new HonoStreamAppBuilder()
|
|
539
|
+
const RPC = Procedures<{}, RPCConfig>()
|
|
540
|
+
|
|
541
|
+
RPC.CreateStream('ErrorStream', { scope: 'error', version: 1 }, async function* () {
|
|
542
|
+
yield { count: 1 }
|
|
543
|
+
throw new Error('Stream error')
|
|
544
|
+
})
|
|
545
|
+
|
|
546
|
+
builder.register(RPC, () => ({}))
|
|
547
|
+
const app = builder.build()
|
|
548
|
+
|
|
549
|
+
const res = await app.request('/error/error-stream/1')
|
|
550
|
+
const text = await res.text()
|
|
551
|
+
|
|
552
|
+
expect(text).toContain('data: {"count":1}')
|
|
553
|
+
expect(text).toContain('event: error')
|
|
554
|
+
expect(text).toContain('Stream error')
|
|
555
|
+
})
|
|
556
|
+
|
|
557
|
+
test('errors during streaming are sent as JSON lines (text mode)', async () => {
|
|
558
|
+
const builder = new HonoStreamAppBuilder({ defaultStreamMode: 'text' })
|
|
559
|
+
const RPC = Procedures<{}, RPCConfig>()
|
|
560
|
+
|
|
561
|
+
RPC.CreateStream('ErrorStream', { scope: 'error', version: 1 }, async function* () {
|
|
562
|
+
yield { count: 1 }
|
|
563
|
+
throw new Error('Stream error')
|
|
564
|
+
})
|
|
565
|
+
|
|
566
|
+
builder.register(RPC, () => ({}))
|
|
567
|
+
const app = builder.build()
|
|
568
|
+
|
|
569
|
+
const res = await app.request('/error/error-stream/1')
|
|
570
|
+
const text = await res.text()
|
|
571
|
+
const lines = text.trim().split('\n')
|
|
572
|
+
|
|
573
|
+
expect(JSON.parse(lines[0]!)).toEqual({ count: 1 })
|
|
574
|
+
// Error is wrapped by Procedures with "Error in streaming handler for {name}" prefix
|
|
575
|
+
expect(JSON.parse(lines[1]!).error).toContain('Stream error')
|
|
576
|
+
})
|
|
577
|
+
|
|
578
|
+
test('validation errors return 400 by default when no error handler', async () => {
|
|
579
|
+
const builder = new HonoStreamAppBuilder()
|
|
580
|
+
const RPC = Procedures<{}, RPCConfig>()
|
|
581
|
+
|
|
582
|
+
RPC.CreateStream(
|
|
583
|
+
'ValidatedStream',
|
|
584
|
+
{
|
|
585
|
+
scope: 'validated',
|
|
586
|
+
version: 1,
|
|
587
|
+
schema: {
|
|
588
|
+
params: v.object({ count: v.number() }),
|
|
589
|
+
},
|
|
590
|
+
},
|
|
591
|
+
async function* (ctx, params) {
|
|
592
|
+
yield { count: params.count }
|
|
593
|
+
}
|
|
594
|
+
)
|
|
595
|
+
|
|
596
|
+
builder.register(RPC, () => ({}))
|
|
597
|
+
const app = builder.build()
|
|
598
|
+
|
|
599
|
+
const res = await app.request('/validated/validated-stream/1?count=not-a-number')
|
|
600
|
+
|
|
601
|
+
// Default: returns 400 JSON error
|
|
602
|
+
expect(res.status).toBe(400)
|
|
603
|
+
const body = await res.json()
|
|
604
|
+
expect(body.error).toContain('Validation error')
|
|
605
|
+
})
|
|
606
|
+
|
|
607
|
+
// Tests for onPreStreamError and onMidStreamError callbacks
|
|
608
|
+
|
|
609
|
+
test('onPreStreamError handles validation errors with custom Response', async () => {
|
|
610
|
+
const onPreStreamError = vi.fn((procedure, c, error) => {
|
|
611
|
+
return c.json(
|
|
612
|
+
{ customError: true, procedureName: procedure.name, details: error.message },
|
|
613
|
+
422
|
|
614
|
+
)
|
|
615
|
+
})
|
|
616
|
+
const builder = new HonoStreamAppBuilder({ onPreStreamError })
|
|
617
|
+
const RPC = Procedures<{}, RPCConfig>()
|
|
618
|
+
|
|
619
|
+
RPC.CreateStream(
|
|
620
|
+
'ValidatedStream',
|
|
621
|
+
{
|
|
622
|
+
scope: 'validated',
|
|
623
|
+
version: 1,
|
|
624
|
+
schema: {
|
|
625
|
+
params: v.object({ count: v.number() }),
|
|
626
|
+
},
|
|
627
|
+
},
|
|
628
|
+
async function* (ctx, params) {
|
|
629
|
+
yield { count: params.count }
|
|
630
|
+
}
|
|
631
|
+
)
|
|
632
|
+
|
|
633
|
+
builder.register(RPC, () => ({}))
|
|
634
|
+
const app = builder.build()
|
|
635
|
+
|
|
636
|
+
const res = await app.request('/validated/validated-stream/1?count=not-a-number')
|
|
637
|
+
|
|
638
|
+
expect(res.status).toBe(422)
|
|
639
|
+
const body = await res.json()
|
|
640
|
+
expect(body.customError).toBe(true)
|
|
641
|
+
expect(body.procedureName).toBe('ValidatedStream')
|
|
642
|
+
expect(body.details).toContain('Validation error')
|
|
643
|
+
|
|
644
|
+
expect(onPreStreamError).toHaveBeenCalledTimes(1)
|
|
645
|
+
})
|
|
646
|
+
|
|
647
|
+
test('onPreStreamError handles context resolution errors', async () => {
|
|
648
|
+
const onPreStreamError = vi.fn((procedure, c, error) => {
|
|
649
|
+
return c.json({ contextError: error.message }, 401)
|
|
650
|
+
})
|
|
651
|
+
const builder = new HonoStreamAppBuilder({ onPreStreamError })
|
|
652
|
+
const RPC = Procedures<{ userId: string }, RPCConfig>()
|
|
653
|
+
|
|
654
|
+
RPC.CreateStream('SecureStream', { scope: 'secure', version: 1 }, async function* (ctx) {
|
|
655
|
+
yield { userId: ctx.userId }
|
|
656
|
+
})
|
|
657
|
+
|
|
658
|
+
builder.register(RPC, () => {
|
|
659
|
+
throw new Error('Authentication required')
|
|
660
|
+
})
|
|
661
|
+
const app = builder.build()
|
|
662
|
+
|
|
663
|
+
const res = await app.request('/secure/secure-stream/1')
|
|
664
|
+
|
|
665
|
+
expect(res.status).toBe(401)
|
|
666
|
+
const body = await res.json()
|
|
667
|
+
expect(body.contextError).toBe('Authentication required')
|
|
668
|
+
|
|
669
|
+
expect(onPreStreamError).toHaveBeenCalledTimes(1)
|
|
670
|
+
})
|
|
671
|
+
|
|
672
|
+
test('onMidStreamError returns custom value written to SSE stream', async () => {
|
|
673
|
+
const onMidStreamError = vi.fn((procedure, c, error) => {
|
|
674
|
+
return {
|
|
675
|
+
data: {
|
|
676
|
+
type: 'error',
|
|
677
|
+
code: 'STREAM_FAILED',
|
|
678
|
+
message: error.message,
|
|
679
|
+
retryable: false,
|
|
680
|
+
},
|
|
681
|
+
closeStream: true,
|
|
682
|
+
}
|
|
683
|
+
})
|
|
684
|
+
|
|
685
|
+
const builder = new HonoStreamAppBuilder({ onMidStreamError })
|
|
686
|
+
const RPC = Procedures<{}, RPCConfig>()
|
|
687
|
+
|
|
688
|
+
RPC.CreateStream('ErrorStream', { scope: 'error', version: 1 }, async function* () {
|
|
689
|
+
yield { type: 'data', value: 1 }
|
|
690
|
+
throw new Error('Something broke')
|
|
691
|
+
})
|
|
692
|
+
|
|
693
|
+
builder.register(RPC, () => ({}))
|
|
694
|
+
const app = builder.build()
|
|
695
|
+
|
|
696
|
+
const res = await app.request('/error/error-stream/1')
|
|
697
|
+
const text = await res.text()
|
|
698
|
+
|
|
699
|
+
// First yield should be present
|
|
700
|
+
expect(text).toContain('data: {"type":"data","value":1}')
|
|
701
|
+
// Error should use custom format from onMidStreamError
|
|
702
|
+
expect(text).toContain('data: {"type":"error","code":"STREAM_FAILED"')
|
|
703
|
+
expect(text).toContain('"retryable":false')
|
|
704
|
+
// Event should use procedure name (not 'error') since custom value provided
|
|
705
|
+
expect(text).toContain('event: ErrorStream')
|
|
706
|
+
|
|
707
|
+
expect(onMidStreamError).toHaveBeenCalledTimes(1)
|
|
708
|
+
})
|
|
709
|
+
|
|
710
|
+
test('onMidStreamError returns custom value written to text stream', async () => {
|
|
711
|
+
const onMidStreamError = vi.fn((procedure, c, error) => {
|
|
712
|
+
return {
|
|
713
|
+
data: { type: 'error', message: error.message },
|
|
714
|
+
}
|
|
715
|
+
})
|
|
716
|
+
|
|
717
|
+
const builder = new HonoStreamAppBuilder({
|
|
718
|
+
defaultStreamMode: 'text',
|
|
719
|
+
onMidStreamError,
|
|
720
|
+
})
|
|
721
|
+
const RPC = Procedures<{}, RPCConfig>()
|
|
722
|
+
|
|
723
|
+
RPC.CreateStream('ErrorStream', { scope: 'error', version: 1 }, async function* () {
|
|
724
|
+
yield { type: 'data', value: 'hello' }
|
|
725
|
+
throw new Error('Stream failed')
|
|
726
|
+
})
|
|
727
|
+
|
|
728
|
+
builder.register(RPC, () => ({}))
|
|
729
|
+
const app = builder.build()
|
|
730
|
+
|
|
731
|
+
const res = await app.request('/error/error-stream/1')
|
|
732
|
+
const text = await res.text()
|
|
733
|
+
const lines = text.trim().split('\n')
|
|
734
|
+
|
|
735
|
+
expect(JSON.parse(lines[0]!)).toEqual({ type: 'data', value: 'hello' })
|
|
736
|
+
// Error message may be wrapped by Procedures with "Error in streaming handler for X - " prefix
|
|
737
|
+
const errorLine = JSON.parse(lines[1]!)
|
|
738
|
+
expect(errorLine.type).toBe('error')
|
|
739
|
+
expect(errorLine.message).toContain('Stream failed')
|
|
740
|
+
|
|
741
|
+
expect(onMidStreamError).toHaveBeenCalledTimes(1)
|
|
742
|
+
})
|
|
743
|
+
|
|
744
|
+
test('onMidStreamError returning undefined falls back to default error format', async () => {
|
|
745
|
+
const onMidStreamError = vi.fn(() => undefined)
|
|
746
|
+
|
|
747
|
+
const builder = new HonoStreamAppBuilder({
|
|
748
|
+
defaultStreamMode: 'text',
|
|
749
|
+
onMidStreamError,
|
|
750
|
+
})
|
|
751
|
+
const RPC = Procedures<{}, RPCConfig>()
|
|
752
|
+
|
|
753
|
+
RPC.CreateStream('ErrorStream', { scope: 'error', version: 1 }, async function* () {
|
|
754
|
+
yield { value: 1 }
|
|
755
|
+
throw new Error('Fallback test')
|
|
756
|
+
})
|
|
757
|
+
|
|
758
|
+
builder.register(RPC, () => ({}))
|
|
759
|
+
const app = builder.build()
|
|
760
|
+
|
|
761
|
+
const res = await app.request('/error/error-stream/1')
|
|
762
|
+
const text = await res.text()
|
|
763
|
+
const lines = text.trim().split('\n')
|
|
764
|
+
|
|
765
|
+
expect(JSON.parse(lines[0]!)).toEqual({ value: 1 })
|
|
766
|
+
// Falls back to default { error: message } format
|
|
767
|
+
expect(JSON.parse(lines[1]!).error).toContain('Fallback test')
|
|
768
|
+
|
|
769
|
+
expect(onMidStreamError).toHaveBeenCalledTimes(1)
|
|
770
|
+
})
|
|
771
|
+
})
|
|
772
|
+
|
|
773
|
+
// --------------------------------------------------------------------------
|
|
774
|
+
// Context Resolution Tests
|
|
775
|
+
// --------------------------------------------------------------------------
|
|
776
|
+
describe('context resolution', () => {
|
|
777
|
+
test('context can be a static object', async () => {
|
|
778
|
+
const builder = new HonoStreamAppBuilder({ defaultStreamMode: 'text' })
|
|
779
|
+
const RPC = Procedures<{ requestId: string }, RPCConfig>()
|
|
780
|
+
|
|
781
|
+
RPC.CreateStream('GetId', { scope: 'get-id', version: 1 }, async function* (ctx) {
|
|
782
|
+
yield { id: ctx.requestId }
|
|
783
|
+
})
|
|
784
|
+
|
|
785
|
+
builder.register(RPC, { requestId: 'static-123' })
|
|
786
|
+
const app = builder.build()
|
|
787
|
+
|
|
788
|
+
const res = await app.request('/get-id/get-id/1')
|
|
789
|
+
const text = await res.text()
|
|
790
|
+
expect(JSON.parse(text.trim())).toEqual({ id: 'static-123' })
|
|
791
|
+
})
|
|
792
|
+
|
|
793
|
+
test('context can be sync function', async () => {
|
|
794
|
+
const builder = new HonoStreamAppBuilder({ defaultStreamMode: 'text' })
|
|
795
|
+
const RPC = Procedures<{ requestId: string }, RPCConfig>()
|
|
796
|
+
|
|
797
|
+
RPC.CreateStream('GetId', { scope: 'get-id', version: 1 }, async function* (ctx) {
|
|
798
|
+
yield { id: ctx.requestId }
|
|
799
|
+
})
|
|
800
|
+
|
|
801
|
+
builder.register(RPC, (c) => ({ requestId: c.req.header('x-request-id') || 'unknown' }))
|
|
802
|
+
const app = builder.build()
|
|
803
|
+
|
|
804
|
+
const res = await app.request('/get-id/get-id/1', {
|
|
805
|
+
headers: { 'X-Request-Id': 'req-456' },
|
|
806
|
+
})
|
|
807
|
+
const text = await res.text()
|
|
808
|
+
expect(JSON.parse(text.trim())).toEqual({ id: 'req-456' })
|
|
809
|
+
})
|
|
810
|
+
|
|
811
|
+
test('context can be async function', async () => {
|
|
812
|
+
const builder = new HonoStreamAppBuilder({ defaultStreamMode: 'text' })
|
|
813
|
+
const RPC = Procedures<{ requestId: string }, RPCConfig>()
|
|
814
|
+
|
|
815
|
+
RPC.CreateStream('GetId', { scope: 'get-id', version: 1 }, async function* (ctx) {
|
|
816
|
+
yield { id: ctx.requestId }
|
|
817
|
+
})
|
|
818
|
+
|
|
819
|
+
builder.register(RPC, async () => {
|
|
820
|
+
await new Promise((r) => setTimeout(r, 10))
|
|
821
|
+
return { requestId: 'async-789' }
|
|
822
|
+
})
|
|
823
|
+
const app = builder.build()
|
|
824
|
+
|
|
825
|
+
const res = await app.request('/get-id/get-id/1')
|
|
826
|
+
const text = await res.text()
|
|
827
|
+
expect(JSON.parse(text.trim())).toEqual({ id: 'async-789' })
|
|
828
|
+
})
|
|
829
|
+
})
|
|
830
|
+
|
|
831
|
+
// --------------------------------------------------------------------------
|
|
832
|
+
// Documentation Tests
|
|
833
|
+
// --------------------------------------------------------------------------
|
|
834
|
+
describe('documentation', () => {
|
|
835
|
+
test('generates complete route documentation', () => {
|
|
836
|
+
const paramsSchema = v.object({ id: v.string() })
|
|
837
|
+
const yieldSchema = v.object({ message: v.string() })
|
|
838
|
+
const returnSchema = v.object({ total: v.number() })
|
|
839
|
+
|
|
840
|
+
const builder = new HonoStreamAppBuilder()
|
|
841
|
+
const RPC = Procedures<{}, RPCConfig>()
|
|
842
|
+
|
|
843
|
+
RPC.CreateStream(
|
|
844
|
+
'StreamMessages',
|
|
845
|
+
{
|
|
846
|
+
scope: 'messages',
|
|
847
|
+
version: 1,
|
|
848
|
+
schema: { params: paramsSchema, yieldType: yieldSchema, returnType: returnSchema },
|
|
849
|
+
},
|
|
850
|
+
async function* () {
|
|
851
|
+
yield { message: 'test' }
|
|
852
|
+
}
|
|
853
|
+
)
|
|
854
|
+
|
|
855
|
+
builder.register(RPC, () => ({}))
|
|
856
|
+
builder.build()
|
|
857
|
+
|
|
858
|
+
const doc = builder.docs[0]!
|
|
859
|
+
expect(doc.path).toBe('/messages/stream-messages/1')
|
|
860
|
+
expect(doc.methods).toEqual(['get', 'post'])
|
|
861
|
+
expect(doc.streamMode).toBe('sse')
|
|
862
|
+
expect(doc.jsonSchema.params).toBeDefined()
|
|
863
|
+
expect(doc.jsonSchema.returnType).toBeDefined()
|
|
864
|
+
|
|
865
|
+
// yieldType is nested under SSE envelope's data property
|
|
866
|
+
const yt = doc.jsonSchema.yieldType as Record<string, any>
|
|
867
|
+
expect(yt.description).toBe('SSE message envelope. The data field contains the procedure yield value.')
|
|
868
|
+
expect(yt.required).toEqual(['data', 'event', 'id'])
|
|
869
|
+
expect(yt.properties.event).toEqual({ type: 'string' })
|
|
870
|
+
expect(yt.properties.id).toEqual({ type: 'string' })
|
|
871
|
+
expect(yt.properties.retry).toEqual({ type: 'number' })
|
|
872
|
+
// Developer's yieldType is nested under data
|
|
873
|
+
expect(yt.properties.data.properties.message).toBeDefined()
|
|
874
|
+
})
|
|
875
|
+
|
|
876
|
+
test('SSE mode generates SSE envelope even when no yieldType is defined', () => {
|
|
877
|
+
const builder = new HonoStreamAppBuilder()
|
|
878
|
+
const RPC = Procedures<{}, RPCConfig>()
|
|
879
|
+
|
|
880
|
+
RPC.CreateStream('NoYield', { scope: 'test', version: 1 }, async function* () {
|
|
881
|
+
yield {}
|
|
882
|
+
})
|
|
883
|
+
|
|
884
|
+
builder.register(RPC, () => ({}))
|
|
885
|
+
builder.build()
|
|
886
|
+
|
|
887
|
+
const yt = builder.docs[0]!.jsonSchema.yieldType as Record<string, any>
|
|
888
|
+
expect(yt).toBeDefined()
|
|
889
|
+
expect(yt.type).toBe('object')
|
|
890
|
+
expect(yt.description).toBe('SSE message envelope. The data field contains the procedure yield value.')
|
|
891
|
+
expect(yt.required).toEqual(['data', 'event', 'id'])
|
|
892
|
+
// data is empty schema when no yieldType defined
|
|
893
|
+
expect(yt.properties.data).toEqual({})
|
|
894
|
+
expect(yt.properties.event).toEqual({ type: 'string' })
|
|
895
|
+
expect(yt.properties.id).toEqual({ type: 'string' })
|
|
896
|
+
expect(yt.properties.retry).toEqual({ type: 'number' })
|
|
897
|
+
})
|
|
898
|
+
|
|
899
|
+
test('text mode passes yieldType through as-is', () => {
|
|
900
|
+
const yieldSchema = v.object({ chunk: v.string() })
|
|
901
|
+
const builder = new HonoStreamAppBuilder({ defaultStreamMode: 'text' })
|
|
902
|
+
const RPC = Procedures<{}, RPCConfig>()
|
|
903
|
+
|
|
904
|
+
RPC.CreateStream(
|
|
905
|
+
'TextStream',
|
|
906
|
+
{ scope: 'test', version: 1, schema: { yieldType: yieldSchema } },
|
|
907
|
+
async function* () {
|
|
908
|
+
yield { chunk: 'hi' }
|
|
909
|
+
}
|
|
910
|
+
)
|
|
911
|
+
|
|
912
|
+
builder.register(RPC, () => ({}))
|
|
913
|
+
builder.build()
|
|
914
|
+
|
|
915
|
+
const yt = builder.docs[0]!.jsonSchema.yieldType as Record<string, any>
|
|
916
|
+
expect(yt).toBeDefined()
|
|
917
|
+
// Text mode should NOT have SSE envelope fields injected
|
|
918
|
+
expect(yt.properties?.event).toBeUndefined()
|
|
919
|
+
expect(yt.properties?.id).toBeUndefined()
|
|
920
|
+
expect(yt.properties?.retry).toBeUndefined()
|
|
921
|
+
})
|
|
922
|
+
|
|
923
|
+
test('yieldType with id property does not collide with SSE id field', () => {
|
|
924
|
+
// User's yieldType has an `id` field (number) — this should be nested under
|
|
925
|
+
// the SSE envelope's `data` property, not collide with the SSE `id` (string)
|
|
926
|
+
const yieldSchema = v.object({
|
|
927
|
+
id: v.number(),
|
|
928
|
+
message: v.string(),
|
|
929
|
+
})
|
|
930
|
+
const builder = new HonoStreamAppBuilder()
|
|
931
|
+
const RPC = Procedures<{}, RPCConfig>()
|
|
932
|
+
|
|
933
|
+
RPC.CreateStream(
|
|
934
|
+
'Notifications',
|
|
935
|
+
{ scope: 'test', version: 1, schema: { yieldType: yieldSchema } },
|
|
936
|
+
async function* () {
|
|
937
|
+
yield { id: 42, message: 'hello' }
|
|
938
|
+
}
|
|
939
|
+
)
|
|
940
|
+
|
|
941
|
+
builder.register(RPC, () => ({}))
|
|
942
|
+
builder.build()
|
|
943
|
+
|
|
944
|
+
const yt = builder.docs[0]!.jsonSchema.yieldType as Record<string, any>
|
|
945
|
+
expect(yt.required).toEqual(['data', 'event', 'id'])
|
|
946
|
+
// SSE envelope id is a string
|
|
947
|
+
expect(yt.properties.id).toEqual({ type: 'string' })
|
|
948
|
+
// User's id (number) is safely nested under data
|
|
949
|
+
expect(yt.properties.data.properties.id.type).toBe('number')
|
|
950
|
+
expect(yt.properties.data.properties.message.type).toBe('string')
|
|
951
|
+
})
|
|
952
|
+
|
|
953
|
+
test('streamMode is recorded in docs', () => {
|
|
954
|
+
const builder = new HonoStreamAppBuilder({ defaultStreamMode: 'text' })
|
|
955
|
+
const RPC = Procedures<{}, RPCConfig>()
|
|
956
|
+
|
|
957
|
+
RPC.CreateStream('Test', { scope: 'test', version: 1 }, async function* () {
|
|
958
|
+
yield {}
|
|
959
|
+
})
|
|
960
|
+
|
|
961
|
+
builder.register(RPC, () => ({}))
|
|
962
|
+
builder.build()
|
|
963
|
+
|
|
964
|
+
expect(builder.docs[0]!.streamMode).toBe('text')
|
|
965
|
+
})
|
|
966
|
+
|
|
967
|
+
test('per-factory streamMode is recorded in docs', () => {
|
|
968
|
+
const builder = new HonoStreamAppBuilder({ defaultStreamMode: 'sse' })
|
|
969
|
+
const RPC = Procedures<{}, RPCConfig>()
|
|
970
|
+
|
|
971
|
+
RPC.CreateStream('Test', { scope: 'test', version: 1 }, async function* () {
|
|
972
|
+
yield {}
|
|
973
|
+
})
|
|
974
|
+
|
|
975
|
+
builder.register(RPC, () => ({}), { streamMode: 'text' })
|
|
976
|
+
builder.build()
|
|
977
|
+
|
|
978
|
+
expect(builder.docs[0]!.streamMode).toBe('text')
|
|
979
|
+
})
|
|
980
|
+
|
|
981
|
+
test("doc.kind is 'stream'", () => {
|
|
982
|
+
const builder = new HonoStreamAppBuilder()
|
|
983
|
+
const RPC = Procedures<{}, RPCConfig>()
|
|
984
|
+
|
|
985
|
+
RPC.CreateStream('StreamEvents', { scope: 'events', version: 1 }, async function* () {
|
|
986
|
+
yield {}
|
|
987
|
+
})
|
|
988
|
+
|
|
989
|
+
builder.register(RPC, () => ({}))
|
|
990
|
+
builder.build()
|
|
991
|
+
|
|
992
|
+
const doc = builder.docs[0]!
|
|
993
|
+
expect(doc.kind).toBe('stream')
|
|
994
|
+
})
|
|
995
|
+
})
|
|
996
|
+
|
|
997
|
+
// --------------------------------------------------------------------------
|
|
998
|
+
// Filter Tests (Only Streaming Procedures)
|
|
999
|
+
// --------------------------------------------------------------------------
|
|
1000
|
+
describe('procedure filtering', () => {
|
|
1001
|
+
test('only registers streaming procedures', async () => {
|
|
1002
|
+
const builder = new HonoStreamAppBuilder()
|
|
1003
|
+
const RPC = Procedures<{}, RPCConfig>()
|
|
1004
|
+
|
|
1005
|
+
// Regular procedure (should be ignored)
|
|
1006
|
+
RPC.Create('NonStream', { scope: 'non-stream', version: 1 }, async () => ({
|
|
1007
|
+
ok: true,
|
|
1008
|
+
}))
|
|
1009
|
+
|
|
1010
|
+
// Streaming procedure (should be registered)
|
|
1011
|
+
RPC.CreateStream('Stream', { scope: 'stream', version: 1 }, async function* () {
|
|
1012
|
+
yield { ok: true }
|
|
1013
|
+
})
|
|
1014
|
+
|
|
1015
|
+
builder.register(RPC, () => ({}))
|
|
1016
|
+
const app = builder.build()
|
|
1017
|
+
|
|
1018
|
+
// Only streaming procedure should be in docs
|
|
1019
|
+
expect(builder.docs).toHaveLength(1)
|
|
1020
|
+
expect(builder.docs[0]!.name).toBe('Stream')
|
|
1021
|
+
|
|
1022
|
+
// Non-streaming route should 404
|
|
1023
|
+
const nonStreamRes = await app.request('/non-stream/non-stream/1', { method: 'POST' })
|
|
1024
|
+
expect(nonStreamRes.status).toBe(404)
|
|
1025
|
+
|
|
1026
|
+
// Streaming route should work
|
|
1027
|
+
const streamRes = await app.request('/stream/stream/1')
|
|
1028
|
+
expect(streamRes.status).toBe(200)
|
|
1029
|
+
})
|
|
1030
|
+
})
|
|
1031
|
+
|
|
1032
|
+
// --------------------------------------------------------------------------
|
|
1033
|
+
// extendProcedureDoc Tests
|
|
1034
|
+
// --------------------------------------------------------------------------
|
|
1035
|
+
describe('extendProcedureDoc', () => {
|
|
1036
|
+
test('adds custom properties to generated documentation', () => {
|
|
1037
|
+
const builder = new HonoStreamAppBuilder()
|
|
1038
|
+
const RPC = Procedures<{}, RPCConfig>()
|
|
1039
|
+
|
|
1040
|
+
RPC.CreateStream('StreamEvents', { scope: 'events', version: 1 }, async function* () {
|
|
1041
|
+
yield {}
|
|
1042
|
+
})
|
|
1043
|
+
|
|
1044
|
+
builder.register(RPC, () => ({}), {
|
|
1045
|
+
extendProcedureDoc: ({ procedure }) => ({
|
|
1046
|
+
summary: `Stream events endpoint`,
|
|
1047
|
+
tags: ['events'],
|
|
1048
|
+
operationId: procedure.name,
|
|
1049
|
+
}),
|
|
1050
|
+
})
|
|
1051
|
+
builder.build()
|
|
1052
|
+
|
|
1053
|
+
const doc = builder.docs[0]!
|
|
1054
|
+
expect(doc).toHaveProperty('summary', 'Stream events endpoint')
|
|
1055
|
+
expect(doc).toHaveProperty('tags', ['events'])
|
|
1056
|
+
expect(doc).toHaveProperty('operationId', 'StreamEvents')
|
|
1057
|
+
})
|
|
1058
|
+
|
|
1059
|
+
test('base properties take precedence over extended properties', () => {
|
|
1060
|
+
const builder = new HonoStreamAppBuilder()
|
|
1061
|
+
const RPC = Procedures<{}, RPCConfig>()
|
|
1062
|
+
|
|
1063
|
+
RPC.CreateStream('Test', { scope: 'test', version: 1 }, async function* () {
|
|
1064
|
+
yield {}
|
|
1065
|
+
})
|
|
1066
|
+
|
|
1067
|
+
builder.register(RPC, () => ({}), {
|
|
1068
|
+
extendProcedureDoc: () => ({
|
|
1069
|
+
name: 'OverriddenName',
|
|
1070
|
+
path: '/overridden/path',
|
|
1071
|
+
methods: ['put'],
|
|
1072
|
+
customField: 'custom-value',
|
|
1073
|
+
}),
|
|
1074
|
+
})
|
|
1075
|
+
builder.build()
|
|
1076
|
+
|
|
1077
|
+
const doc = builder.docs[0]!
|
|
1078
|
+
// Base properties should NOT be overridden
|
|
1079
|
+
expect(doc.name).toBe('Test')
|
|
1080
|
+
expect(doc.path).toBe('/test/test/1')
|
|
1081
|
+
expect(doc.methods).toEqual(['get', 'post'])
|
|
1082
|
+
// Custom field should be present
|
|
1083
|
+
expect(doc).toHaveProperty('customField', 'custom-value')
|
|
1084
|
+
})
|
|
1085
|
+
})
|
|
1086
|
+
|
|
1087
|
+
// --------------------------------------------------------------------------
|
|
1088
|
+
// Multiple Factory Tests
|
|
1089
|
+
// --------------------------------------------------------------------------
|
|
1090
|
+
describe('multiple factories', () => {
|
|
1091
|
+
test('supports registering multiple factories', async () => {
|
|
1092
|
+
const builder = new HonoStreamAppBuilder({ defaultStreamMode: 'text' })
|
|
1093
|
+
|
|
1094
|
+
const PublicRPC = Procedures<{ public: true }, RPCConfig>()
|
|
1095
|
+
const PrivateRPC = Procedures<{ private: true }, RPCConfig>()
|
|
1096
|
+
|
|
1097
|
+
PublicRPC.CreateStream(
|
|
1098
|
+
'PublicStream',
|
|
1099
|
+
{ scope: 'public', version: 1 },
|
|
1100
|
+
async function* (ctx) {
|
|
1101
|
+
yield { isPublic: ctx.public }
|
|
1102
|
+
}
|
|
1103
|
+
)
|
|
1104
|
+
|
|
1105
|
+
PrivateRPC.CreateStream(
|
|
1106
|
+
'PrivateStream',
|
|
1107
|
+
{ scope: 'private', version: 1 },
|
|
1108
|
+
async function* (ctx) {
|
|
1109
|
+
yield { isPrivate: ctx.private }
|
|
1110
|
+
}
|
|
1111
|
+
)
|
|
1112
|
+
|
|
1113
|
+
builder
|
|
1114
|
+
.register(PublicRPC, () => ({ public: true as const }))
|
|
1115
|
+
.register(PrivateRPC, () => ({ private: true as const }))
|
|
1116
|
+
|
|
1117
|
+
const app = builder.build()
|
|
1118
|
+
|
|
1119
|
+
const publicRes = await app.request('/public/public-stream/1')
|
|
1120
|
+
const publicText = await publicRes.text()
|
|
1121
|
+
expect(JSON.parse(publicText.trim())).toEqual({ isPublic: true })
|
|
1122
|
+
|
|
1123
|
+
const privateRes = await app.request('/private/private-stream/1')
|
|
1124
|
+
const privateText = await privateRes.text()
|
|
1125
|
+
expect(JSON.parse(privateText.trim())).toEqual({ isPrivate: true })
|
|
1126
|
+
})
|
|
1127
|
+
|
|
1128
|
+
test('different factories can have different stream modes', async () => {
|
|
1129
|
+
const builder = new HonoStreamAppBuilder()
|
|
1130
|
+
|
|
1131
|
+
const SSERPC = Procedures<{}, RPCConfig>()
|
|
1132
|
+
const TextRPC = Procedures<{}, RPCConfig>()
|
|
1133
|
+
|
|
1134
|
+
SSERPC.CreateStream('SSEStream', { scope: 'sse', version: 1 }, async function* () {
|
|
1135
|
+
yield { mode: 'sse' }
|
|
1136
|
+
})
|
|
1137
|
+
|
|
1138
|
+
TextRPC.CreateStream('TextStream', { scope: 'text', version: 1 }, async function* () {
|
|
1139
|
+
yield { mode: 'text' }
|
|
1140
|
+
})
|
|
1141
|
+
|
|
1142
|
+
builder
|
|
1143
|
+
.register(SSERPC, () => ({}), { streamMode: 'sse' })
|
|
1144
|
+
.register(TextRPC, () => ({}), { streamMode: 'text' })
|
|
1145
|
+
|
|
1146
|
+
const app = builder.build()
|
|
1147
|
+
|
|
1148
|
+
const sseRes = await app.request('/sse/sse-stream/1')
|
|
1149
|
+
expect(sseRes.headers.get('content-type')).toContain('text/event-stream')
|
|
1150
|
+
|
|
1151
|
+
const textRes = await app.request('/text/text-stream/1')
|
|
1152
|
+
expect(textRes.headers.get('content-type')).toContain('text/plain')
|
|
1153
|
+
})
|
|
1154
|
+
})
|
|
1155
|
+
|
|
1156
|
+
// --------------------------------------------------------------------------
|
|
1157
|
+
// Path Generation Tests
|
|
1158
|
+
// --------------------------------------------------------------------------
|
|
1159
|
+
describe('makeStreamHttpRoutePath', () => {
|
|
1160
|
+
let builder: HonoStreamAppBuilder
|
|
1161
|
+
|
|
1162
|
+
beforeEach(() => {
|
|
1163
|
+
builder = new HonoStreamAppBuilder()
|
|
1164
|
+
})
|
|
1165
|
+
|
|
1166
|
+
test("simple scope: 'events' + 'StreamUpdates' → /events/stream-updates/1", () => {
|
|
1167
|
+
const path = builder.makeStreamHttpRoutePath('StreamUpdates', { scope: 'events', version: 1 })
|
|
1168
|
+
expect(path).toBe('/events/stream-updates/1')
|
|
1169
|
+
})
|
|
1170
|
+
|
|
1171
|
+
test("array scope: ['events', 'live'] + 'Watch' → /events/live/watch/1", () => {
|
|
1172
|
+
const path = builder.makeStreamHttpRoutePath('Watch', {
|
|
1173
|
+
scope: ['events', 'live'],
|
|
1174
|
+
version: 1,
|
|
1175
|
+
})
|
|
1176
|
+
expect(path).toBe('/events/live/watch/1')
|
|
1177
|
+
})
|
|
1178
|
+
|
|
1179
|
+
test('version number included in path', () => {
|
|
1180
|
+
const pathV1 = builder.makeStreamHttpRoutePath('Test', { scope: 'test', version: 1 })
|
|
1181
|
+
const pathV2 = builder.makeStreamHttpRoutePath('Test', { scope: 'test', version: 2 })
|
|
1182
|
+
|
|
1183
|
+
expect(pathV1).toBe('/test/test/1')
|
|
1184
|
+
expect(pathV2).toBe('/test/test/2')
|
|
1185
|
+
})
|
|
1186
|
+
})
|
|
1187
|
+
|
|
1188
|
+
// --------------------------------------------------------------------------
|
|
1189
|
+
// isPrevalidated Tests
|
|
1190
|
+
// --------------------------------------------------------------------------
|
|
1191
|
+
describe('isPrevalidated context property', () => {
|
|
1192
|
+
test('skips procedure-level validation since HonoStreamAppBuilder already validated', async () => {
|
|
1193
|
+
let handlerCalled = false
|
|
1194
|
+
const builder = new HonoStreamAppBuilder({ defaultStreamMode: 'text' })
|
|
1195
|
+
const RPC = Procedures<{}, RPCConfig>()
|
|
1196
|
+
|
|
1197
|
+
RPC.CreateStream(
|
|
1198
|
+
'CheckPrevalidated',
|
|
1199
|
+
{
|
|
1200
|
+
scope: 'check',
|
|
1201
|
+
version: 1,
|
|
1202
|
+
schema: {
|
|
1203
|
+
params: v.object({ name: v.string() }),
|
|
1204
|
+
},
|
|
1205
|
+
},
|
|
1206
|
+
async function* () {
|
|
1207
|
+
handlerCalled = true
|
|
1208
|
+
yield { ok: true }
|
|
1209
|
+
}
|
|
1210
|
+
)
|
|
1211
|
+
|
|
1212
|
+
builder.register(RPC, () => ({}))
|
|
1213
|
+
const app = builder.build()
|
|
1214
|
+
|
|
1215
|
+
// Valid params pass HonoStreamAppBuilder validation, handler runs without double validation
|
|
1216
|
+
const res = await app.request('/check/check-prevalidated/1?name=test')
|
|
1217
|
+
await res.text()
|
|
1218
|
+
|
|
1219
|
+
expect(res.status).toBe(200)
|
|
1220
|
+
expect(handlerCalled).toBe(true)
|
|
1221
|
+
})
|
|
1222
|
+
|
|
1223
|
+
test('valid params work correctly with pre-validation flow', async () => {
|
|
1224
|
+
const builder = new HonoStreamAppBuilder({ defaultStreamMode: 'text' })
|
|
1225
|
+
const RPC = Procedures<{}, RPCConfig>()
|
|
1226
|
+
|
|
1227
|
+
RPC.CreateStream(
|
|
1228
|
+
'ValidParams',
|
|
1229
|
+
{
|
|
1230
|
+
scope: 'valid',
|
|
1231
|
+
version: 1,
|
|
1232
|
+
schema: {
|
|
1233
|
+
params: v.object({ count: v.number() }),
|
|
1234
|
+
},
|
|
1235
|
+
},
|
|
1236
|
+
async function* (ctx, params) {
|
|
1237
|
+
const count = params.count ?? 0
|
|
1238
|
+
for (let i = 0; i < count; i++) {
|
|
1239
|
+
yield { index: i }
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
)
|
|
1243
|
+
|
|
1244
|
+
builder.register(RPC, () => ({}))
|
|
1245
|
+
const app = builder.build()
|
|
1246
|
+
|
|
1247
|
+
// With valid params, both HonoStreamAppBuilder validation and procedure validation should work
|
|
1248
|
+
const res = await app.request('/valid/valid-params/1?count=2')
|
|
1249
|
+
expect(res.status).toBe(200)
|
|
1250
|
+
|
|
1251
|
+
const text = await res.text()
|
|
1252
|
+
const lines = text.trim().split('\n')
|
|
1253
|
+
expect(lines).toHaveLength(2)
|
|
1254
|
+
expect(JSON.parse(lines[0]!)).toEqual({ index: 0 })
|
|
1255
|
+
expect(JSON.parse(lines[1]!)).toEqual({ index: 1 })
|
|
1256
|
+
})
|
|
1257
|
+
|
|
1258
|
+
test('invalid params are caught by HonoStreamAppBuilder before handler runs', async () => {
|
|
1259
|
+
let handlerCalled = false
|
|
1260
|
+
const builder = new HonoStreamAppBuilder({ defaultStreamMode: 'text' })
|
|
1261
|
+
const RPC = Procedures<{}, RPCConfig>()
|
|
1262
|
+
|
|
1263
|
+
RPC.CreateStream(
|
|
1264
|
+
'InvalidParams',
|
|
1265
|
+
{
|
|
1266
|
+
scope: 'invalid',
|
|
1267
|
+
version: 1,
|
|
1268
|
+
schema: {
|
|
1269
|
+
params: v.object({ count: v.number() }),
|
|
1270
|
+
},
|
|
1271
|
+
},
|
|
1272
|
+
async function* () {
|
|
1273
|
+
handlerCalled = true
|
|
1274
|
+
yield { ok: true }
|
|
1275
|
+
}
|
|
1276
|
+
)
|
|
1277
|
+
|
|
1278
|
+
builder.register(RPC, () => ({}))
|
|
1279
|
+
const app = builder.build()
|
|
1280
|
+
|
|
1281
|
+
// With invalid params, HonoStreamAppBuilder catches the error before streaming starts
|
|
1282
|
+
const res = await app.request('/invalid/invalid-params/1?count=not-a-number')
|
|
1283
|
+
expect(res.status).toBe(400)
|
|
1284
|
+
|
|
1285
|
+
const body = await res.json()
|
|
1286
|
+
expect(body.error).toContain('Validation error')
|
|
1287
|
+
|
|
1288
|
+
// Handler should never be called since validation fails before streaming
|
|
1289
|
+
expect(handlerCalled).toBe(false)
|
|
1290
|
+
})
|
|
1291
|
+
})
|
|
1292
|
+
|
|
1293
|
+
// --------------------------------------------------------------------------
|
|
1294
|
+
// SSE Yield Shape Tests
|
|
1295
|
+
// --------------------------------------------------------------------------
|
|
1296
|
+
describe('SSE yield shape', () => {
|
|
1297
|
+
test('custom event names via sse() helper', async () => {
|
|
1298
|
+
const builder = new HonoStreamAppBuilder()
|
|
1299
|
+
const RPC = Procedures<{}, RPCConfig>()
|
|
1300
|
+
|
|
1301
|
+
RPC.CreateStream('Events', { scope: 'events', version: 1 }, async function* () {
|
|
1302
|
+
yield sse({ type: 'user_joined' }, { event: 'join' })
|
|
1303
|
+
yield sse({ type: 'message' }, { event: 'chat' })
|
|
1304
|
+
})
|
|
1305
|
+
|
|
1306
|
+
builder.register(RPC, () => ({}))
|
|
1307
|
+
const app = builder.build()
|
|
1308
|
+
|
|
1309
|
+
const res = await app.request('/events/events/1')
|
|
1310
|
+
const text = await res.text()
|
|
1311
|
+
|
|
1312
|
+
expect(text).toContain('event: join')
|
|
1313
|
+
expect(text).toContain('event: chat')
|
|
1314
|
+
expect(text).not.toContain('event: Events')
|
|
1315
|
+
})
|
|
1316
|
+
|
|
1317
|
+
test('custom id via sse() helper', async () => {
|
|
1318
|
+
const builder = new HonoStreamAppBuilder()
|
|
1319
|
+
const RPC = Procedures<{}, RPCConfig>()
|
|
1320
|
+
|
|
1321
|
+
RPC.CreateStream('Events', { scope: 'events', version: 1 }, async function* () {
|
|
1322
|
+
yield sse({ msg: 'first' }, { id: 'msg-001' })
|
|
1323
|
+
yield sse({ msg: 'second' }, { id: 'msg-002' })
|
|
1324
|
+
})
|
|
1325
|
+
|
|
1326
|
+
builder.register(RPC, () => ({}))
|
|
1327
|
+
const app = builder.build()
|
|
1328
|
+
|
|
1329
|
+
const res = await app.request('/events/events/1')
|
|
1330
|
+
const text = await res.text()
|
|
1331
|
+
|
|
1332
|
+
expect(text).toContain('id: msg-001')
|
|
1333
|
+
expect(text).toContain('id: msg-002')
|
|
1334
|
+
})
|
|
1335
|
+
|
|
1336
|
+
test('string data pass-through without double-stringify', async () => {
|
|
1337
|
+
const builder = new HonoStreamAppBuilder()
|
|
1338
|
+
const RPC = Procedures<{}, RPCConfig>()
|
|
1339
|
+
|
|
1340
|
+
RPC.CreateStream('Events', { scope: 'events', version: 1 }, async function* () {
|
|
1341
|
+
yield 'already a string'
|
|
1342
|
+
yield { needs: 'stringify' }
|
|
1343
|
+
})
|
|
1344
|
+
|
|
1345
|
+
builder.register(RPC, () => ({}))
|
|
1346
|
+
const app = builder.build()
|
|
1347
|
+
|
|
1348
|
+
const res = await app.request('/events/events/1')
|
|
1349
|
+
const text = await res.text()
|
|
1350
|
+
|
|
1351
|
+
// String data should be passed through as-is (not JSON-stringified again)
|
|
1352
|
+
expect(text).toContain('data: already a string')
|
|
1353
|
+
// Object data should be JSON-stringified
|
|
1354
|
+
expect(text).toContain('data: {"needs":"stringify"}')
|
|
1355
|
+
})
|
|
1356
|
+
|
|
1357
|
+
test('default event falls back to procedure name when omitted', async () => {
|
|
1358
|
+
const builder = new HonoStreamAppBuilder()
|
|
1359
|
+
const RPC = Procedures<{}, RPCConfig>()
|
|
1360
|
+
|
|
1361
|
+
RPC.CreateStream('MyProcedure', { scope: 'test', version: 1 }, async function* () {
|
|
1362
|
+
yield { value: 1 }
|
|
1363
|
+
yield sse({ value: 2 }, { event: 'custom' })
|
|
1364
|
+
yield { value: 3 }
|
|
1365
|
+
})
|
|
1366
|
+
|
|
1367
|
+
builder.register(RPC, () => ({}))
|
|
1368
|
+
const app = builder.build()
|
|
1369
|
+
|
|
1370
|
+
const res = await app.request('/test/my-procedure/1')
|
|
1371
|
+
const text = await res.text()
|
|
1372
|
+
|
|
1373
|
+
// Split into individual SSE messages
|
|
1374
|
+
const messages = text.split('\n\n').filter(Boolean)
|
|
1375
|
+
|
|
1376
|
+
// First and third should use procedure name as event
|
|
1377
|
+
expect(messages[0]).toContain('event: MyProcedure')
|
|
1378
|
+
// Second should use custom event
|
|
1379
|
+
expect(messages[1]).toContain('event: custom')
|
|
1380
|
+
// Third should fall back to procedure name
|
|
1381
|
+
expect(messages[2]).toContain('event: MyProcedure')
|
|
1382
|
+
})
|
|
1383
|
+
})
|
|
1384
|
+
|
|
1385
|
+
// --------------------------------------------------------------------------
|
|
1386
|
+
// sse() Helper Tests
|
|
1387
|
+
// --------------------------------------------------------------------------
|
|
1388
|
+
describe('sse() helper', () => {
|
|
1389
|
+
test('tagged yields with custom event/id/retry', async () => {
|
|
1390
|
+
const builder = new HonoStreamAppBuilder()
|
|
1391
|
+
const RPC = Procedures<{}, RPCConfig>()
|
|
1392
|
+
|
|
1393
|
+
RPC.CreateStream('Tagged', { scope: 'tagged', version: 1 }, async function* () {
|
|
1394
|
+
yield sse({ count: 1 }, { event: 'tick', id: 'evt-1', retry: 5000 })
|
|
1395
|
+
})
|
|
1396
|
+
|
|
1397
|
+
builder.register(RPC, () => ({}))
|
|
1398
|
+
const app = builder.build()
|
|
1399
|
+
|
|
1400
|
+
const res = await app.request('/tagged/tagged/1')
|
|
1401
|
+
const text = await res.text()
|
|
1402
|
+
|
|
1403
|
+
expect(text).toContain('event: tick')
|
|
1404
|
+
expect(text).toContain('id: evt-1')
|
|
1405
|
+
expect(text).toContain('retry: 5000')
|
|
1406
|
+
expect(text).toContain('data: {"count":1}')
|
|
1407
|
+
})
|
|
1408
|
+
|
|
1409
|
+
test('plain domain objects use procedure name and auto-incremented id', async () => {
|
|
1410
|
+
const builder = new HonoStreamAppBuilder()
|
|
1411
|
+
const RPC = Procedures<{}, RPCConfig>()
|
|
1412
|
+
|
|
1413
|
+
RPC.CreateStream('Plain', { scope: 'plain', version: 1 }, async function* () {
|
|
1414
|
+
yield { a: 1 }
|
|
1415
|
+
yield { a: 2 }
|
|
1416
|
+
})
|
|
1417
|
+
|
|
1418
|
+
builder.register(RPC, () => ({}))
|
|
1419
|
+
const app = builder.build()
|
|
1420
|
+
|
|
1421
|
+
const res = await app.request('/plain/plain/1')
|
|
1422
|
+
const text = await res.text()
|
|
1423
|
+
|
|
1424
|
+
const messages = text.split('\n\n').filter(Boolean)
|
|
1425
|
+
expect(messages[0]).toContain('event: Plain')
|
|
1426
|
+
expect(messages[0]).toContain('id: 0')
|
|
1427
|
+
expect(messages[0]).toContain('data: {"a":1}')
|
|
1428
|
+
expect(messages[1]).toContain('event: Plain')
|
|
1429
|
+
expect(messages[1]).toContain('id: 1')
|
|
1430
|
+
expect(messages[1]).toContain('data: {"a":2}')
|
|
1431
|
+
})
|
|
1432
|
+
|
|
1433
|
+
test('sse() metadata is invisible in text mode', async () => {
|
|
1434
|
+
const builder = new HonoStreamAppBuilder({ defaultStreamMode: 'text' })
|
|
1435
|
+
const RPC = Procedures<{}, RPCConfig>()
|
|
1436
|
+
|
|
1437
|
+
RPC.CreateStream('TextTagged', { scope: 'text', version: 1 }, async function* () {
|
|
1438
|
+
yield sse({ count: 1 }, { event: 'tick' })
|
|
1439
|
+
yield { count: 2 }
|
|
1440
|
+
})
|
|
1441
|
+
|
|
1442
|
+
builder.register(RPC, () => ({}))
|
|
1443
|
+
const app = builder.build()
|
|
1444
|
+
|
|
1445
|
+
const res = await app.request('/text/text-tagged/1')
|
|
1446
|
+
const text = await res.text()
|
|
1447
|
+
const lines = text.trim().split('\n')
|
|
1448
|
+
|
|
1449
|
+
// Text mode just JSON-stringifies — sse() metadata is not visible
|
|
1450
|
+
expect(JSON.parse(lines[0]!)).toEqual({ count: 1 })
|
|
1451
|
+
expect(JSON.parse(lines[1]!)).toEqual({ count: 2 })
|
|
1452
|
+
})
|
|
1453
|
+
|
|
1454
|
+
test('sse() with partial options', async () => {
|
|
1455
|
+
const builder = new HonoStreamAppBuilder()
|
|
1456
|
+
const RPC = Procedures<{}, RPCConfig>()
|
|
1457
|
+
|
|
1458
|
+
RPC.CreateStream('Partial', { scope: 'partial', version: 1 }, async function* () {
|
|
1459
|
+
yield sse({ v: 1 }, { event: 'custom' })
|
|
1460
|
+
yield sse({ v: 2 }, { id: 'my-id' })
|
|
1461
|
+
yield sse({ v: 3 })
|
|
1462
|
+
})
|
|
1463
|
+
|
|
1464
|
+
builder.register(RPC, () => ({}))
|
|
1465
|
+
const app = builder.build()
|
|
1466
|
+
|
|
1467
|
+
const res = await app.request('/partial/partial/1')
|
|
1468
|
+
const text = await res.text()
|
|
1469
|
+
const messages = text.split('\n\n').filter(Boolean)
|
|
1470
|
+
|
|
1471
|
+
// First: custom event, auto id
|
|
1472
|
+
expect(messages[0]).toContain('event: custom')
|
|
1473
|
+
expect(messages[0]).toContain('id: 0')
|
|
1474
|
+
|
|
1475
|
+
// Second: default event, custom id
|
|
1476
|
+
expect(messages[1]).toContain('event: Partial')
|
|
1477
|
+
expect(messages[1]).toContain('id: my-id')
|
|
1478
|
+
|
|
1479
|
+
// Third: sse() with no options — same as plain object (defaults)
|
|
1480
|
+
expect(messages[2]).toContain('event: Partial')
|
|
1481
|
+
expect(messages[2]).toContain('id: 2')
|
|
1482
|
+
})
|
|
1483
|
+
})
|
|
1484
|
+
|
|
1485
|
+
// --------------------------------------------------------------------------
|
|
1486
|
+
// streamMode in Lifecycle Hooks
|
|
1487
|
+
// --------------------------------------------------------------------------
|
|
1488
|
+
describe('streamMode in lifecycle hooks', () => {
|
|
1489
|
+
test('onStreamStart receives sse streamMode', async () => {
|
|
1490
|
+
const onStreamStart = vi.fn()
|
|
1491
|
+
const builder = new HonoStreamAppBuilder({ onStreamStart })
|
|
1492
|
+
const RPC = Procedures<{}, RPCConfig>()
|
|
1493
|
+
|
|
1494
|
+
RPC.CreateStream('Test', { scope: 'test', version: 1 }, async function* () {
|
|
1495
|
+
yield { ok: true }
|
|
1496
|
+
})
|
|
1497
|
+
|
|
1498
|
+
builder.register(RPC, () => ({}))
|
|
1499
|
+
const app = builder.build()
|
|
1500
|
+
|
|
1501
|
+
await app.request('/test/test/1')
|
|
1502
|
+
|
|
1503
|
+
expect(onStreamStart).toHaveBeenCalledTimes(1)
|
|
1504
|
+
const [, , streamMode] = onStreamStart.mock.calls[0]!
|
|
1505
|
+
expect(streamMode).toBe('sse')
|
|
1506
|
+
})
|
|
1507
|
+
|
|
1508
|
+
test('onStreamEnd receives text streamMode', async () => {
|
|
1509
|
+
const onStreamEnd = vi.fn()
|
|
1510
|
+
const builder = new HonoStreamAppBuilder({ defaultStreamMode: 'text', onStreamEnd })
|
|
1511
|
+
const RPC = Procedures<{}, RPCConfig>()
|
|
1512
|
+
|
|
1513
|
+
RPC.CreateStream('Test', { scope: 'test', version: 1 }, async function* () {
|
|
1514
|
+
yield { ok: true }
|
|
1515
|
+
})
|
|
1516
|
+
|
|
1517
|
+
builder.register(RPC, () => ({}))
|
|
1518
|
+
const app = builder.build()
|
|
1519
|
+
|
|
1520
|
+
const res = await app.request('/test/test/1')
|
|
1521
|
+
await res.text()
|
|
1522
|
+
|
|
1523
|
+
expect(onStreamEnd).toHaveBeenCalledTimes(1)
|
|
1524
|
+
const [, , streamMode] = onStreamEnd.mock.calls[0]!
|
|
1525
|
+
expect(streamMode).toBe('text')
|
|
1526
|
+
})
|
|
1527
|
+
|
|
1528
|
+
test('onStreamStart and onStreamEnd receive matching streamMode', async () => {
|
|
1529
|
+
const modes: { start?: StreamMode; end?: StreamMode } = {}
|
|
1530
|
+
const builder = new HonoStreamAppBuilder({
|
|
1531
|
+
defaultStreamMode: 'text',
|
|
1532
|
+
onStreamStart: (_proc, _c, mode) => { modes.start = mode },
|
|
1533
|
+
onStreamEnd: (_proc, _c, mode) => { modes.end = mode },
|
|
1534
|
+
})
|
|
1535
|
+
const RPC = Procedures<{}, RPCConfig>()
|
|
1536
|
+
|
|
1537
|
+
RPC.CreateStream('Test', { scope: 'test', version: 1 }, async function* () {
|
|
1538
|
+
yield { ok: true }
|
|
1539
|
+
})
|
|
1540
|
+
|
|
1541
|
+
builder.register(RPC, () => ({}))
|
|
1542
|
+
const app = builder.build()
|
|
1543
|
+
|
|
1544
|
+
const res = await app.request('/test/test/1')
|
|
1545
|
+
await res.text()
|
|
1546
|
+
|
|
1547
|
+
expect(modes.start).toBe('text')
|
|
1548
|
+
expect(modes.end).toBe('text')
|
|
1549
|
+
})
|
|
1550
|
+
})
|
|
1551
|
+
|
|
1552
|
+
// --------------------------------------------------------------------------
|
|
1553
|
+
// sse() in onMidStreamError
|
|
1554
|
+
// --------------------------------------------------------------------------
|
|
1555
|
+
describe('sse() in onMidStreamError', () => {
|
|
1556
|
+
test('sse() wraps error data with custom event and id', async () => {
|
|
1557
|
+
const builder = new HonoStreamAppBuilder({
|
|
1558
|
+
onMidStreamError: (procedure, c, error) => {
|
|
1559
|
+
return {
|
|
1560
|
+
data: sse(
|
|
1561
|
+
{ type: 'error', message: error.message },
|
|
1562
|
+
{ event: 'custom-error', id: 'err-1' }
|
|
1563
|
+
),
|
|
1564
|
+
}
|
|
1565
|
+
},
|
|
1566
|
+
})
|
|
1567
|
+
const RPC = Procedures<{}, RPCConfig>()
|
|
1568
|
+
|
|
1569
|
+
RPC.CreateStream('ErrorStream', { scope: 'error', version: 1 }, async function* () {
|
|
1570
|
+
yield { type: 'data', value: 1 }
|
|
1571
|
+
throw new Error('Something broke')
|
|
1572
|
+
})
|
|
1573
|
+
|
|
1574
|
+
builder.register(RPC, () => ({}))
|
|
1575
|
+
const app = builder.build()
|
|
1576
|
+
|
|
1577
|
+
const res = await app.request('/error/error-stream/1')
|
|
1578
|
+
const text = await res.text()
|
|
1579
|
+
|
|
1580
|
+
// Normal yield
|
|
1581
|
+
expect(text).toContain('data: {"type":"data","value":1}')
|
|
1582
|
+
// Error yield with sse() metadata
|
|
1583
|
+
expect(text).toContain('event: custom-error')
|
|
1584
|
+
expect(text).toContain('id: err-1')
|
|
1585
|
+
expect(text).toContain('"type":"error"')
|
|
1586
|
+
})
|
|
1587
|
+
|
|
1588
|
+
test('string error data without sse() uses default event and id', async () => {
|
|
1589
|
+
const builder = new HonoStreamAppBuilder({
|
|
1590
|
+
onMidStreamError: () => {
|
|
1591
|
+
return { data: 'plain error string' }
|
|
1592
|
+
},
|
|
1593
|
+
})
|
|
1594
|
+
const RPC = Procedures<{}, RPCConfig>()
|
|
1595
|
+
|
|
1596
|
+
RPC.CreateStream('ErrorStream', { scope: 'error', version: 1 }, async function* () {
|
|
1597
|
+
yield { count: 1 }
|
|
1598
|
+
throw new Error('fail')
|
|
1599
|
+
})
|
|
1600
|
+
|
|
1601
|
+
builder.register(RPC, () => ({}))
|
|
1602
|
+
const app = builder.build()
|
|
1603
|
+
|
|
1604
|
+
const res = await app.request('/error/error-stream/1')
|
|
1605
|
+
const text = await res.text()
|
|
1606
|
+
|
|
1607
|
+
// String data can't use sse() (not an object), so defaults apply
|
|
1608
|
+
expect(text).toContain('data: plain error string')
|
|
1609
|
+
// event defaults to procedure name when data is provided
|
|
1610
|
+
expect(text).toContain('event: ErrorStream')
|
|
1611
|
+
})
|
|
1612
|
+
})
|
|
1613
|
+
|
|
1614
|
+
// --------------------------------------------------------------------------
|
|
1615
|
+
// Generic TErrorData
|
|
1616
|
+
// --------------------------------------------------------------------------
|
|
1617
|
+
describe('generic TErrorData', () => {
|
|
1618
|
+
test('typed builder constrains onMidStreamError return type', async () => {
|
|
1619
|
+
type ErrorPayload = { type: 'error'; code: string; message: string }
|
|
1620
|
+
|
|
1621
|
+
const builder = new HonoStreamAppBuilder<ErrorPayload>({
|
|
1622
|
+
onMidStreamError: (_procedure, _c, error) => {
|
|
1623
|
+
// This satisfies MidStreamErrorResult<ErrorPayload>
|
|
1624
|
+
return {
|
|
1625
|
+
data: { type: 'error', code: 'STREAM_FAILED', message: error.message },
|
|
1626
|
+
}
|
|
1627
|
+
},
|
|
1628
|
+
})
|
|
1629
|
+
const RPC = Procedures<{}, RPCConfig>()
|
|
1630
|
+
|
|
1631
|
+
RPC.CreateStream('ErrorStream', { scope: 'error', version: 1 }, async function* () {
|
|
1632
|
+
yield { value: 1 }
|
|
1633
|
+
throw new Error('typed error')
|
|
1634
|
+
})
|
|
1635
|
+
|
|
1636
|
+
builder.register(RPC, () => ({}))
|
|
1637
|
+
const app = builder.build()
|
|
1638
|
+
|
|
1639
|
+
const res = await app.request('/error/error-stream/1')
|
|
1640
|
+
const text = await res.text()
|
|
1641
|
+
|
|
1642
|
+
expect(text).toContain('"code":"STREAM_FAILED"')
|
|
1643
|
+
// Error message may be wrapped by Procedures with prefix
|
|
1644
|
+
expect(text).toContain('typed error')
|
|
1645
|
+
})
|
|
1646
|
+
})
|
|
1647
|
+
|
|
1648
|
+
// --------------------------------------------------------------------------
|
|
1649
|
+
// ProcedureValidationError narrowing in onPreStreamError
|
|
1650
|
+
// --------------------------------------------------------------------------
|
|
1651
|
+
describe('ProcedureValidationError narrowing', () => {
|
|
1652
|
+
test('instanceof check works in onPreStreamError', async () => {
|
|
1653
|
+
let wasValidationError = false
|
|
1654
|
+
|
|
1655
|
+
const builder = new HonoStreamAppBuilder({
|
|
1656
|
+
onPreStreamError: (procedure, c, error) => {
|
|
1657
|
+
if (error instanceof ProcedureValidationError) {
|
|
1658
|
+
wasValidationError = true
|
|
1659
|
+
return c.json({ validation: true, errors: error.errors }, 422)
|
|
1660
|
+
}
|
|
1661
|
+
return c.json({ error: error.message }, 500)
|
|
1662
|
+
},
|
|
1663
|
+
})
|
|
1664
|
+
const RPC = Procedures<{}, RPCConfig>()
|
|
1665
|
+
|
|
1666
|
+
RPC.CreateStream(
|
|
1667
|
+
'Validated',
|
|
1668
|
+
{
|
|
1669
|
+
scope: 'validated',
|
|
1670
|
+
version: 1,
|
|
1671
|
+
schema: { params: v.object({ count: v.number() }) },
|
|
1672
|
+
},
|
|
1673
|
+
async function* (ctx, params) {
|
|
1674
|
+
yield { count: params.count }
|
|
1675
|
+
}
|
|
1676
|
+
)
|
|
1677
|
+
|
|
1678
|
+
builder.register(RPC, () => ({}))
|
|
1679
|
+
const app = builder.build()
|
|
1680
|
+
|
|
1681
|
+
const res = await app.request('/validated/validated/1?count=not-a-number')
|
|
1682
|
+
|
|
1683
|
+
expect(res.status).toBe(422)
|
|
1684
|
+
expect(wasValidationError).toBe(true)
|
|
1685
|
+
const body = await res.json()
|
|
1686
|
+
expect(body.validation).toBe(true)
|
|
1687
|
+
expect(body.errors).toBeDefined()
|
|
1688
|
+
})
|
|
1689
|
+
})
|
|
1690
|
+
|
|
1691
|
+
// --------------------------------------------------------------------------
|
|
1692
|
+
// Integration Test
|
|
1693
|
+
// --------------------------------------------------------------------------
|
|
1694
|
+
describe('integration', () => {
|
|
1695
|
+
test('full workflow with streaming procedures', async () => {
|
|
1696
|
+
type StreamContext = { userId: string }
|
|
1697
|
+
|
|
1698
|
+
const RPC = Procedures<StreamContext, RPCConfig>()
|
|
1699
|
+
|
|
1700
|
+
RPC.CreateStream(
|
|
1701
|
+
'WatchNotifications',
|
|
1702
|
+
{
|
|
1703
|
+
scope: ['user', 'notifications'],
|
|
1704
|
+
version: 1,
|
|
1705
|
+
schema: {
|
|
1706
|
+
params: v.object({ limit: v.number() }),
|
|
1707
|
+
yieldType: v.object({ id: v.number(), message: v.string() }),
|
|
1708
|
+
},
|
|
1709
|
+
},
|
|
1710
|
+
async function* (ctx, params) {
|
|
1711
|
+
const limit = params?.limit ?? 3
|
|
1712
|
+
for (let i = 1; i <= limit; i++) {
|
|
1713
|
+
yield { id: i, message: `Notification ${i} for ${ctx.userId}` }
|
|
1714
|
+
}
|
|
1715
|
+
}
|
|
1716
|
+
)
|
|
1717
|
+
|
|
1718
|
+
// Also create a non-streaming procedure to ensure it's filtered out
|
|
1719
|
+
RPC.Create(
|
|
1720
|
+
'GetNotificationCount',
|
|
1721
|
+
{ scope: ['user', 'notifications'], version: 1 },
|
|
1722
|
+
async () => ({
|
|
1723
|
+
count: 10,
|
|
1724
|
+
})
|
|
1725
|
+
)
|
|
1726
|
+
|
|
1727
|
+
const events: string[] = []
|
|
1728
|
+
|
|
1729
|
+
const builder = new HonoStreamAppBuilder({
|
|
1730
|
+
defaultStreamMode: 'text',
|
|
1731
|
+
onRequestStart: () => events.push('request-start'),
|
|
1732
|
+
onRequestEnd: () => events.push('request-end'),
|
|
1733
|
+
onStreamStart: () => events.push('stream-start'),
|
|
1734
|
+
onStreamEnd: () => events.push('stream-end'),
|
|
1735
|
+
})
|
|
1736
|
+
|
|
1737
|
+
builder.register(RPC, (c) => ({
|
|
1738
|
+
userId: c.req.header('x-user-id') || 'anonymous',
|
|
1739
|
+
}))
|
|
1740
|
+
|
|
1741
|
+
const app = builder.build()
|
|
1742
|
+
|
|
1743
|
+
// Only streaming procedure should be registered
|
|
1744
|
+
expect(builder.docs).toHaveLength(1)
|
|
1745
|
+
expect(builder.docs[0]!.name).toBe('WatchNotifications')
|
|
1746
|
+
expect(builder.docs[0]!.methods).toEqual(['get', 'post'])
|
|
1747
|
+
|
|
1748
|
+
// Test streaming
|
|
1749
|
+
const res = await app.request('/user/notifications/watch-notifications/1?limit=2', {
|
|
1750
|
+
headers: { 'X-User-Id': 'user-123' },
|
|
1751
|
+
})
|
|
1752
|
+
|
|
1753
|
+
expect(res.status).toBe(200)
|
|
1754
|
+
|
|
1755
|
+
const text = await res.text()
|
|
1756
|
+
const lines = text.trim().split('\n')
|
|
1757
|
+
expect(lines).toHaveLength(2)
|
|
1758
|
+
expect(JSON.parse(lines[0]!)).toEqual({ id: 1, message: 'Notification 1 for user-123' })
|
|
1759
|
+
expect(JSON.parse(lines[1]!)).toEqual({ id: 2, message: 'Notification 2 for user-123' })
|
|
1760
|
+
|
|
1761
|
+
// Verify hooks were called
|
|
1762
|
+
expect(events).toContain('request-start')
|
|
1763
|
+
expect(events).toContain('stream-start')
|
|
1764
|
+
expect(events).toContain('stream-end')
|
|
1765
|
+
expect(events).toContain('request-end')
|
|
1766
|
+
})
|
|
1767
|
+
})
|
|
1768
|
+
})
|