stitchkit 0.37.0 → 0.39.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/browser/client-url.d.ts +13 -0
- package/dist/browser/client-url.d.ts.map +1 -0
- package/dist/browser/client.d.ts +41 -5
- package/dist/browser/client.d.ts.map +1 -1
- package/dist/browser/http.d.ts +10 -7
- package/dist/browser/http.d.ts.map +1 -1
- package/dist/cli.js +2 -2
- package/dist/contract/define.d.ts +102 -4
- package/dist/contract/define.d.ts.map +1 -1
- package/dist/contract/index.d.ts +1 -1
- package/dist/contract/index.d.ts.map +1 -1
- package/dist/contract/index.js +1 -1
- package/dist/{index-czmqks7r.js → index-6jypn22c.js} +1 -1
- package/dist/{index-4pvtq6h2.js → index-7rbzbnnf.js} +104 -30
- package/dist/{index-xq45akyd.js → index-92gs1m5b.js} +1 -1
- package/dist/{index-e497hxcy.js → index-fyfk537k.js} +50 -5
- package/dist/{index-g3jrbd0z.js → index-xax049k6.js} +101 -5
- package/dist/{index-s6yhmg1k.js → index-y5scd1cr.js} +3 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +243 -111
- package/dist/internal/route-pattern.d.ts +7 -0
- package/dist/internal/route-pattern.d.ts.map +1 -0
- package/dist/node.js +3 -3
- package/dist/observability/index.js +1 -1
- package/dist/react/entity-cache.d.ts +56 -42
- package/dist/react/entity-cache.d.ts.map +1 -1
- package/dist/react.d.ts +1 -1
- package/dist/react.d.ts.map +1 -1
- package/dist/react.js +98 -46
- package/dist/server/create.d.ts.map +1 -1
- package/dist/server/implement.d.ts.map +1 -1
- package/dist/server/index.js +22 -7
- package/dist/server/middleware/cors.d.ts.map +1 -1
- package/dist/server/openapi.d.ts.map +1 -1
- package/dist/server/response-metadata.d.ts +9 -0
- package/dist/server/response-metadata.d.ts.map +1 -0
- package/dist/server/router.d.ts +2 -0
- package/dist/server/router.d.ts.map +1 -1
- package/dist/server/socket-io.d.ts +1 -1
- package/dist/server/types.d.ts +14 -8
- package/dist/server/types.d.ts.map +1 -1
- package/dist/tools/agent.d.ts +3 -0
- package/dist/tools/agent.d.ts.map +1 -1
- package/dist/tools/invoker.d.ts +33 -0
- package/dist/tools/invoker.d.ts.map +1 -0
- package/dist/tools/mcp.d.ts.map +1 -1
- package/dist/tools/mount.d.ts.map +1 -1
- package/dist/tools/names.d.ts +1 -1
- package/dist/tools/names.d.ts.map +1 -1
- package/dist/tools/native-mcp.d.ts +6 -33
- package/dist/tools/native-mcp.d.ts.map +1 -1
- package/dist/tools/remote.d.ts.map +1 -1
- package/dist/tools/runtime-tool.d.ts +58 -0
- package/dist/tools/runtime-tool.d.ts.map +1 -0
- package/dist/tools.d.ts +3 -1
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +295 -182
- package/llms-full.txt +562 -79
- package/package.json +1 -1
package/llms-full.txt
CHANGED
|
@@ -229,8 +229,8 @@ export const users = defineContract({ prefix: 'users' }, {
|
|
|
229
229
|
|
|
230
230
|
| Field | Required | Purpose |
|
|
231
231
|
|-------|----------|---------|
|
|
232
|
-
| `method` | yes | `GET` · `POST` · `PUT` · `PATCH` · `DELETE` |
|
|
233
|
-
| `path` | yes | route path under the contract `prefix`; `:name` marks a path param |
|
|
232
|
+
| `method` | yes | `GET` · `HEAD` · `POST` · `PUT` · `PATCH` · `DELETE` |
|
|
233
|
+
| `path` | yes | route path under the contract `prefix`; `:name` marks a path param and a named terminal `/*filePath` captures the remaining path |
|
|
234
234
|
| `desc` | yes | human description — also the MCP / agent tool description |
|
|
235
235
|
| `params` | no | Zod schema for **path params** (`:id`, …) |
|
|
236
236
|
| `input` | no | Zod schema for the **request body** (or query, for GET/DELETE) |
|
|
@@ -245,15 +245,27 @@ export const users = defineContract({ prefix: 'users' }, {
|
|
|
245
245
|
| `meta` | no | opaque app metadata — read in hooks / on tool mounts, never in OpenAPI ([below](#endpoint-metadata-meta)) |
|
|
246
246
|
| `rawResponse` | no | the handler returns the `Response` itself — a download, a file, an SSE stream. HTTP-only, never a tool, no `output`. See [Raw-response endpoints](./server.md#raw-response-endpoints) |
|
|
247
247
|
| `rawBody` | no | retain original JSON text for a signed HTTP webhook. See [Signed JSON webhooks](./server.md#signed-json-webhooks) |
|
|
248
|
+
| `responseMeta` | no | make a typed-data endpoint HTTP-only, optionally declare its success status and expose `ctx.response.headers` — [Typed JSON response metadata](./server.md#typed-json-response-metadata) |
|
|
248
249
|
| `contentType` | no | documented response media type of a `rawResponse` endpoint (OpenAPI only) |
|
|
249
250
|
|
|
251
|
+
`HEAD` is an explicit HTTP-only operation, never an automatic alias for `GET`.
|
|
252
|
+
It must declare `rawResponse: true`; request body, `input`, multipart, `rawBody`,
|
|
253
|
+
typed `output` and tool exposure are rejected. The handler owns status and
|
|
254
|
+
headers, while Stitchkit guarantees an empty wire body even if the returned
|
|
255
|
+
`Response` accidentally contains one.
|
|
256
|
+
|
|
250
257
|
## `params` vs `input` vs `output`
|
|
251
258
|
|
|
252
259
|
The three schemas are distinct on purpose:
|
|
253
260
|
|
|
254
261
|
- **`params`** — values in the URL path. `path: '/:id'` ⇒
|
|
255
262
|
`params: z.object({ id: z.string() })`. The client takes them from the call
|
|
256
|
-
argument and substitutes them into the URL.
|
|
263
|
+
argument and substitutes them into the URL. A terminal wildcard is explicitly
|
|
264
|
+
named: `path: '/:slug/*filePath'` with
|
|
265
|
+
`params: z.object({ slug: z.string(), filePath: z.string() })` matches both
|
|
266
|
+
`/foo/page` and `/foo/a/b`; the handler reads `ctx.params.filePath` as
|
|
267
|
+
`'page'` or `'a/b'`. Bare `/*`, invalid/duplicate names and a wildcard before
|
|
268
|
+
the final segment fail at contract definition.
|
|
257
269
|
- **`input`** — the request payload. For `POST` / `PUT` / `PATCH` it is the JSON
|
|
258
270
|
body; for `GET` / `DELETE` it is the query string. The handler reads it as
|
|
259
271
|
`ctx.input`.
|
|
@@ -267,6 +279,9 @@ the body.
|
|
|
267
279
|
```ts
|
|
268
280
|
// path: '/:id', params: { id }, input: { text }
|
|
269
281
|
await api.update({ id: '1', text: 'new' }) // PUT /users/1 body: { text: 'new' }
|
|
282
|
+
|
|
283
|
+
// path: '/:slug/*filePath', params: { slug, filePath }
|
|
284
|
+
await api.app({ slug: 'foo', filePath: 'a/b' }) // GET /apps/foo/a/b
|
|
270
285
|
```
|
|
271
286
|
|
|
272
287
|
### Input vs. output types
|
|
@@ -317,10 +332,12 @@ tools. Narrow it with `expose`:
|
|
|
317
332
|
- `expose: ['MCP', 'AGENT']` — a tool only; no HTTP route.
|
|
318
333
|
- omit `expose` — all transports.
|
|
319
334
|
|
|
320
|
-
Tool transports (`MCP`, `AGENT`) skip
|
|
335
|
+
Tool transports (`MCP`, `AGENT`) skip three kinds of endpoint automatically:
|
|
321
336
|
`multipart` (a file upload is not a tool call) and
|
|
322
337
|
[`rawResponse`](./server.md#raw-response-endpoints) (its answer is bytes, which
|
|
323
|
-
a tool result cannot carry — it would serialize to `{}`)
|
|
338
|
+
a tool result cannot carry — it would serialize to `{}`), plus
|
|
339
|
+
[`responseMeta`](./server.md#typed-json-response-metadata) (outbound HTTP
|
|
340
|
+
headers have no meaning on a tool call).
|
|
324
341
|
|
|
325
342
|
## `toolName`
|
|
326
343
|
|
|
@@ -764,6 +781,18 @@ prefix param in the schema, use a non-strict `z.object` (extra keys are dropped
|
|
|
764
781
|
from `ctx.params`, but `ctx.tenantId` still works), or read the param off the
|
|
765
782
|
context root.
|
|
766
783
|
|
|
784
|
+
**Trailing wildcard.** A contract path may end in a named wildcard.
|
|
785
|
+
`/app/:slug/*filePath` matches
|
|
786
|
+
both `/app/foo` and nested paths such as `/app/foo/a/b`; the collected params are
|
|
787
|
+
`{ slug: 'foo', filePath: '' }` and `{ slug: 'foo', filePath: 'a/b' }`
|
|
788
|
+
respectively. Put `filePath` in the endpoint's `params` schema to keep it in
|
|
789
|
+
typed `ctx.params`. Each captured segment is URL-decoded before the remainder is
|
|
790
|
+
joined, so encoded spaces and reserved characters reach the handler as their
|
|
791
|
+
semantic values while `/` remains the segment boundary. Static and named-param
|
|
792
|
+
routes are matched before a catch-all, so a
|
|
793
|
+
more specific endpoint wins regardless of declaration order. The same matcher
|
|
794
|
+
drives `405 Allow` resolution.
|
|
795
|
+
|
|
767
796
|
### Scope-driven mounting (`scopePrefixes`)
|
|
768
797
|
|
|
769
798
|
With several scopes, hand-partitioning services into `groups` duplicates the
|
|
@@ -796,7 +825,7 @@ createServer({
|
|
|
796
825
|
hooks: {
|
|
797
826
|
onRequest(req) { /* logging, global rate limit — may return a Response to short-circuit */ },
|
|
798
827
|
beforeHandle(ctx, endpoint) { /* auth, scope checks — throw to reject */ },
|
|
799
|
-
afterHandle(ctx, result, ep) { /* transform the result
|
|
828
|
+
afterHandle(ctx, result, ep) { /* transform the result data */ },
|
|
800
829
|
onError(ctx, error, ep) { /* custom error response — return a Response */ },
|
|
801
830
|
},
|
|
802
831
|
})
|
|
@@ -849,6 +878,46 @@ not retain the text. `maxJsonBodyBytes` may also be set once on `createServer` /
|
|
|
849
878
|
`createHandler`; a route value wins. Both limits are opt-in and abort an
|
|
850
879
|
oversized stream before it is fully buffered. → ADR 0051
|
|
851
880
|
|
|
881
|
+
## Typed JSON response metadata
|
|
882
|
+
|
|
883
|
+
A JSON endpoint that must attach dynamic HTTP headers while preserving typed
|
|
884
|
+
output declares `responseMeta`. The handler still returns ordinary data:
|
|
885
|
+
|
|
886
|
+
```ts
|
|
887
|
+
const auth = defineContract({ prefix: 'auth' }, {
|
|
888
|
+
complete: {
|
|
889
|
+
method: 'POST', path: '/complete', desc: 'Complete authentication',
|
|
890
|
+
input: CompleteAuthSchema,
|
|
891
|
+
output: AuthUserSchema,
|
|
892
|
+
responseMeta: { status: 200 },
|
|
893
|
+
},
|
|
894
|
+
})
|
|
895
|
+
|
|
896
|
+
complete: async ({ input, response }) => {
|
|
897
|
+
const result = await authenticate(input.token)
|
|
898
|
+
response.headers.append('Set-Cookie', session.set(result.sessionId))
|
|
899
|
+
response.headers.append('Set-Cookie', preferences.set(result.preferencesId))
|
|
900
|
+
return result.user
|
|
901
|
+
}
|
|
902
|
+
```
|
|
903
|
+
|
|
904
|
+
`ctx.response.headers` is a fresh Web Fetch `Headers` bag per request. `append`
|
|
905
|
+
preserves repeated `Set-Cookie` values on Bun and Node. The endpoint is
|
|
906
|
+
HTTP-only, but its typed client method still resolves to `AuthUser` — not
|
|
907
|
+
`Response` — and the final data still passes group/global `afterHandle` and the
|
|
908
|
+
declared `output` schema exactly once.
|
|
909
|
+
|
|
910
|
+
`responseMeta.status` is static contract metadata and OpenAPI publishes the same
|
|
911
|
+
2xx code. Without it, data keeps status `200` and no-data keeps `204`. Bodyless
|
|
912
|
+
`204`/`205` cannot be combined with `output`. Redirects, streams, files and
|
|
913
|
+
handler-owned status/body logic remain [`rawResponse: true`](#raw-response-endpoints).
|
|
914
|
+
|
|
915
|
+
Collected headers are merged only after the complete success pipeline. A
|
|
916
|
+
handler, hook or output-validation failure discards them. `Content-Type`,
|
|
917
|
+
`Content-Length`, `x-request-id` and every `Access-Control-*` header remain
|
|
918
|
+
framework-owned; trying to set one fails loudly with the endpoint identity.
|
|
919
|
+
→ [ADR 0052](../decisions/0052-typed-json-response-metadata.md)
|
|
920
|
+
|
|
852
921
|
## Raw-response endpoints
|
|
853
922
|
|
|
854
923
|
An endpoint that answers with **bytes rather than data** — a PDF download, a
|
|
@@ -891,6 +960,10 @@ const name = res.headers.get('Content-Disposition')
|
|
|
891
960
|
const blob = await res.blob()
|
|
892
961
|
```
|
|
893
962
|
|
|
963
|
+
When the browser should navigate or assign the endpoint directly to `src`, use
|
|
964
|
+
[`createUrlBuilder`](./client.md#contract-url-builders). Raw-response GET methods
|
|
965
|
+
are included, while mutation and multipart methods are intentionally absent.
|
|
966
|
+
|
|
894
967
|
Cross-origin, remember that those headers are readable only because CORS exposes
|
|
895
968
|
them — see [`cors.exposeHeaders`](#serving-files--range-requests).
|
|
896
969
|
|
|
@@ -902,6 +975,11 @@ client — which is what you want for an OAuth redirect or a non-JSON webhook. A
|
|
|
902
975
|
signed JSON webhook can stay validated through
|
|
903
976
|
[`rawBody: true`](#signed-json-webhooks).
|
|
904
977
|
|
|
978
|
+
If an endpoint returns typed JSON and only needs an additional status/header,
|
|
979
|
+
use [`responseMeta`](#typed-json-response-metadata), not `rawResponse`: the raw
|
|
980
|
+
variant deliberately transfers response ownership and changes the client result
|
|
981
|
+
to `Response`.
|
|
982
|
+
|
|
905
983
|
⚠️ **Delete the old raw route when you move an endpoint into the contract.** Raw
|
|
906
984
|
routes are matched **first**, so a leftover one keeps serving the bytes and the
|
|
907
985
|
contract endpoint — with its auth gate — never runs. stitchkit warns at startup
|
|
@@ -957,9 +1035,9 @@ createServer({
|
|
|
957
1035
|
})
|
|
958
1036
|
```
|
|
959
1037
|
|
|
960
|
-
A path may be exact, carry `:param` segments, or end in
|
|
961
|
-
wildcard — and the two combine: `/app/:slug
|
|
962
|
-
`ctx.params.slug === 'x'` and the remainder in `ctx.params
|
|
1038
|
+
A path may be exact, carry `:param` segments, or end in `/*filePath` for a prefix
|
|
1039
|
+
wildcard — and the two combine: `/app/:slug/*filePath` matches `/app/x/a/b` with
|
|
1040
|
+
`ctx.params.slug === 'x'` and the remainder in `ctx.params.filePath` (a SPA
|
|
963
1041
|
deep-link fallback). List more specific routes before the wildcard — the first
|
|
964
1042
|
match wins. `staticRoute()` builds a raw route that serves a directory.
|
|
965
1043
|
|
|
@@ -998,24 +1076,35 @@ seek and cache, use **`serveFile`** (Bun) — it streams the requested byte rang
|
|
|
998
1076
|
and speaks the conditional-request half of RFC 7233 / 9110:
|
|
999
1077
|
|
|
1000
1078
|
```ts
|
|
1001
|
-
import {
|
|
1079
|
+
import { defineContract } from 'stitchkit/contract'
|
|
1080
|
+
import { implement, serveFile } from 'stitchkit/server'
|
|
1081
|
+
import { z } from 'zod'
|
|
1002
1082
|
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1083
|
+
const MediaParams = z.object({ id: z.string() })
|
|
1084
|
+
const media = defineContract({ prefix: 'media' }, {
|
|
1085
|
+
download: {
|
|
1086
|
+
method: 'GET', path: '/:id', desc: 'Download media',
|
|
1087
|
+
params: MediaParams, rawResponse: true, contentType: 'video/mp4',
|
|
1088
|
+
},
|
|
1089
|
+
inspect: {
|
|
1090
|
+
method: 'HEAD', path: '/:id', desc: 'Inspect media',
|
|
1091
|
+
params: MediaParams, rawResponse: true, contentType: 'video/mp4',
|
|
1092
|
+
},
|
|
1093
|
+
})
|
|
1094
|
+
|
|
1095
|
+
const mediaService = implement(media, {
|
|
1096
|
+
download: ({ req, params }) =>
|
|
1097
|
+
serveFile(req, { path: pathForId(params.id), filename: 'clip.mp4' }),
|
|
1098
|
+
inspect: ({ req, params }) =>
|
|
1099
|
+
serveFile(req, { path: pathForId(params.id), filename: 'clip.mp4' }),
|
|
1016
1100
|
})
|
|
1017
1101
|
```
|
|
1018
1102
|
|
|
1103
|
+
GET and HEAD are separate operations deliberately: declaring GET never creates
|
|
1104
|
+
a hidden HEAD alias. Both travel through the normal contract router, params,
|
|
1105
|
+
lifecycle/RBAC and request logging. A HEAD handler may inspect the raw query via
|
|
1106
|
+
`ctx.req.url`, but cannot declare a request input schema or body.
|
|
1107
|
+
|
|
1019
1108
|
`serveFile` returns `206` (range, with `Content-Range` + `Content-Length`), `200`
|
|
1020
1109
|
(full), `416` (unsatisfiable, `Content-Range: bytes */size`), `304`
|
|
1021
1110
|
(`If-None-Match` / `If-Modified-Since`), `404` (missing) or `405` (non GET/HEAD).
|
|
@@ -1135,6 +1224,15 @@ createServer({ services: [users, orders], rawRoutes: [openApiRoute('/openapi.jso
|
|
|
1135
1224
|
|
|
1136
1225
|
Only HTTP-exposed methods appear (an MCP/agent-only tool is skipped).
|
|
1137
1226
|
|
|
1227
|
+
OpenAPI 3.1 has no standard multi-segment path parameter. For a contract path
|
|
1228
|
+
ending in a named wildcard such as `/*filePath`, Stitchkit keeps the literal
|
|
1229
|
+
runtime path, omits `filePath` from the
|
|
1230
|
+
standard `in: path` parameter list, and emits
|
|
1231
|
+
`x-stitchkit-trailing-wildcard` on the operation with its parameter name,
|
|
1232
|
+
schema and semantics. A generic OpenAPI client therefore cannot invent
|
|
1233
|
+
catch-all expansion; use Stitchkit's typed client or teach the generator that
|
|
1234
|
+
extension.
|
|
1235
|
+
|
|
1138
1236
|
### Curating the spec — `includeMethod`
|
|
1139
1237
|
|
|
1140
1238
|
To publish a **subset** — a public spec that advertises only some methods
|
|
@@ -1189,11 +1287,19 @@ and adds cookie auth, SSR cookie forwarding, error parsing into `ApiError`, a
|
|
|
1189
1287
|
`401 → unauthorized` event stream and safe transport retry.
|
|
1190
1288
|
|
|
1191
1289
|
```ts
|
|
1192
|
-
import { createHttpClient } from 'stitchkit'
|
|
1290
|
+
import { contractEndpointMatchers, createHttpClient } from 'stitchkit'
|
|
1291
|
+
import { publicAuth } from '../shared/contracts'
|
|
1193
1292
|
|
|
1194
|
-
const http = createHttpClient({
|
|
1293
|
+
const http = createHttpClient({
|
|
1294
|
+
baseUrl: '/api',
|
|
1295
|
+
suppressUnauthorizedFor: contractEndpointMatchers(publicAuth, ['complete', 'verify']),
|
|
1296
|
+
})
|
|
1195
1297
|
```
|
|
1196
1298
|
|
|
1299
|
+
The returned `ConfiguredHttpClient` keeps that `baseUrl` as a readonly public
|
|
1300
|
+
field. Besides executing requests, it can therefore seed contract URL builders
|
|
1301
|
+
without repeating transport configuration.
|
|
1302
|
+
|
|
1197
1303
|
### `HttpClientConfig`
|
|
1198
1304
|
|
|
1199
1305
|
| Field | Default | Purpose |
|
|
@@ -1203,13 +1309,22 @@ const http = createHttpClient({ baseUrl: '/api' })
|
|
|
1203
1309
|
| `credentials` | `'include'` | fetch credentials mode |
|
|
1204
1310
|
| `retry` | 2× GET, network errors only | transport retry policy |
|
|
1205
1311
|
| `headers` | — | extra headers — an object, or a function re-run per request |
|
|
1206
|
-
| `
|
|
1312
|
+
| `suppressUnauthorizedFor` | `[]` | exact contract-derived operation matchers whose expected 401 does not emit `unauthorized` |
|
|
1207
1313
|
| `parseError` | built-in | map an error body to `{ code, message, details, hint }` |
|
|
1208
1314
|
| `trace` | `false` | emit a W3C `traceparent` header on every request |
|
|
1209
1315
|
|
|
1210
1316
|
`headers` as a function is the hook for runtime tokens — a bearer token or any
|
|
1211
1317
|
short-lived credential — re-evaluated on every request.
|
|
1212
1318
|
|
|
1319
|
+
Expected 401 policy is explicit and contract-driven. Select individual
|
|
1320
|
+
operations with `contractEndpointMatchers(contract, ['login'])`, or omit the
|
|
1321
|
+
second argument to select every HTTP operation in that contract. Pass the same
|
|
1322
|
+
`ContractClientConfig` as the typed client when routes use a static or dynamic
|
|
1323
|
+
`pathPrefix`; dynamic matchers require `stripPrefixKeys`, so the helper can
|
|
1324
|
+
compile the prefix structure without a concrete tenant id. Matching is exact by
|
|
1325
|
+
path segments, including params and trailing wildcards — a shared prefix never
|
|
1326
|
+
suppresses a neighbouring protected endpoint.
|
|
1327
|
+
|
|
1213
1328
|
`trace: true` mints a fresh root trace per request. The stitchkit server
|
|
1214
1329
|
[continues an inbound `traceparent`](./observability.md#trace-context), so the
|
|
1215
1330
|
browser call, the HTTP handler and every nested tool call share one trace id
|
|
@@ -1235,10 +1350,22 @@ await api.update({ id: '1', name: 'M' }) // PUT /users/1 body: { name }
|
|
|
1235
1350
|
await api.delete({ id: '1' }) // DELETE /users/1
|
|
1236
1351
|
```
|
|
1237
1352
|
|
|
1353
|
+
An explicit contract `HEAD` operation is exposed like any other typed method.
|
|
1354
|
+
Because HEAD endpoints are `rawResponse`, it resolves to the untouched
|
|
1355
|
+
`Response`, giving the caller direct access to status and headers without JSON
|
|
1356
|
+
parsing:
|
|
1357
|
+
|
|
1358
|
+
```ts
|
|
1359
|
+
const response = await assets.head({ name: 'clip.mp4' })
|
|
1360
|
+
console.log(response.headers.get('content-length'))
|
|
1361
|
+
```
|
|
1362
|
+
|
|
1238
1363
|
Each call takes one argument object. The client routes each field by the
|
|
1239
1364
|
contract:
|
|
1240
1365
|
|
|
1241
1366
|
- a **path param** (`:id`) is substituted into the URL,
|
|
1367
|
+
- a named terminal wildcard (`/*filePath`) consumes that field and preserves its
|
|
1368
|
+
path segments (`{ filePath: 'a/b' }` → `/a/b`, not `/%2Fa%2Fb` or a query field),
|
|
1242
1369
|
- for `GET` / `DELETE`, the remaining fields become the **query string**
|
|
1243
1370
|
(arrays become repeated keys),
|
|
1244
1371
|
- for `POST` / `PUT` / `PATCH`, they become the **JSON body**,
|
|
@@ -1257,8 +1384,87 @@ await api.users.list()
|
|
|
1257
1384
|
await api.posts.create({ title: 'Hi' })
|
|
1258
1385
|
```
|
|
1259
1386
|
|
|
1387
|
+
When contracts use different path-prefix rules, route the same registry by the
|
|
1388
|
+
scope already declared in each contract. An array composes contracts with
|
|
1389
|
+
different scopes into one logical namespace:
|
|
1390
|
+
|
|
1391
|
+
```ts
|
|
1392
|
+
const api = createScopedClients(
|
|
1393
|
+
{ auth: [publicAuth, authenticatedAuth], widgets },
|
|
1394
|
+
http,
|
|
1395
|
+
{
|
|
1396
|
+
public: {},
|
|
1397
|
+
user: {},
|
|
1398
|
+
tenant: {
|
|
1399
|
+
stripPrefixKeys: ['tenantId'],
|
|
1400
|
+
pathPrefix: ({ tenantId }) => `tenants/${tenantId}`,
|
|
1401
|
+
},
|
|
1402
|
+
},
|
|
1403
|
+
)
|
|
1404
|
+
|
|
1405
|
+
await api.auth.login()
|
|
1406
|
+
await api.auth.me()
|
|
1407
|
+
await api.widgets.list({ tenantId: 't1' })
|
|
1408
|
+
```
|
|
1409
|
+
|
|
1410
|
+
Every scope present in the registry needs a config. Unknown/missing scopes and
|
|
1411
|
+
duplicate method names inside a composed namespace fail before a request runs.
|
|
1412
|
+
|
|
1260
1413
|
`createClients` builds one typed client per contract from a registry — list the
|
|
1261
|
-
contracts once, get the whole API typed.
|
|
1414
|
+
contracts once, get the whole API typed. It accepts the same optional scoped
|
|
1415
|
+
config as `createClient`, so a whole registry can share one resource prefix:
|
|
1416
|
+
|
|
1417
|
+
```ts
|
|
1418
|
+
const tenantApi = createClients({ users, posts }, http, {
|
|
1419
|
+
stripPrefixKeys: ['tenantId'],
|
|
1420
|
+
pathPrefix: ({ tenantId }) => `tenants/${tenantId}`,
|
|
1421
|
+
})
|
|
1422
|
+
```
|
|
1423
|
+
|
|
1424
|
+
Every method now requires `tenantId`, and the callback sees it as a `string`.
|
|
1425
|
+
The batch form delegates to the same single-contract client runtime, including
|
|
1426
|
+
HTTP exposure filtering, multipart, raw responses and output validation.
|
|
1427
|
+
|
|
1428
|
+
## Contract URL builders
|
|
1429
|
+
|
|
1430
|
+
Browser-native consumers such as `<img src>`, downloads and navigation need a
|
|
1431
|
+
URL, not a fetched response. `createUrlBuilder` derives those URLs from the same
|
|
1432
|
+
contract path planner used by both typed-client transports:
|
|
1433
|
+
|
|
1434
|
+
```ts
|
|
1435
|
+
import { createUrlBuilder, createUrlBuilders } from 'stitchkit'
|
|
1436
|
+
|
|
1437
|
+
const mediaUrls = createUrlBuilder(media, http, {
|
|
1438
|
+
stripPrefixKeys: ['tenantId'],
|
|
1439
|
+
pathPrefix: ({ tenantId }) => `tenants/${tenantId}`,
|
|
1440
|
+
})
|
|
1441
|
+
|
|
1442
|
+
const src = mediaUrls.file({
|
|
1443
|
+
tenantId: 't_123',
|
|
1444
|
+
fileId: 'f_456',
|
|
1445
|
+
thumbnail: true,
|
|
1446
|
+
})
|
|
1447
|
+
|
|
1448
|
+
// Body and multipart fields are intentionally absent: only the URL-bound
|
|
1449
|
+
// params are accepted by URL functions.
|
|
1450
|
+
const formAction = mediaUrls.replace({ tenantId: 't_123', fileId: 'f_456' })
|
|
1451
|
+
const beaconUrl = mediaUrls.track({ tenantId: 't_123' })
|
|
1452
|
+
|
|
1453
|
+
const urls = createUrlBuilders({ media, exports }, http)
|
|
1454
|
+
```
|
|
1455
|
+
|
|
1456
|
+
Every HTTP-exposed endpoint appears on a URL builder, including body, multipart
|
|
1457
|
+
and raw-response operations. Path and scoped-prefix keys are consumed by the
|
|
1458
|
+
path. `GET` and `DELETE` input becomes the query string, including repeated keys
|
|
1459
|
+
for arrays; body-method input and multipart files are not URL arguments and are
|
|
1460
|
+
never serialized into the URL. Passing such a field through an untyped boundary
|
|
1461
|
+
fails before a URL is returned.
|
|
1462
|
+
|
|
1463
|
+
Building a URL is synchronous and performs no request, auth event, header
|
|
1464
|
+
resolution or output validation. A `ConfiguredHttpClient` created by
|
|
1465
|
+
`createHttpClient` supplies its base URL; custom transports can pass an explicit
|
|
1466
|
+
`{ baseUrl: 'https://api.example.com' }` instead. Relative bases produce relative
|
|
1467
|
+
URLs.
|
|
1262
1468
|
|
|
1263
1469
|
## `ApiError`
|
|
1264
1470
|
|
|
@@ -1288,7 +1494,7 @@ The HTTP client emits events your app can react to globally:
|
|
|
1288
1494
|
|
|
1289
1495
|
```ts
|
|
1290
1496
|
const unsubscribe = http.subscribe((event) => {
|
|
1291
|
-
if (event.type === 'unauthorized') redirectToLogin() // a 401
|
|
1497
|
+
if (event.type === 'unauthorized') redirectToLogin() // a non-suppressed 401
|
|
1292
1498
|
if (event.type === 'network_error') showOfflineBanner()
|
|
1293
1499
|
})
|
|
1294
1500
|
|
|
@@ -1329,7 +1535,7 @@ segment to every URL — the client half of a multi-tenant API
|
|
|
1329
1535
|
interface ContractClientConfig {
|
|
1330
1536
|
/** Prepended to every request URL. A function is called per request with the
|
|
1331
1537
|
* call's argument object, so the prefix can depend on the arguments. */
|
|
1332
|
-
pathPrefix?: string | ((args:
|
|
1538
|
+
pathPrefix?: string | ((args: { [K in ConsumedKey]: string }) => string)
|
|
1333
1539
|
/** Argument keys consumed by `pathPrefix` — stripped from the query/body so
|
|
1334
1540
|
* they are not also sent there (the endpoint's own path `:params` are
|
|
1335
1541
|
* stripped automatically; list any *extra* keys here). */
|
|
@@ -1341,8 +1547,8 @@ A per-tenant client — `tenantId` goes into the URL, not the body:
|
|
|
1341
1547
|
|
|
1342
1548
|
```ts
|
|
1343
1549
|
const widgets = createClient(widgetsContract, http, {
|
|
1344
|
-
pathPrefix: (args) => `tenants/${args.tenantId}/`,
|
|
1345
1550
|
stripPrefixKeys: ['tenantId'],
|
|
1551
|
+
pathPrefix: ({ tenantId }) => `tenants/${tenantId}/`,
|
|
1346
1552
|
})
|
|
1347
1553
|
|
|
1348
1554
|
widgets.list({ tenantId: 't_123' }) // GET /tenants/t_123/widgets
|
|
@@ -1415,7 +1621,8 @@ The same contract that drives the HTTP API also drives AI tooling. An endpoint
|
|
|
1415
1621
|
exposed on `MCP` becomes a [Model Context Protocol](https://modelcontextprotocol.io)
|
|
1416
1622
|
tool — callable from Claude, Cursor and other MCP clients. An endpoint exposed on
|
|
1417
1623
|
`AGENT` becomes a [Vercel AI SDK](https://sdk.vercel.ai) tool — callable from an
|
|
1418
|
-
agent loop.
|
|
1624
|
+
agent loop. Contract operations are never re-described by hand; pathless
|
|
1625
|
+
runtime operations use one framework definition shared by both tool transports.
|
|
1419
1626
|
|
|
1420
1627
|
## Which endpoints become tools
|
|
1421
1628
|
|
|
@@ -1475,6 +1682,38 @@ and after, compare.
|
|
|
1475
1682
|
> HTTP-only shows up in the list, which is the one check that catches it however
|
|
1476
1683
|
> many places the line was forgotten.
|
|
1477
1684
|
|
|
1685
|
+
## In-process calls — `createToolInvoker`
|
|
1686
|
+
|
|
1687
|
+
When one application operation dispatches to a contract tool, do not mount an
|
|
1688
|
+
AI SDK `ToolSet` and call its transport adapter manually. Compile an in-process
|
|
1689
|
+
invoker once and call the shared framework runner directly:
|
|
1690
|
+
|
|
1691
|
+
```ts
|
|
1692
|
+
import { createToolInvoker } from 'stitchkit/tools'
|
|
1693
|
+
|
|
1694
|
+
const invoker = createToolInvoker(services, {
|
|
1695
|
+
transport: 'AGENT', // required exposure policy
|
|
1696
|
+
source: 'internal', // default; audit names the real call source
|
|
1697
|
+
context: { identity },
|
|
1698
|
+
lifecycle,
|
|
1699
|
+
hooks,
|
|
1700
|
+
})
|
|
1701
|
+
|
|
1702
|
+
const result = await invoker.invoke('update_entity', args)
|
|
1703
|
+
if (!result.ok) throw new AppError(result.code, 'Nested tool call failed')
|
|
1704
|
+
```
|
|
1705
|
+
|
|
1706
|
+
`transport` is required and uses the exact existing `MCP`, `AGENT` or `CLI`
|
|
1707
|
+
exposure rules; there is no internal bypass mode. The immutable name lookup is
|
|
1708
|
+
compiled once. Every invocation—including parallel and recursive calls—gets a
|
|
1709
|
+
fresh tool-call context and runs the same extension resolution, input/output
|
|
1710
|
+
validation, lifecycle, hooks and output-strip reporter as mounted tools.
|
|
1711
|
+
|
|
1712
|
+
`invoke` returns the canonical discriminated `ToolResult`, not an AI SDK or MCP
|
|
1713
|
+
presentation envelope. An unknown name throws `AppError('NOT_FOUND')` before
|
|
1714
|
+
the runner because there is no operation identity against which hooks could run.
|
|
1715
|
+
Duplicate and provider-invalid names fail when the invoker is created.
|
|
1716
|
+
|
|
1478
1717
|
## MCP — `createMcpHandler`
|
|
1479
1718
|
|
|
1480
1719
|
`createMcpHandler` builds a complete Streamable-HTTP MCP server as a single
|
|
@@ -1852,6 +2091,7 @@ the merged `params` + `input`. `context` is merged into every tool handler's
|
|
|
1852
2091
|
| `lifecycle` | `beforeHandle` / `afterHandle` — the tool-side auth gate (see [Guarding tools](#guarding-tools--lifecycle)) |
|
|
1853
2092
|
| `hooks` | tool-call observability hooks — `afterToolCall` fires on every result |
|
|
1854
2093
|
| `extend` | add extra args resolved before the handler runs (see below) |
|
|
2094
|
+
| `runtimeTools` | framework-managed pathless operations from `defineRuntimeTool` |
|
|
1855
2095
|
|
|
1856
2096
|
`lifecycle` works the same as on the MCP server — without it an agent tool call
|
|
1857
2097
|
bypasses the HTTP `beforeHandle` auth gate. Pass your `createAuthHook` result.
|
|
@@ -1902,51 +2142,78 @@ as the HTTP prefix param — see
|
|
|
1902
2142
|
so one handler serves both surfaces. Pair `extend` with `lifecycle` (your
|
|
1903
2143
|
`createAuthHook`) so the tool call is still scope-gated.
|
|
1904
2144
|
|
|
1905
|
-
##
|
|
2145
|
+
## Pathless runtime tools and multimodal results
|
|
1906
2146
|
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
2147
|
+
Use `defineRuntimeTool` for an operation that has no HTTP path but still needs
|
|
2148
|
+
the same validation, isolated per-call context, lifecycle/RBAC and hooks as a
|
|
2149
|
+
contract tool. The handler returns one transport-neutral, schema-validated
|
|
2150
|
+
result. Optional presentation callbacks map that result to MCP content and AI
|
|
2151
|
+
SDK model output without coupling the handler to either SDK:
|
|
1910
2152
|
|
|
1911
2153
|
```ts
|
|
1912
|
-
import { createMcpHandler } from 'stitchkit/tools'
|
|
2154
|
+
import { createMcpHandler, defineRuntimeTool, mountAgent } from 'stitchkit/tools'
|
|
1913
2155
|
import { z } from 'zod'
|
|
1914
2156
|
|
|
2157
|
+
const renderPreview = defineRuntimeTool({
|
|
2158
|
+
name: 'render_preview',
|
|
2159
|
+
description: 'Render and inspect a preview',
|
|
2160
|
+
identity: {
|
|
2161
|
+
serviceName: 'mediaTools',
|
|
2162
|
+
action: 'renderPreview',
|
|
2163
|
+
scope: 'admin',
|
|
2164
|
+
method: 'POST',
|
|
2165
|
+
},
|
|
2166
|
+
input: z.object({ prompt: z.string() }),
|
|
2167
|
+
output: z.object({ assetId: z.string(), imageBase64: z.string() }),
|
|
2168
|
+
handler: async ({ input }) => renderAndSave(input.prompt),
|
|
2169
|
+
present: {
|
|
2170
|
+
mcp: (output) => ({
|
|
2171
|
+
content: [
|
|
2172
|
+
{ type: 'image', data: output.imageBase64, mimeType: 'image/png' },
|
|
2173
|
+
{ type: 'text', text: output.assetId },
|
|
2174
|
+
],
|
|
2175
|
+
}),
|
|
2176
|
+
agent: (output) => ({
|
|
2177
|
+
type: 'content',
|
|
2178
|
+
value: [{
|
|
2179
|
+
type: 'file',
|
|
2180
|
+
data: { type: 'data', data: output.imageBase64 },
|
|
2181
|
+
mediaType: 'image/png',
|
|
2182
|
+
filename: `${output.assetId}.png`,
|
|
2183
|
+
}],
|
|
2184
|
+
}),
|
|
2185
|
+
},
|
|
2186
|
+
})
|
|
2187
|
+
|
|
1915
2188
|
const handleMcp = createMcpHandler({
|
|
1916
2189
|
serverInfo: { name: 'my-app', version: '1.0.0' },
|
|
1917
2190
|
auth,
|
|
1918
2191
|
services: [service],
|
|
1919
2192
|
lifecycle: { beforeHandle: authHook },
|
|
1920
2193
|
hooks: audit.toolCall,
|
|
1921
|
-
nativeTools: ({ registerTool }
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
scope: 'admin',
|
|
1929
|
-
method: 'POST',
|
|
1930
|
-
},
|
|
1931
|
-
input: z.object({ prompt: z.string() }),
|
|
1932
|
-
output: z.object({ assetId: z.string() }),
|
|
1933
|
-
handler: async ({ input, traceId }) => ({
|
|
1934
|
-
content: [
|
|
1935
|
-
{ type: 'image', data: await renderBase64(input.prompt), mimeType: 'image/png' },
|
|
1936
|
-
{ type: 'text', text: `trace: ${traceId}` },
|
|
1937
|
-
],
|
|
1938
|
-
structuredContent: { assetId: await saveAsset(identity) },
|
|
1939
|
-
}),
|
|
1940
|
-
})
|
|
1941
|
-
},
|
|
2194
|
+
nativeTools: ({ registerTool }) => registerTool(renderPreview),
|
|
2195
|
+
})
|
|
2196
|
+
|
|
2197
|
+
const agentTools = mountAgent([service], {
|
|
2198
|
+
runtimeTools: [renderPreview],
|
|
2199
|
+
lifecycle: { beforeHandle: authHook },
|
|
2200
|
+
hooks: audit.toolCall,
|
|
1942
2201
|
})
|
|
1943
2202
|
```
|
|
1944
2203
|
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
2204
|
+
`transports` defaults to `['MCP', 'AGENT']`; set an explicit subset when an
|
|
2205
|
+
operation belongs on only one surface. The configured identity becomes the
|
|
2206
|
+
hook/lifecycle `OperationIdentity` and the tool `RequestEvent`
|
|
2207
|
+
(`serviceName`, `action`, `httpMethod`). A runtime operation has no HTTP route,
|
|
2208
|
+
so no fake `path` is added.
|
|
2209
|
+
|
|
2210
|
+
When `output` is declared, Stitchkit validates the neutral handler result after
|
|
2211
|
+
`afterHandle`. MCP owns `structuredContent` and `isError`: a presenter supplies
|
|
2212
|
+
only rich `content`/metadata, while the framework inserts the validated
|
|
2213
|
+
structured result and normalises failures. The Agent adapter uses the AI SDK's
|
|
2214
|
+
official `toModelOutput` callback, so `execute` and application UI keep the
|
|
2215
|
+
neutral output while the model receives text/file content. Presentation
|
|
2216
|
+
callbacks require an output schema.
|
|
1950
2217
|
|
|
1951
2218
|
The MCP registration uses an identity carrier: the SDK advertises the compiled
|
|
1952
2219
|
JSON Schema but forwards the raw object into Stitchkit. Input failures therefore
|
|
@@ -2240,7 +2507,7 @@ It returns a handle with three pieces, all wired into `createServer`:
|
|
|
2240
2507
|
createServer({
|
|
2241
2508
|
services,
|
|
2242
2509
|
websocket: socket.websocket, // → Bun.serve websocket handlers
|
|
2243
|
-
rawRoutes: [socket.route], // ready-made /socket.io/* route
|
|
2510
|
+
rawRoutes: [socket.route], // ready-made /socket.io/*socketPath route
|
|
2244
2511
|
})
|
|
2245
2512
|
|
|
2246
2513
|
// elsewhere — broadcast:
|
|
@@ -2251,7 +2518,7 @@ socket.io.emit('note:created', note)
|
|
|
2251
2518
|
|--------------|---------|
|
|
2252
2519
|
| `io` | the typed Socket.IO server — attach `connection` handlers, broadcast |
|
|
2253
2520
|
| `websocket` | Bun WebSocket handlers — pass to `createServer({ websocket })` |
|
|
2254
|
-
| `route` | the `/socket.io
|
|
2521
|
+
| `route` | the `/socket.io/*socketPath` raw route — pass to `createServer({ rawRoutes })` |
|
|
2255
2522
|
|
|
2256
2523
|
`SocketIOServerConfig` also takes `path`, `transports`, `pingTimeout` and
|
|
2257
2524
|
`pingInterval`. For anything else socket.io's `ServerOptions` exposes, use the
|
|
@@ -2427,9 +2694,16 @@ updater per entity:
|
|
|
2427
2694
|
import { createEntityCacheHandlers } from 'stitchkit/react'
|
|
2428
2695
|
|
|
2429
2696
|
const widgetCache = createEntityCacheHandlers<Widget>({
|
|
2430
|
-
getId: (
|
|
2431
|
-
|
|
2432
|
-
|
|
2697
|
+
getId: (widget) => widget.id,
|
|
2698
|
+
getListItemId: (widget) => widget.id,
|
|
2699
|
+
toListItem: (widget) => widget,
|
|
2700
|
+
list: {
|
|
2701
|
+
key: ['widgets'],
|
|
2702
|
+
shape: 'paginated',
|
|
2703
|
+
createAt: 'start',
|
|
2704
|
+
updateMissing: 'skip',
|
|
2705
|
+
},
|
|
2706
|
+
detailKey: (event) => ['widgets', event.id],
|
|
2433
2707
|
})
|
|
2434
2708
|
|
|
2435
2709
|
createCacheBridge({ socket, queryClient, handlers: {
|
|
@@ -2439,10 +2713,52 @@ createCacheBridge({ socket, queryClient, handlers: {
|
|
|
2439
2713
|
}})
|
|
2440
2714
|
```
|
|
2441
2715
|
|
|
2442
|
-
|
|
2443
|
-
|
|
2444
|
-
|
|
2445
|
-
|
|
2716
|
+
The `list.shape` discriminant supports `array`, `paginated`, `infinite-array`
|
|
2717
|
+
and `infinite-paginated`. Every mutation preserves the surrounding envelope,
|
|
2718
|
+
page metadata and `pageParams`; an infinite create changes only the selected
|
|
2719
|
+
edge page (`createAt: 'start' | 'end'`). Creates are deduplicated across every
|
|
2720
|
+
cached page. `updateMissing` makes an absent update explicitly skip or insert.
|
|
2721
|
+
|
|
2722
|
+
The event entity may be richer than a list row. Keep the full value in detail
|
|
2723
|
+
cache, project it for lists, and provide the same comparator the backend uses:
|
|
2724
|
+
|
|
2725
|
+
```ts
|
|
2726
|
+
const memberCache = createEntityCacheHandlers<Member, MemberListItem>({
|
|
2727
|
+
getId: (member) => member.id,
|
|
2728
|
+
getListItemId: (item) => item.id,
|
|
2729
|
+
toListItem: (member) => ({
|
|
2730
|
+
id: member.id,
|
|
2731
|
+
name: member.name,
|
|
2732
|
+
joinedAt: member.joinedAt,
|
|
2733
|
+
}),
|
|
2734
|
+
list: {
|
|
2735
|
+
key: (event) => {
|
|
2736
|
+
if (event.type !== 'deleted') {
|
|
2737
|
+
return ['workspaces', event.entity.workspaceId, 'members']
|
|
2738
|
+
}
|
|
2739
|
+
if ('workspaceId' in event.payload) {
|
|
2740
|
+
return ['workspaces', event.payload.workspaceId, 'members']
|
|
2741
|
+
}
|
|
2742
|
+
throw new Error('A scoped delete must carry its entity')
|
|
2743
|
+
},
|
|
2744
|
+
shape: 'array',
|
|
2745
|
+
createAt: 'start',
|
|
2746
|
+
updateMissing: 'skip',
|
|
2747
|
+
compare: (left, right) => left.joinedAt.localeCompare(right.joinedAt),
|
|
2748
|
+
},
|
|
2749
|
+
detailKey: (event) => ['members', event.id],
|
|
2750
|
+
})
|
|
2751
|
+
```
|
|
2752
|
+
|
|
2753
|
+
Static `QueryKey` values remain the short path. A key factory receives a typed
|
|
2754
|
+
`created | updated | deleted` event, so scoped keys can use the full entity or
|
|
2755
|
+
deleted payload without guessing. The same resolved detail key drives the
|
|
2756
|
+
`isFresh` echo guard. Shape checks also leave neighbouring detail caches alone
|
|
2757
|
+
when a list key is intentionally used as a partial query-key prefix.
|
|
2758
|
+
|
|
2759
|
+
The helper deliberately does **not** flatten pages, update totals, derive a
|
|
2760
|
+
sort order or replace arbitrary `setQueryData` logic. Those are application
|
|
2761
|
+
policies; this helper only applies declared CRUD semantics.
|
|
2446
2762
|
|
|
2447
2763
|
## Raw binary lane (Bun)
|
|
2448
2764
|
|
|
@@ -2516,7 +2832,7 @@ Notes:
|
|
|
2516
2832
|
- **Bun-only.** On Node, Socket.IO attaches to the `node:http.Server` `upgrade`
|
|
2517
2833
|
event (`serveNode({ socket })`); a raw lane there is a separate upgrade
|
|
2518
2834
|
handler, not this composition. See [ADR 0020](../decisions/0020-raw-websocket-lane.md).
|
|
2519
|
-
- The upgrade path must not collide with `/socket.io
|
|
2835
|
+
- The upgrade path must not collide with `/socket.io/*socketPath`.
|
|
2520
2836
|
- The tuning (`maxPayloadLength`, `idleTimeout`, `backpressureLimit`, …) is
|
|
2521
2837
|
global — keep `idleTimeout` ≥ Socket.IO needs (> 2 × `pingInterval`).
|
|
2522
2838
|
- For high throughput, handle backpressure in the raw lane: `ws.send()` returns
|
|
@@ -2576,6 +2892,19 @@ gives you the contract and the metadata (`idempotent`, the open `source` tag,
|
|
|
2576
2892
|
|
|
2577
2893
|
# Auth & errors
|
|
2578
2894
|
|
|
2895
|
+
Client-side login/session operations may legitimately answer `401`. Declare
|
|
2896
|
+
that policy with contract-owned matchers instead of path strings:
|
|
2897
|
+
|
|
2898
|
+
```ts
|
|
2899
|
+
const http = createHttpClient({
|
|
2900
|
+
baseUrl,
|
|
2901
|
+
suppressUnauthorizedFor: contractEndpointMatchers(publicAuth, ['login', 'verify']),
|
|
2902
|
+
})
|
|
2903
|
+
```
|
|
2904
|
+
|
|
2905
|
+
Only the selected operations suppress the global `unauthorized` event; a 401
|
|
2906
|
+
from any neighbouring protected route still signals session expiry.
|
|
2907
|
+
|
|
2579
2908
|
stitchkit carries no domain model — it does not know what a user is. What it
|
|
2580
2909
|
provides is the *control flow*: a scope on every endpoint, one hook that
|
|
2581
2910
|
enforces it, and one error model shared by every transport. The identity and
|
|
@@ -2744,6 +3073,20 @@ session.clear() // → a Set-Cookie value that expires it
|
|
|
2744
3073
|
config is not repeated at every call site. `parseCookies(header)` and
|
|
2745
3074
|
`serializeCookie(name, value, opts)` are the lower-level primitives.
|
|
2746
3075
|
|
|
3076
|
+
To set a cookie from a schema-validated JSON endpoint without losing its typed
|
|
3077
|
+
client result, declare [`responseMeta`](./server.md#typed-json-response-metadata)
|
|
3078
|
+
and append the generated value:
|
|
3079
|
+
|
|
3080
|
+
```ts
|
|
3081
|
+
complete: async ({ response }) => {
|
|
3082
|
+
response.headers.append('Set-Cookie', session.set('abc123'))
|
|
3083
|
+
return authenticatedUser
|
|
3084
|
+
}
|
|
3085
|
+
```
|
|
3086
|
+
|
|
3087
|
+
Append once per cookie; Stitchkit preserves separate `Set-Cookie` fields through
|
|
3088
|
+
both Bun and Node. Cookie/session policy remains application logic.
|
|
3089
|
+
|
|
2747
3090
|
## The error model
|
|
2748
3091
|
|
|
2749
3092
|
One error type, `AppError`, is shared by the contract, the server and the
|
|
@@ -3609,7 +3952,7 @@ keeps `tenantId` out of the body/query
|
|
|
3609
3952
|
|
|
3610
3953
|
```ts
|
|
3611
3954
|
const widgetsApi = createClient(widgets, http, {
|
|
3612
|
-
pathPrefix: (
|
|
3955
|
+
pathPrefix: ({ tenantId }) => `tenants/${tenantId}/`,
|
|
3613
3956
|
stripPrefixKeys: ['tenantId'],
|
|
3614
3957
|
})
|
|
3615
3958
|
|
|
@@ -3714,6 +4057,109 @@ current one *up to* your target, and apply each snippet.
|
|
|
3714
4057
|
(`STITCH_ERROR_STATUS`, `serveFile`, `scopePrefixes`, `afterToolCall`'s
|
|
3715
4058
|
`MethodDef`, `maxUploadBytes`) are available to adopt, not required.
|
|
3716
4059
|
|
|
4060
|
+
## Unreleased breaking migrations
|
|
4061
|
+
|
|
4062
|
+
Entity cache handlers now require the cached list shape and CRUD policies. Move
|
|
4063
|
+
`listKey` under `list`, make detail keys event-aware, and state the list-item
|
|
4064
|
+
identity/projection explicitly:
|
|
4065
|
+
|
|
4066
|
+
```ts
|
|
4067
|
+
// before
|
|
4068
|
+
createEntityCacheHandlers<Entity>({
|
|
4069
|
+
getId,
|
|
4070
|
+
listKey: ['entities'],
|
|
4071
|
+
detailKey: (id) => ['entities', id],
|
|
4072
|
+
})
|
|
4073
|
+
|
|
4074
|
+
// after
|
|
4075
|
+
createEntityCacheHandlers<Entity, EntityListItem>({
|
|
4076
|
+
getId,
|
|
4077
|
+
getListItemId: (item) => item.id,
|
|
4078
|
+
toListItem: (entity) => ({ id: entity.id, name: entity.name }),
|
|
4079
|
+
list: {
|
|
4080
|
+
key: ['entities'],
|
|
4081
|
+
shape: 'paginated',
|
|
4082
|
+
createAt: 'start',
|
|
4083
|
+
updateMissing: 'skip',
|
|
4084
|
+
},
|
|
4085
|
+
detailKey: (event) => ['entities', event.id],
|
|
4086
|
+
})
|
|
4087
|
+
```
|
|
4088
|
+
|
|
4089
|
+
Choose `array`, `paginated`, `infinite-array` or `infinite-paginated` to match
|
|
4090
|
+
the actual cached data. A dynamic `list.key` / `detailKey` receives a
|
|
4091
|
+
discriminated event and can derive scoped keys from the created/updated entity
|
|
4092
|
+
or deleted payload. Add `compare` only when the backend has a canonical order;
|
|
4093
|
+
the framework does not guess it or mutate pagination metadata.
|
|
4094
|
+
|
|
4095
|
+
Protected native MCP operations now use the transport-neutral runtime tool
|
|
4096
|
+
definition. Return the schema-owned value from the handler and move MCP content
|
|
4097
|
+
or metadata into `present.mcp`; `structuredContent` and `isError` are
|
|
4098
|
+
framework-owned:
|
|
4099
|
+
|
|
4100
|
+
```ts
|
|
4101
|
+
// before
|
|
4102
|
+
registerTool({ input, output, handler: async () => ({
|
|
4103
|
+
content: [{ type: 'image', data, mimeType: 'image/png' }],
|
|
4104
|
+
structuredContent: { assetId },
|
|
4105
|
+
}) })
|
|
4106
|
+
|
|
4107
|
+
// after
|
|
4108
|
+
const preview = defineRuntimeTool({
|
|
4109
|
+
name: 'render_preview', description, identity, input, output,
|
|
4110
|
+
handler: async () => ({ assetId, data }),
|
|
4111
|
+
present: {
|
|
4112
|
+
mcp: (result) => ({
|
|
4113
|
+
content: [{ type: 'image', data: result.data, mimeType: 'image/png' }],
|
|
4114
|
+
}),
|
|
4115
|
+
},
|
|
4116
|
+
})
|
|
4117
|
+
nativeTools: ({ registerTool }) => registerTool(preview)
|
|
4118
|
+
```
|
|
4119
|
+
|
|
4120
|
+
The removed `NativeMcp*` types have no aliases. Use `RuntimeToolDefinition`,
|
|
4121
|
+
`RuntimeToolIdentity`, `RuntimeToolHandlerContext` and
|
|
4122
|
+
`RuntimeMcpPresentation`. The same definition can now be passed to
|
|
4123
|
+
`mountAgent(services, { runtimeTools: [preview] })`; add `present.agent` only
|
|
4124
|
+
when the model needs rich text/file content instead of the neutral JSON result.
|
|
4125
|
+
|
|
4126
|
+
Trailing wildcards must be named consistently across the path and params schema:
|
|
4127
|
+
|
|
4128
|
+
```ts
|
|
4129
|
+
// before
|
|
4130
|
+
path: '/app/:slug/*'
|
|
4131
|
+
params: z.object({ slug: z.string(), '*': z.string() })
|
|
4132
|
+
ctx.params['*']
|
|
4133
|
+
api.app({ slug: 'foo', '*': 'a/b' })
|
|
4134
|
+
|
|
4135
|
+
// after
|
|
4136
|
+
path: '/app/:slug/*filePath'
|
|
4137
|
+
params: z.object({ slug: z.string(), filePath: z.string() })
|
|
4138
|
+
ctx.params.filePath
|
|
4139
|
+
api.app({ slug: 'foo', filePath: 'a/b' })
|
|
4140
|
+
```
|
|
4141
|
+
|
|
4142
|
+
Bare wildcards have no compatibility alias; raw routes use the same named form.
|
|
4143
|
+
|
|
4144
|
+
### Expected-401 matchers
|
|
4145
|
+
|
|
4146
|
+
`HttpClientConfig.authEndpoints` is removed. Replace manual path prefixes with
|
|
4147
|
+
the operations whose 401 response is expected:
|
|
4148
|
+
|
|
4149
|
+
```ts
|
|
4150
|
+
// before
|
|
4151
|
+
createHttpClient({ baseUrl, authEndpoints: ['/api/auth/'] })
|
|
4152
|
+
|
|
4153
|
+
// after
|
|
4154
|
+
createHttpClient({
|
|
4155
|
+
baseUrl,
|
|
4156
|
+
suppressUnauthorizedFor: contractEndpointMatchers(authContract, ['login', 'verify']),
|
|
4157
|
+
})
|
|
4158
|
+
```
|
|
4159
|
+
|
|
4160
|
+
There is no implicit `/auth/` suppression. Omit `suppressUnauthorizedFor` when
|
|
4161
|
+
every 401 should emit the global `unauthorized` event.
|
|
4162
|
+
|
|
3717
4163
|
## The 0.37 migration
|
|
3718
4164
|
|
|
3719
4165
|
Tool presentation is no longer an executable Zod parser. Replace the removed
|
|
@@ -3908,13 +4354,26 @@ The browser-and-server entrypoint. Re-exports everything from
|
|
|
3908
4354
|
| Export | Kind | Summary |
|
|
3909
4355
|
|--------|------|---------|
|
|
3910
4356
|
| `createClient` | function | build a typed client from a contract — [guide](../guide/client.md#createclient) |
|
|
3911
|
-
| `createClients` | function | build one typed client per contract from a registry |
|
|
4357
|
+
| `createClients` | function | build one exact typed client per contract from a registry; accepts the same scoped config and transports as `createClient` |
|
|
4358
|
+
| `createScopedClients` | function | build one registry routed by contract scope; arrays compose contracts into one namespace |
|
|
4359
|
+
| `ScopeClientConfigs` | _type_ | per-scope client routing configuration consumed by `createScopedClients` |
|
|
4360
|
+
| `ScopedClientRegistry` | _type_ | exact composed registry returned by `createScopedClients` |
|
|
4361
|
+
| `ClientRegistryValue` | _type_ | one contract or a contract array composing one client namespace |
|
|
4362
|
+
| `ClientContract` | _type_ | HTTP-client-compatible contract value used by scoped registries |
|
|
4363
|
+
| `RegistryScope` | _type_ | contract-scope union inferred from a scoped client registry value |
|
|
4364
|
+
| `createUrlBuilder` | function | build synchronous browser-native URLs for all HTTP endpoints; body methods accept URL-bound args only — [guide](../guide/client.md#contract-url-builders) |
|
|
4365
|
+
| `createUrlBuilders` | function | build one exact URL builder per contract in a registry |
|
|
4366
|
+
| `UrlBuilderConfig` | _type_ | explicit `{ baseUrl }` source for a URL builder |
|
|
3912
4367
|
| `ClientConfig` | _type_ | config for `createClient`'s bare-fetch mode (2nd arg, no `HttpClient`) |
|
|
3913
4368
|
| `ContractClientConfig` | _type_ | per-tenant / resource-scoped client config — dynamic `pathPrefix` + `stripPrefixKeys` ([guide](../guide/client.md#contractclientconfig--per-tenant--resource-scoped-clients)) |
|
|
4369
|
+
| `contractEndpointMatchers` | function | compile exact pathname matchers for selected HTTP contract operations and expected-401 policy |
|
|
4370
|
+
| `PathPrefixArgs` | _type_ | required string-valued keys exposed to a typed dynamic `pathPrefix` callback |
|
|
3914
4371
|
| `createHttpClient` | function | the Ky-based HTTP transport — [guide](../guide/client.md#createhttpclient) |
|
|
3915
4372
|
| `ApiError` | class | a non-2xx response, with `code` / `status` / `details` / `hint` |
|
|
3916
4373
|
| `HttpClient` | _type_ | the transport interface `createClient` builds on |
|
|
4374
|
+
| `ConfiguredHttpClient` | _type_ | a framework-created `HttpClient` carrying its readonly `baseUrl` for URL builders |
|
|
3917
4375
|
| `HttpClientConfig` | _type_ | config for `createHttpClient` |
|
|
4376
|
+
| `UnauthorizedMatcher` | _type_ | exact `(pathname) => boolean` policy accepted by `suppressUnauthorizedFor` |
|
|
3918
4377
|
| `RequestOptions` | _type_ | per-call options — params, timeout, response type |
|
|
3919
4378
|
| `HeaderProvider` | _type_ | static or per-request headers |
|
|
3920
4379
|
| `ApiEvent` | _type_ | a client event — `unauthorized` / `network_error` / `logout` |
|
|
@@ -3965,7 +4424,12 @@ from the root `stitchkit`.
|
|
|
3965
4424
|
| `ContractDef` | _type_ | a defined contract |
|
|
3966
4425
|
| `ContractMeta` | _type_ | a contract's `prefix` + optional `scope` and `meta` (a default every endpoint shallow-merges over) |
|
|
3967
4426
|
| `EndpointDef` | _type_ | a single endpoint definition |
|
|
3968
|
-
| `
|
|
4427
|
+
| `HeadEndpointDef` | _type_ | explicit HTTP-only, bodyless `HEAD` endpoint definition |
|
|
4428
|
+
| `EndpointResponseMeta` | _type_ | static success metadata declared by an HTTP-only typed-data endpoint |
|
|
4429
|
+
| `ResponseMetadata` | _type_ | per-request outbound collector exposed as `ctx.response` only for a `responseMeta` endpoint |
|
|
4430
|
+
| `HttpSuccessStatus` | _type_ | supported declared 2xx success statuses |
|
|
4431
|
+
| `BodyHttpSuccessStatus` | _type_ | supported 2xx statuses excluding bodyless 204/205 |
|
|
4432
|
+
| `HttpMethod` | _type_ | `GET \| HEAD \| POST \| PUT \| PATCH \| DELETE` |
|
|
3969
4433
|
| `Transport` | _type_ | `HTTP \| MCP \| AGENT \| CLI` |
|
|
3970
4434
|
| `TransportSource` | _type_ | `http \| mcp \| agent \| cli` — the value of `ctx.source` |
|
|
3971
4435
|
| `RuntimeContext` | _type_ | the loose context seen by transport and hooks |
|
|
@@ -3975,6 +4439,9 @@ from the root `stitchkit`.
|
|
|
3975
4439
|
| `TypedHttpClient` | _type_ | the typed client, HTTP endpoints only (`= ScopedHttpClient<C, unknown>`) |
|
|
3976
4440
|
| `ScopedHttpClient` | _type_ | a client whose `stripPrefixKeys` become required args ([guide](../guide/multi-tenant.md)) |
|
|
3977
4441
|
| `ScopedEndpointFn` | _type_ | one method's signature with the consumed keys folded in |
|
|
4442
|
+
| `TypedUrlBuilder` | _type_ | one contract's HTTP endpoints as synchronous, method-aware URL functions |
|
|
4443
|
+
| `ScopedUrlBuilder` | _type_ | a URL builder whose scoped-prefix keys are required method arguments |
|
|
4444
|
+
| `ScopedUrlFn` | _type_ | one URL method's signature with scoped-prefix keys folded in |
|
|
3978
4445
|
| `MultipartFile` | _type_ | a `multipart` file field — `Blob \| FileDescriptor` |
|
|
3979
4446
|
| `FileDescriptor` | _type_ | a React Native / Expo file — `{ uri, name, type }` |
|
|
3980
4447
|
| `EndpointToolAnnotations` | _type_ | MCP behavioural hints on an endpoint (`readOnlyHint` / `destructiveHint` / `title`) |
|
|
@@ -4217,6 +4684,8 @@ Server-only. Turns contracts into MCP and AI-agent tools. Needs the
|
|
|
4217
4684
|
| `mountMcp` | function | add contract tools to an existing `McpServer` — [guide](../guide/mcp-and-agents.md#mountmcp) |
|
|
4218
4685
|
| `implementRemote` | function | bind a contract to a remote HTTP API — [guide](../guide/mcp-and-agents.md#proxying-a-remote-api--implementremote) |
|
|
4219
4686
|
| `mountAgent` | function | a Vercel AI SDK `ToolSet` from a service — [guide](../guide/mcp-and-agents.md#ai-agents--mountagent) |
|
|
4687
|
+
| `defineRuntimeTool` | function | define one validated pathless operation for MCP, Agent or both — [guide](../guide/mcp-and-agents.md#pathless-runtime-tools-and-multimodal-results) |
|
|
4688
|
+
| `createToolInvoker` | function | compile an exposure-aware in-process dispatcher over the canonical tool runner — [guide](../guide/mcp-and-agents.md#in-process-calls--createtoolinvoker) |
|
|
4220
4689
|
| `createCli` | function | a command-line program from contracts — [guide](../guide/cli.md) (also on `stitchkit/cli`) |
|
|
4221
4690
|
| `createToolkit` | function | context-typed tool mounts — [guide](../guide/cli.md#typed-context) |
|
|
4222
4691
|
| `mountViewFile` | function | a native multimodal "view file" MCP tool |
|
|
@@ -4232,10 +4701,17 @@ Server-only. Turns contracts into MCP and AI-agent tools. Needs the
|
|
|
4232
4701
|
| `McpSchemaValidationConfig` | _type_ | shared `{ policy, requireTypedProperties, allowUntyped, requirePortableFormats, allowFormats }` profile |
|
|
4233
4702
|
| `ValidateMcpSchemasConfig` | _type_ | standalone validation profile plus `services`, `extend`, flattening and logger |
|
|
4234
4703
|
| `NativeMcpRegistrar` | _type_ | protected `registerTool` plus explicit unprotected `rawServer` access |
|
|
4235
|
-
| `
|
|
4236
|
-
| `
|
|
4237
|
-
| `
|
|
4238
|
-
| `
|
|
4704
|
+
| `RuntimeToolDefinition` | _type_ | transport-neutral pathless operation with identity, schemas, handler and optional presenters |
|
|
4705
|
+
| `RuntimeToolDefinitionBase` | _type_ | common name, identity, input, exposure and MCP metadata fields |
|
|
4706
|
+
| `RuntimeToolDefinitionWithOutput` | _type_ | runtime definition whose handler and presenters share a validated output type |
|
|
4707
|
+
| `RuntimeToolDefinitionWithoutOutput` | _type_ | runtime definition without output validation or presentation callbacks |
|
|
4708
|
+
| `RuntimeToolIdentity` | _type_ | `{ serviceName, action, scope?, method, meta? }` for runtime lifecycle/audit |
|
|
4709
|
+
| `RuntimeToolHandlerContext` | _type_ | runtime context with the definition's parsed input |
|
|
4710
|
+
| `RuntimeToolOutput` | _type_ | output inferred from a runtime tool's optional Zod schema |
|
|
4711
|
+
| `RuntimeToolPresenters` | _type_ | optional MCP and AI SDK `toModelOutput` presentation callbacks |
|
|
4712
|
+
| `RuntimeMcpPresentation` | _type_ | MCP content/metadata result without framework-owned `structuredContent` or `isError` |
|
|
4713
|
+
| `RuntimeAgentModelOutput` | _type_ | AI SDK model-facing text/JSON/content output returned by `present.agent` |
|
|
4714
|
+
| `RuntimeToolTransport` | _type_ | runtime exposure: `'MCP' \| 'AGENT'` |
|
|
4239
4715
|
| `AgentMountConfig` | _type_ | config for `mountAgent` |
|
|
4240
4716
|
| `AgentContext` | _type_ | the context merged into agent tool handlers |
|
|
4241
4717
|
| `CliConfig` | _type_ | config for `createCli` |
|
|
@@ -4251,6 +4727,9 @@ Server-only. Turns contracts into MCP and AI-agent tools. Needs the
|
|
|
4251
4727
|
| `ToolErrorOptions` | _type_ | `{ toolName, error, context, endpoint }` for a thrown handler-path value |
|
|
4252
4728
|
| `ErrorHintFn` | _type_ | `(toolName, errorCode) => string \| null` — a per-tool recovery hint, shared by every mount |
|
|
4253
4729
|
| `ToolResult` | _type_ | the result of one tool call |
|
|
4730
|
+
| `ToolInvoker` | _type_ | immutable compiled dispatcher (`names` + `invoke`) |
|
|
4731
|
+
| `ToolInvokerConfig` | _type_ | exposure policy, context, lifecycle, hooks and runner options |
|
|
4732
|
+
| `ToolInvokerTransport` | _type_ | invoker exposure policy: `MCP \| AGENT \| CLI` |
|
|
4254
4733
|
| `ToolCallContext` | _type_ | the context every tool hook receives — `{ source }` plus whatever the mount's `context` added |
|
|
4255
4734
|
| `ViewFileOptions` | _type_ | options for `mountViewFile` |
|
|
4256
4735
|
| `McpAnnotations` | _type_ | MCP annotations on a media result |
|
|
@@ -4389,6 +4868,10 @@ and `react-query-kit` peers.
|
|
|
4389
4868
|
| `createEntityCacheHandlers` | function | created/updated/deleted cache handlers for one entity — [guide](../guide/realtime.md#entity-cache-handlers) |
|
|
4390
4869
|
| `EntityCacheConfig` | _type_ | config for `createEntityCacheHandlers` |
|
|
4391
4870
|
| `EntityCacheHandlers` | _type_ | the `{ created, updated, deleted }` handlers it returns |
|
|
4871
|
+
| `EntityCacheEvent` | _type_ | discriminated created/updated/deleted input for dynamic cache keys |
|
|
4872
|
+
| `EntityCacheKey` | _type_ | static `QueryKey` or event-aware key factory |
|
|
4873
|
+
| `EntityCacheListConfig` | _type_ | list shape, scoped key, insertion/missing-update policy and comparator |
|
|
4874
|
+
| `EntityCacheListShape` | _type_ | `array \| paginated \| infinite-array \| infinite-paginated` |
|
|
4392
4875
|
| `DeletedPayload` | _type_ | a `deleted` event payload — the entity or a bare `{ id }` |
|
|
4393
4876
|
| `CursorQueryConfig` | _type_ | config for `createCursorQuery` |
|
|
4394
4877
|
| `CacheBridge` | _type_ | the `createCacheBridge` handle |
|