ts-procedures 8.3.0 → 8.5.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 (126) hide show
  1. package/agent_config/claude-code/skills/ts-procedures/SKILL.md +26 -8
  2. package/agent_config/claude-code/skills/ts-procedures/templates/client.md +3 -3
  3. package/agent_config/claude-code/skills/ts-procedures/templates/hono.md +3 -3
  4. package/agent_config/claude-code/skills/ts-procedures/templates/procedure.md +3 -3
  5. package/agent_config/claude-code/skills/ts-procedures/templates/stream-procedure.md +3 -3
  6. package/build/client/call.js +1 -1
  7. package/build/client/call.js.map +1 -1
  8. package/build/client/index.d.ts +1 -1
  9. package/build/client/index.js +23 -1
  10. package/build/client/index.js.map +1 -1
  11. package/build/client/index.test.js +87 -0
  12. package/build/client/index.test.js.map +1 -1
  13. package/build/client/resolve-options.d.ts +5 -4
  14. package/build/client/resolve-options.js +18 -7
  15. package/build/client/resolve-options.js.map +1 -1
  16. package/build/client/resolve-options.test.js +53 -24
  17. package/build/client/resolve-options.test.js.map +1 -1
  18. package/build/client/stream.js +1 -1
  19. package/build/client/stream.js.map +1 -1
  20. package/build/client/types.d.ts +31 -3
  21. package/build/codegen/__fixtures__/make-envelope.d.ts +41 -0
  22. package/build/codegen/__fixtures__/make-envelope.js +38 -0
  23. package/build/codegen/__fixtures__/make-envelope.js.map +1 -0
  24. package/build/codegen/bin/cli.d.ts +15 -0
  25. package/build/codegen/bin/cli.js +46 -21
  26. package/build/codegen/bin/cli.js.map +1 -1
  27. package/build/codegen/bin/cli.test.js +54 -1
  28. package/build/codegen/bin/cli.test.js.map +1 -1
  29. package/build/codegen/bin/flag-specs.d.ts +10 -0
  30. package/build/codegen/bin/flag-specs.js +62 -0
  31. package/build/codegen/bin/flag-specs.js.map +1 -0
  32. package/build/codegen/bin/flag-specs.test.d.ts +1 -0
  33. package/build/codegen/bin/flag-specs.test.js +35 -0
  34. package/build/codegen/bin/flag-specs.test.js.map +1 -0
  35. package/build/codegen/collect-models.d.ts +48 -0
  36. package/build/codegen/collect-models.js +84 -0
  37. package/build/codegen/collect-models.js.map +1 -0
  38. package/build/codegen/collect-models.test.d.ts +1 -0
  39. package/build/codegen/collect-models.test.js +59 -0
  40. package/build/codegen/collect-models.test.js.map +1 -0
  41. package/build/codegen/emit-client-runtime.js +1 -0
  42. package/build/codegen/emit-client-runtime.js.map +1 -1
  43. package/build/codegen/emit-models.d.ts +26 -0
  44. package/build/codegen/emit-models.js +53 -0
  45. package/build/codegen/emit-models.js.map +1 -0
  46. package/build/codegen/emit-models.test.d.ts +1 -0
  47. package/build/codegen/emit-models.test.js +42 -0
  48. package/build/codegen/emit-models.test.js.map +1 -0
  49. package/build/codegen/emit-scope.d.ts +10 -0
  50. package/build/codegen/emit-scope.js +119 -34
  51. package/build/codegen/emit-scope.js.map +1 -1
  52. package/build/codegen/emit-types.d.ts +26 -1
  53. package/build/codegen/emit-types.js +27 -5
  54. package/build/codegen/emit-types.js.map +1 -1
  55. package/build/codegen/index.d.ts +15 -0
  56. package/build/codegen/index.js +5 -0
  57. package/build/codegen/index.js.map +1 -1
  58. package/build/codegen/model-refs.d.ts +27 -0
  59. package/build/codegen/model-refs.js +49 -0
  60. package/build/codegen/model-refs.js.map +1 -0
  61. package/build/codegen/model-refs.test.d.ts +1 -0
  62. package/build/codegen/model-refs.test.js +33 -0
  63. package/build/codegen/model-refs.test.js.map +1 -0
  64. package/build/codegen/pipeline.d.ts +7 -0
  65. package/build/codegen/pipeline.js +6 -1
  66. package/build/codegen/pipeline.js.map +1 -1
  67. package/build/codegen/schema-walk.d.ts +13 -0
  68. package/build/codegen/schema-walk.js +26 -0
  69. package/build/codegen/schema-walk.js.map +1 -0
  70. package/build/codegen/schema-walk.test.d.ts +1 -0
  71. package/build/codegen/schema-walk.test.js +35 -0
  72. package/build/codegen/schema-walk.test.js.map +1 -0
  73. package/build/codegen/targets/_shared/target-run.d.ts +15 -0
  74. package/build/codegen/targets/ts/run.js +37 -1
  75. package/build/codegen/targets/ts/run.js.map +1 -1
  76. package/build/codegen/targets/ts/shared-models.test.d.ts +1 -0
  77. package/build/codegen/targets/ts/shared-models.test.js +354 -0
  78. package/build/codegen/targets/ts/shared-models.test.js.map +1 -0
  79. package/build/doc-envelope.d.ts +13 -0
  80. package/build/doc-envelope.js +23 -0
  81. package/build/doc-envelope.js.map +1 -0
  82. package/build/doc-envelope.test.d.ts +1 -0
  83. package/build/doc-envelope.test.js +31 -0
  84. package/build/doc-envelope.test.js.map +1 -0
  85. package/build/exports.d.ts +2 -0
  86. package/build/exports.js +1 -0
  87. package/build/exports.js.map +1 -1
  88. package/docs/client-and-codegen.md +163 -0
  89. package/docs/handoffs/ajsc-named-type-collision.md +134 -0
  90. package/docs/handoffs/ajsc-named-type-support.md +181 -0
  91. package/docs/handoffs/shared-models-auto-resolve-response.md +181 -0
  92. package/docs/superpowers/plans/2026-06-05-dx-feedback-round.md +1292 -0
  93. package/docs/superpowers/plans/2026-06-06-shared-models-convention-and-diagnostics.md +659 -0
  94. package/docs/superpowers/specs/2026-06-05-dx-feedback-round-design.md +285 -0
  95. package/package.json +2 -2
  96. package/src/client/call.ts +1 -1
  97. package/src/client/index.test.ts +98 -0
  98. package/src/client/index.ts +32 -1
  99. package/src/client/resolve-options.test.ts +73 -26
  100. package/src/client/resolve-options.ts +23 -9
  101. package/src/client/stream.ts +1 -1
  102. package/src/client/types.ts +34 -3
  103. package/src/codegen/__fixtures__/make-envelope.ts +89 -0
  104. package/src/codegen/bin/cli.test.ts +65 -1
  105. package/src/codegen/bin/cli.ts +51 -22
  106. package/src/codegen/bin/flag-specs.test.ts +38 -0
  107. package/src/codegen/bin/flag-specs.ts +71 -0
  108. package/src/codegen/collect-models.test.ts +68 -0
  109. package/src/codegen/collect-models.ts +125 -0
  110. package/src/codegen/emit-client-runtime.ts +1 -0
  111. package/src/codegen/emit-models.test.ts +48 -0
  112. package/src/codegen/emit-models.ts +63 -0
  113. package/src/codegen/emit-scope.ts +145 -33
  114. package/src/codegen/emit-types.ts +48 -7
  115. package/src/codegen/index.ts +20 -0
  116. package/src/codegen/model-refs.test.ts +37 -0
  117. package/src/codegen/model-refs.ts +57 -0
  118. package/src/codegen/pipeline.ts +13 -1
  119. package/src/codegen/schema-walk.test.ts +37 -0
  120. package/src/codegen/schema-walk.ts +23 -0
  121. package/src/codegen/targets/_shared/target-run.ts +15 -0
  122. package/src/codegen/targets/ts/run.ts +50 -0
  123. package/src/codegen/targets/ts/shared-models.test.ts +391 -0
  124. package/src/doc-envelope.test.ts +35 -0
  125. package/src/doc-envelope.ts +30 -0
  126. package/src/exports.ts +2 -0
@@ -0,0 +1,285 @@
1
+ # DX Feedback Round — Design
2
+
3
+ **Date:** 2026-06-05
4
+ **Status:** Approved (pending spec review)
5
+ **Source:** Downstream-developer feedback from building the demo server (`src/server`), 5 findings.
6
+
7
+ This round addresses five findings. Each section states the problem, the decision
8
+ taken (with the alternatives considered), and the concrete shape of the change.
9
+
10
+ Two items are cross-logged but **explicitly out of scope** this round (no scope creep):
11
+ - The `ts-channels` codegen has the identical #3 (per-route inlining) defect — coordinating the two is follow-up.
12
+ - #5 is cross-logged with `ts-channels #9` / `mvc-kit #2` — aligning all three scaffolders is follow-up.
13
+
14
+ ---
15
+
16
+ ## #1 — `ts-procedures-codegen --help` (MEDIUM)
17
+
18
+ ### Problem
19
+ `--help` falls through to the unknown-flag branch and exits non-zero with a
20
+ misfiring did-you-mean suggestion (`Did you mean --url?`). There is no usage text
21
+ anywhere in the CLI, so the rich flag surface (`--watch`, `--dry-run`,
22
+ `--enum-style`, Kotlin/Swift targets, …) is undiscoverable without reading the
23
+ built JS.
24
+
25
+ ### Decision: structured flags as the single source of truth
26
+ Replace the bare-string `KNOWN_FLAGS` array (`src/codegen/bin/cli.ts`) with a
27
+ structured table — one entry per flag:
28
+
29
+ ```ts
30
+ interface FlagSpec {
31
+ name: string // '--service-name'
32
+ arg?: string // '<name>' (omitted for booleans)
33
+ description: string // one line
34
+ group: 'Source' | 'Output' | 'Codegen' | 'Targets' | 'Misc'
35
+ default?: string // shown in help when meaningful, e.g. 'Api'
36
+ }
37
+ ```
38
+
39
+ Derive **both** the validation set (the current `KNOWN_FLAGS` membership check)
40
+ and the `--help` output from this table — adding a flag now documents it and the
41
+ two can never drift. This directly resolves the CLAUDE.md warning that
42
+ `KNOWN_FLAGS` must be hand-synced with `parseArgs`.
43
+
44
+ Behaviour:
45
+ - `--help` / `-h` / **bare invocation with no args** → print grouped usage
46
+ (header, usage line, flags grouped by `group` with aligned descriptions and
47
+ defaults) and **exit 0**.
48
+ - Exclude `--help`/`-h` from the did-you-mean candidate set so the suggestion is
49
+ never actively misleading.
50
+
51
+ ### Alternatives considered
52
+ - *Hand-written usage block, `KNOWN_FLAGS` stays bare strings.* Rejected:
53
+ descriptions live apart from the flag list and drift — exactly the duplication
54
+ the codebase warns against.
55
+
56
+ ---
57
+
58
+ ## #2 — Offline doc-envelope emission (LOW)
59
+
60
+ ### Problem
61
+ `--file <path>` codegen works, but nothing *produces* that file. The envelope
62
+ only exists in-process via `builder.toDocEnvelope(cfg)`, so the regen loop forces
63
+ either a running server (`codegen --url`) or a hand-written `JSON.stringify`
64
+ script.
65
+
66
+ ### Decision: exported helper only
67
+ Ship a public, stable helper:
68
+
69
+ ```ts
70
+ export async function writeDocEnvelope(
71
+ source: DocEnvelope | { toDocEnvelope(): DocEnvelope } | DocRegistry,
72
+ path: string,
73
+ ): Promise<void>
74
+ ```
75
+
76
+ - Accepts a built `HonoAppBuilder`/`DocRegistry` (anything with
77
+ `toDocEnvelope()`) **or** a plain `DocEnvelope`.
78
+ - Serializes pretty JSON to `path` (creating parent dirs).
79
+ - Exported from a stable entry point and documented with a 3-line script:
80
+ `build builder → writeDocEnvelope(builder, 'docs.json') → codegen --file docs.json`.
81
+
82
+ No module-loading magic in the CLI — the user owns the tiny emit script, which is
83
+ the most portable option and avoids a TS-loader dependency.
84
+
85
+ ### Alternatives considered
86
+ - *CLI `--builder <module>` that imports the user's builder in-process.* Rejected
87
+ for now: importing user TS/ESM requires a loader (tsx/jiti) the package doesn't
88
+ carry; pushes complexity and failure modes into the CLI.
89
+
90
+ ---
91
+
92
+ ## #3 — Shared types keyed on `$id` (MEDIUM)
93
+
94
+ ### Problem
95
+ Codegen inlines a fresh structural literal at every route site. The same `Message`
96
+ entity is emitted ~4× under different names in one scope file
97
+ (`GetThread.Response.Message`, `ListMessages.Response.RootType`,
98
+ `SendMessage.Response.Body`, nested in `CreateThread`), none connected to the
99
+ authored `@shared/schemas` `Message`. The copies type-check only because the
100
+ shapes coincide today; a field change one codegen path misses drifts **silently**.
101
+
102
+ ### Decision: hoist in codegen, leave the envelope untouched
103
+
104
+ **Architectural call.** The `$id` already rides along on route subschemas in the
105
+ envelope (verified: `Type.Object({…}, { $id, title })` survives `toDocEnvelope()`).
106
+ Rather than change the envelope into a `$defs`/`$ref` table — which would break
107
+ every already-serialized envelope and force the Kotlin/Swift pipelines to become
108
+ `$ref`-aware — the **TS codegen** does a pre-pass over the inlined schemas. Result:
109
+ zero envelope-shape change, zero impact on Kotlin/Swift, already-serialized
110
+ envelopes keep working.
111
+
112
+ #### Identity rule (correctness-critical)
113
+ - Hoist **only** schemas carrying `$id`. `$id` is the dedup key (globally unique).
114
+ - The generated type **name** comes from `title` (fallback: derive from `$id`).
115
+ - Schemas with a `title` but no `$id` are **not** hoisted — they stay inlined
116
+ exactly as today. Identity is *declared*, never inferred from structure.
117
+ - If two subschemas share an `$id` but have **divergent bodies** → **hard error**
118
+ at collect time (never silently pick one).
119
+ - Schemas without `$id` produce byte-identical output to today, so existing
120
+ consumers who never set `$id` see no change.
121
+
122
+ #### Components
123
+ 1. **`src/codegen/collect-models.ts`** (new) — walks every route's JSON schema
124
+ recursively (all four route kinds: rpc/api/stream/http-stream slots), collects
125
+ each subschema carrying `$id` into a model registry
126
+ `{ $id, name, schema }`. Detects `$id` body-divergence collisions and throws.
127
+ Also detects generated-name collisions between distinct `$id`s and disambiguates
128
+ (`Message`, `Message2`, …) deterministically by first-seen order.
129
+
130
+ 2. **`_models.ts`** (new emitted file) — the single hub:
131
+ - For each model whose `$id` is in the `sharedTypesImport` map → emit a
132
+ re-export: `export { Message } from '@shared/schemas'` (renamed to the
133
+ model name if `name` differs).
134
+ - Otherwise → emit a generated named type for the model schema (via ajsc).
135
+ - Scopes **always** import from `_models.ts`, regardless of generated-vs-vendored.
136
+ Flipping a type between the two is a one-line change here with zero scope churn.
137
+
138
+ 3. **`emit-scope` change** — when a route's schema contains a hoisted `$id`
139
+ subschema, the emitted route type **references** the model's name (import in
140
+ flat mode, qualified `${Service}Models.Message` in namespace mode) instead of
141
+ inlining the literal. Non-hoisted subschemas emit unchanged.
142
+
143
+ 4. **`sharedTypesImport` config** (config-file only — a map doesn't fit a CLI flag):
144
+ ```jsonc
145
+ // ts-procedures-codegen.config.json
146
+ "sharedTypesImport": {
147
+ "https://schemas.example/message": { "module": "@shared/schemas", "name": "Message" }
148
+ }
149
+ ```
150
+
151
+ 5. **Flag:** `--share-models` defaults **on** (consistent with the other
152
+ good-by-default flags); `--no-share-models` opts out and restores pure
153
+ per-route inlining. Added to the structured flag table from #1.
154
+
155
+ #### Scope
156
+ - **TS target only.** Kotlin/Swift continue to inline (they simply ignore `$id`
157
+ metadata; the envelope is unchanged so their pipelines are untouched).
158
+
159
+ #### Key technical risk — verify first
160
+ The plan opens with a short **verification spike** before committing the emission
161
+ mechanism:
162
+ - **ajsc `$ref` behaviour (v7.2.0):** confirm how ajsc emits a `$ref` to a hoisted
163
+ model — whether it produces a *reference* to a named type or inlines it. Per
164
+ CLAUDE.md ajsc "resolves `$defs`/`$ref`", which may mean *inline*. Two paths:
165
+ - *Preferred:* feed ajsc a route schema with the hoisted subschema replaced by
166
+ `{ $ref: '#/$defs/Message' }` + a `$defs` table, and capture ajsc's named-type
167
+ reference.
168
+ - *Fallback:* emit model types standalone, then post-emit string-substitute the
169
+ inlined literal with the model name — the same word-boundary patching approach
170
+ `renameExtractedTypes` (`src/codegen/emit-types.ts`) already uses.
171
+ - **Nested `$id` survival:** confirm `extractJsonSchema` preserves `$id` on
172
+ **nested** subschemas (root `$id` is verified; nested is not). If stripped, fix
173
+ extraction to preserve it — this is a prerequisite for hoisting nested entities
174
+ like `Thread.messages: Message[]`.
175
+
176
+ ### Alternatives considered
177
+ - *Change the envelope to `$defs`/`$ref`.* Rejected: breaks serialized envelopes
178
+ and forces all targets to become `$ref`-aware.
179
+ - *Hoist into existing `_types.ts`.* Rejected: mixes domain entities with
180
+ transport/runtime types and only works in self-contained mode.
181
+ - *Per-scope first-declarer-owns.* Rejected: implicit cross-scope ordering/coupling.
182
+ - *Auto-merge structurally-identical types.* Explicitly rejected per the feedback:
183
+ coincidental shape-equality must not couple two scopes.
184
+ - *Dedup by `$id` OR `title`.* Rejected: two unrelated schemas titled `Response`
185
+ would wrongly merge — weak identity.
186
+
187
+ ---
188
+
189
+ ## #4 — Dynamic auth seam: function-valued headers (MEDIUM)
190
+
191
+ ### Problem
192
+ `config.headers` is a static `Record<string,string>` (`src/client/types.ts:202`),
193
+ captured once at construction. The only dynamic seam is `onBeforeRequest`, which
194
+ is discoverable only by reading `_client.ts`. A live bearer token set in `headers`
195
+ silently goes stale after the next login (the easy-and-wrong path compiles).
196
+
197
+ ### Decision: function-valued headers + signposting docs
198
+ Make `headers` accept a function on both the client `defaults` and per-call options:
199
+
200
+ ```ts
201
+ type HeadersInit =
202
+ | Record<string, string>
203
+ | (() => Record<string, string> | Promise<Record<string, string>>)
204
+ ```
205
+
206
+ - `ProcedureCallDefaults.headers` and `ProcedureCallOptions.headers` adopt
207
+ `HeadersInit` (`src/client/types.ts`).
208
+ - Resolution happens on the **async request path**: `call.ts` / `stream.ts` await
209
+ header resolution before building the `AdapterRequest`. The static-record path
210
+ stays synchronous and fast (no behavioural change when headers is a plain object).
211
+ - Merge semantics preserved: resolve defaults-headers and per-call-headers to
212
+ records, then merge with per-call winning — `onBeforeRequest` still has final say.
213
+ - The resolved `AdapterRequest.headers` remains a plain `Record<string,string>` —
214
+ adapters are unaffected.
215
+ - Regenerates into self-contained `_types.ts` / `_client.ts`; direct consumers get
216
+ it from `ts-procedures/client`.
217
+
218
+ **Docs:** add a short auth section (in the generated-client docs /
219
+ `client-and-codegen.md`) presenting function-valued `headers` and `onBeforeRequest`
220
+ as the canonical auth seams, with an explicit "a token in static `headers` goes
221
+ stale — use a function or the hook" warning.
222
+
223
+ ### Alternatives considered
224
+ - *First-class `auth`/`bearer` option wiring the hook internally.* Not chosen this
225
+ round — function-valued headers is the general-purpose primitive that also covers
226
+ auth; a dedicated `auth` sugar can layer on later if wanted.
227
+ - *Docs only.* Rejected: the easy-and-wrong static-headers path still compiles.
228
+
229
+ ---
230
+
231
+ ## #5 — Scaffolder file-naming knobs (LOW)
232
+
233
+ ### Problem
234
+ The scaffolder (the AI skill `agent_config/claude-code/skills/ts-procedures/SKILL.md`
235
+ scaffold mode + `templates/*.md`, **not** a CLI) hardcodes `{{Name}}.procedure.ts` —
236
+ PascalCase, one file per procedure — so every scaffold is renamed and folded into a
237
+ per-scope file by hand.
238
+
239
+ ### Decision: `fileNameStyle` + `groupBy` with auto-default
240
+ Add two scaffold-mode args, surfaced in `SKILL.md` and consumed by the templates:
241
+ - `fileNameStyle`: `'PascalCase'` (current) | `'kebab.concern'` (e.g.
242
+ `get-user.procedure.ts`).
243
+ - `groupBy`: `'flat'` (current, CWD) | `'scope'` (e.g. `<scope>/get-user.procedure.ts`,
244
+ scope taken from the procedure config).
245
+
246
+ When **unspecified**, the skill infers the default by inspecting the target
247
+ directory's existing convention (per-scope kebab files vs flat Pascal) — so a
248
+ scaffold matches the consumer's layout with no manual rename pass.
249
+
250
+ Changes:
251
+ - `SKILL.md` scaffold-mode section: document the two args + the auto-default
252
+ inference instruction; update the "Files Generated" table to show the computed
253
+ filename.
254
+ - `templates/*.md`: replace the hardcoded `{{Name}}.<concern>.ts` output with a
255
+ computed `{{fileName}}` placeholder and an optional `{{scope}}/` directory
256
+ prefix, plus the placeholder-derivation notes (`{{kebab}}` already exists).
257
+
258
+ ### Alternatives considered
259
+ - *Add knobs, explicit-only (no auto-detect).* Rejected: misses the zero-config
260
+ match to the repo layout, which is the actual friction.
261
+ - *Defer.* Rejected by scope decision (all three findings actioned this round).
262
+
263
+ ---
264
+
265
+ ## Testing strategy
266
+ - **#1:** unit test `--help`/`-h`/bare → exit 0 + usage contains every flag from the
267
+ table; `--help` not in did-you-mean candidates; unknown flag still errors.
268
+ - **#2:** unit test `writeDocEnvelope` with (a) a plain envelope, (b) a builder-like
269
+ object — round-trips to disk and re-loads via `resolveEnvelope --file`.
270
+ - **#3:** spike verification first; then fixture-based tests on the canonical
271
+ `__fixtures__/users-envelope.json` extended with `$id`'d schemas — assert one
272
+ `_models.ts` declaration per `$id`, route types reference it, `$id` collision
273
+ throws, `sharedTypesImport` produces re-exports, and `--no-share-models` restores
274
+ byte-identical legacy output. Add a no-`$id` regression asserting unchanged output.
275
+ - **#4:** unit test function-valued `headers` (sync + async) resolves per request,
276
+ merges with per-call, and `onBeforeRequest` still overrides; static-record path
277
+ unchanged.
278
+ - **#5:** verify by scaffolding under each `fileNameStyle`/`groupBy` combination and
279
+ the auto-default inference (skill-level / template review).
280
+
281
+ ## Out of scope (follow-up)
282
+ - `ts-channels` codegen shared-types fix (identical to #3).
283
+ - Cross-scaffolder alignment with `ts-channels #9` / `mvc-kit #2` (#5).
284
+ - A first-class `auth`/`bearer` client option (sugar over #4).
285
+ - Kotlin/Swift shared-model emission.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ts-procedures",
3
- "version": "8.3.0",
3
+ "version": "8.5.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",
@@ -88,7 +88,7 @@
88
88
  "trpc"
89
89
  ],
90
90
  "optionalDependencies": {
91
- "ajsc": "^7.2.0",
91
+ "ajsc": "^7.3.0",
92
92
  "astro": "6.x.x || 7.x.x",
93
93
  "hono": "^4.7.4"
94
94
  },
@@ -55,7 +55,7 @@ export async function executeCall<TResponse>(config: ExecuteCallConfig): Promise
55
55
  let request = buildAdapterRequest(descriptor, resolvedBasePath)
56
56
 
57
57
  // 2. Apply request-level options (headers, signal, timeout, meta)
58
- const applied = applyRequestOptions(request, defaults, options)
58
+ const applied = await applyRequestOptions(request, defaults, options)
59
59
  request = applied.request
60
60
  const signalSources = applied.signalSources
61
61
 
@@ -387,6 +387,104 @@ describe('createClient', () => {
387
387
  }
388
388
  })
389
389
 
390
+ // ── auth (bearer token sugar) ─────────────────────────────
391
+
392
+ it('auth wires Authorization: Bearer <token>, re-evaluated per call', async () => {
393
+ const capturedHeaders: Record<string, string>[] = []
394
+ const adapter: ClientAdapter = {
395
+ request: vi.fn(async (req: AdapterRequest): Promise<AdapterResponse> => {
396
+ capturedHeaders.push(req.headers ?? {})
397
+ return { status: 200, headers: {}, body: {} }
398
+ }),
399
+ stream: vi.fn(async (): Promise<AdapterStreamResponse> => ({
400
+ status: 200,
401
+ headers: new Headers(),
402
+ body: makeAsyncIterable([]),
403
+ })),
404
+ }
405
+
406
+ let token = 'tok-1'
407
+ const client = createClient({
408
+ adapter,
409
+ basePath: 'https://api.example.com',
410
+ auth: () => token,
411
+ scopes: (instance: ClientInstance) => ({
412
+ users: {
413
+ getUser: () => instance.call<unknown>(makeCallDescriptor()),
414
+ },
415
+ }),
416
+ })
417
+
418
+ await client.users.getUser()
419
+ expect(capturedHeaders[0]?.['Authorization']).toBe('Bearer tok-1')
420
+
421
+ token = 'tok-2'
422
+ await client.users.getUser()
423
+ expect(capturedHeaders[1]?.['Authorization']).toBe('Bearer tok-2')
424
+ })
425
+
426
+ it('auth omits the Authorization header when the token is null', async () => {
427
+ const capturedHeaders: Record<string, string>[] = []
428
+ const adapter: ClientAdapter = {
429
+ request: vi.fn(async (req: AdapterRequest): Promise<AdapterResponse> => {
430
+ capturedHeaders.push(req.headers ?? {})
431
+ return { status: 200, headers: {}, body: {} }
432
+ }),
433
+ stream: vi.fn(async (): Promise<AdapterStreamResponse> => ({
434
+ status: 200,
435
+ headers: new Headers(),
436
+ body: makeAsyncIterable([]),
437
+ })),
438
+ }
439
+
440
+ const client = createClient({
441
+ adapter,
442
+ basePath: 'https://api.example.com',
443
+ auth: () => null,
444
+ scopes: (instance: ClientInstance) => ({
445
+ users: {
446
+ getUser: () => instance.call<unknown>(makeCallDescriptor()),
447
+ },
448
+ }),
449
+ })
450
+
451
+ await client.users.getUser()
452
+ expect(capturedHeaders[0]?.['Authorization']).toBeUndefined()
453
+ })
454
+
455
+ it('auth composes with existing defaults.headers (both present)', async () => {
456
+ const capturedHeaders: Record<string, string>[] = []
457
+ const adapter: ClientAdapter = {
458
+ request: vi.fn(async (req: AdapterRequest): Promise<AdapterResponse> => {
459
+ capturedHeaders.push(req.headers ?? {})
460
+ return { status: 200, headers: {}, body: {} }
461
+ }),
462
+ stream: vi.fn(async (): Promise<AdapterStreamResponse> => ({
463
+ status: 200,
464
+ headers: new Headers(),
465
+ body: makeAsyncIterable([]),
466
+ })),
467
+ }
468
+
469
+ const client = createClient({
470
+ adapter,
471
+ basePath: 'https://api.example.com',
472
+ defaults: { headers: { 'x-client': 'web' } },
473
+ auth: async () => 'tok-async',
474
+ scopes: (instance: ClientInstance) => ({
475
+ users: {
476
+ getUser: () => instance.call<unknown>(makeCallDescriptor()),
477
+ },
478
+ }),
479
+ })
480
+
481
+ await client.users.getUser()
482
+ expect(capturedHeaders[0]).toMatchObject({
483
+ 'x-client': 'web',
484
+ Authorization: 'Bearer tok-async',
485
+ })
486
+ })
487
+
390
488
  it('ClientInstance exposes defaults', () => {
391
489
  const adapter = makeAdapter()
392
490
  const defaults = { timeout: 5000, headers: { 'x-client': 'test' } }
@@ -6,6 +6,8 @@ import type {
6
6
  CallDescriptor,
7
7
  StreamDescriptor,
8
8
  ProcedureCallOptions,
9
+ ProcedureCallDefaults,
10
+ ClientHeadersInit,
9
11
  TypedStream,
10
12
  Result,
11
13
  ResultNoTyped,
@@ -13,6 +15,24 @@ import type {
13
15
 
14
16
  // ── createClient ──────────────────────────────────────────
15
17
 
18
+ /**
19
+ * Folds `config.auth` into the resolved default headers as a single async
20
+ * function-valued `ClientHeadersInit`. Resolves the user's existing default
21
+ * headers (record OR function) to a record first, then appends
22
+ * `Authorization: Bearer <token>` when `auth()` yields a non-null token.
23
+ * Re-evaluated per request, so a rotating token never goes stale.
24
+ */
25
+ function composeAuthHeaders(
26
+ base: ClientHeadersInit | undefined,
27
+ auth: NonNullable<CreateClientConfig<unknown>['auth']>,
28
+ ): ClientHeadersInit {
29
+ return async () => {
30
+ const resolvedBase = base == null ? {} : typeof base === 'function' ? await base() : base
31
+ const token = await auth()
32
+ return token ? { ...resolvedBase, Authorization: `Bearer ${token}` } : resolvedBase
33
+ }
34
+ }
35
+
16
36
  /**
17
37
  * Creates a typed client from a config object.
18
38
  *
@@ -31,11 +51,21 @@ export function createClient<TScopes>(config: CreateClientConfig<TScopes>): TSco
31
51
  adapter,
32
52
  basePath,
33
53
  hooks: globalHooks = {},
34
- defaults: globalDefaults = {},
54
+ defaults: configDefaults = {},
35
55
  errorRegistry,
56
+ auth,
36
57
  scopes,
37
58
  } = config
38
59
 
60
+ // `auth` is sugar over a function-valued `defaults.headers`: fold it into the
61
+ // resolved default headers as an async function so it shares the single
62
+ // per-request header-resolution path (no new resolution branch). The user's
63
+ // existing `defaults.headers` (record or function) is resolved first, then the
64
+ // bearer token is appended — a null/undefined token omits the header.
65
+ const globalDefaults: ProcedureCallDefaults = auth
66
+ ? { ...configDefaults, headers: composeAuthHeaders(configDefaults.headers, auth) }
67
+ : configDefaults
68
+
39
69
  const instance: ClientInstance = {
40
70
  basePath,
41
71
  adapter,
@@ -171,6 +201,7 @@ export type {
171
201
  ClientInstance,
172
202
  ProcedureCallDefaults,
173
203
  ProcedureCallOptions,
204
+ ClientHeadersInit,
174
205
  CreateClientConfig,
175
206
  RequestMeta,
176
207
  ErrorRegistry,
@@ -9,6 +9,13 @@ import {
9
9
  } from './resolve-options.js'
10
10
  import type { AdapterRequest, ProcedureCallDefaults, ProcedureCallOptions } from './types.js'
11
11
 
12
+ // Minimal valid AdapterRequest (headers undefined) shared across describe blocks.
13
+ const baseRequest: AdapterRequest = {
14
+ url: 'https://api.example.com/foo',
15
+ method: 'POST',
16
+ body: { hello: 'world' },
17
+ }
18
+
12
19
  // ── resolveBasePath ───────────────────────────────────────
13
20
 
14
21
  describe('resolveBasePath', () => {
@@ -38,24 +45,70 @@ describe('resolveBasePath', () => {
38
45
  // ── resolveHeaders ────────────────────────────────────────
39
46
 
40
47
  describe('resolveHeaders', () => {
41
- it('returns undefined when neither side sets headers', () => {
42
- expect(resolveHeaders(undefined, undefined)).toBeUndefined()
48
+ it('returns undefined when neither side sets headers', async () => {
49
+ expect(await resolveHeaders(undefined, undefined)).toBeUndefined()
43
50
  })
44
51
 
45
- it('returns default headers when only defaults set', () => {
46
- expect(resolveHeaders({ headers: { 'x-a': '1' } }, undefined)).toEqual({ 'x-a': '1' })
52
+ it('returns default headers when only defaults set', async () => {
53
+ expect(await resolveHeaders({ headers: { 'x-a': '1' } }, undefined)).toEqual({ 'x-a': '1' })
47
54
  })
48
55
 
49
- it('per-call keys override default keys', () => {
56
+ it('per-call keys override default keys', async () => {
50
57
  const defaults: ProcedureCallDefaults = { headers: { 'x-a': 'default', 'x-b': 'keep' } }
51
58
  const options: ProcedureCallOptions = { headers: { 'x-a': 'override' } }
52
- expect(resolveHeaders(defaults, options)).toEqual({
59
+ expect(await resolveHeaders(defaults, options)).toEqual({
53
60
  'x-a': 'override',
54
61
  'x-b': 'keep',
55
62
  })
56
63
  })
57
64
  })
58
65
 
66
+ // ── resolveHeaders (function-valued) ──────────────────────
67
+
68
+ describe('function-valued headers', () => {
69
+ it('resolves a sync function-valued default header', async () => {
70
+ const { request } = await applyRequestOptions(
71
+ baseRequest,
72
+ { headers: () => ({ Authorization: 'Bearer t1' }) },
73
+ undefined,
74
+ )
75
+ expect(request.headers).toMatchObject({ Authorization: 'Bearer t1' })
76
+ })
77
+
78
+ it('resolves an async function-valued header', async () => {
79
+ const { request } = await applyRequestOptions(
80
+ baseRequest,
81
+ { headers: async () => ({ Authorization: 'Bearer async' }) },
82
+ undefined,
83
+ )
84
+ expect(request.headers).toMatchObject({ Authorization: 'Bearer async' })
85
+ })
86
+
87
+ it('per-call headers win over default headers (both functions)', async () => {
88
+ const { request } = await applyRequestOptions(
89
+ baseRequest,
90
+ { headers: () => ({ Authorization: 'Bearer default', 'x-a': '1' }) },
91
+ { headers: () => ({ Authorization: 'Bearer call' }) },
92
+ )
93
+ expect(request.headers).toMatchObject({ Authorization: 'Bearer call', 'x-a': '1' })
94
+ })
95
+
96
+ it('static record path still works', async () => {
97
+ const { request } = await applyRequestOptions(baseRequest, { headers: { 'x-s': 'v' } }, undefined)
98
+ expect(request.headers).toMatchObject({ 'x-s': 'v' })
99
+ })
100
+
101
+ it('re-evaluates the function on each call (no staleness)', async () => {
102
+ let token = 't1'
103
+ const headers = () => ({ Authorization: `Bearer ${token}` })
104
+ const first = await applyRequestOptions(baseRequest, { headers }, undefined)
105
+ expect(first.request.headers).toMatchObject({ Authorization: 'Bearer t1' })
106
+ token = 't2'
107
+ const second = await applyRequestOptions(baseRequest, { headers }, undefined)
108
+ expect(second.request.headers).toMatchObject({ Authorization: 'Bearer t2' })
109
+ })
110
+ })
111
+
59
112
  // ── resolveMeta ───────────────────────────────────────────
60
113
 
61
114
  describe('resolveMeta', () => {
@@ -202,21 +255,15 @@ describe('resolveSignalSources', () => {
202
255
  // ── applyRequestOptions ───────────────────────────────────
203
256
 
204
257
  // Helper so existing tests can destructure .request without boilerplate
205
- const apply = (
258
+ const apply = async (
206
259
  req: AdapterRequest,
207
260
  d: ProcedureCallDefaults | undefined,
208
261
  o: ProcedureCallOptions | undefined,
209
- ) => applyRequestOptions(req, d, o).request
262
+ ) => (await applyRequestOptions(req, d, o)).request
210
263
 
211
264
  describe('applyRequestOptions', () => {
212
- const baseRequest: AdapterRequest = {
213
- url: 'https://api.example.com/foo',
214
- method: 'POST',
215
- body: { hello: 'world' },
216
- }
217
-
218
- it('returns the request unchanged when nothing is provided', () => {
219
- const result = apply(baseRequest, undefined, undefined)
265
+ it('returns the request unchanged when nothing is provided', async () => {
266
+ const result = await apply(baseRequest, undefined, undefined)
220
267
  expect(result.url).toBe(baseRequest.url)
221
268
  expect(result.body).toEqual({ hello: 'world' })
222
269
  expect(result.headers).toBeUndefined()
@@ -224,12 +271,12 @@ describe('applyRequestOptions', () => {
224
271
  expect(result.meta).toBeUndefined()
225
272
  })
226
273
 
227
- it('merges default + per-call headers, preserving route-declared headers', () => {
274
+ it('merges default + per-call headers, preserving route-declared headers', async () => {
228
275
  const reqWithHeaders: AdapterRequest = {
229
276
  ...baseRequest,
230
277
  headers: { 'content-type': 'application/json', 'x-route': 'declared' },
231
278
  }
232
- const result = apply(
279
+ const result = await apply(
233
280
  reqWithHeaders,
234
281
  { headers: { 'x-default': 'd', 'x-route': 'from-default' } },
235
282
  { headers: { 'x-call': 'c', 'x-route': 'from-call' } },
@@ -243,23 +290,23 @@ describe('applyRequestOptions', () => {
243
290
  })
244
291
  })
245
292
 
246
- it('attaches meta to the request when provided', () => {
247
- const result = apply(baseRequest, undefined, {
293
+ it('attaches meta to the request when provided', async () => {
294
+ const result = await apply(baseRequest, undefined, {
248
295
  meta: { traceId: 'abc' } as never,
249
296
  })
250
297
  expect(result.meta).toEqual({ traceId: 'abc' })
251
298
  })
252
299
 
253
- it('passes per-call signal through', () => {
300
+ it('passes per-call signal through', async () => {
254
301
  const controller = new AbortController()
255
- const result = apply(baseRequest, undefined, { signal: controller.signal })
302
+ const result = await apply(baseRequest, undefined, { signal: controller.signal })
256
303
  expect(result.signal).toBe(controller.signal)
257
304
  })
258
305
 
259
- it('attaches a signal when per-call timeout is set', () => {
306
+ it('attaches a signal when per-call timeout is set', async () => {
260
307
  const spy = vi.spyOn(AbortSignal, 'timeout')
261
308
  try {
262
- const result = apply(baseRequest, undefined, { timeout: 100 })
309
+ const result = await apply(baseRequest, undefined, { timeout: 100 })
263
310
  expect(spy).toHaveBeenCalledWith(100)
264
311
  expect(result.signal).toBeDefined()
265
312
  expect(result.signal?.aborted).toBe(false)
@@ -268,9 +315,9 @@ describe('applyRequestOptions', () => {
268
315
  }
269
316
  })
270
317
 
271
- it('exposes signalSources alongside the request', () => {
318
+ it('exposes signalSources alongside the request', async () => {
272
319
  const ctrl = new AbortController()
273
- const { request, signalSources } = applyRequestOptions(baseRequest, undefined, {
320
+ const { request, signalSources } = await applyRequestOptions(baseRequest, undefined, {
274
321
  signal: ctrl.signal,
275
322
  timeout: 500,
276
323
  })