vapor-chamber 1.23.0 → 1.25.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 (238) hide show
  1. package/CHANGELOG.md +649 -0
  2. package/README.md +115 -82
  3. package/ROADMAP.md +3 -3
  4. package/dist/LICENSE.txt +6 -3
  5. package/dist/a11y.d.ts +29 -0
  6. package/dist/a11y.d.ts.map +1 -0
  7. package/dist/a11y.js +43 -0
  8. package/dist/a11y.js.map +1 -0
  9. package/dist/chamber-vapor.d.ts +8 -19
  10. package/dist/chamber-vapor.d.ts.map +1 -1
  11. package/dist/chamber-vapor.js +2 -2
  12. package/dist/chamber-vapor.js.map +1 -1
  13. package/dist/chamber.d.ts +22 -59
  14. package/dist/chamber.d.ts.map +1 -1
  15. package/dist/chamber.js +53 -71
  16. package/dist/chamber.js.map +1 -1
  17. package/dist/command-bus.d.ts +153 -131
  18. package/dist/command-bus.d.ts.map +1 -1
  19. package/dist/command-bus.js +269 -187
  20. package/dist/command-bus.js.map +1 -1
  21. package/dist/dict.d.ts +1 -1
  22. package/dist/dict.js.map +1 -1
  23. package/dist/directives.d.ts +42 -78
  24. package/dist/directives.d.ts.map +1 -1
  25. package/dist/directives2.js +61 -3
  26. package/dist/directives2.js.map +1 -1
  27. package/dist/form.d.ts +30 -1
  28. package/dist/form.d.ts.map +1 -1
  29. package/dist/http-cache.d.ts +6 -9
  30. package/dist/http-cache.d.ts.map +1 -1
  31. package/dist/http-errors.d.ts +28 -20
  32. package/dist/http-errors.d.ts.map +1 -1
  33. package/dist/http.d.ts +19 -54
  34. package/dist/http.d.ts.map +1 -1
  35. package/dist/http.js +43 -29
  36. package/dist/http.js.map +1 -1
  37. package/dist/iife-core.d.ts +8 -10
  38. package/dist/iife-core.d.ts.map +1 -1
  39. package/dist/iife-core.js +13 -10
  40. package/dist/iife-core.js.map +1 -1
  41. package/dist/iife-elements.d.ts +9 -14
  42. package/dist/iife-elements.d.ts.map +1 -1
  43. package/dist/iife-elements.js +12 -9
  44. package/dist/iife-elements.js.map +1 -1
  45. package/dist/iife.d.ts +7 -9
  46. package/dist/iife.d.ts.map +1 -1
  47. package/dist/iife.js +13 -9
  48. package/dist/iife.js.map +1 -1
  49. package/dist/index.d.ts +9 -37
  50. package/dist/index.d.ts.map +1 -1
  51. package/dist/index.js +189 -124
  52. package/dist/index.js.map +1 -1
  53. package/dist/keys.js.map +1 -1
  54. package/dist/ledger.d.ts +29 -0
  55. package/dist/ledger.d.ts.map +1 -0
  56. package/dist/ledger.js +59 -0
  57. package/dist/ledger.js.map +1 -0
  58. package/dist/mcp.d.ts +5 -25
  59. package/dist/mcp.d.ts.map +1 -1
  60. package/dist/mcp.js +4 -7
  61. package/dist/mcp.js.map +1 -1
  62. package/dist/outbox.d.ts +4 -3
  63. package/dist/outbox.d.ts.map +1 -1
  64. package/dist/outbox.js +18 -9
  65. package/dist/outbox.js.map +1 -1
  66. package/dist/plugins-core.d.ts +8 -18
  67. package/dist/plugins-core.d.ts.map +1 -1
  68. package/dist/plugins-core.js +197 -0
  69. package/dist/plugins-core.js.map +1 -0
  70. package/dist/plugins-extra.d.ts.map +1 -1
  71. package/dist/plugins-io.d.ts +7 -83
  72. package/dist/plugins-io.d.ts.map +1 -1
  73. package/dist/plugins-io.js +4 -289
  74. package/dist/plugins-io.js.map +1 -1
  75. package/dist/plugins-schema.d.ts +1 -1
  76. package/dist/plugins-schema.d.ts.map +1 -1
  77. package/dist/plugins-schema.js +3 -5
  78. package/dist/plugins-schema.js.map +1 -1
  79. package/dist/plugins.d.ts +2 -2
  80. package/dist/plugins.d.ts.map +1 -1
  81. package/dist/router/announce.d.ts +14 -0
  82. package/dist/router/announce.d.ts.map +1 -0
  83. package/dist/router/composables.d.ts +2 -3
  84. package/dist/router/composables.d.ts.map +1 -1
  85. package/dist/router/dom.d.ts +5 -0
  86. package/dist/router/dom.d.ts.map +1 -1
  87. package/dist/router/engine.d.ts +7 -8
  88. package/dist/router/engine.d.ts.map +1 -1
  89. package/dist/router/errors.d.ts +4 -4
  90. package/dist/router/errors.d.ts.map +1 -1
  91. package/dist/router/index.d.ts +14 -1
  92. package/dist/router/index.d.ts.map +1 -1
  93. package/dist/router/loaders.d.ts.map +1 -1
  94. package/dist/router/menu.d.ts +7 -2
  95. package/dist/router/menu.d.ts.map +1 -1
  96. package/dist/router/remote.d.ts +8 -13
  97. package/dist/router/remote.d.ts.map +1 -1
  98. package/dist/router/remote.js.map +1 -1
  99. package/dist/router/revalidate.d.ts +9 -16
  100. package/dist/router/revalidate.d.ts.map +1 -1
  101. package/dist/router/table.d.ts +4 -3
  102. package/dist/router/table.d.ts.map +1 -1
  103. package/dist/router/types.d.ts +5 -2
  104. package/dist/router/types.d.ts.map +1 -1
  105. package/dist/router/url.d.ts.map +1 -1
  106. package/dist/router/vapor.d.ts +6 -8
  107. package/dist/router/vapor.d.ts.map +1 -1
  108. package/dist/router/vapor.js.map +1 -1
  109. package/dist/router/vdom.js.map +1 -1
  110. package/dist/router.js +104 -29
  111. package/dist/router.js.map +1 -1
  112. package/dist/scheduler.d.ts +31 -0
  113. package/dist/scheduler.d.ts.map +1 -0
  114. package/dist/schema.d.ts +30 -9
  115. package/dist/schema.d.ts.map +1 -1
  116. package/dist/settled.d.ts +28 -55
  117. package/dist/settled.d.ts.map +1 -1
  118. package/dist/settled.js +24 -1
  119. package/dist/settled.js.map +1 -1
  120. package/dist/signal.d.ts.map +1 -1
  121. package/dist/signal.js +3 -5
  122. package/dist/signal.js.map +1 -1
  123. package/dist/ssr.d.ts +8 -40
  124. package/dist/ssr.d.ts.map +1 -1
  125. package/dist/ssr2.js +2 -2
  126. package/dist/ssr2.js.map +1 -1
  127. package/dist/store.d.ts +1 -1
  128. package/dist/store.d.ts.map +1 -1
  129. package/dist/store.js +5 -2
  130. package/dist/store.js.map +1 -1
  131. package/dist/stream-parser.d.ts +2 -4
  132. package/dist/stream-parser.d.ts.map +1 -1
  133. package/dist/stream-parser.js +2 -4
  134. package/dist/stream-parser.js.map +1 -1
  135. package/dist/testing.d.ts +14 -5
  136. package/dist/testing.d.ts.map +1 -1
  137. package/dist/transitions.d.ts +14 -36
  138. package/dist/transitions.d.ts.map +1 -1
  139. package/dist/transitions2.js.map +1 -1
  140. package/dist/transports.d.ts +27 -41
  141. package/dist/transports.d.ts.map +1 -1
  142. package/dist/transports.js +1 -414
  143. package/dist/transports2.js +415 -0
  144. package/dist/transports2.js.map +1 -0
  145. package/dist/utilities.d.ts +1 -1
  146. package/dist/utilities.d.ts.map +1 -1
  147. package/dist/vapor-chamber-core.iife.js +625 -762
  148. package/dist/vapor-chamber-core.iife.js.map +1 -1
  149. package/dist/vapor-chamber-core.iife.min.js +1 -1
  150. package/dist/vapor-chamber-elements.iife.js +643 -790
  151. package/dist/vapor-chamber-elements.iife.js.map +1 -1
  152. package/dist/vapor-chamber-elements.iife.min.js +1 -1
  153. package/dist/vapor-chamber.iife.js +928 -949
  154. package/dist/vapor-chamber.iife.js.map +1 -1
  155. package/dist/vapor-chamber.iife.min.js +1 -1
  156. package/dist/vapor.d.ts +3 -3
  157. package/dist/vapor.js.map +1 -1
  158. package/dist/vite-hmr.d.ts +33 -91
  159. package/dist/vite-hmr.d.ts.map +1 -1
  160. package/dist/vite-hmr.js +3 -4
  161. package/dist/vite-hmr.js.map +1 -1
  162. package/dist/vitest-mcp.js +3 -3
  163. package/dist/vitest-mcp.js.map +1 -1
  164. package/dist/vitest-pure.d.ts +10 -20
  165. package/dist/vitest-pure.d.ts.map +1 -1
  166. package/dist/vitest-pure.js +11 -21
  167. package/dist/vitest-pure.js.map +1 -1
  168. package/dist/vitest.d.ts.map +1 -1
  169. package/dist/vitest.js.map +1 -1
  170. package/dist/vue.d.ts +9 -18
  171. package/dist/vue.d.ts.map +1 -1
  172. package/dist/vue.js.map +1 -1
  173. package/package.json +5 -2
  174. package/scripts/build.mjs +11 -13
  175. package/scripts/check-ascii.mjs +8 -17
  176. package/scripts/check-example-directive.mjs +7 -6
  177. package/scripts/check-size.mjs +119 -3
  178. package/scripts/gate.mjs +0 -3
  179. package/scripts/measure-size.mjs +14 -22
  180. package/scripts/serve-docs.mjs +4 -5
  181. package/scripts/stamp-docs.mjs +7 -12
  182. package/src/a11y.ts +67 -0
  183. package/src/chamber-vapor.ts +12 -22
  184. package/src/chamber.ts +101 -185
  185. package/src/command-bus.ts +504 -427
  186. package/src/dict.ts +1 -1
  187. package/src/directives.ts +186 -156
  188. package/src/form.ts +112 -4
  189. package/src/http-cache.ts +6 -9
  190. package/src/http-errors.ts +26 -20
  191. package/src/http.ts +66 -103
  192. package/src/iife-core.ts +13 -18
  193. package/src/iife-elements.ts +14 -22
  194. package/src/iife.ts +11 -16
  195. package/src/index.ts +39 -49
  196. package/src/ledger.ts +115 -0
  197. package/src/mcp.ts +12 -45
  198. package/src/outbox.ts +31 -19
  199. package/src/plugins-core.ts +70 -157
  200. package/src/plugins-extra.ts +32 -54
  201. package/src/plugins-io.ts +18 -230
  202. package/src/plugins-schema.ts +6 -6
  203. package/src/plugins.ts +1 -3
  204. package/src/router/announce.ts +88 -0
  205. package/src/router/composables.ts +25 -23
  206. package/src/router/dom.ts +23 -16
  207. package/src/router/engine.ts +32 -43
  208. package/src/router/errors.ts +6 -7
  209. package/src/router/index.ts +45 -34
  210. package/src/router/loaders.ts +6 -8
  211. package/src/router/menu.ts +7 -2
  212. package/src/router/outlet.ts +2 -2
  213. package/src/router/remote.ts +8 -13
  214. package/src/router/revalidate.ts +38 -40
  215. package/src/router/table.ts +41 -25
  216. package/src/router/types.ts +5 -2
  217. package/src/router/url.ts +9 -12
  218. package/src/router/vapor.ts +8 -11
  219. package/src/scheduler.ts +70 -0
  220. package/src/schema.ts +75 -54
  221. package/src/settled.ts +61 -59
  222. package/src/signal.ts +7 -10
  223. package/src/ssr.ts +14 -58
  224. package/src/store.ts +21 -10
  225. package/src/stream-parser.ts +6 -10
  226. package/src/testing.ts +43 -30
  227. package/src/transitions.ts +27 -64
  228. package/src/transports.ts +140 -216
  229. package/src/utilities.ts +10 -15
  230. package/src/vapor.ts +3 -3
  231. package/src/vite-hmr.ts +50 -124
  232. package/src/vitest-mcp.ts +4 -4
  233. package/src/vitest-pure.ts +27 -37
  234. package/src/vitest.ts +6 -2
  235. package/src/vue.ts +10 -19
  236. package/dist/http-errors.js +0 -11
  237. package/dist/http-errors.js.map +0 -1
  238. package/dist/transports.js.map +0 -1
package/CHANGELOG.md CHANGED
@@ -2,6 +2,655 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## v1.25.0 - 2026-09-29
6
+
7
+ ### Changed (breaking): a failure's code is `owner:condition:subject`, and the owner comes from the wiring
8
+
9
+ Every failure the library, a plugin or a transport raises is a `BusError` whose
10
+ `code` says who raised it, what went wrong from a closed vocabulary (`missing`,
11
+ `already`, `invalid`, `refused`, `limited`, `timeout`, `lost`, `aborted`,
12
+ `exceeded`, `failed`, `unexpected`, `unknown`), and what it is about. The owner
13
+ is never typed at a site: the bus hands each party a `fail` bound to its owner
14
+ (`core` inside the bus, a plugin's declared `id`, `transport`, `remote` for a
15
+ backend), so no party can raise under another's name. Designed from RFC 3463's
16
+ `class.subject.detail`, AIP-193's (domain, reason), OpenTelemetry's scope bound
17
+ at `getTracer(name)` and Temporal's runtime-only failure kinds
18
+ (docs/plan-failures-and-contract.md 4.5).
19
+
20
+ | Was | Is |
21
+ |---|---|
22
+ | `VC_CORE_NO_HANDLER` | `core:missing:handler` |
23
+ | `VC_CORE_THROTTLED` | `core:limited:handler` (`register({ throttle })`), `throttle:limited:handler` (the plugin) |
24
+ | `VC_CORE_REQUEST_TIMEOUT` | `core:timeout:request` |
25
+ | `VC_CORE_MAX_DEPTH` / `VC_CORE_SEALED` / `VC_CORE_ABORTED` | `core:exceeded:depth` / `core:refused:bus` / `core:aborted:dispatch` |
26
+ | `VC_CORE_BEFORE_CANCEL` | `core:refused:hook` |
27
+ | `VC_PLUGIN_THREW` | `<plugin id>:failed:plugin` (`plugin:failed:plugin` when it declares none) |
28
+ | `VC_PLUGIN_CIRCUIT_OPEN` / `VC_PLUGIN_RATE_LIMITED` | `circuitBreaker:limited:action` / `rateLimit:limited:action` |
29
+ | `VC_VALIDATION_FAILED` | `validator:invalid:payload`, `validateSchemas:invalid:payload` |
30
+ | `VC_TRANSPORT_TIMEOUT` / `_CLOSED` / `_QUEUE_FULL` | `transport:timeout:reply` / `transport:lost:reply` / `transport:lost:command` |
31
+ | `VC_TRANSPORT_REDIRECT` / `_PROTOCOL` | `transport:refused:redirect` / `transport:lost:result` |
32
+ | `VC_UNKNOWN` | `core:unknown:error`, no longer retryable |
33
+ | `VC_TEST_*` (seven) | `test:missing:tap`, `test:already:instance`, `test:unexpected:version`, `test:lost:tap`, `test:invalid:files`, `test:missing:run`, `test:missing:coverage` |
34
+ | a backend's failure (any path) | `remote:<condition of its status>:<its code>` (next entry) |
35
+
36
+ **Migrating:** compare the new strings. `RETRYABLE_CODES` is
37
+ `RETRYABLE_CONDITIONS` (`limited`, `timeout`); new `ownerOf(e)` and
38
+ `conditionOf(e)` read the parts, `failureCondition(e)` any failure's.
39
+ `BusError.severity`, `BusError.emitter` and the `BusEmitter` type are gone:
40
+ whoever logs a failure decides its level (the catalogue's `severity` is the
41
+ suggestion), and the owner is in the code. A hand-built `new BusError('limited:quota', ...)`
42
+ is the app's (`app:limited:quota`). A plugin declares its owner as `id`
43
+ (`Object.assign(fn, { id: 'myPlugin' })`) and receives its `fail` as a third
44
+ argument; a plugin that calls another passes its own `fail` on; a plugin called
45
+ outside a bus in a test is wrapped with the new `wired(plugin)`, exported
46
+ from `vapor-chamber` beside `createTestBus` (not from `vapor-chamber/vitest`,
47
+ whose pure entry imports nothing from the library at runtime).
48
+
49
+ **What it closes.** A backend could make a permanent refusal retryable by
50
+ sending one of six library code strings in a 2xx body (the residue stated in
51
+ v1.23.0). The verdict is now the failure's condition: a backend's code is the
52
+ subject, after the owner and the condition, so no string it sends can reach
53
+ the verdict. `code` is read-only (a private field with a getter), so neither
54
+ owner nor condition can be rewritten after the fact.
55
+
56
+ **What else changed with it.**
57
+ - `toJSON()` is the failure as an RFC 9457 problem with the members it needs:
58
+ `detail`, `code`, `action`, the context as extensions; `type` implicit. The
59
+ transports' reader reads that shape back, so a failure crossing a worker, a
60
+ channel or storage has one shape both ways.
61
+ - The stack is decided once, by the constructor: only `failed` (a bug) captures
62
+ one; every expected refusal is control flow and skips it.
63
+ - The catalogue lost its `emitter` column (the owner is the code's first part);
64
+ its `retryable` column is an outcome of the condition, asserted. The registry
65
+ sweep now asks which site MINTS each code: every minted code has a row, and
66
+ the eight declared-but-never-raised codes are listed with their reasons.
67
+ - The test double mints as `core`, so a test reads the codes production does.
68
+
69
+ **Speed** (three-arm A/B, production, 11 rotated rounds, against the previous
70
+ tree; the self-control arm is the noise band): the happy path is unchanged,
71
+ 0.99-1.03x on bare, three-plugin, async, throttle/circuit/rateLimit passing and
72
+ `retry` passing. Failures that used to capture a stack are 4-12x faster: no
73
+ handler 0.086x, circuit open 0.097x, rate limit 0.101x, `retry` on a permanent
74
+ refusal 0.26x. Refusals that already skipped it are within noise
75
+ (1.02-1.07x). Locking `code` by `freeze` or a non-writable property cost 10-30%
76
+ on those refusals and was not shipped.
77
+
78
+ **Size**, measured on the new IIFE target (next entry), against the old code on
79
+ that same target: full +79 / +57, core +12 / +58, elements +12 / +46 raw /
80
+ brotli; the budgets are set to the new, lower numbers. The Blade consumer
81
+ bundle (ESM) measures 6,328 brotli against 6,260; its ceiling moves to 6,340,
82
+ with the steps in `tests/esm-treeshake.test.ts`.
83
+
84
+ ### Changed (breaking): one wire contract, and retries by it
85
+
86
+ The answer to a command is the same shape on every wire
87
+ (docs/plan-failures-and-contract.md 4.4): `{ state }` on success, `{ redirect }`
88
+ for a navigation, an RFC 9457 problem on failure, with only the members it
89
+ uses: `{ status, code, detail, errors? }`. A single command's failure is its
90
+ non-2xx response; a batched result or a WebSocket frame is `{ id, ... }`. The
91
+ client reads a problem as `remote:<condition of its status>:<code>`, the
92
+ condition from a status table that says only what RFC 9110 says of a status,
93
+ `detail` the message, everything else `error.context`. Removed, on both sides:
94
+ `{ ok: false, error, code }`, the `ok` flag, `error` and `message` as message
95
+ fields, `type` and `title`; `FormBus.setErrors` takes only
96
+ `[{ pointer: '/payload/<field>', detail }]`, which the reference controller now
97
+ sends for a `ValidationException` (it sent no field errors at all).
98
+
99
+ Retries read the same contract (`failureCondition(error)`); who retries, and
100
+ what, is the next section. A redirect (`transport:refused:redirect`) is never
101
+ re-sent, and a batch that answers without a command's result
102
+ (`transport:lost:result`, it may have run) is re-sent only with a key - 1.24
103
+ re-sent neither, and `tests/wire-contract.test.ts` pins both. The bridges'
104
+ `retry` and `noRetry` options are gone (a declaration of "safe to send again"
105
+ replaces them). The outbox keeps a record whose answer declared a
106
+ `Retry-After` and flushes again when that wait is over; `dispose()` ends the
107
+ wait. `serialize()`'s lanes, the retry's waits and the outbox's wait are one
108
+ module (`src/scheduler.ts`).
109
+
110
+ No external consumers; the reference controller, the example servers, the
111
+ guides and the tests moved with it (`tests/wire-contract.test.ts`).
112
+
113
+ ### Changed (breaking): the async bus retries, by class, and `retry()` / `retrying()` are gone
114
+
115
+ The async bus re-sends the call that produced the outcome - a handler, or a
116
+ plugin declaring `transport: true` (the three bridges do) - so the plugins
117
+ outside see one dispatch (docs/plan-shape.md 4). On by default:
118
+
119
+ - **By class.** Transient, always: `limited`, `timeout`, any failure declaring
120
+ `retryIn` (a `Retry-After`), after that wait. Uncertain - `lost`, `failed`
121
+ (a 500, a handler's throw), `unexpected`, `unknown`: the first attempt may
122
+ have landed - only for an action declared idempotent or a command carrying
123
+ an idempotency key. Never: a verdict, an abort, a depth bound, a library
124
+ bug, the bus's own `register({ throttle })` refusal (a throttle drops
125
+ repeats), an outbox replay (its scheduler owns it).
126
+ - **Bounded.** 3 attempts; full jitter under `200ms * 2^n`, capped at 20s; a
127
+ declared wait clamped to the same cap. `dispose()` and the dispatch's own
128
+ signal end a wait (`core:aborted:dispatch`). A failure sent more than once
129
+ carries `context.attempts`.
130
+ - **Budgeted per bus** (gRPC A6): 10 tokens, a failure costs one, a success
131
+ refunds a tenth, no retry below half.
132
+ - **Declared.** `createAsyncCommandBus({ retry: { maxAttempts, baseDelay,
133
+ maxDelay, actions: { 'cart*': 'idempotent', orderPay: false, searchRun: 2 } } })`;
134
+ `retry: false` turns it off; `retry` on a schema action joins
135
+ `retry.actions`. An action declared idempotent gets its dispatch id as its
136
+ `Idempotency-Key`, one for every attempt.
137
+
138
+ Migration: `bus.use(retry(opts))` and `bus.use(retrying(bridge))` go; use the
139
+ bridge alone and declare on the bus. 1.24's `retry()` re-sent a 500 and a
140
+ handler's plain throw by default; now declare the action `'idempotent'` for
141
+ that. `RETRYABLE_CONDITIONS` is `limited` and `timeout`, and the registry's
142
+ `retryable` column follows it (`core:unknown:error` reads false). The IIFEs'
143
+ `connect()` and `createApp()` take the bus's `retry` option in place of
144
+ `retry: n`. `conditionOfStatus` and `failureCondition` moved into the core
145
+ (still exported from the root; no longer from `http-errors`). New types from the root: `AsyncCommandBusOptions`,
146
+ `RetryOptions`, `RetryDeclaration`, `AsyncSchemaCommandBusOptions`. Size: the IIFEs
147
+ +114 to +136 B brotli, the policy now inside the async bus they create.
148
+ The reference controller answers its `409 in_progress` (a key still running)
149
+ with `Retry-After: 1`, so the bus's re-send comes back for the finished answer
150
+ instead of settling as a conflict; the examples, guides and READMEs moved
151
+ with it. `tests/retry-policy.test.ts`.
152
+
153
+ ### Changed: the IIFE bundles target 2022 browsers
154
+
155
+ The `<script>`-tag builds target Chrome 100, Firefox 100 and Safari 16 (was
156
+ Chrome 80, Firefox 75, Safari 13). Below that floor `?.`, `??`, class fields and
157
+ private fields were lowered into helpers in every bundle; above it all three are
158
+ native. Core IIFE 27,164 -> 25,923 raw, 7,992 -> 7,727 brotli, with the change
159
+ above included. A page on an older browser keeps working with the previous
160
+ release's IIFE. The ESM build (what apps bundle) was already es2022.
161
+
162
+ ### Changed: the async bus's `request()` settles one promise
163
+
164
+ It raced three (the dispatch, the timer, the caller's abort); it now settles
165
+ one through a single `done`, as the sync bus's `request()` does, and removes
166
+ its dedup entry only while that entry is still its own. Measured on a request
167
+ whose responder answers at once: 2,958 -> 2,293 ns. Settled results are
168
+ unchanged (`tests/request-dispose-ab.test.ts`).
169
+
170
+ ### Changed (breaking): the HTTP client's `safe` helpers return the contract's problem
171
+
172
+ `http.safe.*` resolved `error` to the response body as it came, or to
173
+ `{ message, code }` built for the occasion. It is now the RFC 9457 problem the
174
+ wire contract uses (`ProblemDetails`, now exported from `http-errors`): the
175
+ backend's body with its `status` added, `detail` its sentence or
176
+ `HTTP <status>`; with no response (a timeout, an abort, the network)
177
+ `{ detail }` only. Read `error.detail` where you read `error.message`.
178
+
179
+ `HttpError.status` is gone: it always equalled `response.status`, which the
180
+ HttpClient contract already guarantees a failure carries. Read
181
+ `error.response?.status`; `failureCondition` and the outbox's verdict read it
182
+ there too, so a custom client's error is judged by its `response`, not by a
183
+ bare `status` field.
184
+
185
+ ### Changed (breaking, types only): `Plugin` is typed for either bus
186
+
187
+ `Plugin` declared `next: () => CommandResult` while installing on the async
188
+ bus, where `next()` may be a promise; the built-in plugins escaped it with
189
+ `as unknown as Plugin` and `const plugin: any`. It is now generic:
190
+ `<R extends CommandResult | Promise<CommandResult>>(cmd, next: () => R, fail) => R | CommandResult`,
191
+ a sync plugin on the sync bus and an async one on the async bus. A plugin that
192
+ reads the result settles it through `onSettled` (now exported, with
193
+ `MaybeAsyncResult`); reading `.ok` straight off `next()` no longer compiles as
194
+ a `Plugin`, which is the defect it would be on the async bus. A plugin for one
195
+ bus only is a `SyncPlugin` or an `AsyncPlugin`; `PluginParts` is what every
196
+ plugin may carry (`dispose`, `id`). The async bus has one `use(AsyncPlugin)`
197
+ (a `Plugin` fits it), `BaseBus.use` takes a `Plugin` rather than `any`, and
198
+ the IIFEs' `connect({ plugins })` takes `AsyncPlugin[]`. No cast is left in the
199
+ built-in plugins. It found the defect in two example plugins and in the
200
+ README's timing example, all corrected.
201
+
202
+ ### Changed: every `limited` refusal declares when to come back
203
+
204
+ `circuitBreaker()` and `rateLimit()` refuse with `context.retryIn`, as a
205
+ throttle and a `Retry-After` already did: the time left until the circuit goes
206
+ half-open, and until the oldest dispatch leaves the window. `retry()` waits
207
+ that time instead of spending its attempts on refusals, and the outbox flushes
208
+ again when it is over, whatever order the plugins are installed in. No new
209
+ option and no shared state: the wait travels on the failure, where the
210
+ contract already carries it.
211
+
212
+ ### Removed (breaking): `agentOrigin()` from `vapor-chamber/mcp`
213
+
214
+ A no-op since v1.12.0, deprecated and "kept for one release": the core stamps
215
+ `meta.origin === 'agent'` on MCP-driven dispatches itself. Delete the
216
+ `bus.use(agentOrigin(), ...)` line. Its tests now assert the core's stamp.
217
+
218
+ ### Added: `failureCondition` from the package root
219
+
220
+ A custom `isRetryable` reads the same condition the default rule does
221
+ (`failureCondition(error)`: a library failure's own condition, an HTTP
222
+ response's status through the status table, a timeout, an abort, `lost`),
223
+ instead of matching statuses inside `error.message`, which is what
224
+ `examples/feature-retry.ts` taught; it now uses it, and `retrying()` for the
225
+ wire.
226
+
227
+ ### Changed (breaking, reference controller): an `Idempotency-Key` that is not a String is ignored
228
+
229
+ The PHP controller accepted a raw, unquoted key "from a client that predates
230
+ the quoting". A value that is not a Structured Field String fails to parse,
231
+ and RFC 9651 ignores a field that fails to parse: the request runs with no
232
+ key.
233
+
234
+ ### Fixed: the examples teach the current shapes
235
+
236
+ Read in full, every example and companion: the Laravel app and backend, the
237
+ Astro, island-cart, Vapor SFC and router demos, the patterns and the feature
238
+ snippets. Old shapes replaced (`{ ok }` answers, `quantity`, a composable from
239
+ the package root in a Vue component, `useCommand` inside React, statuses read
240
+ out of `error.message`, a hand-rolled retry, uncoded plugin refusals, the
241
+ PHP actions validating the target so a field error pointed at a payload field
242
+ that did not exist); pressed buttons use `aria-disabled` instead of dropping
243
+ keyboard focus with `disabled`; outcomes reach a live region or `announce()`;
244
+ text colours meet 4.5:1 (WCAG 1.4.3); the static example server no longer
245
+ crashes on a malformed URL.
246
+
247
+ ### Fixed: the sprinkled-Blade example is accessible
248
+
249
+ It disabled the focused button while busy, which sends a keyboard user's focus
250
+ to `<body>` (the defect `v-vc-command` avoids), wrote the backend's message
251
+ into `innerHTML`, and its status line was not a live region. It now marks the
252
+ button `aria-disabled` and ignores a press in flight, writes text, and
253
+ announces the outcome (`role="status"`).
254
+
255
+ ### Changed: source comments carry the current reason, not the release history
256
+
257
+ Release-by-release logs in module headers, "used to" narratives and version
258
+ tags on comments are rewritten as the rule each one established, keeping the
259
+ measurement or the test that pins it; the history stays in this file.
260
+
261
+ ### Fixed (router): typed params match only their type
262
+
263
+ A row's `params: { id: 'int' }` compiled to `[^/]+` and cast with `parseInt`,
264
+ so `/products/7x` and `/products/7.5` resolved product 7 and `/products/abc`
265
+ handed an `int` param over as the string `'abc'`; a `bool` read anything but
266
+ `1`/`true` as false. A typed param with no regex of its own now matches only
267
+ its type (`int`: `-?\d+`, `bool`: `1|0|true|false`, case-insensitive like the
268
+ rest of matching), so a URL that does not fit falls through to the next row.
269
+
270
+ ### Fixed (router): query params and pagination hand out values, not the location
271
+
272
+ - `decodeQueryParam` returned a present array param by reference, so a
273
+ component's `tags.value.push(...)` rewrote the committed location's query
274
+ while the URL stayed as it was. It returns a copy, as it already did for a
275
+ declared default.
276
+ - `usePagination()`'s `page` is a number even on a route that declares no
277
+ `page` param (it read `?page=2` as `'2'`); the route's own declaration still
278
+ wins.
279
+
280
+ ### Fixed (router): `revalidateRoutes` never fails the command it follows
281
+
282
+ A revalidation map naming a record outside the current chain threw inside the
283
+ plugin after the mutation had succeeded: on a sync bus the command came back
284
+ as `plugin:failed:plugin`, on an async bus it was an unhandled rejection. The
285
+ coded `unknown_route_name` error now goes to `onError` (default
286
+ `console.error`) and the command's result passes through. The plugin declares
287
+ `id: 'revalidateRoutes'`.
288
+
289
+ ### Changed (breaking, router): a routes endpoint answers by the wire contract
290
+
291
+ `unwrapRoutesPayload` (the `{ url }` and `{ inline }` route sources) read the
292
+ old `{ ok, state, error }` envelope. It accepts a bare payload, `{ state }`,
293
+ or `{ problem }` - the last as a `routes_load_failed` carrying the problem's
294
+ `detail`.
295
+
296
+ ### Fixed: `authGuard` refuses with a code
297
+
298
+ Its refusal was a plain `Error`, the one library refusal without a code, so
299
+ `retry()` re-sent an unauthenticated command and a circuit breaker counted it
300
+ as a failing server. It is now `authGuard:refused:action`, raised through the
301
+ plugin's own `fail` (`id: 'authGuard'`), and in `ERROR_CODE_REGISTRY`. A
302
+ protected prefix matched the exact action name twice over; once is enough.
303
+
304
+ ### Fixed: a backend's failure opens the circuit breaker
305
+
306
+ `circuitBreaker()` skipped every `failed` condition to ignore a plugin that
307
+ threw, so a backend's 5xx (`remote:failed:<code>`) never counted and the
308
+ circuit never opened on the failure it exists for. It now skips only a party's
309
+ own throw, by the rule `retry()` already used (one internal predicate for
310
+ both). A plugin that throws still neither counts nor resets the run.
311
+
312
+ ### Fixed: a history cleared while an undo is in flight stays cleared
313
+
314
+ If the server refused an undo that was still pending when `clear()` ran, the
315
+ revert pushed the command back into the undo stack of a history the user had
316
+ just cleared. A revert now moves a command only if it is still where the undo
317
+ put it. Found by the coverage pass; `tests/history-undo-lands.test.ts` (failed
318
+ on the old code).
319
+
320
+ ### Fixed: `retry()` no longer builds an Error on every successful dispatch
321
+
322
+ It opened each dispatch with `_errResult(new Error('No attempts made'))`, a
323
+ stack capture on every call, success included, for a result that could never
324
+ be returned (at least one attempt is guaranteed). Measured: `retry` alone
325
+ 48,857 ns per successful dispatch against 5,635 without it, 6,031 once fixed; a
326
+ realistic chain (validator, idempotent, serialize, circuitBreaker, retry)
327
+ halved, 69,001 to 34,135 ns. `tests/retry-happy-path.test.ts`.
328
+
329
+ ### Fixed: `register()`'s cleanup removes only what it registered
330
+
331
+ It deleted whatever handler (and undo handler) held the action name when it
332
+ ran. KeepAlive with a `max` sets up the new page and THEN evicts the old one,
333
+ so the evicted page's cleanup deleted the handler of the page on screen:
334
+ `dispatch` answered `core:missing:handler`. The whitepaper's rule ("the newest
335
+ handler always wins regardless of teardown") was drawn from HMR, where the
336
+ order is the other way round. `createTestBus()` had the same cleanup and now
337
+ matches. `tests/register-ownership.test.ts` (5, all failed on the old code,
338
+ including a real Vapor KeepAlive).
339
+
340
+ ### Fixed: a store held outside any scope is no longer disposed under its owner
341
+
342
+ `docs/store.md` says the last holder out disposes and an unscoped caller owns
343
+ `$dispose()`, but only scoped holders were counted: a module-level store was
344
+ disposed by the first component that joined and left, its actions answered
345
+ `ok: false`, and the next `useStore` built a fresh store with its state reset.
346
+ A call outside any scope now pins the store until its owner disposes it.
347
+ `tests/store-unscoped-holder.test.ts`.
348
+
349
+ ### Fixed: `useCommand().register` follows KeepAlive
350
+
351
+ `useCommandHistory` and `useCommandError` paused for a KeepAlive deactivation;
352
+ `useCommand`, the composable that registers handlers, did not, so after A -> B
353
+ -> A the cached B's handler answered while A was on screen. A deactivated page
354
+ now releases its handlers and takes them back when activated (register()'s
355
+ cleanup removes only its own, above). Listeners (`on()`) are unchanged: whether
356
+ a cached page keeps listening is still an open decision.
357
+ `tests/usecommand-keepalive.test.ts`.
358
+
359
+ ### Fixed: errors match the library's own catalogue
360
+
361
+ `validator()` refused with a plain `Error` (no code) although the catalogue
362
+ declares a validation code for per-action validation; it now refuses with
363
+ `validator:invalid:payload`, the message still the rule's own. One consequence: `retry()`'s
364
+ default rule treated the uncoded error as retryable (no status), so a refused
365
+ validation was retried; a coded permanent refusal is not. And three sites built
366
+ the throttled and aborted refusals with a severity the catalogue contradicted
367
+ (severity has since left the failure object; see the first entry). `tests/errors-match-catalogue.test.ts`.
368
+
369
+ ### Fixed: the `Idempotency-Key` header is a Structured Field String
370
+
371
+ The draft this bridge follows makes the value a quoted, escaped String (RFC
372
+ 9651); it was sent raw. The key is the command plus its target as JSON, so it
373
+ was unquoted and unescaped, and a target with any character outside Latin-1
374
+ (an emoji, CJK text) made a value `Headers` refuses outright: the request never
375
+ left. Now the key is percent-encoded (the web's own encoding, UTF-8) and
376
+ quoted: only unreserved ASCII and `%XX`, so an sf-string with nothing left to
377
+ escape, reversible, so no two keys collide. The reference controller unquotes
378
+ and `rawurldecode`s it back to the exact key, the same one the batching bridge
379
+ sends in the body, and still accepts a raw key from an older client. Two
380
+ encodings were measured and dropped first: hand escaping plus `%uXXXX` (+63 B
381
+ brotli on the Blade consumer bundle), and the same in one regex pass (+87).
382
+ **Upgrade order: the backend first**, or a quoted key misses a cache written
383
+ under the raw one for up to the replay window. `tests/idempotency-header.test.ts`;
384
+ `docs/integrations/laravel.md` updated, and its "line for line" snippet
385
+ corrected where it had drifted from the controller (`fail` -> `problem`, the
386
+ redirect-aware success body).
387
+
388
+ ### Changed: error advice is DEV-only; one throttle gate
389
+
390
+ Production error messages state the fact; the fix sentence is DEV's, and the
391
+ catalogue (`ERROR_CODE_REGISTRY`, each code's `fix`) is where a production
392
+ reader finds it (docs/plan-failures-and-contract.md, settled item 5). Touched:
393
+ `core:refused:bus`, `core:exceeded:depth`, `core:timeout:request`,
394
+ `core:missing:handler` (production is `No handler registered for "x".`), the
395
+ naming-pattern violation, `createVaporChamberApp`'s VDOM tail and `persist`'s
396
+ "stale after a deploy". Match on `.code`, never on `.message`; development
397
+ builds read as before. `vueDetectionHint` still ships: it is a diagnosis, and a
398
+ `<script>`-tag page only ever runs a production IIFE.
399
+
400
+ `register(action, h, { throttle })` and the `throttle()` plugin were the same
401
+ gate written twice; both use one now, so the plugin also skips the stack
402
+ capture on its rejected path, the hot one. The refusal reads `"x" throttled.
403
+ Retry in Nms.` from either (it said `Handler "x"` or `Action "x"`).
404
+
405
+ These paid for this release's fixes without a raise (measured before the
406
+ failure shape and the IIFE target in the first two entries): all three IIFE
407
+ budgets went DOWN (full 41,172 / 12,303, core 27,164 / 7,992, elements 28,928 / 8,499
408
+ raw / brotli) and the Blade consumer bundle measures 6,260 brotli, below
409
+ v1.24.0's 6,363; its ceiling locks at 6,280.
410
+
411
+ ### Added: `announce()` - status messages for assistive technology; `vc-error` made semantic
412
+
413
+ `announce(message, { assertive })` says something to a screen reader without
414
+ moving focus (WCAG 4.1.3), through ONE shared pair of live regions per document
415
+ (polite, assertive), created on first use: the pattern of Angular CDK's
416
+ LiveAnnouncer and React Aria's `announce()`. A repeated message is heard again.
417
+ `setAnnouncer(fn)` lets an app take the announcing over (its own status bar, its
418
+ own words); `setAnnouncer(null)` hands it back. Exported from the root.
419
+
420
+ Two features now speak through it instead of on their own:
421
+
422
+ - **`v-vc-command` failures.** `vc-error` stays for styling, but a class is
423
+ invisible to a screen reader. A failed dispatch now also announces the
424
+ failure's message assertively, focus left where it is. The words are the
425
+ failure's own: since 1.24 a backend problem's `detail`, already localized.
426
+ An app that wants other words uses `setAnnouncer`.
427
+ - **The router's page changes** use the shared assertive region; the router no
428
+ longer creates a region of its own.
429
+
430
+ `tests/a11y-announce.test.ts` (7), `tests/browser/command-errors.browser.test.ts`
431
+ (3; 2 failed on the old code). Size: `./directives` +0.2 KB brotli, `./router`
432
+ unchanged, core unchanged, IIFEs unchanged.
433
+
434
+ ### Fixed: an undo the server refuses no longer reads as "undone"
435
+
436
+ `useCommandHistory()` and the `history()` plugin moved their stacks before an
437
+ undo or redo and never looked at the outcome. On an async bus, an undo the
438
+ server refused left the history saying "undone": the command was no longer
439
+ undoable, and a redo was offered for a change that never happened. For a
440
+ screen-reader user, who cannot see that nothing changed, that is
441
+ indistinguishable from success (WCAG 3.3.4).
442
+
443
+ Now the stacks still move first, so anything observing the call sees what it
444
+ always saw, and move BACK when the undo or redo does not land: a throw, a
445
+ rejection, or a failed `CommandResult`, returned or resolved. While one is in
446
+ flight, another press does nothing. The API is unchanged: `undo()` and
447
+ `redo()` return the command at once, and a synchronous local undo behaves
448
+ exactly as before. `tests/history-undo-lands.test.ts` (8; 6 failed on the old
449
+ code). A first cut that moved the stacks only AFTER the call broke the pinned
450
+ island-cart wiring (a listener on a redo's re-dispatch reads the redone state),
451
+ which is why the order is move-then-revert.
452
+
453
+ **One implementation.** The two histories each carried their own stacks,
454
+ recording rule, undo and redo, so this defect existed twice. They now wrap one
455
+ internal ledger (`src/ledger.ts`) and differ only where they genuinely differ:
456
+ where a command is recorded, what else is skipped, how the stacks are exposed.
457
+ The `history()` plugin's `bus` option is now typed `CommandBus |
458
+ AsyncCommandBus`, what it already worked with at runtime.
459
+
460
+ **Size:** full IIFE +787 raw / +278 brotli (budget raised, measured steps in
461
+ `scripts/check-size.mjs`); core and elements IIFEs unchanged; ESM import
462
+ everything 24.9 KB brotli either way. `tests/history-redo-ab.test.ts` now derives
463
+ a ledger per arm, so its reverted baseline still reaches the code it times.
464
+
465
+ ### Added: FormBus takes the server's errors, and says what an element needs to make them accessible
466
+
467
+ Most real form errors come from the server, not from client rules, and a form
468
+ had no way to show them: `onSubmit` throwing a 422 made `submit()` reject, and
469
+ its field errors were lost. Now:
470
+
471
+ - `form.setErrors(errors)` takes an RFC 9457 problem's `errors` list
472
+ (`{ pointer, detail }`; `/email`, `#/payload/email` and `/address/city` all
473
+ resolve to their field) or a Laravel field map (`{ email: [...] }`). Each
474
+ error shows at once and clears when its own field changes; rule errors keep
475
+ working; `reset()` clears them; `submit()` clears them before validating,
476
+ since the server decides again.
477
+ - `submit()` lands a field-carrying failure itself: when `onSubmit` throws an
478
+ error whose problem has `errors`, the fields are marked and it resolves
479
+ `false`. Any other failure still rejects, as before.
480
+ - `form.aria(field)` gives `aria-invalid` and `aria-describedby`, set only while
481
+ the error is shown; `form.errorId(field)` is the message element's id, unique
482
+ per form (two forms on a page cannot produce duplicate ids);
483
+ `form.firstInvalid()` names the field to focus after a failed submit.
484
+
485
+ The messages are the backend's, already localized; the form holds no strings.
486
+ `tests/form-a11y.test.ts` (11; 10 failed on the old code), and in real Chromium
487
+ `tests/browser/form-errors.browser.test.ts`: Playwright's role engine reads the
488
+ field as invalid, described by the server's message.
489
+
490
+ ### Fixed: a `role="button"` element with `v-vc-command` works from the keyboard
491
+
492
+ A `<div role="button">` carrying the directive answered pointer clicks only:
493
+ not focusable, and Enter and Space did nothing (WCAG 2.1.1). A native
494
+ `<button>` gets both from the platform; a role does not. The directive now gives
495
+ a non-native `role="button"` element what the ARIA Authoring Practices ask of a
496
+ button: `tabindex="0"` when it has none (removed again at teardown; a tabindex
497
+ the app set is kept), Enter activates on key down, Space on key up with its page
498
+ scroll prevented. Activation is `el.click()`, so it takes the same path as a
499
+ pointer press. A key the app already handled (`defaultPrevented`) is left
500
+ alone, and a native button still dispatches once per key.
501
+ `tests/browser/command-keyboard.browser.test.ts` (6, real key presses through
502
+ Playwright; 3 failed on the old code).
503
+
504
+ ### Added: route changes reach assistive technology
505
+
506
+ A full page load announces the new page to a screen reader and restarts focus;
507
+ a client-side navigation did neither. The router now announces each one in an
508
+ assertive live region (`document.title`, else the first `<h1>`, else the path),
509
+ skipping the initial load and query-only changes, and removes the region on
510
+ `destroy()`. New options: `announce` (default `true`; `false`, or a function
511
+ returning the text) and `focusOnNavigate` (a selector for a small element the
512
+ app provides, focused after each navigation; made focusable only if it is not).
513
+ Rules from Next.js's route announcer and Gatsby's user testing with disabled
514
+ users. `tests/router/announce.test.ts` (7), and in real Chromium
515
+ `tests/browser/route-announce.browser.test.ts`.
516
+
517
+ **Behaviour change:** after the first navigation, the document gains the shared
518
+ visually hidden live regions (`<div data-vc-announcer="assertive">`, see "Added:
519
+ `announce()`" below); the router no longer creates a region of its own. Pass
520
+ `announce: false` to opt out.
521
+
522
+ ### Added: `aria-current="page"` on the current link
523
+
524
+ `stampActiveLinks` now sets `aria-current="page"` on the exactly-active anchor
525
+ and removes it when the route moves on; an `aria-current` the page set for
526
+ another purpose (`"step"`) is left alone. Section links that are only a prefix
527
+ match keep `data-active` and are not marked, since ARIA asks for one current
528
+ element per set. `MenuItem.exactActive` and `Breadcrumb.current` now say they
529
+ are the fields to bind `aria-current` to; `MenuItem.active` says it is not.
530
+
531
+ ### Fixed: `v-vc-command` no longer takes keyboard focus away
532
+
533
+ While a dispatch was in flight the directive set `disabled` on its button.
534
+ HTML's focus fixup rule answers that by moving focus to `<body>`, so a keyboard
535
+ user who pressed Enter on a command button was sent back to the top of the page
536
+ on every dispatch, and focus did not return when the button was re-enabled.
537
+ Measured in real Chromium, production build, with the new browser runner
538
+ (`npm run test:browser`, `tests/browser/command-focus.browser.test.ts`): during
539
+ the dispatch `{ disabled: true, focused: false }`, after it
540
+ `{ disabled: false, focused: false }`. A zero-delay timeout read the opposite,
541
+ because the rule runs during the next rendering update.
542
+
543
+ Now a button in flight (a `<button>`, or any element with `role="button"`) gets
544
+ `aria-disabled="true"`, restored to its previous value when it lands. A
545
+ `role="button"` element used to get nothing at all in flight. Links are left as
546
+ they were: their press is navigation. Not `aria-busy`: it tells assistive technology to hold off
547
+ on the element's content, and a button's content is its accessible name. What `disabled` also did is
548
+ kept by hand and pinned by the same file, each measured on the old code first:
549
+
550
+ - the press that starts the dispatch does not submit a form (`preventDefault`);
551
+ - no further press reaches any listener, the app's own included, until the
552
+ dispatch lands (a capture listener, attached only while in flight, so
553
+ `.delegate` still attaches nothing per element at rest);
554
+ - the button works again once the dispatch lands.
555
+
556
+ **Behaviour changes to check:**
557
+
558
+ - CSS keyed on `:disabled` to show a button working stops matching during a
559
+ dispatch. Use `.vc-loading` (unchanged). Announcing progress in words is the
560
+ app's (a live region, in its own language).
561
+ - A `disabled` the app sets is no longer cleared when a dispatch lands: the old
562
+ `finally` set `disabled = false` whatever the app had set.
563
+ - Do not bind `:aria-disabled` on the element; `:disabled` is
564
+ now safe to bind (whitepaper, "A bound update overwrites what `v-vc-command`
565
+ writes by hand").
566
+
567
+ `tests/directives-vapor-fixture.test.ts`'s identity test now also asserts the
568
+ in-flight guard comes off after every dispatch.
569
+
570
+ ## v1.24.0 - 2026-09-24
571
+
572
+ ### Added: RFC 9457 problem documents are read on every path
573
+
574
+ A backend that answers a failure as `application/problem+json` -
575
+ `{ type, title, status, detail, code }`, `code` as an extension member - lost it
576
+ three ways. Measured with only `fetch` stubbed, then against the reference
577
+ controller running under Laravel 13:
578
+
579
+ - `createHttpClient` parsed a body only when its content type contained
580
+ `application/json`, which `application/problem+json` does not. The problem
581
+ stayed a string, so `HttpError.code` read `undefined` - on reads, in
582
+ `router-fetch`, and in any bridge handed an `httpClient`. `postCommand` was
583
+ never affected: its fetch calls `raw.json()` whatever the type.
584
+ - Every bridge took its message from `error ?? message`, never `detail`, so the
585
+ backend's sentence arrived as `HTTP 404`.
586
+ - A batched result, or a WebSocket frame, carrying `problem` resolved as a
587
+ SUCCESS whose value was `undefined`.
588
+
589
+ Now:
590
+
591
+ - The client parses `application/json` and any `+json` structured suffix
592
+ (RFC 6839), parameters allowed. A type that only contains "json"
593
+ (`application/json-seq`) stays text - it used to be `JSON.parse`d, which
594
+ throws on a real JSON text sequence.
595
+ - `Accept` is `application/json, application/problem+json`.
596
+ - A problem's `detail` is the error's message, read once, in `HttpError`
597
+ itself - so the client, `router-fetch` and every bridge agree. A body without
598
+ `detail` keeps `HTTP <status>`, and a body with the older `error`/`message`
599
+ keeps what the bridges read from it.
600
+ - A result `{ id, problem }` is a failure, batched or over WebSocket: message
601
+ from `detail`, then `title`; code from `problem.code`, then the result's
602
+ `code`. Both paths now share one reading, `resultFailure()` in
603
+ `transports.ts`, where each had its own `ok === false` branch. The single
604
+ bridge keeps its own: there a problem is the error response and arrives as
605
+ an HttpError, and routing it through the helper cost core +128 B raw for a
606
+ shape its 2xx never carries.
607
+ - `ProblemDetails` is exported, and `BackendResponse` gains `detail` and
608
+ `problem`.
609
+
610
+ **What is deliberately not read.** `type` is an opaque URI: nothing derives a
611
+ code from it, so a backend sends `code` as an extension member. The `status`
612
+ inside a batched problem is data, not the response's status, and changes no
613
+ retry decision.
614
+
615
+ **The reference controller** (`examples/laravel-backend/VaporChamberController.php`)
616
+ now answers every failure as `application/problem+json`, with an absolute `type`
617
+ (`url('/problems/<code>')`), the status's reason phrase as `title`, and `code`.
618
+ On `batch()` a failed command's problem rides on its own result inside the 200,
619
+ beside `ok: false`. An exception's own `render()` contributes its `code` member
620
+ when it has one, the last segment of its `type` otherwise.
621
+
622
+ **Upgrade order: the client first.** A v1.23.0 client against the new
623
+ controller still sees every failure as a failure - `ok: false` beside `problem`
624
+ is there for exactly that - but reads `HTTP 422` and `Backend error` for
625
+ messages, and loses the code on a batched result. The previous
626
+ `{ ok: false, error, code }` shape is still read everywhere, so a backend can
627
+ move one endpoint at a time.
628
+
629
+ **Examples:** the two mock backends (`examples/static-server.mjs`,
630
+ `examples/sprinkled-blade/mock-server.mjs`) answer failures as problems, and
631
+ their 500 no longer echoes the exception's text to the client - it is logged.
632
+
633
+ **Fixture:** `tests/problem-details.test.ts` - 17 cases across the client,
634
+ `router-fetch`, `createHttpBridge` (through `postCommand` and through an
635
+ `httpClient`), `createBatchingHttpBridge` and `createWsBridge`, each with
636
+ today's shape as the control. 13 fail against v1.23.0; the 4 controls pass on
637
+ both. The stubbed backend it answers with - `reply`, `problemReply`,
638
+ `batchServer`, `singleServer`, `MockWebSocket` - is now one shared file,
639
+ `tests/backend-stubs.ts`, and `batch-redirect`, `outbox-refusal` and
640
+ `transports` use it instead of their own copies.
641
+
642
+ **Size:** +136 B raw / +62 B brotli on the full IIFE, which carries the batch
643
+ and WebSocket bridges; +21 B raw on core and elements, +3 / -1 B brotli; +7 B
644
+ brotli on the Blade consumer ESM bundle, under its unchanged ceiling. Squeezed
645
+ before landing - how is in `scripts/check-size.mjs`.
646
+
647
+ ### Fixed: the last uncovered branch, back to 100% on all four axes
648
+
649
+ `runFlush` falls back to `new Error('Unknown error')` when a replay fails with
650
+ no `error` - the shape a hand-written plugin can return - and no test reached
651
+ it since v1.23.0 added `isRetryable`. `tests/outbox-edges.test.ts` now does,
652
+ and pins that the predicate sees that Error rather than `undefined`.
653
+
5
654
  ## v1.23.0 - 2026-09-23
6
655
 
7
656
  Post-v1.22.0 work. The first four entries were written into the v1.22.0 section