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.
Files changed (147) hide show
  1. package/README.md +2 -0
  2. package/agent_config/claude-code/agents/ts-procedures-architect.md +13 -6
  3. package/agent_config/claude-code/skills/ts-procedures/SKILL.md +26 -4
  4. package/agent_config/claude-code/skills/ts-procedures/anti-patterns.md +87 -19
  5. package/agent_config/claude-code/skills/ts-procedures/api-reference.md +162 -16
  6. package/agent_config/claude-code/skills/ts-procedures/patterns.md +179 -16
  7. package/agent_config/claude-code/skills/ts-procedures-review/SKILL.md +1 -1
  8. package/agent_config/claude-code/skills/ts-procedures-review/checklist.md +20 -12
  9. package/agent_config/claude-code/skills/ts-procedures-scaffold/SKILL.md +2 -1
  10. package/agent_config/claude-code/skills/ts-procedures-scaffold/templates/client.md +22 -15
  11. package/agent_config/claude-code/skills/ts-procedures-scaffold/templates/express-rpc.md +20 -17
  12. package/agent_config/claude-code/skills/ts-procedures-scaffold/templates/hono-api.md +20 -16
  13. package/agent_config/claude-code/skills/ts-procedures-scaffold/templates/hono-rpc.md +20 -17
  14. package/agent_config/claude-code/skills/ts-procedures-scaffold/templates/hono-stream.md +16 -3
  15. package/agent_config/copilot/copilot-instructions.md +78 -12
  16. package/agent_config/cursor/cursorrules +78 -12
  17. package/build/client/call.d.ts +2 -1
  18. package/build/client/call.js +9 -1
  19. package/build/client/call.js.map +1 -1
  20. package/build/client/error-dispatch.d.ts +13 -0
  21. package/build/client/error-dispatch.js +26 -0
  22. package/build/client/error-dispatch.js.map +1 -0
  23. package/build/client/error-dispatch.test.d.ts +1 -0
  24. package/build/client/error-dispatch.test.js +56 -0
  25. package/build/client/error-dispatch.test.js.map +1 -0
  26. package/build/client/fetch-adapter.js +10 -4
  27. package/build/client/fetch-adapter.js.map +1 -1
  28. package/build/client/index.d.ts +2 -1
  29. package/build/client/index.js +5 -1
  30. package/build/client/index.js.map +1 -1
  31. package/build/client/stream.d.ts +2 -1
  32. package/build/client/stream.js +13 -3
  33. package/build/client/stream.js.map +1 -1
  34. package/build/client/typed-error-dispatch.test.d.ts +1 -0
  35. package/build/client/typed-error-dispatch.test.js +168 -0
  36. package/build/client/typed-error-dispatch.test.js.map +1 -0
  37. package/build/client/types.d.ts +37 -0
  38. package/build/codegen/e2e.test.js +9 -4
  39. package/build/codegen/e2e.test.js.map +1 -1
  40. package/build/codegen/emit-client-runtime.js +4 -0
  41. package/build/codegen/emit-client-runtime.js.map +1 -1
  42. package/build/codegen/emit-errors.d.ts +17 -6
  43. package/build/codegen/emit-errors.integration.test.d.ts +1 -0
  44. package/build/codegen/emit-errors.integration.test.js +162 -0
  45. package/build/codegen/emit-errors.integration.test.js.map +1 -0
  46. package/build/codegen/emit-errors.js +50 -39
  47. package/build/codegen/emit-errors.js.map +1 -1
  48. package/build/codegen/emit-errors.test.js +75 -78
  49. package/build/codegen/emit-errors.test.js.map +1 -1
  50. package/build/codegen/emit-index.d.ts +7 -0
  51. package/build/codegen/emit-index.js +26 -4
  52. package/build/codegen/emit-index.js.map +1 -1
  53. package/build/codegen/emit-index.test.js +55 -23
  54. package/build/codegen/emit-index.test.js.map +1 -1
  55. package/build/codegen/emit-scope.d.ts +8 -0
  56. package/build/codegen/emit-scope.js +82 -7
  57. package/build/codegen/emit-scope.js.map +1 -1
  58. package/build/codegen/pipeline.js +22 -2
  59. package/build/codegen/pipeline.js.map +1 -1
  60. package/build/implementations/http/doc-registry.d.ts +17 -1
  61. package/build/implementations/http/doc-registry.js +47 -79
  62. package/build/implementations/http/doc-registry.js.map +1 -1
  63. package/build/implementations/http/doc-registry.test.js +149 -16
  64. package/build/implementations/http/doc-registry.test.js.map +1 -1
  65. package/build/implementations/http/error-taxonomy.d.ts +249 -0
  66. package/build/implementations/http/error-taxonomy.js +252 -0
  67. package/build/implementations/http/error-taxonomy.js.map +1 -0
  68. package/build/implementations/http/error-taxonomy.test.d.ts +1 -0
  69. package/build/implementations/http/error-taxonomy.test.js +399 -0
  70. package/build/implementations/http/error-taxonomy.test.js.map +1 -0
  71. package/build/implementations/http/express-rpc/error-taxonomy.test.d.ts +1 -0
  72. package/build/implementations/http/express-rpc/error-taxonomy.test.js +83 -0
  73. package/build/implementations/http/express-rpc/error-taxonomy.test.js.map +1 -0
  74. package/build/implementations/http/express-rpc/index.d.ts +39 -8
  75. package/build/implementations/http/express-rpc/index.js +39 -8
  76. package/build/implementations/http/express-rpc/index.js.map +1 -1
  77. package/build/implementations/http/hono-api/error-taxonomy.test.d.ts +1 -0
  78. package/build/implementations/http/hono-api/error-taxonomy.test.js +137 -0
  79. package/build/implementations/http/hono-api/error-taxonomy.test.js.map +1 -0
  80. package/build/implementations/http/hono-api/index.d.ts +38 -1
  81. package/build/implementations/http/hono-api/index.js +32 -0
  82. package/build/implementations/http/hono-api/index.js.map +1 -1
  83. package/build/implementations/http/hono-rpc/error-taxonomy.test.d.ts +1 -0
  84. package/build/implementations/http/hono-rpc/error-taxonomy.test.js +64 -0
  85. package/build/implementations/http/hono-rpc/error-taxonomy.test.js.map +1 -0
  86. package/build/implementations/http/hono-rpc/index.d.ts +34 -7
  87. package/build/implementations/http/hono-rpc/index.js +31 -4
  88. package/build/implementations/http/hono-rpc/index.js.map +1 -1
  89. package/build/implementations/http/hono-stream/error-taxonomy.test.d.ts +1 -0
  90. package/build/implementations/http/hono-stream/error-taxonomy.test.js +87 -0
  91. package/build/implementations/http/hono-stream/error-taxonomy.test.js.map +1 -0
  92. package/build/implementations/http/hono-stream/index.d.ts +40 -3
  93. package/build/implementations/http/hono-stream/index.js +37 -10
  94. package/build/implementations/http/hono-stream/index.js.map +1 -1
  95. package/build/implementations/http/hono-stream/index.test.js +45 -18
  96. package/build/implementations/http/hono-stream/index.test.js.map +1 -1
  97. package/build/implementations/http/on-request-error.test.d.ts +1 -0
  98. package/build/implementations/http/on-request-error.test.js +173 -0
  99. package/build/implementations/http/on-request-error.test.js.map +1 -0
  100. package/build/implementations/http/route-errors.test.d.ts +1 -0
  101. package/build/implementations/http/route-errors.test.js +139 -0
  102. package/build/implementations/http/route-errors.test.js.map +1 -0
  103. package/build/implementations/types.d.ts +43 -3
  104. package/docs/client-and-codegen.md +105 -12
  105. package/docs/core.md +14 -5
  106. package/docs/http-integrations.md +138 -5
  107. package/docs/streaming.md +3 -1
  108. package/docs/superpowers/plans/2026-04-24-doc-registry-simplification.md +886 -0
  109. package/package.json +7 -2
  110. package/src/client/call.ts +10 -1
  111. package/src/client/error-dispatch.test.ts +72 -0
  112. package/src/client/error-dispatch.ts +27 -0
  113. package/src/client/fetch-adapter.ts +11 -5
  114. package/src/client/index.ts +9 -0
  115. package/src/client/stream.ts +14 -3
  116. package/src/client/typed-error-dispatch.test.ts +211 -0
  117. package/src/client/types.ts +42 -0
  118. package/src/codegen/e2e.test.ts +9 -4
  119. package/src/codegen/emit-client-runtime.ts +4 -0
  120. package/src/codegen/emit-errors.integration.test.ts +183 -0
  121. package/src/codegen/emit-errors.test.ts +91 -87
  122. package/src/codegen/emit-errors.ts +123 -41
  123. package/src/codegen/emit-index.test.ts +68 -24
  124. package/src/codegen/emit-index.ts +66 -4
  125. package/src/codegen/emit-scope.ts +124 -7
  126. package/src/codegen/pipeline.ts +25 -2
  127. package/src/implementations/http/README.md +21 -7
  128. package/src/implementations/http/doc-registry.test.ts +164 -16
  129. package/src/implementations/http/doc-registry.ts +58 -82
  130. package/src/implementations/http/error-taxonomy.test.ts +438 -0
  131. package/src/implementations/http/error-taxonomy.ts +361 -0
  132. package/src/implementations/http/express-rpc/README.md +23 -24
  133. package/src/implementations/http/express-rpc/error-taxonomy.test.ts +103 -0
  134. package/src/implementations/http/express-rpc/index.ts +75 -14
  135. package/src/implementations/http/hono-api/README.md +284 -0
  136. package/src/implementations/http/hono-api/error-taxonomy.test.ts +179 -0
  137. package/src/implementations/http/hono-api/index.ts +76 -1
  138. package/src/implementations/http/hono-rpc/README.md +20 -21
  139. package/src/implementations/http/hono-rpc/error-taxonomy.test.ts +82 -0
  140. package/src/implementations/http/hono-rpc/index.ts +65 -9
  141. package/src/implementations/http/hono-stream/README.md +44 -25
  142. package/src/implementations/http/hono-stream/error-taxonomy.test.ts +98 -0
  143. package/src/implementations/http/hono-stream/index.test.ts +54 -18
  144. package/src/implementations/http/hono-stream/index.ts +83 -13
  145. package/src/implementations/http/on-request-error.test.ts +201 -0
  146. package/src/implementations/http/route-errors.test.ts +176 -0
  147. 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 ClientInstance from ts-procedures/client', () => {
37
+ it('imports createClient as a value and client types for the factory', () => {
38
38
  const output = emitIndexFile([usersGroup])
39
- expect(output).toContain("import type { ClientInstance } from 'ts-procedures/client'")
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], { namespaceTypes: true, serviceName: 'UsersApi' })
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 factory', () => {
100
- const output = emitIndexFile([usersGroup], { namespaceTypes: true, serviceName: 'auth-service' })
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 import and re-export when hasErrors is false', () => {
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 the factory', () => {
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('users: _users.bindUsersScope(client)')
156
+ expect(output).toContain('export function createApiClient(')
134
157
  })
135
158
 
136
- it('does not import errors even when hasErrors is true', () => {
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 statement', () => {
184
+ it('uses custom clientImportPath in both import statements', () => {
144
185
  const output = emitIndexFile([usersGroup], { clientImportPath: '@my-app/client' })
145
- expect(output).toContain("import type { ClientInstance } from '@my-app/client'")
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 type { ClientInstance } from 'ts-procedures/client'")
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
- const errorsImport = namespaceTypes && hasErrors
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
- return [
84
+ const pieces: string[] = [
73
85
  CODEGEN_HEADER,
74
- `import type { ClientInstance } from '${clientImportPath}'`,
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
- ].join('\n')
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
- return { typeDeclarations: declarations, callable, hasStream: false }
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
- return { typeDeclarations: declarations, callable, hasStream: false }
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
- return { typeDeclarations: declarations, callable, hasStream: true }
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 { ajsc: ajscOpts, clientImportPath = 'ts-procedures/client', namespaceTypes = false } = options ?? {}
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 = { ajsc: ajscOpts, namespaceTypes, scopePascal: pascal }
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
- clientImports,
492
+ importsBlock,
376
493
  '',
377
494
  '// ── Types ────────────────────────────────────────',
378
495
  '',
@@ -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, { ajsc: ajscOpts, clientImportPath, namespaceTypes })
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
- const rawIndexCode = emitIndexFile(groupArray, { clientImportPath, hasErrors, namespaceTypes, serviceName })
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
- | `onPreStreamError` | Stream | On pre-stream error (validation, auth) |
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
- **Note:** `HonoAPIAppBuilder.build()` is async (resolves query parser on first call).
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
- headers: [{ name: 'Authorization', description: 'Bearer token', required: false }],
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
- - `defaultErrors()` returns error schemas for all 4 procedure error types
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 { RPCConfig, RPCHttpRouteDoc, StreamHttpRouteDoc, StreamMode } from 'ts-procedures/implementations/types'
318
- import type { APIConfig, APIHttpRouteDoc, APIInput, HttpMethod } from 'ts-procedures/http'
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'