stitchkit 0.80.1 → 0.82.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 (184) hide show
  1. package/CHANGELOG.md +6552 -0
  2. package/README.md +4 -0
  3. package/dist/agent-runtime/provider-failure.d.ts.map +1 -1
  4. package/dist/agent-runtime-coding-tools.js +1 -1
  5. package/dist/agent-runtime-harness.js +7 -5
  6. package/dist/agent-runtime.js +15 -19
  7. package/dist/application/notification-outbox.d.ts +80 -0
  8. package/dist/application/notification-outbox.d.ts.map +1 -0
  9. package/dist/application/process-lifecycle.d.ts +166 -0
  10. package/dist/application/process-lifecycle.d.ts.map +1 -0
  11. package/dist/application/state-store.d.ts +18 -0
  12. package/dist/application/state-store.d.ts.map +1 -0
  13. package/dist/application.d.ts +3 -0
  14. package/dist/application.d.ts.map +1 -1
  15. package/dist/application.js +568 -11
  16. package/dist/browser/http.d.ts +14 -0
  17. package/dist/browser/http.d.ts.map +1 -1
  18. package/dist/browser/resumable.d.ts +6 -0
  19. package/dist/browser/resumable.d.ts.map +1 -1
  20. package/dist/cli.js +9 -7
  21. package/dist/contract/define.d.ts +27 -4
  22. package/dist/contract/define.d.ts.map +1 -1
  23. package/dist/contract/errors-factory.d.ts +22 -3
  24. package/dist/contract/errors-factory.d.ts.map +1 -1
  25. package/dist/contract/index.d.ts +2 -2
  26. package/dist/contract/index.d.ts.map +1 -1
  27. package/dist/contract/index.js +1 -1
  28. package/dist/geo/index.d.ts +4 -0
  29. package/dist/geo/index.d.ts.map +1 -0
  30. package/dist/geo/maxmind.d.ts +4 -0
  31. package/dist/geo/maxmind.d.ts.map +1 -0
  32. package/dist/geo/resolver.d.ts +9 -0
  33. package/dist/geo/resolver.d.ts.map +1 -0
  34. package/dist/geo/types.d.ts +59 -0
  35. package/dist/geo/types.d.ts.map +1 -0
  36. package/dist/geo.d.ts +3 -0
  37. package/dist/geo.d.ts.map +1 -0
  38. package/dist/geo.js +245 -0
  39. package/dist/{index-9s6z8vgf.js → index-1dd82z59.js} +1 -1
  40. package/dist/{index-vwp69871.js → index-27487ay5.js} +4 -4
  41. package/dist/{index-7ag81v2f.js → index-27gm3d6g.js} +10 -321
  42. package/dist/{index-y01f4223.js → index-2bxcmqkb.js} +127 -43
  43. package/dist/{index-dxesg10d.js → index-3ds42w1w.js} +3 -3
  44. package/dist/index-3xwxfj4z.js +334 -0
  45. package/dist/{index-egaabxkz.js → index-5z8tezpx.js} +2 -2
  46. package/dist/{index-fxz0v7vg.js → index-7evdsyez.js} +1 -83
  47. package/dist/{index-xcetq6j3.js → index-90d7h2cr.js} +32 -13
  48. package/dist/{index-t0z5p5w6.js → index-apn8repm.js} +1 -1
  49. package/dist/{index-758zx2ha.js → index-dggwnnjv.js} +2 -2
  50. package/dist/{index-dwf1f2ap.js → index-f9mb610r.js} +1 -62
  51. package/dist/{index-5mtc969c.js → index-fwbkp2jn.js} +1 -1
  52. package/dist/index-p13mwz16.js +16 -0
  53. package/dist/{index-m9kzax52.js → index-p18f59zy.js} +21 -5
  54. package/dist/{index-hb5pf3s4.js → index-qmgs7y68.js} +1 -1
  55. package/dist/{index-2rwwmr7v.js → index-s4ckmrac.js} +1 -1
  56. package/dist/{index-h4pj6fta.js → index-s8cnx02s.js} +8 -5
  57. package/dist/{index-k9ay6v6d.js → index-vchzar7e.js} +74 -23
  58. package/dist/index-vsbzgd7b.js +84 -0
  59. package/dist/{index-dxjjv399.js → index-xbppf54a.js} +95 -15
  60. package/dist/index-y91ry9h2.js +4 -0
  61. package/dist/index-zcgf3gqf.js +83 -0
  62. package/dist/index.js +17 -13
  63. package/dist/internal/error-message.d.ts +3 -0
  64. package/dist/internal/error-message.d.ts.map +1 -0
  65. package/dist/internal/media-type.d.ts +8 -0
  66. package/dist/internal/media-type.d.ts.map +1 -0
  67. package/dist/internal/route-pattern.d.ts +21 -0
  68. package/dist/internal/route-pattern.d.ts.map +1 -1
  69. package/dist/internal/upgrade-plan.d.ts +18 -0
  70. package/dist/internal/upgrade-plan.d.ts.map +1 -0
  71. package/dist/node.js +9 -6
  72. package/dist/observability/audit.d.ts +28 -1
  73. package/dist/observability/audit.d.ts.map +1 -1
  74. package/dist/observability/bounded-logger.d.ts +20 -0
  75. package/dist/observability/bounded-logger.d.ts.map +1 -0
  76. package/dist/observability/context.d.ts +12 -3
  77. package/dist/observability/context.d.ts.map +1 -1
  78. package/dist/observability/index.d.ts +3 -2
  79. package/dist/observability/index.d.ts.map +1 -1
  80. package/dist/observability/index.js +138 -8
  81. package/dist/observability/sanitize.d.ts +17 -0
  82. package/dist/observability/sanitize.d.ts.map +1 -1
  83. package/dist/primitives.js +1 -1
  84. package/dist/react/query-client.d.ts +33 -0
  85. package/dist/react/query-client.d.ts.map +1 -0
  86. package/dist/react.d.ts +1 -0
  87. package/dist/react.d.ts.map +1 -1
  88. package/dist/react.js +83 -1
  89. package/dist/realtime/registry.d.ts +93 -0
  90. package/dist/realtime/registry.d.ts.map +1 -0
  91. package/dist/release/header.d.ts +9 -0
  92. package/dist/release/header.d.ts.map +1 -0
  93. package/dist/release/marker.d.ts +39 -0
  94. package/dist/release/marker.d.ts.map +1 -0
  95. package/dist/release/socket.d.ts +57 -0
  96. package/dist/release/socket.d.ts.map +1 -0
  97. package/dist/release/watcher.d.ts +66 -0
  98. package/dist/release/watcher.d.ts.map +1 -0
  99. package/dist/release.d.ts +15 -0
  100. package/dist/release.d.ts.map +1 -0
  101. package/dist/release.js +196 -0
  102. package/dist/remote.js +12 -7
  103. package/dist/server/context.d.ts +2 -1
  104. package/dist/server/context.d.ts.map +1 -1
  105. package/dist/server/create.d.ts.map +1 -1
  106. package/dist/server/error-hook.d.ts +17 -16
  107. package/dist/server/error-hook.d.ts.map +1 -1
  108. package/dist/server/file-state-store.d.ts +23 -0
  109. package/dist/server/file-state-store.d.ts.map +1 -0
  110. package/dist/server/implement.d.ts.map +1 -1
  111. package/dist/server/index.d.ts +5 -2
  112. package/dist/server/index.d.ts.map +1 -1
  113. package/dist/server/index.js +469 -11
  114. package/dist/server/middleware/cookies.d.ts +33 -1
  115. package/dist/server/middleware/cookies.d.ts.map +1 -1
  116. package/dist/server/middleware/cors.d.ts +16 -1
  117. package/dist/server/middleware/cors.d.ts.map +1 -1
  118. package/dist/server/openapi.d.ts.map +1 -1
  119. package/dist/server/release-signal.d.ts +35 -0
  120. package/dist/server/release-signal.d.ts.map +1 -0
  121. package/dist/server/router.d.ts.map +1 -1
  122. package/dist/server/types.d.ts +26 -2
  123. package/dist/server/types.d.ts.map +1 -1
  124. package/dist/testing/surface-manifest.d.ts.map +1 -1
  125. package/dist/testing.js +7 -4
  126. package/dist/tool-invoker.js +8 -6
  127. package/dist/tools/download-core.d.ts.map +1 -1
  128. package/dist/tools/remote.d.ts.map +1 -1
  129. package/dist/tools/view-file.d.ts.map +1 -1
  130. package/dist/tools.js +20 -16
  131. package/dist/tracking/attribution.d.ts +51 -0
  132. package/dist/tracking/attribution.d.ts.map +1 -0
  133. package/dist/tracking/beacon.d.ts +14 -0
  134. package/dist/tracking/beacon.d.ts.map +1 -0
  135. package/dist/tracking/clicks.d.ts +46 -0
  136. package/dist/tracking/clicks.d.ts.map +1 -0
  137. package/dist/tracking/client.d.ts +121 -0
  138. package/dist/tracking/client.d.ts.map +1 -0
  139. package/dist/tracking/contract.d.ts +57 -0
  140. package/dist/tracking/contract.d.ts.map +1 -0
  141. package/dist/tracking/delivery.d.ts +24 -0
  142. package/dist/tracking/delivery.d.ts.map +1 -0
  143. package/dist/tracking/host.d.ts +43 -0
  144. package/dist/tracking/host.d.ts.map +1 -0
  145. package/dist/tracking/once-per-page.d.ts +17 -0
  146. package/dist/tracking/once-per-page.d.ts.map +1 -0
  147. package/dist/tracking/outbox-storage-indexeddb.d.ts +12 -0
  148. package/dist/tracking/outbox-storage-indexeddb.d.ts.map +1 -0
  149. package/dist/tracking/outbox-storage-memory.d.ts +12 -0
  150. package/dist/tracking/outbox-storage-memory.d.ts.map +1 -0
  151. package/dist/tracking/outbox.d.ts +98 -0
  152. package/dist/tracking/outbox.d.ts.map +1 -0
  153. package/dist/tracking/schemas.d.ts +183 -0
  154. package/dist/tracking/schemas.d.ts.map +1 -0
  155. package/dist/tracking/scroll.d.ts +22 -0
  156. package/dist/tracking/scroll.d.ts.map +1 -0
  157. package/dist/tracking/sequence-reserve.d.ts +34 -0
  158. package/dist/tracking/sequence-reserve.d.ts.map +1 -0
  159. package/dist/tracking/server/active-interval.d.ts +26 -0
  160. package/dist/tracking/server/active-interval.d.ts.map +1 -0
  161. package/dist/tracking/server/bot.d.ts +9 -0
  162. package/dist/tracking/server/bot.d.ts.map +1 -0
  163. package/dist/tracking/server/disposition.d.ts +55 -0
  164. package/dist/tracking/server/disposition.d.ts.map +1 -0
  165. package/dist/tracking/server/hash.d.ts +13 -0
  166. package/dist/tracking/server/hash.d.ts.map +1 -0
  167. package/dist/tracking/server/presence.d.ts +27 -0
  168. package/dist/tracking/server/presence.d.ts.map +1 -0
  169. package/dist/tracking/server/visit-lease.d.ts +92 -0
  170. package/dist/tracking/server/visit-lease.d.ts.map +1 -0
  171. package/dist/tracking/visible-time.d.ts +38 -0
  172. package/dist/tracking/visible-time.d.ts.map +1 -0
  173. package/dist/tracking-server.d.ts +15 -0
  174. package/dist/tracking-server.d.ts.map +1 -0
  175. package/dist/tracking-server.js +173 -0
  176. package/dist/tracking.d.ts +24 -0
  177. package/dist/tracking.d.ts.map +1 -0
  178. package/dist/tracking.js +1091 -0
  179. package/dist/upgrade-cli.d.ts +27 -0
  180. package/dist/upgrade-cli.d.ts.map +1 -0
  181. package/dist/upgrade-cli.js +177 -0
  182. package/llms-full.txt +1001 -29
  183. package/llms.txt +4 -0
  184. package/package.json +30 -5
package/llms-full.txt CHANGED
@@ -59,11 +59,15 @@ own, recorded as an ADR.
59
59
  | `stitchkit/remote` | browser **and** server | stable | peer-free `implementRemote` for thin HTTP proxy processes |
60
60
  | `stitchkit/files` | server (Bun or Node) | stable | peer-free managed local-file boundary |
61
61
  | `stitchkit/telegram` | server (Bun or Node) | evolving | peer-free Telegram platform primitives — Mini App `initData` verification and Bot API send-failure classification |
62
+ | `stitchkit/tracking` | browser **and** server | evolving | visitor-tracking mechanics — `createTrackingClient`, the tab-shared outbox, the page-leave beacon, attribution, the contract factory; no event vocabulary, no React |
63
+ | `stitchkit/tracking/server` | server (Bun or Node) | evolving | the decisions a tracking backend makes — dispositions, visit lease over an application-owned store, active intervals, presence; no database |
64
+ | `stitchkit/release` | browser **and** server | evolving | a page follows the release it was built for — `createReleaseMarker` on the server, `createReleaseWatcher` in the browser, the `X-Build-Id` header and a socket event between them |
65
+ | `stitchkit/geo` | server (Bun or Node) | evolving | managed GeoIP reader generations, last-known-good reload and the optional MaxMind adapter |
62
66
  | `stitchkit/observability` | server | stable | request/tool event projections — `createObservability`, trace context, sanitisation |
63
67
  | `stitchkit/testing` | tests on Bun or Node | stable | in-process generated clients over a real Fetch handler, plus the store and managed-resource conformance kits |
64
68
  | `stitchkit/declaration` | browser + build and deployment tooling (Bun or Node) | evolving | `ProjectDeclarationSchema` — the one machine-readable statement a repository makes about itself |
65
- | `stitchkit/react` | browser | stable | `createCursorQuery`, `createCacheBridge` |
66
- | `stitchkit/agent-runtime` | server | evolving<br>_redefined in 11 of the 25 minors since 0.56.2, most recently 0.75.0_ | optional durable conversation/run loop, history, models, prompts, fencing and events |
69
+ | `stitchkit/react` | browser + server rendering | stable | `createCursorQuery`, `createCacheBridge`, QueryClient and `ApiError` retry policy |
70
+ | `stitchkit/agent-runtime` | server | evolving<br>_redefined in 11 of the 27 minors since 0.56.2, most recently 0.75.0_ | optional durable conversation/run loop, history, models, prompts, fencing and events |
67
71
  | `stitchkit/agent-runtime/harness` | server | evolving | resource-aware process-local facade over the canonical Agent runtime; supervision stays outside |
68
72
  | `stitchkit/agent-runtime/coding-tools` | server (Bun or Node) | evolving | bounded host-authorized direct file and shell tools; a root boundary, not an OS sandbox |
69
73
  | `stitchkit/agent-runtime/openrouter` | server | evolving | isolated OpenRouter language-model adapter |
@@ -71,7 +75,7 @@ own, recorded as an ADR.
71
75
  | `stitchkit/agent-runtime/sqlite/bun` | server (Bun) | evolving | durable built-in SQLite store for the agent runtime |
72
76
  | `stitchkit/agent-runtime/sqlite/node` | server (Node ≥ 22.5) | evolving | durable built-in SQLite store for the agent runtime |
73
77
  | `stitchkit-tui` | terminal (Bun) | evolving | optional official OpenTUI host over a caller-composed headless runtime |
74
- | `stitchkit/application` | browser + server | evolving<br>_redefined in 6 of the 25 minors since 0.56.2, most recently 0.79.0_ | managed resource graph, readiness, admission, schedules, subtree restart and bounded shutdown |
78
+ | `stitchkit/application` | browser + server | evolving<br>_redefined in 6 of the 27 minors since 0.56.2, most recently 0.79.0_ | managed resource graph, readiness, admission, schedules, subtree restart and bounded shutdown |
75
79
  | `stitchkit/application/grammy` | server | evolving | isolated grammY polling and webhook lifecycle adapters |
76
80
  | `stitchkit/application/opentelemetry` | server | evolving | maps application snapshots onto an injected OpenTelemetry `Meter` |
77
81
  | `stitchkit/application/schemas` | browser + server | evolving | the application's snapshot, health and shutdown schemas alone, without the kernel |
@@ -182,6 +186,7 @@ map — feature → packages:
182
186
  | MCP host/client tests | `@modelcontextprotocol/client` |
183
187
  | MCP Apps UI widgets | `@modelcontextprotocol/ext-apps` |
184
188
  | React data layer (`stitchkit/react`) | `@tanstack/react-query` `react-query-kit` |
189
+ | MaxMind GeoIP (`stitchkit/geo`) | `maxmind` |
185
190
  | **Socket.IO server on Bun** | `socket.io` `@socket.io/bun-engine` |
186
191
  | **Socket.IO server on Node** | `socket.io` |
187
192
  | Socket.IO client | `socket.io-client` (runtime peer; unrelated root declarations remain peer-free) |
@@ -237,6 +242,370 @@ and recovery stay framework-owned primitives, while model choice, permissions,
237
242
  executables and OS isolation remain application policy.
238
243
 
239
244
 
245
+ ==============================================================================
246
+ # Guide: Release (docs/guide/release.md)
247
+ ==============================================================================
248
+
249
+ # Release
250
+
251
+ A tab that outlives a release keeps running a bundle the server no longer
252
+ serves. `stitchkit/release` makes the page follow the release it was built
253
+ for: the server names its current frontend build, the browser compares that
254
+ to its own, and reloads under a policy you declare. Both halves are
255
+ **evolving**. → ADR 0167
256
+
257
+ ## The server: a marker and two channels
258
+
259
+ ```ts
260
+ import { readFileSync } from 'node:fs'
261
+ import { createReleaseMarker } from 'stitchkit/release'
262
+ import { bindReleaseRefreshSignal, createServer } from 'stitchkit/server'
263
+
264
+ // Where the *active* release wrote its build id. After a frontend-only
265
+ // release the backend may still run from an older root — read the pointer to
266
+ // the current one, not the process's own cwd. `null` means "no release": a
267
+ // dev server under HMR stays silent.
268
+ const release = createReleaseMarker({
269
+ read: () => (env.RELEASE_ROOT ? readFileSync(`${env.RELEASE_ROOT}/current/frontend/.next/BUILD_ID`, 'utf8') : null),
270
+ onError: (error) => logger.warn('release marker', error), // an unreadable file, a value that is not an id, a subscriber that threw
271
+ })
272
+
273
+ const server = createServer({ services, cors, release })
274
+
275
+ // A deploy that replaced the frontend without restarting the backend sends
276
+ // SIGUSR2; the marker re-reads and tells its subscribers.
277
+ bindReleaseRefreshSignal(release, { onRefresh: (r) => logger.info('build', r) })
278
+ ```
279
+
280
+ With `release` configured, **every** response — success, error, raw route —
281
+ carries `X-Build-Id`, and `DEFAULT_CORS_EXPOSE_HEADERS` lets a cross-origin
282
+ page read it. That is the universal channel: any application that makes HTTP
283
+ requests has it, with no socket and no polling of its own.
284
+
285
+ Where a Socket.IO server exists, add the fast path:
286
+
287
+ ```ts
288
+ import { bindReleaseToSocketServer } from 'stitchkit/release'
289
+
290
+ bindReleaseToSocketServer(io, release) // event `release` on connection and on change
291
+ ```
292
+
293
+ A connection re-reads the file before it is answered, so a deploy signal the
294
+ process missed while it was down is repaired by the next client that connects.
295
+
296
+ Two things to keep straight: a custom `cors.exposeHeaders` **replaces** the
297
+ default list, so add `X-Build-Id` to yours or a cross-origin page cannot read
298
+ it; and do not list `SIGUSR2` in `bindProcessSignals` as well, or a deploy
299
+ would shut the server down.
300
+
301
+ ## The deploy step
302
+
303
+ The framework binds the signal; **your release sends it**, after the new
304
+ frontend is active:
305
+
306
+ ```bash
307
+ pm2 sendSignal SIGUSR2 my-backend # or: kill -USR2 <pid>
308
+ ```
309
+
310
+ Name it in the release steps of the project. A backend restart needs no
311
+ signal — the marker reads on start, and every reconnecting browser asks.
312
+
313
+ ## The browser: a watcher and its feeds
314
+
315
+ ```ts
316
+ import { createHttpClient } from 'stitchkit'
317
+ import { createReleaseWatcher, observeReleaseFromSocket } from 'stitchkit/release'
318
+
319
+ const release = createReleaseWatcher({
320
+ own: env.NEXT_PUBLIC_BUILD_ID ?? 'dev', // what THIS bundle was built with; `dev` never reloads
321
+ policy: 'when-hidden', // or 'immediate' | 'on-navigation'
322
+ maxDeferMs: 15 * 60 * 1000,
323
+ onStale: () => toast('A new version is ready'),
324
+ })
325
+
326
+ const http = createHttpClient({ baseUrl, release }) // reads X-Build-Id from every response
327
+ observeReleaseFromSocket(socket, release) // and the socket event, where there is one
328
+ ```
329
+
330
+ `observe` compares to `own`, never to the first id it happened to hear — a
331
+ tab that loaded a cached bundle after the release is exactly the tab that must
332
+ reload. An `own` of `dev` (configurable via `ignore`), or no `own` at all,
333
+ never reloads, so a development bundle against a production API is not sent
334
+ in a loop. And one reload per id: the id a reload was attempted for is kept
335
+ in session storage, and if the page comes back and the server still names it,
336
+ the server is wrong about what it serves (a marker reading the wrong root, a
337
+ cached response) — the page stays, `stale()` is true, `onStale` has fired,
338
+ and nothing loops.
339
+
340
+ | Policy | Reloads |
341
+ |--------|---------|
342
+ | `immediate` | now — a chat, a dashboard |
343
+ | `when-hidden` | when the tab is next hidden, or at `maxDeferMs` — a page with forms |
344
+ | `on-navigation` | when you call `release.navigated()` on a route change, or at `maxDeferMs` — an SPA where a full load replaces a transition |
345
+
346
+ `browserReleaseHost()` is what the watcher reads from a tab; a test passes its
347
+ own `host` and drives visibility, the timer and `reload` by hand. Without a
348
+ `document` (server-side rendering) the default host is inert: `observe`
349
+ records the verdict and reloads nothing.
350
+
351
+ ## Adopting it
352
+
353
+ 1. Bake the build id into the bundle, from **one** source. Without it the
354
+ watcher has no `own` and never reloads — and it never says so, which is the
355
+ part worth spending a paragraph on.
356
+
357
+ `NEXT_PUBLIC_BUILD_ID=$(git rev-parse --short HEAD)` in the build command is
358
+ the short version and it has a failure mode: on an immutable-release layout
359
+ that variable usually lives in a static environment file, so it is easy for
360
+ two releases to ship the same id. Then `own` equals what the server reports
361
+ on every response, the watcher is correct to stay quiet, and nothing
362
+ anywhere is red. A reload that never happens looks exactly like a reload
363
+ that was not needed.
364
+
365
+ So mint it once and let one value reach all three readers. In Next, that is
366
+ `next.config`:
367
+
368
+ ```ts
369
+ const buildId = process.env.BUILD_ID ?? execSync('git rev-parse --short HEAD').toString().trim()
370
+
371
+ export default {
372
+ generateBuildId: () => buildId, // → .next/BUILD_ID, which the server marker reads
373
+ env: { NEXT_PUBLIC_BUILD_ID: buildId }, // → the bundle, which becomes `own`
374
+ }
375
+ ```
376
+
377
+ The bundle's id and the file the server reports now come from the same
378
+ expression evaluated once, so they cannot drift apart per release. Any
379
+ arrangement with that property will do; the one to avoid is two places that
380
+ each decide the id and are expected to agree.
381
+ 2. Point `read` at the id of the **active** release, not the process's cwd.
382
+ 3. Add `release: (data: { buildId: string | null }) => void` to your
383
+ `ServerToClientEvents` map where you type your socket, so your own `on`
384
+ knows the event the binding emits. This is also what makes a **typed**
385
+ `Server<…>` fit `bindReleaseToSocketServer`: its `emit` is narrowed to the
386
+ names in your map, so until `release` is one of them the call fails to
387
+ typecheck — as a long structural mismatch, which reads like a bug in the
388
+ binding and is this line instead.
389
+ 4. Send `SIGUSR2` from the deploy step that activates a frontend without
390
+ restarting the backend, and name that step in the project's release steps.
391
+
392
+
393
+ ==============================================================================
394
+ # Guide: Visitor tracking (docs/guide/tracking.md)
395
+ ==============================================================================
396
+
397
+ # Visitor tracking
398
+
399
+ `stitchkit/tracking` is the browser half of visitor tracking — the outbox, the
400
+ visit lease, the page-leave beacon, visible time, scroll milestones,
401
+ declarative clicks, attribution — and `stitchkit/tracking/server` is the set of
402
+ decisions a tracking backend makes about what arrives. Neither has an event
403
+ vocabulary of its own, a database, or a React component: those are the
404
+ application's, and the boundary is deliberate. → ADR 0166
405
+
406
+ Both entrypoints are **evolving**.
407
+
408
+ ## What the framework owns, and what you do
409
+
410
+ | Framework | Application |
411
+ |-----------|-------------|
412
+ | the event envelope, the batch, the dispositions, the visit entry and lease schemas | the event **types**, their metadata, their labels |
413
+ | the tab-shared outbox with reserved sequences and a short flush lease | which storage adapter (IndexedDB in a tab) |
414
+ | the page-leave beacon that arrives, and its queued insurance copy | the session, the router, the React provider |
415
+ | visible time, scroll milestones, `data-track` clicks, UTM first/current touch | the referrer → source map, the attribute names |
416
+ | `dispositionTrackingBatch`, `issueVisitLease`, `activeIntervalOf`, presence | the tables, the transaction, adoption, geo, reports |
417
+
418
+ ## Contract
419
+
420
+ ```ts
421
+ import { createTrackingContract } from 'stitchkit/tracking'
422
+
423
+ export const tracking = createTrackingContract({
424
+ scope: 'public',
425
+ eventTypes: ['PAGE_VIEW', 'PAGE_LEAVE', 'SCROLL_DEPTH', 'SESSION_HEARTBEAT', 'CLICK', 'OUTBOUND_CLICK', 'INTERACTION', 'ITEM_VIEW'],
426
+ // eventExtras: z.object({ locale: z.string() }), // fields you carry beside the envelope
427
+ })
428
+ ```
429
+
430
+ Two operations: `bootstrap` (`POST /visit`) issues or renews a visit lease,
431
+ `track` (`POST /events`) receives a batch. `track` declares
432
+ `safelistedBody: true`, because the page-leave event is a string beacon — the
433
+ only body a document that is being unloaded can deliver to another origin —
434
+ and that in turn requires an explicit `cors.origin` allow-list on the server.
435
+ Read [safelisted request bodies](./server.md#safelisted-request-bodies-beacons)
436
+ before deploying: it is one rule, and it is the one that keeps the beacon path
437
+ from being a CSRF hole.
438
+
439
+ `scope: 'public'` is the usual choice: the landing page is read by anonymous
440
+ visitors, and their path is part of the funnel. Identity comes from the cookie
441
+ on the server side; the client claims nothing about itself.
442
+
443
+ ## Browser
444
+
445
+ ```ts
446
+ import {
447
+ browserTrackingHost, CONVENTIONAL_TRACKING_EVENT_TYPES, createTrackingClient,
448
+ createTrackingOutbox, indexedDbOutboxStorage,
449
+ } from 'stitchkit/tracking'
450
+
451
+ // Only the types the browser writes — a server-side event must not be trackable here.
452
+ type BrowserEvents = Pick<EventMetadataMap, ClientTrackingEventType>
453
+
454
+ const client = createTrackingClient<BrowserEvents>({
455
+ host: browserTrackingHost(),
456
+ buildId: env.NEXT_PUBLIC_BUILD_ID, // a typed env accessor; the client refuses `undefined`
457
+ builtin: CONVENTIONAL_TRACKING_EVENT_TYPES, // your names for the events the client emits
458
+ bootstrap: (entry) => api.tracking.bootstrap(entry),
459
+ deliver: (batch) => api.tracking.track(batch), // or a socket first, HTTP as the fallback
460
+ unloadUrl: urls.tracking.track(),
461
+ outbox: createTrackingOutbox(indexedDbOutboxStorage('my-app-tracking')),
462
+ referrerMap: [{ pattern: /t\.me|telegram\.org/, source: 'telegram', medium: 'social' }],
463
+ isAction: isInteractionAction,
464
+ onVisit: (visitId) => rum.setVisit(visitId),
465
+ })
466
+ ```
467
+
468
+ `EventMetadataMap` is your `{ [type]: metadata }` map; `track` is typed by it:
469
+
470
+ ```ts
471
+ client.track('ITEM_VIEW', { itemId, itemTitle })
472
+ client.track('SIGN_OUT')
473
+ ```
474
+
475
+ Two knobs match the client to your schema: `batchSize` is the contract's
476
+ `maxEventsPerBatch` (a larger batch is a `400` the outbox can never retire),
477
+ and `decorate` adds your `eventExtras` — a locale, an area, or the identity a
478
+ client that cannot send headers on unload has to carry in the body — to every
479
+ event as it is minted, so the outbox, the beacon and `deliver` all see it:
480
+
481
+ ```ts
482
+ createTrackingClient<BrowserEvents, TrackingEventEnvelope<keyof BrowserEvents> & { locale: string }>({
483
+ decorate: (event) => ({ ...event, locale: currentLocale() }),
484
+ // …
485
+ })
486
+ ```
487
+
488
+ A `bootstrap` operation that needs extra fields of its own (a locale, a RUM
489
+ flag) is not the factory's: build the `entry` schema with
490
+ `createTrackingSchemas(…).entry.extend({ … })` and declare that endpoint yourself.
491
+
492
+ The client owns the mechanics that were the same in every application that
493
+ wrote them: the visit lease and its renewal after a long sleep, a pending queue
494
+ until the visit exists, synchronous event identity from a block of reserved
495
+ sequence numbers, the outbox and its flush lease, one bounded delivery retry,
496
+ the page-leave beacon *and* its queued copy, additive visible time, scroll
497
+ milestones, `data-track` / `data-track-action` clicks, heartbeats. It does not
498
+ own the router or React — tell it about navigation, and wrap it yourself:
499
+
500
+ ```tsx
501
+ const TrackerContext = createContext<TrackFn<BrowserEvents> | null>(null)
502
+
503
+ export function TrackingProvider({ children }: { children: React.ReactNode }) {
504
+ const [client] = useState(() => createTrackingClient<BrowserEvents>({ /* as above */ }))
505
+ const pathname = usePathname()
506
+ const search = useSearchParams().toString()
507
+ useEffect(() => client.start(), [client])
508
+ useEffect(() => client.onNavigate(pathname, search ? `?${search}` : ''), [client, pathname, search])
509
+ return <TrackerContext.Provider value={client.track}>{children}</TrackerContext.Provider>
510
+ }
511
+
512
+ export const useTracker = () => useContext(TrackerContext) ?? (() => { throw new Error('outside TrackingProvider') })()
513
+ ```
514
+
515
+ A component that records "opened X" can mount twice on one page; keep that
516
+ memory at document scope with `createOncePerPage`, not in a ref.
517
+
518
+ ### Why these details are not optional
519
+
520
+ Four of them were found the expensive way, by consuming applications, and each
521
+ is a test in this repository that reddens when the mechanism is put back:
522
+
523
+ - the beacon body is a **string** — a `Blob` typed `application/json` reports
524
+ `true` and dies on the preflight it cannot have;
525
+ - the event gets its sequence number **before** it is written — awaiting the
526
+ outbox inside `pagehide` loses the event with the document;
527
+ - the flush lease is **short and released on leave** — a ten-second lease a
528
+ dying document kept delayed the next document's first flush by ten seconds;
529
+ - `oncePerPage` lives at document scope — a remounted component has fresh refs.
530
+
531
+ ## Server
532
+
533
+ The server half is pure. Read from your database, decide, write:
534
+
535
+ ```ts
536
+ import { activeIntervalOf, dispositionTrackingBatch, hashTrackingEvent, issueVisitLease } from 'stitchkit/tracking/server'
537
+
538
+ track: async ({ input, ipAddress, userAgent }) => {
539
+ const actorOwnerId = session?.userId ?? null
540
+ const visits = await db.visit.findMany({ where: { id: { in: input.events.map((e) => e.visitId) } } })
541
+ const stored = await db.trackingEvent.findMany({ where: { clientEventId: { in: input.events.map((e) => e.eventId) } } })
542
+ const decided = dispositionTrackingBatch({
543
+ events: input.events,
544
+ visits: visits.map((v) => ({ id: v.id, browserStreamId: v.browserStreamId, ownerId: v.userId })),
545
+ existing: new Map(stored.map((e) => [e.clientEventId, e.clientPayloadHash])),
546
+ actorOwnerId,
547
+ userAgent,
548
+ })
549
+ const now = new Date()
550
+ await db.$transaction(async (tx) => {
551
+ for (const visitId of decided.adoptable) await adoptVisit(tx, visitId, actorOwnerId, now)
552
+ await tx.trackingEvent.createMany({
553
+ data: decided.accepted.map((event, ordinal) => ({
554
+ ...yourColumns(event), clientPayloadHash: hashTrackingEvent(event), serverItemOrdinal: ordinal, createdAt: now,
555
+ })),
556
+ })
557
+ for (const event of decided.accepted) {
558
+ const interval = activeIntervalOf(event, now)
559
+ if (interval) await tx.activeTimeInterval.upsert(/* by interval.intervalId */)
560
+ }
561
+ })
562
+ return { accepted: decided.accepted.length, dispositions: decided.dispositions }
563
+ }
564
+ ```
565
+
566
+ `hashTrackingEvent` is `sha256(JSON.stringify(parsedEvent))` with no key
567
+ sorting — exactly what applications already store, so a migration does not
568
+ spend a week reporting conflicts. `decided.conflicts` names duplicates whose
569
+ payload changed; log them.
570
+
571
+ `issueVisitLease` runs the visit algorithm over a store you implement — six
572
+ methods over your tables, inside a lineage lock you hold (`pg_advisory_xact_lock`
573
+ over the browser stream id is the usual one):
574
+
575
+ ```ts
576
+ const store: TrackingVisitStore<Prisma.TransactionClient> = {
577
+ withLineageLock: (lineage, fn) => db.$transaction(async (tx) => {
578
+ await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${lineage}))`
579
+ return fn(tx)
580
+ }),
581
+ findActive: (tx, q) => tx.visit.findFirst({ where: { /* q.browserStreamId, q.cutoff, ownership */ } }),
582
+ touch: (tx, id, now, health) => tx.visit.update({ where: { id }, data: { lastActivityAt: now, ...health } }),
583
+ adopt: (tx, visit, ownerId, now) => /* assign the owner, back-fill events, record the merge */,
584
+ endOpen: (tx, lineage, now) => tx.visit.updateMany({ where: { browserStreamId: lineage, endedAt: null }, data: { endedAt: now } }),
585
+ create: (tx, visit, health) => tx.visit.create({ data: { id: visit.id, ...deviceGeoAndSource(visit) , ...health } }),
586
+ }
587
+
588
+ bootstrap: ({ input, userAgent }) =>
589
+ issueVisitLease(store, { ownerId: session?.userId ?? null, userAgent }, input, { ownership: 'adopting' })
590
+ ```
591
+
592
+ `ownership` is the one policy two applications disagreed on: `'adopting'`
593
+ continues an anonymous visit and gives it to the caller who signs in — the
594
+ path from the landing page to the first signed-in action becomes one path; `'owned'` never
595
+ continues an anonymous visit, for an application where every visitor already
596
+ has an identity. A bot gets a lease that no store holds, so its events are
597
+ `identity-invalid` and nothing of it reaches a report.
598
+
599
+ `createPresenceRegistry` is who is here now, in this process: touch it from
600
+ `track`, read it for a live feed. It is honestly empty after a restart.
601
+
602
+ ## Not here
603
+
604
+ GeoIP, event labels, funnels, reports, the analytics page, Socket.IO push into
605
+ an admin UI, RUM — application code, and the reason two applications' tracking
606
+ looks different even though their mechanics are now one.
607
+
608
+
240
609
  ==============================================================================
241
610
  # Guide: Contracts (docs/guide/contracts.md)
242
611
  ==============================================================================
@@ -331,11 +700,13 @@ with output and a default bodyless `204` without it.
331
700
 
332
701
  The three schemas are distinct on purpose:
333
702
 
334
- - **`params`** — values in the URL path. `path: '/:id'`
335
- `params: z.object({ id: z.string() })`. The client takes them from the call
336
- argument and substitutes them into the URL. A terminal wildcard is explicitly
337
- named: `path: '/:slug/*filePath'` with
338
- `params: z.object({ slug: z.string(), filePath: z.string() })` matches both
703
+ - **`params`** — values in the URL path. `path: '/:id'` infers
704
+ `{ id: string }` and materializes the same Zod string schema for HTTP, OpenAPI,
705
+ MCP, Agent and CLI. Declare `params: z.object({ id: z.uuid() })` only when the
706
+ path value needs validation or coercion; an explicit schema must cover every
707
+ name in the path. The client takes params from the call argument and
708
+ substitutes them into the URL. A terminal wildcard is explicitly named:
709
+ `path: '/:slug/*filePath'` matches both
339
710
  `/foo/page` and `/foo/a/b`; the handler reads `ctx.params.filePath` as
340
711
  `'page'` or `'a/b'`. Bare `/*`, invalid/duplicate names and a wildcard before
341
712
  the final segment fail at contract definition.
@@ -350,7 +721,7 @@ caller passes a single flat object, the client routes each field to the path or
350
721
  the body.
351
722
 
352
723
  ```ts
353
- // path: '/:id', params: { id }, input: { text }
724
+ // path: '/:id' infers params: { id: string }; input: { text }
354
725
  await api.update({ id: '1', text: 'new' }) // PUT /users/1 body: { text: 'new' }
355
726
 
356
727
  // path: '/:slug/*filePath', params: { slug, filePath }
@@ -418,6 +789,11 @@ With this policy, omitting `expose` materializes `['HTTP']` on the returned
418
789
  endpoint. MCP, Agent and CLI then require an explicit endpoint array. The plain
419
790
  factory and `defineContract` keep the default-on behaviour above.
420
791
 
792
+ Consequently, `expose: ['HTTP']` is redundant inside a factory configured with
793
+ `toolExposure: 'explicit'`: omit it unless the explicit spelling is useful to a
794
+ local reader. Add `MCP`, `AGENT` or `CLI` only on endpoints that intentionally
795
+ join those surfaces.
796
+
421
797
  Tool transports (`MCP`, `AGENT`) skip four kinds of endpoint automatically:
422
798
  `multipart` (a file upload is not a tool call),
423
799
  [`rawResponse`](./server.md#raw-response-endpoints) (its answer is bytes, which
@@ -1285,12 +1661,83 @@ not retain the text. `maxJsonBodyBytes` may also be set once on `createServer` /
1285
1661
  `createHandler`; a route value wins. Both limits are opt-in and abort an
1286
1662
  oversized stream before it is fully buffered. → ADR 0051
1287
1663
 
1664
+ ## Safelisted request bodies (beacons)
1665
+
1666
+ A body the router reads is `application/json`, and a non-empty body with any
1667
+ other `Content-Type` is a `400`. That is deliberate: a `text/plain` body is a
1668
+ CORS *simple* request, sent with cookies from any site before the browser asks
1669
+ whether the page may read the reply — so requiring JSON forces a preflight, and
1670
+ the preflight is where a foreign form is stopped.
1671
+
1672
+ A page that is being unloaded does not get a preflight. `navigator.sendBeacon`
1673
+ with an `application/json` blob and `fetch({ keepalive: true })` with a JSON
1674
+ `Content-Type` both report success and both die before the server sees them
1675
+ when the API is on another origin. A beacon with a **string** body is
1676
+ `text/plain` and arrives. Declare the endpoint that receives it:
1677
+
1678
+ ```ts
1679
+ const tracking = defineContract(
1680
+ { prefix: 'tracking', scope: 'public' },
1681
+ {
1682
+ track: {
1683
+ method: 'POST', path: '/events', desc: 'Batch track browser events',
1684
+ safelistedBody: true,
1685
+ maxJsonBodyBytes: 256 * 1024,
1686
+ input: TrackEventsRequestSchema,
1687
+ output: TrackEventsResponseSchema,
1688
+ },
1689
+ },
1690
+ )
1691
+
1692
+ // browser, on `pagehide`
1693
+ navigator.sendBeacon(urls.tracking.track(), JSON.stringify(batch)) // text/plain;charset=UTF-8
1694
+ ```
1695
+
1696
+ The body is parsed by the same JSON parser, validated against the same `input`
1697
+ and bounded by the same `maxJsonBodyBytes`; `application/json` keeps working.
1698
+ What changes is one rule, and it is the rule that keeps the door from being a
1699
+ hole: a `text/plain` body is accepted **only from an `Origin` on the server's
1700
+ explicit `cors.origin` allow-list**. Everything else is a `403` before the text
1701
+ is read:
1702
+
1703
+ | Request | Outcome |
1704
+ |---------|---------|
1705
+ | `Origin` on the allow-list (string or list, case-insensitive) | parsed |
1706
+ | `Content-Type: text/plain; charset=application/json` from a foreign origin | `403` — the media type is compared whole, never as a substring |
1707
+ | `Origin` of a site the server never named | `403` |
1708
+ | `Origin: null` — sandboxed iframe, cross-origin redirect, `file://` | `403` |
1709
+ | no `Origin` header | `403` |
1710
+ | server `cors.origin: '*'` | `403` — a wildcard is not an allow-list |
1711
+ | server without `cors` | `403` — there is no allow-list |
1712
+ | `application/json` from any origin | unchanged — never subject to the check |
1713
+
1714
+ The check is a browser invariant, not authentication: a `curl` with a stolen
1715
+ cookie and a forged `Origin` passes, and that is an authenticated call, not a
1716
+ cross-site one. Two things follow for the application:
1717
+
1718
+ - **`sendBeacon` sends no headers.** A bearer-authenticated client has to put
1719
+ its token in the body — and the body is read *after* `authorize`, so that
1720
+ token is available in `beforeHandle` or the handler, never in an auth hook.
1721
+ Cookie-authenticated apps are unaffected.
1722
+ - **`POST` only.** `defineContract` refuses the flag on `GET`, `HEAD`, `PUT`,
1723
+ `PATCH`, `DELETE`, multipart, streaming and input-less endpoints: none of them
1724
+ is a simple request with a JSON body, so the flag would only widen the surface.
1725
+
1726
+ Unlike `rawBody`, the flag does not force `expose: ['HTTP']` — a beacon endpoint
1727
+ may still be a tool; only HTTP body parsing changes. OpenAPI lists both media
1728
+ types with one schema. → ADR 0165
1729
+
1730
+ A [raw route](#raw-routes) has none of this: `parseBody` reads any
1731
+ `Content-Type`, so a raw beacon route is reachable from any site with the
1732
+ user's cookies unless the route checks `Origin` itself. Prefer the flag.
1733
+
1288
1734
  ### Choosing an HTTP boundary
1289
1735
 
1290
1736
  | Need | Contract declaration | What remains framework-owned |
1291
1737
  |------|----------------------|------------------------------|
1292
1738
  | Typed JSON request/response | ordinary `input` / `output` | routing, auth, schemas, hooks, client, OpenAPI |
1293
1739
  | HMAC-signed JSON | `rawBody: true` + `input` / `output` | the same pipeline plus the exact decoded request text |
1740
+ | Page-unload beacon from another origin | `safelistedBody: true` + `input` / `output` | the same pipeline; a `text/plain` body is admitted only from an allow-listed `Origin` |
1294
1741
  | File upload | typed `multipart` descriptor | file cardinality/limits, text input validation and client form encoding |
1295
1742
  | File, stream or redirect response behind contract auth | `rawResponse: true` | request parsing, route identity, auth and typed URL/client surface |
1296
1743
  | Transport that cannot be expressed as the contract pipeline | `RawRoute` | only raw routing, CORS, request hook and error normalisation |
@@ -1455,12 +1902,20 @@ createServer({
1455
1902
  {
1456
1903
  method: 'POST',
1457
1904
  path: '/webhooks/:provider',
1905
+ serviceName: 'webhooks',
1906
+ action: 'receive',
1458
1907
  handler: (req, ctx) => handleWebhook(ctx.params.provider, req),
1459
1908
  },
1460
1909
  ],
1461
1910
  })
1462
1911
  ```
1463
1912
 
1913
+ `serviceName` and `action` are optional observability identity. Declare
1914
+ `serviceName` when audit, logging, sampling or another cross-cutting policy
1915
+ groups this raw route with a service; add `action` when the policy needs an
1916
+ operation within that service. Stitchkit never derives either from `path`.
1917
+ `action` without `serviceName`, and empty identity strings, fail at startup.
1918
+
1464
1919
  A path may be exact, carry `:param` segments, or end in `/*filePath` for a prefix
1465
1920
  wildcard — and the two combine: `/app/:slug/*filePath` matches `/app/x/a/b` with
1466
1921
  `ctx.params.slug === 'x'` and the remainder in `ctx.params.filePath` (a SPA
@@ -5932,6 +6387,79 @@ Durable job tables, lifecycle journals/outboxes, provider inboxes and business
5932
6387
  retry rules do **not** disappear. They were never process-local glue and remain
5933
6388
  application-owned.
5934
6389
 
6390
+ ## Durable process facts and owner notifications
6391
+
6392
+ `createProcessLifecycleLedger` records a bounded versioned list of process runs.
6393
+ Start, readiness and shutdown target both `runId` and `pid`, so hot reload and
6394
+ PID reuse cannot close the wrong generation. A start classifies what happened to
6395
+ the newest run before it, and the classification is decided by three facts —
6396
+ whether that run recorded its own exit, whether the pid is the same, and whether
6397
+ the version changed:
6398
+
6399
+ | newest run | same pid | version | `previousExit` | predecessor's `termination` |
6400
+ |---|---|---|---|---|
6401
+ | none | — | — | `first-boot` | — |
6402
+ | recorded `stoppedAt` | — | — | `clean` / `forced` / `abnormal` as recorded | unchanged |
6403
+ | still `active` | yes | — | `hot-reload` | `hot-reload`, closed at the new start |
6404
+ | still `active` | no | changed | `handoff` | stays `active`; it records its own stop later |
6405
+ | still `active` | no | same | `abnormal` (default) | `abnormal`, closed at the new start — an upper bound, the crash time is unknown |
6406
+
6407
+ A version of `unknown` on either side is never a version change, so a dev build
6408
+ after a crashed release reads `abnormal`, not `handoff`. `forced` means the
6409
+ process itself acknowledged a kill; `abnormal` means a successor found it dead.
6410
+
6411
+ The last row is a choice, and the default is the single-process deployment:
6412
+ one process per build, a new pid of the same build means the old one stopped
6413
+ answering. Where two processes of **one** build overlap on purpose — a
6414
+ cluster, a zero-downtime reload of the same build — pass
6415
+ `sameVersionOverlap: 'handoff'` to the ledger, and the predecessor stays
6416
+ `active` until it records its own shutdown. The cost of that setting is
6417
+ symmetric: a real crash under it is reported as a handoff and the dead run
6418
+ stays `active` in the ledger until retention drops it. The list is kept in the
6419
+ order the transitions wrote it — every write goes through one atomic update,
6420
+ so that order is the causal one, and a successor whose clock lags its
6421
+ predecessor still finds it at the head; `startedAt` is data, not the sort key.
6422
+ Retention (`retain`, default 20) drops finished runs first and an active one —
6423
+ a live handoff predecessor — only when nothing finished is left. Facts are
6424
+ published through the ledger's own subscription and resource value;
6425
+ `ApplicationEventSink` remains the strict application-state stream.
6426
+
6427
+ `createNotificationOutbox` is the transport-neutral durable delivery side. It
6428
+ persists before send, claims an item with an expiring lease, carries one stable
6429
+ idempotency key into the transport, retries under an injected clock and records
6430
+ terminal drops. The guarantee is at-least-once: a crash after remote acceptance
6431
+ but before the receipt is persisted may redeliver, so transports should use the
6432
+ key when they support deduplication. The retry budget is sized for the outage
6433
+ an owner notification has to outlive, not for a flaky call: the default backoff
6434
+ doubles from one second and caps at sixty (`backoffDelay`, the one formula the
6435
+ client's resumable streams also use, with jitter 0), and the 99 waits between
6436
+ the default `maxAttempts` of 100 sum to 1+2+4+8+16+32 s plus 93 × 60 s — about
6437
+ 94 minutes — before `onDropped` sees `attempt-limit`.
6438
+ `state()` is a read and never fails on the bounds a transition enforces — a file
6439
+ that grew past `maxStateBytes` under an older limit is inspectable and is
6440
+ trimmed by the next transition. Two bounds do fail loudly: `enqueue` past
6441
+ `maxQueue` throws rather than dropping silently, and a `backoffMs` that returns
6442
+ `NaN`, a negative or an infinite delay rejects the flush. `stop()` lets the
6443
+ send in flight finish and claims nothing more; a `send` without its own
6444
+ deadline holds `stop()` — and so the resource's `force()` — for that one call,
6445
+ so give the transport a timeout. Superseding a key that is being sent right now
6446
+ does not recall it: the send completes, and only its receipt is not written.
6447
+
6448
+ Both primitives depend on the structural `StateStore`. On a server,
6449
+ `createFileStateStore` supplies the shared Zod-validated JSON adapter with an
6450
+ inter-process lock, unique temporary file, fsync and atomic rename. The lock
6451
+ is a file with a heartbeat: the holder refreshes its mtime every third of
6452
+ `staleLockMs` (default 3 s), a contender waits up to `lockTimeoutMs` (default
6453
+ 10 s), and the stale bound must sit inside the timeout — otherwise a crashed
6454
+ holder blocks every update until the lock ages out, which the constructor
6455
+ refuses. A lock whose heartbeat is stale is reclaimed once its recorded pid is
6456
+ gone; a live or unverifiable pid (a reused number, another user's process)
6457
+ keeps it for ten stale bounds — and never fewer than thirty missed heartbeats,
6458
+ so a scheduler stall on a loaded host cannot pull a live lock from under its
6459
+ holder — after which the heartbeat wins and the lock is abandoned. Temporary files a crashed writer left beside the state are swept on
6460
+ the store's first update. Ledger corruption may be declared reconstructable;
6461
+ an outbox must fail closed rather than silently discard pending delivery.
6462
+
5935
6463
 
5936
6464
  ==============================================================================
5937
6465
  # Guide: Generic application primitives (docs/guide/primitives.md)
@@ -7641,6 +8169,40 @@ gives you the contract and the metadata (`idempotent`, the open `source` tag,
7641
8169
  `createRetainedTopics`); the wire and the per-call execution stay yours. See
7642
8170
  [ADR 0028](../decisions/0028-revert-contract-dispatcher.md).
7643
8171
 
8172
+ ## Authenticated room registry and replay
8173
+
8174
+ `bindSocketRegistry` composes over `bindRealtimeServer`; it never authenticates
8175
+ a socket a second time and never creates another outbound validator. The
8176
+ identity in `connection.raw.data` has already passed the Socket.IO handshake.
8177
+
8178
+ `rooms(identity)` names the rooms a connecting identity may be in; the registry
8179
+ joins them and refuses a later `join` to any other. The disconnect listener is
8180
+ attached before that lookup is awaited, so a socket that drops while its
8181
+ permissions are still being resolved never becomes a member. `registry.room(name)` mints
8182
+ the opaque `AuthorizedSocketRoom` for a name — the token proves the name came
8183
+ through this registry, not that anyone is in the room, so a room with no open
8184
+ tab is an ordinary `emitTo` target that reports zero recipients. The registry
8185
+ owns join/leave, multiple sockets per identity, immutable snapshots and
8186
+ listener cleanup.
8187
+
8188
+ Replay is revisioned: while a socket's snapshot is open, frames sent through
8189
+ `emitTo` to its rooms are held back and delivered after the snapshot, so the
8190
+ socket never sees a delta for state its snapshot already contains, nor a
8191
+ snapshot that predates a delta it already received. A snapshot taken across a
8192
+ `revision` change is retried up to `replayAttempts` (default 3). The hold-back buffer is
8193
+ bounded by `maxBufferedFrames` (default 1000): past it the attempt is abandoned
8194
+ and retried, and when the attempts are spent `onResyncRequired(socketId,
8195
+ identity)` fires with nothing half-delivered — the application decides what a
8196
+ resync means for that socket. A retry relies on the fresh snapshot carrying
8197
+ what the abandoned frames carried, which holds when every emitted change also
8198
+ moves `revision`; without a `revision` the registry has no way to tell, so
8199
+ declare one wherever replay matters. Room tokens are recognised, not
8200
+ remembered: a token the application keeps stays valid for the life of the
8201
+ registry, and a name seen once does not live on in a map. This ordering exists only for frames that go
8202
+ through `emitTo`; a broadcast through `realtime.to(room).emit` reaches the
8203
+ socket immediately, replay or not, so keep room traffic whose order matters
8204
+ relative to the snapshot on the registry.
8205
+
7644
8206
 
7645
8207
  ==============================================================================
7646
8208
  # Guide: Live data (docs/guide/live.md)
@@ -8424,8 +8986,29 @@ session.clear() // → a Set-Cookie value that expires it
8424
8986
  ```
8425
8987
 
8426
8988
  `defineCookie` bundles a cookie's name and options into a typed handle, so the
8427
- config is not repeated at every call site. `parseCookies(header)` and
8428
- `serializeCookie(name, value, opts)` are the lower-level primitives.
8989
+ config is not repeated at every call site. `parseCookies(header)`,
8990
+ `parseCookieHeader(header)` and `serializeCookie(name, value, opts)` are the
8991
+ lower-level primitives.
8992
+
8993
+ **Two cookies, one name.** A browser sends every cookie whose domain and path
8994
+ match, so a `sid` on `.example.com` and a `sid` on `app.example.com` both arrive
8995
+ — in an order the browser chooses (RFC 6265 §5.4) and a server should not rely
8996
+ on (§4.2.2). `get` returns the **last** one by default, which is a fact of the
8997
+ header order, not a rule: the same session then validates in one browser and
8998
+ not in another. Name the behaviour you rely on, or refuse to guess:
8999
+
9000
+ ```ts
9001
+ const session = defineCookie({ name: 'sid', domain: '.example.com', duplicates: 'reject' })
9002
+
9003
+ session.get(req) // undefined when two *different* values arrive; the one value when they agree
9004
+ session.getAll(req) // every candidate, in header order — validate each, clear the loser
9005
+ ```
9006
+
9007
+ `duplicates: 'reject'` turns the lottery into "no session", which a login page
9008
+ handles; `'first'` and `'last'` exist so an application can state which coin it
9009
+ flips. For a session cookie on a parent domain the durable fix is `getAll`:
9010
+ validate each candidate and answer with `clear()` for the one that lost, so the
9011
+ next request carries one.
8429
9012
 
8430
9013
  To set a cookie from a schema-validated JSON endpoint without losing its typed
8431
9014
  client result, declare [`responseMeta`](./server.md#typed-json-response-metadata)
@@ -8580,6 +9163,38 @@ onError: (ctx, err) => {
8580
9163
  }
8581
9164
  ```
8582
9165
 
9166
+ ## One application vocabulary and one wire map
9167
+
9168
+ `defineErrors` can also resolve Stitchkit's framework codes into the same
9169
+ application vocabulary. A status fallback is explicit because several
9170
+ application codes may legally share one HTTP status:
9171
+
9172
+ ```ts
9173
+ const vocabulary = defineErrors(
9174
+ {
9175
+ VALIDATION_ERROR: { status: 400, message: 'Invalid request' },
9176
+ INTERNAL_ERROR: { status: 500, message: 'Internal error' },
9177
+ },
9178
+ {
9179
+ fallback: { 400: 'VALIDATION_ERROR', 500: 'INTERNAL_ERROR' },
9180
+ map: { FILE_TOO_LARGE: 'VALIDATION_ERROR' },
9181
+ },
9182
+ )
9183
+
9184
+ const onError = createErrorHook({ vocabulary })
9185
+ // `vocabulary` *is* the wire map: `codeMap` and `unmappedCode` cannot sit
9186
+ // beside it — the type forbids the combination and the runtime refuses it.
9187
+ // A `map` target must be declared under the framework code's own status
9188
+ // (`NOT_FOUND` → a 404 code, never a 410 one): the wire keeps the framework
9189
+ // status and carries your code, so `defineErrors` refuses the mismatch where
9190
+ // it already refuses a `fallback` under the wrong status.
9191
+ ```
9192
+
9193
+ An explicit per-code `map` wins over a status fallback. With
9194
+ `exhaustive: true`, `map` must be a complete `Record<StitchErrorCode, AppCode>`
9195
+ at compile time. Transport boundaries identify errors with `AppError.is`, not
9196
+ `instanceof`, so two bundled copies still agree.
9197
+
8583
9198
  ## Domain errors — `defineErrors`
8584
9199
 
8585
9200
  Declare each domain code, HTTP status, default message and optional
@@ -8990,12 +9605,11 @@ route-match, *before* validation, so both are present on every event for a
8990
9605
  request that matched a contract route, including a pre-handler 400. Nothing to
8991
9606
  wire.
8992
9607
 
8993
- They are **absent** on events that never reached a contract route: a raw route
8994
- (`openApiRoute`, an MCP mount, any `rawRoutes` entry), a request short-circuited
8995
- by `onRequest`, and an unmatched 404 or 405. Both fields are optional in
8996
- `RequestEvent` for exactly that reason a sink with a `NOT NULL` column on
8997
- either, or a dashboard grouping by `(serviceName, action)`, has to allow for
8998
- it.
9608
+ Raw routes may opt into the same context with `serviceName` and optional
9609
+ `action` on their `RawRoute` declaration. Without that declaration they remain
9610
+ absent, as they do for a request short-circuited by `onRequest` and an unmatched
9611
+ 404 or 405. Both fields therefore remain optional in `RequestEvent`; a sink
9612
+ with a `NOT NULL` column on either still has to account for unattributed paths.
8999
9613
 
9000
9614
  When failure attribution itself is asynchronous, use `createErrorHook`'s
9001
9615
  `onError(error, info, ctx, endpoint)` observer. The framework awaits it before
@@ -9348,6 +9962,26 @@ hooks: {
9348
9962
  Keep any sink **asynchronous and self-contained**: a slow or failing write must
9349
9963
  never block or break the request. Swallow the sink's own errors.
9350
9964
 
9965
+ ## Typed dimensions and bounded loggers
9966
+
9967
+ `createDimensionsProjector` keeps endpoint metadata opaque. Configure typed
9968
+ request, result and error projections, then call its phase methods from the
9969
+ corresponding lifecycle hooks. Every projection writes through
9970
+ `setRequestDimensions`; repeated keys follow the declared `overwrite`,
9971
+ `preserve` or `error` policy. Two of its inputs are programming errors and
9972
+ throw, which fails the request rather than recording a lie: a repeated key
9973
+ under `collision: 'error'`, and a key named `__proto__` (it would set the
9974
+ bag's prototype — it can only arrive from parsed input, never from an object
9975
+ literal, which has no such own key).
9976
+
9977
+ `createBoundedLogger` decorates the existing `StitchLogger` interface, so Pino,
9978
+ console and application sinks keep their ownership. It reuses the same
9979
+ sanitizer as request audit: sensitive keys, configured paths, URL fragments and
9980
+ `Error` fields are redacted before string/collection/depth/total-byte bounds are
9981
+ applied. Active trace, span, user and dimensions are reserved framework fields;
9982
+ caller data cannot overwrite them. A throwing getter, cyclic value or failing
9983
+ sink never escapes into application code.
9984
+
9351
9985
 
9352
9986
  ==============================================================================
9353
9987
  # Guide: Testing & deployment (docs/guide/testing-and-deployment.md)
@@ -10125,6 +10759,159 @@ Do not duplicate DTOs or handwritten API wrappers in either integration. The
10125
10759
  shared contract remains the only transport schema source.
10126
10760
 
10127
10761
 
10762
+ ==============================================================================
10763
+ # Guide: React Query policy (docs/guide/react.md)
10764
+ ==============================================================================
10765
+
10766
+ ---
10767
+ title: React Query policy
10768
+ description: One request-local server client, one browser singleton and explicit retry rules for Stitchkit API errors
10769
+ type: guide
10770
+ status: active
10771
+ created: 2026-09-06
10772
+ updated: 2026-09-06
10773
+ ---
10774
+
10775
+ # React Query policy
10776
+
10777
+ `stitchkit/react` contains policy adapters for TanStack Query. It does not own
10778
+ application queries, cache keys or authentication.
10779
+
10780
+ ## Query client per runtime
10781
+
10782
+ Create the getter once. On the server, pass React's `cache` so identity is local
10783
+ to one render request. In the browser, the returned getter keeps one singleton
10784
+ inside this factory only:
10785
+
10786
+ ```ts
10787
+ import { cache } from 'react'
10788
+ import { createQueryClientFactory } from 'stitchkit/react'
10789
+
10790
+ export const getQueryClient = createQueryClientFactory({
10791
+ serverCache: cache,
10792
+ queryClient: {
10793
+ defaultOptions: { queries: { staleTime: 30_000 } },
10794
+ },
10795
+ })
10796
+ ```
10797
+
10798
+ Pending queries are dehydrated so streaming SSR can resume them. Mutations do
10799
+ not retry by default. Every TanStack default may still be supplied through
10800
+ `queryClient`; a supplied query or mutation retry policy wins over the helper's
10801
+ defaults.
10802
+
10803
+ ## API error retries
10804
+
10805
+ `apiErrorRetry()` returns a TanStack retry predicate. The default retries one
10806
+ network or `5xx` failure and refuses authorization, validation, abort and other
10807
+ `4xx` errors:
10808
+
10809
+ ```ts
10810
+ const retry = apiErrorRetry({
10811
+ attempts: 2,
10812
+ never: ['UNAUTHORIZED', 'FORBIDDEN'],
10813
+ statusRanges: [[500, 599]],
10814
+ })
10815
+ ```
10816
+
10817
+ Ranges are inclusive. The predicate uses `ApiError.is`, so it remains valid when
10818
+ two bundles contain different copies of the class.
10819
+
10820
+ Authentication remains application-owned. Memoize the application's own
10821
+ session query with `cache`; the generic starter does not invent a `getSession`
10822
+ endpoint or cookie vocabulary.
10823
+
10824
+
10825
+ ==============================================================================
10826
+ # Guide: GeoIP (docs/guide/geo.md)
10827
+ ==============================================================================
10828
+
10829
+ ---
10830
+ title: GeoIP
10831
+ description: A server-only managed GeoIP reader with three observable states and last-known-good generation reloads.
10832
+ type: guide
10833
+ status: active
10834
+ created: 2026-09-06
10835
+ updated: 2026-09-06
10836
+ ---
10837
+
10838
+ # GeoIP
10839
+
10840
+ `stitchkit/geo` is a server-only, evolving entrypoint for reading local GeoIP
10841
+ databases without making a database vendor part of the framework contract. It
10842
+ exposes a managed resolver, a peer-neutral reader boundary and an optional
10843
+ MaxMind adapter.
10844
+
10845
+ ## MaxMind
10846
+
10847
+ Install `maxmind` only in applications that use the adapter:
10848
+
10849
+ ```bash
10850
+ bun add maxmind
10851
+ ```
10852
+
10853
+ Create one resolver and place it in the application resource graph:
10854
+
10855
+ ```ts
10856
+ import { createApplication } from 'stitchkit/application';
10857
+ import { createGeoIpResolver, createMaxMindGeoIpLoader } from 'stitchkit/geo';
10858
+
10859
+ const geo = createGeoIpResolver({
10860
+ paths: {
10861
+ city: '/srv/geo/GeoLite2-City.mmdb',
10862
+ asn: '/srv/geo/GeoLite2-ASN.mmdb',
10863
+ },
10864
+ loader: createMaxMindGeoIpLoader(),
10865
+ reload: { intervalMs: 60_000 },
10866
+ onError(error) {
10867
+ logger.warn('GeoIP reader unavailable', { error });
10868
+ },
10869
+ });
10870
+
10871
+ const application = createApplication({ resources: [geo] });
10872
+ ```
10873
+
10874
+ The loader imports the optional peer lazily. City and optional ASN files open
10875
+ as one revision: if either changes during the open, the incomplete generation
10876
+ is discarded.
10877
+
10878
+ ## Three states
10879
+
10880
+ `geo.snapshot()` distinguishes facts that must not be collapsed:
10881
+
10882
+ - `uninitialized` — the managed resource has not started, or has closed;
10883
+ - `unavailable` — no usable database generation could be opened;
10884
+ - `ready` — lookups use a complete generation.
10885
+
10886
+ A refresh failure while a reader is already `ready` preserves that reader and
10887
+ sets `reloadError`. This is last-known-good service, not a successful refresh.
10888
+ Applications decide whether that fact degrades their own readiness.
10889
+
10890
+ ## Lookups
10891
+
10892
+ ```ts
10893
+ const attribution = await geo.resolve(requestIp);
10894
+ ```
10895
+
10896
+ `resolve` returns `null` for invalid, loopback, private, link-local and otherwise
10897
+ non-public addresses without querying the reader. A database miss or lookup
10898
+ failure also returns `null`; `onError` receives operational failures. A result
10899
+ may include country, region, city, postal, coordinates, timezone and ASN fields.
10900
+
10901
+ When a reload succeeds, new lookups move to the new generation immediately.
10902
+ The previous reader closes only after its in-flight lookups finish.
10903
+
10904
+ ## A custom reader
10905
+
10906
+ Use `GeoIpReaderLoader` when the database is not MaxMind-compatible. `revision`
10907
+ must identify the complete input generation and return `null` when it cannot be
10908
+ read. `open` returns a reader for exactly that revision. Stitchkit serializes
10909
+ reloads, swaps generations and owns their close lifecycle.
10910
+
10911
+ Download schedules, licenses, database paths, attribution persistence and
10912
+ analytics policy belong to the application.
10913
+
10914
+
10128
10915
  ==============================================================================
10129
10916
  # Guide: Upgrading (docs/guide/upgrading.md)
10130
10917
  ==============================================================================
@@ -10142,7 +10929,19 @@ A release that breaks a public API leads its `CHANGELOG.md` entry with a
10142
10929
  **`### ⚠️ Breaking changes`** section (exact heading), each item carrying a
10143
10930
  **before → after** snippet. A version with **no** such section is **purely
10144
10931
  additive** — adopting it changes nothing in your code. (See
10145
- [`AGENTS.md` → Breaking changes](../../AGENTS.md).)
10932
+ [`AGENTS.md` → Breaking changes](../../AGENTS.md).) So the list is recoverable
10933
+ mechanically, and the package recovers it for you:
10934
+
10935
+ ```bash
10936
+ bunx stitchkit@latest upgrade # or: npx stitchkit@latest upgrade
10937
+ ```
10938
+
10939
+ Run it inside the project that depends on stitchkit. It reads your installed
10940
+ version out of `node_modules`, reads the changelog **shipped inside the
10941
+ package**, and prints every breaking section your range crosses, oldest first,
10942
+ each with its **Who must act** line — the list this guide's migrations expand
10943
+ on. Nothing to clone, nothing to be told: `--from` / `--to` override either end
10944
+ of the range if you want a different one.
10146
10945
 
10147
10946
  So upgrading is: read the `### ⚠️ Breaking changes` of every version *above* your
10148
10947
  current one *up to* your target, and apply each snippet.
@@ -10171,6 +10970,57 @@ makes one thing your job rather than the resolver's:
10171
10970
  The mechanical part is identical either way. Only the *noticing* differs, and an
10172
10971
  exact pin moves it onto you.
10173
10972
 
10973
+ ## Released migration: 0.82.0
10974
+
10975
+ One thing, and only if you mount MCP or agent tools from a `createTrackingContract` contract.
10976
+
10977
+ ```bash
10978
+ rg -n "createTrackingContract"
10979
+ ```
10980
+
10981
+ The event ingest (`track`, `POST /events`) is now `expose: ['HTTP']`, which is
10982
+ what its sibling `bootstrap` always declared. Before, it declared nothing — and
10983
+ an endpoint with no `expose` is a tool on MCP and AGENT by default, so the
10984
+ mounted surface carried a `track_<prefix>` tool. Since this contract is built
10985
+ inside the framework, an application that had made every tool opt-in for the
10986
+ endpoints *it* authors could not have opted this one out.
10987
+
10988
+ **If you pin a tool-surface digest or count**, it moves by one; re-record it.
10989
+ **If you assert on tool names**, `track_<prefix>` is gone. **If nothing in your
10990
+ project mounts tools from this contract**, there is nothing to do — the HTTP
10991
+ routes, the client and the schemas are unchanged.
10992
+
10993
+ Un-declaring the tool was never the intent, so there is no option to keep it. If
10994
+ an agent in your system genuinely needs to record something, give it your own
10995
+ endpoint with your own schema: the browser ingest trusts a client-minted event
10996
+ id and a visit lease, and an agent holds neither.
10997
+
10998
+ ## Released migration: 0.81.0
10999
+
11000
+ **Path literals own string params.**
11001
+
11002
+ An endpoint whose path contains `:id` or a terminal `*rest` no longer needs a
11003
+ duplicate `params: z.object({ …string() })` declaration. Remove schemas that do
11004
+ nothing beyond repeating path names:
11005
+
11006
+ ```ts
11007
+ // before
11008
+ { path: '/projects/:projectId/*filePath',
11009
+ params: z.object({ projectId: z.string(), filePath: z.string() }) }
11010
+
11011
+ // after
11012
+ { path: '/projects/:projectId/*filePath' }
11013
+ ```
11014
+
11015
+ Keep an explicit schema when it validates or coerces. It must cover every name
11016
+ in the path — an explicit schema that misses one now fails at `defineContract`
11017
+ with the path and the missing field named. Client calls and handler contexts
11018
+ infer the string fields from the literal, so a call that omitted one becomes a
11019
+ compile error instead of building a URL with an unresolved segment. Measured
11020
+ across three consuming applications before release (373 endpoints with path
11021
+ segments), no explicit schema missed a field: the migration is deleting
11022
+ schemas that only repeated the path, or nothing.
11023
+
10174
11024
  ## Released migration: 0.80.0
10175
11025
 
10176
11026
  One thing, and only if you parse an async-operation snapshot.
@@ -13381,8 +14231,9 @@ from the root `stitchkit`.
13381
14231
  | `ScopedDefineContract` | _type_ | the `defineContract` `createContractFactory` returns — endpoint `scope` overrides join the union |
13382
14232
  | `ALL_TRANSPORTS` | constant | `['HTTP', 'MCP', 'AGENT', 'CLI']` |
13383
14233
  | `ContractDef` | _type_ | a defined contract |
14234
+ | `PathParams` | _type_ | infer named `:segments` and a terminal `*wildcard` from a path literal as string params |
13384
14235
  | `ContractMeta` | _type_ | a contract's `prefix` + optional `scope` and `meta` (a default every endpoint shallow-merges over) |
13385
- | `EndpointDef` | _type_ | a single endpoint definition; `output` declares JSON response presence (`null` is data, `undefined` is invalid) |
14236
+ | `EndpointDef` | _type_ | a single endpoint definition; `output` declares JSON response presence (`null` is data, `undefined` is invalid); `safelistedBody: true` admits the JSON body as `text/plain` from an allow-listed `Origin` — [guide](../guide/server.md#safelisted-request-bodies-beacons) |
13386
14237
  | `EndpointStreamDescriptor` | _type_ | HTTP-only schema-derived stream declaration: item schema, envelope/item framing, stream-end/terminal completion, NDJSON/SSE encoding and frame/lifetime/heartbeat/idle bounds — [guide](../guide/server.md#contract-first-streams) |
13387
14238
  | `HeadEndpointDef` | _type_ | explicit HTTP-only, bodyless `HEAD` endpoint definition |
13388
14239
  | `EndpointResponseMeta` | _type_ | static success metadata declared by an HTTP-only typed-data endpoint |
@@ -13418,6 +14269,7 @@ from the root `stitchkit`.
13418
14269
 
13419
14270
  ### Errors
13420
14271
 
14272
+ | `ErrorVocabularyCode` / `ErrorVocabularyMapping` / `VocabularyCodeMap` | _type_ | application-code union, partial/exhaustive framework mapping input and resolved wire map returned by `defineErrors` |
13421
14273
  | Export | Kind | Summary |
13422
14274
  |--------|------|---------|
13423
14275
  | `AppError` | class | the framework error — `code` / `status` / `details` / `hint` |
@@ -13430,7 +14282,7 @@ from the root `stitchkit`.
13430
14282
  | `rateLimited` | function | throw `429 RATE_LIMITED` |
13431
14283
  | `appError` | function | throw an `AppError` for any code |
13432
14284
  | `defineErrors` | function | declare immutable domain error definitions → typed `AppError` constructors, codes and schemas — [guide](../guide/auth-and-errors.md#domain-errors--defineerrors) |
13433
- | `DefinedErrors` | _type_ | the `{ errors, codes, definitions, isCode }` handle `defineErrors` returns |
14285
+ | `DefinedErrors` | _type_ | the `{ errors, codes, definitions, isCode, codeMap }` handle `defineErrors` returns |
13434
14286
  | `DefinedAppError` | _type_ | literal-code error instance with schema-refined details |
13435
14287
  | `ErrorDefinition` | _type_ | `{ status, message?, details? }` definition for one domain code |
13436
14288
  | `ErrorDefinitions` | _type_ | string-keyed domain error definition registry |
@@ -13586,7 +14438,7 @@ Also re-exports the error helpers from `stitchkit/contract`.
13586
14438
  | `composeLifecycleHooks` | function | compose HTTP lifecycle phases in declaration order with short-circuit/fallthrough semantics |
13587
14439
  | `AuthorizationContext` | _type_ | HTTP pre-body context with validated params, `input: undefined` and no files |
13588
14440
  | `RouteGroup` | _type_ | a prefixed group of services with its own hooks; matched errors try group `onError` → global `onError` → standard envelope, keeping the original error on fallback — [precedence](../guide/server.md#lifecycle-hooks) |
13589
- | `RawRoute` | _type_ | a non-contract `Request → Response` route with a concrete `BunServer` context |
14441
+ | `RawRoute` | _type_ | a non-contract `Request → Response` route with a concrete `BunServer` context and optional stable `serviceName` / `action` observability identity |
13590
14442
  | `RawRouteContext` | _type_ | the Bun-bound routing context a raw handler receives |
13591
14443
  | `BunServer` | _type_ | the `Bun.serve` instance type |
13592
14444
  | `ServerPassthrough` | _type_ | extra `Bun.serve` options |
@@ -13603,7 +14455,8 @@ Also re-exports the error helpers from `stitchkit/contract`.
13603
14455
  |--------|------|---------|
13604
14456
  | `createAuthHook` | function | one scope gate for HTTP `authorize` and tool `beforeHandle` — [guide](../guide/auth-and-errors.md#createauthhook) |
13605
14457
  | `composeAuthHooks` | function | route multiple canonical auth domains by owned scope and atomically commit their typed contributions |
13606
- | `createErrorHook` | function | an async-capable, endpoint-aware `onError` hook from a code map + envelope renderer — [guide](../guide/auth-and-errors.md#createerrorhook) |
14458
+ | `createErrorHook` | function | an async-capable, endpoint-aware `onError` hook from a `defineErrors` vocabulary or a code map, plus an envelope renderer — [guide](../guide/auth-and-errors.md#createerrorhook) |
14459
+ | `ErrorHookBase` / `ErrorHookMapping` | _type_ | common hook fields and the vocabulary-or-map branch accepted by `createErrorHook` |
13607
14460
  | `ErrorHookConfig` | _type_ | async observer/renderer config with partial `codeMap` and optional typed `unmappedCode` fallback |
13608
14461
  | `ResolvedError` | _type_ | the normalised error handed to `createErrorHook`'s `render` |
13609
14462
  | `createBearerResolver` | function | a bearer-token identity resolver |
@@ -13633,19 +14486,23 @@ Also re-exports the error helpers from `stitchkit/contract`.
13633
14486
 
13634
14487
  | Export | Kind | Summary |
13635
14488
  |--------|------|---------|
13636
- | `defineCookie` | function | a typed cookie `get` / `set` / `clear` handle — [guide](../guide/auth-and-errors.md#cookies) |
13637
- | `parseCookies` | function | parse a `Cookie` header to a record |
14489
+ | `defineCookie` | function | a typed cookie `get` / `getAll` / `set` / `clear` handle; `duplicates` names what `get` answers when two cookies share the name — [guide](../guide/auth-and-errors.md#cookies) |
14490
+ | `parseCookies` | function | parse a `Cookie` header to a record (the last of two same-named cookies wins) |
14491
+ | `parseCookieHeader` | function | parse a `Cookie` header to `[name, value]` pairs in header order, duplicates kept |
13638
14492
  | `serializeCookie` | function | build a `Set-Cookie` value |
13639
14493
  | `corsHeaders` | function | compute CORS response headers |
13640
14494
  | `corsPreflightResponse` | function | build a preflight `Response` |
13641
14495
  | `DEFAULT_CORS_ALLOW_HEADERS` | const | the default `Access-Control-Allow-Headers` (incl. `traceparent`) — extend it when overriding `cors.headers` |
13642
- | `DEFAULT_CORS_EXPOSE_HEADERS` | const | the default `Access-Control-Expose-Headers` (incl. `Content-Disposition`, `ETag`, `Content-Range`) — extend it when overriding `cors.exposeHeaders` |
14496
+ | `DEFAULT_CORS_EXPOSE_HEADERS` | const | the default `Access-Control-Expose-Headers` (incl. `Content-Disposition`, `ETag`, `Content-Range`, `X-Build-Id`) — extend it when overriding `cors.exposeHeaders`, or a cross-origin page loses the build id |
13643
14497
  | `CookieDef` | _type_ | the `defineCookie` handle |
13644
- | `CookieOptions` | _type_ | cookie attributes |
14498
+ | `CookieOptions` | _type_ | cookie attributes, plus the `duplicates` read policy |
14499
+ | `CookieDuplicatesPolicy` | _type_ | `'last'` (default) / `'first'` / `'reject'` — `reject` yields `undefined` for two different values and the value when they agree |
13645
14500
  | `CorsConfig` | _type_ | CORS policy |
13646
14501
 
13647
14502
  ### Realtime (server)
13648
14503
 
14504
+ | `bindSocketRegistry` | function | room membership and consistent replay over an already-authenticated realtime server |
14505
+ | `AuthorizedSocketRoom` / `SocketRegistry` / `SocketRegistryConnection` / `SocketRegistryOptions` / `SocketRegistryServer` / `SocketRegistrySnapshot` / `SocketReplayFrame` | _type_ | authorized room token, structural server boundary, replay frames and immutable registry state |
13649
14506
  | Export | Kind | Summary |
13650
14507
  |--------|------|---------|
13651
14508
  | `createSocketIOServer` | function | the typed Socket.IO server — [guide](../guide/realtime.md#server--createsocketioserver) |
@@ -13668,6 +14525,8 @@ Also re-exports the error helpers from `stitchkit/contract`.
13668
14525
 
13669
14526
  ### Primitives
13670
14527
 
14528
+ | `createFileStateStore` | function | cross-process locked atomic JSON state updates over one Zod schema |
14529
+ | `FileStateStoreCorruption` / `FileStateStoreOptions` | _type_ | explicit corrupt-state report/policy and lock timing configuration |
13671
14530
  | Export | Kind | Summary |
13672
14531
  |--------|------|---------|
13673
14532
  | `streamSSE` | function | an async generator → SSE `Response` — [guide](../guide/server.md#sse-streaming) |
@@ -13686,6 +14545,7 @@ Also re-exports the error helpers from `stitchkit/contract`.
13686
14545
  | `MultipartReceiver` | _type_ | consumer-owned Web-stream storage receiver |
13687
14546
  | `MultipartReceiverResult` | _type_ | receiver value plus rollback cleanup |
13688
14547
  | `StreamingMultipartImplementation` | _type_ | receiver registry and handler shape inferred by `defineMultipartStream` |
14548
+ | `bindReleaseRefreshSignal` / `ReleaseRefreshSignalOptions` / `ReleaseRefreshSignal` | function / _type_ | a deploy signal (`SIGUSR2`, the one user-defined signal the process vocabulary has) refreshes a `stitchkit/release` marker, whose subscribers tell every socket; never throws at the signal — [guide](../guide/release.md) |
13689
14549
  | `bindProcessSignals` | function | bind `SIGINT` / `SIGTERM` to one managed `shutdown()` — one chain, force on a later signal, default disposition on the one after — [guide](../guide/testing-and-deployment.md#process-signals--bindprocesssignals) |
13690
14550
  | `ProcessSignalsOptions` | _type_ | config for `bindProcessSignals` |
13691
14551
  | `ProcessSignalsBinding` | _type_ | the `{ promise, close }` handle `bindProcessSignals` returns |
@@ -13763,6 +14623,17 @@ cutovers are covered by the executable
13763
14623
  | Export | Kind | Summary |
13764
14624
  |--------|------|---------|
13765
14625
  | `createApplication` | function | compose a validated resource DAG into one startup, readiness, admission and shutdown state machine |
14626
+ | `StateStore` / `StateStoreUpdate` | _type_ | async atomic read/update boundary shared by durable application state machines |
14627
+ | `createProcessLifecycleLedger` / `ProcessLifecycleLedger` / `ProcessLifecycleLedgerConfig` | function / _type_ | durable bounded run history and typed fact subscription |
14628
+ | `LifecycleRunSchema` / `LifecycleRun` / `LifecycleStateSchema` / `LifecycleState` / `LifecycleTerminationSchema` / `LifecycleTermination` | schema / _type_ | versioned process-run state and termination vocabulary |
14629
+ | `PreviousExitSchema` / `PreviousExit` / `StartFact` / `ReadyFact` / `ShutdownFact` / `ProcessLifecycleFact` | schema / _type_ | first-boot, hot-reload, clean, forced, handoff and abnormal lifecycle facts |
14630
+ | `transitionProcessStart` / `transitionProcessReady` / `transitionProcessShutdown` | function | pure idempotent runId+pid-owned transitions |
14631
+ | `LifecycleTransition` / `TransitionStartInput` / `TransitionReadyInput` / `TransitionShutdownInput` / `SameVersionOverlap` | _type_ | pure transition inputs, the same-build overlap policy (`abnormal` default / `handoff`) and state+fact result |
14632
+ | `lifecycleLedgerResource` / `LifecycleLedgerResource` / `LifecycleLedgerResourceConfig` | function / _type_ | managed-resource adapter that records start, readiness and shutdown |
14633
+ | `createNotificationOutbox` / `NotificationOutbox` / `NotificationOutboxConfig` | function / _type_ | bounded at-least-once queue with durable claims, leases, retries and supersede |
14634
+ | `NotificationOutboxItem` / `NotificationOutboxReceipt` / `NotificationOutboxState` / `NotificationSend` / `NotificationFailureClassification` / `DroppedNotification` / `EnqueueNotification` | _type_ | versioned queue records, stable send identity and delivery decisions |
14635
+ | `notificationOutboxStateSchema` | function | strict persisted-state schema over an application payload schema |
14636
+ | `notificationOutboxResource` / `NotificationOutboxResource` / `NotificationOutboxResourceConfig` | function / _type_ | thin managed-resource scheduler over an outbox handle |
13766
14637
  | `ApplicationHandle.restart` | method | replace one resource and everything that depends on it, leaving the rest of the graph running and the process epoch unchanged |
13767
14638
  | `ApplicationRestartInputSchema` / `ApplicationRestartInput` | schema / _type_ | the resource to replace, by id, and optionally `gracePeriodMs` / `forceTimeoutMs` for this restart — the application's own shutdown budget otherwise |
13768
14639
  | `ApplicationRestartResultSchema` / `ApplicationRestartResult` | schema / _type_ | the subtree that was actually taken down and brought back, in start order, with the outcome, the reason on anything but success, and how long it took |
@@ -14401,6 +15272,11 @@ audit event. See the [Observability guide](../guide/observability.md).
14401
15272
  | Export | Kind | Summary |
14402
15273
  |--------|------|---------|
14403
15274
  | `createObservability` | function | configure framework-owned request completion and canonical tool event sinks — [guide](../guide/observability.md#createobservability) |
15275
+ | `createDimensionsProjector` / `DimensionsProjector` / `DimensionsProjectorConfig` / `ProjectedDimensions` | function / _type_ | typed request/result/error attribution projected into the existing request context |
15276
+ | `DimensionCollision` / `SetRequestDimensionsOptions` | _type_ | explicit overwrite, preserve or error policy for dimension keys |
15277
+ | `createBoundedLogger` | function | decorate a `StitchLogger` with request context, shared sanitisation, redaction and total bounds |
15278
+ | `DEFAULT_REDACT_PATHS` | constant | baseline credential/token paths added to the sanitizer's sensitive-key policy |
15279
+ | `BoundedLoggerBounds` / `BoundedLoggerOptions` | _type_ | per-value and total record ceilings plus sink/redaction configuration |
14404
15280
  | `RequestEvent` | _type_ | the normalised audit event handed to the sink; opt-in HTTP cancellation rows carry `outcome: 'cancelled'` |
14405
15281
  | `ObservabilityConfig` | _type_ | independent request and tool sink configuration |
14406
15282
  | `Observability` | _type_ | `{ request?, toolCall, getStatus(), flush(), close() }` with bounded sink lifecycle |
@@ -14821,6 +15697,97 @@ injected grammY bot. → ADR 0143
14821
15697
 
14822
15698
  ---
14823
15699
 
15700
+ ## `stitchkit/tracking`
15701
+
15702
+ Browser-safe visitor-tracking mechanics, with no event vocabulary of their own
15703
+ and no React. The React wrapper (a provider and a hook, ~40 lines) is the
15704
+ application's — it owns the router and the session. The server half is
15705
+ `stitchkit/tracking/server`. → ADR 0166 — [guide](../guide/tracking.md)
15706
+
15707
+ | Export | Kind | Summary |
15708
+ |--------|------|---------|
15709
+ | `createTrackingClient` | function | the client: visit lease and renewal, pending queue, synchronous identity from reserved sequences, outbox and flush lease, delivery, the string-body page-leave beacon plus a queued copy, visible time, scroll milestones, declarative clicks, heartbeats |
15710
+ | `TrackingClient` / `TrackingClientConfig` | _type_ | `track` / `onNavigate` / `start` / `visitId` / `browserStreamId` / `attribution`, and the host, `buildId`, `builtin` names, `bootstrap`, `deliver`, optional `decorate` (your `eventExtras` on every minted event), `unload` / `unloadUrl`, `outbox`, `onUnauthorized`, `onVisit`, `onFailure`, `isAction`, `referrerMap`, `clickAttributes`, `scrollMilestones`, `batchSize`, periods and limits |
15711
+ | `TrackFn` / `EventsWithMetadata` / `EventsWithoutMetadata` | _type_ | `track(type)` / `track(type, metadata)` typed by the application's metadata map, and the two halves of that map |
15712
+ | `BuiltinTrackingEventTypes` / `CONVENTIONAL_TRACKING_EVENT_TYPES` | _type_ / const | the application's names for the events the client emits itself; the conventional `PAGE_VIEW` … `INTERACTION` set |
15713
+ | `browserTrackingHost` / `TrackingHost` / `TrackingPageContext` | function / _type_ | everything the client takes from a browser tab behind one interface — page context, visibility, scroll depth, events, intervals, clocks, storage — so a test or a non-tab host can supply its own |
15714
+ | `createTrackingContract` / `TrackingContractConfig` / `TrackingContractEndpoints` | function / _type_ | `bootstrap` (`POST /visit`) and `track` (`POST /events`, `safelistedBody`) from the application's event types, scope and optional extras |
15715
+ | `createTrackingSchemas` / `TrackingSchemas` / `TrackingSchemasConfig` | function / _type_ | every schema of one surface — `event`, `request`, `response`, `disposition`, `entry`, `bootstrap` — from `eventTypes`, optional `eventExtras`, `buildIdPattern`, `maxEventsPerBatch` |
15716
+ | `TrackingEventEnvelope` / `TrackingEventShape` | _type_ | `eventId`, `visitId`, `browserStreamId`, `browserSequence`, `type`, `page`, `metadata`, `clientTimestamp` |
15717
+ | `TrackEventsRequest` / `TrackEventsResponse` / `TrackEventsResponseSchema` | _type_ / schema | a batch (`buildId`, `events`, `utm`) and its answer (`accepted`, `dispositions`) |
15718
+ | `TrackingDisposition` / `TrackingDispositionSchema` / `TrackingDispositionStatus` / `TrackingDispositionStatusSchema` | _type_ / schema | `accepted` / `duplicate` / `identity-invalid` / `excluded-bot` — every one terminal |
15719
+ | `VisitEntryContext` / `VisitEntryContextShape` / `VisitBootstrapResponse` / `VisitBootstrapResponseSchema` | _type_ / schema | what a browser reports to open a visit (lineage, landing, referrer, UTM, display, screen, `buildId`, outbox health) and the lease it gets back |
15720
+ | `TrackingOutboxState` / `TrackingOutboxStateSchema` | _type_ / schema | `available` / `unavailable` |
15721
+ | `UtmData` / `UtmDataSchema` / `AttributionData` / `AttributionDataSchema` | _type_ / schema | campaign tags and a touch (UTM, referrer, landing page) |
15722
+ | `DEFAULT_BUILD_ID_PATTERN` | const | `dev` or a git SHA |
15723
+ | `createTrackingOutbox` / `TrackingOutbox` / `TrackingOutboxOptions` / `TrackingOutboxHealth` | function / _type_ | the tab-shared outbox: `streamId`, `reserveSequences`, `enqueue`, `acquireLease` / `releaseLease` (short, released on leave), `readBatch`, `acknowledge`, `health`; bounded by count and age |
15724
+ | `TrackingOutboxStorage` / `TrackingOutboxMeta` / `TrackingOutboxEvents` / `TrackingOutboxRecord` / `TrackingQueuedEvent` | _type_ | one atomic, all-or-nothing `transact` over a key/value store and an event store (`readonly` for pure reads) — the contract an adapter implements |
15725
+ | `indexedDbOutboxStorage` / `memoryOutboxStorage` | function | the IndexedDB adapter (one `readwrite` transaction per `transact`) and the in-memory one for tests and degraded mode |
15726
+ | `createSequenceReserve` / `SequenceReserve` / `SequenceReserveOptions` | function / _type_ | a block of reserved numbers spent synchronously — `take` / `refill` / `shared` — with a per-tab fallback when the source fails |
15727
+ | `deliverTrackingBatch` / `TrackingDeliveryOptions` / `TrackingDeliveryOutcome` | function / _type_ | one batch, one bounded retry on `0` / `5xx`; `delivered` / `failed` / `auth-invalidated` (on `401` / `403`) with an optional `onUnauthorized` recovery |
15728
+ | `sendUnloadBeacon` | function | `sendBeacon` with a **string** body — the `text/plain` request a dying document can still complete; `false` without the API |
15729
+ | `createVisibleTimeMeter` / `VisibleTimeMeter` / `VisibleInterval` / `VisibleHeartbeat` / `VisibleTimeMeterOptions` | function / _type_ | additive visible time — `checkpoint` / `cut` / `heartbeat`, each interval with its own id; a hidden heartbeat carries no interval |
15730
+ | `scrollDepthPercent` / `createScrollMilestones` / `ScrollMilestones` | function / _type_ | depth as a percentage of the scrollable range; milestones fired once each per page |
15731
+ | `createOncePerPage` / `OncePerPage` | function / _type_ | one fact per key per page within a window — the guard a twice-mounted component cannot keep itself |
15732
+ | `resolveAttribution` / `ResolveAttributionInput` / `ResolvedAttribution` / `AttributionStorage` / `ReferrerRule` | function / _type_ | first-touch once, current-touch on every new UTM; the referrer → source/medium map is the application's, there is no built-in list |
15733
+ | `parseUtmFromSearch` / `parseReferrer` | function | the two parsers `resolveAttribution` is built from |
15734
+ | `resolveTrackedClick` / `ResolveTrackedClickOptions` / `TrackedClick` / `TrackedClickAttributes` / `ClickTarget` | function / _type_ | `data-track` → click, `data-track-action` → interaction, a link to another origin → outbound; attribute names configurable; `leavesPage` says which path to send on |
15735
+
15736
+ ---
15737
+
15738
+ ## `stitchkit/tracking/server`
15739
+
15740
+ The decisions a tracking backend makes, without the storage it makes them
15741
+ over. The application reads visits and stored hashes from its database, hands
15742
+ them here and writes what comes back; `issueVisitLease` runs the visit
15743
+ algorithm over a store the application implements. → ADR 0166
15744
+
15745
+ | Export | Kind | Summary |
15746
+ |--------|------|---------|
15747
+ | `dispositionTrackingBatch` / `DispositionInput` / `DispositionResult` / `DispositionEvent` / `KnownVisit` | function / _type_ | decide a batch: bot → all `excluded-bot`; wrong lineage or owner → `identity-invalid`; stored id → `duplicate` (+ `conflicts` when the hash differs); rest `accepted`; anonymous visits an identified caller may `adopt` |
15748
+ | `issueVisitLease` / `IssueVisitLeaseOptions` / `IssuedVisitLease` | function / _type_ | under the lineage lock: continue the named or latest active visit (adopting an anonymous one when `ownership: 'adopting'`), else end what is open and start a new one; a bot gets a lease no store holds |
15749
+ | `TrackingVisitStore` / `FindActiveVisitQuery` / `ActiveVisit` / `VisitActor` / `VisitOutboxHealth` / `VisitOwnership` | _type_ | the six-method store the application implements over its database — `withLineageLock`, `findActive`, `touch`, optional `adopt`, `endOpen`, `create` — and the shapes it exchanges |
15750
+ | `activeIntervalOf` / `ActiveTimeInterval` / `ActiveIntervalOptions` | function / _type_ | the visible-time interval an event carries, dated by the server clock; `null` for an unshaped or implausible one |
15751
+ | `hashTrackingEvent` | function | `sha256(JSON.stringify(parsedEvent))` — the stored payload hash, unsorted on purpose so it matches what applications already hold |
15752
+ | `isBotUserAgent` / `DEFAULT_BOT_USER_AGENT_PATTERN` | function / const | crawlers, monitors and `headlesschrome` are not visitors |
15753
+ | `createPresenceRegistry` / `PresenceRegistry` / `PresenceEntry` | function / _type_ | who is here now, in this process: `touch` / `snapshot(filter)` / `presentVisitOf` with a 45 s TTL |
15754
+
15755
+ ---
15756
+
15757
+ ## `stitchkit/release`
15758
+
15759
+ A page follows the release it was built for. The server keeps a marker of the
15760
+ current frontend build and names it on every response (`X-Build-Id`) and every
15761
+ socket connection; the browser compares that to its **own** build id and
15762
+ reloads under a declared policy. Reading the build id and sending the deploy
15763
+ signal stay with the application. → ADR 0167 — [guide](../guide/release.md)
15764
+
15765
+ | Export | Kind | Summary |
15766
+ |--------|------|---------|
15767
+ | `createReleaseMarker` / `ReleaseMarker` / `ReleaseMarkerConfig` / `ReleaseRefresh` | function / _type_ | `current()`, `refresh()` → `{ changed, buildId }`, `subscribe`; built on an application `read` that returns `null` where there is no release; a bad value or a throwing subscriber goes to `onError`, never out |
15768
+ | `RELEASE_HEADER` | const | `X-Build-Id` — set on every response of a handler configured with `release`, exposed through CORS by default |
15769
+ | `createReleaseWatcher` / `ReleaseWatcher` / `ReleaseWatcherConfig` / `ReleaseReloadPolicy` | function / _type_ | `observe(buildId)` against the bundle's own id; `immediate` / `when-hidden` / `on-navigation` with `maxDeferMs`; one reload per id, remembered across it; `navigated`, `stale`, `dispose`, `onStale` |
15770
+ | `browserReleaseHost` / `ReleaseWatcherHost` | function / _type_ | visibility, timer and reload behind one interface — a test supplies its own |
15771
+ | `bindReleaseToSocketServer` / `observeReleaseFromSocket` | function | the socket channel: each connection re-reads and hears the build, everyone hears a change; the client hands the event to the watcher |
15772
+ | `ReleaseSocketServer` / `ReleaseSocketEmitter` / `ReleaseSocketListener` / `ReleaseSocketOptions` | _type_ | the structural slices of a Socket.IO server and client the bindings use — no peer import |
15773
+
15774
+ ---
15775
+
15776
+ ## `stitchkit/geo`
15777
+
15778
+ Server-only managed GeoIP generations. Applications own database acquisition,
15779
+ licensing and domain mapping; the optional MaxMind peer is loaded only when its
15780
+ adapter is used.
15781
+
15782
+ | Export | Kind | Summary |
15783
+ |--------|------|---------|
15784
+ | `createGeoIpResolver` / `GeoIpResolver` / `GeoIpResolverOptions` / `GeoIpSnapshot` | function / _type_ | public-IP lookup over one stable reader generation; unavailable start, last-known-good degraded reload and cleanup are explicit states |
15785
+ | `createMaxMindGeoIpLoader` / `MaxMindGeoIpLoaderOptions` / `MaxMindGeoIpPaths` | function / _type_ | lazy optional-peer City+ASN loader with stable before/after file revision checks |
15786
+ | `GeoIpReader` / `GeoIpReaderLoader` | _type_ | peer-neutral reader/generation seam for adapters and tests |
15787
+ | `GeoAttribution` / `mapGeoIpRecord` | _type_ / function | generic optional place and autonomous-system projection; application-specific fields remain outside |
15788
+
15789
+ ---
15790
+
14824
15791
  ## `stitchkit/declaration`
14825
15792
 
14826
15793
  Zod-only, dependency-free. The **project declaration**: the single
@@ -14913,7 +15880,7 @@ runtime-agnostic pieces of `stitchkit/server` and the error helpers.
14913
15880
  | `NodeServerHandle` | _type_ | managed Node handle (`url`, `port`, `runtime`, `status`, `shutdown`) |
14914
15881
  | `NodeRuntimeServer` | _type_ | concrete `srvx/node` runtime escape hatch |
14915
15882
  | `NodeSocketLifecycle` | _type_ | Bun-free Socket.IO lifecycle accepted by `serveNode` |
14916
- | `HandlerConfig` / `ServiceDef` / `RawRoute` / `RawRouteContext` | _type_ | runtime-neutral handler types; raw routes default their host server to `unknown` |
15883
+ | `HandlerConfig` / `ServiceDef` / `RawRoute` / `RawRouteContext` | _type_ | runtime-neutral handler types; raw routes default their host server to `unknown` and may declare stable `serviceName` / `action` observability identity |
14917
15884
  | `SocketIORequestPolicy` / `SocketIOServerConfig` / `SocketIOPeerLoaders` / `SocketIOServerHandle` | _type_ | runtime-neutral handshake policy, config, optional-peer loaders and the Bun-free Node handle with `io`, `attach` and lifecycle |
14918
15885
  | `UnixClientTransportConfig` / `UnixClientTransport` | _type_ | Unix socket bounds, explicit response-body mode and owned Fetch-compatible handle |
14919
15886
  | `UnixResponseBodyMode` | _type_ | finite cumulative `bounded` mode or explicit pull-driven `streaming` mode |
@@ -14956,12 +15923,14 @@ SDK nor the `ai` peer.
14956
15923
 
14957
15924
  ## `stitchkit/react`
14958
15925
 
14959
- Browser-only. The React data-layer helpers. Needs the `@tanstack/react-query`
14960
- and `react-query-kit` peers.
15926
+ Browser- and server-render-safe React data-layer helpers. Needs the
15927
+ `@tanstack/react-query` and `react-query-kit` peers.
14961
15928
 
14962
15929
  | Export | Kind | Summary |
14963
15930
  |--------|------|---------|
14964
15931
  | `createCursorQuery` | function | a cursor-paginated infinite query — [guide](../guide/client.md#cursor-pagination) |
15932
+ | `apiErrorRetry` | function | build a bounded network/HTTP-status retry predicate using cross-bundle `ApiError.is` — [guide](../guide/react.md#api-error-retries) |
15933
+ | `createQueryClientFactory` | function | request-local SSR QueryClient through a supplied cache adapter plus a factory-local browser singleton — [guide](../guide/react.md#query-client-per-runtime) |
14965
15934
  | `createCacheBridge` | function | sync socket events into the Query cache — [guide](../guide/realtime.md#cache-bridge) |
14966
15935
  | `createRealtimeCacheBridge` | function | the same bridge fed by a **validated realtime contract**; the payload comes from the contract's args tuple instead of inferring `never` |
14967
15936
  | `createEntityCacheHandlers` | function | created/updated/deleted cache handlers for one entity — [guide](../guide/realtime.md#entity-cache-handlers) |
@@ -14975,6 +15944,9 @@ and `react-query-kit` peers.
14975
15944
  | `EntityCacheListShape` | _type_ | `array \| paginated \| infinite-array \| infinite-paginated` |
14976
15945
  | `DeletedPayload` | _type_ | a `deleted` event payload — the entity or a bare `{ id }` |
14977
15946
  | `CursorQueryConfig` | _type_ | config for `createCursorQuery` |
15947
+ | `ApiErrorRetryConfig` | _type_ | retry attempts, excluded application codes, inclusive status ranges and network policy |
15948
+ | `QueryClientFactoryConfig` / `QueryClientServerCache` | _type_ | TanStack config, mutation observer, retry policy and request-cache adapter |
15949
+ | `QueryRetryValue` | _type_ | TanStack's accepted query retry value retained by the factory pass-through |
14978
15950
  | `CacheBridge` | _type_ | the `createCacheBridge` handle |
14979
15951
  | `CacheBridgeConfig` | _type_ | config for `createCacheBridge` |
14980
15952
  | `RealtimeCacheBridgeConfig` | _type_ | config for `createRealtimeCacheBridge` — same fields, a `ValidatedRealtimeSocket` in place of the raw socket |