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