stitchkit 0.80.1 → 0.81.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 (177) hide show
  1. package/dist/agent-runtime/provider-failure.d.ts.map +1 -1
  2. package/dist/agent-runtime-coding-tools.js +1 -1
  3. package/dist/agent-runtime-harness.js +7 -5
  4. package/dist/agent-runtime.js +15 -19
  5. package/dist/application/notification-outbox.d.ts +80 -0
  6. package/dist/application/notification-outbox.d.ts.map +1 -0
  7. package/dist/application/process-lifecycle.d.ts +166 -0
  8. package/dist/application/process-lifecycle.d.ts.map +1 -0
  9. package/dist/application/state-store.d.ts +18 -0
  10. package/dist/application/state-store.d.ts.map +1 -0
  11. package/dist/application.d.ts +3 -0
  12. package/dist/application.d.ts.map +1 -1
  13. package/dist/application.js +568 -11
  14. package/dist/browser/http.d.ts +14 -0
  15. package/dist/browser/http.d.ts.map +1 -1
  16. package/dist/browser/resumable.d.ts +6 -0
  17. package/dist/browser/resumable.d.ts.map +1 -1
  18. package/dist/cli.js +9 -7
  19. package/dist/contract/define.d.ts +27 -4
  20. package/dist/contract/define.d.ts.map +1 -1
  21. package/dist/contract/errors-factory.d.ts +22 -3
  22. package/dist/contract/errors-factory.d.ts.map +1 -1
  23. package/dist/contract/index.d.ts +2 -2
  24. package/dist/contract/index.d.ts.map +1 -1
  25. package/dist/contract/index.js +1 -1
  26. package/dist/geo/index.d.ts +4 -0
  27. package/dist/geo/index.d.ts.map +1 -0
  28. package/dist/geo/maxmind.d.ts +4 -0
  29. package/dist/geo/maxmind.d.ts.map +1 -0
  30. package/dist/geo/resolver.d.ts +9 -0
  31. package/dist/geo/resolver.d.ts.map +1 -0
  32. package/dist/geo/types.d.ts +59 -0
  33. package/dist/geo/types.d.ts.map +1 -0
  34. package/dist/geo.d.ts +3 -0
  35. package/dist/geo.d.ts.map +1 -0
  36. package/dist/geo.js +245 -0
  37. package/dist/{index-9s6z8vgf.js → index-1dd82z59.js} +1 -1
  38. package/dist/{index-vwp69871.js → index-27487ay5.js} +4 -4
  39. package/dist/{index-7ag81v2f.js → index-27gm3d6g.js} +10 -321
  40. package/dist/{index-y01f4223.js → index-2bxcmqkb.js} +127 -43
  41. package/dist/{index-dxesg10d.js → index-3ds42w1w.js} +3 -3
  42. package/dist/index-3xwxfj4z.js +334 -0
  43. package/dist/{index-egaabxkz.js → index-5z8tezpx.js} +2 -2
  44. package/dist/{index-fxz0v7vg.js → index-7evdsyez.js} +1 -83
  45. package/dist/{index-xcetq6j3.js → index-90d7h2cr.js} +32 -13
  46. package/dist/{index-t0z5p5w6.js → index-apn8repm.js} +1 -1
  47. package/dist/{index-758zx2ha.js → index-dggwnnjv.js} +2 -2
  48. package/dist/{index-dwf1f2ap.js → index-f9mb610r.js} +1 -62
  49. package/dist/{index-5mtc969c.js → index-fwbkp2jn.js} +1 -1
  50. package/dist/index-p13mwz16.js +16 -0
  51. package/dist/{index-m9kzax52.js → index-p18f59zy.js} +21 -5
  52. package/dist/{index-hb5pf3s4.js → index-qmgs7y68.js} +1 -1
  53. package/dist/{index-2rwwmr7v.js → index-s4ckmrac.js} +1 -1
  54. package/dist/{index-h4pj6fta.js → index-s8cnx02s.js} +8 -5
  55. package/dist/{index-k9ay6v6d.js → index-vchzar7e.js} +74 -23
  56. package/dist/index-vsbzgd7b.js +84 -0
  57. package/dist/{index-dxjjv399.js → index-xbppf54a.js} +95 -15
  58. package/dist/index-y91ry9h2.js +4 -0
  59. package/dist/index-zcgf3gqf.js +83 -0
  60. package/dist/index.js +17 -13
  61. package/dist/internal/error-message.d.ts +3 -0
  62. package/dist/internal/error-message.d.ts.map +1 -0
  63. package/dist/internal/media-type.d.ts +8 -0
  64. package/dist/internal/media-type.d.ts.map +1 -0
  65. package/dist/internal/route-pattern.d.ts +21 -0
  66. package/dist/internal/route-pattern.d.ts.map +1 -1
  67. package/dist/node.js +9 -6
  68. package/dist/observability/audit.d.ts +28 -1
  69. package/dist/observability/audit.d.ts.map +1 -1
  70. package/dist/observability/bounded-logger.d.ts +20 -0
  71. package/dist/observability/bounded-logger.d.ts.map +1 -0
  72. package/dist/observability/context.d.ts +12 -3
  73. package/dist/observability/context.d.ts.map +1 -1
  74. package/dist/observability/index.d.ts +3 -2
  75. package/dist/observability/index.d.ts.map +1 -1
  76. package/dist/observability/index.js +138 -8
  77. package/dist/observability/sanitize.d.ts +17 -0
  78. package/dist/observability/sanitize.d.ts.map +1 -1
  79. package/dist/primitives.js +1 -1
  80. package/dist/react/query-client.d.ts +33 -0
  81. package/dist/react/query-client.d.ts.map +1 -0
  82. package/dist/react.d.ts +1 -0
  83. package/dist/react.d.ts.map +1 -1
  84. package/dist/react.js +83 -1
  85. package/dist/realtime/registry.d.ts +93 -0
  86. package/dist/realtime/registry.d.ts.map +1 -0
  87. package/dist/release/header.d.ts +9 -0
  88. package/dist/release/header.d.ts.map +1 -0
  89. package/dist/release/marker.d.ts +39 -0
  90. package/dist/release/marker.d.ts.map +1 -0
  91. package/dist/release/socket.d.ts +44 -0
  92. package/dist/release/socket.d.ts.map +1 -0
  93. package/dist/release/watcher.d.ts +66 -0
  94. package/dist/release/watcher.d.ts.map +1 -0
  95. package/dist/release.d.ts +15 -0
  96. package/dist/release.d.ts.map +1 -0
  97. package/dist/release.js +196 -0
  98. package/dist/remote.js +12 -7
  99. package/dist/server/context.d.ts +2 -1
  100. package/dist/server/context.d.ts.map +1 -1
  101. package/dist/server/create.d.ts.map +1 -1
  102. package/dist/server/error-hook.d.ts +17 -16
  103. package/dist/server/error-hook.d.ts.map +1 -1
  104. package/dist/server/file-state-store.d.ts +23 -0
  105. package/dist/server/file-state-store.d.ts.map +1 -0
  106. package/dist/server/implement.d.ts.map +1 -1
  107. package/dist/server/index.d.ts +5 -2
  108. package/dist/server/index.d.ts.map +1 -1
  109. package/dist/server/index.js +469 -11
  110. package/dist/server/middleware/cookies.d.ts +33 -1
  111. package/dist/server/middleware/cookies.d.ts.map +1 -1
  112. package/dist/server/middleware/cors.d.ts +16 -1
  113. package/dist/server/middleware/cors.d.ts.map +1 -1
  114. package/dist/server/openapi.d.ts.map +1 -1
  115. package/dist/server/release-signal.d.ts +35 -0
  116. package/dist/server/release-signal.d.ts.map +1 -0
  117. package/dist/server/router.d.ts.map +1 -1
  118. package/dist/server/types.d.ts +26 -2
  119. package/dist/server/types.d.ts.map +1 -1
  120. package/dist/testing/surface-manifest.d.ts.map +1 -1
  121. package/dist/testing.js +7 -4
  122. package/dist/tool-invoker.js +8 -6
  123. package/dist/tools/download-core.d.ts.map +1 -1
  124. package/dist/tools/remote.d.ts.map +1 -1
  125. package/dist/tools/view-file.d.ts.map +1 -1
  126. package/dist/tools.js +20 -16
  127. package/dist/tracking/attribution.d.ts +51 -0
  128. package/dist/tracking/attribution.d.ts.map +1 -0
  129. package/dist/tracking/beacon.d.ts +14 -0
  130. package/dist/tracking/beacon.d.ts.map +1 -0
  131. package/dist/tracking/clicks.d.ts +46 -0
  132. package/dist/tracking/clicks.d.ts.map +1 -0
  133. package/dist/tracking/client.d.ts +121 -0
  134. package/dist/tracking/client.d.ts.map +1 -0
  135. package/dist/tracking/contract.d.ts +46 -0
  136. package/dist/tracking/contract.d.ts.map +1 -0
  137. package/dist/tracking/delivery.d.ts +24 -0
  138. package/dist/tracking/delivery.d.ts.map +1 -0
  139. package/dist/tracking/host.d.ts +43 -0
  140. package/dist/tracking/host.d.ts.map +1 -0
  141. package/dist/tracking/once-per-page.d.ts +17 -0
  142. package/dist/tracking/once-per-page.d.ts.map +1 -0
  143. package/dist/tracking/outbox-storage-indexeddb.d.ts +12 -0
  144. package/dist/tracking/outbox-storage-indexeddb.d.ts.map +1 -0
  145. package/dist/tracking/outbox-storage-memory.d.ts +12 -0
  146. package/dist/tracking/outbox-storage-memory.d.ts.map +1 -0
  147. package/dist/tracking/outbox.d.ts +98 -0
  148. package/dist/tracking/outbox.d.ts.map +1 -0
  149. package/dist/tracking/schemas.d.ts +183 -0
  150. package/dist/tracking/schemas.d.ts.map +1 -0
  151. package/dist/tracking/scroll.d.ts +22 -0
  152. package/dist/tracking/scroll.d.ts.map +1 -0
  153. package/dist/tracking/sequence-reserve.d.ts +34 -0
  154. package/dist/tracking/sequence-reserve.d.ts.map +1 -0
  155. package/dist/tracking/server/active-interval.d.ts +26 -0
  156. package/dist/tracking/server/active-interval.d.ts.map +1 -0
  157. package/dist/tracking/server/bot.d.ts +9 -0
  158. package/dist/tracking/server/bot.d.ts.map +1 -0
  159. package/dist/tracking/server/disposition.d.ts +55 -0
  160. package/dist/tracking/server/disposition.d.ts.map +1 -0
  161. package/dist/tracking/server/hash.d.ts +13 -0
  162. package/dist/tracking/server/hash.d.ts.map +1 -0
  163. package/dist/tracking/server/presence.d.ts +27 -0
  164. package/dist/tracking/server/presence.d.ts.map +1 -0
  165. package/dist/tracking/server/visit-lease.d.ts +92 -0
  166. package/dist/tracking/server/visit-lease.d.ts.map +1 -0
  167. package/dist/tracking/visible-time.d.ts +38 -0
  168. package/dist/tracking/visible-time.d.ts.map +1 -0
  169. package/dist/tracking-server.d.ts +14 -0
  170. package/dist/tracking-server.d.ts.map +1 -0
  171. package/dist/tracking-server.js +173 -0
  172. package/dist/tracking.d.ts +24 -0
  173. package/dist/tracking.d.ts.map +1 -0
  174. package/dist/tracking.js +1090 -0
  175. package/llms-full.txt +938 -29
  176. package/llms.txt +4 -0
  177. package/package.json +25 -4
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 26 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 26 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,341 @@ 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: `NEXT_PUBLIC_BUILD_ID=$(git rev-parse
354
+ --short HEAD)` at build time, or Next's `generateBuildId`. Without it the
355
+ watcher has no `own` and never reloads.
356
+ 2. Point `read` at the id of the **active** release, not the process's cwd.
357
+ 3. Add `release: (data: { buildId: string | null }) => void` to your
358
+ `ServerToClientEvents` map where you type your socket, so your own `on`
359
+ knows the event the binding emits.
360
+ 4. Send `SIGUSR2` from the deploy step that activates a frontend without
361
+ restarting the backend, and name that step in the project's release steps.
362
+
363
+
364
+ ==============================================================================
365
+ # Guide: Visitor tracking (docs/guide/tracking.md)
366
+ ==============================================================================
367
+
368
+ # Visitor tracking
369
+
370
+ `stitchkit/tracking` is the browser half of visitor tracking — the outbox, the
371
+ visit lease, the page-leave beacon, visible time, scroll milestones,
372
+ declarative clicks, attribution — and `stitchkit/tracking/server` is the set of
373
+ decisions a tracking backend makes about what arrives. Neither has an event
374
+ vocabulary of its own, a database, or a React component: those are the
375
+ application's, and the boundary is deliberate. → ADR 0166
376
+
377
+ Both entrypoints are **evolving**.
378
+
379
+ ## What the framework owns, and what you do
380
+
381
+ | Framework | Application |
382
+ |-----------|-------------|
383
+ | the event envelope, the batch, the dispositions, the visit entry and lease schemas | the event **types**, their metadata, their labels |
384
+ | the tab-shared outbox with reserved sequences and a short flush lease | which storage adapter (IndexedDB in a tab) |
385
+ | the page-leave beacon that arrives, and its queued insurance copy | the session, the router, the React provider |
386
+ | visible time, scroll milestones, `data-track` clicks, UTM first/current touch | the referrer → source map, the attribute names |
387
+ | `dispositionTrackingBatch`, `issueVisitLease`, `activeIntervalOf`, presence | the tables, the transaction, adoption, geo, reports |
388
+
389
+ ## Contract
390
+
391
+ ```ts
392
+ import { createTrackingContract } from 'stitchkit/tracking'
393
+
394
+ export const tracking = createTrackingContract({
395
+ scope: 'public',
396
+ eventTypes: ['PAGE_VIEW', 'PAGE_LEAVE', 'SCROLL_DEPTH', 'SESSION_HEARTBEAT', 'CLICK', 'OUTBOUND_CLICK', 'INTERACTION', 'ITEM_VIEW'],
397
+ // eventExtras: z.object({ locale: z.string() }), // fields you carry beside the envelope
398
+ })
399
+ ```
400
+
401
+ Two operations: `bootstrap` (`POST /visit`) issues or renews a visit lease,
402
+ `track` (`POST /events`) receives a batch. `track` declares
403
+ `safelistedBody: true`, because the page-leave event is a string beacon — the
404
+ only body a document that is being unloaded can deliver to another origin —
405
+ and that in turn requires an explicit `cors.origin` allow-list on the server.
406
+ Read [safelisted request bodies](./server.md#safelisted-request-bodies-beacons)
407
+ before deploying: it is one rule, and it is the one that keeps the beacon path
408
+ from being a CSRF hole.
409
+
410
+ `scope: 'public'` is the usual choice: the landing page is read by anonymous
411
+ visitors, and their path is part of the funnel. Identity comes from the cookie
412
+ on the server side; the client claims nothing about itself.
413
+
414
+ ## Browser
415
+
416
+ ```ts
417
+ import {
418
+ browserTrackingHost, CONVENTIONAL_TRACKING_EVENT_TYPES, createTrackingClient,
419
+ createTrackingOutbox, indexedDbOutboxStorage,
420
+ } from 'stitchkit/tracking'
421
+
422
+ // Only the types the browser writes — a server-side event must not be trackable here.
423
+ type BrowserEvents = Pick<EventMetadataMap, ClientTrackingEventType>
424
+
425
+ const client = createTrackingClient<BrowserEvents>({
426
+ host: browserTrackingHost(),
427
+ buildId: env.NEXT_PUBLIC_BUILD_ID, // a typed env accessor; the client refuses `undefined`
428
+ builtin: CONVENTIONAL_TRACKING_EVENT_TYPES, // your names for the events the client emits
429
+ bootstrap: (entry) => api.tracking.bootstrap(entry),
430
+ deliver: (batch) => api.tracking.track(batch), // or a socket first, HTTP as the fallback
431
+ unloadUrl: urls.tracking.track(),
432
+ outbox: createTrackingOutbox(indexedDbOutboxStorage('my-app-tracking')),
433
+ referrerMap: [{ pattern: /t\.me|telegram\.org/, source: 'telegram', medium: 'social' }],
434
+ isAction: isInteractionAction,
435
+ onVisit: (visitId) => rum.setVisit(visitId),
436
+ })
437
+ ```
438
+
439
+ `EventMetadataMap` is your `{ [type]: metadata }` map; `track` is typed by it:
440
+
441
+ ```ts
442
+ client.track('ITEM_VIEW', { itemId, itemTitle })
443
+ client.track('SIGN_OUT')
444
+ ```
445
+
446
+ Two knobs match the client to your schema: `batchSize` is the contract's
447
+ `maxEventsPerBatch` (a larger batch is a `400` the outbox can never retire),
448
+ and `decorate` adds your `eventExtras` — a locale, an area, or the identity a
449
+ client that cannot send headers on unload has to carry in the body — to every
450
+ event as it is minted, so the outbox, the beacon and `deliver` all see it:
451
+
452
+ ```ts
453
+ createTrackingClient<BrowserEvents, TrackingEventEnvelope<keyof BrowserEvents> & { locale: string }>({
454
+ decorate: (event) => ({ ...event, locale: currentLocale() }),
455
+ // …
456
+ })
457
+ ```
458
+
459
+ A `bootstrap` operation that needs extra fields of its own (a locale, a RUM
460
+ flag) is not the factory's: build the `entry` schema with
461
+ `createTrackingSchemas(…).entry.extend({ … })` and declare that endpoint yourself.
462
+
463
+ The client owns the mechanics that were the same in every application that
464
+ wrote them: the visit lease and its renewal after a long sleep, a pending queue
465
+ until the visit exists, synchronous event identity from a block of reserved
466
+ sequence numbers, the outbox and its flush lease, one bounded delivery retry,
467
+ the page-leave beacon *and* its queued copy, additive visible time, scroll
468
+ milestones, `data-track` / `data-track-action` clicks, heartbeats. It does not
469
+ own the router or React — tell it about navigation, and wrap it yourself:
470
+
471
+ ```tsx
472
+ const TrackerContext = createContext<TrackFn<BrowserEvents> | null>(null)
473
+
474
+ export function TrackingProvider({ children }: { children: React.ReactNode }) {
475
+ const [client] = useState(() => createTrackingClient<BrowserEvents>({ /* as above */ }))
476
+ const pathname = usePathname()
477
+ const search = useSearchParams().toString()
478
+ useEffect(() => client.start(), [client])
479
+ useEffect(() => client.onNavigate(pathname, search ? `?${search}` : ''), [client, pathname, search])
480
+ return <TrackerContext.Provider value={client.track}>{children}</TrackerContext.Provider>
481
+ }
482
+
483
+ export const useTracker = () => useContext(TrackerContext) ?? (() => { throw new Error('outside TrackingProvider') })()
484
+ ```
485
+
486
+ A component that records "opened X" can mount twice on one page; keep that
487
+ memory at document scope with `createOncePerPage`, not in a ref.
488
+
489
+ ### Why these details are not optional
490
+
491
+ Four of them were found the expensive way, by consuming applications, and each
492
+ is a test in this repository that reddens when the mechanism is put back:
493
+
494
+ - the beacon body is a **string** — a `Blob` typed `application/json` reports
495
+ `true` and dies on the preflight it cannot have;
496
+ - the event gets its sequence number **before** it is written — awaiting the
497
+ outbox inside `pagehide` loses the event with the document;
498
+ - the flush lease is **short and released on leave** — a ten-second lease a
499
+ dying document kept delayed the next document's first flush by ten seconds;
500
+ - `oncePerPage` lives at document scope — a remounted component has fresh refs.
501
+
502
+ ## Server
503
+
504
+ The server half is pure. Read from your database, decide, write:
505
+
506
+ ```ts
507
+ import { activeIntervalOf, dispositionTrackingBatch, hashTrackingEvent, issueVisitLease } from 'stitchkit/tracking/server'
508
+
509
+ track: async ({ input, ipAddress, userAgent }) => {
510
+ const actorOwnerId = session?.userId ?? null
511
+ const visits = await db.visit.findMany({ where: { id: { in: input.events.map((e) => e.visitId) } } })
512
+ const stored = await db.trackingEvent.findMany({ where: { clientEventId: { in: input.events.map((e) => e.eventId) } } })
513
+ const decided = dispositionTrackingBatch({
514
+ events: input.events,
515
+ visits: visits.map((v) => ({ id: v.id, browserStreamId: v.browserStreamId, ownerId: v.userId })),
516
+ existing: new Map(stored.map((e) => [e.clientEventId, e.clientPayloadHash])),
517
+ actorOwnerId,
518
+ userAgent,
519
+ })
520
+ const now = new Date()
521
+ await db.$transaction(async (tx) => {
522
+ for (const visitId of decided.adoptable) await adoptVisit(tx, visitId, actorOwnerId, now)
523
+ await tx.trackingEvent.createMany({
524
+ data: decided.accepted.map((event, ordinal) => ({
525
+ ...yourColumns(event), clientPayloadHash: hashTrackingEvent(event), serverItemOrdinal: ordinal, createdAt: now,
526
+ })),
527
+ })
528
+ for (const event of decided.accepted) {
529
+ const interval = activeIntervalOf(event, now)
530
+ if (interval) await tx.activeTimeInterval.upsert(/* by interval.intervalId */)
531
+ }
532
+ })
533
+ return { accepted: decided.accepted.length, dispositions: decided.dispositions }
534
+ }
535
+ ```
536
+
537
+ `hashTrackingEvent` is `sha256(JSON.stringify(parsedEvent))` with no key
538
+ sorting — exactly what applications already store, so a migration does not
539
+ spend a week reporting conflicts. `decided.conflicts` names duplicates whose
540
+ payload changed; log them.
541
+
542
+ `issueVisitLease` runs the visit algorithm over a store you implement — six
543
+ methods over your tables, inside a lineage lock you hold (`pg_advisory_xact_lock`
544
+ over the browser stream id is the usual one):
545
+
546
+ ```ts
547
+ const store: TrackingVisitStore<Prisma.TransactionClient> = {
548
+ withLineageLock: (lineage, fn) => db.$transaction(async (tx) => {
549
+ await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${lineage}))`
550
+ return fn(tx)
551
+ }),
552
+ findActive: (tx, q) => tx.visit.findFirst({ where: { /* q.browserStreamId, q.cutoff, ownership */ } }),
553
+ touch: (tx, id, now, health) => tx.visit.update({ where: { id }, data: { lastActivityAt: now, ...health } }),
554
+ adopt: (tx, visit, ownerId, now) => /* assign the owner, back-fill events, record the merge */,
555
+ endOpen: (tx, lineage, now) => tx.visit.updateMany({ where: { browserStreamId: lineage, endedAt: null }, data: { endedAt: now } }),
556
+ create: (tx, visit, health) => tx.visit.create({ data: { id: visit.id, ...deviceGeoAndSource(visit) , ...health } }),
557
+ }
558
+
559
+ bootstrap: ({ input, userAgent }) =>
560
+ issueVisitLease(store, { ownerId: session?.userId ?? null, userAgent }, input, { ownership: 'adopting' })
561
+ ```
562
+
563
+ `ownership` is the one policy two applications disagreed on: `'adopting'`
564
+ continues an anonymous visit and gives it to the caller who signs in — the
565
+ path from the landing page to the first signed-in action becomes one path; `'owned'` never
566
+ continues an anonymous visit, for an application where every visitor already
567
+ has an identity. A bot gets a lease that no store holds, so its events are
568
+ `identity-invalid` and nothing of it reaches a report.
569
+
570
+ `createPresenceRegistry` is who is here now, in this process: touch it from
571
+ `track`, read it for a live feed. It is honestly empty after a restart.
572
+
573
+ ## Not here
574
+
575
+ GeoIP, event labels, funnels, reports, the analytics page, Socket.IO push into
576
+ an admin UI, RUM — application code, and the reason two applications' tracking
577
+ looks different even though their mechanics are now one.
578
+
579
+
240
580
  ==============================================================================
241
581
  # Guide: Contracts (docs/guide/contracts.md)
242
582
  ==============================================================================
@@ -331,11 +671,13 @@ with output and a default bodyless `204` without it.
331
671
 
332
672
  The three schemas are distinct on purpose:
333
673
 
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
674
+ - **`params`** — values in the URL path. `path: '/:id'` infers
675
+ `{ id: string }` and materializes the same Zod string schema for HTTP, OpenAPI,
676
+ MCP, Agent and CLI. Declare `params: z.object({ id: z.uuid() })` only when the
677
+ path value needs validation or coercion; an explicit schema must cover every
678
+ name in the path. The client takes params from the call argument and
679
+ substitutes them into the URL. A terminal wildcard is explicitly named:
680
+ `path: '/:slug/*filePath'` matches both
339
681
  `/foo/page` and `/foo/a/b`; the handler reads `ctx.params.filePath` as
340
682
  `'page'` or `'a/b'`. Bare `/*`, invalid/duplicate names and a wildcard before
341
683
  the final segment fail at contract definition.
@@ -350,7 +692,7 @@ caller passes a single flat object, the client routes each field to the path or
350
692
  the body.
351
693
 
352
694
  ```ts
353
- // path: '/:id', params: { id }, input: { text }
695
+ // path: '/:id' infers params: { id: string }; input: { text }
354
696
  await api.update({ id: '1', text: 'new' }) // PUT /users/1 body: { text: 'new' }
355
697
 
356
698
  // path: '/:slug/*filePath', params: { slug, filePath }
@@ -418,6 +760,11 @@ With this policy, omitting `expose` materializes `['HTTP']` on the returned
418
760
  endpoint. MCP, Agent and CLI then require an explicit endpoint array. The plain
419
761
  factory and `defineContract` keep the default-on behaviour above.
420
762
 
763
+ Consequently, `expose: ['HTTP']` is redundant inside a factory configured with
764
+ `toolExposure: 'explicit'`: omit it unless the explicit spelling is useful to a
765
+ local reader. Add `MCP`, `AGENT` or `CLI` only on endpoints that intentionally
766
+ join those surfaces.
767
+
421
768
  Tool transports (`MCP`, `AGENT`) skip four kinds of endpoint automatically:
422
769
  `multipart` (a file upload is not a tool call),
423
770
  [`rawResponse`](./server.md#raw-response-endpoints) (its answer is bytes, which
@@ -1285,12 +1632,83 @@ not retain the text. `maxJsonBodyBytes` may also be set once on `createServer` /
1285
1632
  `createHandler`; a route value wins. Both limits are opt-in and abort an
1286
1633
  oversized stream before it is fully buffered. → ADR 0051
1287
1634
 
1635
+ ## Safelisted request bodies (beacons)
1636
+
1637
+ A body the router reads is `application/json`, and a non-empty body with any
1638
+ other `Content-Type` is a `400`. That is deliberate: a `text/plain` body is a
1639
+ CORS *simple* request, sent with cookies from any site before the browser asks
1640
+ whether the page may read the reply — so requiring JSON forces a preflight, and
1641
+ the preflight is where a foreign form is stopped.
1642
+
1643
+ A page that is being unloaded does not get a preflight. `navigator.sendBeacon`
1644
+ with an `application/json` blob and `fetch({ keepalive: true })` with a JSON
1645
+ `Content-Type` both report success and both die before the server sees them
1646
+ when the API is on another origin. A beacon with a **string** body is
1647
+ `text/plain` and arrives. Declare the endpoint that receives it:
1648
+
1649
+ ```ts
1650
+ const tracking = defineContract(
1651
+ { prefix: 'tracking', scope: 'public' },
1652
+ {
1653
+ track: {
1654
+ method: 'POST', path: '/events', desc: 'Batch track browser events',
1655
+ safelistedBody: true,
1656
+ maxJsonBodyBytes: 256 * 1024,
1657
+ input: TrackEventsRequestSchema,
1658
+ output: TrackEventsResponseSchema,
1659
+ },
1660
+ },
1661
+ )
1662
+
1663
+ // browser, on `pagehide`
1664
+ navigator.sendBeacon(urls.tracking.track(), JSON.stringify(batch)) // text/plain;charset=UTF-8
1665
+ ```
1666
+
1667
+ The body is parsed by the same JSON parser, validated against the same `input`
1668
+ and bounded by the same `maxJsonBodyBytes`; `application/json` keeps working.
1669
+ What changes is one rule, and it is the rule that keeps the door from being a
1670
+ hole: a `text/plain` body is accepted **only from an `Origin` on the server's
1671
+ explicit `cors.origin` allow-list**. Everything else is a `403` before the text
1672
+ is read:
1673
+
1674
+ | Request | Outcome |
1675
+ |---------|---------|
1676
+ | `Origin` on the allow-list (string or list, case-insensitive) | parsed |
1677
+ | `Content-Type: text/plain; charset=application/json` from a foreign origin | `403` — the media type is compared whole, never as a substring |
1678
+ | `Origin` of a site the server never named | `403` |
1679
+ | `Origin: null` — sandboxed iframe, cross-origin redirect, `file://` | `403` |
1680
+ | no `Origin` header | `403` |
1681
+ | server `cors.origin: '*'` | `403` — a wildcard is not an allow-list |
1682
+ | server without `cors` | `403` — there is no allow-list |
1683
+ | `application/json` from any origin | unchanged — never subject to the check |
1684
+
1685
+ The check is a browser invariant, not authentication: a `curl` with a stolen
1686
+ cookie and a forged `Origin` passes, and that is an authenticated call, not a
1687
+ cross-site one. Two things follow for the application:
1688
+
1689
+ - **`sendBeacon` sends no headers.** A bearer-authenticated client has to put
1690
+ its token in the body — and the body is read *after* `authorize`, so that
1691
+ token is available in `beforeHandle` or the handler, never in an auth hook.
1692
+ Cookie-authenticated apps are unaffected.
1693
+ - **`POST` only.** `defineContract` refuses the flag on `GET`, `HEAD`, `PUT`,
1694
+ `PATCH`, `DELETE`, multipart, streaming and input-less endpoints: none of them
1695
+ is a simple request with a JSON body, so the flag would only widen the surface.
1696
+
1697
+ Unlike `rawBody`, the flag does not force `expose: ['HTTP']` — a beacon endpoint
1698
+ may still be a tool; only HTTP body parsing changes. OpenAPI lists both media
1699
+ types with one schema. → ADR 0165
1700
+
1701
+ A [raw route](#raw-routes) has none of this: `parseBody` reads any
1702
+ `Content-Type`, so a raw beacon route is reachable from any site with the
1703
+ user's cookies unless the route checks `Origin` itself. Prefer the flag.
1704
+
1288
1705
  ### Choosing an HTTP boundary
1289
1706
 
1290
1707
  | Need | Contract declaration | What remains framework-owned |
1291
1708
  |------|----------------------|------------------------------|
1292
1709
  | Typed JSON request/response | ordinary `input` / `output` | routing, auth, schemas, hooks, client, OpenAPI |
1293
1710
  | HMAC-signed JSON | `rawBody: true` + `input` / `output` | the same pipeline plus the exact decoded request text |
1711
+ | 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
1712
  | File upload | typed `multipart` descriptor | file cardinality/limits, text input validation and client form encoding |
1295
1713
  | File, stream or redirect response behind contract auth | `rawResponse: true` | request parsing, route identity, auth and typed URL/client surface |
1296
1714
  | Transport that cannot be expressed as the contract pipeline | `RawRoute` | only raw routing, CORS, request hook and error normalisation |
@@ -1455,12 +1873,20 @@ createServer({
1455
1873
  {
1456
1874
  method: 'POST',
1457
1875
  path: '/webhooks/:provider',
1876
+ serviceName: 'webhooks',
1877
+ action: 'receive',
1458
1878
  handler: (req, ctx) => handleWebhook(ctx.params.provider, req),
1459
1879
  },
1460
1880
  ],
1461
1881
  })
1462
1882
  ```
1463
1883
 
1884
+ `serviceName` and `action` are optional observability identity. Declare
1885
+ `serviceName` when audit, logging, sampling or another cross-cutting policy
1886
+ groups this raw route with a service; add `action` when the policy needs an
1887
+ operation within that service. Stitchkit never derives either from `path`.
1888
+ `action` without `serviceName`, and empty identity strings, fail at startup.
1889
+
1464
1890
  A path may be exact, carry `:param` segments, or end in `/*filePath` for a prefix
1465
1891
  wildcard — and the two combine: `/app/:slug/*filePath` matches `/app/x/a/b` with
1466
1892
  `ctx.params.slug === 'x'` and the remainder in `ctx.params.filePath` (a SPA
@@ -5932,6 +6358,79 @@ Durable job tables, lifecycle journals/outboxes, provider inboxes and business
5932
6358
  retry rules do **not** disappear. They were never process-local glue and remain
5933
6359
  application-owned.
5934
6360
 
6361
+ ## Durable process facts and owner notifications
6362
+
6363
+ `createProcessLifecycleLedger` records a bounded versioned list of process runs.
6364
+ Start, readiness and shutdown target both `runId` and `pid`, so hot reload and
6365
+ PID reuse cannot close the wrong generation. A start classifies what happened to
6366
+ the newest run before it, and the classification is decided by three facts —
6367
+ whether that run recorded its own exit, whether the pid is the same, and whether
6368
+ the version changed:
6369
+
6370
+ | newest run | same pid | version | `previousExit` | predecessor's `termination` |
6371
+ |---|---|---|---|---|
6372
+ | none | — | — | `first-boot` | — |
6373
+ | recorded `stoppedAt` | — | — | `clean` / `forced` / `abnormal` as recorded | unchanged |
6374
+ | still `active` | yes | — | `hot-reload` | `hot-reload`, closed at the new start |
6375
+ | still `active` | no | changed | `handoff` | stays `active`; it records its own stop later |
6376
+ | still `active` | no | same | `abnormal` (default) | `abnormal`, closed at the new start — an upper bound, the crash time is unknown |
6377
+
6378
+ A version of `unknown` on either side is never a version change, so a dev build
6379
+ after a crashed release reads `abnormal`, not `handoff`. `forced` means the
6380
+ process itself acknowledged a kill; `abnormal` means a successor found it dead.
6381
+
6382
+ The last row is a choice, and the default is the single-process deployment:
6383
+ one process per build, a new pid of the same build means the old one stopped
6384
+ answering. Where two processes of **one** build overlap on purpose — a
6385
+ cluster, a zero-downtime reload of the same build — pass
6386
+ `sameVersionOverlap: 'handoff'` to the ledger, and the predecessor stays
6387
+ `active` until it records its own shutdown. The cost of that setting is
6388
+ symmetric: a real crash under it is reported as a handoff and the dead run
6389
+ stays `active` in the ledger until retention drops it. The list is kept in the
6390
+ order the transitions wrote it — every write goes through one atomic update,
6391
+ so that order is the causal one, and a successor whose clock lags its
6392
+ predecessor still finds it at the head; `startedAt` is data, not the sort key.
6393
+ Retention (`retain`, default 20) drops finished runs first and an active one —
6394
+ a live handoff predecessor — only when nothing finished is left. Facts are
6395
+ published through the ledger's own subscription and resource value;
6396
+ `ApplicationEventSink` remains the strict application-state stream.
6397
+
6398
+ `createNotificationOutbox` is the transport-neutral durable delivery side. It
6399
+ persists before send, claims an item with an expiring lease, carries one stable
6400
+ idempotency key into the transport, retries under an injected clock and records
6401
+ terminal drops. The guarantee is at-least-once: a crash after remote acceptance
6402
+ but before the receipt is persisted may redeliver, so transports should use the
6403
+ key when they support deduplication. The retry budget is sized for the outage
6404
+ an owner notification has to outlive, not for a flaky call: the default backoff
6405
+ doubles from one second and caps at sixty (`backoffDelay`, the one formula the
6406
+ client's resumable streams also use, with jitter 0), and the 99 waits between
6407
+ the default `maxAttempts` of 100 sum to 1+2+4+8+16+32 s plus 93 × 60 s — about
6408
+ 94 minutes — before `onDropped` sees `attempt-limit`.
6409
+ `state()` is a read and never fails on the bounds a transition enforces — a file
6410
+ that grew past `maxStateBytes` under an older limit is inspectable and is
6411
+ trimmed by the next transition. Two bounds do fail loudly: `enqueue` past
6412
+ `maxQueue` throws rather than dropping silently, and a `backoffMs` that returns
6413
+ `NaN`, a negative or an infinite delay rejects the flush. `stop()` lets the
6414
+ send in flight finish and claims nothing more; a `send` without its own
6415
+ deadline holds `stop()` — and so the resource's `force()` — for that one call,
6416
+ so give the transport a timeout. Superseding a key that is being sent right now
6417
+ does not recall it: the send completes, and only its receipt is not written.
6418
+
6419
+ Both primitives depend on the structural `StateStore`. On a server,
6420
+ `createFileStateStore` supplies the shared Zod-validated JSON adapter with an
6421
+ inter-process lock, unique temporary file, fsync and atomic rename. The lock
6422
+ is a file with a heartbeat: the holder refreshes its mtime every third of
6423
+ `staleLockMs` (default 3 s), a contender waits up to `lockTimeoutMs` (default
6424
+ 10 s), and the stale bound must sit inside the timeout — otherwise a crashed
6425
+ holder blocks every update until the lock ages out, which the constructor
6426
+ refuses. A lock whose heartbeat is stale is reclaimed once its recorded pid is
6427
+ gone; a live or unverifiable pid (a reused number, another user's process)
6428
+ keeps it for ten stale bounds — and never fewer than thirty missed heartbeats,
6429
+ so a scheduler stall on a loaded host cannot pull a live lock from under its
6430
+ holder — after which the heartbeat wins and the lock is abandoned. Temporary files a crashed writer left beside the state are swept on
6431
+ the store's first update. Ledger corruption may be declared reconstructable;
6432
+ an outbox must fail closed rather than silently discard pending delivery.
6433
+
5935
6434
 
5936
6435
  ==============================================================================
5937
6436
  # Guide: Generic application primitives (docs/guide/primitives.md)
@@ -7641,6 +8140,40 @@ gives you the contract and the metadata (`idempotent`, the open `source` tag,
7641
8140
  `createRetainedTopics`); the wire and the per-call execution stay yours. See
7642
8141
  [ADR 0028](../decisions/0028-revert-contract-dispatcher.md).
7643
8142
 
8143
+ ## Authenticated room registry and replay
8144
+
8145
+ `bindSocketRegistry` composes over `bindRealtimeServer`; it never authenticates
8146
+ a socket a second time and never creates another outbound validator. The
8147
+ identity in `connection.raw.data` has already passed the Socket.IO handshake.
8148
+
8149
+ `rooms(identity)` names the rooms a connecting identity may be in; the registry
8150
+ joins them and refuses a later `join` to any other. The disconnect listener is
8151
+ attached before that lookup is awaited, so a socket that drops while its
8152
+ permissions are still being resolved never becomes a member. `registry.room(name)` mints
8153
+ the opaque `AuthorizedSocketRoom` for a name — the token proves the name came
8154
+ through this registry, not that anyone is in the room, so a room with no open
8155
+ tab is an ordinary `emitTo` target that reports zero recipients. The registry
8156
+ owns join/leave, multiple sockets per identity, immutable snapshots and
8157
+ listener cleanup.
8158
+
8159
+ Replay is revisioned: while a socket's snapshot is open, frames sent through
8160
+ `emitTo` to its rooms are held back and delivered after the snapshot, so the
8161
+ socket never sees a delta for state its snapshot already contains, nor a
8162
+ snapshot that predates a delta it already received. A snapshot taken across a
8163
+ `revision` change is retried up to `replayAttempts` (default 3). The hold-back buffer is
8164
+ bounded by `maxBufferedFrames` (default 1000): past it the attempt is abandoned
8165
+ and retried, and when the attempts are spent `onResyncRequired(socketId,
8166
+ identity)` fires with nothing half-delivered — the application decides what a
8167
+ resync means for that socket. A retry relies on the fresh snapshot carrying
8168
+ what the abandoned frames carried, which holds when every emitted change also
8169
+ moves `revision`; without a `revision` the registry has no way to tell, so
8170
+ declare one wherever replay matters. Room tokens are recognised, not
8171
+ remembered: a token the application keeps stays valid for the life of the
8172
+ registry, and a name seen once does not live on in a map. This ordering exists only for frames that go
8173
+ through `emitTo`; a broadcast through `realtime.to(room).emit` reaches the
8174
+ socket immediately, replay or not, so keep room traffic whose order matters
8175
+ relative to the snapshot on the registry.
8176
+
7644
8177
 
7645
8178
  ==============================================================================
7646
8179
  # Guide: Live data (docs/guide/live.md)
@@ -8424,8 +8957,29 @@ session.clear() // → a Set-Cookie value that expires it
8424
8957
  ```
8425
8958
 
8426
8959
  `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.
8960
+ config is not repeated at every call site. `parseCookies(header)`,
8961
+ `parseCookieHeader(header)` and `serializeCookie(name, value, opts)` are the
8962
+ lower-level primitives.
8963
+
8964
+ **Two cookies, one name.** A browser sends every cookie whose domain and path
8965
+ match, so a `sid` on `.example.com` and a `sid` on `app.example.com` both arrive
8966
+ — in an order the browser chooses (RFC 6265 §5.4) and a server should not rely
8967
+ on (§4.2.2). `get` returns the **last** one by default, which is a fact of the
8968
+ header order, not a rule: the same session then validates in one browser and
8969
+ not in another. Name the behaviour you rely on, or refuse to guess:
8970
+
8971
+ ```ts
8972
+ const session = defineCookie({ name: 'sid', domain: '.example.com', duplicates: 'reject' })
8973
+
8974
+ session.get(req) // undefined when two *different* values arrive; the one value when they agree
8975
+ session.getAll(req) // every candidate, in header order — validate each, clear the loser
8976
+ ```
8977
+
8978
+ `duplicates: 'reject'` turns the lottery into "no session", which a login page
8979
+ handles; `'first'` and `'last'` exist so an application can state which coin it
8980
+ flips. For a session cookie on a parent domain the durable fix is `getAll`:
8981
+ validate each candidate and answer with `clear()` for the one that lost, so the
8982
+ next request carries one.
8429
8983
 
8430
8984
  To set a cookie from a schema-validated JSON endpoint without losing its typed
8431
8985
  client result, declare [`responseMeta`](./server.md#typed-json-response-metadata)
@@ -8580,6 +9134,38 @@ onError: (ctx, err) => {
8580
9134
  }
8581
9135
  ```
8582
9136
 
9137
+ ## One application vocabulary and one wire map
9138
+
9139
+ `defineErrors` can also resolve Stitchkit's framework codes into the same
9140
+ application vocabulary. A status fallback is explicit because several
9141
+ application codes may legally share one HTTP status:
9142
+
9143
+ ```ts
9144
+ const vocabulary = defineErrors(
9145
+ {
9146
+ VALIDATION_ERROR: { status: 400, message: 'Invalid request' },
9147
+ INTERNAL_ERROR: { status: 500, message: 'Internal error' },
9148
+ },
9149
+ {
9150
+ fallback: { 400: 'VALIDATION_ERROR', 500: 'INTERNAL_ERROR' },
9151
+ map: { FILE_TOO_LARGE: 'VALIDATION_ERROR' },
9152
+ },
9153
+ )
9154
+
9155
+ const onError = createErrorHook({ vocabulary })
9156
+ // `vocabulary` *is* the wire map: `codeMap` and `unmappedCode` cannot sit
9157
+ // beside it — the type forbids the combination and the runtime refuses it.
9158
+ // A `map` target must be declared under the framework code's own status
9159
+ // (`NOT_FOUND` → a 404 code, never a 410 one): the wire keeps the framework
9160
+ // status and carries your code, so `defineErrors` refuses the mismatch where
9161
+ // it already refuses a `fallback` under the wrong status.
9162
+ ```
9163
+
9164
+ An explicit per-code `map` wins over a status fallback. With
9165
+ `exhaustive: true`, `map` must be a complete `Record<StitchErrorCode, AppCode>`
9166
+ at compile time. Transport boundaries identify errors with `AppError.is`, not
9167
+ `instanceof`, so two bundled copies still agree.
9168
+
8583
9169
  ## Domain errors — `defineErrors`
8584
9170
 
8585
9171
  Declare each domain code, HTTP status, default message and optional
@@ -8990,12 +9576,11 @@ route-match, *before* validation, so both are present on every event for a
8990
9576
  request that matched a contract route, including a pre-handler 400. Nothing to
8991
9577
  wire.
8992
9578
 
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.
9579
+ Raw routes may opt into the same context with `serviceName` and optional
9580
+ `action` on their `RawRoute` declaration. Without that declaration they remain
9581
+ absent, as they do for a request short-circuited by `onRequest` and an unmatched
9582
+ 404 or 405. Both fields therefore remain optional in `RequestEvent`; a sink
9583
+ with a `NOT NULL` column on either still has to account for unattributed paths.
8999
9584
 
9000
9585
  When failure attribution itself is asynchronous, use `createErrorHook`'s
9001
9586
  `onError(error, info, ctx, endpoint)` observer. The framework awaits it before
@@ -9348,6 +9933,26 @@ hooks: {
9348
9933
  Keep any sink **asynchronous and self-contained**: a slow or failing write must
9349
9934
  never block or break the request. Swallow the sink's own errors.
9350
9935
 
9936
+ ## Typed dimensions and bounded loggers
9937
+
9938
+ `createDimensionsProjector` keeps endpoint metadata opaque. Configure typed
9939
+ request, result and error projections, then call its phase methods from the
9940
+ corresponding lifecycle hooks. Every projection writes through
9941
+ `setRequestDimensions`; repeated keys follow the declared `overwrite`,
9942
+ `preserve` or `error` policy. Two of its inputs are programming errors and
9943
+ throw, which fails the request rather than recording a lie: a repeated key
9944
+ under `collision: 'error'`, and a key named `__proto__` (it would set the
9945
+ bag's prototype — it can only arrive from parsed input, never from an object
9946
+ literal, which has no such own key).
9947
+
9948
+ `createBoundedLogger` decorates the existing `StitchLogger` interface, so Pino,
9949
+ console and application sinks keep their ownership. It reuses the same
9950
+ sanitizer as request audit: sensitive keys, configured paths, URL fragments and
9951
+ `Error` fields are redacted before string/collection/depth/total-byte bounds are
9952
+ applied. Active trace, span, user and dimensions are reserved framework fields;
9953
+ caller data cannot overwrite them. A throwing getter, cyclic value or failing
9954
+ sink never escapes into application code.
9955
+
9351
9956
 
9352
9957
  ==============================================================================
9353
9958
  # Guide: Testing & deployment (docs/guide/testing-and-deployment.md)
@@ -10125,6 +10730,159 @@ Do not duplicate DTOs or handwritten API wrappers in either integration. The
10125
10730
  shared contract remains the only transport schema source.
10126
10731
 
10127
10732
 
10733
+ ==============================================================================
10734
+ # Guide: React Query policy (docs/guide/react.md)
10735
+ ==============================================================================
10736
+
10737
+ ---
10738
+ title: React Query policy
10739
+ description: One request-local server client, one browser singleton and explicit retry rules for Stitchkit API errors
10740
+ type: guide
10741
+ status: active
10742
+ created: 2026-09-06
10743
+ updated: 2026-09-06
10744
+ ---
10745
+
10746
+ # React Query policy
10747
+
10748
+ `stitchkit/react` contains policy adapters for TanStack Query. It does not own
10749
+ application queries, cache keys or authentication.
10750
+
10751
+ ## Query client per runtime
10752
+
10753
+ Create the getter once. On the server, pass React's `cache` so identity is local
10754
+ to one render request. In the browser, the returned getter keeps one singleton
10755
+ inside this factory only:
10756
+
10757
+ ```ts
10758
+ import { cache } from 'react'
10759
+ import { createQueryClientFactory } from 'stitchkit/react'
10760
+
10761
+ export const getQueryClient = createQueryClientFactory({
10762
+ serverCache: cache,
10763
+ queryClient: {
10764
+ defaultOptions: { queries: { staleTime: 30_000 } },
10765
+ },
10766
+ })
10767
+ ```
10768
+
10769
+ Pending queries are dehydrated so streaming SSR can resume them. Mutations do
10770
+ not retry by default. Every TanStack default may still be supplied through
10771
+ `queryClient`; a supplied query or mutation retry policy wins over the helper's
10772
+ defaults.
10773
+
10774
+ ## API error retries
10775
+
10776
+ `apiErrorRetry()` returns a TanStack retry predicate. The default retries one
10777
+ network or `5xx` failure and refuses authorization, validation, abort and other
10778
+ `4xx` errors:
10779
+
10780
+ ```ts
10781
+ const retry = apiErrorRetry({
10782
+ attempts: 2,
10783
+ never: ['UNAUTHORIZED', 'FORBIDDEN'],
10784
+ statusRanges: [[500, 599]],
10785
+ })
10786
+ ```
10787
+
10788
+ Ranges are inclusive. The predicate uses `ApiError.is`, so it remains valid when
10789
+ two bundles contain different copies of the class.
10790
+
10791
+ Authentication remains application-owned. Memoize the application's own
10792
+ session query with `cache`; the generic starter does not invent a `getSession`
10793
+ endpoint or cookie vocabulary.
10794
+
10795
+
10796
+ ==============================================================================
10797
+ # Guide: GeoIP (docs/guide/geo.md)
10798
+ ==============================================================================
10799
+
10800
+ ---
10801
+ title: GeoIP
10802
+ description: A server-only managed GeoIP reader with three observable states and last-known-good generation reloads.
10803
+ type: guide
10804
+ status: active
10805
+ created: 2026-09-06
10806
+ updated: 2026-09-06
10807
+ ---
10808
+
10809
+ # GeoIP
10810
+
10811
+ `stitchkit/geo` is a server-only, evolving entrypoint for reading local GeoIP
10812
+ databases without making a database vendor part of the framework contract. It
10813
+ exposes a managed resolver, a peer-neutral reader boundary and an optional
10814
+ MaxMind adapter.
10815
+
10816
+ ## MaxMind
10817
+
10818
+ Install `maxmind` only in applications that use the adapter:
10819
+
10820
+ ```bash
10821
+ bun add maxmind
10822
+ ```
10823
+
10824
+ Create one resolver and place it in the application resource graph:
10825
+
10826
+ ```ts
10827
+ import { createApplication } from 'stitchkit/application';
10828
+ import { createGeoIpResolver, createMaxMindGeoIpLoader } from 'stitchkit/geo';
10829
+
10830
+ const geo = createGeoIpResolver({
10831
+ paths: {
10832
+ city: '/srv/geo/GeoLite2-City.mmdb',
10833
+ asn: '/srv/geo/GeoLite2-ASN.mmdb',
10834
+ },
10835
+ loader: createMaxMindGeoIpLoader(),
10836
+ reload: { intervalMs: 60_000 },
10837
+ onError(error) {
10838
+ logger.warn('GeoIP reader unavailable', { error });
10839
+ },
10840
+ });
10841
+
10842
+ const application = createApplication({ resources: [geo] });
10843
+ ```
10844
+
10845
+ The loader imports the optional peer lazily. City and optional ASN files open
10846
+ as one revision: if either changes during the open, the incomplete generation
10847
+ is discarded.
10848
+
10849
+ ## Three states
10850
+
10851
+ `geo.snapshot()` distinguishes facts that must not be collapsed:
10852
+
10853
+ - `uninitialized` — the managed resource has not started, or has closed;
10854
+ - `unavailable` — no usable database generation could be opened;
10855
+ - `ready` — lookups use a complete generation.
10856
+
10857
+ A refresh failure while a reader is already `ready` preserves that reader and
10858
+ sets `reloadError`. This is last-known-good service, not a successful refresh.
10859
+ Applications decide whether that fact degrades their own readiness.
10860
+
10861
+ ## Lookups
10862
+
10863
+ ```ts
10864
+ const attribution = await geo.resolve(requestIp);
10865
+ ```
10866
+
10867
+ `resolve` returns `null` for invalid, loopback, private, link-local and otherwise
10868
+ non-public addresses without querying the reader. A database miss or lookup
10869
+ failure also returns `null`; `onError` receives operational failures. A result
10870
+ may include country, region, city, postal, coordinates, timezone and ASN fields.
10871
+
10872
+ When a reload succeeds, new lookups move to the new generation immediately.
10873
+ The previous reader closes only after its in-flight lookups finish.
10874
+
10875
+ ## A custom reader
10876
+
10877
+ Use `GeoIpReaderLoader` when the database is not MaxMind-compatible. `revision`
10878
+ must identify the complete input generation and return `null` when it cannot be
10879
+ read. `open` returns a reader for exactly that revision. Stitchkit serializes
10880
+ reloads, swaps generations and owns their close lifecycle.
10881
+
10882
+ Download schedules, licenses, database paths, attribution persistence and
10883
+ analytics policy belong to the application.
10884
+
10885
+
10128
10886
  ==============================================================================
10129
10887
  # Guide: Upgrading (docs/guide/upgrading.md)
10130
10888
  ==============================================================================
@@ -10142,7 +10900,10 @@ A release that breaks a public API leads its `CHANGELOG.md` entry with a
10142
10900
  **`### ⚠️ Breaking changes`** section (exact heading), each item carrying a
10143
10901
  **before → after** snippet. A version with **no** such section is **purely
10144
10902
  additive** — adopting it changes nothing in your code. (See
10145
- [`AGENTS.md` → Breaking changes](../../AGENTS.md).)
10903
+ [`AGENTS.md` → Breaking changes](../../AGENTS.md).) `bun scripts/upgrade-plan.ts
10904
+ <installed> <target>` prints every breaking section your range crosses, oldest
10905
+ first, each with its **Who must act** line — the list this guide's migrations
10906
+ expand on.
10146
10907
 
10147
10908
  So upgrading is: read the `### ⚠️ Breaking changes` of every version *above* your
10148
10909
  current one *up to* your target, and apply each snippet.
@@ -10171,6 +10932,32 @@ makes one thing your job rather than the resolver's:
10171
10932
  The mechanical part is identical either way. Only the *noticing* differs, and an
10172
10933
  exact pin moves it onto you.
10173
10934
 
10935
+ ## Released migration: 0.81.0
10936
+
10937
+ **Path literals own string params.**
10938
+
10939
+ An endpoint whose path contains `:id` or a terminal `*rest` no longer needs a
10940
+ duplicate `params: z.object({ …string() })` declaration. Remove schemas that do
10941
+ nothing beyond repeating path names:
10942
+
10943
+ ```ts
10944
+ // before
10945
+ { path: '/projects/:projectId/*filePath',
10946
+ params: z.object({ projectId: z.string(), filePath: z.string() }) }
10947
+
10948
+ // after
10949
+ { path: '/projects/:projectId/*filePath' }
10950
+ ```
10951
+
10952
+ Keep an explicit schema when it validates or coerces. It must cover every name
10953
+ in the path — an explicit schema that misses one now fails at `defineContract`
10954
+ with the path and the missing field named. Client calls and handler contexts
10955
+ infer the string fields from the literal, so a call that omitted one becomes a
10956
+ compile error instead of building a URL with an unresolved segment. Measured
10957
+ across three consuming applications before release (373 endpoints with path
10958
+ segments), no explicit schema missed a field: the migration is deleting
10959
+ schemas that only repeated the path, or nothing.
10960
+
10174
10961
  ## Released migration: 0.80.0
10175
10962
 
10176
10963
  One thing, and only if you parse an async-operation snapshot.
@@ -13381,8 +14168,9 @@ from the root `stitchkit`.
13381
14168
  | `ScopedDefineContract` | _type_ | the `defineContract` `createContractFactory` returns — endpoint `scope` overrides join the union |
13382
14169
  | `ALL_TRANSPORTS` | constant | `['HTTP', 'MCP', 'AGENT', 'CLI']` |
13383
14170
  | `ContractDef` | _type_ | a defined contract |
14171
+ | `PathParams` | _type_ | infer named `:segments` and a terminal `*wildcard` from a path literal as string params |
13384
14172
  | `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) |
14173
+ | `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
14174
  | `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
14175
  | `HeadEndpointDef` | _type_ | explicit HTTP-only, bodyless `HEAD` endpoint definition |
13388
14176
  | `EndpointResponseMeta` | _type_ | static success metadata declared by an HTTP-only typed-data endpoint |
@@ -13418,6 +14206,7 @@ from the root `stitchkit`.
13418
14206
 
13419
14207
  ### Errors
13420
14208
 
14209
+ | `ErrorVocabularyCode` / `ErrorVocabularyMapping` / `VocabularyCodeMap` | _type_ | application-code union, partial/exhaustive framework mapping input and resolved wire map returned by `defineErrors` |
13421
14210
  | Export | Kind | Summary |
13422
14211
  |--------|------|---------|
13423
14212
  | `AppError` | class | the framework error — `code` / `status` / `details` / `hint` |
@@ -13430,7 +14219,7 @@ from the root `stitchkit`.
13430
14219
  | `rateLimited` | function | throw `429 RATE_LIMITED` |
13431
14220
  | `appError` | function | throw an `AppError` for any code |
13432
14221
  | `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 |
14222
+ | `DefinedErrors` | _type_ | the `{ errors, codes, definitions, isCode, codeMap }` handle `defineErrors` returns |
13434
14223
  | `DefinedAppError` | _type_ | literal-code error instance with schema-refined details |
13435
14224
  | `ErrorDefinition` | _type_ | `{ status, message?, details? }` definition for one domain code |
13436
14225
  | `ErrorDefinitions` | _type_ | string-keyed domain error definition registry |
@@ -13586,7 +14375,7 @@ Also re-exports the error helpers from `stitchkit/contract`.
13586
14375
  | `composeLifecycleHooks` | function | compose HTTP lifecycle phases in declaration order with short-circuit/fallthrough semantics |
13587
14376
  | `AuthorizationContext` | _type_ | HTTP pre-body context with validated params, `input: undefined` and no files |
13588
14377
  | `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 |
14378
+ | `RawRoute` | _type_ | a non-contract `Request → Response` route with a concrete `BunServer` context and optional stable `serviceName` / `action` observability identity |
13590
14379
  | `RawRouteContext` | _type_ | the Bun-bound routing context a raw handler receives |
13591
14380
  | `BunServer` | _type_ | the `Bun.serve` instance type |
13592
14381
  | `ServerPassthrough` | _type_ | extra `Bun.serve` options |
@@ -13603,7 +14392,8 @@ Also re-exports the error helpers from `stitchkit/contract`.
13603
14392
  |--------|------|---------|
13604
14393
  | `createAuthHook` | function | one scope gate for HTTP `authorize` and tool `beforeHandle` — [guide](../guide/auth-and-errors.md#createauthhook) |
13605
14394
  | `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) |
14395
+ | `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) |
14396
+ | `ErrorHookBase` / `ErrorHookMapping` | _type_ | common hook fields and the vocabulary-or-map branch accepted by `createErrorHook` |
13607
14397
  | `ErrorHookConfig` | _type_ | async observer/renderer config with partial `codeMap` and optional typed `unmappedCode` fallback |
13608
14398
  | `ResolvedError` | _type_ | the normalised error handed to `createErrorHook`'s `render` |
13609
14399
  | `createBearerResolver` | function | a bearer-token identity resolver |
@@ -13633,19 +14423,23 @@ Also re-exports the error helpers from `stitchkit/contract`.
13633
14423
 
13634
14424
  | Export | Kind | Summary |
13635
14425
  |--------|------|---------|
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 |
14426
+ | `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) |
14427
+ | `parseCookies` | function | parse a `Cookie` header to a record (the last of two same-named cookies wins) |
14428
+ | `parseCookieHeader` | function | parse a `Cookie` header to `[name, value]` pairs in header order, duplicates kept |
13638
14429
  | `serializeCookie` | function | build a `Set-Cookie` value |
13639
14430
  | `corsHeaders` | function | compute CORS response headers |
13640
14431
  | `corsPreflightResponse` | function | build a preflight `Response` |
13641
14432
  | `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` |
14433
+ | `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
14434
  | `CookieDef` | _type_ | the `defineCookie` handle |
13644
- | `CookieOptions` | _type_ | cookie attributes |
14435
+ | `CookieOptions` | _type_ | cookie attributes, plus the `duplicates` read policy |
14436
+ | `CookieDuplicatesPolicy` | _type_ | `'last'` (default) / `'first'` / `'reject'` — `reject` yields `undefined` for two different values and the value when they agree |
13645
14437
  | `CorsConfig` | _type_ | CORS policy |
13646
14438
 
13647
14439
  ### Realtime (server)
13648
14440
 
14441
+ | `bindSocketRegistry` | function | room membership and consistent replay over an already-authenticated realtime server |
14442
+ | `AuthorizedSocketRoom` / `SocketRegistry` / `SocketRegistryConnection` / `SocketRegistryOptions` / `SocketRegistryServer` / `SocketRegistrySnapshot` / `SocketReplayFrame` | _type_ | authorized room token, structural server boundary, replay frames and immutable registry state |
13649
14443
  | Export | Kind | Summary |
13650
14444
  |--------|------|---------|
13651
14445
  | `createSocketIOServer` | function | the typed Socket.IO server — [guide](../guide/realtime.md#server--createsocketioserver) |
@@ -13668,6 +14462,8 @@ Also re-exports the error helpers from `stitchkit/contract`.
13668
14462
 
13669
14463
  ### Primitives
13670
14464
 
14465
+ | `createFileStateStore` | function | cross-process locked atomic JSON state updates over one Zod schema |
14466
+ | `FileStateStoreCorruption` / `FileStateStoreOptions` | _type_ | explicit corrupt-state report/policy and lock timing configuration |
13671
14467
  | Export | Kind | Summary |
13672
14468
  |--------|------|---------|
13673
14469
  | `streamSSE` | function | an async generator → SSE `Response` — [guide](../guide/server.md#sse-streaming) |
@@ -13686,6 +14482,7 @@ Also re-exports the error helpers from `stitchkit/contract`.
13686
14482
  | `MultipartReceiver` | _type_ | consumer-owned Web-stream storage receiver |
13687
14483
  | `MultipartReceiverResult` | _type_ | receiver value plus rollback cleanup |
13688
14484
  | `StreamingMultipartImplementation` | _type_ | receiver registry and handler shape inferred by `defineMultipartStream` |
14485
+ | `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
14486
  | `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
14487
  | `ProcessSignalsOptions` | _type_ | config for `bindProcessSignals` |
13691
14488
  | `ProcessSignalsBinding` | _type_ | the `{ promise, close }` handle `bindProcessSignals` returns |
@@ -13763,6 +14560,17 @@ cutovers are covered by the executable
13763
14560
  | Export | Kind | Summary |
13764
14561
  |--------|------|---------|
13765
14562
  | `createApplication` | function | compose a validated resource DAG into one startup, readiness, admission and shutdown state machine |
14563
+ | `StateStore` / `StateStoreUpdate` | _type_ | async atomic read/update boundary shared by durable application state machines |
14564
+ | `createProcessLifecycleLedger` / `ProcessLifecycleLedger` / `ProcessLifecycleLedgerConfig` | function / _type_ | durable bounded run history and typed fact subscription |
14565
+ | `LifecycleRunSchema` / `LifecycleRun` / `LifecycleStateSchema` / `LifecycleState` / `LifecycleTerminationSchema` / `LifecycleTermination` | schema / _type_ | versioned process-run state and termination vocabulary |
14566
+ | `PreviousExitSchema` / `PreviousExit` / `StartFact` / `ReadyFact` / `ShutdownFact` / `ProcessLifecycleFact` | schema / _type_ | first-boot, hot-reload, clean, forced, handoff and abnormal lifecycle facts |
14567
+ | `transitionProcessStart` / `transitionProcessReady` / `transitionProcessShutdown` | function | pure idempotent runId+pid-owned transitions |
14568
+ | `LifecycleTransition` / `TransitionStartInput` / `TransitionReadyInput` / `TransitionShutdownInput` / `SameVersionOverlap` | _type_ | pure transition inputs, the same-build overlap policy (`abnormal` default / `handoff`) and state+fact result |
14569
+ | `lifecycleLedgerResource` / `LifecycleLedgerResource` / `LifecycleLedgerResourceConfig` | function / _type_ | managed-resource adapter that records start, readiness and shutdown |
14570
+ | `createNotificationOutbox` / `NotificationOutbox` / `NotificationOutboxConfig` | function / _type_ | bounded at-least-once queue with durable claims, leases, retries and supersede |
14571
+ | `NotificationOutboxItem` / `NotificationOutboxReceipt` / `NotificationOutboxState` / `NotificationSend` / `NotificationFailureClassification` / `DroppedNotification` / `EnqueueNotification` | _type_ | versioned queue records, stable send identity and delivery decisions |
14572
+ | `notificationOutboxStateSchema` | function | strict persisted-state schema over an application payload schema |
14573
+ | `notificationOutboxResource` / `NotificationOutboxResource` / `NotificationOutboxResourceConfig` | function / _type_ | thin managed-resource scheduler over an outbox handle |
13766
14574
  | `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
14575
  | `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
14576
  | `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 +15209,11 @@ audit event. See the [Observability guide](../guide/observability.md).
14401
15209
  | Export | Kind | Summary |
14402
15210
  |--------|------|---------|
14403
15211
  | `createObservability` | function | configure framework-owned request completion and canonical tool event sinks — [guide](../guide/observability.md#createobservability) |
15212
+ | `createDimensionsProjector` / `DimensionsProjector` / `DimensionsProjectorConfig` / `ProjectedDimensions` | function / _type_ | typed request/result/error attribution projected into the existing request context |
15213
+ | `DimensionCollision` / `SetRequestDimensionsOptions` | _type_ | explicit overwrite, preserve or error policy for dimension keys |
15214
+ | `createBoundedLogger` | function | decorate a `StitchLogger` with request context, shared sanitisation, redaction and total bounds |
15215
+ | `DEFAULT_REDACT_PATHS` | constant | baseline credential/token paths added to the sanitizer's sensitive-key policy |
15216
+ | `BoundedLoggerBounds` / `BoundedLoggerOptions` | _type_ | per-value and total record ceilings plus sink/redaction configuration |
14404
15217
  | `RequestEvent` | _type_ | the normalised audit event handed to the sink; opt-in HTTP cancellation rows carry `outcome: 'cancelled'` |
14405
15218
  | `ObservabilityConfig` | _type_ | independent request and tool sink configuration |
14406
15219
  | `Observability` | _type_ | `{ request?, toolCall, getStatus(), flush(), close() }` with bounded sink lifecycle |
@@ -14821,6 +15634,97 @@ injected grammY bot. → ADR 0143
14821
15634
 
14822
15635
  ---
14823
15636
 
15637
+ ## `stitchkit/tracking`
15638
+
15639
+ Browser-safe visitor-tracking mechanics, with no event vocabulary of their own
15640
+ and no React. The React wrapper (a provider and a hook, ~40 lines) is the
15641
+ application's — it owns the router and the session. The server half is
15642
+ `stitchkit/tracking/server`. → ADR 0166 — [guide](../guide/tracking.md)
15643
+
15644
+ | Export | Kind | Summary |
15645
+ |--------|------|---------|
15646
+ | `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 |
15647
+ | `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 |
15648
+ | `TrackFn` / `EventsWithMetadata` / `EventsWithoutMetadata` | _type_ | `track(type)` / `track(type, metadata)` typed by the application's metadata map, and the two halves of that map |
15649
+ | `BuiltinTrackingEventTypes` / `CONVENTIONAL_TRACKING_EVENT_TYPES` | _type_ / const | the application's names for the events the client emits itself; the conventional `PAGE_VIEW` … `INTERACTION` set |
15650
+ | `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 |
15651
+ | `createTrackingContract` / `TrackingContractConfig` / `TrackingContractEndpoints` | function / _type_ | `bootstrap` (`POST /visit`) and `track` (`POST /events`, `safelistedBody`) from the application's event types, scope and optional extras |
15652
+ | `createTrackingSchemas` / `TrackingSchemas` / `TrackingSchemasConfig` | function / _type_ | every schema of one surface — `event`, `request`, `response`, `disposition`, `entry`, `bootstrap` — from `eventTypes`, optional `eventExtras`, `buildIdPattern`, `maxEventsPerBatch` |
15653
+ | `TrackingEventEnvelope` / `TrackingEventShape` | _type_ | `eventId`, `visitId`, `browserStreamId`, `browserSequence`, `type`, `page`, `metadata`, `clientTimestamp` |
15654
+ | `TrackEventsRequest` / `TrackEventsResponse` / `TrackEventsResponseSchema` | _type_ / schema | a batch (`buildId`, `events`, `utm`) and its answer (`accepted`, `dispositions`) |
15655
+ | `TrackingDisposition` / `TrackingDispositionSchema` / `TrackingDispositionStatus` / `TrackingDispositionStatusSchema` | _type_ / schema | `accepted` / `duplicate` / `identity-invalid` / `excluded-bot` — every one terminal |
15656
+ | `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 |
15657
+ | `TrackingOutboxState` / `TrackingOutboxStateSchema` | _type_ / schema | `available` / `unavailable` |
15658
+ | `UtmData` / `UtmDataSchema` / `AttributionData` / `AttributionDataSchema` | _type_ / schema | campaign tags and a touch (UTM, referrer, landing page) |
15659
+ | `DEFAULT_BUILD_ID_PATTERN` | const | `dev` or a git SHA |
15660
+ | `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 |
15661
+ | `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 |
15662
+ | `indexedDbOutboxStorage` / `memoryOutboxStorage` | function | the IndexedDB adapter (one `readwrite` transaction per `transact`) and the in-memory one for tests and degraded mode |
15663
+ | `createSequenceReserve` / `SequenceReserve` / `SequenceReserveOptions` | function / _type_ | a block of reserved numbers spent synchronously — `take` / `refill` / `shared` — with a per-tab fallback when the source fails |
15664
+ | `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 |
15665
+ | `sendUnloadBeacon` | function | `sendBeacon` with a **string** body — the `text/plain` request a dying document can still complete; `false` without the API |
15666
+ | `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 |
15667
+ | `scrollDepthPercent` / `createScrollMilestones` / `ScrollMilestones` | function / _type_ | depth as a percentage of the scrollable range; milestones fired once each per page |
15668
+ | `createOncePerPage` / `OncePerPage` | function / _type_ | one fact per key per page within a window — the guard a twice-mounted component cannot keep itself |
15669
+ | `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 |
15670
+ | `parseUtmFromSearch` / `parseReferrer` | function | the two parsers `resolveAttribution` is built from |
15671
+ | `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 |
15672
+
15673
+ ---
15674
+
15675
+ ## `stitchkit/tracking/server`
15676
+
15677
+ The decisions a tracking backend makes, without the storage it makes them
15678
+ over. The application reads visits and stored hashes from its database, hands
15679
+ them here and writes what comes back; `issueVisitLease` runs the visit
15680
+ algorithm over a store the application implements. → ADR 0166
15681
+
15682
+ | Export | Kind | Summary |
15683
+ |--------|------|---------|
15684
+ | `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` |
15685
+ | `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 |
15686
+ | `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 |
15687
+ | `activeIntervalOf` / `ActiveTimeInterval` / `ActiveIntervalOptions` | function / _type_ | the visible-time interval an event carries, dated by the server clock; `null` for an unshaped or implausible one |
15688
+ | `hashTrackingEvent` | function | `sha256(JSON.stringify(parsedEvent))` — the stored payload hash, unsorted on purpose so it matches what applications already hold |
15689
+ | `isBotUserAgent` / `DEFAULT_BOT_USER_AGENT_PATTERN` | function / const | crawlers, monitors and `headlesschrome` are not visitors |
15690
+ | `createPresenceRegistry` / `PresenceRegistry` / `PresenceEntry` | function / _type_ | who is here now, in this process: `touch` / `snapshot(filter)` / `presentVisitOf` with a 45 s TTL |
15691
+
15692
+ ---
15693
+
15694
+ ## `stitchkit/release`
15695
+
15696
+ A page follows the release it was built for. The server keeps a marker of the
15697
+ current frontend build and names it on every response (`X-Build-Id`) and every
15698
+ socket connection; the browser compares that to its **own** build id and
15699
+ reloads under a declared policy. Reading the build id and sending the deploy
15700
+ signal stay with the application. → ADR 0167 — [guide](../guide/release.md)
15701
+
15702
+ | Export | Kind | Summary |
15703
+ |--------|------|---------|
15704
+ | `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 |
15705
+ | `RELEASE_HEADER` | const | `X-Build-Id` — set on every response of a handler configured with `release`, exposed through CORS by default |
15706
+ | `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` |
15707
+ | `browserReleaseHost` / `ReleaseWatcherHost` | function / _type_ | visibility, timer and reload behind one interface — a test supplies its own |
15708
+ | `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 |
15709
+ | `ReleaseSocketServer` / `ReleaseSocketEmitter` / `ReleaseSocketListener` / `ReleaseSocketOptions` | _type_ | the structural slices of a Socket.IO server and client the bindings use — no peer import |
15710
+
15711
+ ---
15712
+
15713
+ ## `stitchkit/geo`
15714
+
15715
+ Server-only managed GeoIP generations. Applications own database acquisition,
15716
+ licensing and domain mapping; the optional MaxMind peer is loaded only when its
15717
+ adapter is used.
15718
+
15719
+ | Export | Kind | Summary |
15720
+ |--------|------|---------|
15721
+ | `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 |
15722
+ | `createMaxMindGeoIpLoader` / `MaxMindGeoIpLoaderOptions` / `MaxMindGeoIpPaths` | function / _type_ | lazy optional-peer City+ASN loader with stable before/after file revision checks |
15723
+ | `GeoIpReader` / `GeoIpReaderLoader` | _type_ | peer-neutral reader/generation seam for adapters and tests |
15724
+ | `GeoAttribution` / `mapGeoIpRecord` | _type_ / function | generic optional place and autonomous-system projection; application-specific fields remain outside |
15725
+
15726
+ ---
15727
+
14824
15728
  ## `stitchkit/declaration`
14825
15729
 
14826
15730
  Zod-only, dependency-free. The **project declaration**: the single
@@ -14913,7 +15817,7 @@ runtime-agnostic pieces of `stitchkit/server` and the error helpers.
14913
15817
  | `NodeServerHandle` | _type_ | managed Node handle (`url`, `port`, `runtime`, `status`, `shutdown`) |
14914
15818
  | `NodeRuntimeServer` | _type_ | concrete `srvx/node` runtime escape hatch |
14915
15819
  | `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` |
15820
+ | `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
15821
  | `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
15822
  | `UnixClientTransportConfig` / `UnixClientTransport` | _type_ | Unix socket bounds, explicit response-body mode and owned Fetch-compatible handle |
14919
15823
  | `UnixResponseBodyMode` | _type_ | finite cumulative `bounded` mode or explicit pull-driven `streaming` mode |
@@ -14956,12 +15860,14 @@ SDK nor the `ai` peer.
14956
15860
 
14957
15861
  ## `stitchkit/react`
14958
15862
 
14959
- Browser-only. The React data-layer helpers. Needs the `@tanstack/react-query`
14960
- and `react-query-kit` peers.
15863
+ Browser- and server-render-safe React data-layer helpers. Needs the
15864
+ `@tanstack/react-query` and `react-query-kit` peers.
14961
15865
 
14962
15866
  | Export | Kind | Summary |
14963
15867
  |--------|------|---------|
14964
15868
  | `createCursorQuery` | function | a cursor-paginated infinite query — [guide](../guide/client.md#cursor-pagination) |
15869
+ | `apiErrorRetry` | function | build a bounded network/HTTP-status retry predicate using cross-bundle `ApiError.is` — [guide](../guide/react.md#api-error-retries) |
15870
+ | `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
15871
  | `createCacheBridge` | function | sync socket events into the Query cache — [guide](../guide/realtime.md#cache-bridge) |
14966
15872
  | `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
15873
  | `createEntityCacheHandlers` | function | created/updated/deleted cache handlers for one entity — [guide](../guide/realtime.md#entity-cache-handlers) |
@@ -14975,6 +15881,9 @@ and `react-query-kit` peers.
14975
15881
  | `EntityCacheListShape` | _type_ | `array \| paginated \| infinite-array \| infinite-paginated` |
14976
15882
  | `DeletedPayload` | _type_ | a `deleted` event payload — the entity or a bare `{ id }` |
14977
15883
  | `CursorQueryConfig` | _type_ | config for `createCursorQuery` |
15884
+ | `ApiErrorRetryConfig` | _type_ | retry attempts, excluded application codes, inclusive status ranges and network policy |
15885
+ | `QueryClientFactoryConfig` / `QueryClientServerCache` | _type_ | TanStack config, mutation observer, retry policy and request-cache adapter |
15886
+ | `QueryRetryValue` | _type_ | TanStack's accepted query retry value retained by the factory pass-through |
14978
15887
  | `CacheBridge` | _type_ | the `createCacheBridge` handle |
14979
15888
  | `CacheBridgeConfig` | _type_ | config for `createCacheBridge` |
14980
15889
  | `RealtimeCacheBridgeConfig` | _type_ | config for `createRealtimeCacheBridge` — same fields, a `ValidatedRealtimeSocket` in place of the raw socket |