stitchkit 0.80.0 → 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/revision-signal.d.ts +86 -0
- package/dist/application/revision-signal.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 +4 -0
- package/dist/application.d.ts.map +1 -1
- package/dist/application.js +725 -1
- 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 +986 -30
- package/llms.txt +4 -0
- package/package.json +26 -5
package/llms-full.txt
CHANGED
|
@@ -59,11 +59,15 @@ own, recorded as an ADR.
|
|
|
59
59
|
| `stitchkit/remote` | browser **and** server | stable | peer-free `implementRemote` for thin HTTP proxy processes |
|
|
60
60
|
| `stitchkit/files` | server (Bun or Node) | stable | peer-free managed local-file boundary |
|
|
61
61
|
| `stitchkit/telegram` | server (Bun or Node) | evolving | peer-free Telegram platform primitives — Mini App `initData` verification and Bot API send-failure classification |
|
|
62
|
+
| `stitchkit/tracking` | browser **and** server | evolving | visitor-tracking mechanics — `createTrackingClient`, the tab-shared outbox, the page-leave beacon, attribution, the contract factory; no event vocabulary, no React |
|
|
63
|
+
| `stitchkit/tracking/server` | server (Bun or Node) | evolving | the decisions a tracking backend makes — dispositions, visit lease over an application-owned store, active intervals, presence; no database |
|
|
64
|
+
| `stitchkit/release` | browser **and** server | evolving | a page follows the release it was built for — `createReleaseMarker` on the server, `createReleaseWatcher` in the browser, the `X-Build-Id` header and a socket event between them |
|
|
65
|
+
| `stitchkit/geo` | server (Bun or Node) | evolving | managed GeoIP reader generations, last-known-good reload and the optional MaxMind adapter |
|
|
62
66
|
| `stitchkit/observability` | server | stable | request/tool event projections — `createObservability`, trace context, sanitisation |
|
|
63
67
|
| `stitchkit/testing` | tests on Bun or Node | stable | in-process generated clients over a real Fetch handler, plus the store and managed-resource conformance kits |
|
|
64
68
|
| `stitchkit/declaration` | browser + build and deployment tooling (Bun or Node) | evolving | `ProjectDeclarationSchema` — the one machine-readable statement a repository makes about itself |
|
|
65
|
-
| `stitchkit/react` | browser | stable | `createCursorQuery`, `createCacheBridge` |
|
|
66
|
-
| `stitchkit/agent-runtime` | server | evolving<br>_redefined in 11 of the
|
|
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
|
|
@@ -5153,7 +5579,7 @@ description: Compose process-local resources, schedules, readiness, drain and op
|
|
|
5153
5579
|
type: architecture
|
|
5154
5580
|
status: active
|
|
5155
5581
|
created: 2026-08-23
|
|
5156
|
-
updated: 2026-
|
|
5582
|
+
updated: 2026-09-04 14:11 +07:00
|
|
5157
5583
|
---
|
|
5158
5584
|
|
|
5159
5585
|
# Managed application kernel
|
|
@@ -5663,6 +6089,40 @@ underlying Promise actually settles. `drain()` therefore reports real work;
|
|
|
5663
6089
|
`force()` closes admission and reports remaining work without claiming to have
|
|
5664
6090
|
terminated it. → ADR 0118.
|
|
5665
6091
|
|
|
6092
|
+
### Bounded revision wake-up
|
|
6093
|
+
|
|
6094
|
+
Use `createRevisionSignal` when several operations need to wait for the same
|
|
6095
|
+
process-local fact to change, but the change itself is not a queue item:
|
|
6096
|
+
|
|
6097
|
+
```ts
|
|
6098
|
+
const connectionsChanged = createRevisionSignal({ maxWaiters: 256 })
|
|
6099
|
+
|
|
6100
|
+
const observed = connectionsChanged.getSnapshot().revision
|
|
6101
|
+
const result = await connectionsChanged.wait(observed, {
|
|
6102
|
+
signal: request.signal,
|
|
6103
|
+
timeoutMs: 10_000,
|
|
6104
|
+
})
|
|
6105
|
+
|
|
6106
|
+
// After changing the canonical connection state:
|
|
6107
|
+
connectionsChanged.advance()
|
|
6108
|
+
```
|
|
6109
|
+
|
|
6110
|
+
`wait(after)` resolves immediately when the signal is already newer than
|
|
6111
|
+
`after`; this closes the race between reading a snapshot and registering the
|
|
6112
|
+
wait. Waiting on the current revision parks one operation, up to `maxWaiters`.
|
|
6113
|
+
Every terminal result carries the revision it observed and distinguishes
|
|
6114
|
+
`changed`, `timed-out`, `aborted`, `closed` and `capacity`. A future `after` is
|
|
6115
|
+
a caller error rather than a revision the signal could honestly promise to
|
|
6116
|
+
reach.
|
|
6117
|
+
|
|
6118
|
+
One advance wakes every older waiter. It is not consumed by the first reader,
|
|
6119
|
+
does not retain event payloads and has no replay history. A bounded channel is
|
|
6120
|
+
for delivering items to one reader; a credit window grants a finite resource.
|
|
6121
|
+
|
|
6122
|
+
The signal is already its whole lifecycle handle. Call its idempotent `close()`
|
|
6123
|
+
from the `close` phase of the managed resource that owns the changing fact;
|
|
6124
|
+
there is no second managed-resource wrapper around the same state. → ADR 0163.
|
|
6125
|
+
|
|
5666
6126
|
### Bounded delivery and byte credit
|
|
5667
6127
|
|
|
5668
6128
|
`createBoundedChannel` is for one asynchronous reader when an event bus is not a
|
|
@@ -5898,6 +6358,79 @@ Durable job tables, lifecycle journals/outboxes, provider inboxes and business
|
|
|
5898
6358
|
retry rules do **not** disappear. They were never process-local glue and remain
|
|
5899
6359
|
application-owned.
|
|
5900
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
|
+
|
|
5901
6434
|
|
|
5902
6435
|
==============================================================================
|
|
5903
6436
|
# Guide: Generic application primitives (docs/guide/primitives.md)
|
|
@@ -7607,6 +8140,40 @@ gives you the contract and the metadata (`idempotent`, the open `source` tag,
|
|
|
7607
8140
|
`createRetainedTopics`); the wire and the per-call execution stay yours. See
|
|
7608
8141
|
[ADR 0028](../decisions/0028-revert-contract-dispatcher.md).
|
|
7609
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
|
+
|
|
7610
8177
|
|
|
7611
8178
|
==============================================================================
|
|
7612
8179
|
# Guide: Live data (docs/guide/live.md)
|
|
@@ -8390,8 +8957,29 @@ session.clear() // → a Set-Cookie value that expires it
|
|
|
8390
8957
|
```
|
|
8391
8958
|
|
|
8392
8959
|
`defineCookie` bundles a cookie's name and options into a typed handle, so the
|
|
8393
|
-
config is not repeated at every call site. `parseCookies(header)
|
|
8394
|
-
`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.
|
|
8395
8983
|
|
|
8396
8984
|
To set a cookie from a schema-validated JSON endpoint without losing its typed
|
|
8397
8985
|
client result, declare [`responseMeta`](./server.md#typed-json-response-metadata)
|
|
@@ -8546,6 +9134,38 @@ onError: (ctx, err) => {
|
|
|
8546
9134
|
}
|
|
8547
9135
|
```
|
|
8548
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
|
+
|
|
8549
9169
|
## Domain errors — `defineErrors`
|
|
8550
9170
|
|
|
8551
9171
|
Declare each domain code, HTTP status, default message and optional
|
|
@@ -8956,12 +9576,11 @@ route-match, *before* validation, so both are present on every event for a
|
|
|
8956
9576
|
request that matched a contract route, including a pre-handler 400. Nothing to
|
|
8957
9577
|
wire.
|
|
8958
9578
|
|
|
8959
|
-
|
|
8960
|
-
|
|
8961
|
-
|
|
8962
|
-
|
|
8963
|
-
|
|
8964
|
-
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.
|
|
8965
9584
|
|
|
8966
9585
|
When failure attribution itself is asynchronous, use `createErrorHook`'s
|
|
8967
9586
|
`onError(error, info, ctx, endpoint)` observer. The framework awaits it before
|
|
@@ -9314,6 +9933,26 @@ hooks: {
|
|
|
9314
9933
|
Keep any sink **asynchronous and self-contained**: a slow or failing write must
|
|
9315
9934
|
never block or break the request. Swallow the sink's own errors.
|
|
9316
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
|
+
|
|
9317
9956
|
|
|
9318
9957
|
==============================================================================
|
|
9319
9958
|
# Guide: Testing & deployment (docs/guide/testing-and-deployment.md)
|
|
@@ -10091,6 +10730,159 @@ Do not duplicate DTOs or handwritten API wrappers in either integration. The
|
|
|
10091
10730
|
shared contract remains the only transport schema source.
|
|
10092
10731
|
|
|
10093
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
|
+
|
|
10094
10886
|
==============================================================================
|
|
10095
10887
|
# Guide: Upgrading (docs/guide/upgrading.md)
|
|
10096
10888
|
==============================================================================
|
|
@@ -10108,7 +10900,10 @@ A release that breaks a public API leads its `CHANGELOG.md` entry with a
|
|
|
10108
10900
|
**`### ⚠️ Breaking changes`** section (exact heading), each item carrying a
|
|
10109
10901
|
**before → after** snippet. A version with **no** such section is **purely
|
|
10110
10902
|
additive** — adopting it changes nothing in your code. (See
|
|
10111
|
-
[`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.
|
|
10112
10907
|
|
|
10113
10908
|
So upgrading is: read the `### ⚠️ Breaking changes` of every version *above* your
|
|
10114
10909
|
current one *up to* your target, and apply each snippet.
|
|
@@ -10137,6 +10932,32 @@ makes one thing your job rather than the resolver's:
|
|
|
10137
10932
|
The mechanical part is identical either way. Only the *noticing* differs, and an
|
|
10138
10933
|
exact pin moves it onto you.
|
|
10139
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
|
+
|
|
10140
10961
|
## Released migration: 0.80.0
|
|
10141
10962
|
|
|
10142
10963
|
One thing, and only if you parse an async-operation snapshot.
|
|
@@ -13347,8 +14168,9 @@ from the root `stitchkit`.
|
|
|
13347
14168
|
| `ScopedDefineContract` | _type_ | the `defineContract` `createContractFactory` returns — endpoint `scope` overrides join the union |
|
|
13348
14169
|
| `ALL_TRANSPORTS` | constant | `['HTTP', 'MCP', 'AGENT', 'CLI']` |
|
|
13349
14170
|
| `ContractDef` | _type_ | a defined contract |
|
|
14171
|
+
| `PathParams` | _type_ | infer named `:segments` and a terminal `*wildcard` from a path literal as string params |
|
|
13350
14172
|
| `ContractMeta` | _type_ | a contract's `prefix` + optional `scope` and `meta` (a default every endpoint shallow-merges over) |
|
|
13351
|
-
| `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) |
|
|
13352
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) |
|
|
13353
14175
|
| `HeadEndpointDef` | _type_ | explicit HTTP-only, bodyless `HEAD` endpoint definition |
|
|
13354
14176
|
| `EndpointResponseMeta` | _type_ | static success metadata declared by an HTTP-only typed-data endpoint |
|
|
@@ -13384,6 +14206,7 @@ from the root `stitchkit`.
|
|
|
13384
14206
|
|
|
13385
14207
|
### Errors
|
|
13386
14208
|
|
|
14209
|
+
| `ErrorVocabularyCode` / `ErrorVocabularyMapping` / `VocabularyCodeMap` | _type_ | application-code union, partial/exhaustive framework mapping input and resolved wire map returned by `defineErrors` |
|
|
13387
14210
|
| Export | Kind | Summary |
|
|
13388
14211
|
|--------|------|---------|
|
|
13389
14212
|
| `AppError` | class | the framework error — `code` / `status` / `details` / `hint` |
|
|
@@ -13396,7 +14219,7 @@ from the root `stitchkit`.
|
|
|
13396
14219
|
| `rateLimited` | function | throw `429 RATE_LIMITED` |
|
|
13397
14220
|
| `appError` | function | throw an `AppError` for any code |
|
|
13398
14221
|
| `defineErrors` | function | declare immutable domain error definitions → typed `AppError` constructors, codes and schemas — [guide](../guide/auth-and-errors.md#domain-errors--defineerrors) |
|
|
13399
|
-
| `DefinedErrors` | _type_ | the `{ errors, codes, definitions, isCode }` handle `defineErrors` returns |
|
|
14222
|
+
| `DefinedErrors` | _type_ | the `{ errors, codes, definitions, isCode, codeMap }` handle `defineErrors` returns |
|
|
13400
14223
|
| `DefinedAppError` | _type_ | literal-code error instance with schema-refined details |
|
|
13401
14224
|
| `ErrorDefinition` | _type_ | `{ status, message?, details? }` definition for one domain code |
|
|
13402
14225
|
| `ErrorDefinitions` | _type_ | string-keyed domain error definition registry |
|
|
@@ -13552,7 +14375,7 @@ Also re-exports the error helpers from `stitchkit/contract`.
|
|
|
13552
14375
|
| `composeLifecycleHooks` | function | compose HTTP lifecycle phases in declaration order with short-circuit/fallthrough semantics |
|
|
13553
14376
|
| `AuthorizationContext` | _type_ | HTTP pre-body context with validated params, `input: undefined` and no files |
|
|
13554
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) |
|
|
13555
|
-
| `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 |
|
|
13556
14379
|
| `RawRouteContext` | _type_ | the Bun-bound routing context a raw handler receives |
|
|
13557
14380
|
| `BunServer` | _type_ | the `Bun.serve` instance type |
|
|
13558
14381
|
| `ServerPassthrough` | _type_ | extra `Bun.serve` options |
|
|
@@ -13569,7 +14392,8 @@ Also re-exports the error helpers from `stitchkit/contract`.
|
|
|
13569
14392
|
|--------|------|---------|
|
|
13570
14393
|
| `createAuthHook` | function | one scope gate for HTTP `authorize` and tool `beforeHandle` — [guide](../guide/auth-and-errors.md#createauthhook) |
|
|
13571
14394
|
| `composeAuthHooks` | function | route multiple canonical auth domains by owned scope and atomically commit their typed contributions |
|
|
13572
|
-
| `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` |
|
|
13573
14397
|
| `ErrorHookConfig` | _type_ | async observer/renderer config with partial `codeMap` and optional typed `unmappedCode` fallback |
|
|
13574
14398
|
| `ResolvedError` | _type_ | the normalised error handed to `createErrorHook`'s `render` |
|
|
13575
14399
|
| `createBearerResolver` | function | a bearer-token identity resolver |
|
|
@@ -13599,19 +14423,23 @@ Also re-exports the error helpers from `stitchkit/contract`.
|
|
|
13599
14423
|
|
|
13600
14424
|
| Export | Kind | Summary |
|
|
13601
14425
|
|--------|------|---------|
|
|
13602
|
-
| `defineCookie` | function | a typed cookie `get` / `set` / `clear` handle — [guide](../guide/auth-and-errors.md#cookies) |
|
|
13603
|
-
| `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 |
|
|
13604
14429
|
| `serializeCookie` | function | build a `Set-Cookie` value |
|
|
13605
14430
|
| `corsHeaders` | function | compute CORS response headers |
|
|
13606
14431
|
| `corsPreflightResponse` | function | build a preflight `Response` |
|
|
13607
14432
|
| `DEFAULT_CORS_ALLOW_HEADERS` | const | the default `Access-Control-Allow-Headers` (incl. `traceparent`) — extend it when overriding `cors.headers` |
|
|
13608
|
-
| `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 |
|
|
13609
14434
|
| `CookieDef` | _type_ | the `defineCookie` handle |
|
|
13610
|
-
| `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 |
|
|
13611
14437
|
| `CorsConfig` | _type_ | CORS policy |
|
|
13612
14438
|
|
|
13613
14439
|
### Realtime (server)
|
|
13614
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 |
|
|
13615
14443
|
| Export | Kind | Summary |
|
|
13616
14444
|
|--------|------|---------|
|
|
13617
14445
|
| `createSocketIOServer` | function | the typed Socket.IO server — [guide](../guide/realtime.md#server--createsocketioserver) |
|
|
@@ -13634,6 +14462,8 @@ Also re-exports the error helpers from `stitchkit/contract`.
|
|
|
13634
14462
|
|
|
13635
14463
|
### Primitives
|
|
13636
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 |
|
|
13637
14467
|
| Export | Kind | Summary |
|
|
13638
14468
|
|--------|------|---------|
|
|
13639
14469
|
| `streamSSE` | function | an async generator → SSE `Response` — [guide](../guide/server.md#sse-streaming) |
|
|
@@ -13652,6 +14482,7 @@ Also re-exports the error helpers from `stitchkit/contract`.
|
|
|
13652
14482
|
| `MultipartReceiver` | _type_ | consumer-owned Web-stream storage receiver |
|
|
13653
14483
|
| `MultipartReceiverResult` | _type_ | receiver value plus rollback cleanup |
|
|
13654
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) |
|
|
13655
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) |
|
|
13656
14487
|
| `ProcessSignalsOptions` | _type_ | config for `bindProcessSignals` |
|
|
13657
14488
|
| `ProcessSignalsBinding` | _type_ | the `{ promise, close }` handle `bindProcessSignals` returns |
|
|
@@ -13729,6 +14560,17 @@ cutovers are covered by the executable
|
|
|
13729
14560
|
| Export | Kind | Summary |
|
|
13730
14561
|
|--------|------|---------|
|
|
13731
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 |
|
|
13732
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 |
|
|
13733
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 |
|
|
13734
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 |
|
|
@@ -13775,6 +14617,19 @@ cutovers are covered by the executable
|
|
|
13775
14617
|
| `BoundedAdmissionRefusalError` | class | `run()` refusal with reason and optional `retryAfterMs` |
|
|
13776
14618
|
| `BoundedOperationWaitError` | class | caller wait ended as `cancelled` or `timed-out`; underlying capacity remains leased until work settles |
|
|
13777
14619
|
|
|
14620
|
+
### Revision wake-up
|
|
14621
|
+
|
|
14622
|
+
| Export | Kind | Summary |
|
|
14623
|
+
|--------|------|---------|
|
|
14624
|
+
| `createRevisionSignal` | function | finite broadcast wait for the next monotonic process-local revision |
|
|
14625
|
+
| `RevisionSignal` / `RevisionSignalConfig` / `RevisionSignalClock` / `RevisionSignalTimer` | _type_ | signal handle, waiter bound and replaceable timer boundary |
|
|
14626
|
+
| `RevisionSignalWaitOptions` | _type_ | optional caller `signal` and positive `timeoutMs`; absent options wait until advance or close |
|
|
14627
|
+
| `RevisionSignalWaitOutcomeSchema` / `RevisionSignalWaitOutcome` | schema / _type_ | `changed \| timed-out \| aborted \| closed \| capacity` |
|
|
14628
|
+
| `RevisionSignalWaitResultSchema` / `RevisionSignalWaitResult` | schema / _type_ | terminal wait outcome with the revision observed at settlement |
|
|
14629
|
+
| `RevisionSignalAdvanceResultSchema` / `RevisionSignalAdvanceResult` | schema / _type_ | successful `advanced` or post-close `closed`, with the resulting revision |
|
|
14630
|
+
| `RevisionSignalStateSchema` / `RevisionSignalState` | schema / _type_ | `open \| closed` lifecycle |
|
|
14631
|
+
| `RevisionSignalSnapshotSchema` / `RevisionSignalSnapshot` | schema / _type_ | current revision, finite pending count and lifetime outcome counters |
|
|
14632
|
+
|
|
13778
14633
|
### Bounded delivery
|
|
13779
14634
|
|
|
13780
14635
|
| Export | Kind | Summary |
|
|
@@ -14354,6 +15209,11 @@ audit event. See the [Observability guide](../guide/observability.md).
|
|
|
14354
15209
|
| Export | Kind | Summary |
|
|
14355
15210
|
|--------|------|---------|
|
|
14356
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 |
|
|
14357
15217
|
| `RequestEvent` | _type_ | the normalised audit event handed to the sink; opt-in HTTP cancellation rows carry `outcome: 'cancelled'` |
|
|
14358
15218
|
| `ObservabilityConfig` | _type_ | independent request and tool sink configuration |
|
|
14359
15219
|
| `Observability` | _type_ | `{ request?, toolCall, getStatus(), flush(), close() }` with bounded sink lifecycle |
|
|
@@ -14774,6 +15634,97 @@ injected grammY bot. → ADR 0143
|
|
|
14774
15634
|
|
|
14775
15635
|
---
|
|
14776
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
|
+
|
|
14777
15728
|
## `stitchkit/declaration`
|
|
14778
15729
|
|
|
14779
15730
|
Zod-only, dependency-free. The **project declaration**: the single
|
|
@@ -14866,7 +15817,7 @@ runtime-agnostic pieces of `stitchkit/server` and the error helpers.
|
|
|
14866
15817
|
| `NodeServerHandle` | _type_ | managed Node handle (`url`, `port`, `runtime`, `status`, `shutdown`) |
|
|
14867
15818
|
| `NodeRuntimeServer` | _type_ | concrete `srvx/node` runtime escape hatch |
|
|
14868
15819
|
| `NodeSocketLifecycle` | _type_ | Bun-free Socket.IO lifecycle accepted by `serveNode` |
|
|
14869
|
-
| `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 |
|
|
14870
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 |
|
|
14871
15822
|
| `UnixClientTransportConfig` / `UnixClientTransport` | _type_ | Unix socket bounds, explicit response-body mode and owned Fetch-compatible handle |
|
|
14872
15823
|
| `UnixResponseBodyMode` | _type_ | finite cumulative `bounded` mode or explicit pull-driven `streaming` mode |
|
|
@@ -14909,12 +15860,14 @@ SDK nor the `ai` peer.
|
|
|
14909
15860
|
|
|
14910
15861
|
## `stitchkit/react`
|
|
14911
15862
|
|
|
14912
|
-
Browser-
|
|
14913
|
-
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.
|
|
14914
15865
|
|
|
14915
15866
|
| Export | Kind | Summary |
|
|
14916
15867
|
|--------|------|---------|
|
|
14917
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) |
|
|
14918
15871
|
| `createCacheBridge` | function | sync socket events into the Query cache — [guide](../guide/realtime.md#cache-bridge) |
|
|
14919
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` |
|
|
14920
15873
|
| `createEntityCacheHandlers` | function | created/updated/deleted cache handlers for one entity — [guide](../guide/realtime.md#entity-cache-handlers) |
|
|
@@ -14928,6 +15881,9 @@ and `react-query-kit` peers.
|
|
|
14928
15881
|
| `EntityCacheListShape` | _type_ | `array \| paginated \| infinite-array \| infinite-paginated` |
|
|
14929
15882
|
| `DeletedPayload` | _type_ | a `deleted` event payload — the entity or a bare `{ id }` |
|
|
14930
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 |
|
|
14931
15887
|
| `CacheBridge` | _type_ | the `createCacheBridge` handle |
|
|
14932
15888
|
| `CacheBridgeConfig` | _type_ | config for `createCacheBridge` |
|
|
14933
15889
|
| `RealtimeCacheBridgeConfig` | _type_ | config for `createRealtimeCacheBridge` — same fields, a `ValidatedRealtimeSocket` in place of the raw socket |
|