stitchkit 0.46.0 → 0.48.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 (46) hide show
  1. package/README.md +13 -3
  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/cli.js +2 -2
  11. package/dist/contract/define.d.ts +67 -15
  12. package/dist/contract/define.d.ts.map +1 -1
  13. package/dist/contract/index.d.ts +1 -1
  14. package/dist/contract/index.d.ts.map +1 -1
  15. package/dist/contract/index.js +1 -1
  16. package/dist/{index-zwqty9zf.js → index-44xysy8r.js} +465 -49
  17. package/dist/{index-pwyedf7b.js → index-45dz4m51.js} +46 -0
  18. package/dist/{index-c40tkxcd.js → index-8ekq6res.js} +1 -1
  19. package/dist/{index-5s8b7z6q.js → index-ee621cmy.js} +9 -9
  20. package/dist/{index-62pqb23z.js → index-kp8xamqp.js} +1 -1
  21. package/dist/{index-w1s873ng.js → index-nrytvb30.js} +4 -3
  22. package/dist/index.js +195 -94
  23. package/dist/node.js +2 -2
  24. package/dist/observability/audit.d.ts +22 -3
  25. package/dist/observability/audit.d.ts.map +1 -1
  26. package/dist/observability/index.d.ts +1 -1
  27. package/dist/observability/index.d.ts.map +1 -1
  28. package/dist/observability/index.js +82 -15
  29. package/dist/server/context.d.ts +8 -5
  30. package/dist/server/context.d.ts.map +1 -1
  31. package/dist/server/create.d.ts.map +1 -1
  32. package/dist/server/implement.d.ts +33 -2
  33. package/dist/server/implement.d.ts.map +1 -1
  34. package/dist/server/index.d.ts +3 -3
  35. package/dist/server/index.d.ts.map +1 -1
  36. package/dist/server/index.js +28 -6
  37. package/dist/server/middleware/auth.d.ts +7 -4
  38. package/dist/server/middleware/auth.d.ts.map +1 -1
  39. package/dist/server/multipart.d.ts +13 -18
  40. package/dist/server/multipart.d.ts.map +1 -1
  41. package/dist/server/openapi.d.ts.map +1 -1
  42. package/dist/server/types.d.ts +48 -15
  43. package/dist/server/types.d.ts.map +1 -1
  44. package/dist/tools.js +171 -74
  45. package/llms-full.txt +411 -67
  46. package/package.json +1 -1
@@ -29,59 +29,431 @@ import {
29
29
  typedEntries,
30
30
  validateDeclaredOutput,
31
31
  zodIssues
32
- } from "./index-5s8b7z6q.js";
32
+ } from "./index-ee621cmy.js";
33
33
 
34
34
  // src/server/multipart.ts
35
- var DEFAULT_MAX_UPLOAD_BYTES = 25 * 1024 * 1024;
36
- async function readBodyCapped(req, maxBytes) {
37
- const reader = req.body?.getReader();
38
- if (!reader)
39
- return new Uint8Array(0);
40
- const chunks = [];
41
- let total = 0;
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;
49
+ }
50
+ return index;
51
+ }
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;
60
+ }
61
+ return true;
62
+ }
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) {
74
+ const contentType = req.headers.get("content-type") ?? "";
75
+ if (!contentType.toLowerCase().startsWith("multipart/form-data")) {
76
+ badRequest("Request body must be multipart/form-data");
77
+ }
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;
83
+ }
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 };
92
+ }
93
+ function parsePartHeaders(bytes) {
94
+ let text;
42
95
  try {
96
+ text = decoder.decode(bytes);
97
+ } catch {
98
+ badRequest("Invalid multipart headers");
99
+ }
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());
107
+ }
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");
115
+ }
116
+ return { ...disposition, contentType, declaredSize };
117
+ }
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");
142
+ }
143
+ this.#consume(this.#initialBoundary.length);
144
+ await this.#consumeBoundarySuffix();
145
+ }
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);
176
+ }
177
+ });
178
+ return { ...parsed, stream, consumed: () => ended };
179
+ }
180
+ cancel(reason) {
181
+ this.#reader.cancel(reason).catch(() => {});
182
+ }
183
+ release() {
184
+ this.#reader.releaseLock();
185
+ }
186
+ async#readPartChunk() {
43
187
  while (true) {
44
- const { done, value } = await reader.read();
45
- if (done)
46
- break;
47
- total += value.length;
48
- if (total > maxBytes) {
49
- await reader.cancel();
50
- badRequest(`Upload exceeds the ${Math.round(maxBytes / 1024 / 1024)} MB limit`);
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 };
51
195
  }
52
- chunks.push(value);
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 };
202
+ }
203
+ if (this.#sourceDone)
204
+ badRequest("Incomplete multipart body");
205
+ await this.#readMore();
53
206
  }
54
- } finally {
55
- reader.releaseLock();
56
207
  }
57
- const buffer = new Uint8Array(total);
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;
230
+ }
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();
236
+ }
237
+ }
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;
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);
259
+ }
260
+ }
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);
300
+ }
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`);
328
+ }
329
+ chunks.push(result.value);
330
+ }
331
+ const value = new Uint8Array(total);
58
332
  let offset = 0;
59
333
  for (const chunk of chunks) {
60
- buffer.set(chunk, offset);
334
+ value.set(chunk, offset);
61
335
  offset += chunk.length;
62
336
  }
63
- return buffer;
337
+ return decoder.decode(value);
64
338
  }
65
- async function parseMultipart(req, fileField, fieldsSchema, maxBytes = DEFAULT_MAX_UPLOAD_BYTES) {
66
- const body = await readBodyCapped(req, maxBytes);
67
- const contentType = req.headers.get("content-type") ?? "";
68
- const formData = await new Response(body, {
69
- headers: { "content-type": contentType }
70
- }).formData();
71
- const file = formData.get(fileField);
72
- if (!file || !(file instanceof File)) {
73
- badRequest(`Missing file field: ${fileField}`);
339
+ function required(policy) {
340
+ return policy.required !== false;
341
+ }
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`);
74
347
  }
348
+ const boundary = parseBoundary(req);
349
+ const parser = new MultipartStreamReader(req, boundary, maxRequestBytes);
75
350
  const fields = {};
76
- for (const [key, value] of formData.entries()) {
77
- if (key === fileField)
78
- continue;
79
- if (isUnsafeKey(key))
80
- continue;
81
- if (typeof value === "string")
82
- fields[key] = value;
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) {
360
+ try {
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;
387
+ }
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}"`);
398
+ }
399
+ const maxFileBytes = policy.maxBytes ?? maxRequestBytes;
400
+ if (part.declaredSize !== undefined && part.declaredSize > maxFileBytes) {
401
+ badRequest(`Multipart field "${part.name}" exceeds ${maxFileBytes} bytes`);
402
+ }
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`);
423
+ }
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
+ }
437
+ }
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();
83
456
  }
84
- return { file, fields: fieldsSchema ? fieldsSchema.parse(fields) : fields };
85
457
  }
86
458
 
87
459
  // src/server/request-body.ts
@@ -167,17 +539,19 @@ function buildBaseContext(req, url, pathParams, traceId, clientIp) {
167
539
  ...getClientInfo(req, clientIp)
168
540
  };
169
541
  }
170
- async function parseRequestInto(ctx, req, url, method, maxUploadBytes, maxJsonBodyBytes) {
542
+ function parsePathParamsInto(ctx, method) {
171
543
  if (method.paramsSchema) {
172
544
  ctx.params = method.paramsSchema.parse(ctx.params);
173
545
  }
546
+ }
547
+ async function parseRequestPayloadInto(ctx, req, url, method, maxJsonBodyBytes) {
174
548
  if (method.multipart) {
175
- const cap = method.maxUploadBytes ?? maxUploadBytes;
176
- const multipart = await parseMultipart(req, method.multipart, method.inputSchema, cap);
549
+ const multipart = await parseMultipart(req, method.multipart, method.inputSchema, method.multipartReceivers);
177
550
  ctx.input = multipart.fields;
178
- if (multipart.file)
179
- ctx.file = multipart.file;
180
- } else if (method.inputSchema) {
551
+ ctx.files = multipart.files;
552
+ return multipart;
553
+ }
554
+ if (method.inputSchema) {
181
555
  if (req.method === "GET") {
182
556
  ctx.input = method.inputSchema.parse(parseQueryParams(url));
183
557
  } else if (req.method === "DELETE") {
@@ -197,6 +571,7 @@ async function parseRequestInto(ctx, req, url, method, maxUploadBytes, maxJsonBo
197
571
  ctx.input = method.inputSchema.parse(parseJsonBody(req, text));
198
572
  }
199
573
  }
574
+ return;
200
575
  }
201
576
  function buildErrorContext(req, url, traceId, clientIp) {
202
577
  return {
@@ -781,8 +1156,16 @@ function createHandler(config) {
781
1156
  const { method, pathParams, groupHooks } = match;
782
1157
  const ctx = buildBaseContext(req, url, pathParams, traceId, clientIp);
783
1158
  setRequestEndpoint(method.serviceName, method.key);
1159
+ let multipartLifecycle;
784
1160
  try {
785
- 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);
786
1169
  if (hooks?.beforeHandle) {
787
1170
  await hooks.beforeHandle(ctx, method);
788
1171
  }
@@ -841,6 +1224,7 @@ function createHandler(config) {
841
1224
  complete(responseStatus);
842
1225
  return body;
843
1226
  } catch (err) {
1227
+ await multipartLifecycle?.rollback();
844
1228
  return respondError(err, ctx, method);
845
1229
  }
846
1230
  }
@@ -939,15 +1323,42 @@ function json(data, status, cors, req) {
939
1323
  }
940
1324
 
941
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
+ }
942
1347
  var HTTP_ONLY = Object.freeze(["HTTP"]);
943
1348
  function implement(contract, handlers) {
944
1349
  const methods = {};
945
1350
  const groupScope = contract.meta.scope ?? "public";
946
1351
  for (const [key, endpoint] of typedEntries(contract.endpoints)) {
947
1352
  const typedHandler = handlers[key];
948
- if (typeof typedHandler !== "function") {
1353
+ const isStreaming = endpoint.multipart?.delivery === "stream";
1354
+ if (!isStreaming && typeof typedHandler !== "function") {
949
1355
  throw new Error(`[stitchkit] implement: missing handler for "${contract.meta.prefix}.${String(key)}"`);
950
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;
951
1362
  methods[String(key)] = {
952
1363
  method: endpoint.method,
953
1364
  path: endpoint.path,
@@ -961,7 +1372,7 @@ function implement(contract, handlers) {
961
1372
  inputSchema: endpoint.input,
962
1373
  outputSchema: endpoint.output,
963
1374
  multipart: endpoint.multipart,
964
- maxUploadBytes: endpoint.maxUploadBytes,
1375
+ multipartReceivers: streamingHandler?.receivers,
965
1376
  maxJsonBodyBytes: endpoint.maxJsonBodyBytes,
966
1377
  idempotent: endpoint.idempotent,
967
1378
  ui: "ui" in endpoint ? endpoint.ui : undefined,
@@ -972,7 +1383,12 @@ function implement(contract, handlers) {
972
1383
  rawBody: endpoint.rawBody,
973
1384
  responseMeta: endpoint.responseMeta,
974
1385
  contentType: "contentType" in endpoint ? endpoint.contentType : undefined,
975
- handler: (ctx) => callRuntimeHandler(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
+ }
976
1392
  };
977
1393
  }
978
1394
  return {
@@ -1352,4 +1768,4 @@ function socketIoLane(websocket) {
1352
1768
  });
1353
1769
  }
1354
1770
 
1355
- export { parseMultipart, createHandler, implement, createImplement, bindRealtimeServer, webSocketLane, composeWebSocketHandlers, createSocketIOServer, socketIoLane };
1771
+ export { parseMultipart, createHandler, defineMultipartStream, implement, createImplement, bindRealtimeServer, webSocketLane, composeWebSocketHandlers, createSocketIOServer, socketIoLane };
@@ -47,6 +47,11 @@ function parseTrailingWildcard(path) {
47
47
  return wildcard;
48
48
  }
49
49
 
50
+ // src/internal/safe-json.ts
51
+ function isUnsafeKey(key) {
52
+ return key === "__proto__";
53
+ }
54
+
50
55
  // src/contract/define.ts
51
56
  var ALL_TRANSPORTS = ["HTTP", "MCP", "AGENT", "CLI"];
52
57
  function defineContract(meta, endpoints) {
@@ -68,6 +73,8 @@ function defineContract(meta, endpoints) {
68
73
  if (ep.maxJsonBodyBytes !== undefined && (!Number.isSafeInteger(ep.maxJsonBodyBytes) || ep.maxJsonBodyBytes <= 0)) {
69
74
  throw new Error(`Contract "${meta.prefix}": endpoint "${key}" maxJsonBodyBytes must be a positive safe integer, received ${ep.maxJsonBodyBytes}`);
70
75
  }
76
+ if (ep.multipart)
77
+ assertMultipartEndpoint(meta.prefix, key, ep);
71
78
  if (ep.rawResponse)
72
79
  assertRawEndpoint(meta.prefix, key, ep);
73
80
  if (ep.method === "HEAD")
@@ -96,6 +103,45 @@ function defineContract(meta, endpoints) {
96
103
  }
97
104
  return { meta, endpoints };
98
105
  }
106
+ function assertPositiveLimit(where, name, value) {
107
+ if (value !== undefined && (!Number.isSafeInteger(value) || value <= 0)) {
108
+ throw new Error(`${where} ${name} must be a positive safe integer, received ${value}`);
109
+ }
110
+ }
111
+ function assertMultipartEndpoint(prefix, key, ep) {
112
+ const where = `Contract "${prefix}": multipart endpoint "${key}"`;
113
+ if (ep.method !== "POST" && ep.method !== "PUT" && ep.method !== "PATCH") {
114
+ throw new Error(`${where} must use POST, PUT or PATCH`);
115
+ }
116
+ const multipart = ep.multipart;
117
+ if (!multipart || typeof multipart !== "object") {
118
+ throw new Error(`${where} must declare a multipart descriptor`);
119
+ }
120
+ assertPositiveLimit(where, "maxRequestBytes", multipart.maxRequestBytes);
121
+ assertPositiveLimit(where, "maxFieldBytes", multipart.maxFieldBytes);
122
+ const entries = Object.entries(multipart.files);
123
+ if (entries.length === 0)
124
+ throw new Error(`${where} must declare at least one file field`);
125
+ for (const [field, policy] of entries) {
126
+ if (!field || isUnsafeKey(field))
127
+ throw new Error(`${where} has an invalid file field name`);
128
+ assertPositiveLimit(`${where} field "${field}"`, "maxBytes", policy.maxBytes);
129
+ assertPositiveLimit(`${where} field "${field}"`, "maxFiles", policy.maxFiles);
130
+ if (policy.multiple !== true && policy.maxFiles !== undefined) {
131
+ throw new Error(`${where} field "${field}" may set maxFiles only with multiple: true`);
132
+ }
133
+ if (policy.contentTypes) {
134
+ if (policy.contentTypes.length === 0) {
135
+ throw new Error(`${where} field "${field}" contentTypes cannot be empty`);
136
+ }
137
+ for (const contentType of policy.contentTypes) {
138
+ if (!/^[a-z0-9!#$&^_.+-]+\/(?:[a-z0-9!#$&^_.+-]+|\*)$/i.test(contentType)) {
139
+ throw new Error(`${where} field "${field}" has invalid content type policy "${contentType}"`);
140
+ }
141
+ }
142
+ }
143
+ }
144
+ }
99
145
  function assertRawEndpoint(prefix, key, ep) {
100
146
  const where = `Contract "${prefix}": raw endpoint "${key}"`;
101
147
  if (ep.output)
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  isRecord,
3
3
  isUnsafeKey
4
- } from "./index-5s8b7z6q.js";
4
+ } from "./index-ee621cmy.js";
5
5
 
6
6
  // src/observability/sanitize.ts
7
7
  var SENSITIVE_WORDS = new Set([