stitchkit 0.45.0 → 0.47.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 (122) hide show
  1. package/README.md +5 -4
  2. package/dist/browser/cancellation.d.ts +14 -0
  3. package/dist/browser/cancellation.d.ts.map +1 -0
  4. package/dist/browser/client-multipart.d.ts +3 -1
  5. package/dist/browser/client-multipart.d.ts.map +1 -1
  6. package/dist/browser/client.d.ts +1 -0
  7. package/dist/browser/client.d.ts.map +1 -1
  8. package/dist/browser/http.d.ts +1 -0
  9. package/dist/browser/http.d.ts.map +1 -1
  10. package/dist/browser/socket-io.d.ts +3 -1
  11. package/dist/browser/socket-io.d.ts.map +1 -1
  12. package/dist/cli.js +3 -4
  13. package/dist/contract/define.d.ts +59 -15
  14. package/dist/contract/define.d.ts.map +1 -1
  15. package/dist/contract/errors.d.ts +1 -0
  16. package/dist/contract/errors.d.ts.map +1 -1
  17. package/dist/contract/index.d.ts +1 -1
  18. package/dist/contract/index.d.ts.map +1 -1
  19. package/dist/contract/index.js +2 -2
  20. package/dist/{index-w0mec79h.js → index-44xysy8r.js} +697 -301
  21. package/dist/{index-r6czv7yt.js → index-45dz4m51.js} +50 -3
  22. package/dist/index-8ekq6res.js +181 -0
  23. package/dist/{index-yh0p5ddn.js → index-ee621cmy.js} +39 -19
  24. package/dist/{index-mvjyq9h9.js → index-kp8xamqp.js} +309 -144
  25. package/dist/{index-36f1xkdb.js → index-nrytvb30.js} +5 -4
  26. package/dist/{index-6jypn22c.js → index-r1qp4rve.js} +9 -1
  27. package/dist/{index-s4qsmgwe.js → index-tss6bk5c.js} +4 -1
  28. package/dist/index-yxpe3phd.js +63 -0
  29. package/dist/index.d.ts +1 -0
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/index.js +315 -134
  32. package/dist/internal/errors.d.ts.map +1 -1
  33. package/dist/internal/secure-fetch.d.ts +23 -3
  34. package/dist/internal/secure-fetch.d.ts.map +1 -1
  35. package/dist/internal/typed.d.ts +7 -0
  36. package/dist/internal/typed.d.ts.map +1 -1
  37. package/dist/internal/within-dir.d.ts +6 -0
  38. package/dist/internal/within-dir.d.ts.map +1 -1
  39. package/dist/logger.d.ts +8 -0
  40. package/dist/logger.d.ts.map +1 -0
  41. package/dist/node.js +3 -4
  42. package/dist/observability/audit.d.ts +22 -3
  43. package/dist/observability/audit.d.ts.map +1 -1
  44. package/dist/observability/event.d.ts +7 -0
  45. package/dist/observability/event.d.ts.map +1 -1
  46. package/dist/observability/index.d.ts +1 -1
  47. package/dist/observability/index.d.ts.map +1 -1
  48. package/dist/observability/index.js +130 -134
  49. package/dist/observability/sanitize.d.ts +8 -1
  50. package/dist/observability/sanitize.d.ts.map +1 -1
  51. package/dist/observability/trace.d.ts +3 -1
  52. package/dist/observability/trace.d.ts.map +1 -1
  53. package/dist/react/cache-bridge.d.ts +4 -0
  54. package/dist/react/cache-bridge.d.ts.map +1 -1
  55. package/dist/react.js +46 -5
  56. package/dist/realtime/contract.d.ts +4 -1
  57. package/dist/realtime/contract.d.ts.map +1 -1
  58. package/dist/realtime/rejection.d.ts +10 -0
  59. package/dist/realtime/rejection.d.ts.map +1 -0
  60. package/dist/realtime/socket.d.ts +14 -1
  61. package/dist/realtime/socket.d.ts.map +1 -1
  62. package/dist/server/cache.d.ts +5 -1
  63. package/dist/server/cache.d.ts.map +1 -1
  64. package/dist/server/context.d.ts +8 -5
  65. package/dist/server/context.d.ts.map +1 -1
  66. package/dist/server/create.d.ts.map +1 -1
  67. package/dist/server/error-hook.d.ts +1 -0
  68. package/dist/server/error-hook.d.ts.map +1 -1
  69. package/dist/server/file.d.ts +6 -0
  70. package/dist/server/file.d.ts.map +1 -1
  71. package/dist/server/implement.d.ts +33 -2
  72. package/dist/server/implement.d.ts.map +1 -1
  73. package/dist/server/index.d.ts +5 -6
  74. package/dist/server/index.d.ts.map +1 -1
  75. package/dist/server/index.js +133 -14
  76. package/dist/server/middleware/auth.d.ts +7 -4
  77. package/dist/server/middleware/auth.d.ts.map +1 -1
  78. package/dist/server/middleware/cors.d.ts.map +1 -1
  79. package/dist/server/multipart.d.ts +13 -18
  80. package/dist/server/multipart.d.ts.map +1 -1
  81. package/dist/server/openapi.d.ts.map +1 -1
  82. package/dist/server/realtime.d.ts +2 -0
  83. package/dist/server/realtime.d.ts.map +1 -1
  84. package/dist/server/router.d.ts +0 -8
  85. package/dist/server/router.d.ts.map +1 -1
  86. package/dist/server/stream.d.ts.map +1 -1
  87. package/dist/server/types.d.ts +50 -22
  88. package/dist/server/types.d.ts.map +1 -1
  89. package/dist/tools/cli-args.d.ts +11 -3
  90. package/dist/tools/cli-args.d.ts.map +1 -1
  91. package/dist/tools/cli.d.ts +5 -0
  92. package/dist/tools/cli.d.ts.map +1 -1
  93. package/dist/tools/coerce.d.ts.map +1 -1
  94. package/dist/tools/json-schema.d.ts.map +1 -1
  95. package/dist/tools/mcp-handler.d.ts +4 -3
  96. package/dist/tools/mcp-handler.d.ts.map +1 -1
  97. package/dist/tools/mcp-prepare.d.ts +95 -0
  98. package/dist/tools/mcp-prepare.d.ts.map +1 -0
  99. package/dist/tools/mcp-round.d.ts +4 -0
  100. package/dist/tools/mcp-round.d.ts.map +1 -1
  101. package/dist/tools/mcp-stdio.d.ts +2 -1
  102. package/dist/tools/mcp-stdio.d.ts.map +1 -1
  103. package/dist/tools/mcp.d.ts +2 -78
  104. package/dist/tools/mcp.d.ts.map +1 -1
  105. package/dist/tools/mount-download.d.ts +5 -0
  106. package/dist/tools/mount-download.d.ts.map +1 -1
  107. package/dist/tools/native-mcp.d.ts +1 -1
  108. package/dist/tools/native-mcp.d.ts.map +1 -1
  109. package/dist/tools/oauth-provider.d.ts +20 -1
  110. package/dist/tools/oauth-provider.d.ts.map +1 -1
  111. package/dist/tools/tool-logger.d.ts +1 -1
  112. package/dist/tools/tool-logger.d.ts.map +1 -1
  113. package/dist/tools/view-file.d.ts +9 -0
  114. package/dist/tools/view-file.d.ts.map +1 -1
  115. package/dist/tools.d.ts +2 -1
  116. package/dist/tools.d.ts.map +1 -1
  117. package/dist/tools.js +505 -278
  118. package/llms-full.txt +473 -85
  119. package/llms.txt +1 -1
  120. package/package.json +2 -2
  121. package/dist/index-frfyw9fa.js +0 -33
  122. package/dist/index-x3fcszf8.js +0 -8
@@ -2,14 +2,12 @@ import {
2
2
  assertCorsConfig,
3
3
  corsHeaders,
4
4
  corsPreflightResponse
5
- } from "./index-6jypn22c.js";
6
- import {
7
- isWithinDir
8
- } from "./index-x3fcszf8.js";
5
+ } from "./index-r1qp4rve.js";
9
6
  import {
10
7
  AppError,
11
8
  __require,
12
9
  badRequest,
10
+ callRuntimeHandler,
13
11
  errorCode,
14
12
  extractIp,
15
13
  getClientInfo,
@@ -29,298 +27,433 @@ import {
29
27
  setRequestEndpoint,
30
28
  setRequestError,
31
29
  typedEntries,
32
- validateDeclaredOutput
33
- } from "./index-yh0p5ddn.js";
30
+ validateDeclaredOutput,
31
+ zodIssues
32
+ } from "./index-ee621cmy.js";
34
33
 
35
34
  // src/server/multipart.ts
36
- var DEFAULT_MAX_UPLOAD_BYTES = 25 * 1024 * 1024;
37
- async function readBodyCapped(req, maxBytes) {
38
- const reader = req.body?.getReader();
39
- if (!reader)
40
- return new Uint8Array(0);
41
- const chunks = [];
42
- let total = 0;
43
- try {
44
- while (true) {
45
- const { done, value } = await reader.read();
46
- if (done)
47
- break;
48
- total += value.length;
49
- if (total > maxBytes) {
50
- await reader.cancel();
51
- badRequest(`Upload exceeds the ${Math.round(maxBytes / 1024 / 1024)} MB limit`);
35
+ var DEFAULT_MAX_REQUEST_BYTES = 25 * 1024 * 1024;
36
+ var DEFAULT_MAX_FIELD_BYTES = 1024 * 1024;
37
+ var DEFAULT_MAX_HEADER_BYTES = 64 * 1024;
38
+ var DEFAULT_MAX_PARTS = 1000;
39
+ var CRLF = new Uint8Array([13, 10]);
40
+ var HEADER_END = new Uint8Array([13, 10, 13, 10]);
41
+ var decoder = new TextDecoder;
42
+ var encoder = new TextEncoder;
43
+ function indexOfBytes(haystack, needle) {
44
+ outer:
45
+ for (let index = 0;index <= haystack.length - needle.length; index += 1) {
46
+ for (let offset = 0;offset < needle.length; offset += 1) {
47
+ if (haystack[index + offset] !== needle[offset])
48
+ continue outer;
52
49
  }
53
- chunks.push(value);
50
+ return index;
54
51
  }
55
- } finally {
56
- reader.releaseLock();
57
- }
58
- const buffer = new Uint8Array(total);
59
- let offset = 0;
60
- for (const chunk of chunks) {
61
- buffer.set(chunk, offset);
62
- offset += chunk.length;
52
+ return -1;
53
+ }
54
+ function startsWithBytes(value, prefix) {
55
+ if (value.length < prefix.length)
56
+ return false;
57
+ for (let index = 0;index < prefix.length; index += 1) {
58
+ if (value[index] !== prefix[index])
59
+ return false;
63
60
  }
64
- return buffer;
61
+ return true;
65
62
  }
66
- async function parseMultipart(req, fileField, fieldsSchema, maxBytes = DEFAULT_MAX_UPLOAD_BYTES) {
67
- const body = await readBodyCapped(req, maxBytes);
63
+ function concatBytes(left, right) {
64
+ if (left.length === 0)
65
+ return right.slice();
66
+ if (right.length === 0)
67
+ return left.slice();
68
+ const joined = new Uint8Array(left.length + right.length);
69
+ joined.set(left);
70
+ joined.set(right, left.length);
71
+ return joined;
72
+ }
73
+ function parseBoundary(req) {
68
74
  const contentType = req.headers.get("content-type") ?? "";
69
- const formData = await new Response(body, {
70
- headers: { "content-type": contentType }
71
- }).formData();
72
- const file = formData.get(fileField);
73
- if (!file || !(file instanceof File)) {
74
- badRequest(`Missing file field: ${fileField}`);
75
+ if (!contentType.toLowerCase().startsWith("multipart/form-data")) {
76
+ badRequest("Request body must be multipart/form-data");
75
77
  }
76
- const fields = {};
77
- for (const [key, value] of formData.entries()) {
78
- if (key === fileField)
79
- continue;
80
- if (isUnsafeKey(key))
81
- continue;
82
- if (typeof value === "string")
83
- fields[key] = value;
84
- }
85
- return { file, fields: fieldsSchema ? fieldsSchema.parse(fields) : fields };
78
+ const match = /(?:^|;)\s*boundary=(?:"([^"]+)"|([^;\s]+))/i.exec(contentType);
79
+ const boundary = match?.[1] ?? match?.[2];
80
+ if (!boundary || boundary.length > 200)
81
+ badRequest("Invalid multipart boundary");
82
+ return boundary;
86
83
  }
87
-
88
- // src/server/router.ts
89
- import { readFile, stat } from "node:fs/promises";
90
- import { resolve } from "node:path";
91
-
92
- // src/server/mime.ts
93
- import { extname } from "node:path";
94
- var MIME_BY_EXT = {
95
- ".html": "text/html; charset=utf-8",
96
- ".css": "text/css; charset=utf-8",
97
- ".js": "text/javascript; charset=utf-8",
98
- ".mjs": "text/javascript; charset=utf-8",
99
- ".json": "application/json; charset=utf-8",
100
- ".svg": "image/svg+xml",
101
- ".png": "image/png",
102
- ".jpg": "image/jpeg",
103
- ".jpeg": "image/jpeg",
104
- ".gif": "image/gif",
105
- ".webp": "image/webp",
106
- ".avif": "image/avif",
107
- ".ico": "image/x-icon",
108
- ".woff2": "font/woff2",
109
- ".txt": "text/plain; charset=utf-8",
110
- ".wasm": "application/wasm",
111
- ".mp4": "video/mp4",
112
- ".webm": "video/webm",
113
- ".mov": "video/quicktime",
114
- ".ogv": "video/ogg",
115
- ".mp3": "audio/mpeg",
116
- ".m4a": "audio/mp4",
117
- ".wav": "audio/wav",
118
- ".flac": "audio/flac",
119
- ".oga": "audio/ogg",
120
- ".ogg": "audio/ogg",
121
- ".pdf": "application/pdf",
122
- ".zip": "application/zip"
123
- };
124
- function mimeForPath(path) {
125
- return MIME_BY_EXT[extname(path).toLowerCase()] ?? "application/octet-stream";
126
- }
127
-
128
- // src/server/router.ts
129
- function joinPath(...parts) {
130
- const joined = parts.filter(Boolean).map((part) => part.replace(/^\/+|\/+$/g, "")).filter(Boolean).join("/");
131
- return `/${joined}`;
84
+ function parseDisposition(value) {
85
+ if (!value || !/^form-data(?:;|$)/i.test(value))
86
+ badRequest("Invalid multipart disposition");
87
+ const name = /(?:^|;)\s*name="([^"]*)"/i.exec(value)?.[1];
88
+ const filename = /(?:^|;)\s*filename="([^"]*)"/i.exec(value)?.[1];
89
+ if (!name || isUnsafeKey(name))
90
+ badRequest("Invalid multipart field name");
91
+ return filename === undefined ? { name } : { name, filename };
132
92
  }
133
- function matchSegments(patternSegments, requestSegments) {
134
- const wildcardSegment = patternSegments.at(-1);
135
- const wildcardName = wildcardSegment?.startsWith("*") ? wildcardSegment.slice(1) : null;
136
- const prefixLength = wildcardName ? patternSegments.length - 1 : patternSegments.length;
137
- if (wildcardName) {
138
- if (requestSegments.length < prefixLength)
139
- return null;
140
- } else if (patternSegments.length !== requestSegments.length) {
141
- return null;
93
+ function parsePartHeaders(bytes) {
94
+ let text;
95
+ try {
96
+ text = decoder.decode(bytes);
97
+ } catch {
98
+ badRequest("Invalid multipart headers");
142
99
  }
143
- const params = {};
144
- for (const [i, pattern] of patternSegments.slice(0, prefixLength).entries()) {
145
- const actual = requestSegments[i];
146
- if (actual === undefined)
147
- return null;
148
- if (pattern.startsWith(":")) {
149
- params[pattern.slice(1)] = decodeURIComponent(actual);
150
- } else if (pattern !== actual) {
151
- return null;
152
- }
100
+ const headers = new Headers;
101
+ for (const line of text.split(`\r
102
+ `)) {
103
+ const separator = line.indexOf(":");
104
+ if (separator <= 0)
105
+ badRequest("Invalid multipart header");
106
+ headers.append(line.slice(0, separator).trim(), line.slice(separator + 1).trim());
153
107
  }
154
- if (wildcardName) {
155
- params[wildcardName] = requestSegments.slice(prefixLength).map(decodeURIComponent).join("/");
108
+ const disposition = parseDisposition(headers.get("content-disposition") ?? undefined);
109
+ const rawContentType = headers.get("content-type");
110
+ const contentType = rawContentType?.split(";", 1)[0]?.trim().toLowerCase();
111
+ const rawSize = headers.get("content-length");
112
+ const declaredSize = rawSize === null ? undefined : Number(rawSize);
113
+ if (declaredSize !== undefined && (!Number.isSafeInteger(declaredSize) || declaredSize < 0)) {
114
+ badRequest("Invalid multipart part content-length");
156
115
  }
157
- return params;
158
- }
159
- function segmentRank(segment) {
160
- if (segment?.startsWith("*"))
161
- return 2;
162
- if (segment?.startsWith(":"))
163
- return 1;
164
- return 0;
116
+ return { ...disposition, contentType, declaredSize };
165
117
  }
166
- function buildRouteMap(groups) {
167
- const map = new Map;
168
- for (const { prefix, service, hooks } of groups) {
169
- for (const [, method] of Object.entries(service.methods)) {
170
- if (method.expose && !method.expose.includes("HTTP"))
171
- continue;
172
- const servicePath = joinPath("/", service.prefix, method.path === "/" ? "" : method.path);
173
- const fullPath = prefix ? joinPath(prefix, servicePath) : servicePath;
174
- const segments = fullPath.split("/").filter(Boolean);
175
- const entries = map.get(method.method) ?? [];
176
- entries.push({ method, pattern: fullPath, segments, groupHooks: hooks });
177
- map.set(method.method, entries);
118
+
119
+ class MultipartStreamReader {
120
+ #reader;
121
+ #delimiter;
122
+ #initialBoundary;
123
+ #maxRequestBytes;
124
+ #buffer = new Uint8Array;
125
+ #readBytes = 0;
126
+ #sourceDone = false;
127
+ #terminal = false;
128
+ #activePart = false;
129
+ constructor(req, boundary, maxRequestBytes) {
130
+ if (!req.body)
131
+ badRequest("Multipart request body is empty");
132
+ this.#reader = req.body.getReader();
133
+ this.#delimiter = encoder.encode(`\r
134
+ --${boundary}`);
135
+ this.#initialBoundary = encoder.encode(`--${boundary}`);
136
+ this.#maxRequestBytes = maxRequestBytes;
137
+ }
138
+ async start() {
139
+ await this.#ensure(this.#initialBoundary.length + 2);
140
+ if (!startsWithBytes(this.#buffer, this.#initialBoundary)) {
141
+ badRequest("Malformed multipart body");
178
142
  }
143
+ this.#consume(this.#initialBoundary.length);
144
+ await this.#consumeBoundarySuffix();
179
145
  }
180
- for (const [, entries] of map) {
181
- entries.sort((a, b) => {
182
- const len = Math.min(a.segments.length, b.segments.length);
183
- for (let i = 0;i < len; i++) {
184
- const rankDifference = segmentRank(a.segments[i]) - segmentRank(b.segments[i]);
185
- if (rankDifference !== 0)
186
- return rankDifference;
146
+ async nextPart() {
147
+ if (this.#activePart)
148
+ badRequest("Multipart part stream was not fully consumed");
149
+ if (this.#terminal)
150
+ return null;
151
+ const header = await this.#readUntil(HEADER_END, DEFAULT_MAX_HEADER_BYTES);
152
+ const parsed = parsePartHeaders(header);
153
+ this.#activePart = true;
154
+ let ended = false;
155
+ const stream = new ReadableStream({
156
+ pull: async (controller) => {
157
+ if (ended) {
158
+ controller.close();
159
+ return;
160
+ }
161
+ try {
162
+ const chunk = await this.#readPartChunk();
163
+ if (chunk.value.length > 0)
164
+ controller.enqueue(chunk.value);
165
+ if (chunk.end) {
166
+ ended = true;
167
+ controller.close();
168
+ }
169
+ } catch (error) {
170
+ controller.error(error);
171
+ this.cancel(error);
172
+ }
173
+ },
174
+ cancel: (reason) => {
175
+ this.cancel(reason);
187
176
  }
188
- return a.segments.length - b.segments.length;
189
177
  });
178
+ return { ...parsed, stream, consumed: () => ended };
190
179
  }
191
- return map;
192
- }
193
- function matchRoute(routeMap, httpMethod, pathname) {
194
- const entries = routeMap.get(httpMethod);
195
- if (!entries)
196
- return null;
197
- const requestSegments = pathname.split("/").filter(Boolean);
198
- for (const entry of entries) {
199
- const pathParams = matchSegments(entry.segments, requestSegments);
200
- if (pathParams) {
201
- return {
202
- method: entry.method,
203
- pathParams,
204
- groupHooks: entry.groupHooks
205
- };
206
- }
180
+ cancel(reason) {
181
+ this.#reader.cancel(reason).catch(() => {});
207
182
  }
208
- return null;
209
- }
210
- function allowedMethods(routeMap, pathname) {
211
- const requestSegments = pathname.split("/").filter(Boolean);
212
- const methods = [];
213
- for (const [method, entries] of routeMap) {
214
- for (const entry of entries) {
215
- if (matchSegments(entry.segments, requestSegments)) {
216
- methods.push(method);
217
- break;
183
+ release() {
184
+ this.#reader.releaseLock();
185
+ }
186
+ async#readPartChunk() {
187
+ while (true) {
188
+ const boundaryIndex = indexOfBytes(this.#buffer, this.#delimiter);
189
+ if (boundaryIndex >= 0) {
190
+ const value = this.#buffer.slice(0, boundaryIndex);
191
+ this.#consume(boundaryIndex + this.#delimiter.length);
192
+ await this.#consumeBoundarySuffix();
193
+ this.#activePart = false;
194
+ return { value, end: true };
195
+ }
196
+ const retained = this.#delimiter.length - 1;
197
+ if (this.#buffer.length > retained) {
198
+ const emitLength = this.#buffer.length - retained;
199
+ const value = this.#buffer.slice(0, emitLength);
200
+ this.#consume(emitLength);
201
+ return { value, end: false };
218
202
  }
203
+ if (this.#sourceDone)
204
+ badRequest("Incomplete multipart body");
205
+ await this.#readMore();
219
206
  }
220
207
  }
221
- return methods;
222
- }
223
- function validateRoutes(routeMap) {
224
- for (const [method, entries] of routeMap) {
225
- const seen = new Map;
226
- for (const entry of entries) {
227
- parseTrailingWildcard(entry.pattern);
228
- const normalized = entry.segments.map((s) => s.startsWith(":") ? ":param" : s.startsWith("*") ? "*wildcard" : s).join("/");
229
- const key = `${method} /${normalized}`;
230
- const existing = seen.get(key);
231
- if (existing) {
232
- throw new Error(`Duplicate route: ${method} ${entry.pattern} conflicts with ${existing}`);
208
+ async#consumeBoundarySuffix() {
209
+ await this.#ensure(2);
210
+ if (this.#buffer[0] === 45 && this.#buffer[1] === 45) {
211
+ this.#consume(2);
212
+ this.#terminal = true;
213
+ if (startsWithBytes(this.#buffer, CRLF))
214
+ this.#consume(2);
215
+ return;
216
+ }
217
+ if (!startsWithBytes(this.#buffer, CRLF))
218
+ badRequest("Malformed multipart boundary");
219
+ this.#consume(2);
220
+ }
221
+ async#readUntil(marker, maxBytes) {
222
+ while (true) {
223
+ const index = indexOfBytes(this.#buffer, marker);
224
+ if (index >= 0) {
225
+ if (index > maxBytes)
226
+ badRequest("Multipart part headers are too large");
227
+ const value = this.#buffer.slice(0, index);
228
+ this.#consume(index + marker.length);
229
+ return value;
233
230
  }
234
- seen.set(key, entry.pattern);
231
+ if (this.#buffer.length > maxBytes)
232
+ badRequest("Multipart part headers are too large");
233
+ if (this.#sourceDone)
234
+ badRequest("Incomplete multipart headers");
235
+ await this.#readMore();
235
236
  }
236
237
  }
237
- }
238
- function findShadowedRoutes(routeMap, rawRoutes) {
239
- if (!rawRoutes || rawRoutes.length === 0)
240
- return [];
241
- const shadowed = [];
242
- for (const [httpMethod, entries] of routeMap) {
243
- for (const entry of entries) {
244
- const probe = `/${entry.segments.map((segment) => {
245
- if (segment.startsWith(":"))
246
- return "__param__";
247
- if (segment.startsWith("*"))
248
- return "__wildcard__";
249
- return segment;
250
- }).join("/")}`;
251
- const match = matchRawRoute(rawRoutes, httpMethod, probe);
252
- if (!match)
253
- continue;
254
- shadowed.push({
255
- pattern: `${httpMethod} ${entry.pattern}`,
256
- endpoint: `${entry.method.serviceName}.${entry.method.key}`,
257
- rawRoute: `${match.route.method} ${match.route.path}`,
258
- scope: entry.method.scope
259
- });
238
+ async#ensure(length) {
239
+ while (this.#buffer.length < length && !this.#sourceDone)
240
+ await this.#readMore();
241
+ if (this.#buffer.length < length)
242
+ badRequest("Incomplete multipart body");
243
+ }
244
+ async#readMore() {
245
+ const result = await this.#reader.read();
246
+ if (result.done) {
247
+ this.#sourceDone = true;
248
+ return;
260
249
  }
250
+ this.#readBytes += result.value.length;
251
+ if (this.#readBytes > this.#maxRequestBytes) {
252
+ this.cancel();
253
+ badRequest(`Multipart request exceeds ${this.#maxRequestBytes} bytes`);
254
+ }
255
+ this.#buffer = concatBytes(this.#buffer, result.value);
256
+ }
257
+ #consume(length) {
258
+ this.#buffer = this.#buffer.slice(length);
261
259
  }
262
- return shadowed;
263
260
  }
264
- function matchRawRoute(rawRoutes, httpMethod, pathname) {
265
- for (const route of rawRoutes) {
266
- if (route.method !== "ALL" && route.method !== httpMethod)
267
- continue;
268
- if (parseTrailingWildcard(route.path)) {
269
- const routeSegs = route.path.split("/").filter(Boolean);
270
- const pathSegs = pathname.split("/").filter(Boolean);
271
- const params = matchSegments(routeSegs, pathSegs);
272
- if (params)
273
- return { route, params };
274
- continue;
261
+ function matchesContentType(contentType, accepted) {
262
+ const normalized = contentType.toLowerCase();
263
+ return accepted.some((candidate) => {
264
+ const policy = candidate.toLowerCase();
265
+ return policy.endsWith("/*") ? normalized.startsWith(policy.slice(0, -1)) : normalized === policy;
266
+ });
267
+ }
268
+ function limitedStream(stream, maxBytes, label, signal) {
269
+ const reader = stream.getReader();
270
+ let total = 0;
271
+ const cancelReader = (reason) => {
272
+ reader.cancel(reason).catch(() => {});
273
+ };
274
+ return new ReadableStream({
275
+ async pull(controller) {
276
+ if (signal.aborted) {
277
+ cancelReader(signal.reason);
278
+ controller.error(signal.reason ?? new DOMException("Aborted", "AbortError"));
279
+ return;
280
+ }
281
+ try {
282
+ const result = await reader.read();
283
+ if (result.done) {
284
+ controller.close();
285
+ reader.releaseLock();
286
+ return;
287
+ }
288
+ total += result.value.length;
289
+ if (total > maxBytes) {
290
+ cancelReader();
291
+ badRequest(`${label} exceeds ${maxBytes} bytes`);
292
+ }
293
+ controller.enqueue(result.value);
294
+ } catch (error) {
295
+ controller.error(error);
296
+ }
297
+ },
298
+ cancel(reason) {
299
+ cancelReader(reason);
275
300
  }
276
- if (route.path.includes("/:")) {
277
- const routeSegs = route.path.split("/").filter(Boolean);
278
- const pathSegs = pathname.split("/").filter(Boolean);
279
- const params = matchSegments(routeSegs, pathSegs);
280
- if (params)
281
- return { route, params };
282
- continue;
301
+ });
302
+ }
303
+ async function collectFile(part, stream) {
304
+ const chunks = [];
305
+ const reader = stream.getReader();
306
+ while (true) {
307
+ const result = await reader.read();
308
+ if (result.done)
309
+ break;
310
+ chunks.push(Uint8Array.from(result.value).buffer);
311
+ }
312
+ return new File(chunks, part.filename ?? "upload", {
313
+ type: part.contentType ?? "application/octet-stream"
314
+ });
315
+ }
316
+ async function readText(stream, maxBytes) {
317
+ const chunks = [];
318
+ const reader = stream.getReader();
319
+ let total = 0;
320
+ while (true) {
321
+ const result = await reader.read();
322
+ if (result.done)
323
+ break;
324
+ total += result.value.length;
325
+ if (total > maxBytes) {
326
+ await reader.cancel();
327
+ badRequest(`Multipart text field exceeds ${maxBytes} bytes`);
283
328
  }
284
- if (route.path === pathname)
285
- return { route, params: {} };
329
+ chunks.push(result.value);
286
330
  }
287
- return null;
331
+ const value = new Uint8Array(total);
332
+ let offset = 0;
333
+ for (const chunk of chunks) {
334
+ value.set(chunk, offset);
335
+ offset += chunk.length;
336
+ }
337
+ return decoder.decode(value);
288
338
  }
289
- function validateRawRoutes(rawRoutes) {
290
- for (const route of rawRoutes ?? [])
291
- parseTrailingWildcard(route.path);
339
+ function required(policy) {
340
+ return policy.required !== false;
292
341
  }
293
- function staticRoute(prefix, dir) {
294
- const cleanPrefix = prefix.replace(/\/+$/, "");
295
- const root = resolve(dir.replace(/\/+$/, ""));
296
- return {
297
- method: "GET",
298
- path: `${cleanPrefix}/*filePath`,
299
- handler: async (req) => {
300
- const pathname = new URL(req.url).pathname;
301
- let rel;
342
+ async function parseMultipart(req, descriptor, fieldsSchema, receivers) {
343
+ const maxRequestBytes = descriptor.maxRequestBytes ?? DEFAULT_MAX_REQUEST_BYTES;
344
+ const contentLength = Number(req.headers.get("content-length"));
345
+ if (Number.isFinite(contentLength) && contentLength > maxRequestBytes) {
346
+ badRequest(`Multipart request exceeds ${maxRequestBytes} bytes`);
347
+ }
348
+ const boundary = parseBoundary(req);
349
+ const parser = new MultipartStreamReader(req, boundary, maxRequestBytes);
350
+ const fields = {};
351
+ const files = {};
352
+ const counts = new Map;
353
+ const cleanups = [];
354
+ let rolledBack = false;
355
+ const rollback = async () => {
356
+ if (rolledBack)
357
+ return;
358
+ rolledBack = true;
359
+ for (let index = cleanups.length - 1;index >= 0; index -= 1) {
302
360
  try {
303
- rel = decodeURIComponent(pathname.slice(cleanPrefix.length)).replace(/^\/+/, "");
304
- } catch {
305
- return new Response("Bad request", { status: 400 });
361
+ await cleanups[index]?.();
362
+ } catch (error) {
363
+ console.error("[stitchkit] multipart receiver cleanup failed", error);
364
+ }
365
+ }
366
+ };
367
+ try {
368
+ await parser.start();
369
+ let partCount = 0;
370
+ while (true) {
371
+ const part = await parser.nextPart();
372
+ if (!part)
373
+ break;
374
+ partCount += 1;
375
+ if (partCount > DEFAULT_MAX_PARTS)
376
+ badRequest("Too many multipart parts");
377
+ const policy = descriptor.files[part.name];
378
+ const isFile = part.filename !== undefined || part.contentType !== undefined;
379
+ if (!isFile) {
380
+ if (policy)
381
+ badRequest(`Multipart file field "${part.name}" must contain a file`);
382
+ if (Object.hasOwn(fields, part.name)) {
383
+ badRequest(`Duplicate multipart text field: ${part.name}`);
384
+ }
385
+ fields[part.name] = await readText(part.stream, descriptor.maxFieldBytes ?? DEFAULT_MAX_FIELD_BYTES);
386
+ continue;
306
387
  }
307
- const target = resolve(root, rel);
308
- if (!isWithinDir(root, target)) {
309
- return new Response("Forbidden", { status: 403 });
388
+ if (!policy)
389
+ badRequest(`Unexpected multipart file field: ${part.name}`);
390
+ const count = (counts.get(part.name) ?? 0) + 1;
391
+ counts.set(part.name, count);
392
+ const maxFiles = policy.multiple === true ? policy.maxFiles ?? DEFAULT_MAX_PARTS : 1;
393
+ if (count > maxFiles)
394
+ badRequest(`Too many files for multipart field: ${part.name}`);
395
+ const contentType = part.contentType ?? "";
396
+ if (policy.contentTypes && !matchesContentType(contentType, policy.contentTypes)) {
397
+ badRequest(`Unsupported content type for multipart field "${part.name}"`);
310
398
  }
311
- const info = await stat(target).catch(() => null);
312
- if (!info?.isFile()) {
313
- return new Response("Not found", { status: 404 });
399
+ const maxFileBytes = policy.maxBytes ?? maxRequestBytes;
400
+ if (part.declaredSize !== undefined && part.declaredSize > maxFileBytes) {
401
+ badRequest(`Multipart field "${part.name}" exceeds ${maxFileBytes} bytes`);
314
402
  }
315
- const body = await readFile(target);
316
- return new Response(body, {
317
- headers: {
318
- "Content-Type": mimeForPath(target),
319
- "X-Content-Type-Options": "nosniff"
403
+ const stream = limitedStream(part.stream, maxFileBytes, `Multipart field "${part.name}"`, req.signal);
404
+ let value;
405
+ if (descriptor.delivery === "stream") {
406
+ const receiver = receivers?.[part.name];
407
+ if (!receiver)
408
+ badRequest(`Missing multipart receiver for field: ${part.name}`);
409
+ const result = await receiver({
410
+ metadata: {
411
+ field: part.name,
412
+ filename: part.filename ?? "upload",
413
+ contentType: contentType || "application/octet-stream",
414
+ size: part.declaredSize
415
+ },
416
+ stream,
417
+ signal: req.signal
418
+ });
419
+ cleanups.push(result.cleanup);
420
+ if (!part.consumed()) {
421
+ await stream.cancel();
422
+ badRequest(`Multipart receiver for "${part.name}" did not consume its stream`);
320
423
  }
321
- });
424
+ value = result.value;
425
+ } else {
426
+ value = await collectFile(part, stream);
427
+ }
428
+ if (policy.multiple === true) {
429
+ const existing = files[part.name];
430
+ if (Array.isArray(existing))
431
+ existing.push(value);
432
+ else
433
+ files[part.name] = [value];
434
+ } else {
435
+ files[part.name] = value;
436
+ }
322
437
  }
323
- };
438
+ for (const [field, policy] of Object.entries(descriptor.files)) {
439
+ const count = counts.get(field) ?? 0;
440
+ if (required(policy) && count === 0)
441
+ badRequest(`Missing multipart file field: ${field}`);
442
+ if (policy.multiple === true && count === 0 && !required(policy))
443
+ files[field] = [];
444
+ }
445
+ return {
446
+ files,
447
+ fields: fieldsSchema ? fieldsSchema.parse(fields) : fields,
448
+ rollback
449
+ };
450
+ } catch (error) {
451
+ parser.cancel(error);
452
+ await rollback();
453
+ throw error;
454
+ } finally {
455
+ parser.release();
456
+ }
324
457
  }
325
458
 
326
459
  // src/server/request-body.ts
@@ -406,17 +539,19 @@ function buildBaseContext(req, url, pathParams, traceId, clientIp) {
406
539
  ...getClientInfo(req, clientIp)
407
540
  };
408
541
  }
409
- async function parseRequestInto(ctx, req, url, method, maxUploadBytes, maxJsonBodyBytes) {
542
+ function parsePathParamsInto(ctx, method) {
410
543
  if (method.paramsSchema) {
411
544
  ctx.params = method.paramsSchema.parse(ctx.params);
412
545
  }
546
+ }
547
+ async function parseRequestPayloadInto(ctx, req, url, method, maxJsonBodyBytes) {
413
548
  if (method.multipart) {
414
- const cap = method.maxUploadBytes ?? maxUploadBytes;
415
- const multipart = await parseMultipart(req, method.multipart, method.inputSchema, cap);
549
+ const multipart = await parseMultipart(req, method.multipart, method.inputSchema, method.multipartReceivers);
416
550
  ctx.input = multipart.fields;
417
- if (multipart.file)
418
- ctx.file = multipart.file;
419
- } else if (method.inputSchema) {
551
+ ctx.files = multipart.files;
552
+ return multipart;
553
+ }
554
+ if (method.inputSchema) {
420
555
  if (req.method === "GET") {
421
556
  ctx.input = method.inputSchema.parse(parseQueryParams(url));
422
557
  } else if (req.method === "DELETE") {
@@ -436,6 +571,7 @@ async function parseRequestInto(ctx, req, url, method, maxUploadBytes, maxJsonBo
436
571
  ctx.input = method.inputSchema.parse(parseJsonBody(req, text));
437
572
  }
438
573
  }
574
+ return;
439
575
  }
440
576
  function buildErrorContext(req, url, traceId, clientIp) {
441
577
  return {
@@ -662,6 +798,172 @@ function applyResponseMetadata(target, metadata, endpointIdentity) {
662
798
  }
663
799
  }
664
800
 
801
+ // src/server/router.ts
802
+ function joinPath(...parts) {
803
+ const joined = parts.filter(Boolean).map((part) => part.replace(/^\/+|\/+$/g, "")).filter(Boolean).join("/");
804
+ return `/${joined}`;
805
+ }
806
+ function matchSegments(patternSegments, requestSegments) {
807
+ const wildcardSegment = patternSegments.at(-1);
808
+ const wildcardName = wildcardSegment?.startsWith("*") ? wildcardSegment.slice(1) : null;
809
+ const prefixLength = wildcardName ? patternSegments.length - 1 : patternSegments.length;
810
+ if (wildcardName) {
811
+ if (requestSegments.length < prefixLength)
812
+ return null;
813
+ } else if (patternSegments.length !== requestSegments.length) {
814
+ return null;
815
+ }
816
+ const params = {};
817
+ for (const [i, pattern] of patternSegments.slice(0, prefixLength).entries()) {
818
+ const actual = requestSegments[i];
819
+ if (actual === undefined)
820
+ return null;
821
+ if (pattern.startsWith(":")) {
822
+ params[pattern.slice(1)] = decodeURIComponent(actual);
823
+ } else if (pattern !== actual) {
824
+ return null;
825
+ }
826
+ }
827
+ if (wildcardName) {
828
+ params[wildcardName] = requestSegments.slice(prefixLength).map(decodeURIComponent).join("/");
829
+ }
830
+ return params;
831
+ }
832
+ function segmentRank(segment) {
833
+ if (segment?.startsWith("*"))
834
+ return 2;
835
+ if (segment?.startsWith(":"))
836
+ return 1;
837
+ return 0;
838
+ }
839
+ function buildRouteMap(groups) {
840
+ const map = new Map;
841
+ for (const { prefix, service, hooks } of groups) {
842
+ for (const [, method] of Object.entries(service.methods)) {
843
+ if (method.expose && !method.expose.includes("HTTP"))
844
+ continue;
845
+ const servicePath = joinPath("/", service.prefix, method.path === "/" ? "" : method.path);
846
+ const fullPath = prefix ? joinPath(prefix, servicePath) : servicePath;
847
+ const segments = fullPath.split("/").filter(Boolean);
848
+ const entries = map.get(method.method) ?? [];
849
+ entries.push({ method, pattern: fullPath, segments, groupHooks: hooks });
850
+ map.set(method.method, entries);
851
+ }
852
+ }
853
+ for (const [, entries] of map) {
854
+ entries.sort((a, b) => {
855
+ const len = Math.min(a.segments.length, b.segments.length);
856
+ for (let i = 0;i < len; i++) {
857
+ const rankDifference = segmentRank(a.segments[i]) - segmentRank(b.segments[i]);
858
+ if (rankDifference !== 0)
859
+ return rankDifference;
860
+ }
861
+ return a.segments.length - b.segments.length;
862
+ });
863
+ }
864
+ return map;
865
+ }
866
+ function matchRoute(routeMap, httpMethod, pathname) {
867
+ const entries = routeMap.get(httpMethod);
868
+ if (!entries)
869
+ return null;
870
+ const requestSegments = pathname.split("/").filter(Boolean);
871
+ for (const entry of entries) {
872
+ const pathParams = matchSegments(entry.segments, requestSegments);
873
+ if (pathParams) {
874
+ return {
875
+ method: entry.method,
876
+ pathParams,
877
+ groupHooks: entry.groupHooks
878
+ };
879
+ }
880
+ }
881
+ return null;
882
+ }
883
+ function allowedMethods(routeMap, pathname) {
884
+ const requestSegments = pathname.split("/").filter(Boolean);
885
+ const methods = [];
886
+ for (const [method, entries] of routeMap) {
887
+ for (const entry of entries) {
888
+ if (matchSegments(entry.segments, requestSegments)) {
889
+ methods.push(method);
890
+ break;
891
+ }
892
+ }
893
+ }
894
+ return methods;
895
+ }
896
+ function validateRoutes(routeMap) {
897
+ for (const [method, entries] of routeMap) {
898
+ const seen = new Map;
899
+ for (const entry of entries) {
900
+ parseTrailingWildcard(entry.pattern);
901
+ const normalized = entry.segments.map((s) => s.startsWith(":") ? ":param" : s.startsWith("*") ? "*wildcard" : s).join("/");
902
+ const key = `${method} /${normalized}`;
903
+ const existing = seen.get(key);
904
+ if (existing) {
905
+ throw new Error(`Duplicate route: ${method} ${entry.pattern} conflicts with ${existing}`);
906
+ }
907
+ seen.set(key, entry.pattern);
908
+ }
909
+ }
910
+ }
911
+ function findShadowedRoutes(routeMap, rawRoutes) {
912
+ if (!rawRoutes || rawRoutes.length === 0)
913
+ return [];
914
+ const shadowed = [];
915
+ for (const [httpMethod, entries] of routeMap) {
916
+ for (const entry of entries) {
917
+ const probe = `/${entry.segments.map((segment) => {
918
+ if (segment.startsWith(":"))
919
+ return "__param__";
920
+ if (segment.startsWith("*"))
921
+ return "__wildcard__";
922
+ return segment;
923
+ }).join("/")}`;
924
+ const match = matchRawRoute(rawRoutes, httpMethod, probe);
925
+ if (!match)
926
+ continue;
927
+ shadowed.push({
928
+ pattern: `${httpMethod} ${entry.pattern}`,
929
+ endpoint: `${entry.method.serviceName}.${entry.method.key}`,
930
+ rawRoute: `${match.route.method} ${match.route.path}`,
931
+ scope: entry.method.scope
932
+ });
933
+ }
934
+ }
935
+ return shadowed;
936
+ }
937
+ function matchRawRoute(rawRoutes, httpMethod, pathname) {
938
+ for (const route of rawRoutes) {
939
+ if (route.method !== "ALL" && route.method !== httpMethod)
940
+ continue;
941
+ if (parseTrailingWildcard(route.path)) {
942
+ const routeSegs = route.path.split("/").filter(Boolean);
943
+ const pathSegs = pathname.split("/").filter(Boolean);
944
+ const params = matchSegments(routeSegs, pathSegs);
945
+ if (params)
946
+ return { route, params };
947
+ continue;
948
+ }
949
+ if (route.path.includes("/:")) {
950
+ const routeSegs = route.path.split("/").filter(Boolean);
951
+ const pathSegs = pathname.split("/").filter(Boolean);
952
+ const params = matchSegments(routeSegs, pathSegs);
953
+ if (params)
954
+ return { route, params };
955
+ continue;
956
+ }
957
+ if (route.path === pathname)
958
+ return { route, params: {} };
959
+ }
960
+ return null;
961
+ }
962
+ function validateRawRoutes(rawRoutes) {
963
+ for (const route of rawRoutes ?? [])
964
+ parseTrailingWildcard(route.path);
965
+ }
966
+
665
967
  // src/server/create.ts
666
968
  var BODY_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
667
969
  function createHandler(config) {
@@ -854,8 +1156,16 @@ function createHandler(config) {
854
1156
  const { method, pathParams, groupHooks } = match;
855
1157
  const ctx = buildBaseContext(req, url, pathParams, traceId, clientIp);
856
1158
  setRequestEndpoint(method.serviceName, method.key);
1159
+ let multipartLifecycle;
857
1160
  try {
858
- await parseRequestInto(ctx, req, url, method, config.maxUploadBytes, config.maxJsonBodyBytes);
1161
+ parsePathParamsInto(ctx, method);
1162
+ if (hooks?.authorize) {
1163
+ await hooks.authorize(ctx, method);
1164
+ }
1165
+ if (groupHooks?.authorize) {
1166
+ await groupHooks.authorize(ctx, method);
1167
+ }
1168
+ multipartLifecycle = await parseRequestPayloadInto(ctx, req, url, method, config.maxJsonBodyBytes);
859
1169
  if (hooks?.beforeHandle) {
860
1170
  await hooks.beforeHandle(ctx, method);
861
1171
  }
@@ -914,6 +1224,7 @@ function createHandler(config) {
914
1224
  complete(responseStatus);
915
1225
  return body;
916
1226
  } catch (err) {
1227
+ await multipartLifecycle?.rollback();
917
1228
  return respondError(err, ctx, method);
918
1229
  }
919
1230
  }
@@ -1012,12 +1323,42 @@ function json(data, status, cors, req) {
1012
1323
  }
1013
1324
 
1014
1325
  // src/server/implement.ts
1326
+ function isStreamingImplementation(value) {
1327
+ return typeof value === "object" && value !== null && "kind" in value && value.kind === "stitchkit.multipart.stream";
1328
+ }
1329
+ function defineMultipartStream(endpoint, config) {
1330
+ const receivers = {};
1331
+ for (const [key, receiver] of typedEntries(config.files)) {
1332
+ receivers[String(key)] = receiver;
1333
+ }
1334
+ const declared = Object.keys(endpoint.multipart.files);
1335
+ const configured = Object.keys(receivers);
1336
+ if (declared.length !== configured.length || declared.some((field) => !Object.hasOwn(receivers, field))) {
1337
+ throw new Error("Streaming multipart receivers must exactly match declared file fields");
1338
+ }
1339
+ return {
1340
+ kind: "stitchkit.multipart.stream",
1341
+ receivers,
1342
+ execute(ctx, files) {
1343
+ return callRuntimeHandler(config.handler, { ...ctx, files });
1344
+ }
1345
+ };
1346
+ }
1015
1347
  var HTTP_ONLY = Object.freeze(["HTTP"]);
1016
1348
  function implement(contract, handlers) {
1017
1349
  const methods = {};
1018
1350
  const groupScope = contract.meta.scope ?? "public";
1019
1351
  for (const [key, endpoint] of typedEntries(contract.endpoints)) {
1020
1352
  const typedHandler = handlers[key];
1353
+ const isStreaming = endpoint.multipart?.delivery === "stream";
1354
+ if (!isStreaming && typeof typedHandler !== "function") {
1355
+ throw new Error(`[stitchkit] implement: missing handler for "${contract.meta.prefix}.${String(key)}"`);
1356
+ }
1357
+ if (isStreaming && !isStreamingImplementation(typedHandler)) {
1358
+ throw new Error(`[stitchkit] implement: streaming multipart endpoint "${contract.meta.prefix}.${String(key)}" must use defineMultipartStream()`);
1359
+ }
1360
+ const streamingHandler = isStreamingImplementation(typedHandler) ? typedHandler : undefined;
1361
+ const regularHandler = typeof typedHandler === "function" ? typedHandler : undefined;
1021
1362
  methods[String(key)] = {
1022
1363
  method: endpoint.method,
1023
1364
  path: endpoint.path,
@@ -1031,7 +1372,7 @@ function implement(contract, handlers) {
1031
1372
  inputSchema: endpoint.input,
1032
1373
  outputSchema: endpoint.output,
1033
1374
  multipart: endpoint.multipart,
1034
- maxUploadBytes: endpoint.maxUploadBytes,
1375
+ multipartReceivers: streamingHandler?.receivers,
1035
1376
  maxJsonBodyBytes: endpoint.maxJsonBodyBytes,
1036
1377
  idempotent: endpoint.idempotent,
1037
1378
  ui: "ui" in endpoint ? endpoint.ui : undefined,
@@ -1042,7 +1383,12 @@ function implement(contract, handlers) {
1042
1383
  rawBody: endpoint.rawBody,
1043
1384
  responseMeta: endpoint.responseMeta,
1044
1385
  contentType: "contentType" in endpoint ? endpoint.contentType : undefined,
1045
- handler: (ctx) => typedHandler(ctx)
1386
+ handler: streamingHandler ? (ctx) => streamingHandler.execute(ctx, ctx.files ?? {}) : (ctx) => {
1387
+ if (!regularHandler) {
1388
+ throw new Error(`[stitchkit] implement: missing handler for "${contract.meta.prefix}.${String(key)}"`);
1389
+ }
1390
+ return callRuntimeHandler(regularHandler, ctx);
1391
+ }
1046
1392
  };
1047
1393
  }
1048
1394
  return {
@@ -1056,9 +1402,25 @@ function createImplement() {
1056
1402
  return (contract, handlers) => implement(contract, handlers);
1057
1403
  }
1058
1404
 
1059
- // src/realtime/socket.ts
1405
+ // src/realtime/rejection.ts
1060
1406
  import { z } from "zod";
1061
- var AcknowledgementCallbackSchema = z.function();
1407
+ function realtimeContractViolation(options) {
1408
+ const issues = options.cause instanceof z.ZodError ? zodIssues(options.cause) : undefined;
1409
+ const reason = options.reason.replaceAll("-", " ");
1410
+ const error = new AppError("REALTIME_CONTRACT_VIOLATION", `Realtime event "${options.event}" (${options.direction}, ${options.phase}): ${reason}`, 500, {
1411
+ event: options.event,
1412
+ direction: options.direction,
1413
+ phase: options.phase,
1414
+ reason: options.reason,
1415
+ fault: options.fault,
1416
+ ...issues !== undefined && { issues }
1417
+ });
1418
+ if (options.cause !== undefined)
1419
+ error.cause = options.cause;
1420
+ return { ...options, error };
1421
+ }
1422
+
1423
+ // src/realtime/socket.ts
1062
1424
  function method(target, name) {
1063
1425
  const value = Reflect.get(target, name);
1064
1426
  if (typeof value !== "function") {
@@ -1077,15 +1439,26 @@ function splitWireArguments(definition, args) {
1077
1439
  acknowledgement: (...callbackArgs) => Reflect.apply(acknowledgement, undefined, callbackArgs)
1078
1440
  };
1079
1441
  }
1080
- function eventDefinition(registry, event) {
1442
+ function eventDefinition(registry, event, direction) {
1081
1443
  const definition = registry[event];
1082
- if (!definition)
1083
- throw new Error(`Unknown realtime event "${event}"`);
1444
+ if (!definition) {
1445
+ throw realtimeContractViolation({
1446
+ event,
1447
+ direction,
1448
+ phase: "arguments",
1449
+ reason: "unknown-event",
1450
+ fault: "local"
1451
+ }).error;
1452
+ }
1084
1453
  return definition;
1085
1454
  }
1086
- function reportRejected(rejected, hook) {
1455
+ function reportRejected(rejected, hook, logger) {
1087
1456
  if (!hook) {
1088
- console.error(`[stitchkit] rejected realtime ${rejected.direction} event "${rejected.event}" (${rejected.phase})`, rejected.error);
1457
+ if (logger) {
1458
+ logger.warn(rejected.error.message, rejected.error.details);
1459
+ return;
1460
+ }
1461
+ console.warn(`[stitchkit] rejected realtime ${rejected.direction} event "${rejected.event}" (${rejected.phase})`, rejected.error);
1089
1462
  return;
1090
1463
  }
1091
1464
  Promise.resolve(hook(rejected)).catch((error) => {
@@ -1099,36 +1472,35 @@ function createValidatedRealtimeSocket({
1099
1472
  inboundDirection,
1100
1473
  outboundDirection,
1101
1474
  onRejected,
1475
+ logger,
1102
1476
  subscribe
1103
1477
  }) {
1104
- const onTarget = method(target, "on");
1105
- const offTarget = subscribe ? undefined : method(target, "off");
1106
1478
  const emitTarget = method(target, "emit");
1107
1479
  return {
1108
1480
  on: (event, handler) => {
1109
- const definition = eventDefinition(inbound, event);
1481
+ const definition = eventDefinition(inbound, event, inboundDirection);
1110
1482
  const wrapped = (...wireArgs) => {
1111
1483
  const { values, acknowledgement } = splitWireArguments(definition, wireArgs);
1112
1484
  const parsed = definition.args.safeParse(values);
1113
1485
  if (!parsed.success) {
1114
- reportRejected({
1486
+ reportRejected(realtimeContractViolation({
1115
1487
  event,
1116
1488
  direction: inboundDirection,
1117
1489
  phase: "arguments",
1118
- error: parsed.error
1119
- }, onRejected);
1490
+ reason: "invalid-arguments",
1491
+ fault: "peer",
1492
+ cause: parsed.error
1493
+ }), onRejected, logger);
1120
1494
  return;
1121
1495
  }
1122
1496
  if (definition.ack && !acknowledgement) {
1123
- const callback = AcknowledgementCallbackSchema.safeParse(undefined);
1124
- if (!callback.success) {
1125
- reportRejected({
1126
- event,
1127
- direction: inboundDirection,
1128
- phase: "acknowledgement",
1129
- error: callback.error
1130
- }, onRejected);
1131
- }
1497
+ reportRejected(realtimeContractViolation({
1498
+ event,
1499
+ direction: inboundDirection,
1500
+ phase: "acknowledgement",
1501
+ reason: "missing-acknowledgement",
1502
+ fault: "peer"
1503
+ }), onRejected, logger);
1132
1504
  return;
1133
1505
  }
1134
1506
  const applicationArgs = [...parsed.data];
@@ -1137,12 +1509,14 @@ function createValidatedRealtimeSocket({
1137
1509
  const ack = definition.ack?.safeParse(value);
1138
1510
  if (!ack?.success) {
1139
1511
  if (ack) {
1140
- reportRejected({
1512
+ reportRejected(realtimeContractViolation({
1141
1513
  event,
1142
1514
  direction: outboundDirection,
1143
1515
  phase: "acknowledgement",
1144
- error: ack.error
1145
- }, onRejected);
1516
+ reason: "invalid-acknowledgement-value",
1517
+ fault: "local",
1518
+ cause: ack.error
1519
+ }), onRejected, logger);
1146
1520
  }
1147
1521
  return;
1148
1522
  }
@@ -1153,30 +1527,50 @@ function createValidatedRealtimeSocket({
1153
1527
  };
1154
1528
  if (subscribe)
1155
1529
  return subscribe(event, wrapped);
1530
+ const onTarget = method(target, "on");
1531
+ const offTarget = method(target, "off");
1156
1532
  onTarget(event, wrapped);
1157
1533
  return () => {
1158
- offTarget?.(event, wrapped);
1534
+ offTarget(event, wrapped);
1159
1535
  };
1160
1536
  },
1161
1537
  emit: (event, ...args) => {
1162
- const definition = eventDefinition(outbound, event);
1538
+ const definition = eventDefinition(outbound, event, outboundDirection);
1163
1539
  const { values, acknowledgement } = splitWireArguments(definition, args);
1164
- const parsed = definition.args.parse(values);
1540
+ const parsed = definition.args.safeParse(values);
1541
+ if (!parsed.success) {
1542
+ throw realtimeContractViolation({
1543
+ event,
1544
+ direction: outboundDirection,
1545
+ phase: "arguments",
1546
+ reason: "invalid-arguments",
1547
+ fault: "local",
1548
+ cause: parsed.error
1549
+ }).error;
1550
+ }
1165
1551
  if (definition.ack && !acknowledgement) {
1166
- AcknowledgementCallbackSchema.parse(undefined);
1552
+ throw realtimeContractViolation({
1553
+ event,
1554
+ direction: outboundDirection,
1555
+ phase: "acknowledgement",
1556
+ reason: "missing-acknowledgement",
1557
+ fault: "local"
1558
+ }).error;
1167
1559
  }
1168
- const wireArgs = [...parsed];
1560
+ const wireArgs = [...parsed.data];
1169
1561
  if (definition.ack && acknowledgement) {
1170
1562
  wireArgs.push((value) => {
1171
1563
  const ack = definition.ack?.safeParse(value);
1172
1564
  if (!ack?.success) {
1173
1565
  if (ack) {
1174
- reportRejected({
1566
+ reportRejected(realtimeContractViolation({
1175
1567
  event,
1176
1568
  direction: inboundDirection,
1177
1569
  phase: "acknowledgement",
1178
- error: ack.error
1179
- }, onRejected);
1570
+ reason: "invalid-acknowledgement-value",
1571
+ fault: "peer",
1572
+ cause: ack.error
1573
+ }), onRejected, logger);
1180
1574
  }
1181
1575
  return;
1182
1576
  }
@@ -1196,7 +1590,8 @@ function bindRealtimeServer(contract, handle, options = {}) {
1196
1590
  outbound: contract.serverToClient,
1197
1591
  inboundDirection: "server-inbound",
1198
1592
  outboundDirection: "server-outbound",
1199
- onRejected: options.onRejected
1593
+ onRejected: options.onRejected,
1594
+ logger: options.logger
1200
1595
  });
1201
1596
  const broadcast = outbound(handle.io);
1202
1597
  return {
@@ -1208,7 +1603,8 @@ function bindRealtimeServer(contract, handle, options = {}) {
1208
1603
  outbound: contract.serverToClient,
1209
1604
  inboundDirection: "server-inbound",
1210
1605
  outboundDirection: "server-outbound",
1211
- onRejected: options.onRejected
1606
+ onRejected: options.onRejected,
1607
+ logger: options.logger
1212
1608
  });
1213
1609
  handler({
1214
1610
  raw,
@@ -1372,4 +1768,4 @@ function socketIoLane(websocket) {
1372
1768
  });
1373
1769
  }
1374
1770
 
1375
- export { parseMultipart, mimeForPath, staticRoute, createHandler, implement, createImplement, bindRealtimeServer, webSocketLane, composeWebSocketHandlers, createSocketIOServer, socketIoLane };
1771
+ export { parseMultipart, createHandler, defineMultipartStream, implement, createImplement, bindRealtimeServer, webSocketLane, composeWebSocketHandlers, createSocketIOServer, socketIoLane };