ts-procedures 5.16.0 → 6.0.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/README.md +2 -0
- package/agent_config/claude-code/agents/ts-procedures-architect.md +13 -6
- package/agent_config/claude-code/skills/ts-procedures/SKILL.md +26 -4
- package/agent_config/claude-code/skills/ts-procedures/anti-patterns.md +87 -19
- package/agent_config/claude-code/skills/ts-procedures/api-reference.md +162 -16
- package/agent_config/claude-code/skills/ts-procedures/patterns.md +179 -16
- package/agent_config/claude-code/skills/ts-procedures-review/SKILL.md +1 -1
- package/agent_config/claude-code/skills/ts-procedures-review/checklist.md +20 -12
- package/agent_config/claude-code/skills/ts-procedures-scaffold/SKILL.md +2 -1
- package/agent_config/claude-code/skills/ts-procedures-scaffold/templates/client.md +22 -15
- package/agent_config/claude-code/skills/ts-procedures-scaffold/templates/express-rpc.md +20 -17
- package/agent_config/claude-code/skills/ts-procedures-scaffold/templates/hono-api.md +20 -16
- package/agent_config/claude-code/skills/ts-procedures-scaffold/templates/hono-rpc.md +20 -17
- package/agent_config/claude-code/skills/ts-procedures-scaffold/templates/hono-stream.md +16 -3
- package/agent_config/copilot/copilot-instructions.md +78 -12
- package/agent_config/cursor/cursorrules +78 -12
- package/build/client/call.d.ts +2 -1
- package/build/client/call.js +9 -1
- package/build/client/call.js.map +1 -1
- package/build/client/error-dispatch.d.ts +13 -0
- package/build/client/error-dispatch.js +26 -0
- package/build/client/error-dispatch.js.map +1 -0
- package/build/client/error-dispatch.test.d.ts +1 -0
- package/build/client/error-dispatch.test.js +56 -0
- package/build/client/error-dispatch.test.js.map +1 -0
- package/build/client/fetch-adapter.js +10 -4
- package/build/client/fetch-adapter.js.map +1 -1
- package/build/client/index.d.ts +2 -1
- package/build/client/index.js +5 -1
- package/build/client/index.js.map +1 -1
- package/build/client/stream.d.ts +2 -1
- package/build/client/stream.js +13 -3
- package/build/client/stream.js.map +1 -1
- package/build/client/typed-error-dispatch.test.d.ts +1 -0
- package/build/client/typed-error-dispatch.test.js +168 -0
- package/build/client/typed-error-dispatch.test.js.map +1 -0
- package/build/client/types.d.ts +37 -0
- package/build/codegen/e2e.test.js +9 -4
- package/build/codegen/e2e.test.js.map +1 -1
- package/build/codegen/emit-client-runtime.js +4 -0
- package/build/codegen/emit-client-runtime.js.map +1 -1
- package/build/codegen/emit-errors.d.ts +17 -6
- package/build/codegen/emit-errors.integration.test.d.ts +1 -0
- package/build/codegen/emit-errors.integration.test.js +162 -0
- package/build/codegen/emit-errors.integration.test.js.map +1 -0
- package/build/codegen/emit-errors.js +50 -39
- package/build/codegen/emit-errors.js.map +1 -1
- package/build/codegen/emit-errors.test.js +75 -78
- package/build/codegen/emit-errors.test.js.map +1 -1
- package/build/codegen/emit-index.d.ts +7 -0
- package/build/codegen/emit-index.js +26 -4
- package/build/codegen/emit-index.js.map +1 -1
- package/build/codegen/emit-index.test.js +55 -23
- package/build/codegen/emit-index.test.js.map +1 -1
- package/build/codegen/emit-scope.d.ts +8 -0
- package/build/codegen/emit-scope.js +82 -7
- package/build/codegen/emit-scope.js.map +1 -1
- package/build/codegen/pipeline.js +22 -2
- package/build/codegen/pipeline.js.map +1 -1
- package/build/implementations/http/doc-registry.d.ts +17 -1
- package/build/implementations/http/doc-registry.js +47 -79
- package/build/implementations/http/doc-registry.js.map +1 -1
- package/build/implementations/http/doc-registry.test.js +149 -16
- package/build/implementations/http/doc-registry.test.js.map +1 -1
- package/build/implementations/http/error-taxonomy.d.ts +249 -0
- package/build/implementations/http/error-taxonomy.js +252 -0
- package/build/implementations/http/error-taxonomy.js.map +1 -0
- package/build/implementations/http/error-taxonomy.test.d.ts +1 -0
- package/build/implementations/http/error-taxonomy.test.js +399 -0
- package/build/implementations/http/error-taxonomy.test.js.map +1 -0
- package/build/implementations/http/express-rpc/error-taxonomy.test.d.ts +1 -0
- package/build/implementations/http/express-rpc/error-taxonomy.test.js +83 -0
- package/build/implementations/http/express-rpc/error-taxonomy.test.js.map +1 -0
- package/build/implementations/http/express-rpc/index.d.ts +39 -8
- package/build/implementations/http/express-rpc/index.js +39 -8
- package/build/implementations/http/express-rpc/index.js.map +1 -1
- package/build/implementations/http/hono-api/error-taxonomy.test.d.ts +1 -0
- package/build/implementations/http/hono-api/error-taxonomy.test.js +137 -0
- package/build/implementations/http/hono-api/error-taxonomy.test.js.map +1 -0
- package/build/implementations/http/hono-api/index.d.ts +38 -1
- package/build/implementations/http/hono-api/index.js +32 -0
- package/build/implementations/http/hono-api/index.js.map +1 -1
- package/build/implementations/http/hono-rpc/error-taxonomy.test.d.ts +1 -0
- package/build/implementations/http/hono-rpc/error-taxonomy.test.js +64 -0
- package/build/implementations/http/hono-rpc/error-taxonomy.test.js.map +1 -0
- package/build/implementations/http/hono-rpc/index.d.ts +34 -7
- package/build/implementations/http/hono-rpc/index.js +31 -4
- package/build/implementations/http/hono-rpc/index.js.map +1 -1
- package/build/implementations/http/hono-stream/error-taxonomy.test.d.ts +1 -0
- package/build/implementations/http/hono-stream/error-taxonomy.test.js +87 -0
- package/build/implementations/http/hono-stream/error-taxonomy.test.js.map +1 -0
- package/build/implementations/http/hono-stream/index.d.ts +40 -3
- package/build/implementations/http/hono-stream/index.js +37 -10
- package/build/implementations/http/hono-stream/index.js.map +1 -1
- package/build/implementations/http/hono-stream/index.test.js +45 -18
- package/build/implementations/http/hono-stream/index.test.js.map +1 -1
- package/build/implementations/http/on-request-error.test.d.ts +1 -0
- package/build/implementations/http/on-request-error.test.js +173 -0
- package/build/implementations/http/on-request-error.test.js.map +1 -0
- package/build/implementations/http/route-errors.test.d.ts +1 -0
- package/build/implementations/http/route-errors.test.js +139 -0
- package/build/implementations/http/route-errors.test.js.map +1 -0
- package/build/implementations/types.d.ts +43 -3
- package/docs/client-and-codegen.md +105 -12
- package/docs/core.md +14 -5
- package/docs/http-integrations.md +138 -5
- package/docs/streaming.md +3 -1
- package/docs/superpowers/plans/2026-04-24-doc-registry-simplification.md +886 -0
- package/package.json +7 -2
- package/src/client/call.ts +10 -1
- package/src/client/error-dispatch.test.ts +72 -0
- package/src/client/error-dispatch.ts +27 -0
- package/src/client/fetch-adapter.ts +11 -5
- package/src/client/index.ts +9 -0
- package/src/client/stream.ts +14 -3
- package/src/client/typed-error-dispatch.test.ts +211 -0
- package/src/client/types.ts +42 -0
- package/src/codegen/e2e.test.ts +9 -4
- package/src/codegen/emit-client-runtime.ts +4 -0
- package/src/codegen/emit-errors.integration.test.ts +183 -0
- package/src/codegen/emit-errors.test.ts +91 -87
- package/src/codegen/emit-errors.ts +123 -41
- package/src/codegen/emit-index.test.ts +68 -24
- package/src/codegen/emit-index.ts +66 -4
- package/src/codegen/emit-scope.ts +124 -7
- package/src/codegen/pipeline.ts +25 -2
- package/src/implementations/http/README.md +21 -7
- package/src/implementations/http/doc-registry.test.ts +164 -16
- package/src/implementations/http/doc-registry.ts +58 -82
- package/src/implementations/http/error-taxonomy.test.ts +438 -0
- package/src/implementations/http/error-taxonomy.ts +361 -0
- package/src/implementations/http/express-rpc/README.md +23 -24
- package/src/implementations/http/express-rpc/error-taxonomy.test.ts +103 -0
- package/src/implementations/http/express-rpc/index.ts +75 -14
- package/src/implementations/http/hono-api/README.md +284 -0
- package/src/implementations/http/hono-api/error-taxonomy.test.ts +179 -0
- package/src/implementations/http/hono-api/index.ts +76 -1
- package/src/implementations/http/hono-rpc/README.md +20 -21
- package/src/implementations/http/hono-rpc/error-taxonomy.test.ts +82 -0
- package/src/implementations/http/hono-rpc/index.ts +65 -9
- package/src/implementations/http/hono-stream/README.md +44 -25
- package/src/implementations/http/hono-stream/error-taxonomy.test.ts +98 -0
- package/src/implementations/http/hono-stream/index.test.ts +54 -18
- package/src/implementations/http/hono-stream/index.ts +83 -13
- package/src/implementations/http/on-request-error.test.ts +201 -0
- package/src/implementations/http/route-errors.test.ts +176 -0
- package/src/implementations/types.ts +43 -3
|
@@ -34,9 +34,12 @@ describe('emitIndexFile', () => {
|
|
|
34
34
|
expect(output).toContain('// Auto-generated by ts-procedures-codegen — do not edit')
|
|
35
35
|
})
|
|
36
36
|
|
|
37
|
-
it('imports
|
|
37
|
+
it('imports createClient as a value and client types for the factory', () => {
|
|
38
38
|
const output = emitIndexFile([usersGroup])
|
|
39
|
-
expect(output).toContain("import
|
|
39
|
+
expect(output).toContain("import { createClient } from 'ts-procedures/client'")
|
|
40
|
+
expect(output).toContain(
|
|
41
|
+
"import type { ClientInstance, CreateClientConfig } from 'ts-procedures/client'"
|
|
42
|
+
)
|
|
40
43
|
})
|
|
41
44
|
|
|
42
45
|
it('imports each scope as a namespace using an underscore-prefixed alias', () => {
|
|
@@ -55,27 +58,35 @@ describe('emitIndexFile', () => {
|
|
|
55
58
|
expect(output).toContain("import * as _adminUsers from './admin-users'")
|
|
56
59
|
})
|
|
57
60
|
|
|
58
|
-
it('generates the factory using the default service name (Api)', () => {
|
|
61
|
+
it('generates the bindings factory using the default service name (Api)', () => {
|
|
59
62
|
const output = emitIndexFile([usersGroup, billingGroup])
|
|
60
63
|
expect(output).toContain('export function createApiBindings(client: ClientInstance)')
|
|
61
64
|
expect(output).toContain('users: _users.bindUsersScope(client)')
|
|
62
65
|
expect(output).toContain('billing: _billing.bindBillingScope(client)')
|
|
63
66
|
})
|
|
64
67
|
|
|
68
|
+
it('always emits a createApiClient convenience factory', () => {
|
|
69
|
+
const output = emitIndexFile([usersGroup])
|
|
70
|
+
expect(output).toContain('export function createApiClient(')
|
|
71
|
+
expect(output).toContain('return createClient({')
|
|
72
|
+
expect(output).toContain('scopes: (client) => createApiBindings(client)')
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('createApiClient wires the error registry when hasErrors is true', () => {
|
|
76
|
+
const output = emitIndexFile([usersGroup], { hasErrors: true })
|
|
77
|
+
expect(output).toContain('errorRegistry: _errorsModule.ApiErrorRegistry')
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('createApiClient omits the registry wiring when hasErrors is false', () => {
|
|
81
|
+
const output = emitIndexFile([usersGroup], { hasErrors: false })
|
|
82
|
+
expect(output).not.toContain('errorRegistry:')
|
|
83
|
+
})
|
|
84
|
+
|
|
65
85
|
it('uses camelCase as the binding property key (not the underscored alias)', () => {
|
|
66
86
|
const output = emitIndexFile([adminUsersGroup])
|
|
67
87
|
expect(output).toContain('adminUsers: _adminUsers.bindAdminUsersScope(client)')
|
|
68
88
|
})
|
|
69
89
|
|
|
70
|
-
it('places imports before the namespace block before the factory', () => {
|
|
71
|
-
const output = emitIndexFile([usersGroup, billingGroup], { namespaceTypes: true })
|
|
72
|
-
const importIdx = output.indexOf("import * as _users")
|
|
73
|
-
const namespaceIdx = output.indexOf('export namespace Api')
|
|
74
|
-
const factoryIdx = output.indexOf('export function createApiBindings')
|
|
75
|
-
expect(importIdx).toBeLessThan(namespaceIdx)
|
|
76
|
-
expect(namespaceIdx).toBeLessThan(factoryIdx)
|
|
77
|
-
})
|
|
78
|
-
|
|
79
90
|
describe('service namespace (namespaceTypes: true)', () => {
|
|
80
91
|
it('emits a service namespace named after the default Api', () => {
|
|
81
92
|
const output = emitIndexFile([usersGroup, billingGroup], { namespaceTypes: true })
|
|
@@ -85,10 +96,14 @@ describe('emitIndexFile', () => {
|
|
|
85
96
|
})
|
|
86
97
|
|
|
87
98
|
it('emits a service namespace named after a provided serviceName', () => {
|
|
88
|
-
const output = emitIndexFile([usersGroup], {
|
|
99
|
+
const output = emitIndexFile([usersGroup], {
|
|
100
|
+
namespaceTypes: true,
|
|
101
|
+
serviceName: 'UsersApi',
|
|
102
|
+
})
|
|
89
103
|
expect(output).toContain('export namespace UsersApi {')
|
|
90
104
|
expect(output).toContain('export import Users = _users.Users')
|
|
91
105
|
expect(output).toContain('export function createUsersApiBindings(client: ClientInstance)')
|
|
106
|
+
expect(output).toContain('export function createUsersApiClient(')
|
|
92
107
|
})
|
|
93
108
|
|
|
94
109
|
it('PascalCases each scope when re-exporting', () => {
|
|
@@ -96,10 +111,19 @@ describe('emitIndexFile', () => {
|
|
|
96
111
|
expect(output).toContain('export import AdminUsers = _adminUsers.AdminUsers')
|
|
97
112
|
})
|
|
98
113
|
|
|
99
|
-
it('PascalCases a kebab-case serviceName for both namespace and
|
|
100
|
-
const output = emitIndexFile([usersGroup], {
|
|
114
|
+
it('PascalCases a kebab-case serviceName for both namespace and factories', () => {
|
|
115
|
+
const output = emitIndexFile([usersGroup], {
|
|
116
|
+
namespaceTypes: true,
|
|
117
|
+
serviceName: 'auth-service',
|
|
118
|
+
hasErrors: true,
|
|
119
|
+
})
|
|
101
120
|
expect(output).toContain('export namespace AuthService {')
|
|
102
121
|
expect(output).toContain('export function createAuthServiceBindings(client: ClientInstance)')
|
|
122
|
+
expect(output).toContain('export function createAuthServiceClient(')
|
|
123
|
+
// In namespace mode the registry lives inside the errors namespace.
|
|
124
|
+
expect(output).toContain(
|
|
125
|
+
'errorRegistry: _errorsModule.AuthServiceErrors.AuthServiceErrorRegistry'
|
|
126
|
+
)
|
|
103
127
|
})
|
|
104
128
|
|
|
105
129
|
it('folds errors into the service namespace as `Errors` when hasErrors is true', () => {
|
|
@@ -112,10 +136,9 @@ describe('emitIndexFile', () => {
|
|
|
112
136
|
expect(output).toContain('export import Errors = _errorsModule.UsersApiErrors')
|
|
113
137
|
})
|
|
114
138
|
|
|
115
|
-
it('omits the errors
|
|
139
|
+
it('omits the errors re-export from the namespace when hasErrors is false', () => {
|
|
116
140
|
const output = emitIndexFile([usersGroup], { namespaceTypes: true, hasErrors: false })
|
|
117
|
-
expect(output).not.toContain("from './_errors'")
|
|
118
|
-
expect(output).not.toContain('Errors')
|
|
141
|
+
expect(output).not.toContain("import * as _errorsModule from './_errors'")
|
|
119
142
|
})
|
|
120
143
|
})
|
|
121
144
|
|
|
@@ -126,29 +149,50 @@ describe('emitIndexFile', () => {
|
|
|
126
149
|
expect(output).not.toContain('export import')
|
|
127
150
|
})
|
|
128
151
|
|
|
129
|
-
it('still imports scopes and emits
|
|
152
|
+
it('still imports scopes and emits both factories', () => {
|
|
130
153
|
const output = emitIndexFile([usersGroup, billingGroup])
|
|
131
154
|
expect(output).toContain("import * as _users from './users'")
|
|
132
155
|
expect(output).toContain('export function createApiBindings(client: ClientInstance)')
|
|
133
|
-
expect(output).toContain('
|
|
156
|
+
expect(output).toContain('export function createApiClient(')
|
|
134
157
|
})
|
|
135
158
|
|
|
136
|
-
it('
|
|
159
|
+
it('imports _errors as a value when hasErrors, regardless of namespaceTypes', () => {
|
|
137
160
|
const output = emitIndexFile([usersGroup], { hasErrors: true })
|
|
161
|
+
// Registry is a runtime value, so the import is NOT `import type`.
|
|
162
|
+
expect(output).toContain("import * as _errorsModule from './_errors'")
|
|
163
|
+
// In flat mode the registry is a top-level export of the module.
|
|
164
|
+
expect(output).toContain('errorRegistry: _errorsModule.ApiErrorRegistry')
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
it('qualifies the registry path through the errors namespace in namespace mode', () => {
|
|
168
|
+
const output = emitIndexFile([usersGroup], {
|
|
169
|
+
hasErrors: true,
|
|
170
|
+
namespaceTypes: true,
|
|
171
|
+
})
|
|
172
|
+
expect(output).toContain(
|
|
173
|
+
'errorRegistry: _errorsModule.ApiErrors.ApiErrorRegistry'
|
|
174
|
+
)
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it('omits the _errors import when hasErrors is false', () => {
|
|
178
|
+
const output = emitIndexFile([usersGroup], { hasErrors: false })
|
|
138
179
|
expect(output).not.toContain("from './_errors'")
|
|
139
180
|
})
|
|
140
181
|
})
|
|
141
182
|
|
|
142
183
|
describe('clientImportPath', () => {
|
|
143
|
-
it('uses custom clientImportPath in import
|
|
184
|
+
it('uses custom clientImportPath in both import statements', () => {
|
|
144
185
|
const output = emitIndexFile([usersGroup], { clientImportPath: '@my-app/client' })
|
|
145
|
-
expect(output).toContain("import
|
|
186
|
+
expect(output).toContain("import { createClient } from '@my-app/client'")
|
|
187
|
+
expect(output).toContain(
|
|
188
|
+
"import type { ClientInstance, CreateClientConfig } from '@my-app/client'"
|
|
189
|
+
)
|
|
146
190
|
expect(output).not.toContain("from 'ts-procedures/client'")
|
|
147
191
|
})
|
|
148
192
|
|
|
149
193
|
it('defaults to ts-procedures/client when not specified', () => {
|
|
150
194
|
const output = emitIndexFile([usersGroup])
|
|
151
|
-
expect(output).toContain("import
|
|
195
|
+
expect(output).toContain("import { createClient } from 'ts-procedures/client'")
|
|
152
196
|
})
|
|
153
197
|
})
|
|
154
198
|
})
|
|
@@ -9,7 +9,14 @@ const ERRORS_MODULE_ALIAS = '_errorsModule'
|
|
|
9
9
|
// ---------------------------------------------------------------------------
|
|
10
10
|
|
|
11
11
|
export interface EmitIndexOptions {
|
|
12
|
+
/** Import specifier for types (`ClientInstance`, `CreateClientConfig`). */
|
|
12
13
|
clientImportPath?: string
|
|
14
|
+
/**
|
|
15
|
+
* Import specifier for runtime values (`createClient`). Defaults to
|
|
16
|
+
* `clientImportPath`. In self-contained mode the pipeline sets this to
|
|
17
|
+
* `./_client` while `clientImportPath` points at `./_types`.
|
|
18
|
+
*/
|
|
19
|
+
clientRuntimeImportPath?: string
|
|
13
20
|
hasErrors?: boolean
|
|
14
21
|
namespaceTypes?: boolean
|
|
15
22
|
serviceName?: string
|
|
@@ -30,10 +37,13 @@ export function emitIndexFile(groups: ScopeGroup[], options?: EmitIndexOptions):
|
|
|
30
37
|
namespaceTypes = false,
|
|
31
38
|
serviceName = 'Api',
|
|
32
39
|
} = options ?? {}
|
|
40
|
+
const clientRuntimeImportPath = options?.clientRuntimeImportPath ?? clientImportPath
|
|
33
41
|
|
|
34
42
|
const servicePascal = toPascalCase(serviceName)
|
|
35
43
|
const factoryName = `create${servicePascal}Bindings`
|
|
44
|
+
const clientFactoryName = `create${servicePascal}Client`
|
|
36
45
|
const errorsExportedName = `${servicePascal}Errors`
|
|
46
|
+
const registryName = `${servicePascal}ErrorRegistry`
|
|
37
47
|
|
|
38
48
|
// Underscore-prefix the local module alias so scope names that collide with
|
|
39
49
|
// TypeScript reserved words (e.g. `public`, `default`, `class`) compile.
|
|
@@ -43,7 +53,9 @@ export function emitIndexFile(groups: ScopeGroup[], options?: EmitIndexOptions):
|
|
|
43
53
|
.map((g) => `import * as ${localAlias(g.camelCase)} from './${g.scopeKey}'`)
|
|
44
54
|
.join('\n')
|
|
45
55
|
|
|
46
|
-
|
|
56
|
+
// `_errors` is imported as a value (not `import type`) when errors exist, so
|
|
57
|
+
// the runtime registry is reachable for `createApiClient`.
|
|
58
|
+
const errorsImport = hasErrors
|
|
47
59
|
? `import * as ${ERRORS_MODULE_ALIAS} from './_errors'`
|
|
48
60
|
: ''
|
|
49
61
|
|
|
@@ -69,9 +81,10 @@ export function emitIndexFile(groups: ScopeGroup[], options?: EmitIndexOptions):
|
|
|
69
81
|
.map((g) => ` ${g.camelCase}: ${localAlias(g.camelCase)}.bind${toPascalCase(g.camelCase)}Scope(client),`)
|
|
70
82
|
.join('\n')
|
|
71
83
|
|
|
72
|
-
|
|
84
|
+
const pieces: string[] = [
|
|
73
85
|
CODEGEN_HEADER,
|
|
74
|
-
`import
|
|
86
|
+
`import { createClient } from '${clientRuntimeImportPath}'`,
|
|
87
|
+
`import type { ClientInstance, CreateClientConfig } from '${clientImportPath}'`,
|
|
75
88
|
importsBlock,
|
|
76
89
|
'',
|
|
77
90
|
namespaceBlock,
|
|
@@ -81,5 +94,54 @@ export function emitIndexFile(groups: ScopeGroup[], options?: EmitIndexOptions):
|
|
|
81
94
|
' }',
|
|
82
95
|
'}',
|
|
83
96
|
'',
|
|
84
|
-
]
|
|
97
|
+
]
|
|
98
|
+
|
|
99
|
+
// `createApiClient` is a convenience wrapper that wires `createClient` with
|
|
100
|
+
// the generated error registry baked in, then invokes the bindings factory.
|
|
101
|
+
// Consumers that want manual control over `createClient` still use the
|
|
102
|
+
// factory above.
|
|
103
|
+
if (hasErrors) {
|
|
104
|
+
// In namespace mode, classes + registry live inside `${Service}Errors`;
|
|
105
|
+
// in flat mode they're at the module's top level.
|
|
106
|
+
const registryPath = namespaceTypes
|
|
107
|
+
? `${ERRORS_MODULE_ALIAS}.${errorsExportedName}.${registryName}`
|
|
108
|
+
: `${ERRORS_MODULE_ALIAS}.${registryName}`
|
|
109
|
+
|
|
110
|
+
pieces.push(
|
|
111
|
+
`/**`,
|
|
112
|
+
` * Creates a typed client for this service with the generated error`,
|
|
113
|
+
` * registry pre-configured. Non-2xx responses whose body \`name\` matches`,
|
|
114
|
+
` * a registered error are thrown as typed class instances instead of`,
|
|
115
|
+
` * generic \`ClientRequestError\`s.`,
|
|
116
|
+
` */`,
|
|
117
|
+
`export function ${clientFactoryName}(`,
|
|
118
|
+
` config: Omit<CreateClientConfig<ReturnType<typeof ${factoryName}>>, 'scopes' | 'errorRegistry'>`,
|
|
119
|
+
`) {`,
|
|
120
|
+
` return createClient({`,
|
|
121
|
+
` ...config,`,
|
|
122
|
+
` errorRegistry: ${registryPath},`,
|
|
123
|
+
` scopes: (client) => ${factoryName}(client),`,
|
|
124
|
+
` })`,
|
|
125
|
+
`}`,
|
|
126
|
+
'',
|
|
127
|
+
)
|
|
128
|
+
} else {
|
|
129
|
+
pieces.push(
|
|
130
|
+
`/**`,
|
|
131
|
+
` * Creates a typed client for this service. No error registry is`,
|
|
132
|
+
` * attached because the DocEnvelope contained no typed errors.`,
|
|
133
|
+
` */`,
|
|
134
|
+
`export function ${clientFactoryName}(`,
|
|
135
|
+
` config: Omit<CreateClientConfig<ReturnType<typeof ${factoryName}>>, 'scopes'>`,
|
|
136
|
+
`) {`,
|
|
137
|
+
` return createClient({`,
|
|
138
|
+
` ...config,`,
|
|
139
|
+
` scopes: (client) => ${factoryName}(client),`,
|
|
140
|
+
` })`,
|
|
141
|
+
`}`,
|
|
142
|
+
'',
|
|
143
|
+
)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return pieces.join('\n')
|
|
85
147
|
}
|
|
@@ -22,18 +22,30 @@ export interface EmitScopeOptions {
|
|
|
22
22
|
ajsc?: AjscOptions
|
|
23
23
|
clientImportPath?: string
|
|
24
24
|
namespaceTypes?: boolean
|
|
25
|
+
/** Service identifier used to namespace generated error types (defaults to 'Api'). */
|
|
26
|
+
serviceName?: string
|
|
27
|
+
/**
|
|
28
|
+
* Error keys present in the generated `_errors.ts`. Routes may list keys
|
|
29
|
+
* that aren't emitted (e.g. no schema); those are filtered out at emit time
|
|
30
|
+
* so generated code never references undefined types.
|
|
31
|
+
*/
|
|
32
|
+
errorKeys?: Set<string>
|
|
25
33
|
}
|
|
26
34
|
|
|
27
35
|
interface RouteChunks {
|
|
28
36
|
typeDeclarations: string[]
|
|
29
37
|
callable: string
|
|
30
38
|
hasStream: boolean
|
|
39
|
+
/** True when this route emitted an `Errors` type (drives the `_errors` import at the top of the scope file). */
|
|
40
|
+
hasErrors: boolean
|
|
31
41
|
}
|
|
32
42
|
|
|
33
43
|
interface EmitRouteContext {
|
|
34
44
|
ajsc?: AjscOptions
|
|
35
45
|
namespaceTypes: boolean
|
|
36
46
|
scopePascal: string
|
|
47
|
+
serviceName: string
|
|
48
|
+
errorKeys?: Set<string>
|
|
37
49
|
}
|
|
38
50
|
|
|
39
51
|
// ---------------------------------------------------------------------------
|
|
@@ -162,6 +174,62 @@ async function formatTypes(
|
|
|
162
174
|
return { declarations, refs }
|
|
163
175
|
}
|
|
164
176
|
|
|
177
|
+
// ---------------------------------------------------------------------------
|
|
178
|
+
// Route-level Errors union injection
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Builds the body of an `Errors` type union from the route's declared error
|
|
183
|
+
* keys. Filters to keys actually emitted in `_errors.ts` so generated code
|
|
184
|
+
* never references undefined types.
|
|
185
|
+
*
|
|
186
|
+
* In namespace mode the union uses qualified names (`ApiErrors.UseCaseError`);
|
|
187
|
+
* in flat mode it uses the bundled wildcard import alias (`_errors.UseCaseError`).
|
|
188
|
+
* Returns `null` when no keys remain.
|
|
189
|
+
*/
|
|
190
|
+
function buildErrorUnion(
|
|
191
|
+
routeErrors: string[] | undefined,
|
|
192
|
+
ctx: EmitRouteContext
|
|
193
|
+
): string | null {
|
|
194
|
+
if (!routeErrors || routeErrors.length === 0) return null
|
|
195
|
+
const available = ctx.errorKeys
|
|
196
|
+
const filtered = available ? routeErrors.filter((k) => available.has(k)) : routeErrors
|
|
197
|
+
if (filtered.length === 0) return null
|
|
198
|
+
const qualify = ctx.namespaceTypes
|
|
199
|
+
? (k: string) => `${toPascalCase(ctx.serviceName)}Errors.${k}`
|
|
200
|
+
: (k: string) => `_errors.${k}`
|
|
201
|
+
return filtered.map(qualify).join(' | ')
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Injects `export type Errors = ...` into an existing route namespace block
|
|
206
|
+
* (namespace mode) or appends a flat `export type ${pascal}Errors = ...` in
|
|
207
|
+
* flat mode. Mutates the `declarations` array in place and returns whether an
|
|
208
|
+
* injection happened.
|
|
209
|
+
*/
|
|
210
|
+
function injectRouteErrors(
|
|
211
|
+
declarations: string[],
|
|
212
|
+
routePascal: string,
|
|
213
|
+
errorUnion: string | null,
|
|
214
|
+
namespaceTypes: boolean
|
|
215
|
+
): boolean {
|
|
216
|
+
if (!errorUnion) return false
|
|
217
|
+
if (namespaceTypes) {
|
|
218
|
+
const lastIdx = declarations.length - 1
|
|
219
|
+
if (lastIdx < 0) return false
|
|
220
|
+
const lastDecl = declarations[lastIdx]!
|
|
221
|
+
const closingIdx = lastDecl.lastIndexOf(' }')
|
|
222
|
+
if (closingIdx === -1) return false
|
|
223
|
+
declarations[lastIdx] =
|
|
224
|
+
lastDecl.slice(0, closingIdx) +
|
|
225
|
+
` export type Errors = ${errorUnion}\n` +
|
|
226
|
+
lastDecl.slice(closingIdx)
|
|
227
|
+
return true
|
|
228
|
+
}
|
|
229
|
+
declarations.push(`export type ${routePascal}Errors = ${errorUnion}`)
|
|
230
|
+
return true
|
|
231
|
+
}
|
|
232
|
+
|
|
165
233
|
// ---------------------------------------------------------------------------
|
|
166
234
|
// Route emitters
|
|
167
235
|
// ---------------------------------------------------------------------------
|
|
@@ -192,7 +260,14 @@ async function emitRpcRoute(route: RPCHttpRouteDoc, ctx: EmitRouteContext): Prom
|
|
|
192
260
|
` },`,
|
|
193
261
|
].join('\n')
|
|
194
262
|
|
|
195
|
-
|
|
263
|
+
const hasErrors = injectRouteErrors(
|
|
264
|
+
declarations,
|
|
265
|
+
pascal,
|
|
266
|
+
buildErrorUnion(route.errors, ctx),
|
|
267
|
+
ctx.namespaceTypes
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
return { typeDeclarations: declarations, callable, hasStream: false, hasErrors }
|
|
196
271
|
}
|
|
197
272
|
|
|
198
273
|
async function emitApiRoute(route: APIHttpRouteDoc, ctx: EmitRouteContext): Promise<RouteChunks> {
|
|
@@ -262,7 +337,14 @@ async function emitApiRoute(route: APIHttpRouteDoc, ctx: EmitRouteContext): Prom
|
|
|
262
337
|
` },`,
|
|
263
338
|
].join('\n')
|
|
264
339
|
|
|
265
|
-
|
|
340
|
+
const hasErrors = injectRouteErrors(
|
|
341
|
+
declarations,
|
|
342
|
+
pascal,
|
|
343
|
+
buildErrorUnion(route.errors, ctx),
|
|
344
|
+
ctx.namespaceTypes
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
return { typeDeclarations: declarations, callable, hasStream: false, hasErrors }
|
|
266
348
|
}
|
|
267
349
|
|
|
268
350
|
async function emitStreamRoute(route: StreamHttpRouteDoc, ctx: EmitRouteContext): Promise<RouteChunks> {
|
|
@@ -300,7 +382,14 @@ async function emitStreamRoute(route: StreamHttpRouteDoc, ctx: EmitRouteContext)
|
|
|
300
382
|
` },`,
|
|
301
383
|
].join('\n')
|
|
302
384
|
|
|
303
|
-
|
|
385
|
+
const hasErrors = injectRouteErrors(
|
|
386
|
+
declarations,
|
|
387
|
+
pascal,
|
|
388
|
+
buildErrorUnion(route.errors, ctx),
|
|
389
|
+
ctx.namespaceTypes
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
return { typeDeclarations: declarations, callable, hasStream: true, hasErrors }
|
|
304
393
|
}
|
|
305
394
|
|
|
306
395
|
// ---------------------------------------------------------------------------
|
|
@@ -318,14 +407,27 @@ export async function emitScopeFile(
|
|
|
318
407
|
group: ScopeGroup,
|
|
319
408
|
options?: EmitScopeOptions,
|
|
320
409
|
): Promise<string> {
|
|
321
|
-
const {
|
|
410
|
+
const {
|
|
411
|
+
ajsc: ajscOpts,
|
|
412
|
+
clientImportPath = 'ts-procedures/client',
|
|
413
|
+
namespaceTypes = false,
|
|
414
|
+
serviceName = 'Api',
|
|
415
|
+
errorKeys,
|
|
416
|
+
} = options ?? {}
|
|
322
417
|
|
|
323
418
|
const pascal = toPascalCase(group.camelCase)
|
|
324
|
-
const ctx: EmitRouteContext = {
|
|
419
|
+
const ctx: EmitRouteContext = {
|
|
420
|
+
ajsc: ajscOpts,
|
|
421
|
+
namespaceTypes,
|
|
422
|
+
scopePascal: pascal,
|
|
423
|
+
serviceName,
|
|
424
|
+
errorKeys,
|
|
425
|
+
}
|
|
325
426
|
|
|
326
427
|
const allTypeDeclarations: string[] = []
|
|
327
428
|
const callables: string[] = []
|
|
328
429
|
let hasStream = false
|
|
430
|
+
let scopeHasErrors = false
|
|
329
431
|
|
|
330
432
|
for (const route of group.routes) {
|
|
331
433
|
let chunks: RouteChunks
|
|
@@ -351,13 +453,28 @@ export async function emitScopeFile(
|
|
|
351
453
|
allTypeDeclarations.push(...chunks.typeDeclarations)
|
|
352
454
|
callables.push(chunks.callable)
|
|
353
455
|
if (chunks.hasStream) hasStream = true
|
|
456
|
+
if (chunks.hasErrors) scopeHasErrors = true
|
|
354
457
|
}
|
|
355
458
|
|
|
356
|
-
// Build import line
|
|
459
|
+
// Build client import line
|
|
357
460
|
const clientImports = hasStream
|
|
358
461
|
? `import type { ClientInstance, ProcedureCallOptions, TypedStream } from '${clientImportPath}'`
|
|
359
462
|
: `import type { ClientInstance, ProcedureCallOptions } from '${clientImportPath}'`
|
|
360
463
|
|
|
464
|
+
// Build _errors import line when at least one route emits an Errors union.
|
|
465
|
+
// Namespace mode uses the qualified `${Service}Errors` namespace; flat mode
|
|
466
|
+
// pulls classes in via a wildcard alias (`_errors.UseCaseError`).
|
|
467
|
+
let errorsImport = ''
|
|
468
|
+
if (scopeHasErrors) {
|
|
469
|
+
if (namespaceTypes) {
|
|
470
|
+
errorsImport = `import type { ${toPascalCase(serviceName)}Errors } from './_errors'`
|
|
471
|
+
} else {
|
|
472
|
+
errorsImport = `import type * as _errors from './_errors'`
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
const importsBlock = [clientImports, errorsImport].filter(Boolean).join('\n')
|
|
477
|
+
|
|
361
478
|
let typesBlock: string
|
|
362
479
|
if (namespaceTypes && allTypeDeclarations.length > 0) {
|
|
363
480
|
typesBlock = `export namespace ${pascal} {\n${allTypeDeclarations.join('\n\n')}\n}\n`
|
|
@@ -372,7 +489,7 @@ export async function emitScopeFile(
|
|
|
372
489
|
|
|
373
490
|
return [
|
|
374
491
|
CODEGEN_HEADER,
|
|
375
|
-
|
|
492
|
+
importsBlock,
|
|
376
493
|
'',
|
|
377
494
|
'// ── Types ────────────────────────────────────────',
|
|
378
495
|
'',
|
package/src/codegen/pipeline.ts
CHANGED
|
@@ -43,6 +43,13 @@ export async function runPipeline(options: PipelineOptions): Promise<GeneratedFi
|
|
|
43
43
|
const groups = groupRoutesByScope(envelope.routes)
|
|
44
44
|
const groupArray = Array.from(groups.values())
|
|
45
45
|
|
|
46
|
+
// Error keys that will be emitted in `_errors.ts` — only those with a schema.
|
|
47
|
+
// Scope emit uses this to filter `route.errors` so generated code never
|
|
48
|
+
// references an undefined error type.
|
|
49
|
+
const errorKeys = new Set(
|
|
50
|
+
envelope.errors.filter((e) => e.schema != null).map((e) => e.name)
|
|
51
|
+
)
|
|
52
|
+
|
|
46
53
|
if (selfContained) {
|
|
47
54
|
for (const group of groupArray) {
|
|
48
55
|
if (group.scopeKey === '_types' || group.scopeKey === '_client') {
|
|
@@ -56,7 +63,13 @@ export async function runPipeline(options: PipelineOptions): Promise<GeneratedFi
|
|
|
56
63
|
const files: GeneratedFile[] = []
|
|
57
64
|
|
|
58
65
|
for (const group of groupArray) {
|
|
59
|
-
const rawCode = await emitScopeFile(group, {
|
|
66
|
+
const rawCode = await emitScopeFile(group, {
|
|
67
|
+
ajsc: ajscOpts,
|
|
68
|
+
clientImportPath,
|
|
69
|
+
namespaceTypes,
|
|
70
|
+
serviceName,
|
|
71
|
+
errorKeys: errorKeys.size > 0 ? errorKeys : undefined,
|
|
72
|
+
})
|
|
60
73
|
const lines = rawCode.split('\n')
|
|
61
74
|
lines.splice(1, 0, hashComment)
|
|
62
75
|
const code = lines.join('\n')
|
|
@@ -72,7 +85,17 @@ export async function runPipeline(options: PipelineOptions): Promise<GeneratedFi
|
|
|
72
85
|
files.push({ path: join(outDir, '_errors.ts'), code: errorsWithHash })
|
|
73
86
|
}
|
|
74
87
|
|
|
75
|
-
|
|
88
|
+
// In self-contained mode types come from `./_types` but the runtime
|
|
89
|
+
// (`createClient`) lives in `./_client`. In regular mode both share the
|
|
90
|
+
// single `clientImportPath` (e.g. `ts-procedures/client`).
|
|
91
|
+
const clientRuntimeImportPath = selfContained ? './_client' : clientImportPath
|
|
92
|
+
const rawIndexCode = emitIndexFile(groupArray, {
|
|
93
|
+
clientImportPath,
|
|
94
|
+
clientRuntimeImportPath,
|
|
95
|
+
hasErrors,
|
|
96
|
+
namespaceTypes,
|
|
97
|
+
serviceName,
|
|
98
|
+
})
|
|
76
99
|
const indexLines = rawIndexCode.split('\n')
|
|
77
100
|
indexLines.splice(1, 0, hashComment)
|
|
78
101
|
const indexCode = indexLines.join('\n')
|
|
@@ -118,6 +118,12 @@ All HTTP implementations automatically inject an `AbortSignal` into the handler
|
|
|
118
118
|
|
|
119
119
|
For streaming procedures, `signal.reason` is `'stream-completed'` on normal completion, allowing handlers to distinguish from client disconnection.
|
|
120
120
|
|
|
121
|
+
### Error Handling
|
|
122
|
+
|
|
123
|
+
All four builders support two peer error-handling modes — **declarative** (`errors` taxonomy + `unknownError`) and **imperative** (`onError` callback) — plus a cross-cutting `onRequestError` observer for logging, tracing, and metrics.
|
|
124
|
+
|
|
125
|
+
Full spec (taxonomy shape, `toResponse`/`onCatch`/`match`, per-route narrowing, mid-stream caveats): **[docs/http-integrations.md § Error Handling](../../../docs/http-integrations.md#error-handling)**.
|
|
126
|
+
|
|
121
127
|
### Lifecycle Hooks
|
|
122
128
|
|
|
123
129
|
**RPC Implementations:**
|
|
@@ -152,7 +158,8 @@ onRequestStart → onStreamStart → [yields...] → onStreamEnd → onRequestEn
|
|
|
152
158
|
| `onError` | RPC, API | On handler error |
|
|
153
159
|
| `onStreamStart` | Stream | Before first yield |
|
|
154
160
|
| `onStreamEnd` | Stream | After stream completes |
|
|
155
|
-
| `
|
|
161
|
+
| `onError` (HonoStream) | Stream | Imperative pre-stream error callback — peer of `errors` taxonomy |
|
|
162
|
+
| `onRequestError` | All | Cross-cutting observer — fires for every caught error before dispatch |
|
|
156
163
|
| `onMidStreamError` | Stream | On mid-stream error (generator throws) |
|
|
157
164
|
|
|
158
165
|
### Route Documentation
|
|
@@ -225,7 +232,7 @@ const app = builder.build()
|
|
|
225
232
|
const docs = builder.docs
|
|
226
233
|
```
|
|
227
234
|
|
|
228
|
-
|
|
235
|
+
All four builders' `build()` methods are synchronous — they return the framework app instance directly. Don't `await` the call.
|
|
229
236
|
|
|
230
237
|
**Key methods:**
|
|
231
238
|
|
|
@@ -250,8 +257,7 @@ import { DocRegistry } from 'ts-procedures/http-docs'
|
|
|
250
257
|
|
|
251
258
|
const docs = new DocRegistry({
|
|
252
259
|
basePath: '/api',
|
|
253
|
-
|
|
254
|
-
errors: DocRegistry.defaultErrors(),
|
|
260
|
+
errors: appErrors, // your ErrorTaxonomy — framework defaults auto-merged and deduped
|
|
255
261
|
})
|
|
256
262
|
.from(rpcBuilder)
|
|
257
263
|
.from(apiBuilder)
|
|
@@ -262,7 +268,7 @@ app.get('/docs', (c) => c.json(docs.toJSON()))
|
|
|
262
268
|
|
|
263
269
|
- `from()` stores a reference — routes are read lazily at `toJSON()` time
|
|
264
270
|
- `toJSON()` supports optional `filter` and `transform` options
|
|
265
|
-
- `
|
|
271
|
+
- `errors` accepts an `ErrorTaxonomy` or raw `ErrorDoc[]`; framework defaults are auto-merged (opt out via `includeDefaults: false`)
|
|
266
272
|
- All builders satisfy the `DocSource` interface (`{ readonly docs: AnyHttpRouteDoc[] }`)
|
|
267
273
|
|
|
268
274
|
### Client Code Generation
|
|
@@ -314,8 +320,16 @@ apiBuilder.register(factory, (c) => ctx, {
|
|
|
314
320
|
## TypeScript Types
|
|
315
321
|
|
|
316
322
|
```typescript
|
|
317
|
-
import {
|
|
318
|
-
|
|
323
|
+
import type {
|
|
324
|
+
RPCConfig,
|
|
325
|
+
RPCHttpRouteDoc,
|
|
326
|
+
StreamHttpRouteDoc,
|
|
327
|
+
StreamMode,
|
|
328
|
+
APIConfig,
|
|
329
|
+
APIHttpRouteDoc,
|
|
330
|
+
APIInput,
|
|
331
|
+
HttpMethod,
|
|
332
|
+
} from 'ts-procedures/http'
|
|
319
333
|
|
|
320
334
|
// Client Runtime
|
|
321
335
|
import { createClient, createFetchAdapter } from 'ts-procedures/client'
|