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.
- package/dist/agent-runtime/provider-failure.d.ts.map +1 -1
- package/dist/agent-runtime-coding-tools.js +1 -1
- package/dist/agent-runtime-harness.js +7 -5
- package/dist/agent-runtime.js +15 -19
- package/dist/application/notification-outbox.d.ts +80 -0
- package/dist/application/notification-outbox.d.ts.map +1 -0
- package/dist/application/process-lifecycle.d.ts +166 -0
- package/dist/application/process-lifecycle.d.ts.map +1 -0
- package/dist/application/state-store.d.ts +18 -0
- package/dist/application/state-store.d.ts.map +1 -0
- package/dist/application.d.ts +3 -0
- package/dist/application.d.ts.map +1 -1
- package/dist/application.js +568 -11
- package/dist/browser/http.d.ts +14 -0
- package/dist/browser/http.d.ts.map +1 -1
- package/dist/browser/resumable.d.ts +6 -0
- package/dist/browser/resumable.d.ts.map +1 -1
- package/dist/cli.js +9 -7
- package/dist/contract/define.d.ts +27 -4
- package/dist/contract/define.d.ts.map +1 -1
- package/dist/contract/errors-factory.d.ts +22 -3
- package/dist/contract/errors-factory.d.ts.map +1 -1
- package/dist/contract/index.d.ts +2 -2
- package/dist/contract/index.d.ts.map +1 -1
- package/dist/contract/index.js +1 -1
- package/dist/geo/index.d.ts +4 -0
- package/dist/geo/index.d.ts.map +1 -0
- package/dist/geo/maxmind.d.ts +4 -0
- package/dist/geo/maxmind.d.ts.map +1 -0
- package/dist/geo/resolver.d.ts +9 -0
- package/dist/geo/resolver.d.ts.map +1 -0
- package/dist/geo/types.d.ts +59 -0
- package/dist/geo/types.d.ts.map +1 -0
- package/dist/geo.d.ts +3 -0
- package/dist/geo.d.ts.map +1 -0
- package/dist/geo.js +245 -0
- package/dist/{index-9s6z8vgf.js → index-1dd82z59.js} +1 -1
- package/dist/{index-vwp69871.js → index-27487ay5.js} +4 -4
- package/dist/{index-7ag81v2f.js → index-27gm3d6g.js} +10 -321
- package/dist/{index-y01f4223.js → index-2bxcmqkb.js} +127 -43
- package/dist/{index-dxesg10d.js → index-3ds42w1w.js} +3 -3
- package/dist/index-3xwxfj4z.js +334 -0
- package/dist/{index-egaabxkz.js → index-5z8tezpx.js} +2 -2
- package/dist/{index-fxz0v7vg.js → index-7evdsyez.js} +1 -83
- package/dist/{index-xcetq6j3.js → index-90d7h2cr.js} +32 -13
- package/dist/{index-t0z5p5w6.js → index-apn8repm.js} +1 -1
- package/dist/{index-758zx2ha.js → index-dggwnnjv.js} +2 -2
- package/dist/{index-dwf1f2ap.js → index-f9mb610r.js} +1 -62
- package/dist/{index-5mtc969c.js → index-fwbkp2jn.js} +1 -1
- package/dist/index-p13mwz16.js +16 -0
- package/dist/{index-m9kzax52.js → index-p18f59zy.js} +21 -5
- package/dist/{index-hb5pf3s4.js → index-qmgs7y68.js} +1 -1
- package/dist/{index-2rwwmr7v.js → index-s4ckmrac.js} +1 -1
- package/dist/{index-h4pj6fta.js → index-s8cnx02s.js} +8 -5
- package/dist/{index-k9ay6v6d.js → index-vchzar7e.js} +74 -23
- package/dist/index-vsbzgd7b.js +84 -0
- package/dist/{index-dxjjv399.js → index-xbppf54a.js} +95 -15
- package/dist/index-y91ry9h2.js +4 -0
- package/dist/index-zcgf3gqf.js +83 -0
- package/dist/index.js +17 -13
- package/dist/internal/error-message.d.ts +3 -0
- package/dist/internal/error-message.d.ts.map +1 -0
- package/dist/internal/media-type.d.ts +8 -0
- package/dist/internal/media-type.d.ts.map +1 -0
- package/dist/internal/route-pattern.d.ts +21 -0
- package/dist/internal/route-pattern.d.ts.map +1 -1
- package/dist/node.js +9 -6
- package/dist/observability/audit.d.ts +28 -1
- package/dist/observability/audit.d.ts.map +1 -1
- package/dist/observability/bounded-logger.d.ts +20 -0
- package/dist/observability/bounded-logger.d.ts.map +1 -0
- package/dist/observability/context.d.ts +12 -3
- package/dist/observability/context.d.ts.map +1 -1
- package/dist/observability/index.d.ts +3 -2
- package/dist/observability/index.d.ts.map +1 -1
- package/dist/observability/index.js +138 -8
- package/dist/observability/sanitize.d.ts +17 -0
- package/dist/observability/sanitize.d.ts.map +1 -1
- package/dist/primitives.js +1 -1
- package/dist/react/query-client.d.ts +33 -0
- package/dist/react/query-client.d.ts.map +1 -0
- package/dist/react.d.ts +1 -0
- package/dist/react.d.ts.map +1 -1
- package/dist/react.js +83 -1
- package/dist/realtime/registry.d.ts +93 -0
- package/dist/realtime/registry.d.ts.map +1 -0
- package/dist/release/header.d.ts +9 -0
- package/dist/release/header.d.ts.map +1 -0
- package/dist/release/marker.d.ts +39 -0
- package/dist/release/marker.d.ts.map +1 -0
- package/dist/release/socket.d.ts +44 -0
- package/dist/release/socket.d.ts.map +1 -0
- package/dist/release/watcher.d.ts +66 -0
- package/dist/release/watcher.d.ts.map +1 -0
- package/dist/release.d.ts +15 -0
- package/dist/release.d.ts.map +1 -0
- package/dist/release.js +196 -0
- package/dist/remote.js +12 -7
- package/dist/server/context.d.ts +2 -1
- package/dist/server/context.d.ts.map +1 -1
- package/dist/server/create.d.ts.map +1 -1
- package/dist/server/error-hook.d.ts +17 -16
- package/dist/server/error-hook.d.ts.map +1 -1
- package/dist/server/file-state-store.d.ts +23 -0
- package/dist/server/file-state-store.d.ts.map +1 -0
- package/dist/server/implement.d.ts.map +1 -1
- package/dist/server/index.d.ts +5 -2
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +469 -11
- package/dist/server/middleware/cookies.d.ts +33 -1
- package/dist/server/middleware/cookies.d.ts.map +1 -1
- package/dist/server/middleware/cors.d.ts +16 -1
- package/dist/server/middleware/cors.d.ts.map +1 -1
- package/dist/server/openapi.d.ts.map +1 -1
- package/dist/server/release-signal.d.ts +35 -0
- package/dist/server/release-signal.d.ts.map +1 -0
- package/dist/server/router.d.ts.map +1 -1
- package/dist/server/types.d.ts +26 -2
- package/dist/server/types.d.ts.map +1 -1
- package/dist/testing/surface-manifest.d.ts.map +1 -1
- package/dist/testing.js +7 -4
- package/dist/tool-invoker.js +8 -6
- package/dist/tools/download-core.d.ts.map +1 -1
- package/dist/tools/remote.d.ts.map +1 -1
- package/dist/tools/view-file.d.ts.map +1 -1
- package/dist/tools.js +20 -16
- package/dist/tracking/attribution.d.ts +51 -0
- package/dist/tracking/attribution.d.ts.map +1 -0
- package/dist/tracking/beacon.d.ts +14 -0
- package/dist/tracking/beacon.d.ts.map +1 -0
- package/dist/tracking/clicks.d.ts +46 -0
- package/dist/tracking/clicks.d.ts.map +1 -0
- package/dist/tracking/client.d.ts +121 -0
- package/dist/tracking/client.d.ts.map +1 -0
- package/dist/tracking/contract.d.ts +46 -0
- package/dist/tracking/contract.d.ts.map +1 -0
- package/dist/tracking/delivery.d.ts +24 -0
- package/dist/tracking/delivery.d.ts.map +1 -0
- package/dist/tracking/host.d.ts +43 -0
- package/dist/tracking/host.d.ts.map +1 -0
- package/dist/tracking/once-per-page.d.ts +17 -0
- package/dist/tracking/once-per-page.d.ts.map +1 -0
- package/dist/tracking/outbox-storage-indexeddb.d.ts +12 -0
- package/dist/tracking/outbox-storage-indexeddb.d.ts.map +1 -0
- package/dist/tracking/outbox-storage-memory.d.ts +12 -0
- package/dist/tracking/outbox-storage-memory.d.ts.map +1 -0
- package/dist/tracking/outbox.d.ts +98 -0
- package/dist/tracking/outbox.d.ts.map +1 -0
- package/dist/tracking/schemas.d.ts +183 -0
- package/dist/tracking/schemas.d.ts.map +1 -0
- package/dist/tracking/scroll.d.ts +22 -0
- package/dist/tracking/scroll.d.ts.map +1 -0
- package/dist/tracking/sequence-reserve.d.ts +34 -0
- package/dist/tracking/sequence-reserve.d.ts.map +1 -0
- package/dist/tracking/server/active-interval.d.ts +26 -0
- package/dist/tracking/server/active-interval.d.ts.map +1 -0
- package/dist/tracking/server/bot.d.ts +9 -0
- package/dist/tracking/server/bot.d.ts.map +1 -0
- package/dist/tracking/server/disposition.d.ts +55 -0
- package/dist/tracking/server/disposition.d.ts.map +1 -0
- package/dist/tracking/server/hash.d.ts +13 -0
- package/dist/tracking/server/hash.d.ts.map +1 -0
- package/dist/tracking/server/presence.d.ts +27 -0
- package/dist/tracking/server/presence.d.ts.map +1 -0
- package/dist/tracking/server/visit-lease.d.ts +92 -0
- package/dist/tracking/server/visit-lease.d.ts.map +1 -0
- package/dist/tracking/visible-time.d.ts +38 -0
- package/dist/tracking/visible-time.d.ts.map +1 -0
- package/dist/tracking-server.d.ts +14 -0
- package/dist/tracking-server.d.ts.map +1 -0
- package/dist/tracking-server.js +173 -0
- package/dist/tracking.d.ts +24 -0
- package/dist/tracking.d.ts.map +1 -0
- package/dist/tracking.js +1090 -0
- package/llms-full.txt +938 -29
- package/llms.txt +4 -0
- 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
|
|
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
|
|
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
|
-
`
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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'
|
|
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)
|
|
8428
|
-
`serializeCookie(name, value, opts)` are the
|
|
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
|
-
|
|
8994
|
-
|
|
8995
|
-
|
|
8996
|
-
|
|
8997
|
-
|
|
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
|
|
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-
|
|
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 |
|