ts-procedures 8.2.0 → 8.3.0

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 (40) hide show
  1. package/agent_config/bin/setup.mjs +2 -2
  2. package/agent_config/claude-code/.claude-plugin/plugin.json +1 -1
  3. package/agent_config/claude-code/agents/ts-procedures-architect.md +1 -1
  4. package/agent_config/claude-code/skills/ts-procedures/SKILL.md +353 -6
  5. package/agent_config/claude-code/skills/ts-procedures/api-reference.md +4 -2
  6. package/agent_config/claude-code/skills/ts-procedures/patterns.md +30 -6
  7. package/agent_config/copilot/copilot-instructions.md +10 -6
  8. package/agent_config/cursor/cursorrules +10 -6
  9. package/agent_config/lib/install-claude.mjs +4 -4
  10. package/build/codegen/emit-errors.integration.test.js +22 -0
  11. package/build/codegen/emit-errors.integration.test.js.map +1 -1
  12. package/build/implementations/http/error-taxonomy.d.ts +40 -0
  13. package/build/implementations/http/error-taxonomy.js +57 -5
  14. package/build/implementations/http/error-taxonomy.js.map +1 -1
  15. package/build/implementations/http/error-taxonomy.test.js +95 -1
  16. package/build/implementations/http/error-taxonomy.test.js.map +1 -1
  17. package/build/implementations/http/hono/handlers/http.js +19 -24
  18. package/build/implementations/http/hono/handlers/http.js.map +1 -1
  19. package/build/implementations/http/hono/handlers/http.test.js +64 -1
  20. package/build/implementations/http/hono/handlers/http.test.js.map +1 -1
  21. package/docs/ai-agent-setup.md +5 -6
  22. package/docs/client-and-codegen.md +8 -0
  23. package/docs/core.md +2 -0
  24. package/docs/http-integrations.md +4 -0
  25. package/package.json +1 -1
  26. package/src/codegen/emit-errors.integration.test.ts +26 -0
  27. package/src/implementations/http/error-taxonomy.test.ts +111 -0
  28. package/src/implementations/http/error-taxonomy.ts +60 -5
  29. package/src/implementations/http/hono/handlers/http.test.ts +69 -1
  30. package/src/implementations/http/hono/handlers/http.ts +19 -21
  31. package/agent_config/claude-code/skills/ts-procedures-kotlin/SKILL.md +0 -106
  32. package/agent_config/claude-code/skills/ts-procedures-review/SKILL.md +0 -48
  33. package/agent_config/claude-code/skills/ts-procedures-scaffold/SKILL.md +0 -50
  34. package/agent_config/claude-code/skills/ts-procedures-swift/SKILL.md +0 -119
  35. /package/agent_config/claude-code/skills/{ts-procedures-review → ts-procedures}/checklist.md +0 -0
  36. /package/agent_config/claude-code/skills/{ts-procedures-scaffold → ts-procedures}/templates/astro-catchall.md +0 -0
  37. /package/agent_config/claude-code/skills/{ts-procedures-scaffold → ts-procedures}/templates/client.md +0 -0
  38. /package/agent_config/claude-code/skills/{ts-procedures-scaffold → ts-procedures}/templates/hono.md +0 -0
  39. /package/agent_config/claude-code/skills/{ts-procedures-scaffold → ts-procedures}/templates/procedure.md +0 -0
  40. /package/agent_config/claude-code/skills/{ts-procedures-scaffold → ts-procedures}/templates/stream-procedure.md +0 -0
@@ -22,7 +22,7 @@ npx ts-procedures-setup copilot # GitHub Copilot only
22
22
 
23
23
  | Tool | Files | Auto-updates? |
24
24
  |------|-------|---------------|
25
- | **Claude Code** | `.claude/skills/ts-procedures/`, `.claude/skills/ts-procedures-scaffold/`, `.claude/skills/ts-procedures-review/`, `.claude/agents/ts-procedures-architect.md` | Yes |
25
+ | **Claude Code** | `.claude/skills/ts-procedures/` (single skill — reference + `review`/`scaffold` modes + Kotlin/Swift codegen), `.claude/agents/ts-procedures-architect.md` | Yes |
26
26
  | **Cursor** | `.cursorrules` (marker-based section) | Yes |
27
27
  | **GitHub Copilot** | `.github/copilot-instructions.md` (marker-based section) | Yes |
28
28
 
@@ -34,9 +34,10 @@ After initial setup, rules are automatically refreshed on every `npm install` or
34
34
 
35
35
  Once installed, Claude Code gets:
36
36
 
37
- - **Framework reference skill** — `ts-procedures` with core API, schema system, error handling, and decision framework (auto-discovered by Claude Code)
38
- - **Scaffold skill**`/ts-procedures-scaffold <type> <Name>` generates procedures, streams, and HTTP setups with correct patterns
39
- - **Review skill** — `/ts-procedures-review <path>` checks code against a 60+ item checklist
37
+ - **Unified `ts-procedures` skill** one skill with three modes selected by the first word of its arguments:
38
+ - *Reference* (no argument) core API, schema system, error handling, decision framework, plus Kotlin/Swift client-codegen reference (auto-discovered by Claude Code)
39
+ - *Scaffold* — `/ts-procedures scaffold <type> <Name>` generates procedures, streams, and HTTP setups with correct patterns
40
+ - *Review* — `/ts-procedures review <path>` checks code against a 60+ item checklist
40
41
  - **Architecture agent** — `ts-procedures-architect` helps plan procedure structure, schema design, and HTTP implementation choices
41
42
 
42
43
  ## CLI Options
@@ -54,8 +55,6 @@ The `.claude/` files are auto-generated and regenerated on `npm install`. You ca
54
55
  ```gitignore
55
56
  # Auto-generated AI agent rules (regenerated on npm install)
56
57
  .claude/skills/ts-procedures/
57
- .claude/skills/ts-procedures-scaffold/
58
- .claude/skills/ts-procedures-review/
59
58
  .claude/agents/ts-procedures-*.md
60
59
  ```
61
60
 
@@ -4,6 +4,10 @@
4
4
 
5
5
  ts-procedures can generate type-safe client SDKs directly from your server's `DocRegistry` output. Generated files include TypeScript types and callable functions for every registered procedure, organized by scope — no manual type duplication required.
6
6
 
7
+ ## Setup / prerequisites
8
+
9
+ > **ESM only.** ts-procedures and its generated output are ESM-only — a `tsconfig.json` with `"module": "commonjs"` fails immediately. In your consuming project set `"type": "module"` in `package.json`, and in `tsconfig.json` use `"module": "ESNext"` with `"moduleResolution": "Bundler"` (or `"NodeNext"`). Run scripts with [`tsx`](https://github.com/privatenumber/tsx) during development (e.g. `tsx src/index.ts`).
10
+
7
11
  ## Quick Start
8
12
 
9
13
  **Step 1 — Serve your docs endpoint** (see [DocRegistry](./http-integrations.md#docregistry--composing-docs-from-multiple-builders) for setup):
@@ -35,7 +39,11 @@ const api = createApiClient({
35
39
  },
36
40
  },
37
41
  })
42
+ ```
38
43
 
44
+ > **Note:** `basePath` must be the **origin only** (e.g. `http://localhost:3000`), not `http://localhost:3000/api`. Generated client paths already include the server's `pathPrefix` (e.g. `/api/...`), so putting the prefix in `basePath` too doubles it up to `/api/api/...`.
45
+
46
+ ```typescript
39
47
  try {
40
48
  const user = await api.users.GetUser({ pathParams: { id: '123' } })
41
49
  } catch (err) {
package/docs/core.md CHANGED
@@ -214,6 +214,8 @@ schema: { params: Type.Object({ title: Type.String() }) }
214
214
 
215
215
  TypeBox schemas are valid JSON Schema and work directly with AJV for runtime validation.
216
216
 
217
+ > **Composing schemas:** the bundled typebox has no `Type.Composite`. Compose with a flat object spread instead — `Type.Object({ ...Base.properties, extra: Type.String() })` — which also keeps the emitted JSON Schema a single `object` rather than an `allOf`.
218
+
217
219
  ### Validation Behavior
218
220
 
219
221
  AJV is configured with:
@@ -134,6 +134,10 @@ new HonoAppBuilder({
134
134
 
135
135
  For streaming procedures the taxonomy covers the pre-stream path only; mid-stream errors are handled via `stream.onMidStreamError`.
136
136
 
137
+ > **Typed client classes — when you get them for free.** A taxonomy entry declared with just `{ class, statusCode }` is self-describing: its default body is `{ name, message }`, so codegen emits a typed client error class and registry entry automatically — `catch (e) { if (e instanceof ApiErrors.NotFound) ... }` works with zero extra ceremony. The framework can only do this when it knows the wire shape. Two cases where it can't, and the client falls back to the untyped `ClientHttpError` until you help it:
138
+ > - **Custom `toResponse` without a `schema`.** Once you shape the body yourself, the framework won't guess it. Add an explicit `schema` matching your `toResponse` to restore the typed class.
139
+ > - **Raw `ErrorDoc`s** added via `DocRegistry.documentError(...)` or a `config.errors` array (rather than a taxonomy). These carry no body contract — give them a `schema` to make them typed on the client.
140
+
137
141
  ### Imperative — the `onError` callback
138
142
 
139
143
  For apps that don't need typed client dispatch or declarative docs, configure `onError` directly and handle every error in one place:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ts-procedures",
3
- "version": "8.2.0",
3
+ "version": "8.3.0",
4
4
  "description": "A TypeScript RPC framework that creates type-safe, schema-validated procedure calls with a single function definition. Define your procedures once and get full type inference, runtime validation, and framework integration hooks.",
5
5
  "main": "build/exports.js",
6
6
  "types": "build/exports.d.ts",
@@ -10,6 +10,11 @@ import { tmpdir } from 'node:os'
10
10
  import { join } from 'node:path'
11
11
  import { execSync } from 'node:child_process'
12
12
  import { generateClient } from './index.js'
13
+ import { emitErrorsFile } from './emit-errors.js'
14
+ import {
15
+ defineErrorTaxonomy,
16
+ taxonomyToErrorDocs,
17
+ } from '../implementations/http/error-taxonomy.js'
13
18
  import type { DocEnvelope } from '../implementations/types.js'
14
19
 
15
20
  describe('generated _errors.ts — runtime behavior', () => {
@@ -180,4 +185,25 @@ describe('generated _errors.ts — runtime behavior', () => {
180
185
  rmSync(outDir, { recursive: true, force: true })
181
186
  }
182
187
  }, 30000)
188
+
189
+ // ---------------------------------------------------------------------------
190
+ // Taxonomy-derived envelope: a class+statusCode-only entry must produce a
191
+ // typed client error class + registry entry (previously schema-less → skipped).
192
+ // ---------------------------------------------------------------------------
193
+ it('emits a typed class + registry entry for a class+statusCode-only taxonomy entry', async () => {
194
+ const taxonomy = defineErrorTaxonomy({
195
+ // Only { class, statusCode } — the common case. No schema, no toResponse.
196
+ AuthError: { class: class AuthError extends Error {}, statusCode: 401 },
197
+ })
198
+ const errorDocs = taxonomyToErrorDocs(taxonomy)
199
+
200
+ // The synthesized schema is what makes codegen emit a class for it.
201
+ const result = await emitErrorsFile(errorDocs)
202
+ expect(result).toBeDefined()
203
+ expect(result).toContain('export class AuthError')
204
+ expect(result).toContain('export const ErrorRegistry = {')
205
+ // Precise registry membership — `^\s*AuthError,$` avoids matching a
206
+ // substring like `MyAuthError,` or an occurrence in a comment.
207
+ expect(result).toMatch(/^\s*AuthError,$/m)
208
+ })
183
209
  })
@@ -5,10 +5,14 @@ import {
5
5
  ProcedureYieldValidationError,
6
6
  } from '../../errors.js'
7
7
  import type { TProcedureRegistration } from '../../index.js'
8
+ import * as AJV from 'ajv'
8
9
  import {
9
10
  defineErrorTaxonomy,
10
11
  resolveErrorResponse,
11
12
  defaultErrorTaxonomy,
13
+ taxonomyToErrorDocs,
14
+ defaultErrorSchema,
15
+ defaultErrorBody,
12
16
  } from './error-taxonomy.js'
13
17
 
14
18
  class UseCaseError extends Error {
@@ -436,3 +440,110 @@ describe('resolveErrorResponse', () => {
436
440
  expect(Object.keys(taxonomy)).toEqual(['B', 'A'])
437
441
  })
438
442
  })
443
+
444
+ describe('taxonomyToErrorDocs', () => {
445
+ test('synthesizes a { name const, message } schema for class+statusCode-only entries', () => {
446
+ const taxonomy = defineErrorTaxonomy({
447
+ AuthError: { class: AuthError, statusCode: 401 },
448
+ })
449
+ const docs = taxonomyToErrorDocs(taxonomy)
450
+ const auth = docs.find((d) => d.name === 'AuthError')
451
+ expect(auth?.statusCode).toBe(401)
452
+ expect(auth?.schema).toEqual({
453
+ type: 'object',
454
+ properties: {
455
+ name: { type: 'string', const: 'AuthError' },
456
+ message: { type: 'string' },
457
+ },
458
+ required: ['name', 'message'],
459
+ })
460
+ })
461
+
462
+ test('does NOT synthesize a schema when a custom toResponse is present', () => {
463
+ const taxonomy = defineErrorTaxonomy({
464
+ UseCaseError: {
465
+ class: UseCaseError,
466
+ statusCode: 422,
467
+ toResponse: (err) => ({ name: 'UseCaseError', message: err.externalMsg }),
468
+ },
469
+ })
470
+ const docs = taxonomyToErrorDocs(taxonomy)
471
+ const useCase = docs.find((d) => d.name === 'UseCaseError')
472
+ expect(useCase?.schema).toBeUndefined()
473
+ })
474
+
475
+ test('preserves an explicit schema untouched', () => {
476
+ const explicitSchema = {
477
+ type: 'object',
478
+ properties: {
479
+ name: { type: 'string', const: 'UseCaseError' },
480
+ reason: { type: 'string' },
481
+ },
482
+ required: ['name', 'reason'],
483
+ }
484
+ const taxonomy = defineErrorTaxonomy({
485
+ UseCaseError: {
486
+ class: UseCaseError,
487
+ statusCode: 422,
488
+ schema: explicitSchema,
489
+ },
490
+ })
491
+ const docs = taxonomyToErrorDocs(taxonomy)
492
+ const useCase = docs.find((d) => d.name === 'UseCaseError')
493
+ expect(useCase?.schema).toBe(explicitSchema)
494
+ })
495
+ })
496
+
497
+ describe('defaultErrorSchema', () => {
498
+ test('synthesizes the { name, message } envelope for a bare entry', () => {
499
+ expect(defaultErrorSchema('AuthError', { class: AuthError, statusCode: 401 })).toEqual({
500
+ type: 'object',
501
+ properties: {
502
+ name: { type: 'string', const: 'AuthError' },
503
+ message: { type: 'string' },
504
+ },
505
+ required: ['name', 'message'],
506
+ })
507
+ })
508
+
509
+ test('returns undefined when a custom toResponse is present (shape unknown)', () => {
510
+ expect(
511
+ defaultErrorSchema('UseCaseError', {
512
+ class: UseCaseError,
513
+ statusCode: 422,
514
+ toResponse: () => ({ name: 'UseCaseError' }),
515
+ })
516
+ ).toBeUndefined()
517
+ })
518
+
519
+ test('returns the explicit schema when one is set', () => {
520
+ const schema = { type: 'object', properties: {} }
521
+ expect(
522
+ defaultErrorSchema('UseCaseError', { class: UseCaseError, statusCode: 422, schema })
523
+ ).toBe(schema)
524
+ })
525
+ })
526
+
527
+ // The synthesized schema and the runtime body share one source (defaultErrorBody).
528
+ // This locks the invariant: whatever the default branch serializes must validate
529
+ // against the schema codegen turns into the client error class. If either side
530
+ // changes shape, this fails before consumers see a mismatch.
531
+ describe('defaultErrorBody / defaultErrorSchema invariant', () => {
532
+ const ajv = new AJV.Ajv()
533
+
534
+ test('default body validates against the synthesized schema', () => {
535
+ const schema = defaultErrorSchema('AuthError', { class: AuthError, statusCode: 401 })
536
+ const validate = ajv.compile(schema!)
537
+
538
+ expect(validate(defaultErrorBody('AuthError', new Error('nope')))).toBe(true)
539
+ // A non-Error throw stringifies to a message — still valid.
540
+ expect(validate(defaultErrorBody('AuthError', 'plain string'))).toBe(true)
541
+ // Wrong discriminator name is rejected by the `const` — proves the schema
542
+ // actually constrains the wire shape the client dispatcher keys on.
543
+ expect(validate({ name: 'SomethingElse', message: 'x' })).toBe(false)
544
+ })
545
+
546
+ test('default body carries exactly the schema-described keys', () => {
547
+ expect(Object.keys(defaultErrorBody('X', new Error('m'))).sort()).toEqual(['message', 'name'])
548
+ })
549
+ })
@@ -204,9 +204,67 @@ export const PROCEDURE_REGISTRATION_ERROR_DOC: ErrorDoc = {
204
204
  },
205
205
  }
206
206
 
207
+ /**
208
+ * The default response body for an entry without a custom `toResponse`:
209
+ * `{ name: <key>, message }`. This is the single source of truth for the default
210
+ * wire shape — `resolveErrorResponse` serializes with it and
211
+ * {@link defaultErrorSchema} describes it. Keeping both derived from one place
212
+ * means the synthesized schema can never drift from what the runtime emits.
213
+ */
214
+ export function defaultErrorBody(key: string, err: unknown): { name: string; message: string } {
215
+ return {
216
+ name: key,
217
+ message: err instanceof Error ? err.message : String(err),
218
+ }
219
+ }
220
+
221
+ /**
222
+ * Synthesizes the response-body JSON Schema for a taxonomy entry that ships
223
+ * neither an explicit `schema` nor a custom `toResponse`.
224
+ *
225
+ * The common case for `defineErrorTaxonomy` is `{ class, statusCode }` only.
226
+ * For those entries the default `toResponse` (see `resolveErrorResponse`) emits
227
+ * exactly `{ name: <key>, message }`. Without a schema, `taxonomyToErrorDocs`
228
+ * produced a schema-less `ErrorDoc`, and codegen (`emit-errors.ts`) only emits a
229
+ * typed client error class for docs that carry a schema — so these entries
230
+ * silently fell back to the untyped `ClientHttpError`, while framework errors
231
+ * (which ship schemas) worked. That mismatch was confusing.
232
+ *
233
+ * By describing the default envelope here, the entry becomes self-describing and
234
+ * codegen emits a typed client error class with zero ceremony from the consumer.
235
+ *
236
+ * Rules:
237
+ * - Entry has an explicit `schema` → caller keeps it (this is not consulted).
238
+ * - Entry has a custom `toResponse` but no `schema` → returns `undefined`; the
239
+ * body shape is unknown and we never guess it.
240
+ * - Entry has neither → returns the `{ name: const <key>, message }` schema that
241
+ * describes {@link defaultErrorBody} — the exact body the runtime serializes.
242
+ * (An invariant test keeps the two from drifting.)
243
+ */
244
+ export function defaultErrorSchema(
245
+ key: string,
246
+ entry: ErrorTaxonomyEntry
247
+ ): Record<string, unknown> | undefined {
248
+ if (entry.schema) return entry.schema
249
+ if (entry.toResponse) return undefined
250
+ return {
251
+ type: 'object',
252
+ properties: {
253
+ name: { type: 'string', const: key },
254
+ message: { type: 'string' },
255
+ },
256
+ required: ['name', 'message'],
257
+ }
258
+ }
259
+
207
260
  /**
208
261
  * Converts a taxonomy into {@link ErrorDoc} objects suitable for a DocEnvelope.
209
262
  *
263
+ * For entries that supply neither a `schema` nor a custom `toResponse`, the
264
+ * schema of the default `{ name, message }` envelope is synthesized (see
265
+ * {@link defaultErrorSchema}) so client codegen emits a typed error class for
266
+ * them too — matching the behavior of the schema-carrying framework defaults.
267
+ *
210
268
  * @internal Used by `DocRegistry` to merge taxonomy entries into the envelope.
211
269
  * Consumers should pass their taxonomy directly to `new DocRegistry({ errors: taxonomy })`
212
270
  * rather than calling this helper — the constructor handles the conversion.
@@ -216,7 +274,7 @@ export function taxonomyToErrorDocs(taxonomy: ErrorTaxonomy): ErrorDoc[] {
216
274
  name: key,
217
275
  statusCode: entry.statusCode,
218
276
  description: entry.description ?? '',
219
- schema: entry.schema,
277
+ schema: defaultErrorSchema(key, entry),
220
278
  }))
221
279
  }
222
280
 
@@ -314,10 +372,7 @@ export function resolveErrorResponse(params: {
314
372
 
315
373
  const rawBody = entry.toResponse
316
374
  ? entry.toResponse(candidate as any, { key })
317
- : {
318
- name: key,
319
- message: candidate instanceof Error ? candidate.message : String(candidate),
320
- }
375
+ : defaultErrorBody(key, candidate)
321
376
  const body = ensureName(rawBody, key)
322
377
 
323
378
  return {
@@ -120,11 +120,79 @@ describe('installHttpRoute', () => {
120
120
  path: '/q', method: 'get',
121
121
  schema: { req: { query: Type.Any() }, res: { body: Type.Any() } },
122
122
  },
123
- async (_ctx, { query }) => ({ body: query }))
123
+ async (_ctx, { query }) => query)
124
124
  }, { api: { queryParser } })
125
125
 
126
126
  const res = await app.request('/q?a=1&b=2')
127
127
  expect(queryParser).toHaveBeenCalledWith('a=1&b=2')
128
128
  expect(await res.json()).toEqual({ parsed: true, raw: 'a=1&b=2' })
129
129
  })
130
+
131
+ test('domain object with a top-level "body" field serializes whole (no res.headers)', async () => {
132
+ const { app } = buildApp((P) => {
133
+ P.CreateHttp('GetMessage', {
134
+ path: '/msg', method: 'get',
135
+ schema: { res: { body: Type.Object({ id: Type.String(), body: Type.String() }) } },
136
+ }, async () => ({ id: '1', body: 'hello text' }))
137
+ })
138
+ const res = await app.request('/msg')
139
+ expect(res.status).toBe(200)
140
+ expect(await res.json()).toEqual({ id: '1', body: 'hello text' })
141
+ })
142
+
143
+ test('envelope unwraps when res.headers is declared', async () => {
144
+ const { app } = buildApp((P) => {
145
+ P.CreateHttp('Enveloped', {
146
+ path: '/env', method: 'get',
147
+ schema: { res: { body: Type.Object({ ok: Type.Boolean() }), headers: Type.Object({}) } },
148
+ }, async () => ({ body: { ok: true }, headers: { 'x-trace': 'z' } }))
149
+ })
150
+ const res = await app.request('/env')
151
+ expect(res.status).toBe(200)
152
+ expect(res.headers.get('x-trace')).toBe('z')
153
+ expect(await res.json()).toEqual({ ok: true })
154
+ })
155
+
156
+ // Headers-only response shape — `res: { headers }` with no `res.body`. The
157
+ // handler returns `{ headers }` (no body key). The response must be bodyless,
158
+ // not an empty `application/json` payload that a client can't parse.
159
+ test('headers-only res schema returns a clean bodyless response', async () => {
160
+ const { app } = buildApp((P) => {
161
+ P.CreateHttp('HeadOnly', {
162
+ path: '/ho', method: 'get',
163
+ schema: { res: { headers: Type.Object({}) } },
164
+ }, async () => ({ headers: { 'x-trace': 'h1' } }))
165
+ })
166
+ const res = await app.request('/ho')
167
+ expect(res.status).toBe(200)
168
+ expect(res.headers.get('x-trace')).toBe('h1')
169
+ expect(res.headers.get('content-type')).toBeNull()
170
+ expect(await res.text()).toBe('')
171
+ })
172
+
173
+ // A void handler on a non-204 status sends a clean bodyless response rather
174
+ // than an empty `application/json` payload — uniform with the headers-only and
175
+ // 204 paths (an undefined body is always bodyless).
176
+ test('void handler on a 200 status returns a clean bodyless response', async () => {
177
+ const { app } = buildApp((P) => {
178
+ P.CreateHttp('Void', { path: '/void', method: 'get', schema: {} }, async () => undefined)
179
+ })
180
+ const res = await app.request('/void')
181
+ expect(res.status).toBe(200)
182
+ expect(res.headers.get('content-type')).toBeNull()
183
+ expect(await res.text()).toBe('')
184
+ })
185
+
186
+ test('res.headers declared with a 204 status applies headers and no body', async () => {
187
+ const { app } = buildApp((P) => {
188
+ P.CreateHttp('NoContent', {
189
+ path: '/nc', method: 'delete',
190
+ schema: { res: { headers: Type.Object({}) } },
191
+ }, async () => ({ headers: { 'x-trace': 'd1' } }))
192
+ })
193
+ const res = await app.request('/nc', { method: 'DELETE' })
194
+ expect(res.status).toBe(204)
195
+ expect(res.headers.get('x-trace')).toBe('d1')
196
+ expect(await res.text()).toBe('')
197
+ })
130
198
  })
@@ -103,32 +103,30 @@ export function installHttpRoute(params: {
103
103
 
104
104
  cfg.api?.onSuccess?.(procedure, c)
105
105
 
106
- // 204 No Content no body, just optional headers
107
- if (successStatus === 204) {
108
- if (result && typeof result === 'object' && 'headers' in result && (result as any).headers) {
109
- for (const [k, v] of Object.entries((result as any).headers as Record<string, string>)) {
110
- c.header(k, v)
111
- }
112
- }
113
- return c.body(null, 204)
106
+ // The `{ body, headers }` envelope is OPT-IN via `schema.res.headers`: when
107
+ // the route declares response headers, the handler returns `{ body, headers }`;
108
+ // otherwise its return value IS the body. Gating on the schema (rather than
109
+ // duck-typing the result for a `body`/`headers` key) lets a domain object
110
+ // safely carry its own `body`/`headers` field without being unwrapped.
111
+ // Normalize both shapes to a single `{ body, headers }` so the response
112
+ // decision below is uniform for every legal return shape.
113
+ const hasResHeaders = procedure.config.schema?.res?.headers != null
114
+ const { body, headers } = hasResHeaders
115
+ ? ((result ?? {}) as { body?: unknown; headers?: unknown })
116
+ : { body: result, headers: undefined }
117
+
118
+ if (headers && typeof headers === 'object') {
119
+ for (const [k, v] of Object.entries(headers as Record<string, string>)) c.header(k, v)
114
120
  }
115
121
 
116
- let body: unknown = result
117
- let headers: Record<string, string> | undefined
118
-
119
- if (result && typeof result === 'object' && 'body' in result && 'headers' in result) {
120
- body = (result as any).body
121
- headers = (result as any).headers as Record<string, string>
122
- } else if (result && typeof result === 'object' && 'headers' in result && !('body' in result)) {
123
- for (const [k, v] of Object.entries((result as any).headers as Record<string, string>)) {
124
- c.header(k, v)
125
- }
122
+ // No body to send — a 204 status, or an `undefined` return (a `void`
123
+ // handler, or a headers-only `res: { headers }` envelope). Emit a clean
124
+ // bodyless response instead of `c.json(undefined)`, which would send an
125
+ // empty, unparseable body under an `application/json` content-type.
126
+ if (successStatus === 204 || body === undefined) {
126
127
  return c.body(null, successStatus as any)
127
- } else if (result && typeof result === 'object' && 'body' in result && !('headers' in result)) {
128
- body = (result as any).body
129
128
  }
130
129
 
131
- if (headers) for (const [k, v] of Object.entries(headers)) c.header(k, v)
132
130
  return c.json(body, successStatus as any)
133
131
  } catch (error) {
134
132
  return dispatchPreStreamError({
@@ -1,106 +0,0 @@
1
- ---
2
- name: ts-procedures-kotlin
3
- description: "Kotlin client codegen for ts-procedures — generate types-only Kotlin source from a ts-procedures DocEnvelope for Android/JVM consumers. Use when the user mentions Kotlin, Android, mobile clients, kotlinx-serialization, or asks how to generate non-TypeScript types from a ts-procedures server."
4
- user-invocable: false
5
- ---
6
-
7
- # ts-procedures — Kotlin Client Codegen
8
-
9
- You are assisting a developer who needs to generate Kotlin types from a `ts-procedures` server's `DocEnvelope`. The Kotlin target is **types-only** — no runtime, no adapter, no error registry. Mobile/Android consumers own the HTTP layer.
10
-
11
- ## When this skill applies
12
-
13
- - The user mentions Kotlin, Android, kotlinx-serialization, mobile client, or `--target kotlin`.
14
- - The user wants to share API types between a `ts-procedures` server and a Kotlin/JVM consumer.
15
- - The user is debugging Kotlin codegen output, Gradle setup, or contextual serializer registration.
16
-
17
- If the user is generating a **TypeScript** client, redirect them to the main `ts-procedures` skill. For **Swift / iOS / macOS / Apple-platform** consumers, redirect to `ts-procedures-swift`.
18
-
19
- ## Quickstart
20
-
21
- ```bash
22
- npx ts-procedures-codegen \
23
- --target kotlin \
24
- --kotlin-package com.example.api \
25
- --url https://api.example.com/_ts-procedures.json \
26
- --out ./src/main/kotlin/com/example/api
27
- ```
28
-
29
- One `.kt` file per scope. Types accessed as `Users.GetUser.Response`, `Users.GetUser.Body.Address` (nested classes via `inlineTypes: true`), `Users.GetUser.Errors.NotFound`.
30
-
31
- ## CLI flags (Kotlin-specific)
32
-
33
- | Flag | Default | Purpose |
34
- |---|---|---|
35
- | `--target kotlin` | `ts` | Switch to the Kotlin codegen path |
36
- | `--kotlin-package <com.example.api>` | required | Sets the `package` declaration on every emitted `.kt` file |
37
- | `--kotlin-serializer <kotlinx\|none>` | `kotlinx` | `kotlinx` emits `@Serializable`; `none` emits plain data classes for Moshi/Gson/hand-written serialization |
38
- | `--unsupported-unions <throw\|fallback>` | `throw` | **Currently a no-op for Kotlin** — ajsc v7.2 silently emits an empty `data class` for untagged `oneOf` regardless. CLI warns when set |
39
-
40
- `--array-item-naming`, `--depluralize`, `--uncountable-words` also apply to the Kotlin target.
41
-
42
- ## Output shape (what consumers see)
43
-
44
- ```kotlin
45
- package com.example.api
46
-
47
- import kotlinx.serialization.Serializable
48
- import kotlinx.serialization.SerialName
49
- import kotlinx.serialization.Contextual
50
- import kotlinx.serialization.json.JsonClassDiscriminator
51
-
52
- object Users {
53
- object GetUser {
54
- const val method = "GET"
55
- const val pathTemplate = "/users/{id}"
56
- fun path(p: PathParams): String = "/users/${p.id}"
57
-
58
- @Serializable data class PathParams(val id: String)
59
-
60
- @Serializable
61
- data class Response(
62
- val id: String,
63
- @SerialName("created-at") @Contextual val createdAt: java.time.Instant,
64
- val address: Address,
65
- ) {
66
- @Serializable data class Address(val street: String, val city: String)
67
- }
68
-
69
- object Errors {
70
- @Serializable
71
- data class NotFound(val name: String = "NotFound", val message: String)
72
- }
73
- }
74
- }
75
- ```
76
-
77
- For routes without path params, `path` is a `const val`, not a function.
78
-
79
- ## Consumer-side setup the dev MUST do
80
-
81
- The generated code requires these on the Android/JVM side. **Don't let the user assume the codegen handles them.**
82
-
83
- 1. **Gradle:** `kotlin("plugin.serialization")` plugin + `org.jetbrains.kotlinx:kotlinx-serialization-json` dependency. (`kotlinx-serialization-core` is a transitive dep; no need to declare it explicitly.)
84
-
85
- 2. **Contextual serializers:** `format: date-time`/`uuid`/`uri`/`date`/`time` map to JVM stdlib types annotated with `@Contextual`. The consumer's `Json` configuration MUST register `contextual(java.time.Instant::class, ...)` etc., otherwise decoding fails. We don't ship the serializers — choice between ISO-8601 and epoch ms is application-specific.
86
-
87
- 3. **Discriminated unions:** `@JsonClassDiscriminator` is read automatically by `kotlinx-serialization-json` — no extra config needed.
88
-
89
- 4. **No runtime dispatch:** error types are emitted as nested data classes (`Users.GetUser.Errors.NotFound`), but there's no `instanceof`-style registry, no `dispatchTypedError`. Consumers catch HTTP failures themselves and inspect `body.name` (a regular `String` field, not a type-system discriminator) to decide which error data class to deserialize against. This is by design; don't suggest implementing it.
90
-
91
- The full setup guide lives at `docs/codegen-kotlin.md` in the `ts-procedures` repo.
92
-
93
- ## Documented limitations to flag during reviews
94
-
95
- - **Untagged `oneOf` produces an empty `data class`.** Won't round-trip. Add a server-side discriminator, hand-write a `KSerializer`, or pre-process the envelope.
96
- - **Tuples > 3 elements throw** at codegen time. Refactor to a struct schema upstream.
97
- - **`additionalProperties: { type: T }` is silently dropped** with a KDoc note. Add a sibling `Map<String, T>` field by hand if your contract uses extra keys.
98
- - **Schema-level `examples` are not modeled.** They're documentation-only on the server side; consumers don't see them.
99
-
100
- ## Anti-patterns
101
-
102
- - Suggesting the Kotlin target ships an HTTP adapter or error registry.
103
- - Recommending `--kotlin-serializer none` without noting the consumer is responsible for adapter setup.
104
- - Treating `--unsupported-unions fallback` as functional for Kotlin — it's a no-op (the CLI itself warns when set).
105
- - Saying KMP (Kotlin Multiplatform) is supported — JVM only for now.
106
- - Mixing `--target kotlin` flags into a TypeScript-target invocation; some flags are silently ignored, others (like `--kotlin-package`) are required only for kotlin.
@@ -1,48 +0,0 @@
1
- ---
2
- name: ts-procedures-review
3
- description: "Review ts-procedures code for pattern adherence, schema correctness, error handling, and signal propagation."
4
- argument-hint: "<path>"
5
- allowed-tools: Read Grep Glob
6
- context: fork
7
- effort: high
8
- ---
9
-
10
- # Review ts-procedures Code
11
-
12
- Parse `$ARGUMENTS` as a file or directory path. If a directory, review all `.ts` and `.tsx` files within it.
13
-
14
- ## Instructions
15
-
16
- 1. Read the target file(s).
17
- 2. Identify ts-procedures imports (`ts-procedures`, `ts-procedures/hono`, `ts-procedures/http`, `ts-procedures/http-docs`, `ts-procedures/http-errors`, `ts-procedures/client`, `ts-procedures/codegen`) to determine file types.
18
- 3. Check each file against the categorized checklist in [checklist.md](checklist.md).
19
- 4. For detailed code examples of each violation pattern, reference [anti-patterns.md](../ts-procedures/anti-patterns.md) — it shows 20 common mistakes with before/after code fixes and severity ratings.
20
- 5. Output findings grouped by severity.
21
-
22
- ## Output Format
23
-
24
- For each finding:
25
-
26
- ```
27
- [SEVERITY] file:line — Violation
28
- Problem: What's wrong
29
- Fix: Concrete before/after code
30
- ```
31
-
32
- Severity levels:
33
- - **CRITICAL** — Will cause bugs, silent failures, resource leaks, or runtime errors. Must fix.
34
- - **WARNING** — Anti-pattern that hurts maintainability or correctness. Should fix.
35
- - **SUGGESTION** — Improvement for readability, type safety, or DX. Nice to have.
36
-
37
- ## Summary
38
-
39
- After individual findings, provide:
40
- - Total findings by severity
41
- - Overall assessment (healthy / needs attention / significant issues)
42
- - Top 3 priorities to address
43
- - If structural issues found, suggest `/ts-procedures:scaffold <type> <Name>` to generate correct implementations
44
-
45
- ## Reference
46
-
47
- See [checklist.md](checklist.md) for the complete categorized checklist by file type.
48
- See [anti-patterns.md](../ts-procedures/anti-patterns.md) for detailed code examples of each violation.