ts-procedures 7.0.0 → 7.1.1

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.
Files changed (80) hide show
  1. package/agent_config/claude-code/skills/ts-procedures/api-reference.md +4 -0
  2. package/agent_config/copilot/copilot-instructions.md +2 -0
  3. package/agent_config/cursor/cursorrules +2 -0
  4. package/build/client/index.js +5 -0
  5. package/build/client/index.js.map +1 -1
  6. package/build/client/stream.d.ts +25 -1
  7. package/build/client/stream.js +48 -5
  8. package/build/client/stream.js.map +1 -1
  9. package/build/client/stream.test.js +68 -1
  10. package/build/client/stream.test.js.map +1 -1
  11. package/build/codegen/bin/cli.js +91 -0
  12. package/build/codegen/bin/cli.js.map +1 -1
  13. package/build/codegen/bin/cli.test.js +15 -0
  14. package/build/codegen/bin/cli.test.js.map +1 -1
  15. package/build/codegen/e2e.test.js +97 -74
  16. package/build/codegen/e2e.test.js.map +1 -1
  17. package/build/codegen/emit-index.js +11 -1
  18. package/build/codegen/emit-index.js.map +1 -1
  19. package/build/codegen/emit-scope.js +58 -16
  20. package/build/codegen/emit-scope.js.map +1 -1
  21. package/build/codegen/emit-scope.test.js +164 -2
  22. package/build/codegen/emit-scope.test.js.map +1 -1
  23. package/build/codegen/emit-types.d.ts +28 -0
  24. package/build/codegen/emit-types.js +69 -5
  25. package/build/codegen/emit-types.js.map +1 -1
  26. package/build/codegen/emit-types.test.js +30 -0
  27. package/build/codegen/emit-types.test.js.map +1 -1
  28. package/build/codegen/resolve-envelope.js +4 -1
  29. package/build/codegen/resolve-envelope.js.map +1 -1
  30. package/build/codegen/resolve-envelope.test.js +10 -0
  31. package/build/codegen/resolve-envelope.test.js.map +1 -1
  32. package/build/codegen/test-helpers/run-tsc.d.ts +33 -0
  33. package/build/codegen/test-helpers/run-tsc.js +49 -0
  34. package/build/codegen/test-helpers/run-tsc.js.map +1 -0
  35. package/build/implementations/http/doc-registry.js +14 -0
  36. package/build/implementations/http/doc-registry.js.map +1 -1
  37. package/build/implementations/http/doc-registry.test.js +37 -1
  38. package/build/implementations/http/doc-registry.test.js.map +1 -1
  39. package/build/implementations/http/hono-rpc/index.d.ts +11 -0
  40. package/build/implementations/http/hono-rpc/index.js +22 -1
  41. package/build/implementations/http/hono-rpc/index.js.map +1 -1
  42. package/build/implementations/http/hono-rpc/index.test.js +25 -0
  43. package/build/implementations/http/hono-rpc/index.test.js.map +1 -1
  44. package/build/implementations/http/hono-stream/error-taxonomy.test.js +72 -0
  45. package/build/implementations/http/hono-stream/error-taxonomy.test.js.map +1 -1
  46. package/build/implementations/http/hono-stream/index.d.ts +18 -4
  47. package/build/implementations/http/hono-stream/index.js +97 -18
  48. package/build/implementations/http/hono-stream/index.js.map +1 -1
  49. package/build/implementations/http/hono-stream/index.test.js +3 -3
  50. package/build/implementations/http/hono-stream/index.test.js.map +1 -1
  51. package/build/implementations/types.d.ts +10 -0
  52. package/build/index.js +22 -17
  53. package/build/index.js.map +1 -1
  54. package/build/index.test.js +36 -6
  55. package/build/index.test.js.map +1 -1
  56. package/package.json +1 -1
  57. package/src/client/index.ts +6 -0
  58. package/src/client/stream.test.ts +82 -1
  59. package/src/client/stream.ts +67 -4
  60. package/src/codegen/bin/cli.test.ts +26 -0
  61. package/src/codegen/bin/cli.ts +91 -0
  62. package/src/codegen/e2e.test.ts +100 -78
  63. package/src/codegen/emit-index.ts +11 -1
  64. package/src/codegen/emit-scope.test.ts +172 -2
  65. package/src/codegen/emit-scope.ts +66 -13
  66. package/src/codegen/emit-types.test.ts +34 -0
  67. package/src/codegen/emit-types.ts +83 -5
  68. package/src/codegen/resolve-envelope.test.ts +11 -0
  69. package/src/codegen/resolve-envelope.ts +4 -1
  70. package/src/codegen/test-helpers/run-tsc.ts +56 -0
  71. package/src/implementations/http/doc-registry.test.ts +43 -1
  72. package/src/implementations/http/doc-registry.ts +19 -0
  73. package/src/implementations/http/hono-rpc/index.test.ts +32 -0
  74. package/src/implementations/http/hono-rpc/index.ts +27 -1
  75. package/src/implementations/http/hono-stream/error-taxonomy.test.ts +80 -0
  76. package/src/implementations/http/hono-stream/index.test.ts +3 -3
  77. package/src/implementations/http/hono-stream/index.ts +118 -22
  78. package/src/implementations/types.ts +7 -0
  79. package/src/index.test.ts +43 -6
  80. package/src/index.ts +23 -20
@@ -133,13 +133,16 @@ export async function jsonSchemaToExtractedTypes(
133
133
  const declarations: string[] = []
134
134
  let body = ''
135
135
 
136
+ // Match `export type Root =` strictly — `Root` must be followed by `=` (with
137
+ // optional whitespace), so sibling extracted names like `RootType` (which
138
+ // ajsc emits for an `Array<RootType>` root schema) fall through to the
139
+ // declarations branch instead of being eaten as the body.
140
+ const rootDeclPattern = /^export\s+type\s+Root\s*=\s*/
141
+
136
142
  for (const block of blocks) {
137
- if (block.startsWith('export type Root')) {
143
+ if (rootDeclPattern.test(block)) {
138
144
  // Strip "export type Root = " prefix and trailing ";"
139
- body = block
140
- .replace(/^export\s+type\s+Root\s*=\s*/, '')
141
- .replace(/;\s*$/, '')
142
- .trim()
145
+ body = block.replace(rootDeclPattern, '').replace(/;\s*$/, '').trim()
143
146
  } else {
144
147
  // Sub-type or enum declaration — remove trailing ";" for consistency
145
148
  declarations.push(block.replace(/;\s*$/, ''))
@@ -156,3 +159,78 @@ export async function jsonSchemaToExtractedTypes(
156
159
 
157
160
  return { declarations, body }
158
161
  }
162
+
163
+ /**
164
+ * Returns the declared name of an `export type|enum|interface X = …` block,
165
+ * or `undefined` if none can be parsed. Used by `renameExtractedTypes` to
166
+ * detect collisions between ajsc-extracted sub-types and reserved identifiers.
167
+ */
168
+ export function extractedDeclName(decl: string): string | undefined {
169
+ const m = decl.match(/^export\s+(?:type|enum|interface)\s+(\w+)/)
170
+ return m?.[1]
171
+ }
172
+
173
+ /**
174
+ * Rewrites an {@link ExtractedTypeOutput} to avoid identifier collisions.
175
+ *
176
+ * ajsc with `inlineTypes: false` derives sub-type names from the parent
177
+ * property's name — so a schema property literally called `params` produces
178
+ * `export type Params = {…}`. When that collides with the route's own
179
+ * `Params` shortName (or any other reserved name), the resulting namespace
180
+ * has duplicate `export type Params` declarations and won't compile.
181
+ *
182
+ * This helper takes:
183
+ * - `result`: the raw ajsc output for one schema
184
+ * - `taken`: a set of names that are NOT free to use (mutated as renames
185
+ * are applied so subsequent calls share the same allocation map)
186
+ *
187
+ * For every extracted declaration whose name is already in `taken`, a unique
188
+ * alias is generated (`Params` → `ParamsInner`, then `ParamsInner2`, …) so
189
+ * the renamed type reads like a real, intentional name (not a placeholder).
190
+ * The declaration is rewritten with the new name and every word-boundary
191
+ * occurrence in `result.body` is substituted so the body keeps referencing
192
+ * the renamed type.
193
+ */
194
+ export function renameExtractedTypes(
195
+ result: ExtractedTypeOutput,
196
+ taken: Set<string>,
197
+ ): ExtractedTypeOutput {
198
+ let body = result.body
199
+ const declarations: string[] = []
200
+
201
+ for (const decl of result.declarations) {
202
+ const name = extractedDeclName(decl)
203
+ if (name == null) {
204
+ declarations.push(decl)
205
+ continue
206
+ }
207
+ if (!taken.has(name)) {
208
+ taken.add(name)
209
+ declarations.push(decl)
210
+ continue
211
+ }
212
+
213
+ // Allocate a unique alias. `Inner` reads as an intentional name (the
214
+ // sub-type referenced *by* the conflicting outer type) rather than the
215
+ // `_` suffix which looks like a forgotten placeholder.
216
+ let alias = `${name}Inner`
217
+ let suffix = 2
218
+ while (taken.has(alias)) {
219
+ alias = `${name}Inner${suffix}`
220
+ suffix += 1
221
+ }
222
+ taken.add(alias)
223
+
224
+ // Rewrite the declaration's leading identifier.
225
+ const renamedDecl = decl.replace(
226
+ new RegExp(`^(export\\s+(?:type|enum|interface)\\s+)${name}\\b`),
227
+ `$1${alias}`,
228
+ )
229
+ declarations.push(renamedDecl)
230
+
231
+ // Patch every word-boundary occurrence of the old name in the body.
232
+ body = body.replace(new RegExp(`\\b${name}\\b`, 'g'), alias)
233
+ }
234
+
235
+ return { declarations, body }
236
+ }
@@ -53,6 +53,17 @@ describe('resolveEnvelope', () => {
53
53
  await expect(resolveEnvelope({ envelope: empty })).rejects.toThrow(/routes/)
54
54
  })
55
55
 
56
+ // Defensive (downstream bug repro): forgetting `builder.build()` is a common
57
+ // cause of an empty routes array because hono-rpc/hono-api/hono-stream/express-rpc
58
+ // builders only populate their `_docs` array inside `build()`. The current
59
+ // error message says "Register at least one procedure", which led the
60
+ // downstream dev to look in the wrong place. The message should mention
61
+ // `.build()` as a likely cause so the next person hits the right fix faster.
62
+ it('empty-routes error message mentions builder.build() as a likely cause', async () => {
63
+ const empty: DocEnvelope = { basePath: '', headers: [], errors: [], routes: [] }
64
+ await expect(resolveEnvelope({ envelope: empty })).rejects.toThrow(/\.build\(\)/)
65
+ })
66
+
56
67
  it('throws when routes array is empty (file input)', async () => {
57
68
  const dir = await mkdtemp(join(tmpdir(), 'ts-proc-test-'))
58
69
  const filePath = join(dir, 'empty.json')
@@ -53,7 +53,10 @@ export async function resolveEnvelope(input: ResolveInput): Promise<DocEnvelope>
53
53
 
54
54
  if (envelope.routes.length === 0) {
55
55
  throw new Error(
56
- '[ts-procedures-codegen] DocEnvelope has an empty "routes" array. Register at least one procedure before generating.'
56
+ '[ts-procedures-codegen] DocEnvelope has an empty "routes" array. ' +
57
+ 'Common causes: (1) you forgot to call `builder.build()` before passing ' +
58
+ 'the builder to `DocRegistry.from(...)` — hono/express builders only populate ' +
59
+ '`docs` inside `build()`; (2) no procedures registered with the builder.'
57
60
  )
58
61
  }
59
62
 
@@ -0,0 +1,56 @@
1
+ import { execSync } from 'node:child_process'
2
+ import { writeFileSync } from 'node:fs'
3
+ import { join } from 'node:path'
4
+
5
+ /**
6
+ * Default tsconfig used by codegen e2e tests when none is supplied.
7
+ * Mirrors what consumers running `tsc --strict` typically use, plus the
8
+ * codegen output's expected module/target settings.
9
+ */
10
+ export const DEFAULT_E2E_TSCONFIG = {
11
+ compilerOptions: {
12
+ strict: true,
13
+ target: 'ES2022',
14
+ module: 'ES2022',
15
+ moduleResolution: 'bundler',
16
+ noEmit: true,
17
+ skipLibCheck: true,
18
+ },
19
+ include: ['**/*.ts'],
20
+ } as const
21
+
22
+ /**
23
+ * Runs `tsc --noEmit` on the given tsconfig and throws an `AssertionError`-style
24
+ * message that includes captured stderr/stdout when compilation fails. Returns
25
+ * silently on success.
26
+ *
27
+ * Replaces the previously-duplicated try/execSync/stderr-format dance that
28
+ * appeared in 5+ places in `e2e.test.ts` — keeps tests focused on what they
29
+ * generated, not how they shell out to tsc.
30
+ *
31
+ * `tsconfigInline` is written to `tmpDir/tsconfig.json` automatically; pass
32
+ * `tsconfigPath` instead to point at an existing file.
33
+ */
34
+ export function runTsc(args: {
35
+ tmpDir: string
36
+ tsconfigInline?: Record<string, unknown>
37
+ tsconfigPath?: string
38
+ }): void {
39
+ const tsconfigPath = args.tsconfigPath ?? join(args.tmpDir, 'tsconfig.json')
40
+ if (args.tsconfigInline != null) {
41
+ writeFileSync(tsconfigPath, JSON.stringify(args.tsconfigInline))
42
+ }
43
+
44
+ const tscPath = join(process.cwd(), 'node_modules', '.bin', 'tsc')
45
+ try {
46
+ execSync(`${tscPath} --noEmit --project ${tsconfigPath}`, { stdio: 'pipe' })
47
+ } catch (err) {
48
+ const e = err as { stdout?: Buffer; stderr?: Buffer; message?: string }
49
+ const stdout = e.stdout?.toString() ?? ''
50
+ const stderr = e.stderr?.toString() ?? ''
51
+ const combined = [stdout, stderr].filter(Boolean).join('\n').trim()
52
+ throw new Error(
53
+ `[runTsc] tsc failed for ${tsconfigPath}:\n${combined || (e.message ?? '(no output)')}`,
54
+ )
55
+ }
56
+ }
@@ -1,4 +1,4 @@
1
- import { describe, expect, it, test } from 'vitest'
1
+ import { describe, expect, it, test, vi, afterEach } from 'vitest'
2
2
  import { v } from 'suretype'
3
3
  import { Procedures } from '../../index.js'
4
4
  import { HonoRPCAppBuilder } from './hono-rpc/index.js'
@@ -538,6 +538,48 @@ describe('DocRegistry', () => {
538
538
  expect(parsed.routes[0].name).toBe('Echo')
539
539
  })
540
540
 
541
+ describe('coverage warnings via skippedProcedures', () => {
542
+ afterEach(() => {
543
+ vi.restoreAllMocks()
544
+ delete process.env.TS_PROCEDURES_SUPPRESS_COVERAGE_WARNINGS
545
+ })
546
+
547
+ test('warns when sources expose skippedProcedures entries', () => {
548
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
549
+ const sourceWithSkipped: DocSource<RPCHttpRouteDoc> = {
550
+ docs: [rpcDoc],
551
+ skippedProcedures: [
552
+ { name: 'StreamProc', reason: 'wrong builder' },
553
+ ],
554
+ }
555
+
556
+ new DocRegistry().from(sourceWithSkipped).toJSON()
557
+
558
+ expect(warnSpy).toHaveBeenCalledOnce()
559
+ const message = warnSpy.mock.calls[0]![0] as string
560
+ expect(message).toContain('1 procedure(s)')
561
+ expect(message).toContain('StreamProc')
562
+ expect(message).toContain('wrong builder')
563
+ })
564
+
565
+ test('does not warn when sources have no skippedProcedures', () => {
566
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
567
+ new DocRegistry().from(makeSource([rpcDoc])).toJSON()
568
+ expect(warnSpy).not.toHaveBeenCalled()
569
+ })
570
+
571
+ test('TS_PROCEDURES_SUPPRESS_COVERAGE_WARNINGS=1 silences the warning', () => {
572
+ process.env.TS_PROCEDURES_SUPPRESS_COVERAGE_WARNINGS = '1'
573
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
574
+ const source: DocSource<RPCHttpRouteDoc> = {
575
+ docs: [rpcDoc],
576
+ skippedProcedures: [{ name: 'X', reason: 'y' }],
577
+ }
578
+ new DocRegistry().from(source).toJSON()
579
+ expect(warnSpy).not.toHaveBeenCalled()
580
+ })
581
+ })
582
+
541
583
  test('accepts a real HonoRPCAppBuilder as source', () => {
542
584
  const RPC = Procedures<{ userId: string }, RPCConfig>()
543
585
 
@@ -88,6 +88,25 @@ export class DocRegistry {
88
88
  routes = routes.filter(options.filter)
89
89
  }
90
90
 
91
+ // Surface coverage gaps — when a builder skipped procedures (e.g. a
92
+ // streaming procedure registered with HonoRPCAppBuilder), the per-builder
93
+ // warning fires once at `build()` time, but consumers who only call
94
+ // `registry.toJSON()` (e.g. to dump the envelope to disk for codegen)
95
+ // wouldn't see it without this aggregate. Single warning per call,
96
+ // suppressed when the env var is set so CI pipelines stay quiet.
97
+ if (process.env.TS_PROCEDURES_SUPPRESS_COVERAGE_WARNINGS !== '1') {
98
+ const skipped = this.sources.flatMap((source) => source.skippedProcedures ?? [])
99
+ if (skipped.length > 0) {
100
+ const lines = skipped.map(
101
+ ({ name, reason }) => ` - "${name}": ${reason}`
102
+ )
103
+ console.warn(
104
+ `[ts-procedures DocRegistry] ${skipped.length} procedure(s) registered with a builder but not served — they will be missing from the doc envelope:\n${lines.join('\n')}\n` +
105
+ `(Suppress with TS_PROCEDURES_SUPPRESS_COVERAGE_WARNINGS=1)`
106
+ )
107
+ }
108
+ }
109
+
91
110
  const envelope: DocEnvelope = {
92
111
  basePath: this.basePath,
93
112
  headers: [...this.headers],
@@ -598,6 +598,38 @@ describe('HonoRPCAppBuilder', () => {
598
598
 
599
599
  expect(res.status).toBe(404)
600
600
  })
601
+
602
+ test('skips streaming procedures and tracks them in skippedProcedures', async () => {
603
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
604
+ try {
605
+ const builder = new HonoRPCAppBuilder()
606
+ const RPC = Procedures<{}, RPCConfig>()
607
+
608
+ RPC.Create('RegularProc', { scope: 'test', version: 1 }, async () => ({ ok: true }))
609
+ RPC.CreateStream('StreamProc', { scope: 'test', version: 1 }, async function* () {
610
+ yield { msg: 'stream' }
611
+ })
612
+
613
+ builder.register(RPC, () => ({}))
614
+ builder.build()
615
+
616
+ // Only the regular procedure makes it into docs
617
+ expect(builder.docs).toHaveLength(1)
618
+ expect(builder.docs[0]!.name).toBe('RegularProc')
619
+
620
+ // Stream proc surfaces via skippedProcedures
621
+ expect(builder.skippedProcedures).toHaveLength(1)
622
+ expect(builder.skippedProcedures[0]!.name).toBe('StreamProc')
623
+ expect(builder.skippedProcedures[0]!.reason).toMatch(/HonoStreamAppBuilder/)
624
+
625
+ // And we logged a warning so devs aren't blindsided
626
+ expect(warnSpy).toHaveBeenCalledWith(
627
+ expect.stringContaining('Skipping procedure "StreamProc"')
628
+ )
629
+ } finally {
630
+ warnSpy.mockRestore()
631
+ }
632
+ })
601
633
  })
602
634
 
603
635
  // --------------------------------------------------------------------------
@@ -151,6 +151,7 @@ export class HonoRPCAppBuilder {
151
151
 
152
152
  private _app: Hono = new Hono()
153
153
  private _docs: (RPCHttpRouteDoc & object)[] = []
154
+ private _skipped: { name: string; reason: string }[] = []
154
155
 
155
156
  get app(): Hono {
156
157
  return this._app
@@ -160,6 +161,16 @@ export class HonoRPCAppBuilder {
160
161
  return this._docs
161
162
  }
162
163
 
164
+ /**
165
+ * Procedures that were skipped at `build()` time because they don't fit
166
+ * this builder (e.g. streaming procedures registered against an RPC
167
+ * builder). Surfaced via `DocSource.skippedProcedures` so DocRegistry can
168
+ * warn about coverage gaps.
169
+ */
170
+ get skippedProcedures(): { name: string; reason: string }[] {
171
+ return this._skipped
172
+ }
173
+
163
174
  /**
164
175
  * Registers a procedure factory with its context.
165
176
  * @param factory - The procedure factory created by Procedures<Context, RPCConfig>()
@@ -189,7 +200,22 @@ export class HonoRPCAppBuilder {
189
200
  */
190
201
  build(): Hono {
191
202
  this.factories.forEach(({ factory, factoryContext, extendProcedureDoc }) => {
192
- factory.getProcedures().map((procedure: TProcedureRegistration<any, RPCConfig>) => {
203
+ factory.getProcedures().forEach((procedure: TProcedureRegistration<any, RPCConfig>) => {
204
+ // Skip streaming procedures — RPC builder cannot serve them. Without
205
+ // this guard, the procedure would silently get a POST route that
206
+ // returns its async generator object as a JSON response, which is not
207
+ // a useful failure mode. Procedures meant to stream should be
208
+ // registered with HonoStreamAppBuilder.
209
+ if ((procedure as { isStream?: boolean }).isStream === true) {
210
+ const reason =
211
+ 'Streaming procedure registered with HonoRPCAppBuilder — register it with HonoStreamAppBuilder instead.'
212
+ this._skipped.push({ name: procedure.name, reason })
213
+ console.warn(
214
+ `[ts-procedures hono-rpc] Skipping procedure "${procedure.name}": ${reason}`
215
+ )
216
+ return
217
+ }
218
+
193
219
  const route = this.buildRpcHttpRouteDoc(procedure, extendProcedureDoc)
194
220
 
195
221
  this._docs.push(route)
@@ -96,3 +96,83 @@ describe('HonoStreamAppBuilder — error taxonomy (pre-stream)', () => {
96
96
  expect(await res.json()).toEqual({ name: 'ServiceUnavailable' })
97
97
  })
98
98
  })
99
+
100
+ describe('HonoStreamAppBuilder — error taxonomy (mid-stream)', () => {
101
+ test('taxonomy resolves the body for a typed error thrown mid-stream (SSE)', async () => {
102
+ const errors = defineErrorTaxonomy({
103
+ AuthError: {
104
+ class: AuthError,
105
+ statusCode: 403,
106
+ toResponse: (err) => ({ name: 'AuthError', reason: err.reason }),
107
+ },
108
+ })
109
+ const RPC = Procedures<{}, RPCConfig>()
110
+ RPC.CreateStream('Stream', { scope: 'test', version: 1 }, async function* () {
111
+ yield { msg: 'first' }
112
+ throw new AuthError('forbidden')
113
+ })
114
+
115
+ const app = new HonoStreamAppBuilder({ errors })
116
+ .register(RPC, () => ({}))
117
+ .build()
118
+
119
+ const res = await app.request('/test/stream/1', { method: 'POST' })
120
+ expect(res.status).toBe(200) // status committed before error
121
+ const text = await res.text()
122
+ // Last event is the error event with the resolved body shape
123
+ expect(text).toContain('event: error')
124
+ expect(text).toContain('"name":"AuthError"')
125
+ expect(text).toContain('"reason":"forbidden"')
126
+ })
127
+
128
+ test('onMidStreamError still runs as fallback when taxonomy does not match', async () => {
129
+ const RPC = Procedures<{}, RPCConfig>()
130
+ RPC.CreateStream('Stream', { scope: 'test', version: 1 }, async function* () {
131
+ yield { msg: 'first' }
132
+ throw new TypeError('mid-stream boom')
133
+ })
134
+
135
+ const app = new HonoStreamAppBuilder({
136
+ errors: {
137
+ AuthError: {
138
+ class: AuthError,
139
+ statusCode: 403,
140
+ toResponse: () => ({ name: 'AuthError', reason: 'forbidden' }),
141
+ },
142
+ },
143
+ onMidStreamError: (_p, _c, err) => ({ data: { name: 'FallbackError', message: err.message } }),
144
+ })
145
+ .register(RPC, () => ({}))
146
+ .build()
147
+
148
+ const res = await app.request('/test/stream/1', { method: 'POST' })
149
+ const text = await res.text()
150
+ expect(text).toContain('"name":"FallbackError"')
151
+ expect(text).toContain('mid-stream boom')
152
+ })
153
+
154
+ test('mid-stream typed body falls through to text mode', async () => {
155
+ const RPC = Procedures<{}, RPCConfig>()
156
+ RPC.CreateStream('Stream', { scope: 'test', version: 1 }, async function* () {
157
+ yield { msg: 'first' }
158
+ throw new AuthError('forbidden')
159
+ })
160
+ const app = new HonoStreamAppBuilder({
161
+ defaultStreamMode: 'text',
162
+ errors: {
163
+ AuthError: {
164
+ class: AuthError,
165
+ statusCode: 403,
166
+ toResponse: () => ({ name: 'AuthError', reason: 'forbidden' }),
167
+ },
168
+ },
169
+ })
170
+ .register(RPC, () => ({}))
171
+ .build()
172
+
173
+ const res = await app.request('/test/stream/1', { method: 'POST' })
174
+ const text = await res.text()
175
+ expect(text).toContain('"name":"AuthError"')
176
+ expect(text).toContain('"reason":"forbidden"')
177
+ })
178
+ })
@@ -890,7 +890,7 @@ describe('HonoStreamAppBuilder', () => {
890
890
 
891
891
  const doc = builder.docs[0]!
892
892
  expect(doc.path).toBe('/messages/stream-messages/1')
893
- expect(doc.methods).toEqual(['get', 'post'])
893
+ expect(doc.methods).toEqual(['post', 'get'])
894
894
  expect(doc.streamMode).toBe('sse')
895
895
  expect(doc.jsonSchema.params).toBeDefined()
896
896
  expect(doc.jsonSchema.returnType).toBeDefined()
@@ -1111,7 +1111,7 @@ describe('HonoStreamAppBuilder', () => {
1111
1111
  // Base properties should NOT be overridden
1112
1112
  expect(doc.name).toBe('Test')
1113
1113
  expect(doc.path).toBe('/test/test/1')
1114
- expect(doc.methods).toEqual(['get', 'post'])
1114
+ expect(doc.methods).toEqual(['post', 'get'])
1115
1115
  // Custom field should be present
1116
1116
  expect(doc).toHaveProperty('customField', 'custom-value')
1117
1117
  })
@@ -1779,7 +1779,7 @@ describe('HonoStreamAppBuilder', () => {
1779
1779
  // Only streaming procedure should be registered
1780
1780
  expect(builder.docs).toHaveLength(1)
1781
1781
  expect(builder.docs[0]!.name).toBe('WatchNotifications')
1782
- expect(builder.docs[0]!.methods).toEqual(['get', 'post'])
1782
+ expect(builder.docs[0]!.methods).toEqual(['post', 'get'])
1783
1783
 
1784
1784
  // Test streaming
1785
1785
  const res = await app.request('/user/notifications/watch-notifications/1?limit=2', {
@@ -75,10 +75,13 @@ export type HonoStreamAppBuilderConfig<TErrorData = unknown> = {
75
75
  onStreamEnd?: (procedure: TStreamProcedureRegistration, c: Context, streamMode: StreamMode) => void
76
76
  /**
77
77
  * Declarative error-to-response mapping (one of the two peer error modes).
78
- * Thrown error classes map to status codes + bodies declaratively. Mid-stream
79
- * errors still go through `onMidStreamError` the HTTP status is already
80
- * committed once streaming starts. See hono-api for the full taxonomy
81
- * contract.
78
+ * Thrown error classes map to status codes + bodies declaratively. The
79
+ * taxonomy applies to BOTH pre-stream errors (where the status code is
80
+ * honored) AND mid-stream errors (where only the body shape is honored —
81
+ * the HTTP status is already committed once streaming starts; the body is
82
+ * written as the SSE `event: 'error'` data, or a JSON line in text mode,
83
+ * for the client's error registry to dispatch). See hono-api for the full
84
+ * taxonomy contract.
82
85
  */
83
86
  errors?: ErrorTaxonomy
84
87
  /**
@@ -185,6 +188,7 @@ export class HonoStreamAppBuilder<TErrorData = unknown> {
185
188
 
186
189
  private _app: Hono = new Hono()
187
190
  private _docs: (StreamHttpRouteDoc & object)[] = []
191
+ private _skipped: { name: string; reason: string }[] = []
188
192
 
189
193
  get app(): Hono {
190
194
  return this._app
@@ -194,6 +198,16 @@ export class HonoStreamAppBuilder<TErrorData = unknown> {
194
198
  return this._docs
195
199
  }
196
200
 
201
+ /**
202
+ * Procedures that were skipped at `build()` time because they don't fit
203
+ * this builder (e.g. non-streaming procedures registered against the
204
+ * stream builder). Surfaced via `DocSource.skippedProcedures` so
205
+ * DocRegistry can warn about coverage gaps.
206
+ */
207
+ get skippedProcedures(): { name: string; reason: string }[] {
208
+ return this._skipped
209
+ }
210
+
197
211
  /**
198
212
  * Registers a procedure factory with its context.
199
213
  * Only streaming procedures (created with CreateStream) will be registered.
@@ -355,24 +369,61 @@ export class HonoStreamAppBuilder<TErrorData = unknown> {
355
369
  })
356
370
  }
357
371
  } catch (error) {
358
- // Get error yield value from callback (onMidStreamError)
359
- let errorResult: MidStreamErrorResult<TErrorData> | undefined
372
+ // Dispatch order mirrors hono-rpc: taxonomy onMidStreamError → default.
373
+ // The HTTP status is already committed (200 OK headers were sent the
374
+ // moment streaming started), so the taxonomy here only drives the
375
+ // wire-protocol body shape — clients dispatch through the same error
376
+ // registry as RPC/API responses by reading `data.name`.
377
+ let errorData: unknown
378
+ let sseEventOverride: string | undefined
379
+ let sseIdOverride: string | undefined
380
+ let sseRetryOverride: number | undefined
381
+ let runOnCatch: (() => Promise<void>) | undefined
360
382
 
361
- if (this.config?.onMidStreamError) {
362
- errorResult = this.config.onMidStreamError(procedure, c, error as Error)
383
+ if (this.config?.errors || this.config?.unknownError) {
384
+ const resolved = resolveErrorResponse({
385
+ err: error,
386
+ userTaxonomy: this.config.errors,
387
+ unknownError: this.config.unknownError,
388
+ procedure,
389
+ raw: c,
390
+ })
391
+ if (resolved) {
392
+ errorData = resolved.body
393
+ sseEventOverride = 'error'
394
+ runOnCatch = resolved.runOnCatch
395
+ }
396
+ }
397
+
398
+ if (errorData === undefined && this.config?.onMidStreamError) {
399
+ const errorResult: MidStreamErrorResult<TErrorData> | undefined =
400
+ this.config.onMidStreamError(procedure, c, error as Error)
401
+ if (errorResult?.data !== undefined) {
402
+ errorData = errorResult.data
403
+ sseEventOverride = procedure.name
404
+ }
405
+ }
406
+
407
+ if (errorData === undefined) {
408
+ errorData = { error: (error as Error).message }
409
+ sseEventOverride = 'error'
363
410
  }
364
411
 
365
- // Write error value to stream
366
- const errorData = errorResult?.data ?? { error: (error as Error).message }
367
412
  const sseMeta = getSSEMeta(errorData)
368
413
 
369
414
  await stream.writeSSE({
370
415
  data: typeof errorData === 'string' ? errorData : JSON.stringify(errorData),
371
- event: sseMeta?.event ?? (errorResult?.data !== undefined ? procedure.name : 'error'),
372
- id: sseMeta?.id ?? String(eventId++),
373
- ...(sseMeta?.retry !== undefined && { retry: sseMeta.retry }),
416
+ event: sseMeta?.event ?? sseEventOverride ?? 'error',
417
+ id: sseMeta?.id ?? sseIdOverride ?? String(eventId++),
418
+ ...((sseMeta?.retry ?? sseRetryOverride) !== undefined && {
419
+ retry: (sseMeta?.retry ?? sseRetryOverride) as number,
420
+ }),
374
421
  })
375
422
 
423
+ if (runOnCatch) {
424
+ await runOnCatch()
425
+ }
426
+
376
427
  // closeStream defaults to true if not specified
377
428
  // (stream closes naturally after this handler completes)
378
429
  } finally {
@@ -408,16 +459,43 @@ export class HonoStreamAppBuilder<TErrorData = unknown> {
408
459
  await stream.writeln(JSON.stringify(value))
409
460
  }
410
461
  } catch (error) {
411
- // Get error yield value from callback (onMidStreamError)
412
- let errorResult: MidStreamErrorResult<TErrorData> | undefined
462
+ // Same dispatch order as SSE taxonomy first, onMidStreamError next,
463
+ // hard default last. Text streams have no event/id metadata, so we
464
+ // only forward the body bytes.
465
+ let errorData: unknown
466
+ let runOnCatch: (() => Promise<void>) | undefined
413
467
 
414
- if (this.config?.onMidStreamError) {
415
- errorResult = this.config.onMidStreamError(procedure, c, error as Error)
468
+ if (this.config?.errors || this.config?.unknownError) {
469
+ const resolved = resolveErrorResponse({
470
+ err: error,
471
+ userTaxonomy: this.config.errors,
472
+ unknownError: this.config.unknownError,
473
+ procedure,
474
+ raw: c,
475
+ })
476
+ if (resolved) {
477
+ errorData = resolved.body
478
+ runOnCatch = resolved.runOnCatch
479
+ }
480
+ }
481
+
482
+ if (errorData === undefined && this.config?.onMidStreamError) {
483
+ const errorResult: MidStreamErrorResult<TErrorData> | undefined =
484
+ this.config.onMidStreamError(procedure, c, error as Error)
485
+ if (errorResult?.data !== undefined) {
486
+ errorData = errorResult.data
487
+ }
488
+ }
489
+
490
+ if (errorData === undefined) {
491
+ errorData = { error: (error as Error).message }
416
492
  }
417
493
 
418
- // Write error value to stream
419
- const errorData = errorResult?.data ?? { error: (error as Error).message }
420
494
  await stream.writeln(JSON.stringify(errorData))
495
+
496
+ if (runOnCatch) {
497
+ await runOnCatch()
498
+ }
421
499
  } finally {
422
500
  if (this.config?.onStreamEnd) {
423
501
  this.config.onStreamEnd(procedure, c, 'text')
@@ -436,8 +514,23 @@ export class HonoStreamAppBuilder<TErrorData = unknown> {
436
514
  this.factories.forEach(({ factory, factoryContext, streamMode, extendProcedureDoc }) => {
437
515
  const mode = streamMode ?? this.config?.defaultStreamMode ?? 'sse'
438
516
 
439
- factory
440
- .getProcedures()
517
+ const procedures = factory.getProcedures()
518
+
519
+ // Track non-streaming procedures so DocRegistry can warn about coverage
520
+ // gaps (e.g. a regular procedure registered with this builder will get
521
+ // dropped here and needs to be registered with HonoRPCAppBuilder).
522
+ for (const p of procedures as { name: string; isStream?: boolean }[]) {
523
+ if (p.isStream !== true) {
524
+ const reason =
525
+ 'Non-streaming procedure registered with HonoStreamAppBuilder — register it with HonoRPCAppBuilder (or HonoApiAppBuilder) instead.'
526
+ this._skipped.push({ name: p.name, reason })
527
+ console.warn(
528
+ `[ts-procedures hono-stream] Skipping procedure "${p.name}": ${reason}`
529
+ )
530
+ }
531
+ }
532
+
533
+ procedures
441
534
  .filter(
442
535
  (p: { isStream?: boolean }): p is TStreamProcedureRegistration => p.isStream === true
443
536
  )
@@ -471,7 +564,10 @@ export class HonoStreamAppBuilder<TErrorData = unknown> {
471
564
  config,
472
565
  prefix: this.config?.pathPrefix,
473
566
  })
474
- const methods = ['get', 'post'] as const
567
+ // POST first so codegen (which uses `methods[0]`) defaults to POST. POST is
568
+ // the canonical method for streaming procedures because it can carry a body
569
+ // for params; GET is the supplementary method for query-string callers.
570
+ const methods = ['post', 'get'] as const
475
571
  const jsonSchema: { params?: Record<string, unknown>; yieldType?: Record<string, unknown>; returnType?: Record<string, unknown> } = {}
476
572
 
477
573
  if (config.schema?.params) {