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.
- package/README.md +5 -4
- 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/browser/socket-io.d.ts +3 -1
- package/dist/browser/socket-io.d.ts.map +1 -1
- package/dist/cli.js +3 -4
- package/dist/contract/define.d.ts +59 -15
- package/dist/contract/define.d.ts.map +1 -1
- package/dist/contract/errors.d.ts +1 -0
- package/dist/contract/errors.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 +2 -2
- package/dist/{index-w0mec79h.js → index-44xysy8r.js} +697 -301
- package/dist/{index-r6czv7yt.js → index-45dz4m51.js} +50 -3
- package/dist/index-8ekq6res.js +181 -0
- package/dist/{index-yh0p5ddn.js → index-ee621cmy.js} +39 -19
- package/dist/{index-mvjyq9h9.js → index-kp8xamqp.js} +309 -144
- package/dist/{index-36f1xkdb.js → index-nrytvb30.js} +5 -4
- package/dist/{index-6jypn22c.js → index-r1qp4rve.js} +9 -1
- package/dist/{index-s4qsmgwe.js → index-tss6bk5c.js} +4 -1
- package/dist/index-yxpe3phd.js +63 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +315 -134
- package/dist/internal/errors.d.ts.map +1 -1
- package/dist/internal/secure-fetch.d.ts +23 -3
- package/dist/internal/secure-fetch.d.ts.map +1 -1
- package/dist/internal/typed.d.ts +7 -0
- package/dist/internal/typed.d.ts.map +1 -1
- package/dist/internal/within-dir.d.ts +6 -0
- package/dist/internal/within-dir.d.ts.map +1 -1
- package/dist/logger.d.ts +8 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/node.js +3 -4
- package/dist/observability/audit.d.ts +22 -3
- package/dist/observability/audit.d.ts.map +1 -1
- package/dist/observability/event.d.ts +7 -0
- package/dist/observability/event.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 +130 -134
- package/dist/observability/sanitize.d.ts +8 -1
- package/dist/observability/sanitize.d.ts.map +1 -1
- package/dist/observability/trace.d.ts +3 -1
- package/dist/observability/trace.d.ts.map +1 -1
- package/dist/react/cache-bridge.d.ts +4 -0
- package/dist/react/cache-bridge.d.ts.map +1 -1
- package/dist/react.js +46 -5
- package/dist/realtime/contract.d.ts +4 -1
- package/dist/realtime/contract.d.ts.map +1 -1
- package/dist/realtime/rejection.d.ts +10 -0
- package/dist/realtime/rejection.d.ts.map +1 -0
- package/dist/realtime/socket.d.ts +14 -1
- package/dist/realtime/socket.d.ts.map +1 -1
- package/dist/server/cache.d.ts +5 -1
- package/dist/server/cache.d.ts.map +1 -1
- 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/error-hook.d.ts +1 -0
- package/dist/server/error-hook.d.ts.map +1 -1
- package/dist/server/file.d.ts +6 -0
- package/dist/server/file.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 +5 -6
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +133 -14
- package/dist/server/middleware/auth.d.ts +7 -4
- package/dist/server/middleware/auth.d.ts.map +1 -1
- package/dist/server/middleware/cors.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/realtime.d.ts +2 -0
- package/dist/server/realtime.d.ts.map +1 -1
- package/dist/server/router.d.ts +0 -8
- package/dist/server/router.d.ts.map +1 -1
- package/dist/server/stream.d.ts.map +1 -1
- package/dist/server/types.d.ts +50 -22
- package/dist/server/types.d.ts.map +1 -1
- package/dist/tools/cli-args.d.ts +11 -3
- package/dist/tools/cli-args.d.ts.map +1 -1
- package/dist/tools/cli.d.ts +5 -0
- package/dist/tools/cli.d.ts.map +1 -1
- package/dist/tools/coerce.d.ts.map +1 -1
- package/dist/tools/json-schema.d.ts.map +1 -1
- package/dist/tools/mcp-handler.d.ts +4 -3
- package/dist/tools/mcp-handler.d.ts.map +1 -1
- package/dist/tools/mcp-prepare.d.ts +95 -0
- package/dist/tools/mcp-prepare.d.ts.map +1 -0
- package/dist/tools/mcp-round.d.ts +4 -0
- package/dist/tools/mcp-round.d.ts.map +1 -1
- package/dist/tools/mcp-stdio.d.ts +2 -1
- package/dist/tools/mcp-stdio.d.ts.map +1 -1
- package/dist/tools/mcp.d.ts +2 -78
- package/dist/tools/mcp.d.ts.map +1 -1
- package/dist/tools/mount-download.d.ts +5 -0
- package/dist/tools/mount-download.d.ts.map +1 -1
- package/dist/tools/native-mcp.d.ts +1 -1
- package/dist/tools/native-mcp.d.ts.map +1 -1
- package/dist/tools/oauth-provider.d.ts +20 -1
- package/dist/tools/oauth-provider.d.ts.map +1 -1
- package/dist/tools/tool-logger.d.ts +1 -1
- package/dist/tools/tool-logger.d.ts.map +1 -1
- package/dist/tools/view-file.d.ts +9 -0
- package/dist/tools/view-file.d.ts.map +1 -1
- package/dist/tools.d.ts +2 -1
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +505 -278
- package/llms-full.txt +473 -85
- package/llms.txt +1 -1
- package/package.json +2 -2
- package/dist/index-frfyw9fa.js +0 -33
- package/dist/index-x3fcszf8.js +0 -8
|
@@ -2,14 +2,12 @@ import {
|
|
|
2
2
|
assertCorsConfig,
|
|
3
3
|
corsHeaders,
|
|
4
4
|
corsPreflightResponse
|
|
5
|
-
} from "./index-
|
|
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
|
-
|
|
30
|
+
validateDeclaredOutput,
|
|
31
|
+
zodIssues
|
|
32
|
+
} from "./index-ee621cmy.js";
|
|
34
33
|
|
|
35
34
|
// src/server/multipart.ts
|
|
36
|
-
var
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
50
|
+
return index;
|
|
54
51
|
}
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
for (
|
|
61
|
-
|
|
62
|
-
|
|
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
|
|
61
|
+
return true;
|
|
65
62
|
}
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
70
|
-
|
|
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
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
|
144
|
-
for (const
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
|
|
155
|
-
|
|
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
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
|
|
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
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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
|
-
|
|
285
|
-
return { route, params: {} };
|
|
329
|
+
chunks.push(result.value);
|
|
286
330
|
}
|
|
287
|
-
|
|
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
|
|
290
|
-
|
|
291
|
-
parseTrailingWildcard(route.path);
|
|
339
|
+
function required(policy) {
|
|
340
|
+
return policy.required !== false;
|
|
292
341
|
}
|
|
293
|
-
function
|
|
294
|
-
const
|
|
295
|
-
const
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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
|
-
|
|
304
|
-
} catch {
|
|
305
|
-
|
|
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
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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
|
|
312
|
-
if (
|
|
313
|
-
|
|
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
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
418
|
-
|
|
419
|
-
}
|
|
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
|
-
|
|
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
|
-
|
|
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) =>
|
|
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/
|
|
1405
|
+
// src/realtime/rejection.ts
|
|
1060
1406
|
import { z } from "zod";
|
|
1061
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1119
|
-
|
|
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
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
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
|
-
|
|
1145
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
1179
|
-
|
|
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,
|
|
1771
|
+
export { parseMultipart, createHandler, defineMultipartStream, implement, createImplement, bindRealtimeServer, webSocketLane, composeWebSocketHandlers, createSocketIOServer, socketIoLane };
|