stitchkit 0.37.0 → 0.38.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 (36) hide show
  1. package/dist/browser/client-url.d.ts +11 -0
  2. package/dist/browser/client-url.d.ts.map +1 -0
  3. package/dist/browser/client.d.ts +16 -4
  4. package/dist/browser/client.d.ts.map +1 -1
  5. package/dist/browser/http.d.ts +5 -1
  6. package/dist/browser/http.d.ts.map +1 -1
  7. package/dist/cli.js +2 -2
  8. package/dist/contract/define.d.ts +75 -1
  9. package/dist/contract/define.d.ts.map +1 -1
  10. package/dist/contract/index.d.ts +1 -1
  11. package/dist/contract/index.d.ts.map +1 -1
  12. package/dist/contract/index.js +1 -1
  13. package/dist/{index-xq45akyd.js → index-bgdd42pt.js} +1 -1
  14. package/dist/{index-s6yhmg1k.js → index-n5t4gnfz.js} +3 -1
  15. package/dist/{index-4pvtq6h2.js → index-p3kwf73n.js} +84 -23
  16. package/dist/{index-g3jrbd0z.js → index-x4wbc8sz.js} +28 -0
  17. package/dist/index.d.ts +2 -2
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +147 -114
  20. package/dist/node.js +2 -2
  21. package/dist/observability/index.js +1 -1
  22. package/dist/server/create.d.ts.map +1 -1
  23. package/dist/server/implement.d.ts.map +1 -1
  24. package/dist/server/index.js +19 -5
  25. package/dist/server/openapi.d.ts.map +1 -1
  26. package/dist/server/response-metadata.d.ts +9 -0
  27. package/dist/server/response-metadata.d.ts.map +1 -0
  28. package/dist/server/router.d.ts.map +1 -1
  29. package/dist/server/types.d.ts +10 -2
  30. package/dist/server/types.d.ts.map +1 -1
  31. package/dist/tools/mount.d.ts.map +1 -1
  32. package/dist/tools/remote.d.ts.map +1 -1
  33. package/dist/tools.js +128 -114
  34. package/llms-full.txt +165 -10
  35. package/package.json +1 -1
  36. /package/dist/{index-e497hxcy.js → index-mzx0an0s.js} +0 -0
package/dist/tools.js CHANGED
@@ -2,7 +2,7 @@ import {
2
2
  inputIsQuery,
3
3
  signJwt,
4
4
  verifyPkce
5
- } from "./index-xq45akyd.js";
5
+ } from "./index-bgdd42pt.js";
6
6
  import {
7
7
  DEFAULT_CORS_ALLOW_HEADERS
8
8
  } from "./index-czmqks7r.js";
@@ -24,7 +24,7 @@ import {
24
24
  readCapped,
25
25
  toolResultFromError,
26
26
  writeDownload
27
- } from "./index-s6yhmg1k.js";
27
+ } from "./index-n5t4gnfz.js";
28
28
  import {
29
29
  toJsonSchema
30
30
  } from "./index-frfyw9fa.js";
@@ -38,7 +38,7 @@ import {
38
38
  isRecord,
39
39
  mergeMeta,
40
40
  typedEntries
41
- } from "./index-e497hxcy.js";
41
+ } from "./index-mzx0an0s.js";
42
42
 
43
43
  // src/tools/agent.ts
44
44
  import { jsonSchema, tool } from "ai";
@@ -1235,6 +1235,117 @@ function appendFormFields(formData, values, skipKeys) {
1235
1235
  }
1236
1236
  }
1237
1237
 
1238
+ // src/browser/client-url.ts
1239
+ function isParamArray(value) {
1240
+ return Array.isArray(value) && value.every((item) => typeof item === "string" || typeof item === "number");
1241
+ }
1242
+ function collectQueryParams(args, endpoint) {
1243
+ const params = {};
1244
+ let hasParams = false;
1245
+ for (const [key, value] of Object.entries(args)) {
1246
+ if (value === undefined || value === null)
1247
+ continue;
1248
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean" || isParamArray(value)) {
1249
+ params[key] = value;
1250
+ hasParams = true;
1251
+ continue;
1252
+ }
1253
+ const what = Array.isArray(value) ? "an array with non-primitive items" : typeof value === "object" ? "a nested object" : `a ${typeof value}`;
1254
+ throw new Error(`${endpoint.method} ${endpoint.path}: input field "${key}" is ${what} — it cannot ` + "travel as a query parameter. GET / DELETE input must be flat (string / number / " + "boolean, or an array of string / number); flatten the field or move the " + "operation to a body verb (POST).");
1255
+ }
1256
+ return hasParams ? params : undefined;
1257
+ }
1258
+ function hasStringKeys(args, keys) {
1259
+ for (const key of keys) {
1260
+ if (typeof args[key] !== "string")
1261
+ return false;
1262
+ }
1263
+ return true;
1264
+ }
1265
+ function resolvePathPrefix(config, args) {
1266
+ if (!config?.pathPrefix)
1267
+ return "";
1268
+ if (typeof config.pathPrefix === "string")
1269
+ return config.pathPrefix;
1270
+ const keys = config.stripPrefixKeys ?? [];
1271
+ if (!hasStringKeys(args, keys)) {
1272
+ const missing = keys.find((key) => typeof args[key] !== "string");
1273
+ throw new Error(`Missing path prefix key: ${missing}`);
1274
+ }
1275
+ return config.pathPrefix(args);
1276
+ }
1277
+ function extractParamNames(path) {
1278
+ const matches = path.match(/:(\w+)/g);
1279
+ const names = matches ? matches.map((match) => match.slice(1)) : [];
1280
+ if (path.endsWith("/*"))
1281
+ names.push("*");
1282
+ return names;
1283
+ }
1284
+ function fillPathParams(path, args) {
1285
+ let filled = path.replace(/:(\w+)/g, (_, key) => {
1286
+ const value = args[key];
1287
+ if (value === undefined || value === null) {
1288
+ throw new Error(`Missing path param: ${key}`);
1289
+ }
1290
+ return encodeURIComponent(String(value));
1291
+ });
1292
+ if (!filled.endsWith("/*"))
1293
+ return filled;
1294
+ const wildcard = args["*"];
1295
+ if (wildcard === undefined || wildcard === null) {
1296
+ throw new Error("Missing path param: *");
1297
+ }
1298
+ const remainder = String(wildcard).split("/").map((segment) => encodeURIComponent(segment)).join("/");
1299
+ filled = `${filled.slice(0, -1)}${remainder}`;
1300
+ return filled;
1301
+ }
1302
+ function stripConsumedArgs(args, path, scopeKeys) {
1303
+ const consumed = new Set(scopeKeys);
1304
+ for (const name of extractParamNames(path))
1305
+ consumed.add(name);
1306
+ const remaining = {};
1307
+ for (const [key, value] of Object.entries(args)) {
1308
+ if (!consumed.has(key) && value !== undefined)
1309
+ remaining[key] = value;
1310
+ }
1311
+ return remaining;
1312
+ }
1313
+ function appendQuery(relativeUrl, params) {
1314
+ if (!params)
1315
+ return relativeUrl;
1316
+ const search = new URLSearchParams;
1317
+ for (const [key, value] of Object.entries(params)) {
1318
+ if (Array.isArray(value)) {
1319
+ for (const item of value)
1320
+ search.append(key, String(item));
1321
+ } else {
1322
+ search.set(key, String(value));
1323
+ }
1324
+ }
1325
+ return search.size > 0 ? `${relativeUrl}?${search}` : relativeUrl;
1326
+ }
1327
+ function planClientRequest(endpoint, contractPrefix, args, config) {
1328
+ let pathPrefix = resolvePathPrefix(config, args);
1329
+ if (pathPrefix && !pathPrefix.endsWith("/"))
1330
+ pathPrefix += "/";
1331
+ if (pathPrefix.startsWith("/"))
1332
+ pathPrefix = pathPrefix.slice(1);
1333
+ const endpointPath = endpoint.path === "/" ? "" : endpoint.path;
1334
+ let relativeUrl = fillPathParams(`${pathPrefix}${contractPrefix}${endpointPath}`, args);
1335
+ if (relativeUrl.endsWith("/"))
1336
+ relativeUrl = relativeUrl.slice(0, -1);
1337
+ const remainingArgs = stripConsumedArgs(args, endpoint.path, config?.stripPrefixKeys ?? []);
1338
+ if (inputIsQuery(endpoint.method)) {
1339
+ relativeUrl = appendQuery(relativeUrl, collectQueryParams(remainingArgs, endpoint));
1340
+ }
1341
+ return { relativeUrl, remainingArgs };
1342
+ }
1343
+ function joinClientBaseUrl(baseUrl, relativeUrl) {
1344
+ const base = baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl;
1345
+ const path = relativeUrl.startsWith("/") ? relativeUrl : `/${relativeUrl}`;
1346
+ return `${base}${path}`;
1347
+ }
1348
+
1238
1349
  // src/browser/http.ts
1239
1350
  import ky, { isHTTPError } from "ky";
1240
1351
  class ApiError extends Error {
@@ -1285,25 +1396,6 @@ function withOutput(endpoint, result) {
1285
1396
  return result;
1286
1397
  return result.then((value) => value === undefined ? undefined : schema.parse(value));
1287
1398
  }
1288
- function isParamArray(value) {
1289
- return Array.isArray(value) && value.every((v) => typeof v === "string" || typeof v === "number");
1290
- }
1291
- function collectQueryParams(args, skipKeys, endpoint) {
1292
- const params = {};
1293
- let hasParams = false;
1294
- for (const [key, value] of Object.entries(args)) {
1295
- if (skipKeys.has(key) || value === undefined || value === null)
1296
- continue;
1297
- if (typeof value === "string" || typeof value === "number" || typeof value === "boolean" || isParamArray(value)) {
1298
- params[key] = value;
1299
- hasParams = true;
1300
- continue;
1301
- }
1302
- const what = Array.isArray(value) ? "an array with non-primitive items" : typeof value === "object" ? "a nested object" : `a ${typeof value}`;
1303
- throw new Error(`${endpoint.method} ${endpoint.path}: input field "${key}" is ${what} — it cannot ` + "travel as a query parameter. GET / DELETE input must be flat (string / number / " + "boolean, or an array of string / number); flatten the field or move the " + "operation to a body verb (POST).");
1304
- }
1305
- return hasParams ? params : undefined;
1306
- }
1307
1399
  function createClient(contract, configOrClient, contractConfig) {
1308
1400
  const client = {};
1309
1401
  const makeMethod = isHttpAdapter(configOrClient) ? (endpoint) => createHttpMethod(endpoint, contract.meta.prefix, configOrClient, contractConfig) : (endpoint) => createFetchMethod(endpoint, contract.meta.prefix, configOrClient, contractConfig);
@@ -1322,27 +1414,9 @@ function setClientMethod(target, key, method) {
1322
1414
  }
1323
1415
  function createHttpMethod(endpoint, prefix, client, config) {
1324
1416
  const httpMethod = endpoint.method.toLowerCase();
1325
- const isGet = httpMethod === "get";
1326
- const paramNames = extractParamNames(endpoint.path);
1327
- const prefixKeys = new Set([...config?.stripPrefixKeys ?? [], ...paramNames]);
1328
1417
  return (...args) => {
1329
1418
  const firstArg = args[0] ?? {};
1330
- let pathPrefixStr = "";
1331
- if (config?.pathPrefix) {
1332
- pathPrefixStr = typeof config.pathPrefix === "function" ? config.pathPrefix(firstArg) : config.pathPrefix;
1333
- if (pathPrefixStr && !pathPrefixStr.endsWith("/"))
1334
- pathPrefixStr += "/";
1335
- }
1336
- let url = `${pathPrefixStr}${prefix}${endpoint.path}`;
1337
- for (const name of paramNames) {
1338
- const value = firstArg[name];
1339
- if (value === undefined || value === null) {
1340
- throw new Error(`Missing path param: ${name}`);
1341
- }
1342
- url = url.replace(`:${name}`, encodeURIComponent(String(value)));
1343
- }
1344
- if (url.endsWith("/"))
1345
- url = url.slice(0, -1);
1419
+ const plan = planClientRequest(endpoint, prefix, firstArg, config);
1346
1420
  if (endpoint.multipart) {
1347
1421
  const file = firstArg[endpoint.multipart];
1348
1422
  if (!isMultipartFile(file)) {
@@ -1353,59 +1427,28 @@ function createHttpMethod(endpoint, prefix, client, config) {
1353
1427
  }
1354
1428
  const formData = new FormData;
1355
1429
  appendMultipartFile(formData, endpoint.multipart, file);
1356
- appendFormFields(formData, firstArg, new Set([...prefixKeys, endpoint.multipart]));
1357
- return withOutput(endpoint, client[httpMethod](url, formData, withTimeout(undefined, endpoint)));
1430
+ appendFormFields(formData, plan.remainingArgs, new Set([endpoint.multipart]));
1431
+ return withOutput(endpoint, client[httpMethod](plan.relativeUrl, formData, withTimeout(undefined, endpoint)));
1358
1432
  }
1359
- if (isGet) {
1360
- const params = collectQueryParams(firstArg, prefixKeys, endpoint);
1361
- return withOutput(endpoint, client.get(url, withTimeout(params ? { params } : undefined, endpoint)));
1433
+ if (httpMethod === "get") {
1434
+ return withOutput(endpoint, client.get(plan.relativeUrl, withTimeout(undefined, endpoint)));
1362
1435
  }
1363
1436
  if (httpMethod === "delete") {
1364
- const params = collectQueryParams(firstArg, prefixKeys, endpoint);
1365
- return withOutput(endpoint, client.delete(url, withTimeout(params ? { params } : undefined, endpoint)));
1366
- }
1367
- const payload = {};
1368
- for (const [key, value] of Object.entries(firstArg)) {
1369
- if (!prefixKeys.has(key) && value !== undefined) {
1370
- payload[key] = value;
1371
- }
1437
+ return withOutput(endpoint, client.delete(plan.relativeUrl, withTimeout(undefined, endpoint)));
1372
1438
  }
1373
- return withOutput(endpoint, client[httpMethod](url, Object.keys(payload).length > 0 ? payload : undefined, withTimeout(undefined, endpoint)));
1439
+ return withOutput(endpoint, client[httpMethod](plan.relativeUrl, Object.keys(plan.remainingArgs).length > 0 ? plan.remainingArgs : undefined, withTimeout(undefined, endpoint)));
1374
1440
  };
1375
1441
  }
1376
1442
  function createFetchMethod(endpoint, prefix, config, contractConfig) {
1377
- const prefixKeys = new Set(contractConfig?.stripPrefixKeys ?? []);
1378
1443
  return async (args) => {
1379
- let pathPrefixStr = "";
1380
- if (contractConfig?.pathPrefix) {
1381
- pathPrefixStr = typeof contractConfig.pathPrefix === "function" ? contractConfig.pathPrefix(args ?? {}) : contractConfig.pathPrefix;
1382
- if (pathPrefixStr && !pathPrefixStr.endsWith("/"))
1383
- pathPrefixStr += "/";
1384
- }
1385
- let url = buildFetchUrl(config.baseUrl, prefix, endpoint.path, args, pathPrefixStr);
1444
+ const plan = planClientRequest(endpoint, prefix, args ?? {}, contractConfig);
1445
+ const url = joinClientBaseUrl(config.baseUrl, plan.relativeUrl);
1386
1446
  const headers = {
1387
1447
  Accept: "application/json",
1388
1448
  ...typeof config.headers === "function" ? config.headers() : config.headers
1389
1449
  };
1390
1450
  const signal = endpoint.timeout !== undefined ? AbortSignal.timeout(endpoint.timeout) : undefined;
1391
- const isQuery = inputIsQuery(endpoint.method);
1392
- const hasBody = !isQuery && !endpoint.multipart && endpoint.input && args;
1393
- if (isQuery && args) {
1394
- const remaining = stripParams(args, endpoint.path, prefixKeys);
1395
- const params = collectQueryParams(remaining, new Set, endpoint);
1396
- if (params) {
1397
- const searchParams = new URLSearchParams;
1398
- for (const [k, v] of Object.entries(params)) {
1399
- if (Array.isArray(v)) {
1400
- for (const item of v)
1401
- searchParams.append(k, String(item));
1402
- } else {
1403
- searchParams.set(k, String(v));
1404
- }
1405
- }
1406
- url += `?${searchParams}`;
1407
- }
1408
- }
1451
+ const hasBody = endpoint.method !== "GET" && endpoint.method !== "DELETE" && !endpoint.multipart && endpoint.input && args;
1409
1452
  if (hasBody)
1410
1453
  headers["Content-Type"] = "application/json";
1411
1454
  if (endpoint.multipart && args) {
@@ -1415,7 +1458,7 @@ function createFetchMethod(endpoint, prefix, config, contractConfig) {
1415
1458
  }
1416
1459
  const formData = new FormData;
1417
1460
  appendMultipartFile(formData, endpoint.multipart, file);
1418
- appendFormFields(formData, stripParams(args, endpoint.path, prefixKeys), new Set([endpoint.multipart]));
1461
+ appendFormFields(formData, plan.remainingArgs, new Set([endpoint.multipart]));
1419
1462
  const res2 = await fetch(url, {
1420
1463
  method: endpoint.method,
1421
1464
  headers,
@@ -1439,7 +1482,7 @@ function createFetchMethod(endpoint, prefix, config, contractConfig) {
1439
1482
  credentials: config.credentials,
1440
1483
  signal,
1441
1484
  ...hasBody && {
1442
- body: JSON.stringify(stripParams(hasBody, endpoint.path, prefixKeys))
1485
+ body: JSON.stringify(plan.remainingArgs)
1443
1486
  }
1444
1487
  });
1445
1488
  if (!res.ok) {
@@ -1462,36 +1505,6 @@ async function throwForErrorResponse(res, config, fallbackBody) {
1462
1505
  }
1463
1506
  throw new ApiError("HTTP_ERROR", res.status, { body });
1464
1507
  }
1465
- function extractParamNames(path) {
1466
- const matches = path.match(/:(\w+)/g);
1467
- return matches ? matches.map((m) => m.slice(1)) : [];
1468
- }
1469
- function buildFetchUrl(baseUrl, prefix, path, args, pathPrefix = "") {
1470
- let fullPath = `/${pathPrefix}${prefix}${path === "/" ? "" : path}`;
1471
- if (args) {
1472
- fullPath = fullPath.replace(/:(\w+)/g, (_, key) => {
1473
- const val = args[key];
1474
- if (val === undefined || val === null) {
1475
- throw new Error(`Missing path param: ${key}`);
1476
- }
1477
- return encodeURIComponent(String(val));
1478
- });
1479
- }
1480
- return `${baseUrl}${fullPath}`;
1481
- }
1482
- function stripParams(args, path, extra) {
1483
- const skip = new Set(extra);
1484
- for (const match of path.matchAll(/:(\w+)/g)) {
1485
- if (match[1])
1486
- skip.add(match[1]);
1487
- }
1488
- const result = {};
1489
- for (const [k, v] of Object.entries(args)) {
1490
- if (!skip.has(k))
1491
- result[k] = v;
1492
- }
1493
- return result;
1494
- }
1495
1508
 
1496
1509
  // src/tools/remote.ts
1497
1510
  var HTTP_ONLY = Object.freeze(["HTTP"]);
@@ -1514,7 +1527,7 @@ function implementRemote(contract, http, options) {
1514
1527
  serviceName: contract.meta.prefix,
1515
1528
  key,
1516
1529
  toolName: "toolName" in endpoint ? endpoint.toolName : undefined,
1517
- expose: endpoint.rawResponse || endpoint.rawBody ? HTTP_ONLY : endpoint.expose,
1530
+ expose: endpoint.rawResponse || endpoint.rawBody || endpoint.responseMeta ? HTTP_ONLY : endpoint.expose,
1518
1531
  ui: "ui" in endpoint ? endpoint.ui : undefined,
1519
1532
  annotations: "annotations" in endpoint ? endpoint.annotations : undefined,
1520
1533
  meta: mergeMeta(contract.meta.meta, endpoint.meta),
@@ -1527,6 +1540,7 @@ function implementRemote(contract, http, options) {
1527
1540
  idempotent: endpoint.idempotent,
1528
1541
  rawResponse: endpoint.rawResponse,
1529
1542
  rawBody: endpoint.rawBody,
1543
+ responseMeta: endpoint.responseMeta,
1530
1544
  contentType: "contentType" in endpoint ? endpoint.contentType : undefined,
1531
1545
  handler: async (ctx) => {
1532
1546
  const call = client[key];
package/llms-full.txt CHANGED
@@ -230,7 +230,7 @@ export const users = defineContract({ prefix: 'users' }, {
230
230
  | Field | Required | Purpose |
231
231
  |-------|----------|---------|
232
232
  | `method` | yes | `GET` · `POST` · `PUT` · `PATCH` · `DELETE` |
233
- | `path` | yes | route path under the contract `prefix`; `:name` marks a path param |
233
+ | `path` | yes | route path under the contract `prefix`; `:name` marks a path param and a terminal `/*` 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,6 +245,7 @@ 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
 
250
251
  ## `params` vs `input` vs `output`
@@ -253,7 +254,10 @@ The three schemas are distinct on purpose:
253
254
 
254
255
  - **`params`** — values in the URL path. `path: '/:id'` ⇒
255
256
  `params: z.object({ id: z.string() })`. The client takes them from the call
256
- argument and substitutes them into the URL.
257
+ argument and substitutes them into the URL. A terminal wildcard is the
258
+ quoted `'*'` field: `path: '/:slug/*'` with
259
+ `params: z.object({ slug: z.string(), '*': z.string() })` matches both
260
+ `/foo/page` and `/foo/a/b`; the handler receives `'page'` or `'a/b'`.
257
261
  - **`input`** — the request payload. For `POST` / `PUT` / `PATCH` it is the JSON
258
262
  body; for `GET` / `DELETE` it is the query string. The handler reads it as
259
263
  `ctx.input`.
@@ -267,6 +271,9 @@ the body.
267
271
  ```ts
268
272
  // path: '/:id', params: { id }, input: { text }
269
273
  await api.update({ id: '1', text: 'new' }) // PUT /users/1 body: { text: 'new' }
274
+
275
+ // path: '/:slug/*', params: { slug, '*': remainder }
276
+ await api.app({ slug: 'foo', '*': 'a/b' }) // GET /apps/foo/a/b
270
277
  ```
271
278
 
272
279
  ### Input vs. output types
@@ -317,10 +324,12 @@ tools. Narrow it with `expose`:
317
324
  - `expose: ['MCP', 'AGENT']` — a tool only; no HTTP route.
318
325
  - omit `expose` — all transports.
319
326
 
320
- Tool transports (`MCP`, `AGENT`) skip two kinds of endpoint automatically:
327
+ Tool transports (`MCP`, `AGENT`) skip three kinds of endpoint automatically:
321
328
  `multipart` (a file upload is not a tool call) and
322
329
  [`rawResponse`](./server.md#raw-response-endpoints) (its answer is bytes, which
323
- a tool result cannot carry — it would serialize to `{}`).
330
+ a tool result cannot carry — it would serialize to `{}`), plus
331
+ [`responseMeta`](./server.md#typed-json-response-metadata) (outbound HTTP
332
+ headers have no meaning on a tool call).
324
333
 
325
334
  ## `toolName`
326
335
 
@@ -764,6 +773,17 @@ prefix param in the schema, use a non-strict `z.object` (extra keys are dropped
764
773
  from `ctx.params`, but `ctx.tenantId` still works), or read the param off the
765
774
  context root.
766
775
 
776
+ **Trailing wildcard.** A contract path may end in `/*`. `/app/:slug/*` matches
777
+ both `/app/foo` and nested paths such as `/app/foo/a/b`; the collected params are
778
+ `{ slug: 'foo', '*': '' }` and `{ slug: 'foo', '*': 'a/b' }` respectively. Put
779
+ the quoted `'*'` field in the endpoint's `params` schema to keep it in typed
780
+ `ctx.params`. Each captured segment is URL-decoded before the remainder is
781
+ joined, so encoded spaces and reserved characters reach the handler as their
782
+ semantic values while `/` remains the segment boundary. Static and named-param
783
+ routes are matched before a catch-all, so a
784
+ more specific endpoint wins regardless of declaration order. The same matcher
785
+ drives `405 Allow` resolution.
786
+
767
787
  ### Scope-driven mounting (`scopePrefixes`)
768
788
 
769
789
  With several scopes, hand-partitioning services into `groups` duplicates the
@@ -796,7 +816,7 @@ createServer({
796
816
  hooks: {
797
817
  onRequest(req) { /* logging, global rate limit — may return a Response to short-circuit */ },
798
818
  beforeHandle(ctx, endpoint) { /* auth, scope checks — throw to reject */ },
799
- afterHandle(ctx, result, ep) { /* transform the result, set cache headers */ },
819
+ afterHandle(ctx, result, ep) { /* transform the result data */ },
800
820
  onError(ctx, error, ep) { /* custom error response — return a Response */ },
801
821
  },
802
822
  })
@@ -849,6 +869,46 @@ not retain the text. `maxJsonBodyBytes` may also be set once on `createServer` /
849
869
  `createHandler`; a route value wins. Both limits are opt-in and abort an
850
870
  oversized stream before it is fully buffered. → ADR 0051
851
871
 
872
+ ## Typed JSON response metadata
873
+
874
+ A JSON endpoint that must attach dynamic HTTP headers while preserving typed
875
+ output declares `responseMeta`. The handler still returns ordinary data:
876
+
877
+ ```ts
878
+ const auth = defineContract({ prefix: 'auth' }, {
879
+ complete: {
880
+ method: 'POST', path: '/complete', desc: 'Complete authentication',
881
+ input: CompleteAuthSchema,
882
+ output: AuthUserSchema,
883
+ responseMeta: { status: 200 },
884
+ },
885
+ })
886
+
887
+ complete: async ({ input, response }) => {
888
+ const result = await authenticate(input.token)
889
+ response.headers.append('Set-Cookie', session.set(result.sessionId))
890
+ response.headers.append('Set-Cookie', preferences.set(result.preferencesId))
891
+ return result.user
892
+ }
893
+ ```
894
+
895
+ `ctx.response.headers` is a fresh Web Fetch `Headers` bag per request. `append`
896
+ preserves repeated `Set-Cookie` values on Bun and Node. The endpoint is
897
+ HTTP-only, but its typed client method still resolves to `AuthUser` — not
898
+ `Response` — and the final data still passes group/global `afterHandle` and the
899
+ declared `output` schema exactly once.
900
+
901
+ `responseMeta.status` is static contract metadata and OpenAPI publishes the same
902
+ 2xx code. Without it, data keeps status `200` and no-data keeps `204`. Bodyless
903
+ `204`/`205` cannot be combined with `output`. Redirects, streams, files and
904
+ handler-owned status/body logic remain [`rawResponse: true`](#raw-response-endpoints).
905
+
906
+ Collected headers are merged only after the complete success pipeline. A
907
+ handler, hook or output-validation failure discards them. `Content-Type`,
908
+ `Content-Length`, `x-request-id` and every `Access-Control-*` header remain
909
+ framework-owned; trying to set one fails loudly with the endpoint identity.
910
+ → [ADR 0052](../decisions/0052-typed-json-response-metadata.md)
911
+
852
912
  ## Raw-response endpoints
853
913
 
854
914
  An endpoint that answers with **bytes rather than data** — a PDF download, a
@@ -891,6 +951,10 @@ const name = res.headers.get('Content-Disposition')
891
951
  const blob = await res.blob()
892
952
  ```
893
953
 
954
+ When the browser should navigate or assign the endpoint directly to `src`, use
955
+ [`createUrlBuilder`](./client.md#contract-url-builders). Raw-response GET methods
956
+ are included, while mutation and multipart methods are intentionally absent.
957
+
894
958
  Cross-origin, remember that those headers are readable only because CORS exposes
895
959
  them — see [`cors.exposeHeaders`](#serving-files--range-requests).
896
960
 
@@ -902,6 +966,11 @@ client — which is what you want for an OAuth redirect or a non-JSON webhook. A
902
966
  signed JSON webhook can stay validated through
903
967
  [`rawBody: true`](#signed-json-webhooks).
904
968
 
969
+ If an endpoint returns typed JSON and only needs an additional status/header,
970
+ use [`responseMeta`](#typed-json-response-metadata), not `rawResponse`: the raw
971
+ variant deliberately transfers response ownership and changes the client result
972
+ to `Response`.
973
+
905
974
  ⚠️ **Delete the old raw route when you move an endpoint into the contract.** Raw
906
975
  routes are matched **first**, so a leftover one keeps serving the bytes and the
907
976
  contract endpoint — with its auth gate — never runs. stitchkit warns at startup
@@ -1135,6 +1204,14 @@ createServer({ services: [users, orders], rawRoutes: [openApiRoute('/openapi.jso
1135
1204
 
1136
1205
  Only HTTP-exposed methods appear (an MCP/agent-only tool is skipped).
1137
1206
 
1207
+ OpenAPI 3.1 has no standard multi-segment path parameter. For a contract path
1208
+ ending in `/*`, Stitchkit keeps the literal runtime path, omits `*` from the
1209
+ standard `in: path` parameter list, and emits
1210
+ `x-stitchkit-trailing-wildcard` on the operation with its parameter name,
1211
+ schema and semantics. A generic OpenAPI client therefore cannot invent
1212
+ catch-all expansion; use Stitchkit's typed client or teach the generator that
1213
+ extension.
1214
+
1138
1215
  ### Curating the spec — `includeMethod`
1139
1216
 
1140
1217
  To publish a **subset** — a public spec that advertises only some methods
@@ -1194,6 +1271,10 @@ import { createHttpClient } from 'stitchkit'
1194
1271
  const http = createHttpClient({ baseUrl: '/api' })
1195
1272
  ```
1196
1273
 
1274
+ The returned `ConfiguredHttpClient` keeps that `baseUrl` as a readonly public
1275
+ field. Besides executing requests, it can therefore seed contract URL builders
1276
+ without repeating transport configuration.
1277
+
1197
1278
  ### `HttpClientConfig`
1198
1279
 
1199
1280
  | Field | Default | Purpose |
@@ -1239,6 +1320,8 @@ Each call takes one argument object. The client routes each field by the
1239
1320
  contract:
1240
1321
 
1241
1322
  - a **path param** (`:id`) is substituted into the URL,
1323
+ - a terminal wildcard (`/*`) consumes the `'*'` field and preserves its path
1324
+ segments (`{ '*': 'a/b' }` → `/a/b`, not `/%2Fa%2Fb` or a query field),
1242
1325
  - for `GET` / `DELETE`, the remaining fields become the **query string**
1243
1326
  (arrays become repeated keys),
1244
1327
  - for `POST` / `PUT` / `PATCH`, they become the **JSON body**,
@@ -1258,7 +1341,53 @@ await api.posts.create({ title: 'Hi' })
1258
1341
  ```
1259
1342
 
1260
1343
  `createClients` builds one typed client per contract from a registry — list the
1261
- contracts once, get the whole API typed.
1344
+ contracts once, get the whole API typed. It accepts the same optional scoped
1345
+ config as `createClient`, so a whole registry can share one resource prefix:
1346
+
1347
+ ```ts
1348
+ const tenantApi = createClients({ users, posts }, http, {
1349
+ stripPrefixKeys: ['tenantId'],
1350
+ pathPrefix: ({ tenantId }) => `tenants/${tenantId}`,
1351
+ })
1352
+ ```
1353
+
1354
+ Every method now requires `tenantId`, and the callback sees it as a `string`.
1355
+ The batch form delegates to the same single-contract client runtime, including
1356
+ HTTP exposure filtering, multipart, raw responses and output validation.
1357
+
1358
+ ## Contract URL builders
1359
+
1360
+ Browser-native consumers such as `<img src>`, downloads and navigation need a
1361
+ URL, not a fetched response. `createUrlBuilder` derives those URLs from the same
1362
+ contract path planner used by both typed-client transports:
1363
+
1364
+ ```ts
1365
+ import { createUrlBuilder, createUrlBuilders } from 'stitchkit'
1366
+
1367
+ const mediaUrls = createUrlBuilder(media, http, {
1368
+ stripPrefixKeys: ['tenantId'],
1369
+ pathPrefix: ({ tenantId }) => `tenants/${tenantId}`,
1370
+ })
1371
+
1372
+ const src = mediaUrls.file({
1373
+ tenantId: 't_123',
1374
+ fileId: 'f_456',
1375
+ thumbnail: true,
1376
+ })
1377
+
1378
+ const urls = createUrlBuilders({ media, exports }, http)
1379
+ ```
1380
+
1381
+ Only HTTP-exposed, non-multipart `GET` endpoints appear on a URL builder. Raw
1382
+ response GET endpoints are included, so downloads and streams stay
1383
+ contract-driven. Path and scoped-prefix keys are consumed by the path; remaining
1384
+ GET input becomes the query string, including repeated keys for arrays.
1385
+
1386
+ Building a URL is synchronous and performs no request, auth event, header
1387
+ resolution or output validation. A `ConfiguredHttpClient` created by
1388
+ `createHttpClient` supplies its base URL; custom transports can pass an explicit
1389
+ `{ baseUrl: 'https://api.example.com' }` instead. Relative bases produce relative
1390
+ URLs.
1262
1391
 
1263
1392
  ## `ApiError`
1264
1393
 
@@ -1329,7 +1458,7 @@ segment to every URL — the client half of a multi-tenant API
1329
1458
  interface ContractClientConfig {
1330
1459
  /** Prepended to every request URL. A function is called per request with the
1331
1460
  * call's argument object, so the prefix can depend on the arguments. */
1332
- pathPrefix?: string | ((args: Record<string, unknown>) => string)
1461
+ pathPrefix?: string | ((args: { [K in ConsumedKey]: string }) => string)
1333
1462
  /** Argument keys consumed by `pathPrefix` — stripped from the query/body so
1334
1463
  * they are not also sent there (the endpoint's own path `:params` are
1335
1464
  * stripped automatically; list any *extra* keys here). */
@@ -1341,8 +1470,8 @@ A per-tenant client — `tenantId` goes into the URL, not the body:
1341
1470
 
1342
1471
  ```ts
1343
1472
  const widgets = createClient(widgetsContract, http, {
1344
- pathPrefix: (args) => `tenants/${args.tenantId}/`,
1345
1473
  stripPrefixKeys: ['tenantId'],
1474
+ pathPrefix: ({ tenantId }) => `tenants/${tenantId}/`,
1346
1475
  })
1347
1476
 
1348
1477
  widgets.list({ tenantId: 't_123' }) // GET /tenants/t_123/widgets
@@ -2744,6 +2873,20 @@ session.clear() // → a Set-Cookie value that expires it
2744
2873
  config is not repeated at every call site. `parseCookies(header)` and
2745
2874
  `serializeCookie(name, value, opts)` are the lower-level primitives.
2746
2875
 
2876
+ To set a cookie from a schema-validated JSON endpoint without losing its typed
2877
+ client result, declare [`responseMeta`](./server.md#typed-json-response-metadata)
2878
+ and append the generated value:
2879
+
2880
+ ```ts
2881
+ complete: async ({ response }) => {
2882
+ response.headers.append('Set-Cookie', session.set('abc123'))
2883
+ return authenticatedUser
2884
+ }
2885
+ ```
2886
+
2887
+ Append once per cookie; Stitchkit preserves separate `Set-Cookie` fields through
2888
+ both Bun and Node. Cookie/session policy remains application logic.
2889
+
2747
2890
  ## The error model
2748
2891
 
2749
2892
  One error type, `AppError`, is shared by the contract, the server and the
@@ -3609,7 +3752,7 @@ keeps `tenantId` out of the body/query
3609
3752
 
3610
3753
  ```ts
3611
3754
  const widgetsApi = createClient(widgets, http, {
3612
- pathPrefix: (args) => `tenants/${args.tenantId}/`,
3755
+ pathPrefix: ({ tenantId }) => `tenants/${tenantId}/`,
3613
3756
  stripPrefixKeys: ['tenantId'],
3614
3757
  })
3615
3758
 
@@ -3908,12 +4051,17 @@ The browser-and-server entrypoint. Re-exports everything from
3908
4051
  | Export | Kind | Summary |
3909
4052
  |--------|------|---------|
3910
4053
  | `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 |
4054
+ | `createClients` | function | build one exact typed client per contract from a registry; accepts the same scoped config and transports as `createClient` |
4055
+ | `createUrlBuilder` | function | build synchronous browser-native URLs for one contract's HTTP GET endpoints — [guide](../guide/client.md#contract-url-builders) |
4056
+ | `createUrlBuilders` | function | build one exact URL builder per contract in a registry |
4057
+ | `UrlBuilderConfig` | _type_ | explicit `{ baseUrl }` source for a URL builder |
3912
4058
  | `ClientConfig` | _type_ | config for `createClient`'s bare-fetch mode (2nd arg, no `HttpClient`) |
3913
4059
  | `ContractClientConfig` | _type_ | per-tenant / resource-scoped client config — dynamic `pathPrefix` + `stripPrefixKeys` ([guide](../guide/client.md#contractclientconfig--per-tenant--resource-scoped-clients)) |
4060
+ | `PathPrefixArgs` | _type_ | required string-valued keys exposed to a typed dynamic `pathPrefix` callback |
3914
4061
  | `createHttpClient` | function | the Ky-based HTTP transport — [guide](../guide/client.md#createhttpclient) |
3915
4062
  | `ApiError` | class | a non-2xx response, with `code` / `status` / `details` / `hint` |
3916
4063
  | `HttpClient` | _type_ | the transport interface `createClient` builds on |
4064
+ | `ConfiguredHttpClient` | _type_ | a framework-created `HttpClient` carrying its readonly `baseUrl` for URL builders |
3917
4065
  | `HttpClientConfig` | _type_ | config for `createHttpClient` |
3918
4066
  | `RequestOptions` | _type_ | per-call options — params, timeout, response type |
3919
4067
  | `HeaderProvider` | _type_ | static or per-request headers |
@@ -3965,6 +4113,10 @@ from the root `stitchkit`.
3965
4113
  | `ContractDef` | _type_ | a defined contract |
3966
4114
  | `ContractMeta` | _type_ | a contract's `prefix` + optional `scope` and `meta` (a default every endpoint shallow-merges over) |
3967
4115
  | `EndpointDef` | _type_ | a single endpoint definition |
4116
+ | `EndpointResponseMeta` | _type_ | static success metadata declared by an HTTP-only typed-data endpoint |
4117
+ | `ResponseMetadata` | _type_ | per-request outbound collector exposed as `ctx.response` only for a `responseMeta` endpoint |
4118
+ | `HttpSuccessStatus` | _type_ | supported declared 2xx success statuses |
4119
+ | `BodyHttpSuccessStatus` | _type_ | supported 2xx statuses excluding bodyless 204/205 |
3968
4120
  | `HttpMethod` | _type_ | `GET \| POST \| PUT \| PATCH \| DELETE` |
3969
4121
  | `Transport` | _type_ | `HTTP \| MCP \| AGENT \| CLI` |
3970
4122
  | `TransportSource` | _type_ | `http \| mcp \| agent \| cli` — the value of `ctx.source` |
@@ -3975,6 +4127,9 @@ from the root `stitchkit`.
3975
4127
  | `TypedHttpClient` | _type_ | the typed client, HTTP endpoints only (`= ScopedHttpClient<C, unknown>`) |
3976
4128
  | `ScopedHttpClient` | _type_ | a client whose `stripPrefixKeys` become required args ([guide](../guide/multi-tenant.md)) |
3977
4129
  | `ScopedEndpointFn` | _type_ | one method's signature with the consumed keys folded in |
4130
+ | `TypedUrlBuilder` | _type_ | one contract's HTTP, non-multipart GET endpoints as synchronous URL functions |
4131
+ | `ScopedUrlBuilder` | _type_ | a URL builder whose scoped-prefix keys are required method arguments |
4132
+ | `ScopedUrlFn` | _type_ | one URL method's signature with scoped-prefix keys folded in |
3978
4133
  | `MultipartFile` | _type_ | a `multipart` file field — `Blob \| FileDescriptor` |
3979
4134
  | `FileDescriptor` | _type_ | a React Native / Expo file — `{ uri, name, type }` |
3980
4135
  | `EndpointToolAnnotations` | _type_ | MCP behavioural hints on an endpoint (`readOnlyHint` / `destructiveHint` / `title`) |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stitchkit",
3
- "version": "0.37.0",
3
+ "version": "0.38.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",
File without changes