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.
- package/README.md +13 -3
- package/dist/browser/cancellation.d.ts +14 -0
- package/dist/browser/cancellation.d.ts.map +1 -0
- package/dist/browser/client-multipart.d.ts +3 -1
- package/dist/browser/client-multipart.d.ts.map +1 -1
- package/dist/browser/client.d.ts +1 -0
- package/dist/browser/client.d.ts.map +1 -1
- package/dist/browser/http.d.ts +1 -0
- package/dist/browser/http.d.ts.map +1 -1
- package/dist/cli.js +2 -2
- package/dist/contract/define.d.ts +67 -15
- package/dist/contract/define.d.ts.map +1 -1
- package/dist/contract/index.d.ts +1 -1
- package/dist/contract/index.d.ts.map +1 -1
- package/dist/contract/index.js +1 -1
- package/dist/{index-zwqty9zf.js → index-44xysy8r.js} +465 -49
- package/dist/{index-pwyedf7b.js → index-45dz4m51.js} +46 -0
- package/dist/{index-c40tkxcd.js → index-8ekq6res.js} +1 -1
- package/dist/{index-5s8b7z6q.js → index-ee621cmy.js} +9 -9
- package/dist/{index-62pqb23z.js → index-kp8xamqp.js} +1 -1
- package/dist/{index-w1s873ng.js → index-nrytvb30.js} +4 -3
- package/dist/index.js +195 -94
- package/dist/node.js +2 -2
- package/dist/observability/audit.d.ts +22 -3
- package/dist/observability/audit.d.ts.map +1 -1
- package/dist/observability/index.d.ts +1 -1
- package/dist/observability/index.d.ts.map +1 -1
- package/dist/observability/index.js +82 -15
- package/dist/server/context.d.ts +8 -5
- package/dist/server/context.d.ts.map +1 -1
- package/dist/server/create.d.ts.map +1 -1
- package/dist/server/implement.d.ts +33 -2
- package/dist/server/implement.d.ts.map +1 -1
- package/dist/server/index.d.ts +3 -3
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +28 -6
- package/dist/server/middleware/auth.d.ts +7 -4
- package/dist/server/middleware/auth.d.ts.map +1 -1
- package/dist/server/multipart.d.ts +13 -18
- package/dist/server/multipart.d.ts.map +1 -1
- package/dist/server/openapi.d.ts.map +1 -1
- package/dist/server/types.d.ts +48 -15
- package/dist/server/types.d.ts.map +1 -1
- package/dist/tools.js +171 -74
- package/llms-full.txt +411 -67
- package/package.json +1 -1
|
@@ -29,59 +29,431 @@ import {
|
|
|
29
29
|
typedEntries,
|
|
30
30
|
validateDeclaredOutput,
|
|
31
31
|
zodIssues
|
|
32
|
-
} from "./index-
|
|
32
|
+
} from "./index-ee621cmy.js";
|
|
33
33
|
|
|
34
34
|
// src/server/multipart.ts
|
|
35
|
-
var
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
|
45
|
-
if (
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
334
|
+
value.set(chunk, offset);
|
|
61
335
|
offset += chunk.length;
|
|
62
336
|
}
|
|
63
|
-
return
|
|
337
|
+
return decoder.decode(value);
|
|
64
338
|
}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
if (
|
|
82
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
}
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) =>
|
|
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)
|