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.
- package/agent_config/claude-code/skills/ts-procedures/api-reference.md +4 -0
- package/agent_config/copilot/copilot-instructions.md +2 -0
- package/agent_config/cursor/cursorrules +2 -0
- package/build/client/index.js +5 -0
- package/build/client/index.js.map +1 -1
- package/build/client/stream.d.ts +25 -1
- package/build/client/stream.js +48 -5
- package/build/client/stream.js.map +1 -1
- package/build/client/stream.test.js +68 -1
- package/build/client/stream.test.js.map +1 -1
- package/build/codegen/bin/cli.js +91 -0
- package/build/codegen/bin/cli.js.map +1 -1
- package/build/codegen/bin/cli.test.js +15 -0
- package/build/codegen/bin/cli.test.js.map +1 -1
- package/build/codegen/e2e.test.js +97 -74
- package/build/codegen/e2e.test.js.map +1 -1
- package/build/codegen/emit-index.js +11 -1
- package/build/codegen/emit-index.js.map +1 -1
- package/build/codegen/emit-scope.js +58 -16
- package/build/codegen/emit-scope.js.map +1 -1
- package/build/codegen/emit-scope.test.js +164 -2
- package/build/codegen/emit-scope.test.js.map +1 -1
- package/build/codegen/emit-types.d.ts +28 -0
- package/build/codegen/emit-types.js +69 -5
- package/build/codegen/emit-types.js.map +1 -1
- package/build/codegen/emit-types.test.js +30 -0
- package/build/codegen/emit-types.test.js.map +1 -1
- package/build/codegen/resolve-envelope.js +4 -1
- package/build/codegen/resolve-envelope.js.map +1 -1
- package/build/codegen/resolve-envelope.test.js +10 -0
- package/build/codegen/resolve-envelope.test.js.map +1 -1
- package/build/codegen/test-helpers/run-tsc.d.ts +33 -0
- package/build/codegen/test-helpers/run-tsc.js +49 -0
- package/build/codegen/test-helpers/run-tsc.js.map +1 -0
- package/build/implementations/http/doc-registry.js +14 -0
- package/build/implementations/http/doc-registry.js.map +1 -1
- package/build/implementations/http/doc-registry.test.js +37 -1
- package/build/implementations/http/doc-registry.test.js.map +1 -1
- package/build/implementations/http/hono-rpc/index.d.ts +11 -0
- package/build/implementations/http/hono-rpc/index.js +22 -1
- package/build/implementations/http/hono-rpc/index.js.map +1 -1
- package/build/implementations/http/hono-rpc/index.test.js +25 -0
- package/build/implementations/http/hono-rpc/index.test.js.map +1 -1
- package/build/implementations/http/hono-stream/error-taxonomy.test.js +72 -0
- package/build/implementations/http/hono-stream/error-taxonomy.test.js.map +1 -1
- package/build/implementations/http/hono-stream/index.d.ts +18 -4
- package/build/implementations/http/hono-stream/index.js +97 -18
- package/build/implementations/http/hono-stream/index.js.map +1 -1
- package/build/implementations/http/hono-stream/index.test.js +3 -3
- package/build/implementations/http/hono-stream/index.test.js.map +1 -1
- package/build/implementations/types.d.ts +10 -0
- package/build/index.js +22 -17
- package/build/index.js.map +1 -1
- package/build/index.test.js +36 -6
- package/build/index.test.js.map +1 -1
- package/package.json +1 -1
- package/src/client/index.ts +6 -0
- package/src/client/stream.test.ts +82 -1
- package/src/client/stream.ts +67 -4
- package/src/codegen/bin/cli.test.ts +26 -0
- package/src/codegen/bin/cli.ts +91 -0
- package/src/codegen/e2e.test.ts +100 -78
- package/src/codegen/emit-index.ts +11 -1
- package/src/codegen/emit-scope.test.ts +172 -2
- package/src/codegen/emit-scope.ts +66 -13
- package/src/codegen/emit-types.test.ts +34 -0
- package/src/codegen/emit-types.ts +83 -5
- package/src/codegen/resolve-envelope.test.ts +11 -0
- package/src/codegen/resolve-envelope.ts +4 -1
- package/src/codegen/test-helpers/run-tsc.ts +56 -0
- package/src/implementations/http/doc-registry.test.ts +43 -1
- package/src/implementations/http/doc-registry.ts +19 -0
- package/src/implementations/http/hono-rpc/index.test.ts +32 -0
- package/src/implementations/http/hono-rpc/index.ts +27 -1
- package/src/implementations/http/hono-stream/error-taxonomy.test.ts +80 -0
- package/src/implementations/http/hono-stream/index.test.ts +3 -3
- package/src/implementations/http/hono-stream/index.ts +118 -22
- package/src/implementations/types.ts +7 -0
- package/src/index.test.ts +43 -6
- 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 (
|
|
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.
|
|
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().
|
|
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(['
|
|
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(['
|
|
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(['
|
|
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.
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
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
|
-
//
|
|
359
|
-
|
|
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?.
|
|
362
|
-
|
|
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 ??
|
|
372
|
-
id: sseMeta?.id ?? String(eventId++),
|
|
373
|
-
...(sseMeta?.retry !== undefined && {
|
|
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
|
-
//
|
|
412
|
-
|
|
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?.
|
|
415
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|