stitchkit 0.7.0 → 0.8.1
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/README.md +20 -10
- package/dist/cli.js +3 -7
- package/dist/contract/index.d.ts +1 -1
- package/dist/contract/index.d.ts.map +1 -1
- package/dist/contract/index.js +5 -2
- package/dist/contract/pagination.d.ts +11 -0
- package/dist/contract/pagination.d.ts.map +1 -1
- package/dist/{index-za2p453b.js → index-031q8xmx.js} +1 -1
- package/dist/{index-hty3443r.js → index-9zrq8x5z.js} +9 -6
- package/dist/{index-9bk6r5ff.js → index-eq29zkrx.js} +26 -1
- package/dist/{index-p8byjw3e.js → index-f39j6twc.js} +4 -8
- package/dist/index-jgpsd7dy.js +105 -0
- package/dist/{index-mwmpw6j1.js → index-p9m9c0jw.js} +2 -4
- package/dist/{index-8hsj2qm9.js → index-svvpy5bz.js} +18 -19
- package/dist/index-tm7dqzxc.js +20 -0
- package/dist/index.js +434 -14
- package/dist/node.js +4 -8
- package/dist/observability/index.js +4 -7
- package/dist/react.js +0 -2
- package/dist/server/index.js +89 -34
- package/dist/server/router.d.ts.map +1 -1
- package/dist/server/types.d.ts +10 -2
- package/dist/server/types.d.ts.map +1 -1
- package/dist/tools/mcp-app.d.ts.map +1 -1
- package/dist/tools.js +253 -21
- package/llms-full.txt +3025 -0
- package/llms.txt +23 -0
- package/package.json +8 -4
- package/dist/index-37x76zdn.js +0 -4
- package/dist/index-4843j09b.js +0 -37
- package/dist/index-48ffdxgk.js +0 -6
- package/dist/index-809wc1tt.js +0 -18
- package/dist/index-kdkvp26v.js +0 -380
- package/dist/index-kzfs85xp.js +0 -9
- package/dist/index-mahbaen9.js +0 -72
- package/dist/index-x3fcszf8.js +0 -8
package/llms-full.txt
ADDED
|
@@ -0,0 +1,3025 @@
|
|
|
1
|
+
# stitchkit — full documentation
|
|
2
|
+
|
|
3
|
+
> The complete guide + API reference, inlined for offline agent use. Generated
|
|
4
|
+
> from the `docs/` tree (the source of truth) by `bun run gen:llms`.
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
==============================================================================
|
|
8
|
+
# Guide: Getting started (docs/guide/getting-started.md)
|
|
9
|
+
==============================================================================
|
|
10
|
+
|
|
11
|
+
# Getting started
|
|
12
|
+
|
|
13
|
+
stitchkit turns one contract into an HTTP API, MCP tools, AI-agent tools and a
|
|
14
|
+
typed client. This page gets a working app running; the rest of the guide goes
|
|
15
|
+
deep on each piece.
|
|
16
|
+
|
|
17
|
+
## Requirements
|
|
18
|
+
|
|
19
|
+
- [Bun](https://bun.sh) `>= 1.2` (recommended) or [Node.js](https://nodejs.org)
|
|
20
|
+
`>= 22`. Bun is first-class; Node is supported via `stitchkit/node`.
|
|
21
|
+
|
|
22
|
+
## Install
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
bun add stitchkit zod
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
`zod` is a required peer — schemas are the source of truth everywhere. Other
|
|
29
|
+
peers are optional and pulled in only when you use the matching feature
|
|
30
|
+
(`@modelcontextprotocol/sdk` for MCP, `ai` for agents, `socket.io*` for
|
|
31
|
+
realtime, `@tanstack/react-query` + `react-query-kit` for React). See
|
|
32
|
+
[deps](#dependencies) below.
|
|
33
|
+
|
|
34
|
+
## Entrypoints
|
|
35
|
+
|
|
36
|
+
stitchkit ships five entrypoints. Each is import-safe for one environment —
|
|
37
|
+
keeping server-only code (`Bun.serve`, the MCP SDK) out of browser bundles.
|
|
38
|
+
|
|
39
|
+
| Import | Use in | Holds |
|
|
40
|
+
|--------|--------|-------|
|
|
41
|
+
| `stitchkit` | browser **and** server | `defineContract`, `createClient`, `createHttpClient`, `createSocketIOClient`, `parseSSE`, the error model |
|
|
42
|
+
| `stitchkit/contract` | browser **and** server | the contract layer alone — `defineContract`, errors, pagination |
|
|
43
|
+
| `stitchkit/server` | server | `createServer`, `implement`, hooks, auth, Socket.IO server, server primitives |
|
|
44
|
+
| `stitchkit/tools` | server | `createMcpHandler`, `mountMcp`, `mountAgent` |
|
|
45
|
+
| `stitchkit/react` | browser | `createCursorQuery`, `createCacheBridge` |
|
|
46
|
+
|
|
47
|
+
Rule of thumb: browser code imports `stitchkit` and `stitchkit/react`; server
|
|
48
|
+
code adds `stitchkit/server` and `stitchkit/tools`.
|
|
49
|
+
|
|
50
|
+
## Project layout
|
|
51
|
+
|
|
52
|
+
A contract is shared by both sides, so it lives in its own folder:
|
|
53
|
+
|
|
54
|
+
```
|
|
55
|
+
src/
|
|
56
|
+
├── shared/contracts.ts the contract — imported by server and client
|
|
57
|
+
├── server/index.ts implement() + createServer()
|
|
58
|
+
└── client/api.ts createClient()
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## A first app
|
|
62
|
+
|
|
63
|
+
### 1. Define the contract
|
|
64
|
+
|
|
65
|
+
```ts
|
|
66
|
+
// src/shared/contracts.ts
|
|
67
|
+
import { defineContract } from 'stitchkit'
|
|
68
|
+
import { z } from 'zod'
|
|
69
|
+
|
|
70
|
+
const Note = z.object({ id: z.string(), text: z.string() })
|
|
71
|
+
|
|
72
|
+
export const notes = defineContract({ prefix: 'notes' }, {
|
|
73
|
+
list: { method: 'GET', path: '/', desc: 'List notes', output: z.array(Note) },
|
|
74
|
+
create: { method: 'POST', path: '/', desc: 'Create note', input: z.object({ text: z.string() }), output: Note },
|
|
75
|
+
get: { method: 'GET', path: '/:id', desc: 'Get a note', params: z.object({ id: z.string() }), output: Note },
|
|
76
|
+
})
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### 2. Implement and serve
|
|
80
|
+
|
|
81
|
+
```ts
|
|
82
|
+
// src/server/index.ts
|
|
83
|
+
import { implement, createServer } from 'stitchkit/server'
|
|
84
|
+
import { notes } from '../shared/contracts'
|
|
85
|
+
|
|
86
|
+
const service = implement(notes, {
|
|
87
|
+
list: () => db.list(),
|
|
88
|
+
create: (ctx) => db.create(ctx.input.text), // ctx.input is typed
|
|
89
|
+
get: (ctx) => db.get(ctx.params.id), // ctx.params is typed
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
createServer({ services: [service], port: 3000 })
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### 3. Call it, typed
|
|
96
|
+
|
|
97
|
+
```ts
|
|
98
|
+
// src/client/api.ts
|
|
99
|
+
import { createClient, createHttpClient } from 'stitchkit'
|
|
100
|
+
import { notes } from '../shared/contracts'
|
|
101
|
+
|
|
102
|
+
export const api = createClient(notes, createHttpClient({ baseUrl: '/api' }))
|
|
103
|
+
|
|
104
|
+
await api.list() // GET /notes → Note[]
|
|
105
|
+
await api.create({ text: 'hi' }) // POST /notes → Note
|
|
106
|
+
await api.get({ id: '1' }) // GET /notes/1 → Note
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Run the server with `bun run src/server/index.ts`. The contract is the single
|
|
110
|
+
source of truth — change it and both the handler and the client are re-typed at
|
|
111
|
+
once.
|
|
112
|
+
|
|
113
|
+
#### On Node
|
|
114
|
+
|
|
115
|
+
`createServer` is Bun's `Bun.serve`. On **Node ≥ 22**, swap it for `serveNode`
|
|
116
|
+
(from `stitchkit/node`, built on `srvx`) — the contract, `implement` and the
|
|
117
|
+
client are identical:
|
|
118
|
+
|
|
119
|
+
```ts
|
|
120
|
+
import { serveNode } from 'stitchkit/node'
|
|
121
|
+
|
|
122
|
+
serveNode({ services: [service], port: 3000 })
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Add `@types/bun` as a dev dependency on Node (an optional peer — it types the
|
|
126
|
+
shared `stitchkit/server` surface). See [deployment](./testing-and-deployment.md#deploy-on-node).
|
|
127
|
+
|
|
128
|
+
## Dependencies
|
|
129
|
+
|
|
130
|
+
`ky` is the **only** bundled runtime dependency. Everything else is an *optional
|
|
131
|
+
peer* — your app installs only what the features it uses need, and owns the
|
|
132
|
+
version (one shared instance, no dual-version skew). This matrix is the install
|
|
133
|
+
map — feature → packages:
|
|
134
|
+
|
|
135
|
+
| Feature you use | Install |
|
|
136
|
+
|-----------------|---------|
|
|
137
|
+
| anything (validation) | `zod` |
|
|
138
|
+
| `createServer` (Bun) | — (uses `Bun.serve`) |
|
|
139
|
+
| `serveNode` (Node ≥ 22) | `srvx` (+ `@types/bun` dev) |
|
|
140
|
+
| MCP tools (`stitchkit/tools`) | `@modelcontextprotocol/sdk` |
|
|
141
|
+
| MCP Apps UI widgets | `@modelcontextprotocol/ext-apps` |
|
|
142
|
+
| agent tools (`stitchkit/tools`) | `ai` |
|
|
143
|
+
| React data layer (`stitchkit/react`) | `@tanstack/react-query` `react-query-kit` |
|
|
144
|
+
| **Socket.IO server on Bun** | `socket.io` `@socket.io/bun-engine` |
|
|
145
|
+
| **Socket.IO server on Node** | `socket.io` |
|
|
146
|
+
| Socket.IO client | `socket.io-client` |
|
|
147
|
+
|
|
148
|
+
```bash
|
|
149
|
+
bun add socket.io @socket.io/bun-engine # e.g. the Socket.IO server on Bun
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
If an optional peer is missing, the feature that needs it fails with an
|
|
153
|
+
actionable error naming the package and the install command — not a bare
|
|
154
|
+
`Cannot find module`.
|
|
155
|
+
|
|
156
|
+
## Next
|
|
157
|
+
|
|
158
|
+
- [Contracts](./contracts.md) — every endpoint field, in depth.
|
|
159
|
+
- [HTTP server](./server.md) — `createServer`, hooks, raw routes, primitives.
|
|
160
|
+
- [Typed client](./client.md) — the client, React data layer, SSE.
|
|
161
|
+
- [MCP & agents](./mcp-and-agents.md) — contracts as AI tools.
|
|
162
|
+
- [Realtime](./realtime.md) — Socket.IO and the cache bridge.
|
|
163
|
+
- [Auth & errors](./auth-and-errors.md) — scopes, auth hooks, the error model.
|
|
164
|
+
- [Testing & deployment](./testing-and-deployment.md).
|
|
165
|
+
- [API reference](../api/reference.md) — every export, by entrypoint.
|
|
166
|
+
|
|
167
|
+
A complete runnable app is in [`packages/starter`](../../packages/starter).
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
==============================================================================
|
|
171
|
+
# Guide: Contracts (docs/guide/contracts.md)
|
|
172
|
+
==============================================================================
|
|
173
|
+
|
|
174
|
+
# Contracts
|
|
175
|
+
|
|
176
|
+
A **contract** describes a set of operations once — method, path, schemas,
|
|
177
|
+
scope, which transports each is exposed on. From it stitchkit derives the HTTP
|
|
178
|
+
route, the MCP tool, the agent tool and the typed client. One declaration; the
|
|
179
|
+
surfaces cannot drift.
|
|
180
|
+
|
|
181
|
+
## `defineContract`
|
|
182
|
+
|
|
183
|
+
```ts
|
|
184
|
+
import { defineContract } from 'stitchkit'
|
|
185
|
+
|
|
186
|
+
const contract = defineContract(meta, endpoints)
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
- **`meta`** — `{ prefix: string }`, or `{ prefix: string, scope: string }` to
|
|
190
|
+
give every endpoint a default scope.
|
|
191
|
+
- **`endpoints`** — a map of `key → endpoint definition`. The key becomes the
|
|
192
|
+
client method name and the handler name.
|
|
193
|
+
|
|
194
|
+
`defineContract` throws at definition time if two endpoints declare the same
|
|
195
|
+
`toolName` on the same transport — a tool-name clash is a bug, caught early.
|
|
196
|
+
|
|
197
|
+
## An endpoint
|
|
198
|
+
|
|
199
|
+
```ts
|
|
200
|
+
export const users = defineContract({ prefix: 'users' }, {
|
|
201
|
+
list: {
|
|
202
|
+
method: 'GET',
|
|
203
|
+
path: '/',
|
|
204
|
+
desc: 'List all users',
|
|
205
|
+
output: z.array(UserSchema),
|
|
206
|
+
},
|
|
207
|
+
create: {
|
|
208
|
+
method: 'POST',
|
|
209
|
+
path: '/',
|
|
210
|
+
desc: 'Create a user',
|
|
211
|
+
input: CreateUserSchema,
|
|
212
|
+
output: UserSchema,
|
|
213
|
+
},
|
|
214
|
+
get: {
|
|
215
|
+
method: 'GET',
|
|
216
|
+
path: '/:id',
|
|
217
|
+
desc: 'Get a user by id',
|
|
218
|
+
params: z.object({ id: z.string() }),
|
|
219
|
+
output: UserSchema,
|
|
220
|
+
},
|
|
221
|
+
})
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
### Fields
|
|
225
|
+
|
|
226
|
+
| Field | Required | Purpose |
|
|
227
|
+
|-------|----------|---------|
|
|
228
|
+
| `method` | yes | `GET` · `POST` · `PUT` · `PATCH` · `DELETE` |
|
|
229
|
+
| `path` | yes | route path under the contract `prefix`; `:name` marks a path param |
|
|
230
|
+
| `desc` | yes | human description — also the MCP / agent tool description |
|
|
231
|
+
| `params` | no | Zod schema for **path params** (`:id`, …) |
|
|
232
|
+
| `input` | no | Zod schema for the **request body** (or query, for GET/DELETE) |
|
|
233
|
+
| `output` | no | Zod schema for the **response body** |
|
|
234
|
+
| `scope` | no | access scope for this endpoint — see [Auth & errors](./auth-and-errors.md) |
|
|
235
|
+
| `expose` | no | which transports carry this endpoint — see [below](#transports) |
|
|
236
|
+
| `toolName` | no | explicit MCP / agent tool name (defaults to `prefix_key`) |
|
|
237
|
+
| `multipart` | no | field name of a file upload — see [below](#file-uploads) |
|
|
238
|
+
| `timeout` | no | per-endpoint client timeout in ms, for slow endpoints |
|
|
239
|
+
| `meta` | no | opaque app metadata — read in hooks / on tool mounts, never in OpenAPI ([below](#endpoint-metadata-meta)) |
|
|
240
|
+
|
|
241
|
+
## `params` vs `input` vs `output`
|
|
242
|
+
|
|
243
|
+
The three schemas are distinct on purpose:
|
|
244
|
+
|
|
245
|
+
- **`params`** — values in the URL path. `path: '/:id'` ⇒
|
|
246
|
+
`params: z.object({ id: z.string() })`. The client takes them from the call
|
|
247
|
+
argument and substitutes them into the URL.
|
|
248
|
+
- **`input`** — the request payload. For `POST` / `PUT` / `PATCH` it is the JSON
|
|
249
|
+
body; for `GET` / `DELETE` it is the query string. The handler reads it as
|
|
250
|
+
`ctx.input`.
|
|
251
|
+
- **`output`** — the response shape. When set, the client parses the response
|
|
252
|
+
through it; the handler's return value is type-checked against it.
|
|
253
|
+
|
|
254
|
+
The typed client merges `params` and `input` into one argument object — the
|
|
255
|
+
caller passes a single flat object, the client routes each field to the path or
|
|
256
|
+
the body.
|
|
257
|
+
|
|
258
|
+
```ts
|
|
259
|
+
// path: '/:id', params: { id }, input: { text }
|
|
260
|
+
await api.update({ id: '1', text: 'new' }) // PUT /users/1 body: { text: 'new' }
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
### Input vs. output types
|
|
264
|
+
|
|
265
|
+
For the client, an endpoint's argument type is the schema's **input** type
|
|
266
|
+
(pre-parse). A field with `.default()` is therefore optional for the caller but
|
|
267
|
+
present (required) in the handler's parsed `ctx.input`. The contract handles the
|
|
268
|
+
two type views; you do not.
|
|
269
|
+
|
|
270
|
+
## Transports
|
|
271
|
+
|
|
272
|
+
By default an endpoint is exposed on **every** surface — HTTP, MCP and agent
|
|
273
|
+
tools. Narrow it with `expose`:
|
|
274
|
+
|
|
275
|
+
```ts
|
|
276
|
+
{
|
|
277
|
+
method: 'POST', path: '/', desc: 'Internal sync',
|
|
278
|
+
expose: ['HTTP'], // HTTP only — not an MCP or agent tool
|
|
279
|
+
}
|
|
280
|
+
{
|
|
281
|
+
method: 'GET', path: '/search', desc: 'Search the catalog',
|
|
282
|
+
expose: ['HTTP', 'MCP', 'AGENT'], // explicit — all three
|
|
283
|
+
}
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
- `expose: ['HTTP']` — HTTP only. The endpoint never becomes a tool.
|
|
287
|
+
- `expose: ['MCP', 'AGENT']` — a tool only; no HTTP route.
|
|
288
|
+
- omit `expose` — all transports.
|
|
289
|
+
|
|
290
|
+
Tool transports (`MCP`, `AGENT`) skip `multipart` endpoints automatically — a
|
|
291
|
+
file upload is not a tool call.
|
|
292
|
+
|
|
293
|
+
## `toolName`
|
|
294
|
+
|
|
295
|
+
When an endpoint is exposed as a tool, its name defaults to `prefix_key`
|
|
296
|
+
(`users` + `create` ⇒ `users_create`). Set `toolName` for an explicit, stable
|
|
297
|
+
name:
|
|
298
|
+
|
|
299
|
+
```ts
|
|
300
|
+
{ method: 'POST', path: '/', desc: 'Create a user', toolName: 'create_user', /* … */ }
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
## Endpoint metadata (`meta`)
|
|
304
|
+
|
|
305
|
+
`meta` is an **opaque, app-defined** bag the core attaches no meaning to — the
|
|
306
|
+
same escape-hatch spirit as `scope` being a free string ([ADR 0002](../decisions/0002-generic-core.md) /
|
|
307
|
+
[ADR 0021](../decisions/0021-endpoint-meta-passthrough.md)). Declare app concerns
|
|
308
|
+
the generic core does not model — a feature gate, a rate tier, a cache hint, a
|
|
309
|
+
doc/owner tag — right next to the endpoint:
|
|
310
|
+
|
|
311
|
+
```ts
|
|
312
|
+
broadcast: {
|
|
313
|
+
method: 'POST', path: '/broadcast', desc: 'Send a broadcast',
|
|
314
|
+
input: BroadcastInput, output: Broadcast,
|
|
315
|
+
meta: { requiredFeature: 'broadcasts' }, // opaque to the core
|
|
316
|
+
}
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
It rides through to `MethodDef.meta`, readable in lifecycle hooks (the second
|
|
320
|
+
argument is the endpoint) and on tool mounts. The consumer narrows the type when
|
|
321
|
+
reading:
|
|
322
|
+
|
|
323
|
+
```ts
|
|
324
|
+
beforeHandle: (ctx, endpoint) => {
|
|
325
|
+
const feature = endpoint.meta?.requiredFeature
|
|
326
|
+
if (typeof feature === 'string' && !ctx.user?.features?.includes(feature)) {
|
|
327
|
+
throw forbidden('feature not enabled')
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
`meta` is **app-private** — it is never serialized into the OpenAPI document.
|
|
333
|
+
|
|
334
|
+
> **Declare a meta type as a `type`, an inline literal, or with `satisfies` — not
|
|
335
|
+
> an `interface`.** A TS `interface` has no implicit index signature (it can be
|
|
336
|
+
> augmented by declaration merging), so it is not assignable to `meta`'s
|
|
337
|
+
> `Record<string, unknown>` — and on the overloaded `defineContract` the error
|
|
338
|
+
> misleadingly surfaces as a `scope` mismatch. Use
|
|
339
|
+
> `type EndpointMeta = { requiredFeature?: PlanFeature }`, or
|
|
340
|
+
> `meta: { requiredFeature: 'x' } satisfies EndpointMeta`. The read side is
|
|
341
|
+
> unchanged — `endpoint.meta?.x` is `unknown`, narrow it in the hook.
|
|
342
|
+
|
|
343
|
+
## File uploads
|
|
344
|
+
|
|
345
|
+
`multipart` names the form field carrying a file. The handler receives it as
|
|
346
|
+
`ctx.file`:
|
|
347
|
+
|
|
348
|
+
```ts
|
|
349
|
+
upload: {
|
|
350
|
+
method: 'POST',
|
|
351
|
+
path: '/avatar',
|
|
352
|
+
desc: 'Upload an avatar',
|
|
353
|
+
multipart: 'file',
|
|
354
|
+
output: z.object({ url: z.string() }),
|
|
355
|
+
}
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
The client sends a `multipart/form-data` request; the field value must be a
|
|
359
|
+
`Blob`. See [HTTP server → multipart](./server.md#multipart).
|
|
360
|
+
|
|
361
|
+
## Pagination
|
|
362
|
+
|
|
363
|
+
Every list endpoint should return the cursor envelope — one shape, one infinite-
|
|
364
|
+
query helper:
|
|
365
|
+
|
|
366
|
+
```ts
|
|
367
|
+
import { paginatedSchema } from 'stitchkit'
|
|
368
|
+
|
|
369
|
+
feed: {
|
|
370
|
+
method: 'GET',
|
|
371
|
+
path: '/',
|
|
372
|
+
desc: 'Paginated feed',
|
|
373
|
+
input: z.object({ limit: z.coerce.number().default(20) }),
|
|
374
|
+
output: paginatedSchema(PostSchema), // { items: Post[], nextCursor: string | null }
|
|
375
|
+
}
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
`paginatedSchema(itemSchema)` produces `{ items, nextCursor }`. The page size
|
|
379
|
+
default lives in the contract (`limit`'s `.default()`), never on the client —
|
|
380
|
+
the two cannot diverge. The client side is [`createCursorQuery`](./client.md#cursor-pagination).
|
|
381
|
+
|
|
382
|
+
The **format** of `nextCursor` is the server's choice — keep it opaque. For the
|
|
383
|
+
usual keyset cursor (resume after the last row's `(sortValue, id)`), encode it
|
|
384
|
+
with **`encodeCursor`** and read it back with **`decodeCursor`** (Zod-validated;
|
|
385
|
+
a missing or garbage cursor decodes to `null`, i.e. "start from the top"):
|
|
386
|
+
|
|
387
|
+
```ts
|
|
388
|
+
import { encodeCursor, decodeCursor } from 'stitchkit'
|
|
389
|
+
|
|
390
|
+
const Cursor = z.object({ v: z.string(), id: z.string() }) // your keyset shape
|
|
391
|
+
|
|
392
|
+
const after = decodeCursor(ctx.input.cursor, Cursor) // { v, id } | null
|
|
393
|
+
const rows = await db.list({ after, take: limit + 1 }) // your keyset WHERE
|
|
394
|
+
const nextCursor =
|
|
395
|
+
rows.length > limit ? encodeCursor({ v: last.createdAt, id: last.id }) : null
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
The codec is base64url over UTF-8 (`btoa`/`atob`, not Node `Buffer`) — server,
|
|
399
|
+
client and browser safe, and a non-ASCII sort value round-trips. The keyset
|
|
400
|
+
WHERE clause is yours (it's ORM-specific); stitchkit only carries the string.
|
|
401
|
+
|
|
402
|
+
## Scope
|
|
403
|
+
|
|
404
|
+
An endpoint may carry a `scope` string; the contract `meta.scope` is the default
|
|
405
|
+
for every endpoint that declares none. Scopes are free strings — the framework
|
|
406
|
+
attaches no meaning, your auth hook does. See
|
|
407
|
+
[Auth & errors](./auth-and-errors.md).
|
|
408
|
+
|
|
409
|
+
## One source of truth
|
|
410
|
+
|
|
411
|
+
A contract is plain data — no classes, no decorators, no codegen. It is imported
|
|
412
|
+
by the server (to `implement`), by the client (to `createClient`) and by the
|
|
413
|
+
tool layer (to `mountMcp` / `mountAgent`). Change an endpoint and every surface
|
|
414
|
+
is re-typed by the compiler at once.
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
==============================================================================
|
|
418
|
+
# Guide: HTTP server (docs/guide/server.md)
|
|
419
|
+
==============================================================================
|
|
420
|
+
|
|
421
|
+
# HTTP server
|
|
422
|
+
|
|
423
|
+
stitchkit serves contracts on `Bun.serve()` directly — no Hono, no Elysia, no
|
|
424
|
+
Express. You bind a contract to handlers with `implement()`, then mount the
|
|
425
|
+
result on `createServer()`.
|
|
426
|
+
|
|
427
|
+
## `implement`
|
|
428
|
+
|
|
429
|
+
`implement(contract, handlers)` type-checks each handler against its endpoint's
|
|
430
|
+
schemas and returns a `ServiceDef` to mount.
|
|
431
|
+
|
|
432
|
+
```ts
|
|
433
|
+
import { implement } from 'stitchkit/server'
|
|
434
|
+
import { users } from '../shared/contracts'
|
|
435
|
+
|
|
436
|
+
const usersService = implement(users, {
|
|
437
|
+
list: () => db.users.findMany(),
|
|
438
|
+
create: (ctx) => db.users.create(ctx.input), // ctx.input: typed
|
|
439
|
+
get: (ctx) => db.users.findById(ctx.params.id), // ctx.params: typed
|
|
440
|
+
delete: (ctx) => db.users.delete(ctx.params.id),
|
|
441
|
+
})
|
|
442
|
+
```
|
|
443
|
+
|
|
444
|
+
A handler may be sync or async. Its return value is checked against the
|
|
445
|
+
endpoint's `output` schema; an endpoint without `output` returns nothing.
|
|
446
|
+
|
|
447
|
+
### The handler context
|
|
448
|
+
|
|
449
|
+
Every handler receives one `ctx` argument:
|
|
450
|
+
|
|
451
|
+
| `ctx` field | Type | Source |
|
|
452
|
+
|-------------|------|--------|
|
|
453
|
+
| `params` | inferred from `params` schema | parsed path params |
|
|
454
|
+
| `input` | inferred from `input` schema | parsed body / query |
|
|
455
|
+
| `file` | `File` | the `multipart` upload, if any |
|
|
456
|
+
| `source` | `'http' \| 'mcp' \| 'agent'` | the transport that invoked the handler |
|
|
457
|
+
| `traceId` | `string` | per-request trace id |
|
|
458
|
+
| `ipAddress` | `string` | caller IP |
|
|
459
|
+
| `userAgent` | `string` | caller user-agent |
|
|
460
|
+
|
|
461
|
+
The same handler runs for HTTP, MCP and agent calls — `ctx.source` tells you
|
|
462
|
+
which. Anything an auth hook attaches (e.g. the resolved user) is also on `ctx`.
|
|
463
|
+
|
|
464
|
+
### `createImplement` — a fixed context type
|
|
465
|
+
|
|
466
|
+
To type `ctx` with your app's extras (the user an auth hook injects), fix the
|
|
467
|
+
context type once:
|
|
468
|
+
|
|
469
|
+
```ts
|
|
470
|
+
import { createImplement } from 'stitchkit/server'
|
|
471
|
+
|
|
472
|
+
interface AppContext extends RuntimeContext { user: User | null }
|
|
473
|
+
|
|
474
|
+
export const implement = createImplement<AppContext>()
|
|
475
|
+
// every implement() call now has ctx.user typed
|
|
476
|
+
```
|
|
477
|
+
|
|
478
|
+
## `createServer`
|
|
479
|
+
|
|
480
|
+
`createServer(config)` builds the router and starts `Bun.serve()`. It returns
|
|
481
|
+
the Bun server instance.
|
|
482
|
+
|
|
483
|
+
```ts
|
|
484
|
+
import { createServer } from 'stitchkit/server'
|
|
485
|
+
|
|
486
|
+
createServer({
|
|
487
|
+
services: [usersService, postsService],
|
|
488
|
+
port: 3000,
|
|
489
|
+
cors: { origin: 'https://app.example.com' },
|
|
490
|
+
hooks: { /* … */ },
|
|
491
|
+
logging: true,
|
|
492
|
+
})
|
|
493
|
+
```
|
|
494
|
+
|
|
495
|
+
`createHandler(config)` is the same router as a bare `(req) => Promise<Response>`
|
|
496
|
+
function — no `Bun.serve`. Use it in tests, or to embed stitchkit in another
|
|
497
|
+
server. See [Testing & deployment](./testing-and-deployment.md).
|
|
498
|
+
|
|
499
|
+
### `ServerConfig`
|
|
500
|
+
|
|
501
|
+
| Field | Purpose |
|
|
502
|
+
|-------|---------|
|
|
503
|
+
| `services` | `ServiceDef[]` mounted at the root |
|
|
504
|
+
| `groups` | route groups — a shared path prefix and hooks (see below) |
|
|
505
|
+
| `scopePrefixes` | `scope → path prefix` map — mount `services` by `service.scope` (see below) |
|
|
506
|
+
| `rawRoutes` | non-contract routes (see below) |
|
|
507
|
+
| `maxUploadBytes` | default multipart upload cap (bytes); per-route `EndpointDef.maxUploadBytes` overrides |
|
|
508
|
+
| `port` / `hostname` | listen address — port defaults to `3000` |
|
|
509
|
+
| `cors` | CORS policy — `{ origin, … }` |
|
|
510
|
+
| `hooks` | lifecycle hooks (see below) |
|
|
511
|
+
| `logging` | `true` for built-in request logs, or a custom `StitchLogger` |
|
|
512
|
+
| `traceId` | override per-request trace-id resolution |
|
|
513
|
+
| `websocket` | Bun WebSocket handlers — e.g. from `createSocketIOServer` |
|
|
514
|
+
| `routes` / `development` / `bun` | passthrough to `Bun.serve` |
|
|
515
|
+
|
|
516
|
+
## Route groups
|
|
517
|
+
|
|
518
|
+
A group gives a set of services a shared path prefix and its own hooks:
|
|
519
|
+
|
|
520
|
+
```ts
|
|
521
|
+
createServer({
|
|
522
|
+
groups: [
|
|
523
|
+
{ pathPrefix: '/api', services: [usersService, postsService] },
|
|
524
|
+
{ pathPrefix: '/api/admin', services: [adminService], hooks: { beforeHandle: adminAuth } },
|
|
525
|
+
],
|
|
526
|
+
})
|
|
527
|
+
```
|
|
528
|
+
|
|
529
|
+
Each service's own `prefix` is appended to the group prefix — `usersService`
|
|
530
|
+
above is served at `/api/users`.
|
|
531
|
+
|
|
532
|
+
### Param prefixes (resource-scoped paths)
|
|
533
|
+
|
|
534
|
+
A group `pathPrefix` may contain `:param` segments — the spine of a multi-tenant
|
|
535
|
+
or resource-scoped API:
|
|
536
|
+
|
|
537
|
+
```ts
|
|
538
|
+
createServer({
|
|
539
|
+
groups: [
|
|
540
|
+
{ pathPrefix: '/tenants/:tenantId', services: [widgetsService], hooks: { beforeHandle: auth } },
|
|
541
|
+
],
|
|
542
|
+
})
|
|
543
|
+
// widgetsService (prefix 'widgets') → /tenants/:tenantId/widgets/...
|
|
544
|
+
```
|
|
545
|
+
|
|
546
|
+
**Where the prefix param lands.** The router matches the *full* path (group
|
|
547
|
+
prefix + service prefix + endpoint path) and collects every `:param` — from the
|
|
548
|
+
prefix and from the endpoint alike — into one set. Each is spread onto the
|
|
549
|
+
context root, so it is available as **`ctx.tenantId`** (a raw `string`) in both
|
|
550
|
+
the handler and `beforeHandle`/`afterHandle`/`onError`:
|
|
551
|
+
|
|
552
|
+
```ts
|
|
553
|
+
beforeHandle: (ctx) => {
|
|
554
|
+
const tenantId = ctx.tenantId // string — from the group prefix
|
|
555
|
+
}
|
|
556
|
+
```
|
|
557
|
+
|
|
558
|
+
`ctx.tenantId` is typed `unknown` (it rides the context index signature) — narrow
|
|
559
|
+
it (`String(ctx.tenantId)` / a guard) at the read site.
|
|
560
|
+
|
|
561
|
+
**Relation to the endpoint `params` schema.** `ctx.params` is the endpoint's
|
|
562
|
+
`params` schema **parsed against all collected path params** (prefix + endpoint).
|
|
563
|
+
So to get the prefix param *inside* typed `ctx.params`, add it to that schema:
|
|
564
|
+
|
|
565
|
+
```ts
|
|
566
|
+
// endpoint under /tenants/:tenantId
|
|
567
|
+
{ method: 'GET', path: '/:widgetId', desc: 'Get a widget',
|
|
568
|
+
params: z.object({ tenantId: z.string(), widgetId: z.string() }) }
|
|
569
|
+
// → ctx.params.tenantId and ctx.params.widgetId both typed
|
|
570
|
+
```
|
|
571
|
+
|
|
572
|
+
⚠️ A **`z.strictObject`** params schema that omits the prefix param **rejects the
|
|
573
|
+
request** (the extra `tenantId` key fails the strict parse). Either include every
|
|
574
|
+
prefix param in the schema, use a non-strict `z.object` (extra keys are dropped
|
|
575
|
+
from `ctx.params`, but `ctx.tenantId` still works), or read the param off the
|
|
576
|
+
context root.
|
|
577
|
+
|
|
578
|
+
### Scope-driven mounting (`scopePrefixes`)
|
|
579
|
+
|
|
580
|
+
With several scopes, hand-partitioning services into `groups` duplicates the
|
|
581
|
+
scope↔prefix mapping. Instead, map `scope → prefix` once and pass the flat
|
|
582
|
+
`services` list — each entry mounts under `scopePrefixes[service.scope]`:
|
|
583
|
+
|
|
584
|
+
```ts
|
|
585
|
+
createServer({
|
|
586
|
+
services, // mixed scopes, listed once
|
|
587
|
+
scopePrefixes: { tenant: 'tenants/:tenantId', project: 'projects/:projectId' },
|
|
588
|
+
})
|
|
589
|
+
// scope 'tenant' → /tenants/:tenantId/<prefix>/...
|
|
590
|
+
// scope 'project' → /projects/:projectId/<prefix>/...
|
|
591
|
+
// unmapped scope → mounted flat
|
|
592
|
+
```
|
|
593
|
+
|
|
594
|
+
A prefix may carry `:param` segments (they land on the context exactly as above).
|
|
595
|
+
Services listed under explicit `groups` are unaffected — the group prefix wins.
|
|
596
|
+
Scope stays a free string; the core attaches no meaning beyond this lookup
|
|
597
|
+
(→ ADR 0024). When each scope needs a different handler-context shape, declare one
|
|
598
|
+
`createImplement<Ctx>()` per scope rather than a single superset context.
|
|
599
|
+
|
|
600
|
+
## Lifecycle hooks
|
|
601
|
+
|
|
602
|
+
Four hooks wrap every contract request, in order:
|
|
603
|
+
|
|
604
|
+
```ts
|
|
605
|
+
createServer({
|
|
606
|
+
services,
|
|
607
|
+
hooks: {
|
|
608
|
+
onRequest(req) { /* logging, global rate limit — may return a Response to short-circuit */ },
|
|
609
|
+
beforeHandle(ctx, endpoint) { /* auth, scope checks — throw to reject */ },
|
|
610
|
+
afterHandle(ctx, result, ep) { /* transform the result, set cache headers */ },
|
|
611
|
+
onError(ctx, error, ep) { /* custom error response — return a Response */ },
|
|
612
|
+
},
|
|
613
|
+
})
|
|
614
|
+
```
|
|
615
|
+
|
|
616
|
+
- **`onRequest`** — runs first, with the raw `Request`. Return a `Response` to
|
|
617
|
+
short-circuit (a rate-limit 429, a redirect); return nothing to continue.
|
|
618
|
+
- **`beforeHandle`** — runs after the context is built, before the handler.
|
|
619
|
+
Throw an `AppError` to reject. This is where auth lives —
|
|
620
|
+
[`createAuthHook`](./auth-and-errors.md#createauthhook) is a `beforeHandle`.
|
|
621
|
+
- **`afterHandle`** — receives the handler result; return a replacement to
|
|
622
|
+
transform it.
|
|
623
|
+
- **`onError`** — receives any thrown error; return a `Response` to customise
|
|
624
|
+
the error body. Without it, errors render through the standard envelope.
|
|
625
|
+
|
|
626
|
+
Hooks see `RuntimeContext` (loose types); handlers see `HandlerContext` (typed).
|
|
627
|
+
That split is deliberate — see [ADR 0003](../decisions/0003-two-context-types.md).
|
|
628
|
+
|
|
629
|
+
## Raw routes
|
|
630
|
+
|
|
631
|
+
Some routes cannot be a clean JSON contract — an OAuth redirect, a webhook with
|
|
632
|
+
signature verification, static files, the Socket.IO endpoint. `rawRoutes` are
|
|
633
|
+
plain `Request → Response` handlers, matched by the same router (shared CORS and
|
|
634
|
+
`onRequest`) but with no schema parsing and no `beforeHandle` gate — a raw route
|
|
635
|
+
authorises itself.
|
|
636
|
+
|
|
637
|
+
```ts
|
|
638
|
+
createServer({
|
|
639
|
+
services,
|
|
640
|
+
rawRoutes: [
|
|
641
|
+
{
|
|
642
|
+
method: 'GET',
|
|
643
|
+
path: '/health',
|
|
644
|
+
handler: () => Response.json({ status: 'ok' }),
|
|
645
|
+
},
|
|
646
|
+
{
|
|
647
|
+
method: 'POST',
|
|
648
|
+
path: '/webhooks/:provider',
|
|
649
|
+
handler: (req, ctx) => handleWebhook(ctx.params.provider, req),
|
|
650
|
+
},
|
|
651
|
+
],
|
|
652
|
+
})
|
|
653
|
+
```
|
|
654
|
+
|
|
655
|
+
A path may be exact, carry `:param` segments, or end in `/*` for a prefix
|
|
656
|
+
wildcard — and the two combine: `/app/:slug/*` matches `/app/x/a/b` with
|
|
657
|
+
`ctx.params.slug === 'x'` and the remainder in `ctx.params['*']` (a SPA
|
|
658
|
+
deep-link fallback). List more specific routes before the wildcard — the first
|
|
659
|
+
match wins. `staticRoute()` builds a raw route that serves a directory.
|
|
660
|
+
|
|
661
|
+
### Raw-route helpers
|
|
662
|
+
|
|
663
|
+
A raw route gives up the contract pipeline, so three things get re-implemented in
|
|
664
|
+
every one. `stitchkit/server` ships them — conveniences, not a second pipeline
|
|
665
|
+
(no auth, no schema gate beyond `parseBody`):
|
|
666
|
+
|
|
667
|
+
```ts
|
|
668
|
+
import { respondJson, errorResponse, parseBody, badRequest } from 'stitchkit/server'
|
|
669
|
+
|
|
670
|
+
handler: async (req) => {
|
|
671
|
+
try {
|
|
672
|
+
const body = await parseBody(req, MySchema) // typed value, or null (no throw)
|
|
673
|
+
if (!body) throw badRequest('invalid body') // helpers THROW an AppError
|
|
674
|
+
return respondJson(await myService(body)) // JSON; 204 when null/undefined
|
|
675
|
+
} catch (err) {
|
|
676
|
+
return errorResponse(err) // same envelope as a contract route
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
```
|
|
680
|
+
|
|
681
|
+
The error helpers (`badRequest`, `notFound`, …) **throw** an `AppError`, so raise
|
|
682
|
+
them inside the `try` and let `errorResponse(err)` render it — that runs any
|
|
683
|
+
thrown value through the framework's `normalizeError`, so a raw route returns the
|
|
684
|
+
**identical** `{ error: { code, message, … } }` shape (and `x-request-id`) a
|
|
685
|
+
contract route does.
|
|
686
|
+
|
|
687
|
+
### Serving files & Range requests
|
|
688
|
+
|
|
689
|
+
`staticRoute` is basic on purpose — it reads the whole file into memory and has
|
|
690
|
+
no `Range` or conditional support; put a CDN in front, or use it for small web
|
|
691
|
+
assets. To serve **media** (video / audio / large downloads) that a browser must
|
|
692
|
+
seek and cache, use **`serveFile`** (Bun) — it streams the requested byte range
|
|
693
|
+
and speaks the conditional-request half of RFC 7233 / 9110:
|
|
694
|
+
|
|
695
|
+
```ts
|
|
696
|
+
import { serveFile } from 'stitchkit/server'
|
|
697
|
+
|
|
698
|
+
createServer({
|
|
699
|
+
services,
|
|
700
|
+
rawRoutes: [
|
|
701
|
+
{
|
|
702
|
+
// `ALL` — so a HEAD probe also reaches serveFile (raw routes match the
|
|
703
|
+
// method exactly, and `HEAD` is not a contract `HttpMethod`); serveFile
|
|
704
|
+
// itself handles GET + HEAD and answers 405 for anything else.
|
|
705
|
+
method: 'ALL',
|
|
706
|
+
path: '/media/:id',
|
|
707
|
+
handler: (req, ctx) =>
|
|
708
|
+
serveFile(req, { path: pathForId(ctx.params.id), filename: 'clip.mp4' }),
|
|
709
|
+
},
|
|
710
|
+
],
|
|
711
|
+
})
|
|
712
|
+
```
|
|
713
|
+
|
|
714
|
+
`serveFile` returns `206` (range, with `Content-Range` + `Content-Length`), `200`
|
|
715
|
+
(full), `416` (unsatisfiable, `Content-Range: bytes */size`), `304`
|
|
716
|
+
(`If-None-Match` / `If-Modified-Since`), `404` (missing) or `405` (non GET/HEAD).
|
|
717
|
+
It always sets `Accept-Ranges: bytes`, a weak `ETag` and `Last-Modified` (so
|
|
718
|
+
`If-Range` keeps a changing file from being stitched from stale + fresh bytes),
|
|
719
|
+
and `nosniff`. `Content-Type` is auto-detected from the path — override it, or
|
|
720
|
+
pass `disposition` / `cacheControl` / `etag: false`, via the options.
|
|
721
|
+
|
|
722
|
+
`serveFile` takes an explicit `path` and trusts it — **the caller owns
|
|
723
|
+
containment**. For a URL-derived path use `staticRoute` (which enforces it) or
|
|
724
|
+
`isWithinDir` first. The byte-range parser is exported on its own as
|
|
725
|
+
`parseByteRange(header, size)` for direct use and testing. → ADR 0023.
|
|
726
|
+
|
|
727
|
+
## Server primitives
|
|
728
|
+
|
|
729
|
+
`stitchkit/server` also exports the primitives most APIs need. Each is a small,
|
|
730
|
+
focused helper — not a sub-framework.
|
|
731
|
+
|
|
732
|
+
| Helper | Does |
|
|
733
|
+
|--------|------|
|
|
734
|
+
| `serveFile()` | serve a file with `Range` / `304` / `HEAD` (media seeking) |
|
|
735
|
+
| `streamSSE()` | turn an `AsyncGenerator` into a Server-Sent-Events `Response` |
|
|
736
|
+
| `parseMultipart()` | parse a `multipart/form-data` request with a size cap |
|
|
737
|
+
| `createRateLimiter()` | per-key token-bucket rate limiting |
|
|
738
|
+
| `createCache()` + `cacheHeaders()` | in-memory TTL cache; `Cache-Control` builder |
|
|
739
|
+
| `createEventBus<EventMap>()` | typed in-process pub/sub |
|
|
740
|
+
|
|
741
|
+
### SSE streaming
|
|
742
|
+
|
|
743
|
+
```ts
|
|
744
|
+
import { streamSSE } from 'stitchkit/server'
|
|
745
|
+
|
|
746
|
+
async function* tokens() { yield 'a'; yield 'b' }
|
|
747
|
+
return streamSSE(tokens()) // → a text/event-stream Response
|
|
748
|
+
```
|
|
749
|
+
|
|
750
|
+
The client side is [`parseSSE`](./client.md#sse).
|
|
751
|
+
|
|
752
|
+
### Multipart
|
|
753
|
+
|
|
754
|
+
```ts
|
|
755
|
+
import { parseMultipart } from 'stitchkit/server'
|
|
756
|
+
|
|
757
|
+
const { file, fields } = await parseMultipart(req, { maxBytes: 10_000_000 })
|
|
758
|
+
```
|
|
759
|
+
|
|
760
|
+
When an endpoint declares `multipart`, the framework parses the upload for you
|
|
761
|
+
and the file arrives as `ctx.file` — call `parseMultipart` directly only from a
|
|
762
|
+
raw route.
|
|
763
|
+
|
|
764
|
+
The upload cap defaults to **25 MB**. Raise it per route with
|
|
765
|
+
`EndpointDef.maxUploadBytes`, or set a server-wide default with
|
|
766
|
+
`createServer({ maxUploadBytes })` — a per-route value wins over the global:
|
|
767
|
+
|
|
768
|
+
```ts
|
|
769
|
+
// contract
|
|
770
|
+
upload: { method: 'POST', path: '/', desc: 'Upload a video',
|
|
771
|
+
multipart: 'file', maxUploadBytes: 200 * 1024 * 1024 }
|
|
772
|
+
|
|
773
|
+
// server — default for every multipart route that declares no own cap
|
|
774
|
+
createServer({ services, maxUploadBytes: 50 * 1024 * 1024 })
|
|
775
|
+
```
|
|
776
|
+
|
|
777
|
+
### Rate limiting
|
|
778
|
+
|
|
779
|
+
```ts
|
|
780
|
+
import { createRateLimiter } from 'stitchkit/server'
|
|
781
|
+
|
|
782
|
+
const limiter = createRateLimiter({ capacity: 60, refillPerSecond: 1 })
|
|
783
|
+
// in onRequest: if (!limiter.take(ip)) return new Response('Too many', { status: 429 })
|
|
784
|
+
```
|
|
785
|
+
|
|
786
|
+
### Event bus
|
|
787
|
+
|
|
788
|
+
```ts
|
|
789
|
+
import { createEventBus } from 'stitchkit/server'
|
|
790
|
+
|
|
791
|
+
const bus = createEventBus<{ 'user.created': { id: string } }>()
|
|
792
|
+
bus.on('user.created', ({ id }) => sendWelcome(id))
|
|
793
|
+
bus.emit('user.created', { id: '1' })
|
|
794
|
+
```
|
|
795
|
+
|
|
796
|
+
A typed in-process pub/sub — decouple a handler from the side effects of its
|
|
797
|
+
write without reaching for an external queue.
|
|
798
|
+
|
|
799
|
+
|
|
800
|
+
==============================================================================
|
|
801
|
+
# Guide: Typed client (docs/guide/client.md)
|
|
802
|
+
==============================================================================
|
|
803
|
+
|
|
804
|
+
# Typed client
|
|
805
|
+
|
|
806
|
+
From a contract, `createClient` builds a fully-typed client — one method per
|
|
807
|
+
endpoint, arguments and result inferred from the schemas. There is no codegen
|
|
808
|
+
step: the types come straight from the contract import.
|
|
809
|
+
|
|
810
|
+
## `createHttpClient`
|
|
811
|
+
|
|
812
|
+
The HTTP client is the transport. It wraps [`ky`](https://github.com/sindresorhus/ky)
|
|
813
|
+
and adds cookie auth, SSR cookie forwarding, error parsing into `ApiError`, a
|
|
814
|
+
`401 → unauthorized` event stream and safe transport retry.
|
|
815
|
+
|
|
816
|
+
```ts
|
|
817
|
+
import { createHttpClient } from 'stitchkit'
|
|
818
|
+
|
|
819
|
+
const http = createHttpClient({ baseUrl: '/api' })
|
|
820
|
+
```
|
|
821
|
+
|
|
822
|
+
### `HttpClientConfig`
|
|
823
|
+
|
|
824
|
+
| Field | Default | Purpose |
|
|
825
|
+
|-------|---------|---------|
|
|
826
|
+
| `baseUrl` | — | URL prefix for every request |
|
|
827
|
+
| `timeout` | `30000` | request timeout, ms |
|
|
828
|
+
| `credentials` | `'include'` | fetch credentials mode |
|
|
829
|
+
| `retry` | 2× GET, network errors only | transport retry policy |
|
|
830
|
+
| `headers` | — | extra headers — an object, or a function re-run per request |
|
|
831
|
+
| `authEndpoints` | `['auth/']` | paths that should **not** emit `unauthorized` on 401 |
|
|
832
|
+
| `parseError` | built-in | map an error body to `{ code, message, details, hint }` |
|
|
833
|
+
|
|
834
|
+
`headers` as a function is the hook for runtime tokens — a bearer token or any
|
|
835
|
+
short-lived credential — re-evaluated on every request.
|
|
836
|
+
|
|
837
|
+
Retry is deliberately conservative: only a connection that never landed (a
|
|
838
|
+
network error), only on idempotent `GET`. A server that *responded* with a 5xx
|
|
839
|
+
is the data layer's call (TanStack Query), not the transport's — retrying in
|
|
840
|
+
both places multiplies attempts.
|
|
841
|
+
|
|
842
|
+
## `createClient`
|
|
843
|
+
|
|
844
|
+
```ts
|
|
845
|
+
import { createClient } from 'stitchkit'
|
|
846
|
+
import { users } from '../shared/contracts'
|
|
847
|
+
|
|
848
|
+
export const api = createClient(users, http)
|
|
849
|
+
|
|
850
|
+
await api.list() // GET /users
|
|
851
|
+
await api.create({ name: 'Max' }) // POST /users body: { name }
|
|
852
|
+
await api.get({ id: '1' }) // GET /users/1
|
|
853
|
+
await api.update({ id: '1', name: 'M' }) // PUT /users/1 body: { name }
|
|
854
|
+
await api.delete({ id: '1' }) // DELETE /users/1
|
|
855
|
+
```
|
|
856
|
+
|
|
857
|
+
Each call takes one argument object. The client routes each field by the
|
|
858
|
+
contract:
|
|
859
|
+
|
|
860
|
+
- a **path param** (`:id`) is substituted into the URL,
|
|
861
|
+
- for `GET` / `DELETE`, the remaining fields become the **query string**
|
|
862
|
+
(arrays become repeated keys),
|
|
863
|
+
- for `POST` / `PUT` / `PATCH`, they become the **JSON body**,
|
|
864
|
+
- a `multipart` field must be a `Blob` and is sent as `form-data`.
|
|
865
|
+
|
|
866
|
+
### Many contracts at once
|
|
867
|
+
|
|
868
|
+
```ts
|
|
869
|
+
import { createClients } from 'stitchkit'
|
|
870
|
+
|
|
871
|
+
export const api = createClients({ users, posts, billing }, http)
|
|
872
|
+
await api.users.list()
|
|
873
|
+
await api.posts.create({ title: 'Hi' })
|
|
874
|
+
```
|
|
875
|
+
|
|
876
|
+
`createClients` builds one typed client per contract from a registry — list the
|
|
877
|
+
contracts once, get the whole API typed.
|
|
878
|
+
|
|
879
|
+
## `ApiError`
|
|
880
|
+
|
|
881
|
+
A non-2xx response is thrown as an `ApiError`:
|
|
882
|
+
|
|
883
|
+
```ts
|
|
884
|
+
import { ApiError } from 'stitchkit'
|
|
885
|
+
|
|
886
|
+
try {
|
|
887
|
+
await api.get({ id: 'missing' })
|
|
888
|
+
} catch (err) {
|
|
889
|
+
if (ApiError.is(err)) {
|
|
890
|
+
err.code // 'NOT_FOUND'
|
|
891
|
+
err.status // 404
|
|
892
|
+
err.message // 'Note not found'
|
|
893
|
+
err.details // structured details, if any
|
|
894
|
+
err.hint // optional hint
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
```
|
|
898
|
+
|
|
899
|
+
The error model is shared with the server — see [Auth & errors](./auth-and-errors.md).
|
|
900
|
+
|
|
901
|
+
## Auth events
|
|
902
|
+
|
|
903
|
+
The HTTP client emits events your app can react to globally:
|
|
904
|
+
|
|
905
|
+
```ts
|
|
906
|
+
const unsubscribe = http.subscribe((event) => {
|
|
907
|
+
if (event.type === 'unauthorized') redirectToLogin() // a 401 outside authEndpoints
|
|
908
|
+
if (event.type === 'network_error') showOfflineBanner()
|
|
909
|
+
})
|
|
910
|
+
|
|
911
|
+
http.logout() // mark logged out — suppresses further unauthorized events
|
|
912
|
+
http.resetLogoutState() // clear it after a fresh login
|
|
913
|
+
```
|
|
914
|
+
|
|
915
|
+
## Server-side rendering
|
|
916
|
+
|
|
917
|
+
For SSR, forward the incoming request's cookies so the API call runs as the
|
|
918
|
+
logged-in user:
|
|
919
|
+
|
|
920
|
+
```ts
|
|
921
|
+
http.setServerContext(request.headers.get('cookie') ?? '')
|
|
922
|
+
```
|
|
923
|
+
|
|
924
|
+
## A bare fetch client
|
|
925
|
+
|
|
926
|
+
If you do not need cookie auth, retry or the event stream, pass a plain config
|
|
927
|
+
instead of an `HttpClient` — `createClient` then builds a minimal `fetch`-based
|
|
928
|
+
client:
|
|
929
|
+
|
|
930
|
+
```ts
|
|
931
|
+
const api = createClient(users, {
|
|
932
|
+
baseUrl: 'https://api.example.com',
|
|
933
|
+
headers: () => ({ Authorization: `Bearer ${token()}` }),
|
|
934
|
+
onError: (status, body) => console.warn(status, body),
|
|
935
|
+
})
|
|
936
|
+
```
|
|
937
|
+
|
|
938
|
+
## `ContractClientConfig` — per-tenant / resource-scoped clients
|
|
939
|
+
|
|
940
|
+
`createClient` takes an optional **third** argument that prepends a dynamic
|
|
941
|
+
segment to every URL — the client half of a multi-tenant API
|
|
942
|
+
([Route groups → param prefixes](./server.md#param-prefixes-resource-scoped-paths)):
|
|
943
|
+
|
|
944
|
+
```ts
|
|
945
|
+
interface ContractClientConfig {
|
|
946
|
+
/** Prepended to every request URL. A function is called per request with the
|
|
947
|
+
* call's argument object, so the prefix can depend on the arguments. */
|
|
948
|
+
pathPrefix?: string | ((args: Record<string, unknown>) => string)
|
|
949
|
+
/** Argument keys consumed by `pathPrefix` — stripped from the query/body so
|
|
950
|
+
* they are not also sent there (the endpoint's own path `:params` are
|
|
951
|
+
* stripped automatically; list any *extra* keys here). */
|
|
952
|
+
stripPrefixKeys?: string[]
|
|
953
|
+
}
|
|
954
|
+
```
|
|
955
|
+
|
|
956
|
+
A per-tenant client — `tenantId` goes into the URL, not the body:
|
|
957
|
+
|
|
958
|
+
```ts
|
|
959
|
+
const widgets = createClient(widgetsContract, http, {
|
|
960
|
+
pathPrefix: (args) => `tenants/${args.tenantId}/`,
|
|
961
|
+
stripPrefixKeys: ['tenantId'],
|
|
962
|
+
})
|
|
963
|
+
|
|
964
|
+
widgets.list({ tenantId: 't_123' }) // GET /tenants/t_123/widgets
|
|
965
|
+
widgets.create({ tenantId: 't_123', name: 'A' }) // POST /tenants/t_123/widgets body: { name }
|
|
966
|
+
```
|
|
967
|
+
|
|
968
|
+
"Keys it consumes" = a key the `pathPrefix` function reads (here `tenantId`).
|
|
969
|
+
List it in `stripPrefixKeys` so it lands in the URL **and is removed from the
|
|
970
|
+
query/body** — otherwise it would be sent twice. Endpoint path `:params` are
|
|
971
|
+
stripped for you; only extra prefix keys need listing.
|
|
972
|
+
|
|
973
|
+
## React data layer
|
|
974
|
+
|
|
975
|
+
stitchkit does not ship a hook engine. Pair the typed client with
|
|
976
|
+
[`react-query-kit`](https://github.com/liaoliao666/react-query-kit) — wrap the
|
|
977
|
+
client methods directly:
|
|
978
|
+
|
|
979
|
+
```ts
|
|
980
|
+
import { createQuery, createMutation } from 'react-query-kit'
|
|
981
|
+
import { api } from './api'
|
|
982
|
+
|
|
983
|
+
export const useUsers = createQuery({ queryKey: ['users'], fetcher: () => api.list() })
|
|
984
|
+
export const useCreateUser = createMutation({ mutationFn: api.create })
|
|
985
|
+
```
|
|
986
|
+
|
|
987
|
+
### Cursor pagination
|
|
988
|
+
|
|
989
|
+
For a cursor-paginated list, `createCursorQuery` is the canonical helper:
|
|
990
|
+
|
|
991
|
+
```ts
|
|
992
|
+
import { createCursorQuery } from 'stitchkit/react'
|
|
993
|
+
import { api } from './api'
|
|
994
|
+
|
|
995
|
+
export const useFeed = createCursorQuery({
|
|
996
|
+
queryKey: ['feed'],
|
|
997
|
+
endpoint: api.feed, // the contract method
|
|
998
|
+
})
|
|
999
|
+
```
|
|
1000
|
+
|
|
1001
|
+
It injects `cursor` from the page param and bakes in `getNextPageParam` /
|
|
1002
|
+
`initialPageParam` — an infinite hook is just `queryKey + endpoint`. The page
|
|
1003
|
+
size is the server's call (the contract's `limit` default); the client never
|
|
1004
|
+
sends one. The result keeps the full `react-query-kit` surface (`.getKey()`,
|
|
1005
|
+
`useSuspenseInfiniteQuery`, every option). The endpoint must return the
|
|
1006
|
+
`{ items, nextCursor }` envelope — see [Contracts → pagination](./contracts.md#pagination).
|
|
1007
|
+
|
|
1008
|
+
## SSE
|
|
1009
|
+
|
|
1010
|
+
For a streaming endpoint, consume the response with `parseSSE`:
|
|
1011
|
+
|
|
1012
|
+
```ts
|
|
1013
|
+
import { parseSSE } from 'stitchkit'
|
|
1014
|
+
|
|
1015
|
+
const res = await fetch('/api/chat/stream', { method: 'POST', body })
|
|
1016
|
+
for await (const event of parseSSE(res)) {
|
|
1017
|
+
console.log(event.data)
|
|
1018
|
+
}
|
|
1019
|
+
```
|
|
1020
|
+
|
|
1021
|
+
The server side is [`streamSSE`](./server.md#sse-streaming).
|
|
1022
|
+
|
|
1023
|
+
|
|
1024
|
+
==============================================================================
|
|
1025
|
+
# Guide: MCP & agents (docs/guide/mcp-and-agents.md)
|
|
1026
|
+
==============================================================================
|
|
1027
|
+
|
|
1028
|
+
# MCP & AI agents
|
|
1029
|
+
|
|
1030
|
+
The same contract that drives the HTTP API also drives AI tooling. An endpoint
|
|
1031
|
+
exposed on `MCP` becomes a [Model Context Protocol](https://modelcontextprotocol.io)
|
|
1032
|
+
tool — callable from Claude, Cursor and other MCP clients. An endpoint exposed on
|
|
1033
|
+
`AGENT` becomes a [Vercel AI SDK](https://sdk.vercel.ai) tool — callable from an
|
|
1034
|
+
agent loop. No tool is hand-written; both come from the contract.
|
|
1035
|
+
|
|
1036
|
+
## Which endpoints become tools
|
|
1037
|
+
|
|
1038
|
+
By default every endpoint is a tool on every transport. `expose` narrows it:
|
|
1039
|
+
|
|
1040
|
+
```ts
|
|
1041
|
+
{ method: 'GET', path: '/search', desc: 'Search the catalog' } // HTTP + MCP + AGENT
|
|
1042
|
+
{ method: 'POST', path: '/sync', desc: 'Internal sync', expose: ['HTTP'] } // HTTP only
|
|
1043
|
+
{ method: 'GET', path: '/lookup', desc: 'Look up a price', expose: ['MCP'] } // MCP tool only
|
|
1044
|
+
```
|
|
1045
|
+
|
|
1046
|
+
`desc` is the tool description the model reads — write it for the model, not
|
|
1047
|
+
just for a human. A `multipart` endpoint is never a tool. The tool name defaults
|
|
1048
|
+
to a verb-aware name from the method + prefix (`list` → `list_widgets`, `get` →
|
|
1049
|
+
`get_widget`); set `toolName` for an explicit one. See
|
|
1050
|
+
[Contracts → transports](./contracts.md#transports).
|
|
1051
|
+
|
|
1052
|
+
## MCP — `createMcpHandler`
|
|
1053
|
+
|
|
1054
|
+
`createMcpHandler` builds a complete Streamable-HTTP MCP server as a single
|
|
1055
|
+
`Request → Response` handler. It owns the whole MCP lifecycle — the SSE event
|
|
1056
|
+
store, per-session transports, the server instances — so your app never imports
|
|
1057
|
+
`@modelcontextprotocol/sdk` itself.
|
|
1058
|
+
|
|
1059
|
+
```ts
|
|
1060
|
+
import { createMcpHandler } from 'stitchkit/tools'
|
|
1061
|
+
|
|
1062
|
+
const handleMcp = createMcpHandler({
|
|
1063
|
+
serverInfo: { name: 'my-app', version: '1.0.0' },
|
|
1064
|
+
auth: (req) => resolveApiKey(req), // → an identity, or null for 401
|
|
1065
|
+
services: [usersService, catalogService],
|
|
1066
|
+
})
|
|
1067
|
+
```
|
|
1068
|
+
|
|
1069
|
+
Mount the returned handler on a raw route — typically `/mcp`:
|
|
1070
|
+
|
|
1071
|
+
```ts
|
|
1072
|
+
createServer({
|
|
1073
|
+
services,
|
|
1074
|
+
rawRoutes: [{ method: 'ALL', path: '/mcp', handler: (req) => handleMcp(req) }],
|
|
1075
|
+
})
|
|
1076
|
+
```
|
|
1077
|
+
|
|
1078
|
+
### `McpHandlerConfig`
|
|
1079
|
+
|
|
1080
|
+
| Field | Purpose |
|
|
1081
|
+
|-------|---------|
|
|
1082
|
+
| `serverInfo` | MCP server identity — `{ name, version }` |
|
|
1083
|
+
| `auth` | `(req) => identity \| null` — `null` rejects with 401 |
|
|
1084
|
+
| `services` | the services to expose — an array, or `(auth) => ServiceDef[]` |
|
|
1085
|
+
| `context` | `(auth) => {…}` — values merged into every tool handler's `ctx` |
|
|
1086
|
+
| `lifecycle` | `beforeHandle` / `afterHandle` — the tool-side auth gate (see below) |
|
|
1087
|
+
| `hooks` | tool-call observability hooks — `afterToolCall` fires on every result |
|
|
1088
|
+
| `onIncompatibleSchema` | `'throw'` (default) · `'skip'` · `'warn'` — see below |
|
|
1089
|
+
| `logger` | a `StitchLogger` for the `'warn'` policy |
|
|
1090
|
+
| `nativeTools` | register non-contract tools directly on the `McpServer` |
|
|
1091
|
+
| `instructions` | a short host-facing usage hint, surfaced to MCP tool-search |
|
|
1092
|
+
|
|
1093
|
+
`services` and `context` receive the resolved identity, so a tenant can be
|
|
1094
|
+
shown only its own tools and every handler can read `ctx.tenantId`.
|
|
1095
|
+
|
|
1096
|
+
### Guarding tools — `lifecycle`
|
|
1097
|
+
|
|
1098
|
+
A tool call runs the same handler an HTTP request would. `lifecycle` makes it
|
|
1099
|
+
run the same gate: a `beforeHandle` (throw to reject) and an `afterHandle`
|
|
1100
|
+
(transform the result) — the tool-side twin of `createServer`'s hooks. Pass the
|
|
1101
|
+
**same** [`createAuthHook`](./auth-and-errors.md#createauthhook) result you give
|
|
1102
|
+
the HTTP server and tool calls are scope-checked by the identical rules:
|
|
1103
|
+
|
|
1104
|
+
```ts
|
|
1105
|
+
createMcpHandler({ serverInfo, auth, services, lifecycle: { beforeHandle: authHook } })
|
|
1106
|
+
```
|
|
1107
|
+
|
|
1108
|
+
Without it, a tool call bypasses the HTTP `beforeHandle` — the contract's
|
|
1109
|
+
`scope` is not enforced on the MCP / agent surface. `mountMcp`, `mountAgent` and
|
|
1110
|
+
`buildMcpServer` take `lifecycle` too.
|
|
1111
|
+
|
|
1112
|
+
The observability `hooks` are symmetric with the HTTP side too: `beforeToolCall`
|
|
1113
|
+
and `afterToolCall` receive the resolved **`MethodDef`** as their last argument —
|
|
1114
|
+
the tool-side twin of `afterHandle(ctx, result, endpoint)`. Read
|
|
1115
|
+
`endpoint.serviceName` / `.key` / `.meta` directly for an audit row; you do not
|
|
1116
|
+
need to rebuild a `toolName → identity` map:
|
|
1117
|
+
|
|
1118
|
+
```ts
|
|
1119
|
+
hooks: {
|
|
1120
|
+
afterToolCall: (toolName, args, result, ms, ctx, endpoint) => {
|
|
1121
|
+
audit({ service: endpoint.serviceName, action: endpoint.key, ok: result.ok, ms })
|
|
1122
|
+
},
|
|
1123
|
+
}
|
|
1124
|
+
```
|
|
1125
|
+
|
|
1126
|
+
> **Tool-path identity.** A tool call has no `req`, so `createAuthHook` resolves
|
|
1127
|
+
> identity through `resolveFromContext`, not `resolve`. Set it (read the
|
|
1128
|
+
> identity your `auth` / `context` injected) — without it a scoped tool call
|
|
1129
|
+
> has no identity and **fails closed**. See
|
|
1130
|
+
> [Auth on the tool surface](./auth-and-errors.md#auth-on-the-tool-surface--resolvefromcontext).
|
|
1131
|
+
|
|
1132
|
+
### Incompatible schemas — `onIncompatibleSchema`
|
|
1133
|
+
|
|
1134
|
+
A contract schema that JSON Schema cannot represent (a `z.date()`, a `z.map()`)
|
|
1135
|
+
cannot become a tool. `onIncompatibleSchema` decides what happens:
|
|
1136
|
+
|
|
1137
|
+
- `'throw'` (default) — fail the build, listing every offending tool. A static
|
|
1138
|
+
`services` array is checked when `createMcpHandler` is constructed, so a bad
|
|
1139
|
+
schema fails the deploy, not the first request. Better than a tool that
|
|
1140
|
+
silently vanishes from the surface.
|
|
1141
|
+
- `'warn'` — log through `logger` and drop the tool.
|
|
1142
|
+
- `'skip'` — drop the tool silently.
|
|
1143
|
+
|
|
1144
|
+
`validateMcpSchemas(services)` runs the same check on its own — useful in a
|
|
1145
|
+
startup assertion or a test.
|
|
1146
|
+
|
|
1147
|
+
## `mountMcp`
|
|
1148
|
+
|
|
1149
|
+
If you already run an `McpServer` from the SDK, `mountMcp` adds contract tools
|
|
1150
|
+
to it instead of owning the lifecycle:
|
|
1151
|
+
|
|
1152
|
+
```ts
|
|
1153
|
+
import { mountMcp } from 'stitchkit/tools'
|
|
1154
|
+
|
|
1155
|
+
mountMcp(mcpServer, [usersService], { context: { source: 'mcp' } })
|
|
1156
|
+
```
|
|
1157
|
+
|
|
1158
|
+
`createMcpHandler` is the batteries-included path; `mountMcp` is the building
|
|
1159
|
+
block under it.
|
|
1160
|
+
|
|
1161
|
+
## MCP over stdio — `createStdioMcpServer`
|
|
1162
|
+
|
|
1163
|
+
`createMcpHandler` serves MCP over HTTP. `createStdioMcpServer` serves the same
|
|
1164
|
+
contract tools over **stdio** — the server runs as a subprocess of the MCP
|
|
1165
|
+
client (Claude Desktop, Claude Code, Cursor, Codex), on the user's machine, so
|
|
1166
|
+
it can reach the local filesystem.
|
|
1167
|
+
|
|
1168
|
+
```ts
|
|
1169
|
+
import { createStdioMcpServer } from 'stitchkit/tools'
|
|
1170
|
+
|
|
1171
|
+
await createStdioMcpServer({
|
|
1172
|
+
serverInfo: { name: 'my-app', version: '1.0.0' },
|
|
1173
|
+
auth: resolveIdentity(), // resolved once at startup, not per request
|
|
1174
|
+
services: [usersService],
|
|
1175
|
+
})
|
|
1176
|
+
```
|
|
1177
|
+
|
|
1178
|
+
A stdio server is a single process serving one client, so `auth` is a value (or
|
|
1179
|
+
a promise of one) resolved once at startup — typically from an env var — rather
|
|
1180
|
+
than a per-request `(req) => …`. Keep all logging on **stderr**: stdout is the
|
|
1181
|
+
JSON-RPC channel.
|
|
1182
|
+
|
|
1183
|
+
Both transports build the server through the shared `buildMcpServer` — same
|
|
1184
|
+
contract pipeline, same `services` / `context` / `hooks` / `nativeTools` /
|
|
1185
|
+
`instructions`.
|
|
1186
|
+
|
|
1187
|
+
## OAuth 2.1 — a native remote connector
|
|
1188
|
+
|
|
1189
|
+
A remote MCP server is connectable from Claude (Desktop / web "custom
|
|
1190
|
+
connector") only through the MCP authorization spec: OAuth 2.1 with PKCE, plus
|
|
1191
|
+
the discovery documents (RFC 9728 / 8414), Dynamic Client Registration
|
|
1192
|
+
(RFC 7591) and resource indicators (RFC 8707). A Bearer-only server returns a
|
|
1193
|
+
bare `401` and the connector never establishes.
|
|
1194
|
+
|
|
1195
|
+
stitchkit ships the OAuth **protocol mechanics**; the app supplies only
|
|
1196
|
+
**identity and storage**. Three pieces wire it together:
|
|
1197
|
+
|
|
1198
|
+
```ts
|
|
1199
|
+
import { createMcpHandler, mountOAuthProvider, oauthProtectedResourceRoute } from 'stitchkit/tools'
|
|
1200
|
+
import { createServer } from 'stitchkit/server'
|
|
1201
|
+
|
|
1202
|
+
const resource = 'https://api.example.com/mcp'
|
|
1203
|
+
const issuer = 'https://api.example.com'
|
|
1204
|
+
|
|
1205
|
+
// 1. Resource server — the 401 now points at the metadata.
|
|
1206
|
+
const handleMcp = createMcpHandler({
|
|
1207
|
+
serverInfo: { name: 'my-app', version: '1.0.0' },
|
|
1208
|
+
auth: resolveOAuthToken, // validate the Bearer JWT (verifyJwt + audience)
|
|
1209
|
+
services,
|
|
1210
|
+
protectedResource: { resource, authorizationServers: [issuer] },
|
|
1211
|
+
})
|
|
1212
|
+
|
|
1213
|
+
// 2. Authorization server — DCR, /authorize (PKCE), /token.
|
|
1214
|
+
const oauthRoutes = mountOAuthProvider({
|
|
1215
|
+
issuer,
|
|
1216
|
+
resource,
|
|
1217
|
+
signingSecret: env.OAUTH_SECRET,
|
|
1218
|
+
clients, codes, refreshTokens, // your stores (DB or in-memory)
|
|
1219
|
+
authorizeUser, // your login + consent → { userId } | Response
|
|
1220
|
+
})
|
|
1221
|
+
|
|
1222
|
+
createServer({
|
|
1223
|
+
services,
|
|
1224
|
+
rawRoutes: [
|
|
1225
|
+
{ method: 'ALL', path: '/mcp', handler: (req) => handleMcp(req) },
|
|
1226
|
+
oauthProtectedResourceRoute({ resource, authorizationServers: [issuer] }),
|
|
1227
|
+
...oauthRoutes,
|
|
1228
|
+
],
|
|
1229
|
+
})
|
|
1230
|
+
```
|
|
1231
|
+
|
|
1232
|
+
Access tokens are signed HS256 JWTs (`signJwt`) whose `aud` is the resource —
|
|
1233
|
+
validate them in `auth` with `verifyJwt(token, secret, { audience: resource })`.
|
|
1234
|
+
`authorizeUser` is where the app authenticates the user (reuse an existing
|
|
1235
|
+
session) and records consent; return `{ userId }` to issue a code, or a
|
|
1236
|
+
`Response` to redirect the browser to a login page first. The AS and resource
|
|
1237
|
+
server can co-locate or live on separate origins. See
|
|
1238
|
+
[ADR 0015](../decisions/0015-oauth-resource-server.md).
|
|
1239
|
+
|
|
1240
|
+
## Proxying a remote API — `implementRemote`
|
|
1241
|
+
|
|
1242
|
+
`implement` binds a contract to local handlers. `implementRemote` binds it to a
|
|
1243
|
+
remote HTTP API instead — every handler forwards the call to a deployed server
|
|
1244
|
+
through the contract's typed client:
|
|
1245
|
+
|
|
1246
|
+
```ts
|
|
1247
|
+
import { createHttpClient } from 'stitchkit'
|
|
1248
|
+
import { createStdioMcpServer, implementRemote } from 'stitchkit/tools'
|
|
1249
|
+
|
|
1250
|
+
const http = createHttpClient({
|
|
1251
|
+
baseUrl: 'https://api.example.com',
|
|
1252
|
+
headers: () => ({ Authorization: `Bearer ${apiKey}` }),
|
|
1253
|
+
})
|
|
1254
|
+
|
|
1255
|
+
await createStdioMcpServer({
|
|
1256
|
+
serverInfo: { name: 'my-app', version: '1.0.0' },
|
|
1257
|
+
auth: null,
|
|
1258
|
+
services: contracts.map((c) => implementRemote(c, http)),
|
|
1259
|
+
})
|
|
1260
|
+
```
|
|
1261
|
+
|
|
1262
|
+
This is how you ship a thin **local** MCP server for an API that already runs in
|
|
1263
|
+
the cloud: the local process owns only the transport and any filesystem-facing
|
|
1264
|
+
native tools, while every contract tool proxies to the remote API. One contract,
|
|
1265
|
+
no duplicated business logic.
|
|
1266
|
+
|
|
1267
|
+
`implementRemote(contract, http, { transformArgs })` takes an optional
|
|
1268
|
+
`transformArgs` hook that rewrites a call's arguments before they are forwarded
|
|
1269
|
+
— e.g. to upload a local file referenced in the args and swap in its URL.
|
|
1270
|
+
|
|
1271
|
+
## Structured output
|
|
1272
|
+
|
|
1273
|
+
When a contract endpoint declares an object `output`, its MCP tool registers
|
|
1274
|
+
that as the tool `outputSchema` and the result carries `structuredContent`
|
|
1275
|
+
alongside the text block — the structured payload an MCP App UI consumes.
|
|
1276
|
+
|
|
1277
|
+
## AI agents — `mountAgent`
|
|
1278
|
+
|
|
1279
|
+
`mountAgent` turns a service into a Vercel AI SDK `ToolSet`, ready for
|
|
1280
|
+
`generateText` / `streamText`:
|
|
1281
|
+
|
|
1282
|
+
```ts
|
|
1283
|
+
import { mountAgent } from 'stitchkit/tools'
|
|
1284
|
+
import { generateText } from 'ai'
|
|
1285
|
+
|
|
1286
|
+
const tools = mountAgent(usersService, { context: { userId: 'agent-1' } })
|
|
1287
|
+
|
|
1288
|
+
const result = await generateText({
|
|
1289
|
+
model,
|
|
1290
|
+
tools,
|
|
1291
|
+
prompt: 'Create a user named Max',
|
|
1292
|
+
})
|
|
1293
|
+
```
|
|
1294
|
+
|
|
1295
|
+
Each contract endpoint exposed on `AGENT` becomes a tool whose input schema is
|
|
1296
|
+
the merged `params` + `input`. `context` is merged into every tool handler's
|
|
1297
|
+
`ctx`, alongside `source: 'agent'`.
|
|
1298
|
+
|
|
1299
|
+
### `AgentMountConfig`
|
|
1300
|
+
|
|
1301
|
+
| Field | Purpose |
|
|
1302
|
+
|-------|---------|
|
|
1303
|
+
| `context` | values merged into every tool handler's `ctx` |
|
|
1304
|
+
| `lifecycle` | `beforeHandle` / `afterHandle` — the tool-side auth gate (see [Guarding tools](#guarding-tools--lifecycle)) |
|
|
1305
|
+
| `hooks` | tool-call observability hooks — `afterToolCall` fires on every result |
|
|
1306
|
+
| `extend` | add extra args resolved before the handler runs (see below) |
|
|
1307
|
+
|
|
1308
|
+
`lifecycle` works the same as on the MCP server — without it an agent tool call
|
|
1309
|
+
bypasses the HTTP `beforeHandle` auth gate. Pass your `createAuthHook` result.
|
|
1310
|
+
|
|
1311
|
+
### Adding tool-only args — `extend`
|
|
1312
|
+
|
|
1313
|
+
`extend` adds **tool-only** arguments — fields the model fills that are resolved
|
|
1314
|
+
into context, then stripped before the contract handler sees them. Use it when a
|
|
1315
|
+
tool needs an argument the HTTP endpoint does not — the classic case being a
|
|
1316
|
+
**multi-tenant** server reached by one API key, where the model passes `tenantId`
|
|
1317
|
+
on every call. It is the same `ToolExtend` shape on `mountMcp`, `createMcpHandler`
|
|
1318
|
+
and `mountAgent`:
|
|
1319
|
+
|
|
1320
|
+
```ts
|
|
1321
|
+
interface ToolExtend {
|
|
1322
|
+
/** Extra Zod fields added to every matching tool's input schema. */
|
|
1323
|
+
schema: Record<string, z.ZodType>
|
|
1324
|
+
/** Turn the extra args into context merged into the handler's ctx. */
|
|
1325
|
+
resolve: (args: Record<string, unknown>) => Partial<Ctx> | Promise<Partial<Ctx>>
|
|
1326
|
+
/** Limit the extension to specific methods — default: every method. */
|
|
1327
|
+
filter?: (service: ServiceDef, method: MethodDef) => boolean
|
|
1328
|
+
}
|
|
1329
|
+
```
|
|
1330
|
+
|
|
1331
|
+
On the MCP server — add `tenantId` to each tool, validate access in `resolve`,
|
|
1332
|
+
inject the resolved tenant into `ctx`, and `filter` to the tenant-scoped tools:
|
|
1333
|
+
|
|
1334
|
+
```ts
|
|
1335
|
+
createMcpHandler({
|
|
1336
|
+
serverInfo, auth,
|
|
1337
|
+
services: [widgetsService],
|
|
1338
|
+
extend: {
|
|
1339
|
+
schema: { tenantId: z.string().describe('Tenant to act on') },
|
|
1340
|
+
resolve: async ({ tenantId }) => {
|
|
1341
|
+
const id = String(tenantId)
|
|
1342
|
+
if (!(await tenantExists(id))) throw new Error(`unknown tenant ${id}`)
|
|
1343
|
+
return { tenantId: id } // merged into ctx → ctx.tenantId
|
|
1344
|
+
},
|
|
1345
|
+
filter: (_service, method) => method.scope === 'tenant',
|
|
1346
|
+
},
|
|
1347
|
+
})
|
|
1348
|
+
```
|
|
1349
|
+
|
|
1350
|
+
The model calls `widgets_list({ tenantId: 't_123' })`; `resolve` checks access and
|
|
1351
|
+
puts `tenantId` on `ctx`; the contract handler reads `ctx.tenantId` (same place
|
|
1352
|
+
as the HTTP prefix param — see
|
|
1353
|
+
[Route groups → param prefixes](./server.md#param-prefixes-resource-scoped-paths)),
|
|
1354
|
+
so one handler serves both surfaces. Pair `extend` with `lifecycle` (your
|
|
1355
|
+
`createAuthHook`) so the tool call is still scope-gated.
|
|
1356
|
+
|
|
1357
|
+
## Native multimodal tools
|
|
1358
|
+
|
|
1359
|
+
Contract tools return JSON. For a tool that returns an image or other
|
|
1360
|
+
multimodal content, register a native tool. `mountViewFile` is the built-in one
|
|
1361
|
+
— it lets a model fetch and view a file (with SSRF and path-traversal
|
|
1362
|
+
defenses):
|
|
1363
|
+
|
|
1364
|
+
```ts
|
|
1365
|
+
import { createMcpHandler, mountViewFile } from 'stitchkit/tools'
|
|
1366
|
+
|
|
1367
|
+
const handleMcp = createMcpHandler({
|
|
1368
|
+
serverInfo: { name: 'my-app', version: '1.0.0' },
|
|
1369
|
+
auth,
|
|
1370
|
+
services: [service],
|
|
1371
|
+
nativeTools: (server) => mountViewFile(server, { baseDir: '/srv/uploads' }),
|
|
1372
|
+
})
|
|
1373
|
+
```
|
|
1374
|
+
|
|
1375
|
+
## One handler, three callers
|
|
1376
|
+
|
|
1377
|
+
A contract handler runs the same for an HTTP request, an MCP tool call and an
|
|
1378
|
+
agent tool call. `ctx.source` (`'http'` · `'mcp'` · `'agent'`) tells it which —
|
|
1379
|
+
the rest of the context (`params`, `input`, anything `context` injects) is
|
|
1380
|
+
identical. Write the handler once; it serves every surface.
|
|
1381
|
+
|
|
1382
|
+
|
|
1383
|
+
==============================================================================
|
|
1384
|
+
# Guide: CLI (docs/guide/cli.md)
|
|
1385
|
+
==============================================================================
|
|
1386
|
+
|
|
1387
|
+
# CLI
|
|
1388
|
+
|
|
1389
|
+
The same contract that drives the HTTP API, MCP tools and agent tools also
|
|
1390
|
+
drives a command-line program. `createCli` turns contract methods into commands
|
|
1391
|
+
— `myapp generate "a fox" --wait`, `myapp models list --json | jq …` — run
|
|
1392
|
+
through the very same validation, auth gate and error model as every other
|
|
1393
|
+
surface (HTTP ≡ MCP ≡ agent ≡ CLI, [ADR 0014](../decisions/0014-tool-http-parity.md)).
|
|
1394
|
+
|
|
1395
|
+
It exists for what the other three surfaces cannot do: a generation kicked off
|
|
1396
|
+
with `Bash(run_in_background)` that notifies on exit, a `SKILL.md` that shells
|
|
1397
|
+
out in one line, a pipeable terminal command.
|
|
1398
|
+
|
|
1399
|
+
## Exposure is opt-in
|
|
1400
|
+
|
|
1401
|
+
Unlike MCP and agent — where an endpoint with no `expose` is a tool by default —
|
|
1402
|
+
**a method becomes a CLI command only when its `expose` lists `'CLI'`.** Adding
|
|
1403
|
+
the CLI never silently turns your existing API tools into shell commands.
|
|
1404
|
+
|
|
1405
|
+
```ts
|
|
1406
|
+
{ method: 'POST', path: '/', desc: 'Generate media', toolName: 'generate',
|
|
1407
|
+
expose: ['CLI', 'MCP', 'AGENT'], input: GenerateInput, output: Generation } // CLI + MCP + agent
|
|
1408
|
+
{ method: 'GET', path: '/models', desc: 'List models', toolName: 'list_models',
|
|
1409
|
+
expose: ['CLI'] } // CLI only
|
|
1410
|
+
{ method: 'GET', path: '/search', desc: 'Search' } // HTTP + MCP + AGENT — NOT CLI
|
|
1411
|
+
```
|
|
1412
|
+
|
|
1413
|
+
A fresh contract shows **zero** CLI commands until methods opt in — that is the
|
|
1414
|
+
design, not a bug. The command name is the tool name — `toolName` if set, else a
|
|
1415
|
+
verb-aware name from the method + prefix (`list` → `list_widgets`, `get` →
|
|
1416
|
+
`get_widget`), not a literal `prefix_key`.
|
|
1417
|
+
|
|
1418
|
+
## A minimal CLI
|
|
1419
|
+
|
|
1420
|
+
stitchkit ships no binary — you write the executable and point your app's `bin`
|
|
1421
|
+
at it:
|
|
1422
|
+
|
|
1423
|
+
```ts
|
|
1424
|
+
#!/usr/bin/env node
|
|
1425
|
+
// src/cli.ts
|
|
1426
|
+
import { createCli } from 'stitchkit/cli'
|
|
1427
|
+
import { catalogService, generateService } from './services'
|
|
1428
|
+
|
|
1429
|
+
await createCli({
|
|
1430
|
+
name: 'myapp',
|
|
1431
|
+
version: '1.0.0',
|
|
1432
|
+
services: [catalogService, generateService],
|
|
1433
|
+
auth: process.env.MYAPP_TOKEN, // resolved ONCE, like a stdio MCP server
|
|
1434
|
+
})
|
|
1435
|
+
```
|
|
1436
|
+
|
|
1437
|
+
```json
|
|
1438
|
+
// package.json
|
|
1439
|
+
{ "bin": { "myapp": "./dist/cli.js" } }
|
|
1440
|
+
```
|
|
1441
|
+
|
|
1442
|
+
`stitchkit/cli` pulls in neither the MCP SDK nor `ai`, so a CLI binary needs no
|
|
1443
|
+
MCP/agent peer dependencies.
|
|
1444
|
+
|
|
1445
|
+
## Calling commands
|
|
1446
|
+
|
|
1447
|
+
```
|
|
1448
|
+
<app> <command> [positional] [--flags]
|
|
1449
|
+
```
|
|
1450
|
+
|
|
1451
|
+
Arguments are coerced to the contract schema's types — every argv token is a
|
|
1452
|
+
string, the schema says what it should be:
|
|
1453
|
+
|
|
1454
|
+
| Zod field | CLI |
|
|
1455
|
+
| -------------------- | ---------------------------------------------- |
|
|
1456
|
+
| `z.string()` | `--name "box"` or a positional |
|
|
1457
|
+
| `z.number()` | `--count 3` → `3` |
|
|
1458
|
+
| `z.boolean()` | `--active` (presence) / `--no-active` |
|
|
1459
|
+
| `z.enum([...])` | `--size large` |
|
|
1460
|
+
| `z.array(z.string())`| `--tag a --tag b` → `["a","b"]` |
|
|
1461
|
+
| `z.object({...})` | `--opts '{"k":"v"}'` (JSON) or `--opts.k v` |
|
|
1462
|
+
| `.optional()` / `.default()` | not required |
|
|
1463
|
+
|
|
1464
|
+
Positional arguments fill non-boolean fields in declaration order, so
|
|
1465
|
+
`myapp generate "a fox"` is `--prompt "a fox"`. A piped value fills the first
|
|
1466
|
+
unset field: `echo "a fox" | myapp generate`.
|
|
1467
|
+
|
|
1468
|
+
The advertised schema is never mutated — a CLI call validates against the exact
|
|
1469
|
+
same Zod schema an HTTP or MCP call does.
|
|
1470
|
+
|
|
1471
|
+
## Global flags
|
|
1472
|
+
|
|
1473
|
+
| Flag | Effect |
|
|
1474
|
+
| --------------------- | ---------------------------------------------------------- |
|
|
1475
|
+
| `--json` | Raw JSON on stdout for piping / scripts |
|
|
1476
|
+
| `--wait` | Block-poll an async result to a terminal state |
|
|
1477
|
+
| `--wait-timeout <s>` | Override the `--wait` timeout |
|
|
1478
|
+
| `--output-dir <dir>` | Download result media into a directory |
|
|
1479
|
+
| `--quiet` | Suppress non-essential stderr output |
|
|
1480
|
+
| `--dry-run` | Print the resolved call without executing |
|
|
1481
|
+
| `--help`, `-h` | Usage — top-level or per-command flag table |
|
|
1482
|
+
|
|
1483
|
+
stdout carries the result; errors and progress go to stderr, so `--json` stays
|
|
1484
|
+
pipeable and `2>/dev/null` stays clean. The process exit code carries the error
|
|
1485
|
+
class (`0` ok, `VALIDATION_ERROR → 1`, `UNAUTHORIZED → 2`, `FORBIDDEN → 3`,
|
|
1486
|
+
`NOT_FOUND → 4`, …) — override per app with `exitCodes`.
|
|
1487
|
+
|
|
1488
|
+
## `--wait` — background-friendly generation
|
|
1489
|
+
|
|
1490
|
+
`--wait` polls an async result until it is done. It is generic — the core knows
|
|
1491
|
+
nothing about "generations": you say how to read the poll target, which command
|
|
1492
|
+
to re-call and when it is done.
|
|
1493
|
+
|
|
1494
|
+
```ts
|
|
1495
|
+
await createCli({
|
|
1496
|
+
name: 'myapp',
|
|
1497
|
+
version: '1.0.0',
|
|
1498
|
+
services,
|
|
1499
|
+
wait: {
|
|
1500
|
+
generate: {
|
|
1501
|
+
tool: 'get_generation',
|
|
1502
|
+
poll: (r) => (isRecord(r) && typeof r.id === 'string' ? { id: r.id } : null),
|
|
1503
|
+
done: (r) => isRecord(r) && r.status === 'COMPLETED',
|
|
1504
|
+
},
|
|
1505
|
+
},
|
|
1506
|
+
})
|
|
1507
|
+
```
|
|
1508
|
+
|
|
1509
|
+
```bash
|
|
1510
|
+
# foreground
|
|
1511
|
+
myapp generate "a fox" --wait --output-dir ./out
|
|
1512
|
+
|
|
1513
|
+
# background — frees the agent; a notification fires on exit
|
|
1514
|
+
myapp generate "a fox" --wait --json > result.json &
|
|
1515
|
+
```
|
|
1516
|
+
|
|
1517
|
+
## Auth parity
|
|
1518
|
+
|
|
1519
|
+
A scoped command is guarded by the same `createAuthHook` your HTTP server uses —
|
|
1520
|
+
pass it as `lifecycle`, and inject the identity through `context` so
|
|
1521
|
+
`resolveFromContext` can read it:
|
|
1522
|
+
|
|
1523
|
+
```ts
|
|
1524
|
+
const authHook = createAuthHook({ /* resolve, resolveFromContext, rules */ })
|
|
1525
|
+
|
|
1526
|
+
await createCli({
|
|
1527
|
+
name: 'myapp',
|
|
1528
|
+
version: '1.0.0',
|
|
1529
|
+
auth: await resolveIdentityFromToken(process.env.MYAPP_TOKEN),
|
|
1530
|
+
context: (identity) => ({ user: identity }), // resolveFromContext reads this
|
|
1531
|
+
lifecycle: { beforeHandle: authHook }, // same gate as HTTP
|
|
1532
|
+
services,
|
|
1533
|
+
})
|
|
1534
|
+
```
|
|
1535
|
+
|
|
1536
|
+
Without `lifecycle`, a scoped command runs **unguarded** — the scope check lives
|
|
1537
|
+
entirely inside the `createAuthHook` result, so with no hook wired in there is
|
|
1538
|
+
nothing to enforce a method's `scope`. This matches the MCP / agent surfaces
|
|
1539
|
+
exactly ([ADR 0014](../decisions/0014-tool-http-parity.md)): on every tool
|
|
1540
|
+
transport the auth gate is opt-in, so a contract with scoped methods **must** be
|
|
1541
|
+
given a `lifecycle` (and `context` identity) to be protected. The gate only
|
|
1542
|
+
*fails closed* once the hook **is** present but `resolveFromContext` is missing —
|
|
1543
|
+
then a scoped call has no identity and is rejected.
|
|
1544
|
+
|
|
1545
|
+
## Typed context
|
|
1546
|
+
|
|
1547
|
+
Use `createToolkit<AppContext>()` to type the injected `context` against your
|
|
1548
|
+
app's context shape — the tool-side mirror of `createImplement`
|
|
1549
|
+
([ADR 0017](../decisions/0017-typed-tool-context.md)):
|
|
1550
|
+
|
|
1551
|
+
```ts
|
|
1552
|
+
const tools = createToolkit<{ user: User }>()
|
|
1553
|
+
await tools.createCli({
|
|
1554
|
+
name: 'myapp',
|
|
1555
|
+
version: '1.0.0',
|
|
1556
|
+
services,
|
|
1557
|
+
context: (identity) => ({ user: identity }), // checked against { user: User }
|
|
1558
|
+
})
|
|
1559
|
+
```
|
|
1560
|
+
|
|
1561
|
+
## Not in v1
|
|
1562
|
+
|
|
1563
|
+
File-upload (`multipart`) endpoints are CLI-invisible, the same as on MCP /
|
|
1564
|
+
agent — a dedicated upload command (auto-uploading local paths) is future work.
|
|
1565
|
+
Streaming (SSE) output is not yet piped to stdout.
|
|
1566
|
+
|
|
1567
|
+
|
|
1568
|
+
==============================================================================
|
|
1569
|
+
# Guide: Realtime (docs/guide/realtime.md)
|
|
1570
|
+
==============================================================================
|
|
1571
|
+
|
|
1572
|
+
# Realtime
|
|
1573
|
+
|
|
1574
|
+
stitchkit's realtime layer is [Socket.IO](https://socket.io) — `polling`
|
|
1575
|
+
fallback, heartbeats, acks, a mature client. stitchkit does not ship its own
|
|
1576
|
+
WebSocket engine; it ships thin, typed wrappers over Socket.IO and a bridge that
|
|
1577
|
+
syncs socket events into the TanStack Query cache. See
|
|
1578
|
+
[ADR 0008](../decisions/0008-thin-wrappers.md).
|
|
1579
|
+
|
|
1580
|
+
## Typed events
|
|
1581
|
+
|
|
1582
|
+
Declare the event maps once, in the shared module — both sides import them:
|
|
1583
|
+
|
|
1584
|
+
```ts
|
|
1585
|
+
// shared/contracts.ts
|
|
1586
|
+
export interface ServerToClientEvents {
|
|
1587
|
+
'note:created': (note: Note) => void
|
|
1588
|
+
'note:deleted': (id: string) => void
|
|
1589
|
+
}
|
|
1590
|
+
export interface ClientToServerEvents {
|
|
1591
|
+
'room:join': (room: string) => void
|
|
1592
|
+
}
|
|
1593
|
+
```
|
|
1594
|
+
|
|
1595
|
+
Every `emit` and `on` on both the server and client wrapper is typed against
|
|
1596
|
+
these maps.
|
|
1597
|
+
|
|
1598
|
+
## Server — `createSocketIOServer`
|
|
1599
|
+
|
|
1600
|
+
```ts
|
|
1601
|
+
import { createServer, createSocketIOServer } from 'stitchkit/server'
|
|
1602
|
+
|
|
1603
|
+
const socket = await createSocketIOServer<ServerToClientEvents, ClientToServerEvents>({
|
|
1604
|
+
cors: { origin: 'https://app.example.com' },
|
|
1605
|
+
})
|
|
1606
|
+
|
|
1607
|
+
socket.io.on('connection', (s) => {
|
|
1608
|
+
s.on('room:join', (room) => s.join(room)) // rooms, handshake auth — your logic
|
|
1609
|
+
})
|
|
1610
|
+
```
|
|
1611
|
+
|
|
1612
|
+
It returns a handle with three pieces, all wired into `createServer`:
|
|
1613
|
+
|
|
1614
|
+
```ts
|
|
1615
|
+
createServer({
|
|
1616
|
+
services,
|
|
1617
|
+
websocket: socket.websocket, // → Bun.serve websocket handlers
|
|
1618
|
+
rawRoutes: [socket.route], // ready-made /socket.io/* route
|
|
1619
|
+
})
|
|
1620
|
+
|
|
1621
|
+
// elsewhere — broadcast:
|
|
1622
|
+
socket.io.emit('note:created', note)
|
|
1623
|
+
```
|
|
1624
|
+
|
|
1625
|
+
| Handle field | Purpose |
|
|
1626
|
+
|--------------|---------|
|
|
1627
|
+
| `io` | the typed Socket.IO server — attach `connection` handlers, broadcast |
|
|
1628
|
+
| `websocket` | Bun WebSocket handlers — pass to `createServer({ websocket })` |
|
|
1629
|
+
| `route` | the `/socket.io/*` raw route — pass to `createServer({ rawRoutes })` |
|
|
1630
|
+
|
|
1631
|
+
`SocketIOServerConfig` also takes `path`, `transports`, `pingTimeout` and
|
|
1632
|
+
`pingInterval`. For anything else socket.io's `ServerOptions` exposes, use the
|
|
1633
|
+
typed **`serverOptions`** passthrough — most often `maxHttpBufferSize` to lift the
|
|
1634
|
+
1 MB default for large emits:
|
|
1635
|
+
|
|
1636
|
+
```ts
|
|
1637
|
+
await createSocketIOServer({
|
|
1638
|
+
cors: { origin: 'https://app.example.com' },
|
|
1639
|
+
serverOptions: { maxHttpBufferSize: 5 * 1024 * 1024 }, // 5 MB
|
|
1640
|
+
})
|
|
1641
|
+
```
|
|
1642
|
+
|
|
1643
|
+
The wrapper-owned fields (`cors` / `path` / `transports` / `ping*`) take
|
|
1644
|
+
precedence over the same keys in `serverOptions`. On Bun the engine-level options
|
|
1645
|
+
(`maxHttpBufferSize`, the ping heartbeat, `upgradeTimeout`) are forwarded to
|
|
1646
|
+
`@socket.io/bun-engine` too — so a configured `maxHttpBufferSize` actually applies
|
|
1647
|
+
instead of silently truncating at 1 MB.
|
|
1648
|
+
|
|
1649
|
+
## Client — `createSocketIOClient`
|
|
1650
|
+
|
|
1651
|
+
```ts
|
|
1652
|
+
import { createSocketIOClient } from 'stitchkit'
|
|
1653
|
+
|
|
1654
|
+
const socket = createSocketIOClient<ServerToClientEvents, ClientToServerEvents>({
|
|
1655
|
+
url: 'https://api.example.com',
|
|
1656
|
+
})
|
|
1657
|
+
|
|
1658
|
+
socket.connect()
|
|
1659
|
+
socket.on('note:created', (note) => { /* typed note */ })
|
|
1660
|
+
socket.emit('room:join', 'general') // typed
|
|
1661
|
+
```
|
|
1662
|
+
|
|
1663
|
+
### Durable subscriptions
|
|
1664
|
+
|
|
1665
|
+
`socket.on(...)` returns an unsubscribe and is **durable** — the handler is
|
|
1666
|
+
re-attached to every socket the client builds, so it survives a reconnect. You
|
|
1667
|
+
subscribe once; reconnection is the wrapper's problem, not yours.
|
|
1668
|
+
|
|
1669
|
+
`SocketIOClientConfig` takes `url`, `path`, `withCredentials` (cookies on the
|
|
1670
|
+
handshake — default `true`), `auth`, `query`, `extraHeaders`, `transports`,
|
|
1671
|
+
`reconnectionAttempts` and `reconnectionDelay`.
|
|
1672
|
+
|
|
1673
|
+
### Handshake auth — cookie or token
|
|
1674
|
+
|
|
1675
|
+
By default the handshake carries cookies (`withCredentials: true`) — the right
|
|
1676
|
+
fit for a browser app with a session cookie. A client that holds a token
|
|
1677
|
+
explicitly (desktop, mobile, CLI, server-to-server) authenticates the
|
|
1678
|
+
handshake with **`auth`** instead — the token reaches the server as
|
|
1679
|
+
`socket.handshake.auth`:
|
|
1680
|
+
|
|
1681
|
+
```ts
|
|
1682
|
+
// client — a function is re-read on every (re)connect, so a rotated token is
|
|
1683
|
+
// picked up automatically; no need to recreate the client (or lose durable
|
|
1684
|
+
// subscriptions). It may be async.
|
|
1685
|
+
const socket = createSocketIOClient<ServerToClientEvents, ClientToServerEvents>({
|
|
1686
|
+
url: 'https://api.example.com',
|
|
1687
|
+
auth: () => ({ token: getAccessToken() }),
|
|
1688
|
+
})
|
|
1689
|
+
```
|
|
1690
|
+
|
|
1691
|
+
```ts
|
|
1692
|
+
// server — the gate is your logic, on socket.handshake.auth
|
|
1693
|
+
import { verifyJwt } from 'stitchkit/server'
|
|
1694
|
+
|
|
1695
|
+
socket.io.use(async (s, next) => {
|
|
1696
|
+
const token = s.handshake.auth.token
|
|
1697
|
+
if (typeof token !== 'string') return next(new Error('unauthorized'))
|
|
1698
|
+
try {
|
|
1699
|
+
s.data.user = await verifyJwt(token, secret)
|
|
1700
|
+
next()
|
|
1701
|
+
} catch {
|
|
1702
|
+
next(new Error('unauthorized'))
|
|
1703
|
+
}
|
|
1704
|
+
})
|
|
1705
|
+
```
|
|
1706
|
+
|
|
1707
|
+
A static object (`auth: { token }`) works too, but only the function form
|
|
1708
|
+
re-reads on reconnect — prefer it for rotating tokens. `query` adds handshake
|
|
1709
|
+
URL params (`socket.handshake.query`); `extraHeaders` adds handshake headers,
|
|
1710
|
+
but in a browser those apply to the **polling** transport only (a WebSocket
|
|
1711
|
+
upgrade cannot set request headers) — for browser WebSocket auth use `auth`.
|
|
1712
|
+
If an auth producer throws or rejects, the wrapper sends an empty auth object so
|
|
1713
|
+
the server gate can reject it instead of leaving the handshake waiting forever.
|
|
1714
|
+
|
|
1715
|
+
On a hard auth failure Socket.IO emits `connect_error` and keeps retrying
|
|
1716
|
+
(`reconnectionAttempts` defaults to `Infinity`) — set a finite value if a
|
|
1717
|
+
rejected token should stop hammering the server.
|
|
1718
|
+
|
|
1719
|
+
## Cache bridge
|
|
1720
|
+
|
|
1721
|
+
`createCacheBridge` syncs socket events into the TanStack Query cache — a server
|
|
1722
|
+
push updates the UI with no refetch. It is transport-agnostic: it takes any
|
|
1723
|
+
emitter with `on(event, handler) => unsubscribe`, which the
|
|
1724
|
+
`createSocketIOClient` result satisfies.
|
|
1725
|
+
|
|
1726
|
+
```ts
|
|
1727
|
+
import { createCacheBridge } from 'stitchkit/react'
|
|
1728
|
+
|
|
1729
|
+
const bridge = createCacheBridge({
|
|
1730
|
+
socket,
|
|
1731
|
+
queryClient,
|
|
1732
|
+
handlers: {
|
|
1733
|
+
'note:created': (note, ctx) => {
|
|
1734
|
+
if (ctx.isFresh(['notes'])) return // skip the echo of our own mutation
|
|
1735
|
+
ctx.queryClient.invalidateQueries({ queryKey: ['notes'] })
|
|
1736
|
+
},
|
|
1737
|
+
},
|
|
1738
|
+
})
|
|
1739
|
+
bridge.connect()
|
|
1740
|
+
```
|
|
1741
|
+
|
|
1742
|
+
### The echo problem
|
|
1743
|
+
|
|
1744
|
+
When the client makes a mutation, it updates the cache itself — and the server
|
|
1745
|
+
also broadcasts the change back over the socket. Without care the UI updates
|
|
1746
|
+
twice. `markFresh` plus a short freshness window solves it: mark a key fresh
|
|
1747
|
+
right after a local mutation, and the bridge handler skips the echo.
|
|
1748
|
+
|
|
1749
|
+
```ts
|
|
1750
|
+
// in the mutation:
|
|
1751
|
+
onSuccess: () => bridge.markFresh(['notes'])
|
|
1752
|
+
// in the handler: if (ctx.isFresh(['notes'])) return
|
|
1753
|
+
```
|
|
1754
|
+
|
|
1755
|
+
`createCacheBridge` is a convenience, not a requirement — any code can subscribe
|
|
1756
|
+
to the socket and call `queryClient` directly. The bridge just centralises the
|
|
1757
|
+
event-to-cache mapping and the echo guard.
|
|
1758
|
+
|
|
1759
|
+
## Raw binary lane (Bun)
|
|
1760
|
+
|
|
1761
|
+
Socket.IO carries binary fine — for most streams a binary event (`pcm(frame)`)
|
|
1762
|
+
is enough. But a *truly* high-throughput binary channel (video, large
|
|
1763
|
+
transfers) may want a raw WebSocket with no Socket.IO framing, on the **same**
|
|
1764
|
+
port. On Bun that is awkward: `Bun.serve` has a single `websocket` handler, and
|
|
1765
|
+
`createSocketIOServer().websocket` claims it.
|
|
1766
|
+
|
|
1767
|
+
`composeWebSocketHandlers` composes that one handler from several lanes. A raw
|
|
1768
|
+
lane stamps its own marker onto `ws.data` at upgrade and is matched positively;
|
|
1769
|
+
Socket.IO is the catch-all (`socketIoLane`, placed last) — so the engine's
|
|
1770
|
+
opaque `ws.data` is never inspected, and the whole thing stays cast-free.
|
|
1771
|
+
|
|
1772
|
+
```ts
|
|
1773
|
+
import {
|
|
1774
|
+
composeWebSocketHandlers,
|
|
1775
|
+
createServer,
|
|
1776
|
+
createSocketIOServer,
|
|
1777
|
+
socketIoLane,
|
|
1778
|
+
webSocketLane,
|
|
1779
|
+
} from 'stitchkit/server'
|
|
1780
|
+
import type { RawRoute } from 'stitchkit/server'
|
|
1781
|
+
import type { ServerWebSocket, WebSocketHandler } from 'bun'
|
|
1782
|
+
|
|
1783
|
+
const socket = await createSocketIOServer<ServerToClientEvents, ClientToServerEvents>({
|
|
1784
|
+
cors: { origin: 'https://app.example.com' },
|
|
1785
|
+
})
|
|
1786
|
+
|
|
1787
|
+
// 1. Discriminate the raw lane by a marker on ws.data (a type guard, cast-free
|
|
1788
|
+
// — the `in` operator narrows, no `as`).
|
|
1789
|
+
interface PcmData { lane: 'pcm'; roomId: string }
|
|
1790
|
+
function isPcm(ws: ServerWebSocket<unknown>): ws is ServerWebSocket<PcmData> {
|
|
1791
|
+
const data = ws.data
|
|
1792
|
+
return typeof data === 'object' && data !== null && 'lane' in data && data.lane === 'pcm'
|
|
1793
|
+
}
|
|
1794
|
+
|
|
1795
|
+
// 2. Raw handlers — ws.data is typed PcmData, no casts.
|
|
1796
|
+
const pcmHandlers: WebSocketHandler<PcmData> = {
|
|
1797
|
+
message(ws, frame) { ws.publish(ws.data.roomId, frame) },
|
|
1798
|
+
}
|
|
1799
|
+
|
|
1800
|
+
// 3. An upgrade route stamps the marker (your auth + data live here).
|
|
1801
|
+
const pcmRoute: RawRoute = {
|
|
1802
|
+
method: 'GET',
|
|
1803
|
+
path: '/ws/pcm',
|
|
1804
|
+
handler: (req, ctx) => {
|
|
1805
|
+
if (!ctx.server) throw new Error('needs a running Bun server')
|
|
1806
|
+
const ok = ctx.server.upgrade(req, { data: { lane: 'pcm', roomId: '…' } })
|
|
1807
|
+
return ok ? new Response(null) : new Response(null, { status: 400 })
|
|
1808
|
+
},
|
|
1809
|
+
}
|
|
1810
|
+
|
|
1811
|
+
// 4. Compose — raw lane first, Socket.IO last. The tuning is server-wide, so
|
|
1812
|
+
// set maxPayloadLength to the most permissive lane (Socket.IO's default is
|
|
1813
|
+
// 1 MB, its maxHttpBufferSize).
|
|
1814
|
+
const websocket = composeWebSocketHandlers(
|
|
1815
|
+
[webSocketLane({ match: isPcm, handlers: pcmHandlers }), socketIoLane(socket.websocket)],
|
|
1816
|
+
{ maxPayloadLength: 16 * 1024 * 1024 },
|
|
1817
|
+
)
|
|
1818
|
+
|
|
1819
|
+
createServer({
|
|
1820
|
+
services,
|
|
1821
|
+
websocket,
|
|
1822
|
+
rawRoutes: [socket.route, pcmRoute],
|
|
1823
|
+
})
|
|
1824
|
+
```
|
|
1825
|
+
|
|
1826
|
+
Notes:
|
|
1827
|
+
|
|
1828
|
+
- **Bun-only.** On Node, Socket.IO attaches to the `node:http.Server` `upgrade`
|
|
1829
|
+
event (`serveNode({ socket })`); a raw lane there is a separate upgrade
|
|
1830
|
+
handler, not this composition. See [ADR 0020](../decisions/0020-raw-websocket-lane.md).
|
|
1831
|
+
- The upgrade path must not collide with `/socket.io/*`.
|
|
1832
|
+
- The tuning (`maxPayloadLength`, `idleTimeout`, `backpressureLimit`, …) is
|
|
1833
|
+
global — keep `idleTimeout` ≥ Socket.IO needs (> 2 × `pingInterval`).
|
|
1834
|
+
- For high throughput, handle backpressure in the raw lane: `ws.send()` returns
|
|
1835
|
+
`-1` under pressure; resume on the `drain` callback.
|
|
1836
|
+
|
|
1837
|
+
|
|
1838
|
+
==============================================================================
|
|
1839
|
+
# Guide: Auth & errors (docs/guide/auth-and-errors.md)
|
|
1840
|
+
==============================================================================
|
|
1841
|
+
|
|
1842
|
+
# Auth & errors
|
|
1843
|
+
|
|
1844
|
+
stitchkit carries no domain model — it does not know what a user is. What it
|
|
1845
|
+
provides is the *control flow*: a scope on every endpoint, one hook that
|
|
1846
|
+
enforces it, and one error model shared by every transport. The identity and
|
|
1847
|
+
the scope vocabulary are yours.
|
|
1848
|
+
|
|
1849
|
+
## Scopes
|
|
1850
|
+
|
|
1851
|
+
An endpoint declares a `scope` — a free string. The contract `meta.scope` is the
|
|
1852
|
+
default for endpoints that declare none.
|
|
1853
|
+
|
|
1854
|
+
```ts
|
|
1855
|
+
export const posts = defineContract({ prefix: 'posts', scope: 'user' }, {
|
|
1856
|
+
list: { method: 'GET', path: '/', desc: 'List posts', scope: 'public', output: /* … */ },
|
|
1857
|
+
create: { method: 'POST', path: '/', desc: 'Create', /* inherits 'user' */ },
|
|
1858
|
+
remove: { method: 'DELETE', path: '/:id', desc: 'Delete', scope: 'admin' },
|
|
1859
|
+
})
|
|
1860
|
+
```
|
|
1861
|
+
|
|
1862
|
+
The framework attaches no meaning to the strings — `'public'`, `'user'`,
|
|
1863
|
+
`'admin'` mean whatever your auth hook decides.
|
|
1864
|
+
|
|
1865
|
+
## `createAuthHook`
|
|
1866
|
+
|
|
1867
|
+
`createAuthHook` builds a `beforeHandle` hook that enforces `endpoint.scope`.
|
|
1868
|
+
Every request runs the same three steps — resolve the identity, read the scope,
|
|
1869
|
+
allow / 401 / 403 — so the flow lives in the framework and you supply only
|
|
1870
|
+
`resolve` and a `rules` map.
|
|
1871
|
+
|
|
1872
|
+
```ts
|
|
1873
|
+
import { createAuthHook, createServer } from 'stitchkit/server'
|
|
1874
|
+
|
|
1875
|
+
const authHook = createAuthHook<User>({
|
|
1876
|
+
resolve: (ctx) => resolveSession(ctx), // → a User, or null
|
|
1877
|
+
rules: {
|
|
1878
|
+
public: 'public', // always pass
|
|
1879
|
+
user: 'authenticated', // any resolved identity passes
|
|
1880
|
+
admin: (user) => user.isAdmin, // custom predicate
|
|
1881
|
+
},
|
|
1882
|
+
inject: (ctx, user) => { ctx.user = user },
|
|
1883
|
+
})
|
|
1884
|
+
|
|
1885
|
+
createServer({ services, hooks: { beforeHandle: authHook } })
|
|
1886
|
+
```
|
|
1887
|
+
|
|
1888
|
+
### `AuthRule`
|
|
1889
|
+
|
|
1890
|
+
The value of each `rules` entry, keyed by scope:
|
|
1891
|
+
|
|
1892
|
+
- **`'public'`** — always passes; the identity is attached if present.
|
|
1893
|
+
- **`'authenticated'`** — any resolved identity passes; no identity ⇒ 401.
|
|
1894
|
+
- **a function** `(identity, ctx) => boolean | Promise<boolean>` — a custom
|
|
1895
|
+
check. It receives the full context, so a resource-scoped rule can read the
|
|
1896
|
+
request's path params and do a DB lookup. May be async.
|
|
1897
|
+
|
|
1898
|
+
#### Resource-scoped rule — reading a path/prefix param
|
|
1899
|
+
|
|
1900
|
+
For a multi-tenant API (`pathPrefix: '/tenants/:tenantId'`, see
|
|
1901
|
+
[Route groups → param prefixes](./server.md#param-prefixes-resource-scoped-paths)),
|
|
1902
|
+
the rule gates access to the tenant in the path. A `:param` from the group prefix
|
|
1903
|
+
or the endpoint is on the **context root** as `ctx.<name>` (a raw `string`):
|
|
1904
|
+
|
|
1905
|
+
```ts
|
|
1906
|
+
const authHook = createAuthHook<User>({
|
|
1907
|
+
resolve: sessionResolver,
|
|
1908
|
+
rules: {
|
|
1909
|
+
public: 'public',
|
|
1910
|
+
// gate the tenant in the path — ctx.tenantId comes from the group prefix
|
|
1911
|
+
tenant: async (user, ctx) => userCanAccessTenant(user.id, String(ctx.tenantId)),
|
|
1912
|
+
} satisfies Record<Scope, AuthRule<User>>,
|
|
1913
|
+
// derive per-request facts onto ctx for handlers (role within this tenant, …)
|
|
1914
|
+
inject: async (ctx, user) => {
|
|
1915
|
+
ctx.user = user
|
|
1916
|
+
if (user) ctx.tenantRole = await roleInTenant(user.id, String(ctx.tenantId))
|
|
1917
|
+
},
|
|
1918
|
+
})
|
|
1919
|
+
```
|
|
1920
|
+
|
|
1921
|
+
`inject` runs on every request (identity may be `null`) — use it to put the
|
|
1922
|
+
identity *and* any derived values (role, parent-id) on `ctx` for handlers. Read
|
|
1923
|
+
them back as `ctx.tenantRole` (typed `unknown` — narrow at the read site). The
|
|
1924
|
+
endpoints under the group declare `scope: 'tenant'`.
|
|
1925
|
+
|
|
1926
|
+
### `AuthHookConfig`
|
|
1927
|
+
|
|
1928
|
+
| Field | Purpose |
|
|
1929
|
+
|-------|---------|
|
|
1930
|
+
| `resolve` | `(ctx) => identity \| null` — HTTP identity from `ctx.req` (cookie / bearer + lookup) |
|
|
1931
|
+
| `resolveFromContext` | `(ctx) => identity \| null` — identity on a tool call (no `req`) |
|
|
1932
|
+
| `rules` | access rule per scope; `endpoint.scope` is the key |
|
|
1933
|
+
| `defaultScope` | scope applied when an endpoint declares none |
|
|
1934
|
+
| `inject` | write the resolved identity onto `ctx` for handlers |
|
|
1935
|
+
| `onAnonymous` | thrown when a scope needs an identity and there is none (default 401) |
|
|
1936
|
+
| `onForbidden` | thrown when an identity is present but the rule rejects it (default 403) |
|
|
1937
|
+
|
|
1938
|
+
Annotate `rules` with `satisfies Record<MyScope, AuthRule<User>>` so the compiler
|
|
1939
|
+
catches a scope you forgot to cover.
|
|
1940
|
+
|
|
1941
|
+
### Auth on the tool surface — `resolveFromContext`
|
|
1942
|
+
|
|
1943
|
+
The hook runs in `beforeHandle`, so it guards **every transport** — HTTP, MCP
|
|
1944
|
+
and agent calls all pass through it. But identity is resolved differently per
|
|
1945
|
+
surface:
|
|
1946
|
+
|
|
1947
|
+
- **HTTP** — `resolve(ctx)` reads `ctx.req` (a cookie or bearer token).
|
|
1948
|
+
- **Tool calls (MCP / agent)** — there is no `req`. The transport authenticated
|
|
1949
|
+
the caller (an MCP API key) and `buildMcpServer`'s `context` injected the
|
|
1950
|
+
identity into `ctx`. `resolveFromContext(ctx)` locates it.
|
|
1951
|
+
|
|
1952
|
+
```ts
|
|
1953
|
+
const authHook = createAuthHook<User>({
|
|
1954
|
+
resolve: (ctx) => resolveSession(ctx), // HTTP — from ctx.req
|
|
1955
|
+
resolveFromContext: (ctx) => ctx.user ?? null, // tool — from injected ctx
|
|
1956
|
+
rules: { public: 'public', user: 'authenticated', admin: (u) => u.isAdmin },
|
|
1957
|
+
})
|
|
1958
|
+
```
|
|
1959
|
+
|
|
1960
|
+
The **scope check is identical** on both surfaces — only identity resolution
|
|
1961
|
+
differs. If you omit `resolveFromContext`, a scoped tool call has no identity
|
|
1962
|
+
and **fails closed** (rejected by `onAnonymous`) — the hook never silently
|
|
1963
|
+
passes a tool call it cannot authenticate. → [ADR 0014](../decisions/0014-tool-http-parity.md)
|
|
1964
|
+
|
|
1965
|
+
## `createBearerResolver`
|
|
1966
|
+
|
|
1967
|
+
For API-key or bearer-token auth (the usual MCP case), `createBearerResolver`
|
|
1968
|
+
strips `Authorization: Bearer <token>` and hands the raw token to your lookup:
|
|
1969
|
+
|
|
1970
|
+
```ts
|
|
1971
|
+
import { createBearerResolver } from 'stitchkit/server'
|
|
1972
|
+
|
|
1973
|
+
const resolve = createBearerResolver<User>({
|
|
1974
|
+
lookup: (token, req) => db.apiKeys.resolve(token), // → User, or null
|
|
1975
|
+
})
|
|
1976
|
+
```
|
|
1977
|
+
|
|
1978
|
+
Use it as `createMcpHandler`'s `auth`, or inside `createAuthHook`'s `resolve`.
|
|
1979
|
+
|
|
1980
|
+
## JWT
|
|
1981
|
+
|
|
1982
|
+
`verifyJwt(token, secret)` verifies an HS256 JWT and returns its payload, or
|
|
1983
|
+
throws `unauthorized`. It pins the algorithm (the token's own `alg` can never
|
|
1984
|
+
pick the scheme), and checks `exp` and `nbf`.
|
|
1985
|
+
|
|
1986
|
+
```ts
|
|
1987
|
+
import { verifyJwt, extractToken } from 'stitchkit/server'
|
|
1988
|
+
|
|
1989
|
+
const token = extractToken(req) // from Authorization, or a cookie name
|
|
1990
|
+
const payload = await verifyJwt(token, process.env.JWT_SECRET!)
|
|
1991
|
+
```
|
|
1992
|
+
|
|
1993
|
+
`extractToken(req, cookieName?)` reads a bearer token from the `Authorization`
|
|
1994
|
+
header, or from the named cookie.
|
|
1995
|
+
|
|
1996
|
+
## Cookies
|
|
1997
|
+
|
|
1998
|
+
```ts
|
|
1999
|
+
import { defineCookie } from 'stitchkit/server'
|
|
2000
|
+
|
|
2001
|
+
const session = defineCookie({ name: 'sid', httpOnly: true, secure: true, sameSite: 'Lax', path: '/' })
|
|
2002
|
+
|
|
2003
|
+
session.get(req) // string | undefined
|
|
2004
|
+
session.set('abc123') // → a Set-Cookie header value
|
|
2005
|
+
session.clear() // → a Set-Cookie value that expires it
|
|
2006
|
+
```
|
|
2007
|
+
|
|
2008
|
+
`defineCookie` bundles a cookie's name and options into a typed handle, so the
|
|
2009
|
+
config is not repeated at every call site. `parseCookies(header)` and
|
|
2010
|
+
`serializeCookie(name, value, opts)` are the lower-level primitives.
|
|
2011
|
+
|
|
2012
|
+
## The error model
|
|
2013
|
+
|
|
2014
|
+
One error type, `AppError`, is shared by the contract, the server and the
|
|
2015
|
+
client. It carries a stable `code`, an HTTP `status`, and optional structured
|
|
2016
|
+
`details` and a `hint`.
|
|
2017
|
+
|
|
2018
|
+
### Throwing errors
|
|
2019
|
+
|
|
2020
|
+
Throw `AppError` directly, or use a typed helper — the idiomatic path:
|
|
2021
|
+
|
|
2022
|
+
| Helper | Status | Code |
|
|
2023
|
+
|--------|--------|------|
|
|
2024
|
+
| `notFound(message?)` | 404 | `NOT_FOUND` |
|
|
2025
|
+
| `badRequest(message, details?)` | 400 | `BAD_REQUEST` |
|
|
2026
|
+
| `unauthorized(message?)` | 401 | `UNAUTHORIZED` |
|
|
2027
|
+
| `forbidden(message?)` | 403 | `FORBIDDEN` |
|
|
2028
|
+
| `conflict(message?, details?)` | 409 | `CONFLICT` |
|
|
2029
|
+
| `rateLimited(message?)` | 429 | `RATE_LIMITED` |
|
|
2030
|
+
| `appError(code, message?, details?)` | mapped, else 500 | any `code` |
|
|
2031
|
+
|
|
2032
|
+
```ts
|
|
2033
|
+
import { notFound, badRequest } from 'stitchkit/server'
|
|
2034
|
+
|
|
2035
|
+
const note = db.get(ctx.params.id)
|
|
2036
|
+
if (!note) notFound('Note not found')
|
|
2037
|
+
if (ctx.input.title.length > 200) badRequest('Title too long', { max: 200 })
|
|
2038
|
+
```
|
|
2039
|
+
|
|
2040
|
+
Each helper has a `never` return type — TypeScript knows execution stops, so the
|
|
2041
|
+
code after it is correctly narrowed.
|
|
2042
|
+
|
|
2043
|
+
### The error envelope
|
|
2044
|
+
|
|
2045
|
+
`AppError.toJSON()` renders the public envelope every transport returns:
|
|
2046
|
+
|
|
2047
|
+
```json
|
|
2048
|
+
{ "error": { "code": "NOT_FOUND", "message": "Note not found" } }
|
|
2049
|
+
```
|
|
2050
|
+
|
|
2051
|
+
`details` and `hint` are included when present. On HTTP this is the response
|
|
2052
|
+
body with the matching status; for an MCP or agent call the same `code` and
|
|
2053
|
+
`details` come back as a tool error. A schema-validation failure on the request
|
|
2054
|
+
is turned into a `400 VALIDATION_ERROR` automatically.
|
|
2055
|
+
|
|
2056
|
+
### On the client
|
|
2057
|
+
|
|
2058
|
+
The client parses that envelope back into an `ApiError` with the same `code`,
|
|
2059
|
+
`status`, `details` and `hint` — see [Typed client → ApiError](./client.md#apierror).
|
|
2060
|
+
The error round-trips: one model, server to client.
|
|
2061
|
+
|
|
2062
|
+
### Stitch codes vs your codes
|
|
2063
|
+
|
|
2064
|
+
A `code` is a free string — your app codes (`BOT_NOT_FOUND`, …) are yours and the
|
|
2065
|
+
core never models them (ADR 0002). But stitchkit itself emits a fixed set:
|
|
2066
|
+
`BAD_REQUEST`, `UNAUTHORIZED`, `FORBIDDEN`, `NOT_FOUND`, `METHOD_NOT_ALLOWED`,
|
|
2067
|
+
`CONFLICT`, `RATE_LIMITED`, `VALIDATION_ERROR`, `INTERNAL_SERVER_ERROR`. They are
|
|
2068
|
+
published as **`STITCH_ERROR_STATUS`** (the `code → status` map) and
|
|
2069
|
+
**`StitchErrorCode`** (its `keyof`), with **`isStitchErrorCode()`** (→ ADR 0026).
|
|
2070
|
+
|
|
2071
|
+
If you translate stitch's framework errors into your own wire codes in an
|
|
2072
|
+
`onError` hook, key the map by `StitchErrorCode` so it stays exhaustive — a code
|
|
2073
|
+
stitch adds or renames becomes a compile error, not a silent `500`:
|
|
2074
|
+
|
|
2075
|
+
```ts
|
|
2076
|
+
const STITCH_TO_APP: Record<StitchErrorCode, AppCode> = {
|
|
2077
|
+
NOT_FOUND: 'NOT_FOUND', METHOD_NOT_ALLOWED: 'METHOD_NOT_ALLOWED',
|
|
2078
|
+
BAD_REQUEST: 'VALIDATION_ERROR', VALIDATION_ERROR: 'VALIDATION_ERROR',
|
|
2079
|
+
UNAUTHORIZED: 'UNAUTHORIZED', FORBIDDEN: 'FORBIDDEN', CONFLICT: 'CONFLICT',
|
|
2080
|
+
RATE_LIMITED: 'RATE_LIMITED', INTERNAL_SERVER_ERROR: 'INTERNAL_SERVER_ERROR',
|
|
2081
|
+
}
|
|
2082
|
+
onError: (ctx, err) => {
|
|
2083
|
+
if (AppError.is(err) && isStitchErrorCode(err.code)) {
|
|
2084
|
+
return jsonError(STITCH_TO_APP[err.code], err.status) // keep stitch's status
|
|
2085
|
+
}
|
|
2086
|
+
// … your own AppError / normalizeError path
|
|
2087
|
+
}
|
|
2088
|
+
```
|
|
2089
|
+
|
|
2090
|
+
|
|
2091
|
+
==============================================================================
|
|
2092
|
+
# Guide: Observability (docs/guide/observability.md)
|
|
2093
|
+
==============================================================================
|
|
2094
|
+
|
|
2095
|
+
# Observability
|
|
2096
|
+
|
|
2097
|
+
A request reaches your app through one of two surfaces — an HTTP route or a
|
|
2098
|
+
tool call (MCP / agent). Observability is the same question on both: *what
|
|
2099
|
+
happened, how long did it take, who made it, did it fail.*
|
|
2100
|
+
|
|
2101
|
+
stitchkit answers this at two levels.
|
|
2102
|
+
|
|
2103
|
+
- **The raw hooks** — `LifecycleHooks` and `ToolCallHooks`. Every request and
|
|
2104
|
+
every tool call passes through a point you can observe. The lowest level;
|
|
2105
|
+
always available. [Jump ↓](#the-raw-hooks)
|
|
2106
|
+
- **`stitchkit/observability`** — the audit layer built on those hooks: W3C
|
|
2107
|
+
trace context, an `AsyncLocalStorage` request context, payload sanitisation,
|
|
2108
|
+
and `createAuditHook` to wire it all into one sink. Your logging becomes a
|
|
2109
|
+
table plus a `write` function. [Start here ↓](#the-observability-module)
|
|
2110
|
+
|
|
2111
|
+
stitchkit still ships no logger and no audit store — those are the app's choice.
|
|
2112
|
+
What it ships is the machinery that turns a completed call into a clean,
|
|
2113
|
+
normalised record.
|
|
2114
|
+
|
|
2115
|
+
## The observability module
|
|
2116
|
+
|
|
2117
|
+
`stitchkit/observability` is server-only. It has three parts — a trace context,
|
|
2118
|
+
a request context, and the audit hook — and you usually touch only the last.
|
|
2119
|
+
|
|
2120
|
+
### createAuditHook
|
|
2121
|
+
|
|
2122
|
+
`createAuditHook` is the whole module in one call. You give it a `write` sink;
|
|
2123
|
+
it gives you back a wrapper for each surface. Every completed call — HTTP
|
|
2124
|
+
request, MCP tool call, agent tool call — is normalised into one `RequestEvent`
|
|
2125
|
+
and handed to `write`.
|
|
2126
|
+
|
|
2127
|
+
```ts
|
|
2128
|
+
import { createAuditHook } from 'stitchkit/observability'
|
|
2129
|
+
|
|
2130
|
+
export const audit = createAuditHook({
|
|
2131
|
+
// The only thing the app supplies — persist one row.
|
|
2132
|
+
write: (event) => {
|
|
2133
|
+
db.auditLog.create({ data: {
|
|
2134
|
+
traceId: event.traceId,
|
|
2135
|
+
source: event.source, // 'http' | 'mcp' | 'agent'
|
|
2136
|
+
method: event.method, // verb, or 'TOOL'
|
|
2137
|
+
path: event.path,
|
|
2138
|
+
ok: event.ok,
|
|
2139
|
+
statusCode: event.statusCode,
|
|
2140
|
+
durationMs: event.durationMs,
|
|
2141
|
+
userId: event.userId,
|
|
2142
|
+
payload: event.payload, // already sanitised
|
|
2143
|
+
}})
|
|
2144
|
+
},
|
|
2145
|
+
// Optional — keep only the events you care about.
|
|
2146
|
+
filter: (event) => event.source !== 'http' || event.method !== 'GET',
|
|
2147
|
+
})
|
|
2148
|
+
```
|
|
2149
|
+
|
|
2150
|
+
It returns an [`AuditHook`](#audithook) — `{ http, toolCall }`:
|
|
2151
|
+
|
|
2152
|
+
```ts
|
|
2153
|
+
// HTTP — wrap the fetch handler, inside wrapInRequestContext.
|
|
2154
|
+
Bun.serve({ fetch: wrapInRequestContext(audit.http(handler)) })
|
|
2155
|
+
|
|
2156
|
+
// MCP & agents — pass as the tool-call hooks.
|
|
2157
|
+
createMcpHandler({ /* … */ hooks: audit.toolCall })
|
|
2158
|
+
mountAgent(service, { hooks: audit.toolCall })
|
|
2159
|
+
```
|
|
2160
|
+
|
|
2161
|
+
One `createAuditHook`, one sink, every surface. The sink runs
|
|
2162
|
+
fire-and-forget and its errors are swallowed — a slow or failing audit write
|
|
2163
|
+
can never block or break the request it observes.
|
|
2164
|
+
|
|
2165
|
+
### RequestEvent
|
|
2166
|
+
|
|
2167
|
+
Every surface produces the same shape — so a single audit table stays
|
|
2168
|
+
queryable across all three:
|
|
2169
|
+
|
|
2170
|
+
| Field | Notes |
|
|
2171
|
+
|-------|-------|
|
|
2172
|
+
| `source` | `http` \| `mcp` \| `agent` |
|
|
2173
|
+
| `method` / `path` | the verb + path, or `TOOL` + `/{source}/{tool}` |
|
|
2174
|
+
| `toolName` | tool calls only |
|
|
2175
|
+
| `traceId` / `spanId` / `parentSpanId` | [W3C trace context](#trace-context) |
|
|
2176
|
+
| `ok` / `statusCode` | outcome — real HTTP status, or `200`/`400` for a tool |
|
|
2177
|
+
| `durationMs` / `startedAt` | timing |
|
|
2178
|
+
| `errorCode` / `errorMessage` | failures only |
|
|
2179
|
+
| `payload` | the request body / tool arguments — sanitised |
|
|
2180
|
+
| `resultSize` / `responseBytes` | result item count + serialised size |
|
|
2181
|
+
| `userId` / `ipAddress` / `userAgent` | identity |
|
|
2182
|
+
|
|
2183
|
+
### Request context
|
|
2184
|
+
|
|
2185
|
+
`createAuditHook`'s `http` wrapper reads a request context — trace ids, timing,
|
|
2186
|
+
identity — from `AsyncLocalStorage`. `wrapInRequestContext` establishes it, and
|
|
2187
|
+
must be the **outermost** wrapper of your fetch handler:
|
|
2188
|
+
|
|
2189
|
+
```ts
|
|
2190
|
+
import { getTraceId, wrapInRequestContext } from 'stitchkit/observability'
|
|
2191
|
+
|
|
2192
|
+
Bun.serve({
|
|
2193
|
+
fetch: wrapInRequestContext(audit.http(handler)),
|
|
2194
|
+
})
|
|
2195
|
+
```
|
|
2196
|
+
|
|
2197
|
+
Two fields are filled in late — the resolved user, and the error outcome. Set
|
|
2198
|
+
them from the hooks that know:
|
|
2199
|
+
|
|
2200
|
+
```ts
|
|
2201
|
+
import { setRequestError, setRequestUser } from 'stitchkit/observability'
|
|
2202
|
+
|
|
2203
|
+
createAuthHook({ /* … */ inject: (ctx, user) => user && setRequestUser(user.id) })
|
|
2204
|
+
// in your onError hook:
|
|
2205
|
+
setRequestError({ code: err.code, message: err.message })
|
|
2206
|
+
```
|
|
2207
|
+
|
|
2208
|
+
Make the framework router share this trace id — so request logs and your
|
|
2209
|
+
application logs carry one id — by passing `getTraceId` as the resolver:
|
|
2210
|
+
|
|
2211
|
+
```ts
|
|
2212
|
+
createHandler({ /* … */ traceId: getTraceId })
|
|
2213
|
+
```
|
|
2214
|
+
|
|
2215
|
+
`getRequestContext()` / `getTraceId()` then return the active values from
|
|
2216
|
+
anywhere in the call — stamp `getTraceId()` onto every line your logger writes.
|
|
2217
|
+
|
|
2218
|
+
### Trace context
|
|
2219
|
+
|
|
2220
|
+
stitchkit speaks [W3C Trace Context](https://www.w3.org/TR/trace-context/). An
|
|
2221
|
+
inbound `traceparent` header is continued — its trace id is kept, its span
|
|
2222
|
+
becomes the parent — so a trace spans the front-end call, your HTTP handler and
|
|
2223
|
+
every tool call underneath it. With no inbound header a fresh root trace is
|
|
2224
|
+
minted. Each tool call opens a [`childSpan`](#trace-context) of the request it
|
|
2225
|
+
runs in.
|
|
2226
|
+
|
|
2227
|
+
You rarely call the trace functions directly — `wrapInRequestContext` and
|
|
2228
|
+
`createAuditHook` use them for you. They are exported (`resolveTraceContext`,
|
|
2229
|
+
`parseTraceparent`, `formatTraceparent`, `childSpan`) for when you need to
|
|
2230
|
+
propagate a `traceparent` onward to another service.
|
|
2231
|
+
|
|
2232
|
+
> **Span ids live in the request context, not on `ctx`.** The handler `ctx`
|
|
2233
|
+
> carries a single `traceId`; the full `{ traceId, spanId, parentSpanId }` is on
|
|
2234
|
+
> the observability request context. To stamp `spanId` / `parentSpanId` into an
|
|
2235
|
+
> audit row read `getRequestContext()?.trace`, not `ctx.spanId`:
|
|
2236
|
+
>
|
|
2237
|
+
> ```ts
|
|
2238
|
+
> const trace = getRequestContext()?.trace
|
|
2239
|
+
> audit({ traceId: trace?.traceId, spanId: trace?.spanId, parentSpanId: trace?.parentSpanId })
|
|
2240
|
+
> ```
|
|
2241
|
+
|
|
2242
|
+
### Sanitisation
|
|
2243
|
+
|
|
2244
|
+
A payload goes into an audit row only after `sanitizePayload`:
|
|
2245
|
+
|
|
2246
|
+
- **secret-named keys are masked** — `password`, `token`, `apiKey`, `secret`,
|
|
2247
|
+
`authorization`, `cookie`, … (value → `[redacted]`);
|
|
2248
|
+
- **binary blobs** (`Uint8Array`, `Blob`, `FormData`) collapse to metadata —
|
|
2249
|
+
never the bytes;
|
|
2250
|
+
- the result is **capped** — anything over the byte limit becomes a preview.
|
|
2251
|
+
|
|
2252
|
+
`createAuditHook` runs it on every event; tune it through `sanitize`:
|
|
2253
|
+
|
|
2254
|
+
```ts
|
|
2255
|
+
createAuditHook({
|
|
2256
|
+
write,
|
|
2257
|
+
sanitize: { maxBytes: 8_000, sensitiveKeys: /password|token|pin/i },
|
|
2258
|
+
})
|
|
2259
|
+
```
|
|
2260
|
+
|
|
2261
|
+
`redact`, `truncatePreview` and `measureSize` are exported on their own if you
|
|
2262
|
+
need to sanitise something outside the audit path.
|
|
2263
|
+
|
|
2264
|
+
## The raw hooks
|
|
2265
|
+
|
|
2266
|
+
`createAuditHook` is built on hooks you can also use directly — for a one-off
|
|
2267
|
+
metric, a custom log line, anything that is not a full audit row.
|
|
2268
|
+
|
|
2269
|
+
| Surface | Hook | Fires |
|
|
2270
|
+
|---------|------|-------|
|
|
2271
|
+
| HTTP | `LifecycleHooks.afterHandle` / `onError` | after each HTTP request |
|
|
2272
|
+
| MCP & agent tools | `ToolCallHooks.afterToolCall` | after each tool call |
|
|
2273
|
+
|
|
2274
|
+
`afterHandle(ctx, result, endpoint)` runs after a handler returns;
|
|
2275
|
+
`onError(ctx, error, endpoint)` when one throws. `afterToolCall(toolName, args,
|
|
2276
|
+
result, durationMs, context)` runs after every tool call — success and error
|
|
2277
|
+
alike — carrying the tool name, the arguments, the result, the duration and the
|
|
2278
|
+
call context.
|
|
2279
|
+
|
|
2280
|
+
```ts
|
|
2281
|
+
createMcpHandler({
|
|
2282
|
+
serverInfo, auth, services,
|
|
2283
|
+
hooks: {
|
|
2284
|
+
afterToolCall: (toolName, _args, result, durationMs, context) => {
|
|
2285
|
+
metrics.timing(`tool.${toolName}`, durationMs, { ok: String(result.ok) })
|
|
2286
|
+
},
|
|
2287
|
+
},
|
|
2288
|
+
})
|
|
2289
|
+
```
|
|
2290
|
+
|
|
2291
|
+
Log **after** completion — a record is of a *finished* call; you need the
|
|
2292
|
+
outcome and the duration, neither of which exists before the handler runs.
|
|
2293
|
+
|
|
2294
|
+
### Keying a row on (service, action)
|
|
2295
|
+
|
|
2296
|
+
For a per-endpoint audit row keyed by **service** and **action**, read the
|
|
2297
|
+
endpoint identity off the `MethodDef` the hook receives — `endpoint.serviceName`
|
|
2298
|
+
(the contract prefix) and `endpoint.key` (the endpoint key, e.g. `updatePartial`).
|
|
2299
|
+
They are stable and always present (→ ADR 0022); the action is not in the URL and
|
|
2300
|
+
`toolName` is absent on HTTP-only endpoints, so this is the only reliable pair.
|
|
2301
|
+
`afterHandle` also gives you the handler `result` — so it, not `createAuditHook`'s
|
|
2302
|
+
HTTP wrapper (which never sees the response body), is the home for a rich mutation
|
|
2303
|
+
audit that records output:
|
|
2304
|
+
|
|
2305
|
+
```ts
|
|
2306
|
+
hooks: {
|
|
2307
|
+
afterHandle: (ctx, result, endpoint) => {
|
|
2308
|
+
void logMutation({
|
|
2309
|
+
service: endpoint.serviceName,
|
|
2310
|
+
action: endpoint.key,
|
|
2311
|
+
input: ctx.input,
|
|
2312
|
+
output: result,
|
|
2313
|
+
userId: ctx.userId,
|
|
2314
|
+
traceId: ctx.traceId,
|
|
2315
|
+
})
|
|
2316
|
+
return result // don't transform
|
|
2317
|
+
},
|
|
2318
|
+
}
|
|
2319
|
+
```
|
|
2320
|
+
|
|
2321
|
+
> **Why the HTTP audit is a wrapper, not a lifecycle hook.** `LifecycleHooks`
|
|
2322
|
+
> has a single `onError` — an audit built on it would compete with the app's own
|
|
2323
|
+
> error handler. `createAuditHook`'s `http` wrapper sees the final `Response`
|
|
2324
|
+
> instead, success and error alike, and never contends for a hook. The raw
|
|
2325
|
+
> lifecycle hooks remain yours for everything else.
|
|
2326
|
+
|
|
2327
|
+
Keep any sink **asynchronous and self-contained**: a slow or failing write must
|
|
2328
|
+
never block or break the request. Swallow the sink's own errors.
|
|
2329
|
+
|
|
2330
|
+
|
|
2331
|
+
==============================================================================
|
|
2332
|
+
# Guide: Testing & deployment (docs/guide/testing-and-deployment.md)
|
|
2333
|
+
==============================================================================
|
|
2334
|
+
|
|
2335
|
+
# Testing & deployment
|
|
2336
|
+
|
|
2337
|
+
## Testing
|
|
2338
|
+
|
|
2339
|
+
stitchkit's own test suite runs on `bun:test`. The contract makes most of an
|
|
2340
|
+
API testable without a live socket.
|
|
2341
|
+
|
|
2342
|
+
### Test handlers in process
|
|
2343
|
+
|
|
2344
|
+
`createHandler` is the router as a plain `(req) => Promise<Response>` function —
|
|
2345
|
+
no `Bun.serve`, no port. Drive it with a `Request`:
|
|
2346
|
+
|
|
2347
|
+
```ts
|
|
2348
|
+
import { test, expect } from 'bun:test'
|
|
2349
|
+
import { createHandler } from 'stitchkit/server'
|
|
2350
|
+
import { implement } from 'stitchkit/server'
|
|
2351
|
+
import { notes } from '../shared/contracts'
|
|
2352
|
+
|
|
2353
|
+
const handler = createHandler({
|
|
2354
|
+
services: [implement(notes, {
|
|
2355
|
+
list: () => [],
|
|
2356
|
+
create: (ctx) => ({ id: '1', text: ctx.input.text }),
|
|
2357
|
+
get: (ctx) => ({ id: ctx.params.id, text: 'x' }),
|
|
2358
|
+
})],
|
|
2359
|
+
})
|
|
2360
|
+
|
|
2361
|
+
test('create returns the note', async () => {
|
|
2362
|
+
const res = await handler(new Request('http://test/notes', {
|
|
2363
|
+
method: 'POST',
|
|
2364
|
+
headers: { 'content-type': 'application/json' },
|
|
2365
|
+
body: JSON.stringify({ text: 'hi' }),
|
|
2366
|
+
}))
|
|
2367
|
+
expect(res.status).toBe(200)
|
|
2368
|
+
expect(await res.json()).toEqual({ id: '1', text: 'hi' })
|
|
2369
|
+
})
|
|
2370
|
+
```
|
|
2371
|
+
|
|
2372
|
+
This exercises the full pipeline — routing, schema parsing, hooks, the error
|
|
2373
|
+
envelope — with no network.
|
|
2374
|
+
|
|
2375
|
+
### Test handlers directly
|
|
2376
|
+
|
|
2377
|
+
A handler is a plain function of `ctx`. For pure handler logic, call it with a
|
|
2378
|
+
context object directly — no HTTP at all. The contract's types keep the test
|
|
2379
|
+
`ctx` honest.
|
|
2380
|
+
|
|
2381
|
+
### Validation and errors
|
|
2382
|
+
|
|
2383
|
+
A bad request body comes back as `400 VALIDATION_ERROR`; a thrown `AppError`
|
|
2384
|
+
comes back with its `code` and `status`. Assert on the envelope:
|
|
2385
|
+
|
|
2386
|
+
```ts
|
|
2387
|
+
const res = await handler(new Request('http://test/notes/missing'))
|
|
2388
|
+
expect(res.status).toBe(404)
|
|
2389
|
+
expect(await res.json()).toEqual({ error: { code: 'NOT_FOUND', message: 'Note not found' } })
|
|
2390
|
+
```
|
|
2391
|
+
|
|
2392
|
+
stitchkit's own suite — 143 tests in `packages/core/tests` — is the working
|
|
2393
|
+
reference for testing each piece.
|
|
2394
|
+
|
|
2395
|
+
## Deployment
|
|
2396
|
+
|
|
2397
|
+
### Build
|
|
2398
|
+
|
|
2399
|
+
A stitchkit app is a Bun program — there is no framework build step. Bundle it
|
|
2400
|
+
however the project already does (`bun build`, or run the entry file directly).
|
|
2401
|
+
The `stitchkit` package itself ships pre-built; you consume `dist/`, not `src/`.
|
|
2402
|
+
|
|
2403
|
+
### Runtime
|
|
2404
|
+
|
|
2405
|
+
The recommended target is **Bun ≥ 1.2** — `createServer` is `Bun.serve`. **Node
|
|
2406
|
+
≥ 22** is also supported via `stitchkit/node` (see below).
|
|
2407
|
+
|
|
2408
|
+
```ts
|
|
2409
|
+
createServer({
|
|
2410
|
+
services,
|
|
2411
|
+
port: Number(process.env.PORT ?? 3000),
|
|
2412
|
+
hostname: '0.0.0.0',
|
|
2413
|
+
})
|
|
2414
|
+
```
|
|
2415
|
+
|
|
2416
|
+
`createServer` returns the `Bun.serve` instance — keep the reference if you need
|
|
2417
|
+
`.stop()` for a graceful shutdown.
|
|
2418
|
+
|
|
2419
|
+
### Deploy on Node
|
|
2420
|
+
|
|
2421
|
+
The contract, `implement`, hooks, auth and the client are runtime-agnostic. Only
|
|
2422
|
+
the listener differs: replace `createServer` with **`serveNode`** (from
|
|
2423
|
+
`stitchkit/node`, built on `srvx`) — same `HandlerConfig`:
|
|
2424
|
+
|
|
2425
|
+
```ts
|
|
2426
|
+
import { serveNode } from 'stitchkit/node'
|
|
2427
|
+
|
|
2428
|
+
serveNode({
|
|
2429
|
+
services,
|
|
2430
|
+
port: Number(process.env.PORT ?? 3000),
|
|
2431
|
+
})
|
|
2432
|
+
```
|
|
2433
|
+
|
|
2434
|
+
Notes for a Node host:
|
|
2435
|
+
|
|
2436
|
+
- Add **`@types/bun`** as a dev dependency — it is an optional peer that types the
|
|
2437
|
+
shared `stitchkit/server` surface (without it `tsc` reports a missing `Bun`
|
|
2438
|
+
namespace).
|
|
2439
|
+
- **Socket.IO** attaches to the Node HTTP server via `serveNode({ socket })`, and
|
|
2440
|
+
on Node the default transport is `['websocket']` — set the client to match
|
|
2441
|
+
(`transports: ['websocket']`). See [realtime](./realtime.md).
|
|
2442
|
+
- **Bun-only helpers** do not run on Node: `serveFile` (uses `Bun.file`) and the
|
|
2443
|
+
raw WebSocket lane. `staticRoute` is runtime-neutral (`node:fs`) and works on
|
|
2444
|
+
both, but in production prefer a CDN / the static front-end below.
|
|
2445
|
+
|
|
2446
|
+
### Production checklist
|
|
2447
|
+
|
|
2448
|
+
- **CORS** — set `cors.origin` to your real front-end origin(s). Do not ship
|
|
2449
|
+
`origin: '*'` with credentials.
|
|
2450
|
+
- **Logging** — `logging: true` for built-in request logs, or pass a
|
|
2451
|
+
`StitchLogger` to route them into your logging stack.
|
|
2452
|
+
- **Trace ids** — override `traceId` to reuse an id your platform already
|
|
2453
|
+
assigns, so request logs and application logs share one id.
|
|
2454
|
+
- **Rate limiting** — `createRateLimiter` in `onRequest` for a global limit;
|
|
2455
|
+
per-route limits belong in `beforeHandle`.
|
|
2456
|
+
- **Auth** — a `createAuthHook` `beforeHandle` guards every transport at once;
|
|
2457
|
+
do not re-check auth per handler.
|
|
2458
|
+
- **Errors** — handlers throw `AppError`; let the standard envelope render them.
|
|
2459
|
+
Add an `onError` hook only to integrate an error tracker.
|
|
2460
|
+
- **Secrets** — read them from the environment; never commit them.
|
|
2461
|
+
|
|
2462
|
+
### The static front-end
|
|
2463
|
+
|
|
2464
|
+
stitchkit serves the API. A SPA front-end is built and hosted separately — a
|
|
2465
|
+
static host or CDN in production, its own dev server in development. The backend
|
|
2466
|
+
does not serve static files (`staticRoute` exists for the occasional asset, not
|
|
2467
|
+
a whole app). See [`packages/starter`](../../packages/starter) for the split.
|
|
2468
|
+
|
|
2469
|
+
### MCP
|
|
2470
|
+
|
|
2471
|
+
If the app exposes MCP tools, `createMcpHandler` is mounted as a raw route
|
|
2472
|
+
(`/mcp`). It needs the `@modelcontextprotocol/sdk` peer installed in production —
|
|
2473
|
+
it is optional only for apps that do not use MCP.
|
|
2474
|
+
|
|
2475
|
+
|
|
2476
|
+
==============================================================================
|
|
2477
|
+
# Guide: Multi-tenant (docs/guide/multi-tenant.md)
|
|
2478
|
+
==============================================================================
|
|
2479
|
+
|
|
2480
|
+
# Multi-tenant / resource-scoped paths
|
|
2481
|
+
|
|
2482
|
+
A walkthrough of one mainstream scenario end-to-end: a SaaS API scoped by a path
|
|
2483
|
+
segment — `/tenants/:tenantId/widgets/...` — where the same `tenantId` gates
|
|
2484
|
+
access in auth and is injected into tool calls (one API key → many tenants).
|
|
2485
|
+
|
|
2486
|
+
Every piece below already exists; this recipe wires them into one flow. Each step
|
|
2487
|
+
links to its reference section.
|
|
2488
|
+
|
|
2489
|
+
## 1. The contract — tenant-agnostic
|
|
2490
|
+
|
|
2491
|
+
Endpoints know nothing about the tenant; the path segment lives on the group, not
|
|
2492
|
+
the endpoint:
|
|
2493
|
+
|
|
2494
|
+
```ts
|
|
2495
|
+
export const widgets = defineContract(
|
|
2496
|
+
{ prefix: 'widgets', scope: 'tenant' },
|
|
2497
|
+
{
|
|
2498
|
+
list: { method: 'GET', path: '/', desc: 'List widgets', output: WidgetList },
|
|
2499
|
+
create: { method: 'POST', path: '/', desc: 'Create a widget', input: NewWidget, output: Widget },
|
|
2500
|
+
},
|
|
2501
|
+
)
|
|
2502
|
+
```
|
|
2503
|
+
|
|
2504
|
+
## 2. The server — a param prefix
|
|
2505
|
+
|
|
2506
|
+
Mount the service under a group whose `pathPrefix` carries `:tenantId`. The
|
|
2507
|
+
matched value is on the context root as `ctx.tenantId`
|
|
2508
|
+
([details](./server.md#param-prefixes-resource-scoped-paths)):
|
|
2509
|
+
|
|
2510
|
+
```ts
|
|
2511
|
+
createServer({
|
|
2512
|
+
groups: [
|
|
2513
|
+
{ pathPrefix: '/tenants/:tenantId', services: [widgetsService], hooks: { beforeHandle: authHook } },
|
|
2514
|
+
],
|
|
2515
|
+
})
|
|
2516
|
+
// → /tenants/:tenantId/widgets
|
|
2517
|
+
```
|
|
2518
|
+
|
|
2519
|
+
With more than one scope, let **`scopePrefixes`** map `scope → prefix` and mount
|
|
2520
|
+
the flat `services` list — no hand-partitioning, the mapping lives in one place
|
|
2521
|
+
([details](./server.md#scope-driven-mounting-scopeprefixes), → ADR 0024):
|
|
2522
|
+
|
|
2523
|
+
```ts
|
|
2524
|
+
createServer({
|
|
2525
|
+
services, // mixed scopes, listed once
|
|
2526
|
+
scopePrefixes: { tenant: 'tenants/:tenantId', project: 'projects/:projectId' },
|
|
2527
|
+
hooks: { beforeHandle: authHook },
|
|
2528
|
+
})
|
|
2529
|
+
// `tenant`-scoped → /tenants/:tenantId/..., `project` → /projects/:projectId/..., the rest flat
|
|
2530
|
+
```
|
|
2531
|
+
|
|
2532
|
+
Handlers read `ctx.tenantId` (a raw `string` — narrow it). To get it typed inside
|
|
2533
|
+
`ctx.params`, add `tenantId` to the endpoint's `params` schema (a `z.strictObject`
|
|
2534
|
+
that omits it will reject the request). When each scope guarantees different
|
|
2535
|
+
injected fields, call `createImplement<TenantCtx>()` / `createImplement<BaseCtx>()`
|
|
2536
|
+
**once per scope** and implement each contract with the matching factory — every
|
|
2537
|
+
handler is typed to its scope, with no superset context that lies about a
|
|
2538
|
+
`tenantId` a `public` handler never has.
|
|
2539
|
+
|
|
2540
|
+
## 3. Auth — gate the tenant in the path
|
|
2541
|
+
|
|
2542
|
+
The `tenant` scope rule reads the prefix param and checks access; `inject` puts
|
|
2543
|
+
the identity and derived facts on `ctx`
|
|
2544
|
+
([details](./auth-and-errors.md#resource-scoped-rule--reading-a-pathprefix-param)):
|
|
2545
|
+
|
|
2546
|
+
```ts
|
|
2547
|
+
const authHook = createAuthHook<User>({
|
|
2548
|
+
resolve: sessionResolver,
|
|
2549
|
+
rules: {
|
|
2550
|
+
public: 'public',
|
|
2551
|
+
tenant: (user, ctx) => userCanAccessTenant(user.id, String(ctx.tenantId)),
|
|
2552
|
+
} satisfies Record<Scope, AuthRule<User>>,
|
|
2553
|
+
inject: (ctx, user) => { ctx.user = user },
|
|
2554
|
+
})
|
|
2555
|
+
```
|
|
2556
|
+
|
|
2557
|
+
## 4. The client — a per-tenant `pathPrefix`
|
|
2558
|
+
|
|
2559
|
+
`createClient`'s third argument prepends the tenant segment; `stripPrefixKeys`
|
|
2560
|
+
keeps `tenantId` out of the body/query
|
|
2561
|
+
([details](./client.md#contractclientconfig--per-tenant--resource-scoped-clients)):
|
|
2562
|
+
|
|
2563
|
+
```ts
|
|
2564
|
+
const widgetsApi = createClient(widgets, http, {
|
|
2565
|
+
pathPrefix: (args) => `tenants/${args.tenantId}/`,
|
|
2566
|
+
stripPrefixKeys: ['tenantId'],
|
|
2567
|
+
})
|
|
2568
|
+
|
|
2569
|
+
widgetsApi.list({ tenantId: 't_123' }) // GET /tenants/t_123/widgets
|
|
2570
|
+
```
|
|
2571
|
+
|
|
2572
|
+
`stripPrefixKeys` (a `const` tuple) also makes the consumed keys **typed,
|
|
2573
|
+
required args** on every method — `tenantId` above is type-checked, not a runtime
|
|
2574
|
+
surprise — so no hand-written scoped-client wrapper is needed (→ ADR 0025).
|
|
2575
|
+
|
|
2576
|
+
## 5. The AI surface — `extend` injects the tenant
|
|
2577
|
+
|
|
2578
|
+
One API key serves every tenant: the model passes `tenantId` per call, `resolve`
|
|
2579
|
+
validates it and puts it on `ctx` — so the *same* handler that reads `ctx.tenantId`
|
|
2580
|
+
over HTTP serves the tool call
|
|
2581
|
+
([details](./mcp-and-agents.md#adding-tool-only-args--extend)):
|
|
2582
|
+
|
|
2583
|
+
```ts
|
|
2584
|
+
createMcpHandler({
|
|
2585
|
+
serverInfo, auth,
|
|
2586
|
+
services: [widgetsService],
|
|
2587
|
+
lifecycle: { beforeHandle: authHook },
|
|
2588
|
+
extend: {
|
|
2589
|
+
schema: { tenantId: z.string().describe('Tenant to act on') },
|
|
2590
|
+
resolve: async ({ tenantId }) => ({ tenantId: String(tenantId) }),
|
|
2591
|
+
filter: (_s, m) => m.scope === 'tenant',
|
|
2592
|
+
},
|
|
2593
|
+
})
|
|
2594
|
+
```
|
|
2595
|
+
|
|
2596
|
+
## The through-line
|
|
2597
|
+
|
|
2598
|
+
`ctx.tenantId` is the single join point: the **prefix param** puts it there on
|
|
2599
|
+
HTTP, `extend`'s **resolve** puts it there on a tool call. Handlers and the auth
|
|
2600
|
+
rule read it the same way on both surfaces — one contract, every surface, no
|
|
2601
|
+
per-transport tenant plumbing.
|
|
2602
|
+
|
|
2603
|
+
|
|
2604
|
+
==============================================================================
|
|
2605
|
+
# Guide: Upgrading (docs/guide/upgrading.md)
|
|
2606
|
+
==============================================================================
|
|
2607
|
+
|
|
2608
|
+
# Upgrading stitchkit
|
|
2609
|
+
|
|
2610
|
+
How to move a consuming project from one stitchkit version to another — including
|
|
2611
|
+
across many versions at once (a project frozen on an old version, then jumped
|
|
2612
|
+
forward). The process is mechanical: stitchkit marks every breaking change in one
|
|
2613
|
+
place and one format, so you can recover the full migration from the version diff.
|
|
2614
|
+
|
|
2615
|
+
## The one rule that makes this work
|
|
2616
|
+
|
|
2617
|
+
A release that breaks a public API leads its `CHANGELOG.md` entry with a
|
|
2618
|
+
**`### ⚠️ Breaking changes`** section (exact heading), each item carrying a
|
|
2619
|
+
**before → after** snippet. A version with **no** such section is **purely
|
|
2620
|
+
additive** — adopting it changes nothing in your code. (See
|
|
2621
|
+
[`AGENTS.md` → Breaking changes](../../AGENTS.md).)
|
|
2622
|
+
|
|
2623
|
+
So upgrading is: read the `### ⚠️ Breaking changes` of every version *above* your
|
|
2624
|
+
current one *up to* your target, and apply each snippet.
|
|
2625
|
+
|
|
2626
|
+
## Flow (agent or human)
|
|
2627
|
+
|
|
2628
|
+
1. **Find the current version.** In the consumer: the resolved `stitchkit` in
|
|
2629
|
+
`bun.lock` (authoritative), or `node_modules/stitchkit/package.json`. The range
|
|
2630
|
+
in `package.json` (`^0.6.0`) is intent, not the installed truth.
|
|
2631
|
+
> A `file:` link (`"stitchkit": "file:…"`) means the consumer tracks a **local
|
|
2632
|
+
> checkout**, not a published version — its effective version is whatever that
|
|
2633
|
+
> checkout's `package.json` says, and a plain `install` will not relink it after
|
|
2634
|
+
> the local version moves (`bun install --force` does). Prefer a real
|
|
2635
|
+
> `^x.y.z` range for reproducibility.
|
|
2636
|
+
|
|
2637
|
+
2. **Pick the target.** Latest published (`bun pm view stitchkit version`) or a
|
|
2638
|
+
specific `x.y.z`.
|
|
2639
|
+
|
|
2640
|
+
3. **Read the breaking sections in range.** In stitchkit's
|
|
2641
|
+
[`CHANGELOG.md`](../../CHANGELOG.md), for every version `> current` and
|
|
2642
|
+
`<= target`, read its `### ⚠️ Breaking changes`. Versions without that section
|
|
2643
|
+
are additive — skip them. (Fast scan: `grep -n "Breaking changes" CHANGELOG.md`.)
|
|
2644
|
+
|
|
2645
|
+
4. **Apply each migration** — the before → after snippet tells you exactly what to
|
|
2646
|
+
change at each call site. There are no deprecation shims to lean on; the old
|
|
2647
|
+
shape is gone, so every site must move.
|
|
2648
|
+
|
|
2649
|
+
5. **Bump and install.** `bun add stitchkit@<target>` (or update the range), then
|
|
2650
|
+
`bun install`. Note the caret: `^0.7.0` is `< 0.8.0`, so crossing a breaking
|
|
2651
|
+
minor is always an explicit version bump, never automatic.
|
|
2652
|
+
|
|
2653
|
+
6. **Verify.** `bun run check` (or per-package typecheck) — TypeScript catches the
|
|
2654
|
+
removed/renamed/retyped surfaces. Then a **runtime smoke** (typecheck ≠
|
|
2655
|
+
runtime): bootstrap the server, one HTTP request, and any feature you rely on
|
|
2656
|
+
(Socket.IO connect, an MCP tool call, a multipart upload, …).
|
|
2657
|
+
|
|
2658
|
+
## Worked example — frozen on 0.3, jumping to 0.7
|
|
2659
|
+
|
|
2660
|
+
1. `bun.lock` → consumer resolves `stitchkit@0.3.x`.
|
|
2661
|
+
2. Target: `0.7.0`.
|
|
2662
|
+
3. Scan CHANGELOG `### ⚠️ Breaking changes` for 0.4.0 … 0.7.0 → **none** (every
|
|
2663
|
+
release was additive — new exports, an extra hook argument, opt-in fields).
|
|
2664
|
+
4. Nothing to migrate.
|
|
2665
|
+
5. `bun add stitchkit@^0.7.0`, `bun install`.
|
|
2666
|
+
6. `bun run check` green → runtime smoke → done. New surfaces
|
|
2667
|
+
(`STITCH_ERROR_STATUS`, `serveFile`, `scopePrefixes`, `afterToolCall`'s
|
|
2668
|
+
`MethodDef`, `maxUploadBytes`) are available to adopt, not required.
|
|
2669
|
+
|
|
2670
|
+
## When you author a breaking change in stitchkit
|
|
2671
|
+
|
|
2672
|
+
You are on the other side of this flow — see
|
|
2673
|
+
[`AGENTS.md` → Breaking changes & migration](../../AGENTS.md). In short: it is
|
|
2674
|
+
allowed; write the `### ⚠️ Breaking changes` block with a before → after snippet,
|
|
2675
|
+
bump the minor (pre-1.0), and migrate the controlled consumers in the same pass.
|
|
2676
|
+
|
|
2677
|
+
|
|
2678
|
+
==============================================================================
|
|
2679
|
+
# API reference (docs/api/reference.md)
|
|
2680
|
+
==============================================================================
|
|
2681
|
+
|
|
2682
|
+
# API reference
|
|
2683
|
+
|
|
2684
|
+
Every public export of stitchkit, grouped by entrypoint. Each entry links to the
|
|
2685
|
+
guide page that explains it in context. Types are marked _type_; everything else
|
|
2686
|
+
is a value (a function, a class, a constant).
|
|
2687
|
+
|
|
2688
|
+
The root `stitchkit` entrypoint is browser-safe; `stitchkit/server` and
|
|
2689
|
+
`stitchkit/tools` are server-only. See
|
|
2690
|
+
[Getting started → entrypoints](../guide/getting-started.md#entrypoints).
|
|
2691
|
+
|
|
2692
|
+
---
|
|
2693
|
+
|
|
2694
|
+
## `stitchkit`
|
|
2695
|
+
|
|
2696
|
+
The browser-and-server entrypoint. Re-exports everything from
|
|
2697
|
+
[`stitchkit/contract`](#stitchkitcontract), plus the client and browser realtime.
|
|
2698
|
+
|
|
2699
|
+
### Client
|
|
2700
|
+
|
|
2701
|
+
| Export | Kind | Summary |
|
|
2702
|
+
|--------|------|---------|
|
|
2703
|
+
| `createClient` | function | build a typed client from a contract — [guide](../guide/client.md#createclient) |
|
|
2704
|
+
| `createClients` | function | build one typed client per contract from a registry |
|
|
2705
|
+
| `ClientConfig` | _type_ | config for `createClient`'s bare-fetch mode (2nd arg, no `HttpClient`) |
|
|
2706
|
+
| `ContractClientConfig` | _type_ | per-tenant / resource-scoped client config — dynamic `pathPrefix` + `stripPrefixKeys` ([guide](../guide/client.md#contractclientconfig--per-tenant--resource-scoped-clients)) |
|
|
2707
|
+
| `createHttpClient` | function | the Ky-based HTTP transport — [guide](../guide/client.md#createhttpclient) |
|
|
2708
|
+
| `ApiError` | class | a non-2xx response, with `code` / `status` / `details` / `hint` |
|
|
2709
|
+
| `HttpClient` | _type_ | the transport interface `createClient` builds on |
|
|
2710
|
+
| `HttpClientConfig` | _type_ | config for `createHttpClient` |
|
|
2711
|
+
| `RequestOptions` | _type_ | per-call options — params, timeout, response type |
|
|
2712
|
+
| `HeaderProvider` | _type_ | static or per-request headers |
|
|
2713
|
+
| `ApiEvent` | _type_ | a client event — `unauthorized` / `network_error` / `logout` |
|
|
2714
|
+
| `ApiEventListener` | _type_ | an `ApiEvent` handler |
|
|
2715
|
+
|
|
2716
|
+
### Realtime (client) & streaming
|
|
2717
|
+
|
|
2718
|
+
| Export | Kind | Summary |
|
|
2719
|
+
|--------|------|---------|
|
|
2720
|
+
| `createSocketIOClient` | function | the typed Socket.IO client — [guide](../guide/realtime.md#client--createsocketioclient) |
|
|
2721
|
+
| `parseSSE` | function | parse an SSE `Response` into an async generator — [guide](../guide/client.md#sse) |
|
|
2722
|
+
| `SocketIOClient` | _type_ | the client handle |
|
|
2723
|
+
| `SocketIOClientConfig` | _type_ | config for `createSocketIOClient` |
|
|
2724
|
+
| `SocketEventMap` | _type_ | the shape of an event map |
|
|
2725
|
+
| `ParseSSEOptions` | _type_ | options for `parseSSE` |
|
|
2726
|
+
|
|
2727
|
+
---
|
|
2728
|
+
|
|
2729
|
+
## `stitchkit/contract`
|
|
2730
|
+
|
|
2731
|
+
The contract layer alone — browser-and-server safe. All of this is also exported
|
|
2732
|
+
from the root `stitchkit`.
|
|
2733
|
+
|
|
2734
|
+
### Contract
|
|
2735
|
+
|
|
2736
|
+
| Export | Kind | Summary |
|
|
2737
|
+
|--------|------|---------|
|
|
2738
|
+
| `defineContract` | function | declare a contract — [guide](../guide/contracts.md#definecontract) |
|
|
2739
|
+
| `ALL_TRANSPORTS` | constant | `['HTTP', 'MCP', 'AGENT', 'CLI']` |
|
|
2740
|
+
| `ContractDef` | _type_ | a defined contract |
|
|
2741
|
+
| `ContractMeta` | _type_ | a contract's `prefix` + optional `scope` |
|
|
2742
|
+
| `EndpointDef` | _type_ | a single endpoint definition |
|
|
2743
|
+
| `HttpMethod` | _type_ | `GET \| POST \| PUT \| PATCH \| DELETE` |
|
|
2744
|
+
| `Transport` | _type_ | `HTTP \| MCP \| AGENT \| CLI` |
|
|
2745
|
+
| `TransportSource` | _type_ | `http \| mcp \| agent \| cli` — the value of `ctx.source` |
|
|
2746
|
+
| `RuntimeContext` | _type_ | the loose context seen by transport and hooks |
|
|
2747
|
+
| `HandlerContext` | _type_ | the typed context seen by a handler |
|
|
2748
|
+
| `EndpointFn` | _type_ | the call signature of one client method |
|
|
2749
|
+
| `TypedClient` | _type_ | the full typed client for a contract |
|
|
2750
|
+
| `TypedHttpClient` | _type_ | the typed client, HTTP endpoints only (`= ScopedHttpClient<C, unknown>`) |
|
|
2751
|
+
| `ScopedHttpClient` | _type_ | a client whose `stripPrefixKeys` become required args ([guide](../guide/multi-tenant.md)) |
|
|
2752
|
+
| `ScopedEndpointFn` | _type_ | one method's signature with the consumed keys folded in |
|
|
2753
|
+
|
|
2754
|
+
### Errors
|
|
2755
|
+
|
|
2756
|
+
| Export | Kind | Summary |
|
|
2757
|
+
|--------|------|---------|
|
|
2758
|
+
| `AppError` | class | the framework error — `code` / `status` / `details` / `hint` |
|
|
2759
|
+
| `ErrorEnvelope` | _type_ | the JSON shape of an error response |
|
|
2760
|
+
| `notFound` | function | throw `404 NOT_FOUND` — [guide](../guide/auth-and-errors.md#throwing-errors) |
|
|
2761
|
+
| `badRequest` | function | throw `400 BAD_REQUEST` |
|
|
2762
|
+
| `unauthorized` | function | throw `401 UNAUTHORIZED` |
|
|
2763
|
+
| `forbidden` | function | throw `403 FORBIDDEN` |
|
|
2764
|
+
| `conflict` | function | throw `409 CONFLICT` |
|
|
2765
|
+
| `rateLimited` | function | throw `429 RATE_LIMITED` |
|
|
2766
|
+
| `appError` | function | throw an `AppError` for any code |
|
|
2767
|
+
| `STITCH_ERROR_STATUS` | const | `code → HTTP status` map for stitchkit's own error codes — [guide](../guide/auth-and-errors.md#stitch-codes-vs-your-codes) |
|
|
2768
|
+
| `StitchErrorCode` | _type_ | a code stitchkit itself emits (`keyof STITCH_ERROR_STATUS`) |
|
|
2769
|
+
| `isStitchErrorCode` | function | type guard — is a code one of stitchkit's own? |
|
|
2770
|
+
|
|
2771
|
+
### Pagination
|
|
2772
|
+
|
|
2773
|
+
| Export | Kind | Summary |
|
|
2774
|
+
|--------|------|---------|
|
|
2775
|
+
| `paginatedSchema` | function | the `{ items, nextCursor }` Zod schema — [guide](../guide/contracts.md#pagination) |
|
|
2776
|
+
| `Paginated` | _type_ | the cursor-pagination envelope |
|
|
2777
|
+
| `encodeCursor` | function | encode a keyset value into an opaque `nextCursor` string (base64url, UTF-8-safe) |
|
|
2778
|
+
| `decodeCursor` | function | decode + Zod-validate a cursor back to its value (`null` if missing/invalid) |
|
|
2779
|
+
|
|
2780
|
+
---
|
|
2781
|
+
|
|
2782
|
+
## `stitchkit/server`
|
|
2783
|
+
|
|
2784
|
+
Server-only. Builds and runs the HTTP server, and carries the server primitives.
|
|
2785
|
+
Also re-exports the error helpers from `stitchkit/contract`.
|
|
2786
|
+
|
|
2787
|
+
### Server & handlers
|
|
2788
|
+
|
|
2789
|
+
| Export | Kind | Summary |
|
|
2790
|
+
|--------|------|---------|
|
|
2791
|
+
| `createServer` | function | build the router and start `Bun.serve` — [guide](../guide/server.md#createserver) |
|
|
2792
|
+
| `createHandler` | function | the router as a bare `(req) => Response` — [guide](../guide/server.md#createserver) |
|
|
2793
|
+
| `implement` | function | bind a contract to typed handlers — [guide](../guide/server.md#implement) |
|
|
2794
|
+
| `createImplement` | function | fix the handler context type once |
|
|
2795
|
+
| `staticRoute` | function | a raw route that serves a directory |
|
|
2796
|
+
| `serveFile` | function | serve a file with `Range` / `304` / `HEAD` — [guide](../guide/server.md#serving-files--range-requests) |
|
|
2797
|
+
| `parseByteRange` | function | parse a single `Range` header → range / `unsatisfiable` / `null` |
|
|
2798
|
+
| `weakETag` | function | a weak `ETag` from size + mtime |
|
|
2799
|
+
| `ServeFileOptions` | _type_ | options for `serveFile` |
|
|
2800
|
+
| `ByteRange` | _type_ | an inclusive `{ start, end }` byte range |
|
|
2801
|
+
| `respondJson` | function | a raw route's JSON response (`204` for null/undefined) |
|
|
2802
|
+
| `errorResponse` | function | any thrown value → the framework error envelope + `x-request-id` |
|
|
2803
|
+
| `parseBody` | function | parse + Zod-validate a JSON body → `data` or `null` (no throw) |
|
|
2804
|
+
| `HandlerConfig` | _type_ | config for `createHandler` (runtime-agnostic) |
|
|
2805
|
+
| `BunServerConfig` | _type_ | config for `createServer` (Bun) |
|
|
2806
|
+
| `ServiceDef` | _type_ | the result of `implement` |
|
|
2807
|
+
| `MethodDef` | _type_ | one resolved endpoint inside a service |
|
|
2808
|
+
| `Handlers` | _type_ | the typed handler map `implement` expects |
|
|
2809
|
+
| `LifecycleHooks` | _type_ | `onRequest` / `beforeHandle` / `afterHandle` / `onError` |
|
|
2810
|
+
| `RouteGroup` | _type_ | a prefixed group of services with its own hooks |
|
|
2811
|
+
| `RawRoute` | _type_ | a non-contract `Request → Response` route |
|
|
2812
|
+
| `RawRouteContext` | _type_ | the routing context a raw handler receives |
|
|
2813
|
+
| `BunServer` | _type_ | the `Bun.serve` instance type |
|
|
2814
|
+
| `ServerPassthrough` | _type_ | extra `Bun.serve` options |
|
|
2815
|
+
| `StitchLogger` | _type_ | the custom-logger interface |
|
|
2816
|
+
|
|
2817
|
+
### Auth
|
|
2818
|
+
|
|
2819
|
+
| Export | Kind | Summary |
|
|
2820
|
+
|--------|------|---------|
|
|
2821
|
+
| `createAuthHook` | function | a scope-enforcing `beforeHandle` hook — [guide](../guide/auth-and-errors.md#createauthhook) |
|
|
2822
|
+
| `createBearerResolver` | function | a bearer-token identity resolver |
|
|
2823
|
+
| `verifyJwt` | function | verify an HS256 JWT |
|
|
2824
|
+
| `extractToken` | function | read a bearer token from header or cookie |
|
|
2825
|
+
| `AuthHook` | _type_ | the hook `createAuthHook` returns |
|
|
2826
|
+
| `AuthHookConfig` | _type_ | config for `createAuthHook` |
|
|
2827
|
+
| `AuthRule` | _type_ | `'public' \| 'authenticated' \| predicate` |
|
|
2828
|
+
| `BearerResolverConfig` | _type_ | config for `createBearerResolver` |
|
|
2829
|
+
| `JwtPayload` | _type_ | a decoded JWT payload |
|
|
2830
|
+
|
|
2831
|
+
### Cookies & CORS
|
|
2832
|
+
|
|
2833
|
+
| Export | Kind | Summary |
|
|
2834
|
+
|--------|------|---------|
|
|
2835
|
+
| `defineCookie` | function | a typed cookie `get` / `set` / `clear` handle — [guide](../guide/auth-and-errors.md#cookies) |
|
|
2836
|
+
| `parseCookies` | function | parse a `Cookie` header to a record |
|
|
2837
|
+
| `serializeCookie` | function | build a `Set-Cookie` value |
|
|
2838
|
+
| `corsHeaders` | function | compute CORS response headers |
|
|
2839
|
+
| `corsPreflightResponse` | function | build a preflight `Response` |
|
|
2840
|
+
| `CookieDef` | _type_ | the `defineCookie` handle |
|
|
2841
|
+
| `CookieOptions` | _type_ | cookie attributes |
|
|
2842
|
+
| `CorsConfig` | _type_ | CORS policy |
|
|
2843
|
+
|
|
2844
|
+
### Realtime (server)
|
|
2845
|
+
|
|
2846
|
+
| Export | Kind | Summary |
|
|
2847
|
+
|--------|------|---------|
|
|
2848
|
+
| `createSocketIOServer` | function | the typed Socket.IO server — [guide](../guide/realtime.md#server--createsocketioserver) |
|
|
2849
|
+
| `SocketIOServerConfig` | _type_ | config for `createSocketIOServer` |
|
|
2850
|
+
| `SocketIOServerHandle` | _type_ | the `{ io, websocket, route }` handle |
|
|
2851
|
+
| `composeWebSocketHandlers` | function | compose one Bun `websocket` from N lanes — a raw binary lane beside Socket.IO ([guide](../guide/realtime.md#raw-binary-lane-bun)) |
|
|
2852
|
+
| `webSocketLane` | function | a typed, cast-free lane for `composeWebSocketHandlers` |
|
|
2853
|
+
| `socketIoLane` | function | the Socket.IO catch-all lane for `composeWebSocketHandlers` |
|
|
2854
|
+
| `ComposedLane` | _type_ | a lane bridged to the loose data type |
|
|
2855
|
+
| `WebSocketLane` | _type_ | a typed lane (`{ match, handlers }`) |
|
|
2856
|
+
| `WebSocketComposeConfig` | _type_ | server-wide tuning for the composed handler |
|
|
2857
|
+
|
|
2858
|
+
### Primitives
|
|
2859
|
+
|
|
2860
|
+
| Export | Kind | Summary |
|
|
2861
|
+
|--------|------|---------|
|
|
2862
|
+
| `streamSSE` | function | an async generator → SSE `Response` — [guide](../guide/server.md#sse-streaming) |
|
|
2863
|
+
| `parseSSE` | function | parse an SSE `Response` (also on the root entrypoint) |
|
|
2864
|
+
| `parseMultipart` | function | parse a `multipart/form-data` request — [guide](../guide/server.md#multipart) |
|
|
2865
|
+
| `createRateLimiter` | function | token-bucket rate limiting — [guide](../guide/server.md#rate-limiting) |
|
|
2866
|
+
| `createCache` | function | an in-memory TTL cache |
|
|
2867
|
+
| `cacheHeaders` | function | build a `Cache-Control` header |
|
|
2868
|
+
| `createEventBus` | function | typed in-process pub/sub — [guide](../guide/server.md#event-bus) |
|
|
2869
|
+
| `generateTraceId` | function | a fresh trace id |
|
|
2870
|
+
| `resolveTraceId` | function | the default per-request trace-id resolver |
|
|
2871
|
+
| `extractIp` | function | the caller IP from a request |
|
|
2872
|
+
| `getClientInfo` | function | caller IP + user-agent |
|
|
2873
|
+
| `EventBus` | _type_ | the `createEventBus` handle |
|
|
2874
|
+
| `RateLimitConfig` | _type_ | config for `createRateLimiter` |
|
|
2875
|
+
| `ParseSSEOptions` | _type_ | options for `parseSSE` |
|
|
2876
|
+
|
|
2877
|
+
### OpenAPI
|
|
2878
|
+
|
|
2879
|
+
| Export | Kind | Summary |
|
|
2880
|
+
|--------|------|---------|
|
|
2881
|
+
| `generateOpenApiDocument` | function | an OpenAPI 3.1 document from contract services — [ADR 0018](../decisions/0018-openapi-generation.md) |
|
|
2882
|
+
| `openApiRoute` | function | a `RawRoute` that serves the document as JSON |
|
|
2883
|
+
| `OpenApiConfig` | _type_ | config for `generateOpenApiDocument` |
|
|
2884
|
+
| `OpenApiDocument` | _type_ | the generated document |
|
|
2885
|
+
| `OpenApiInfo` | _type_ | the spec `info` block |
|
|
2886
|
+
| `OpenApiServer` | _type_ | a spec `servers` entry |
|
|
2887
|
+
|
|
2888
|
+
---
|
|
2889
|
+
|
|
2890
|
+
## `stitchkit/observability`
|
|
2891
|
+
|
|
2892
|
+
Server-only. The audit layer one level above the raw hooks — W3C trace context,
|
|
2893
|
+
an `AsyncLocalStorage` request context, payload sanitisation and a normalised
|
|
2894
|
+
audit event. See the [Observability guide](../guide/observability.md).
|
|
2895
|
+
|
|
2896
|
+
### Audit
|
|
2897
|
+
|
|
2898
|
+
| Export | Kind | Summary |
|
|
2899
|
+
|--------|------|---------|
|
|
2900
|
+
| `createAuditHook` | function | wire both surfaces into one sink — [guide](../guide/observability.md#createaudithook) |
|
|
2901
|
+
| `RequestEvent` | _type_ | the normalised audit event handed to the sink |
|
|
2902
|
+
| `AuditConfig` | _type_ | config for `createAuditHook` |
|
|
2903
|
+
| `AuditHook` | _type_ | the `{ http, toolCall }` the hook returns |
|
|
2904
|
+
|
|
2905
|
+
### Request context
|
|
2906
|
+
|
|
2907
|
+
| Export | Kind | Summary |
|
|
2908
|
+
|--------|------|---------|
|
|
2909
|
+
| `wrapInRequestContext` | function | run a fetch handler inside a request context — [guide](../guide/observability.md#request-context) |
|
|
2910
|
+
| `getRequestContext` | function | the active request context |
|
|
2911
|
+
| `getTraceId` | function | the active trace id — pass as `traceId` to `createServer` |
|
|
2912
|
+
| `getUserId` | function | the active user id, once auth has resolved it |
|
|
2913
|
+
| `setRequestUser` | function | attach the resolved user to the active context |
|
|
2914
|
+
| `setRequestError` | function | record the error outcome on the active context |
|
|
2915
|
+
| `runWithRequestContext` | function | run a function inside a given context |
|
|
2916
|
+
| `RequestContext` | _type_ | the per-request record |
|
|
2917
|
+
|
|
2918
|
+
### Trace context
|
|
2919
|
+
|
|
2920
|
+
| Export | Kind | Summary |
|
|
2921
|
+
|--------|------|---------|
|
|
2922
|
+
| `resolveTraceContext` | function | the trace for a request — `traceparent` continued or fresh |
|
|
2923
|
+
| `parseTraceparent` | function | parse a `traceparent` header |
|
|
2924
|
+
| `formatTraceparent` | function | render a `traceparent` header value |
|
|
2925
|
+
| `createTraceContext` | function | a fresh root trace |
|
|
2926
|
+
| `childSpan` | function | a child span of a parent trace |
|
|
2927
|
+
| `TraceContext` | _type_ | `{ traceId, spanId, parentSpanId? }` |
|
|
2928
|
+
|
|
2929
|
+
### Sanitisation
|
|
2930
|
+
|
|
2931
|
+
| Export | Kind | Summary |
|
|
2932
|
+
|--------|------|---------|
|
|
2933
|
+
| `sanitizePayload` | function | redact secrets and cap size — [guide](../guide/observability.md#sanitisation) |
|
|
2934
|
+
| `redact` | function | mask secret-named keys, drop binary blobs |
|
|
2935
|
+
| `truncatePreview` | function | cap a value by serialised size |
|
|
2936
|
+
| `measureSize` | function | item count + byte size of a result |
|
|
2937
|
+
| `JsonValue` | _type_ | a JSON-serialisable value |
|
|
2938
|
+
| `SanitizeOptions` | _type_ | tuning for `redact` / `sanitizePayload` |
|
|
2939
|
+
| `SizeMeasure` | _type_ | the result of `measureSize` |
|
|
2940
|
+
|
|
2941
|
+
---
|
|
2942
|
+
|
|
2943
|
+
## `stitchkit/tools`
|
|
2944
|
+
|
|
2945
|
+
Server-only. Turns contracts into MCP and AI-agent tools. Needs the
|
|
2946
|
+
`@modelcontextprotocol/sdk` peer (for MCP) and the `ai` peer (for agents).
|
|
2947
|
+
|
|
2948
|
+
| Export | Kind | Summary |
|
|
2949
|
+
|--------|------|---------|
|
|
2950
|
+
| `createMcpHandler` | function | a complete Streamable-HTTP MCP server — [guide](../guide/mcp-and-agents.md#mcp--createmcphandler) |
|
|
2951
|
+
| `createStdioMcpServer` | function | a complete stdio MCP server — [guide](../guide/mcp-and-agents.md#mcp-over-stdio--createstdiomcpserver) |
|
|
2952
|
+
| `buildMcpServer` | function | build an `McpServer` from contracts — the transport-neutral core |
|
|
2953
|
+
| `mountMcp` | function | add contract tools to an existing `McpServer` — [guide](../guide/mcp-and-agents.md#mountmcp) |
|
|
2954
|
+
| `implementRemote` | function | bind a contract to a remote HTTP API — [guide](../guide/mcp-and-agents.md#proxying-a-remote-api--implementremote) |
|
|
2955
|
+
| `mountAgent` | function | a Vercel AI SDK `ToolSet` from a service — [guide](../guide/mcp-and-agents.md#ai-agents--mountagent) |
|
|
2956
|
+
| `createCli` | function | a command-line program from contracts — [guide](../guide/cli.md) (also on `stitchkit/cli`) |
|
|
2957
|
+
| `createToolkit` | function | context-typed tool mounts — [guide](../guide/cli.md#typed-context) |
|
|
2958
|
+
| `mountViewFile` | function | a native multimodal "view file" MCP tool |
|
|
2959
|
+
| `resolveMedia` | function | resolve a media reference for a tool result |
|
|
2960
|
+
| `validateMcpSchemas` | function | assert every tool schema is JSON Schema-compatible — [guide](../guide/mcp-and-agents.md#incompatible-schemas--onincompatibleschema) |
|
|
2961
|
+
| `McpHandlerConfig` | _type_ | config for `createMcpHandler` |
|
|
2962
|
+
| `StdioMcpServerConfig` | _type_ | config for `createStdioMcpServer` |
|
|
2963
|
+
| `McpServerBuildConfig` | _type_ | shared config for `buildMcpServer` |
|
|
2964
|
+
| `ImplementRemoteOptions` | _type_ | options for `implementRemote` |
|
|
2965
|
+
| `McpMountConfig` | _type_ | config for `mountMcp` |
|
|
2966
|
+
| `AgentMountConfig` | _type_ | config for `mountAgent` |
|
|
2967
|
+
| `AgentContext` | _type_ | the context merged into agent tool handlers |
|
|
2968
|
+
| `CliConfig` | _type_ | config for `createCli` |
|
|
2969
|
+
| `CliWaitConfig` | _type_ | `--wait` polling config |
|
|
2970
|
+
| `ExitCodeMap` | _type_ | `ToolResult.code` → process exit code |
|
|
2971
|
+
| `Toolkit` | _type_ | the context-pinned tool surface from `createToolkit` |
|
|
2972
|
+
| `ToolExtend` | _type_ | extra-args extension for `mountMcp` / `mountAgent` |
|
|
2973
|
+
| `ToolLifecycle` | _type_ | `beforeHandle` / `afterHandle` gate for tool calls — [guide](../guide/mcp-and-agents.md#guarding-tools--lifecycle) |
|
|
2974
|
+
| `ToolCallHooks` | _type_ | `beforeToolCall` / `afterToolCall` observability hooks |
|
|
2975
|
+
| `ToolResult` | _type_ | the result of one tool call |
|
|
2976
|
+
| `IncompatibleSchemaPolicy` | _type_ | `'throw' \| 'skip' \| 'warn'` |
|
|
2977
|
+
| `McpMediaContent` | _type_ | a multimodal MCP content item |
|
|
2978
|
+
|
|
2979
|
+
---
|
|
2980
|
+
|
|
2981
|
+
## `stitchkit/cli`
|
|
2982
|
+
|
|
2983
|
+
Server-only. Turns contracts into a command-line program — the fourth transport.
|
|
2984
|
+
Light by design: needs neither the MCP SDK nor the `ai` peer.
|
|
2985
|
+
|
|
2986
|
+
| Export | Kind | Summary |
|
|
2987
|
+
|--------|------|---------|
|
|
2988
|
+
| `createCli` | function | build and run a CLI from contracts — [guide](../guide/cli.md) |
|
|
2989
|
+
| `parseCliArgs` | function | argv → typed tool args against a schema (advanced) |
|
|
2990
|
+
| `pollUntilDone` | function | the generic `--wait` poller (advanced) |
|
|
2991
|
+
| `emitResult` | function | write a `ToolResult` to stdout/stderr + exit code (advanced) |
|
|
2992
|
+
| `DEFAULT_EXIT_CODES` | const | the default `ToolResult.code` → exit-code map |
|
|
2993
|
+
| `CliConfig` | _type_ | config for `createCli` |
|
|
2994
|
+
| `CliRunOptions` | _type_ | parsed global flags (`--json`, `--wait`, …) |
|
|
2995
|
+
| `ParsedCliArgs` | _type_ | result of `parseCliArgs` |
|
|
2996
|
+
| `CliWaitConfig` | _type_ | per-command `--wait` polling config |
|
|
2997
|
+
| `ExitCodeMap` | _type_ | `ToolResult.code` → process exit code |
|
|
2998
|
+
| `PollParams` | _type_ | params for `pollUntilDone` |
|
|
2999
|
+
| `CliWriters` | _type_ | stdout/stderr sinks for `emitResult` |
|
|
3000
|
+
| `EmitOptions` | _type_ | options for `emitResult` |
|
|
3001
|
+
|
|
3002
|
+
---
|
|
3003
|
+
|
|
3004
|
+
## `stitchkit/react`
|
|
3005
|
+
|
|
3006
|
+
Browser-only. The React data-layer helpers. Needs the `@tanstack/react-query`
|
|
3007
|
+
and `react-query-kit` peers.
|
|
3008
|
+
|
|
3009
|
+
| Export | Kind | Summary |
|
|
3010
|
+
|--------|------|---------|
|
|
3011
|
+
| `createCursorQuery` | function | a cursor-paginated infinite query — [guide](../guide/client.md#cursor-pagination) |
|
|
3012
|
+
| `createCacheBridge` | function | sync socket events into the Query cache — [guide](../guide/realtime.md#cache-bridge) |
|
|
3013
|
+
| `CursorQueryConfig` | _type_ | config for `createCursorQuery` |
|
|
3014
|
+
| `CacheBridge` | _type_ | the `createCacheBridge` handle |
|
|
3015
|
+
| `CacheBridgeConfig` | _type_ | config for `createCacheBridge` |
|
|
3016
|
+
| `CacheBridgeContext` | _type_ | the `ctx` a bridge handler receives |
|
|
3017
|
+
| `CacheBridgeHandler` | _type_ | one event-to-cache handler |
|
|
3018
|
+
| `CacheBridgeHandlers` | _type_ | the handler map |
|
|
3019
|
+
| `CacheBridgeSocket` | _type_ | the minimal emitter a bridge accepts |
|
|
3020
|
+
|
|
3021
|
+
---
|
|
3022
|
+
|
|
3023
|
+
For the rationale behind these APIs — why `Bun.serve` and not a framework, why
|
|
3024
|
+
two context types, why thin wrappers — see the
|
|
3025
|
+
[Architecture Decisions](../decisions/).
|