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.
- package/dist/browser/client.d.ts.map +1 -1
- package/dist/browser/socket-io.d.ts +11 -2
- package/dist/browser/socket-io.d.ts.map +1 -1
- package/dist/cli.js +3 -7
- package/dist/contract/define.d.ts +44 -2
- 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 +0 -1
- package/dist/{index-za2p453b.js → index-031q8xmx.js} +1 -1
- package/dist/{index-tr7r1530.js → index-13psnhhe.js} +5 -9
- package/dist/{index-5qe31283.js → index-9zrq8x5z.js} +9 -6
- package/dist/index-jgpsd7dy.js +105 -0
- package/dist/{index-mwmpw6j1.js → index-p9m9c0jw.js} +2 -4
- package/dist/index-tm7dqzxc.js +20 -0
- package/dist/{index-5789sbt8.js → index-zshrc6kx.js} +9 -16
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +480 -13
- package/dist/node.js +4 -8
- package/dist/observability/index.js +4 -7
- package/dist/react.js +0 -2
- package/dist/retained.d.ts +30 -0
- package/dist/retained.d.ts.map +1 -0
- package/dist/server/implement.d.ts.map +1 -1
- package/dist/server/index.js +89 -34
- package/dist/server/types.d.ts +7 -0
- package/dist/server/types.d.ts.map +1 -1
- package/dist/tools/dispatch.d.ts +65 -0
- package/dist/tools/dispatch.d.ts.map +1 -0
- package/dist/tools/mcp-app.d.ts.map +1 -1
- package/dist/tools/remote.d.ts.map +1 -1
- package/dist/tools.d.ts +1 -0
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +290 -21
- package/llms-full.txt +111 -3
- package/package.json +5 -3
- package/dist/index-37x76zdn.js +0 -4
- package/dist/index-48ffdxgk.js +0 -6
- package/dist/index-809wc1tt.js +0 -18
- package/dist/index-a1702zj4.js +0 -72
- package/dist/index-kdkvp26v.js +0 -380
- package/dist/index-kzfs85xp.js +0 -9
- package/dist/index-wjwj5bz2.js +0 -37
- package/dist/index-x3fcszf8.js +0 -8
package/dist/tools.js
CHANGED
|
@@ -1,39 +1,32 @@
|
|
|
1
1
|
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
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-
|
|
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-
|
|
28
|
-
import"./index-wjwj5bz2.js";
|
|
25
|
+
} from "./index-jgpsd7dy.js";
|
|
29
26
|
import {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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 {
|
|
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 =
|
|
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
|
|
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 `
|
|
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.
|
|
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:
|
|
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",
|
package/dist/index-37x76zdn.js
DELETED
package/dist/index-48ffdxgk.js
DELETED
package/dist/index-809wc1tt.js
DELETED
|
@@ -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 };
|
package/dist/index-a1702zj4.js
DELETED
|
@@ -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 };
|