stitchkit 0.8.0 → 0.9.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.
Files changed (45) hide show
  1. package/dist/browser/client.d.ts.map +1 -1
  2. package/dist/browser/socket-io.d.ts +11 -2
  3. package/dist/browser/socket-io.d.ts.map +1 -1
  4. package/dist/cli.js +3 -7
  5. package/dist/contract/define.d.ts +44 -2
  6. package/dist/contract/define.d.ts.map +1 -1
  7. package/dist/contract/index.d.ts +1 -1
  8. package/dist/contract/index.d.ts.map +1 -1
  9. package/dist/contract/index.js +0 -1
  10. package/dist/{index-za2p453b.js → index-031q8xmx.js} +1 -1
  11. package/dist/{index-tr7r1530.js → index-13psnhhe.js} +5 -9
  12. package/dist/{index-5qe31283.js → index-9zrq8x5z.js} +9 -6
  13. package/dist/index-jgpsd7dy.js +105 -0
  14. package/dist/{index-mwmpw6j1.js → index-p9m9c0jw.js} +2 -4
  15. package/dist/index-tm7dqzxc.js +20 -0
  16. package/dist/{index-5789sbt8.js → index-zshrc6kx.js} +9 -16
  17. package/dist/index.d.ts +1 -0
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +480 -13
  20. package/dist/node.js +4 -8
  21. package/dist/observability/index.js +4 -7
  22. package/dist/react.js +0 -2
  23. package/dist/retained.d.ts +30 -0
  24. package/dist/retained.d.ts.map +1 -0
  25. package/dist/server/implement.d.ts.map +1 -1
  26. package/dist/server/index.js +89 -34
  27. package/dist/server/types.d.ts +7 -0
  28. package/dist/server/types.d.ts.map +1 -1
  29. package/dist/tools/dispatch.d.ts +65 -0
  30. package/dist/tools/dispatch.d.ts.map +1 -0
  31. package/dist/tools/mcp-app.d.ts.map +1 -1
  32. package/dist/tools/remote.d.ts.map +1 -1
  33. package/dist/tools.d.ts +1 -0
  34. package/dist/tools.d.ts.map +1 -1
  35. package/dist/tools.js +290 -21
  36. package/llms-full.txt +111 -3
  37. package/package.json +5 -3
  38. package/dist/index-37x76zdn.js +0 -4
  39. package/dist/index-48ffdxgk.js +0 -6
  40. package/dist/index-809wc1tt.js +0 -18
  41. package/dist/index-a1702zj4.js +0 -72
  42. package/dist/index-kdkvp26v.js +0 -380
  43. package/dist/index-kzfs85xp.js +0 -9
  44. package/dist/index-wjwj5bz2.js +0 -37
  45. package/dist/index-x3fcszf8.js +0 -8
package/dist/tools.js CHANGED
@@ -1,39 +1,32 @@
1
1
  import {
2
- ApiError,
3
- createClient
4
- } from "./index-kdkvp26v.js";
2
+ inputIsQuery,
3
+ signJwt,
4
+ verifyPkce
5
+ } from "./index-9zrq8x5z.js";
5
6
  import {
6
7
  coerceJsonArgs,
7
8
  collectTools,
8
9
  createCli,
9
10
  createToolRunner,
11
+ executeToolMethod,
10
12
  fetchGuarded,
11
13
  flattenDiscriminatedUnion,
12
14
  formatToolError,
13
15
  pollUntil,
14
16
  readCapped,
15
17
  toolResultFromError
16
- } from "./index-tr7r1530.js";
17
- import {
18
- signJwt,
19
- verifyPkce
20
- } from "./index-5qe31283.js";
21
- import"./index-48ffdxgk.js";
18
+ } from "./index-13psnhhe.js";
22
19
  import {
23
20
  toJsonSchema
24
21
  } from "./index-0ed3bx43.js";
25
22
  import {
23
+ AppError,
26
24
  isWithinDir
27
- } from "./index-x3fcszf8.js";
28
- import"./index-wjwj5bz2.js";
25
+ } from "./index-jgpsd7dy.js";
29
26
  import {
30
- AppError
31
- } from "./index-eq29zkrx.js";
32
- import"./index-kzfs85xp.js";
33
- import {
34
- isRecord
35
- } from "./index-809wc1tt.js";
36
- import"./index-37x76zdn.js";
27
+ isRecord,
28
+ typedEntries
29
+ } from "./index-tm7dqzxc.js";
37
30
 
38
31
  // src/tools/agent.ts
39
32
  import { tool, zodSchema } from "ai";
@@ -76,6 +69,30 @@ function mountAgent(services, config = {}) {
76
69
  }
77
70
  return tools;
78
71
  }
72
+ // src/tools/dispatch.ts
73
+ function createContractDispatcher(services, config) {
74
+ const list = Array.isArray(services) ? services : [services];
75
+ const index = new Map;
76
+ for (const service of list) {
77
+ for (const [name, method] of Object.entries(service.methods)) {
78
+ if (index.has(name)) {
79
+ throw new Error(`createContractDispatcher: duplicate method "${name}" across services`);
80
+ }
81
+ index.set(name, method);
82
+ }
83
+ }
84
+ return {
85
+ methods: [...index.keys()],
86
+ dispatch(method, args, context) {
87
+ const target = index.get(method);
88
+ if (!target) {
89
+ return Promise.resolve(toolResultFromError(new AppError("NOT_FOUND", `Unknown method: ${method}`, 404)));
90
+ }
91
+ const ctx = { ...config.context, ...context, source: config.source };
92
+ return executeToolMethod(target, method, args, ctx, config.hooks, config.lifecycle, config.coerceJsonArgs ?? false);
93
+ }
94
+ };
95
+ }
79
96
  // src/tools/manifest.ts
80
97
  function buildToolManifest(tools) {
81
98
  return tools.map((t) => {
@@ -94,16 +111,15 @@ import { z } from "zod";
94
111
 
95
112
  // src/tools/mcp-app.ts
96
113
  import { readFileSync } from "node:fs";
97
- import { createRequire } from "node:module";
114
+ import { fileURLToPath } from "node:url";
98
115
  var RESOURCE_MIME_TYPE = "text/html;profile=mcp-app";
99
116
  var EXT_APPS_BUNDLE_PLACEHOLDER = "/*__EXT_APPS_BUNDLE__*/";
100
- var require2 = createRequire(import.meta.url);
101
117
  function inlineMcpAppBundle(html) {
102
118
  if (!html.includes(EXT_APPS_BUNDLE_PLACEHOLDER))
103
119
  return html;
104
120
  let bundlePath;
105
121
  try {
106
- bundlePath = require2.resolve("@modelcontextprotocol/ext-apps/app-with-deps");
122
+ bundlePath = fileURLToPath(import.meta.resolve("@modelcontextprotocol/ext-apps/app-with-deps"));
107
123
  } catch {
108
124
  throw new Error("[stitchkit] inlineMcpAppBundle: '@modelcontextprotocol/ext-apps' is not installed. " + "Add it as a dependency to serve MCP App widgets.");
109
125
  }
@@ -836,6 +852,257 @@ function mountOAuthProvider(config) {
836
852
  };
837
853
  return [metadataRoute, registerRoute, authorizeRoute, tokenRoute];
838
854
  }
855
+ // src/browser/http.ts
856
+ import ky, { isHTTPError } from "ky";
857
+ class ApiError extends Error {
858
+ code;
859
+ status;
860
+ details;
861
+ hint;
862
+ constructor(code, status = 0, details, message, hint) {
863
+ super(message ?? `API Error: ${code}`);
864
+ this.code = code;
865
+ this.status = status;
866
+ this.details = details;
867
+ this.hint = hint;
868
+ this.name = "ApiError";
869
+ }
870
+ static is(error) {
871
+ return error instanceof ApiError;
872
+ }
873
+ }
874
+ function parseApiErrorBody(body) {
875
+ if (!isRecord(body) || !isRecord(body.error))
876
+ return null;
877
+ const error = body.error;
878
+ if (typeof error.code !== "string")
879
+ return null;
880
+ return {
881
+ code: error.code,
882
+ message: typeof error.message === "string" ? error.message : undefined,
883
+ details: error.details,
884
+ hint: typeof error.hint === "string" ? error.hint : undefined
885
+ };
886
+ }
887
+
888
+ // src/browser/client.ts
889
+ function withTimeout(options, timeout) {
890
+ if (timeout === undefined)
891
+ return options;
892
+ return { ...options, timeout };
893
+ }
894
+ function isParamArray(value) {
895
+ return Array.isArray(value) && value.every((v) => typeof v === "string" || typeof v === "number");
896
+ }
897
+ function collectQueryParams(args, skipKeys) {
898
+ const params = {};
899
+ let hasParams = false;
900
+ for (const [key, value] of Object.entries(args)) {
901
+ if (skipKeys.has(key) || value === undefined || value === null)
902
+ continue;
903
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean" || isParamArray(value)) {
904
+ params[key] = value;
905
+ hasParams = true;
906
+ }
907
+ }
908
+ return hasParams ? params : undefined;
909
+ }
910
+ function createClient(contract, configOrClient, contractConfig) {
911
+ const client = {};
912
+ const makeMethod = isHttpAdapter(configOrClient) ? (endpoint) => createHttpMethod(endpoint, contract.meta.prefix, configOrClient, contractConfig) : (endpoint) => createFetchMethod(endpoint, contract.meta.prefix, configOrClient, contractConfig);
913
+ for (const [key, endpoint] of typedEntries(contract.endpoints)) {
914
+ if (endpoint.expose && !endpoint.expose.includes("HTTP"))
915
+ continue;
916
+ setClientMethod(client, key, makeMethod(endpoint));
917
+ }
918
+ return client;
919
+ }
920
+ function isHttpAdapter(value) {
921
+ return typeof value === "object" && "get" in value && typeof value.get === "function";
922
+ }
923
+ function setClientMethod(target, key, method) {
924
+ target[key] = method;
925
+ }
926
+ function createHttpMethod(endpoint, prefix, client, config) {
927
+ const httpMethod = endpoint.method.toLowerCase();
928
+ const isGet = httpMethod === "get";
929
+ const paramNames = extractParamNames(endpoint.path);
930
+ const prefixKeys = new Set([...config?.stripPrefixKeys ?? [], ...paramNames]);
931
+ return (...args) => {
932
+ const firstArg = args[0] ?? {};
933
+ let pathPrefixStr = "";
934
+ if (config?.pathPrefix) {
935
+ pathPrefixStr = typeof config.pathPrefix === "function" ? config.pathPrefix(firstArg) : config.pathPrefix;
936
+ if (pathPrefixStr && !pathPrefixStr.endsWith("/"))
937
+ pathPrefixStr += "/";
938
+ }
939
+ let url = `${pathPrefixStr}${prefix}${endpoint.path}`;
940
+ for (const name of paramNames) {
941
+ const value = firstArg[name];
942
+ if (value === undefined || value === null) {
943
+ throw new Error(`Missing path param: ${name}`);
944
+ }
945
+ url = url.replace(`:${name}`, encodeURIComponent(String(value)));
946
+ }
947
+ if (url.endsWith("/"))
948
+ url = url.slice(0, -1);
949
+ if (endpoint.multipart) {
950
+ const file = firstArg[endpoint.multipart];
951
+ if (!isMultipartFile(file)) {
952
+ throw new Error(`Missing multipart file field: ${endpoint.multipart}`);
953
+ }
954
+ const formData = new FormData;
955
+ appendMultipartFile(formData, endpoint.multipart, file);
956
+ appendFormFields(formData, firstArg, new Set([...prefixKeys, endpoint.multipart]));
957
+ return client.post(url, formData, withTimeout(undefined, endpoint.timeout));
958
+ }
959
+ if (isGet) {
960
+ const params = collectQueryParams(firstArg, prefixKeys);
961
+ return client.get(url, withTimeout(params ? { params } : undefined, endpoint.timeout));
962
+ }
963
+ if (httpMethod === "delete") {
964
+ const params = collectQueryParams(firstArg, prefixKeys);
965
+ return client.delete(url, withTimeout(params ? { params } : undefined, endpoint.timeout));
966
+ }
967
+ const payload = {};
968
+ for (const [key, value] of Object.entries(firstArg)) {
969
+ if (!prefixKeys.has(key) && value !== undefined) {
970
+ payload[key] = value;
971
+ }
972
+ }
973
+ return client[httpMethod](url, Object.keys(payload).length > 0 ? payload : undefined, withTimeout(undefined, endpoint.timeout));
974
+ };
975
+ }
976
+ function createFetchMethod(endpoint, prefix, config, contractConfig) {
977
+ const prefixKeys = new Set(contractConfig?.stripPrefixKeys ?? []);
978
+ return async (args) => {
979
+ let pathPrefixStr = "";
980
+ if (contractConfig?.pathPrefix) {
981
+ pathPrefixStr = typeof contractConfig.pathPrefix === "function" ? contractConfig.pathPrefix(args ?? {}) : contractConfig.pathPrefix;
982
+ if (pathPrefixStr && !pathPrefixStr.endsWith("/"))
983
+ pathPrefixStr += "/";
984
+ }
985
+ let url = buildFetchUrl(config.baseUrl, prefix, endpoint.path, args, pathPrefixStr);
986
+ const headers = {
987
+ Accept: "application/json",
988
+ ...typeof config.headers === "function" ? config.headers() : config.headers
989
+ };
990
+ const isQuery = inputIsQuery(endpoint.method);
991
+ const hasBody = !isQuery && !endpoint.multipart && endpoint.input && args;
992
+ if (isQuery && args) {
993
+ const remaining = stripParams(args, endpoint.path, prefixKeys);
994
+ const searchParams = new URLSearchParams;
995
+ for (const [k, v] of Object.entries(remaining)) {
996
+ if (v === undefined || v === null)
997
+ continue;
998
+ if (isParamArray(v)) {
999
+ for (const item of v)
1000
+ searchParams.append(k, String(item));
1001
+ } else if (typeof v !== "object") {
1002
+ searchParams.set(k, String(v));
1003
+ }
1004
+ }
1005
+ if (searchParams.size > 0)
1006
+ url += `?${searchParams}`;
1007
+ }
1008
+ if (hasBody)
1009
+ headers["Content-Type"] = "application/json";
1010
+ if (endpoint.multipart && args) {
1011
+ const file = args[endpoint.multipart];
1012
+ if (!isMultipartFile(file)) {
1013
+ throw new Error(`Missing multipart file field: ${endpoint.multipart}`);
1014
+ }
1015
+ const formData = new FormData;
1016
+ appendMultipartFile(formData, endpoint.multipart, file);
1017
+ appendFormFields(formData, stripParams(args, endpoint.path, prefixKeys), new Set([endpoint.multipart]));
1018
+ const res2 = await fetch(url, {
1019
+ method: endpoint.method,
1020
+ headers,
1021
+ credentials: config.credentials,
1022
+ body: formData
1023
+ });
1024
+ if (!res2.ok) {
1025
+ await throwForErrorResponse(res2, config, null);
1026
+ }
1027
+ if (res2.status === 204)
1028
+ return;
1029
+ const json3 = await res2.json();
1030
+ return endpoint.output ? endpoint.output.parse(json3) : json3;
1031
+ }
1032
+ const res = await fetch(url, {
1033
+ method: endpoint.method,
1034
+ headers,
1035
+ credentials: config.credentials,
1036
+ ...hasBody && {
1037
+ body: JSON.stringify(stripParams(hasBody, endpoint.path, prefixKeys))
1038
+ }
1039
+ });
1040
+ if (!res.ok) {
1041
+ await throwForErrorResponse(res, config, { error: res.statusText });
1042
+ }
1043
+ if (res.status === 204)
1044
+ return;
1045
+ const json2 = await res.json();
1046
+ return endpoint.output ? endpoint.output.parse(json2) : json2;
1047
+ };
1048
+ }
1049
+ function isFileDescriptor(value) {
1050
+ return typeof value === "object" && value !== null && !(value instanceof Blob) && "uri" in value && typeof value.uri === "string" && "name" in value && typeof value.name === "string" && "type" in value && typeof value.type === "string";
1051
+ }
1052
+ function isMultipartFile(value) {
1053
+ return value instanceof Blob || isFileDescriptor(value);
1054
+ }
1055
+ function appendMultipartFile(form, field, file) {
1056
+ const sink = form;
1057
+ sink.append(field, file);
1058
+ }
1059
+ function appendFormFields(formData, values, skipKeys) {
1060
+ for (const [key, value] of Object.entries(values)) {
1061
+ if (skipKeys.has(key) || value === undefined || value === null)
1062
+ continue;
1063
+ formData.append(key, typeof value === "string" ? value : JSON.stringify(value));
1064
+ }
1065
+ }
1066
+ async function throwForErrorResponse(res, config, fallbackBody) {
1067
+ const body = await res.json().catch(() => fallbackBody);
1068
+ config.onError?.(res.status, body);
1069
+ const parsed = parseApiErrorBody(body);
1070
+ if (parsed) {
1071
+ throw new ApiError(parsed.code, res.status, parsed.details, parsed.message, parsed.hint);
1072
+ }
1073
+ throw new ApiError("HTTP_ERROR", res.status, { body });
1074
+ }
1075
+ function extractParamNames(path) {
1076
+ const matches = path.match(/:(\w+)/g);
1077
+ return matches ? matches.map((m) => m.slice(1)) : [];
1078
+ }
1079
+ function buildFetchUrl(baseUrl, prefix, path, args, pathPrefix = "") {
1080
+ let fullPath = `/${pathPrefix}${prefix}${path === "/" ? "" : path}`;
1081
+ if (args) {
1082
+ fullPath = fullPath.replace(/:(\w+)/g, (_, key) => {
1083
+ const val = args[key];
1084
+ if (val === undefined || val === null) {
1085
+ throw new Error(`Missing path param: ${key}`);
1086
+ }
1087
+ return encodeURIComponent(String(val));
1088
+ });
1089
+ }
1090
+ return `${baseUrl}${fullPath}`;
1091
+ }
1092
+ function stripParams(args, path, extra) {
1093
+ const skip = new Set(extra);
1094
+ for (const match of path.matchAll(/:(\w+)/g)) {
1095
+ if (match[1])
1096
+ skip.add(match[1]);
1097
+ }
1098
+ const result = {};
1099
+ for (const [k, v] of Object.entries(args)) {
1100
+ if (!skip.has(k))
1101
+ result[k] = v;
1102
+ }
1103
+ return result;
1104
+ }
1105
+
839
1106
  // src/tools/remote.ts
840
1107
  function toArgs(ctx) {
841
1108
  const { params, input } = ctx;
@@ -865,6 +1132,7 @@ function implementRemote(contract, http, options) {
865
1132
  inputSchema: endpoint.input,
866
1133
  outputSchema: endpoint.output,
867
1134
  multipart: endpoint.multipart,
1135
+ idempotent: endpoint.idempotent,
868
1136
  handler: async (ctx) => {
869
1137
  const call = client[key];
870
1138
  if (!call) {
@@ -1043,6 +1311,7 @@ export {
1043
1311
  createToolkit,
1044
1312
  createStdioMcpServer,
1045
1313
  createMcpHandler,
1314
+ createContractDispatcher,
1046
1315
  createCli,
1047
1316
  collectTools,
1048
1317
  coerceJsonArgs,
package/llms-full.txt CHANGED
@@ -236,6 +236,7 @@ export const users = defineContract({ prefix: 'users' }, {
236
236
  | `toolName` | no | explicit MCP / agent tool name (defaults to `prefix_key`) |
237
237
  | `multipart` | no | field name of a file upload — see [below](#file-uploads) |
238
238
  | `timeout` | no | per-endpoint client timeout in ms, for slow endpoints |
239
+ | `idempotent` | no | safe to call twice with the same input (like `PUT`/`DELETE`); a retrying transport reads it — see [Realtime](./realtime.md#bring-your-own-transport) |
239
240
  | `meta` | no | opaque app metadata — read in hooks / on tool mounts, never in OpenAPI ([below](#endpoint-metadata-meta)) |
240
241
 
241
242
  ## `params` vs `input` vs `output`
@@ -861,7 +862,10 @@ contract:
861
862
  - for `GET` / `DELETE`, the remaining fields become the **query string**
862
863
  (arrays become repeated keys),
863
864
  - for `POST` / `PUT` / `PATCH`, they become the **JSON body**,
864
- - a `multipart` field must be a `Blob` and is sent as `form-data`.
865
+ - a `multipart` field is a `Blob` (web / Bun) or a platform `FileDescriptor`
866
+ (`{ uri, name, type }`, for React Native / Expo) and is sent as `form-data`.
867
+ The exported `MultipartFile` / `FileDescriptor` types let you annotate your own
868
+ upload helpers.
865
869
 
866
870
  ### Many contracts at once
867
871
 
@@ -1668,7 +1672,41 @@ subscribe once; reconnection is the wrapper's problem, not yours.
1668
1672
 
1669
1673
  `SocketIOClientConfig` takes `url`, `path`, `withCredentials` (cookies on the
1670
1674
  handshake — default `true`), `auth`, `query`, `extraHeaders`, `transports`,
1671
- `reconnectionAttempts` and `reconnectionDelay`.
1675
+ `reconnectionAttempts`, `reconnectionDelay` and `retain` (below).
1676
+
1677
+ ### Sticky events
1678
+
1679
+ A handler that subscribes **after** an event was emitted misses it — the UI stays
1680
+ on stale state until the next emission. List those events in **`retain`** and the
1681
+ client keeps each one's last payload and replays it to a handler the moment it
1682
+ subscribes (and on the next subscribe after a re-render). It is the pub/sub
1683
+ analogue of an MQTT *retained* message or an RxJS `BehaviorSubject`.
1684
+
1685
+ ```ts
1686
+ const client = createSocketIOClient<ServerEvents, ClientEvents>({
1687
+ url,
1688
+ retain: ['presence:changed', 'job:state'], // events to keep the last value of
1689
+ })
1690
+ client.connect()
1691
+
1692
+ // Later — even after the event already fired — this handler fires at once with
1693
+ // the last value, then on every future change:
1694
+ client.on('job:state', (s) => render(s))
1695
+ ```
1696
+
1697
+ The retained value survives a `disconnect()` / `connect()` cycle (the store lives
1698
+ outside the socket). Only an event's **first** argument is retained.
1699
+
1700
+ For a pub/sub channel that is **not** Socket.IO (your own transport — see
1701
+ [below](#bring-your-own-transport)), use the same memory directly:
1702
+
1703
+ ```ts
1704
+ import { createRetainedTopics } from 'stitchkit'
1705
+
1706
+ const topics = createRetainedTopics<{ 'job:state': JobState }>()
1707
+ topics.record('job:state', state) // on every publish
1708
+ topics.replay('job:state', (s) => render(s)) // for a late subscriber
1709
+ ```
1672
1710
 
1673
1711
  ### Handshake auth — cookie or token
1674
1712
 
@@ -1834,6 +1872,69 @@ Notes:
1834
1872
  - For high throughput, handle backpressure in the raw lane: `ws.send()` returns
1835
1873
  `-1` under pressure; resume on the `drain` callback.
1836
1874
 
1875
+ ## Bring-your-own transport
1876
+
1877
+ Sometimes the transport is neither HTTP nor Socket.IO — a desktop app whose UI
1878
+ webview talks to its own local Bun sidecar over a raw WebSocket, an IPC channel,
1879
+ a queue worker. You still want one contract: a `defineContract` with typed
1880
+ client/server, Zod validation and a typed error envelope, not a hand-rolled
1881
+ method registry alongside it.
1882
+
1883
+ stitchkit ships the **executor**, not the transport. `createContractDispatcher`
1884
+ runs a contract method by its key through the *same* core as the MCP / agent
1885
+ mounts — same validation, the same `{ ok, data } | { ok: false, code, … }`
1886
+ envelope, the same hooks and `beforeHandle` scope gate. You own the wire (framing,
1887
+ handshake, reconnect) and call `dispatch` per inbound frame.
1888
+
1889
+ ```ts
1890
+ import { createContractDispatcher } from 'stitchkit/tools'
1891
+ import { implement } from 'stitchkit/server'
1892
+
1893
+ const service = implement(runtimeContract, { 'tasks.setDone': (ctx) => doIt(ctx.input), … })
1894
+
1895
+ const dispatcher = createContractDispatcher(service, { source: 'local-ws' })
1896
+
1897
+ // In your raw-WebSocket server, per `{ id, method, params }` frame:
1898
+ ws.onmessage = async (frame) => {
1899
+ const { id, method, params } = JSON.parse(frame.data)
1900
+ const result = await dispatcher.dispatch(method, params) // validates + runs
1901
+ ws.send(JSON.stringify({ id, ...result })) // { ok, data } | { ok:false, code, … }
1902
+ }
1903
+ ```
1904
+
1905
+ `dispatch` never throws for a normal call — a handler error becomes a failed
1906
+ result, and an unknown method is a `NOT_FOUND` result. Pass `hooks`
1907
+ (`beforeToolCall` / `afterToolCall`) and `lifecycle.beforeHandle` to audit and
1908
+ scope-guard exactly like the other transports. Tag calls with your own
1909
+ `source` — `TransportSource` is an open union.
1910
+
1911
+ ### Durability — `idempotent` + replay
1912
+
1913
+ If the sidecar can restart mid-call, the client decides what to do with an
1914
+ in-flight request on reconnect from the operation's **idempotency**, declared on
1915
+ the contract:
1916
+
1917
+ ```ts
1918
+ export const runtimeContract = defineContract({ prefix: 'runtime' }, {
1919
+ 'tasks.setDone': { method: 'POST', path: '/done', desc: '…',
1920
+ idempotent: true, input: taskDone, output: ok }, // re-send after reconnect — same result
1921
+ 'capture.start': { method: 'POST', path: '/start', desc: '…',
1922
+ output: snapshot }, // unset → one-shot, do not re-send
1923
+ })
1924
+ ```
1925
+
1926
+ `idempotent` rides through to `MethodDef.idempotent`; the core attaches no
1927
+ behaviour. Your reconnect logic reads it: replay an idempotent call (the
1928
+ durability guarantee — the user's action is not lost), reject a non-idempotent
1929
+ one rather than fire a second side effect. Pair it with
1930
+ [sticky events](#sticky-events) (`createRetainedTopics`) so a reconnected client
1931
+ also catches up on the latest pushed state.
1932
+
1933
+ This is deliberately *not* a reliable-RPC engine — that would be a competing
1934
+ WebSocket transport ([ADR 0008](../decisions/0008-thin-wrappers.md)). stitchkit
1935
+ gives you the executor and the metadata; the wire stays yours. See
1936
+ [ADR 0027](../decisions/0027-transport-neutral-contract-execution.md).
1937
+
1837
1938
 
1838
1939
  ==============================================================================
1839
1940
  # Guide: Auth & errors (docs/guide/auth-and-errors.md)
@@ -2718,10 +2819,12 @@ The browser-and-server entrypoint. Re-exports everything from
2718
2819
  | Export | Kind | Summary |
2719
2820
  |--------|------|---------|
2720
2821
  | `createSocketIOClient` | function | the typed Socket.IO client — [guide](../guide/realtime.md#client--createsocketioclient) |
2822
+ | `createRetainedTopics` | function | retained last-value store for sticky events — [guide](../guide/realtime.md#sticky-events) |
2721
2823
  | `parseSSE` | function | parse an SSE `Response` into an async generator — [guide](../guide/client.md#sse) |
2722
2824
  | `SocketIOClient` | _type_ | the client handle |
2723
- | `SocketIOClientConfig` | _type_ | config for `createSocketIOClient` |
2825
+ | `SocketIOClientConfig` | _type_ | config for `createSocketIOClient` (incl. `retain`) |
2724
2826
  | `SocketEventMap` | _type_ | the shape of an event map |
2827
+ | `RetainedTopics` | _type_ | the `createRetainedTopics` handle |
2725
2828
  | `ParseSSEOptions` | _type_ | options for `parseSSE` |
2726
2829
 
2727
2830
  ---
@@ -2750,6 +2853,8 @@ from the root `stitchkit`.
2750
2853
  | `TypedHttpClient` | _type_ | the typed client, HTTP endpoints only (`= ScopedHttpClient<C, unknown>`) |
2751
2854
  | `ScopedHttpClient` | _type_ | a client whose `stripPrefixKeys` become required args ([guide](../guide/multi-tenant.md)) |
2752
2855
  | `ScopedEndpointFn` | _type_ | one method's signature with the consumed keys folded in |
2856
+ | `MultipartFile` | _type_ | a `multipart` file field — `Blob \| FileDescriptor` |
2857
+ | `FileDescriptor` | _type_ | a React Native / Expo file — `{ uri, name, type }` |
2753
2858
 
2754
2859
  ### Errors
2755
2860
 
@@ -2953,6 +3058,7 @@ Server-only. Turns contracts into MCP and AI-agent tools. Needs the
2953
3058
  | `mountMcp` | function | add contract tools to an existing `McpServer` — [guide](../guide/mcp-and-agents.md#mountmcp) |
2954
3059
  | `implementRemote` | function | bind a contract to a remote HTTP API — [guide](../guide/mcp-and-agents.md#proxying-a-remote-api--implementremote) |
2955
3060
  | `mountAgent` | function | a Vercel AI SDK `ToolSet` from a service — [guide](../guide/mcp-and-agents.md#ai-agents--mountagent) |
3061
+ | `createContractDispatcher` | function | run a contract over a bring-your-own transport — [guide](../guide/realtime.md#bring-your-own-transport) |
2956
3062
  | `createCli` | function | a command-line program from contracts — [guide](../guide/cli.md) (also on `stitchkit/cli`) |
2957
3063
  | `createToolkit` | function | context-typed tool mounts — [guide](../guide/cli.md#typed-context) |
2958
3064
  | `mountViewFile` | function | a native multimodal "view file" MCP tool |
@@ -2964,6 +3070,8 @@ Server-only. Turns contracts into MCP and AI-agent tools. Needs the
2964
3070
  | `ImplementRemoteOptions` | _type_ | options for `implementRemote` |
2965
3071
  | `McpMountConfig` | _type_ | config for `mountMcp` |
2966
3072
  | `AgentMountConfig` | _type_ | config for `mountAgent` |
3073
+ | `ContractDispatcher` | _type_ | the `createContractDispatcher` handle |
3074
+ | `ContractDispatcherConfig` | _type_ | config for `createContractDispatcher` |
2967
3075
  | `AgentContext` | _type_ | the context merged into agent tool handlers |
2968
3076
  | `CliConfig` | _type_ | config for `createCli` |
2969
3077
  | `CliWaitConfig` | _type_ | `--wait` polling config |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stitchkit",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Contract-first backend framework — one defineContract() into an HTTP API, MCP tools, AI-agent tools and a typed client. Bun and Node.",
5
5
  "keywords": [
6
6
  "bun",
@@ -82,9 +82,11 @@
82
82
  },
83
83
  "scripts": {
84
84
  "check": "bun x tsc --noEmit",
85
- "build:js": "bun build src/index.ts src/react.ts src/tools.ts src/cli.ts src/contract/index.ts src/server/index.ts src/observability/index.ts src/node.ts --outdir dist --target node --packages external --splitting --root src",
85
+ "build:browser": "bun build src/index.ts src/react.ts src/contract/index.ts --outdir dist --target node --packages external --splitting --root src",
86
+ "build:server": "bun build src/server/index.ts src/node.ts src/tools.ts src/cli.ts src/observability/index.ts --outdir dist --target node --packages external --splitting --root src",
87
+ "build:js": "bun run build:browser && bun run build:server",
86
88
  "build:types": "bun x tsc -p tsconfig.build.json --emitDeclarationOnly",
87
- "build": "rm -rf dist && bun run build:js && bun run build:types",
89
+ "build": "rm -rf dist && bun run build:js && bun run build:types && bun scripts/check-browser-clean.mjs",
88
90
  "dev": "bun run build:js -- --watch",
89
91
  "prepublishOnly": "cp ../../README.md ./README.md && bun ../../scripts/gen-llms.ts && bun run build",
90
92
  "test": "bun test",
@@ -1,4 +0,0 @@
1
- import { createRequire } from "node:module";
2
- var __require = /* @__PURE__ */ createRequire(import.meta.url);
3
-
4
- export { __require };
@@ -1,6 +0,0 @@
1
- // src/internal/http-input.ts
2
- function inputIsQuery(method) {
3
- return method === "GET" || method === "DELETE";
4
- }
5
-
6
- export { inputIsQuery };
@@ -1,18 +0,0 @@
1
- // src/internal/typed.ts
2
- function typedEntries(value) {
3
- return Object.entries(value);
4
- }
5
- function isRecord(value) {
6
- return typeof value === "object" && value !== null && !Array.isArray(value);
7
- }
8
- function mapObject(source, mapper) {
9
- const result = {};
10
- for (const [key, value] of typedEntries(source)) {
11
- const mapped = mapper(key, value);
12
- if (mapped !== undefined)
13
- result[key] = mapped;
14
- }
15
- return result;
16
- }
17
-
18
- export { typedEntries, isRecord, mapObject };
@@ -1,72 +0,0 @@
1
- import {
2
- normalizeError
3
- } from "./index-wjwj5bz2.js";
4
-
5
- // src/server/stream.ts
6
- function streamSSE(generator) {
7
- const encoder = new TextEncoder;
8
- const stream = new ReadableStream({
9
- async start(controller) {
10
- try {
11
- for await (const chunk of generator) {
12
- const data = JSON.stringify(chunk);
13
- controller.enqueue(encoder.encode(`data: ${data}
14
-
15
- `));
16
- }
17
- controller.enqueue(encoder.encode(`data: [DONE]
18
-
19
- `));
20
- controller.close();
21
- } catch (err) {
22
- const envelope = normalizeError(err).toJSON();
23
- controller.enqueue(encoder.encode(`data: ${JSON.stringify(envelope)}
24
-
25
- `));
26
- controller.close();
27
- }
28
- }
29
- });
30
- return new Response(stream, {
31
- headers: {
32
- "Content-Type": "text/event-stream",
33
- "Cache-Control": "no-cache",
34
- Connection: "keep-alive"
35
- }
36
- });
37
- }
38
- async function* parseSSE(response, options) {
39
- const reader = response.body?.getReader();
40
- if (!reader)
41
- return;
42
- const decoder = new TextDecoder;
43
- let buffer = "";
44
- try {
45
- while (true) {
46
- const { done, value } = await reader.read();
47
- if (done)
48
- break;
49
- buffer += decoder.decode(value, { stream: true });
50
- const lines = buffer.split(`
51
- `);
52
- buffer = lines.pop() ?? "";
53
- for (const rawLine of lines) {
54
- const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
55
- if (!line.startsWith("data:"))
56
- continue;
57
- const data = line.slice(5).replace(/^ /, "");
58
- if (data === "[DONE]")
59
- return;
60
- try {
61
- yield JSON.parse(data);
62
- } catch (err) {
63
- options?.onParseError?.(data, err instanceof Error ? err : new Error(String(err)));
64
- }
65
- }
66
- }
67
- } finally {
68
- reader.releaseLock();
69
- }
70
- }
71
-
72
- export { streamSSE, parseSSE };