stitchkit 0.1.0 → 0.3.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 +10 -7
- package/dist/browser/http.d.ts +5 -0
- package/dist/browser/http.d.ts.map +1 -1
- package/dist/contract/define.d.ts.map +1 -1
- package/dist/contract/errors.d.ts +4 -3
- package/dist/contract/errors.d.ts.map +1 -1
- package/dist/contract/index.js +1 -1
- package/dist/{index-gfzn1n4n.js → index-0t460v7k.js} +13 -5
- package/dist/{index-n7bmdwmz.js → index-4b1j82gp.js} +8 -4
- package/dist/{index-5sxnvwb1.js → index-4kz3aqpx.js} +2 -2
- package/dist/index-dpj1jcys.js +677 -0
- package/dist/{index-7wfkbvss.js → index-kckky6zw.js} +6 -2
- package/dist/index-kzfs85xp.js +9 -0
- package/dist/index-mwmpw6j1.js +60 -0
- package/dist/index-q6ja2qwq.js +7 -0
- package/dist/index.js +4 -3
- package/dist/internal/errors.d.ts +13 -1
- package/dist/internal/errors.d.ts.map +1 -1
- package/dist/internal/safe-json.d.ts +19 -0
- package/dist/internal/safe-json.d.ts.map +1 -0
- package/dist/internal/within-dir.d.ts +7 -0
- package/dist/internal/within-dir.d.ts.map +1 -0
- package/dist/node.d.ts +4 -0
- package/dist/node.d.ts.map +1 -0
- package/dist/node.js +29 -0
- package/dist/observability/context.d.ts +9 -1
- package/dist/observability/context.d.ts.map +1 -1
- package/dist/observability/index.js +35 -15
- package/dist/observability/sanitize.d.ts.map +1 -1
- package/dist/server/context.d.ts +3 -2
- package/dist/server/context.d.ts.map +1 -1
- package/dist/server/create.d.ts +3 -3
- package/dist/server/create.d.ts.map +1 -1
- package/dist/server/event-bus.d.ts +10 -1
- package/dist/server/event-bus.d.ts.map +1 -1
- package/dist/server/index.d.ts +2 -2
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +94 -606
- package/dist/server/logger.d.ts +5 -4
- package/dist/server/logger.d.ts.map +1 -1
- package/dist/server/middleware/auth.d.ts +24 -1
- package/dist/server/middleware/auth.d.ts.map +1 -1
- package/dist/server/middleware/cookies.d.ts.map +1 -1
- package/dist/server/middleware/cors.d.ts +7 -0
- package/dist/server/middleware/cors.d.ts.map +1 -1
- package/dist/server/multipart.d.ts +2 -1
- package/dist/server/multipart.d.ts.map +1 -1
- package/dist/server/node.d.ts +12 -0
- package/dist/server/node.d.ts.map +1 -0
- package/dist/server/rate-limit.d.ts +7 -2
- package/dist/server/rate-limit.d.ts.map +1 -1
- package/dist/server/request.d.ts +27 -4
- package/dist/server/request.d.ts.map +1 -1
- package/dist/server/router.d.ts +4 -2
- package/dist/server/router.d.ts.map +1 -1
- package/dist/server/stream.d.ts +3 -1
- package/dist/server/stream.d.ts.map +1 -1
- package/dist/server/types.d.ts +35 -20
- package/dist/server/types.d.ts.map +1 -1
- package/dist/tools/agent.d.ts +15 -2
- package/dist/tools/agent.d.ts.map +1 -1
- package/dist/tools/coerce.d.ts +12 -0
- package/dist/tools/coerce.d.ts.map +1 -0
- package/dist/tools/execute.d.ts +26 -2
- package/dist/tools/execute.d.ts.map +1 -1
- package/dist/tools/flatten.d.ts +15 -0
- package/dist/tools/flatten.d.ts.map +1 -0
- package/dist/tools/json-schema.d.ts +18 -0
- package/dist/tools/json-schema.d.ts.map +1 -0
- package/dist/tools/manifest.d.ts +17 -0
- package/dist/tools/manifest.d.ts.map +1 -0
- package/dist/tools/mcp-handler.d.ts.map +1 -1
- package/dist/tools/mcp.d.ts +53 -3
- package/dist/tools/mcp.d.ts.map +1 -1
- package/dist/tools/mount.d.ts +18 -6
- package/dist/tools/mount.d.ts.map +1 -1
- package/dist/tools/schema.d.ts +19 -2
- package/dist/tools/schema.d.ts.map +1 -1
- package/dist/tools/view-file.d.ts.map +1 -1
- package/dist/tools.d.ts +6 -2
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +415 -185
- package/package.json +15 -4
- package/dist/index-ke4mx4ea.js +0 -38
|
@@ -0,0 +1,677 @@
|
|
|
1
|
+
import {
|
|
2
|
+
isWithinDir
|
|
3
|
+
} from "./index-q6ja2qwq.js";
|
|
4
|
+
import {
|
|
5
|
+
normalizeError,
|
|
6
|
+
validateHandlerOutput
|
|
7
|
+
} from "./index-0t460v7k.js";
|
|
8
|
+
import {
|
|
9
|
+
AppError,
|
|
10
|
+
badRequest
|
|
11
|
+
} from "./index-kckky6zw.js";
|
|
12
|
+
import {
|
|
13
|
+
extractIp,
|
|
14
|
+
getClientInfo,
|
|
15
|
+
parseQueryParams,
|
|
16
|
+
resolveSocketIp,
|
|
17
|
+
resolveTraceId
|
|
18
|
+
} from "./index-mwmpw6j1.js";
|
|
19
|
+
import {
|
|
20
|
+
isUnsafeKey,
|
|
21
|
+
safeJsonParse
|
|
22
|
+
} from "./index-kzfs85xp.js";
|
|
23
|
+
|
|
24
|
+
// src/server/multipart.ts
|
|
25
|
+
var DEFAULT_MAX_UPLOAD_BYTES = 25 * 1024 * 1024;
|
|
26
|
+
async function readBodyCapped(req, maxBytes) {
|
|
27
|
+
const reader = req.body?.getReader();
|
|
28
|
+
if (!reader)
|
|
29
|
+
return new Uint8Array(0);
|
|
30
|
+
const chunks = [];
|
|
31
|
+
let total = 0;
|
|
32
|
+
try {
|
|
33
|
+
while (true) {
|
|
34
|
+
const { done, value } = await reader.read();
|
|
35
|
+
if (done)
|
|
36
|
+
break;
|
|
37
|
+
total += value.length;
|
|
38
|
+
if (total > maxBytes) {
|
|
39
|
+
await reader.cancel();
|
|
40
|
+
badRequest(`Upload exceeds the ${Math.round(maxBytes / 1024 / 1024)} MB limit`);
|
|
41
|
+
}
|
|
42
|
+
chunks.push(value);
|
|
43
|
+
}
|
|
44
|
+
} finally {
|
|
45
|
+
reader.releaseLock();
|
|
46
|
+
}
|
|
47
|
+
const buffer = new Uint8Array(total);
|
|
48
|
+
let offset = 0;
|
|
49
|
+
for (const chunk of chunks) {
|
|
50
|
+
buffer.set(chunk, offset);
|
|
51
|
+
offset += chunk.length;
|
|
52
|
+
}
|
|
53
|
+
return buffer;
|
|
54
|
+
}
|
|
55
|
+
async function parseMultipart(req, fileField, fieldsSchema, maxBytes = DEFAULT_MAX_UPLOAD_BYTES) {
|
|
56
|
+
const body = await readBodyCapped(req, maxBytes);
|
|
57
|
+
const contentType = req.headers.get("content-type") ?? "";
|
|
58
|
+
const formData = await new Response(body, {
|
|
59
|
+
headers: { "content-type": contentType }
|
|
60
|
+
}).formData();
|
|
61
|
+
const file = formData.get(fileField);
|
|
62
|
+
if (!file || !(file instanceof File)) {
|
|
63
|
+
badRequest(`Missing file field: ${fileField}`);
|
|
64
|
+
}
|
|
65
|
+
const fields = {};
|
|
66
|
+
for (const [key, value] of formData.entries()) {
|
|
67
|
+
if (key === fileField)
|
|
68
|
+
continue;
|
|
69
|
+
if (isUnsafeKey(key))
|
|
70
|
+
continue;
|
|
71
|
+
if (typeof value === "string") {
|
|
72
|
+
try {
|
|
73
|
+
fields[key] = safeJsonParse(value);
|
|
74
|
+
} catch {
|
|
75
|
+
fields[key] = value;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return { file, fields: fieldsSchema ? fieldsSchema.parse(fields) : fields };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// src/server/middleware/cors.ts
|
|
83
|
+
function assertCorsConfig(config) {
|
|
84
|
+
if (config.credentials && (config.origin === undefined || config.origin === "*")) {
|
|
85
|
+
throw new Error("[stitchkit] cors: `credentials: true` cannot be combined with a wildcard origin. " + "Set `origin` to an explicit string or list.");
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
function resolveOrigin(config, requestOrigin) {
|
|
89
|
+
if (config.origin === undefined || config.origin === "*") {
|
|
90
|
+
return "*";
|
|
91
|
+
}
|
|
92
|
+
if (Array.isArray(config.origin)) {
|
|
93
|
+
if (!requestOrigin)
|
|
94
|
+
return;
|
|
95
|
+
const lower = requestOrigin.toLowerCase();
|
|
96
|
+
return config.origin.some((o) => o.toLowerCase() === lower) ? requestOrigin : undefined;
|
|
97
|
+
}
|
|
98
|
+
return config.origin;
|
|
99
|
+
}
|
|
100
|
+
function corsHeaders(config, requestOrigin) {
|
|
101
|
+
const allowOrigin = resolveOrigin(config, requestOrigin);
|
|
102
|
+
const headers = {
|
|
103
|
+
"Access-Control-Allow-Methods": config.methods ?? "GET, POST, PUT, PATCH, DELETE, OPTIONS",
|
|
104
|
+
"Access-Control-Allow-Headers": config.headers ?? "Content-Type, Authorization, X-Trace-Id"
|
|
105
|
+
};
|
|
106
|
+
if (allowOrigin !== undefined) {
|
|
107
|
+
headers["Access-Control-Allow-Origin"] = allowOrigin;
|
|
108
|
+
if (config.credentials) {
|
|
109
|
+
headers["Access-Control-Allow-Credentials"] = "true";
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (Array.isArray(config.origin)) {
|
|
113
|
+
headers.Vary = "Origin";
|
|
114
|
+
}
|
|
115
|
+
return headers;
|
|
116
|
+
}
|
|
117
|
+
function corsPreflightResponse(config, req) {
|
|
118
|
+
return new Response(null, {
|
|
119
|
+
status: 204,
|
|
120
|
+
headers: corsHeaders(config, req.headers.get("origin"))
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// src/server/router.ts
|
|
125
|
+
import { readFile, stat } from "node:fs/promises";
|
|
126
|
+
import { extname, resolve } from "node:path";
|
|
127
|
+
function joinPath(...parts) {
|
|
128
|
+
const joined = parts.filter(Boolean).map((part) => part.replace(/^\/+|\/+$/g, "")).filter(Boolean).join("/");
|
|
129
|
+
return `/${joined}`;
|
|
130
|
+
}
|
|
131
|
+
function matchSegments(patternSegments, requestSegments) {
|
|
132
|
+
if (patternSegments.length !== requestSegments.length)
|
|
133
|
+
return null;
|
|
134
|
+
const params = {};
|
|
135
|
+
for (const [i, pattern] of patternSegments.entries()) {
|
|
136
|
+
const actual = requestSegments[i];
|
|
137
|
+
if (actual === undefined)
|
|
138
|
+
return null;
|
|
139
|
+
if (pattern.startsWith(":")) {
|
|
140
|
+
params[pattern.slice(1)] = decodeURIComponent(actual);
|
|
141
|
+
} else if (pattern !== actual) {
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return params;
|
|
146
|
+
}
|
|
147
|
+
function buildRouteMap(groups) {
|
|
148
|
+
const map = new Map;
|
|
149
|
+
for (const { prefix, service, hooks } of groups) {
|
|
150
|
+
for (const [, method] of Object.entries(service.methods)) {
|
|
151
|
+
if (method.expose && !method.expose.includes("HTTP"))
|
|
152
|
+
continue;
|
|
153
|
+
const servicePath = joinPath("/", service.prefix, method.path === "/" ? "" : method.path);
|
|
154
|
+
const fullPath = prefix ? joinPath(prefix, servicePath) : servicePath;
|
|
155
|
+
const segments = fullPath.split("/").filter(Boolean);
|
|
156
|
+
const entries = map.get(method.method) ?? [];
|
|
157
|
+
entries.push({ method, service, pattern: fullPath, segments, groupHooks: hooks });
|
|
158
|
+
map.set(method.method, entries);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
for (const [, entries] of map) {
|
|
162
|
+
entries.sort((a, b) => {
|
|
163
|
+
const len = Math.min(a.segments.length, b.segments.length);
|
|
164
|
+
for (let i = 0;i < len; i++) {
|
|
165
|
+
const aIsParam = a.segments[i]?.startsWith(":");
|
|
166
|
+
const bIsParam = b.segments[i]?.startsWith(":");
|
|
167
|
+
if (aIsParam !== bIsParam)
|
|
168
|
+
return aIsParam ? 1 : -1;
|
|
169
|
+
}
|
|
170
|
+
return a.segments.length - b.segments.length;
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
return map;
|
|
174
|
+
}
|
|
175
|
+
function matchRoute(routeMap, httpMethod, pathname) {
|
|
176
|
+
const entries = routeMap.get(httpMethod);
|
|
177
|
+
if (!entries)
|
|
178
|
+
return null;
|
|
179
|
+
const requestSegments = pathname.split("/").filter(Boolean);
|
|
180
|
+
for (const entry of entries) {
|
|
181
|
+
const pathParams = matchSegments(entry.segments, requestSegments);
|
|
182
|
+
if (pathParams) {
|
|
183
|
+
return {
|
|
184
|
+
method: entry.method,
|
|
185
|
+
service: entry.service,
|
|
186
|
+
pathParams,
|
|
187
|
+
groupHooks: entry.groupHooks
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
function allowedMethods(routeMap, pathname) {
|
|
194
|
+
const requestSegments = pathname.split("/").filter(Boolean);
|
|
195
|
+
const methods = [];
|
|
196
|
+
for (const [method, entries] of routeMap) {
|
|
197
|
+
for (const entry of entries) {
|
|
198
|
+
if (matchSegments(entry.segments, requestSegments)) {
|
|
199
|
+
methods.push(method);
|
|
200
|
+
break;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return methods;
|
|
205
|
+
}
|
|
206
|
+
function validateRoutes(routeMap) {
|
|
207
|
+
for (const [method, entries] of routeMap) {
|
|
208
|
+
const seen = new Map;
|
|
209
|
+
for (const entry of entries) {
|
|
210
|
+
const normalized = entry.segments.map((s) => s.startsWith(":") ? ":param" : s).join("/");
|
|
211
|
+
const key = `${method} /${normalized}`;
|
|
212
|
+
const existing = seen.get(key);
|
|
213
|
+
if (existing) {
|
|
214
|
+
throw new Error(`Duplicate route: ${method} ${entry.pattern} conflicts with ${existing}`);
|
|
215
|
+
}
|
|
216
|
+
seen.set(key, entry.pattern);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
function matchRawRoute(rawRoutes, httpMethod, pathname) {
|
|
221
|
+
for (const route of rawRoutes) {
|
|
222
|
+
if (route.method !== "ALL" && route.method !== httpMethod)
|
|
223
|
+
continue;
|
|
224
|
+
if (route.path.endsWith("/*")) {
|
|
225
|
+
const prefix = route.path.slice(0, -2);
|
|
226
|
+
if (pathname === prefix || pathname.startsWith(`${prefix}/`)) {
|
|
227
|
+
return { route, params: {} };
|
|
228
|
+
}
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
if (route.path.includes("/:")) {
|
|
232
|
+
const routeSegs = route.path.split("/").filter(Boolean);
|
|
233
|
+
const pathSegs = pathname.split("/").filter(Boolean);
|
|
234
|
+
const params = matchSegments(routeSegs, pathSegs);
|
|
235
|
+
if (params)
|
|
236
|
+
return { route, params };
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
if (route.path === pathname)
|
|
240
|
+
return { route, params: {} };
|
|
241
|
+
}
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
var STATIC_MIME = {
|
|
245
|
+
".html": "text/html; charset=utf-8",
|
|
246
|
+
".css": "text/css; charset=utf-8",
|
|
247
|
+
".js": "text/javascript; charset=utf-8",
|
|
248
|
+
".mjs": "text/javascript; charset=utf-8",
|
|
249
|
+
".json": "application/json; charset=utf-8",
|
|
250
|
+
".svg": "image/svg+xml",
|
|
251
|
+
".png": "image/png",
|
|
252
|
+
".jpg": "image/jpeg",
|
|
253
|
+
".jpeg": "image/jpeg",
|
|
254
|
+
".gif": "image/gif",
|
|
255
|
+
".webp": "image/webp",
|
|
256
|
+
".ico": "image/x-icon",
|
|
257
|
+
".woff2": "font/woff2",
|
|
258
|
+
".txt": "text/plain; charset=utf-8"
|
|
259
|
+
};
|
|
260
|
+
function staticRoute(prefix, dir) {
|
|
261
|
+
const cleanPrefix = prefix.replace(/\/+$/, "");
|
|
262
|
+
const root = resolve(dir.replace(/\/+$/, ""));
|
|
263
|
+
return {
|
|
264
|
+
method: "GET",
|
|
265
|
+
path: `${cleanPrefix}/*`,
|
|
266
|
+
handler: async (req) => {
|
|
267
|
+
const pathname = new URL(req.url).pathname;
|
|
268
|
+
let rel;
|
|
269
|
+
try {
|
|
270
|
+
rel = decodeURIComponent(pathname.slice(cleanPrefix.length)).replace(/^\/+/, "");
|
|
271
|
+
} catch {
|
|
272
|
+
return new Response("Bad request", { status: 400 });
|
|
273
|
+
}
|
|
274
|
+
const target = resolve(root, rel);
|
|
275
|
+
if (!isWithinDir(root, target)) {
|
|
276
|
+
return new Response("Forbidden", { status: 403 });
|
|
277
|
+
}
|
|
278
|
+
const info = await stat(target).catch(() => null);
|
|
279
|
+
if (!info?.isFile()) {
|
|
280
|
+
return new Response("Not found", { status: 404 });
|
|
281
|
+
}
|
|
282
|
+
const body = await readFile(target);
|
|
283
|
+
return new Response(body, {
|
|
284
|
+
headers: {
|
|
285
|
+
"Content-Type": STATIC_MIME[extname(target).toLowerCase()] ?? "application/octet-stream",
|
|
286
|
+
"X-Content-Type-Options": "nosniff"
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// src/server/context.ts
|
|
294
|
+
var RESERVED_KEYS = new Set([
|
|
295
|
+
"params",
|
|
296
|
+
"input",
|
|
297
|
+
"source",
|
|
298
|
+
"req",
|
|
299
|
+
"url",
|
|
300
|
+
"headers",
|
|
301
|
+
"traceId",
|
|
302
|
+
"spanId",
|
|
303
|
+
"ipAddress",
|
|
304
|
+
"userAgent"
|
|
305
|
+
]);
|
|
306
|
+
async function readJsonBody(req) {
|
|
307
|
+
const text = await req.text();
|
|
308
|
+
if (text.trim() === "")
|
|
309
|
+
return {};
|
|
310
|
+
if (!req.headers.get("content-type")?.includes("application/json")) {
|
|
311
|
+
badRequest("Request body must be application/json");
|
|
312
|
+
}
|
|
313
|
+
try {
|
|
314
|
+
return safeJsonParse(text);
|
|
315
|
+
} catch {
|
|
316
|
+
badRequest("Invalid JSON body");
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
async function buildContext(req, url, method, pathParams, traceId, clientIp) {
|
|
320
|
+
const parsedParams = method.paramsSchema ? method.paramsSchema.parse(pathParams) : undefined;
|
|
321
|
+
let parsedInput;
|
|
322
|
+
let file;
|
|
323
|
+
if (method.multipart) {
|
|
324
|
+
const multipart = await parseMultipart(req, method.multipart, method.inputSchema);
|
|
325
|
+
parsedInput = multipart.fields;
|
|
326
|
+
file = multipart.file;
|
|
327
|
+
} else if (method.inputSchema) {
|
|
328
|
+
if (req.method === "GET") {
|
|
329
|
+
parsedInput = method.inputSchema.parse(parseQueryParams(url));
|
|
330
|
+
} else if (req.method === "DELETE") {
|
|
331
|
+
const ct = req.headers.get("content-type");
|
|
332
|
+
if (ct?.includes("application/json")) {
|
|
333
|
+
parsedInput = method.inputSchema.parse(await readJsonBody(req));
|
|
334
|
+
} else {
|
|
335
|
+
parsedInput = method.inputSchema.parse(parseQueryParams(url));
|
|
336
|
+
}
|
|
337
|
+
} else {
|
|
338
|
+
parsedInput = method.inputSchema.parse(await readJsonBody(req));
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
const safePathParams = {};
|
|
342
|
+
for (const [k, v] of Object.entries(pathParams)) {
|
|
343
|
+
if (!RESERVED_KEYS.has(k) && !isUnsafeKey(k))
|
|
344
|
+
safePathParams[k] = v;
|
|
345
|
+
}
|
|
346
|
+
return {
|
|
347
|
+
params: parsedParams,
|
|
348
|
+
input: parsedInput,
|
|
349
|
+
...file && { file },
|
|
350
|
+
source: "http",
|
|
351
|
+
req,
|
|
352
|
+
url,
|
|
353
|
+
headers: req.headers,
|
|
354
|
+
...safePathParams,
|
|
355
|
+
traceId,
|
|
356
|
+
...getClientInfo(req, clientIp)
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
function buildErrorContext(req, url, traceId, clientIp) {
|
|
360
|
+
return {
|
|
361
|
+
params: undefined,
|
|
362
|
+
input: undefined,
|
|
363
|
+
source: "http",
|
|
364
|
+
req,
|
|
365
|
+
url,
|
|
366
|
+
headers: req.headers,
|
|
367
|
+
traceId,
|
|
368
|
+
...getClientInfo(req, clientIp)
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// src/server/logger.ts
|
|
373
|
+
var c = {
|
|
374
|
+
reset: "\x1B[0m",
|
|
375
|
+
dim: "\x1B[2m",
|
|
376
|
+
red: "\x1B[31m",
|
|
377
|
+
green: "\x1B[32m",
|
|
378
|
+
yellow: "\x1B[33m",
|
|
379
|
+
blue: "\x1B[34m",
|
|
380
|
+
magenta: "\x1B[35m",
|
|
381
|
+
cyan: "\x1B[36m",
|
|
382
|
+
gray: "\x1B[90m"
|
|
383
|
+
};
|
|
384
|
+
var METHOD_COLOR = {
|
|
385
|
+
GET: c.green,
|
|
386
|
+
POST: c.yellow,
|
|
387
|
+
PUT: c.blue,
|
|
388
|
+
PATCH: c.cyan,
|
|
389
|
+
DELETE: c.red,
|
|
390
|
+
OPTIONS: c.gray
|
|
391
|
+
};
|
|
392
|
+
var isProd = false;
|
|
393
|
+
function timestamp() {
|
|
394
|
+
const now = new Date;
|
|
395
|
+
const t = now.toLocaleTimeString("en-US", {
|
|
396
|
+
hour12: false,
|
|
397
|
+
hour: "2-digit",
|
|
398
|
+
minute: "2-digit",
|
|
399
|
+
second: "2-digit"
|
|
400
|
+
});
|
|
401
|
+
return `${t}.${now.getMilliseconds().toString().padStart(3, "0")}`;
|
|
402
|
+
}
|
|
403
|
+
function elapsedMs(startTime) {
|
|
404
|
+
return performance.now() - startTime;
|
|
405
|
+
}
|
|
406
|
+
function safePath(pathname) {
|
|
407
|
+
return pathname.replace(/[-]/g, "");
|
|
408
|
+
}
|
|
409
|
+
function levelForStatus(status) {
|
|
410
|
+
if (status >= 500)
|
|
411
|
+
return "error";
|
|
412
|
+
if (status >= 400)
|
|
413
|
+
return "warn";
|
|
414
|
+
return "info";
|
|
415
|
+
}
|
|
416
|
+
function buildLogFields(method, path, status, durationMs, traceId) {
|
|
417
|
+
return { traceId, method, path, status, durationMs };
|
|
418
|
+
}
|
|
419
|
+
function formatMs(ms) {
|
|
420
|
+
if (ms >= 1000)
|
|
421
|
+
return `${(ms / 1000).toFixed(2)}s`;
|
|
422
|
+
if (ms >= 1)
|
|
423
|
+
return `${Math.round(ms)}ms`;
|
|
424
|
+
return `${Math.round(ms * 1000)}µs`;
|
|
425
|
+
}
|
|
426
|
+
function durationColor(ms) {
|
|
427
|
+
if (ms >= 1000)
|
|
428
|
+
return c.red;
|
|
429
|
+
if (ms > 300)
|
|
430
|
+
return c.yellow;
|
|
431
|
+
return c.green;
|
|
432
|
+
}
|
|
433
|
+
function statusColor(status) {
|
|
434
|
+
if (status >= 500)
|
|
435
|
+
return c.red;
|
|
436
|
+
if (status >= 400)
|
|
437
|
+
return c.yellow;
|
|
438
|
+
if (status >= 300)
|
|
439
|
+
return c.cyan;
|
|
440
|
+
return c.green;
|
|
441
|
+
}
|
|
442
|
+
function ipLabel(ip) {
|
|
443
|
+
if (!ip || ip === "::1" || ip === "127.0.0.1")
|
|
444
|
+
return `${c.magenta}local${c.reset}`;
|
|
445
|
+
return `${c.dim}${ip}${c.reset}`;
|
|
446
|
+
}
|
|
447
|
+
var SKIP_PREFIXES = ["/_bun/", "/_bundle", "/favicon"];
|
|
448
|
+
function shouldLog(pathname, method) {
|
|
449
|
+
if (method === "OPTIONS")
|
|
450
|
+
return false;
|
|
451
|
+
return !SKIP_PREFIXES.some((prefix) => pathname.startsWith(prefix));
|
|
452
|
+
}
|
|
453
|
+
function logIncoming(req, pathname, traceId, ipAddress) {
|
|
454
|
+
const log = { traceId, startTime: performance.now() };
|
|
455
|
+
if (!isProd) {
|
|
456
|
+
const mc = METHOD_COLOR[req.method] ?? c.dim;
|
|
457
|
+
console.log(`${c.gray}[${timestamp()}]${c.reset} ${mc}${req.method}${c.reset} ${c.dim}${traceId}${c.reset} ${c.cyan}→${c.reset} ${safePath(pathname)} ${ipLabel(ipAddress ?? "")}`);
|
|
458
|
+
}
|
|
459
|
+
return log;
|
|
460
|
+
}
|
|
461
|
+
function logOutgoing(req, pathname, status, log, ipAddress) {
|
|
462
|
+
const ms = elapsedMs(log.startTime);
|
|
463
|
+
if (isProd) {
|
|
464
|
+
console.log(JSON.stringify({
|
|
465
|
+
ts: new Date().toISOString(),
|
|
466
|
+
level: levelForStatus(status),
|
|
467
|
+
msg: `${req.method} ${pathname} ${status}`,
|
|
468
|
+
...buildLogFields(req.method, pathname, status, Math.round(ms), log.traceId),
|
|
469
|
+
ip: ipAddress
|
|
470
|
+
}));
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
const mc = METHOD_COLOR[req.method] ?? c.dim;
|
|
474
|
+
console.log(`${c.gray}[${timestamp()}]${c.reset} ${mc}${req.method}${c.reset} ${c.dim}${log.traceId}${c.reset} ${c.cyan}←${c.reset} ${safePath(pathname)} ${statusColor(status)}${status}${c.reset} ${durationColor(ms)}${formatMs(ms)}${c.reset} ${ipLabel(ipAddress ?? "")}`);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// src/server/create.ts
|
|
478
|
+
function createHandler(config) {
|
|
479
|
+
const { cors, hooks, logging = false, trustProxy = false } = config;
|
|
480
|
+
if (cors)
|
|
481
|
+
assertCorsConfig(cors);
|
|
482
|
+
const customLogger = typeof logging === "object" ? logging : null;
|
|
483
|
+
const useDefaultLog = logging === true;
|
|
484
|
+
const resolveId = config.traceId ?? resolveTraceId;
|
|
485
|
+
const routeMap = buildRouteMap(normalizeGroups(config));
|
|
486
|
+
validateRoutes(routeMap);
|
|
487
|
+
async function dispatch(req, url, traceId, server, clientIp) {
|
|
488
|
+
const shouldLogRequest = logging && shouldLog(url.pathname, req.method);
|
|
489
|
+
const ipAddress = extractIp(req, clientIp) || undefined;
|
|
490
|
+
let reqLog;
|
|
491
|
+
if (shouldLogRequest && useDefaultLog) {
|
|
492
|
+
reqLog = logIncoming(req, url.pathname, traceId, ipAddress);
|
|
493
|
+
}
|
|
494
|
+
if (shouldLogRequest && customLogger) {
|
|
495
|
+
customLogger.debug(`${req.method} ${url.pathname}`, {
|
|
496
|
+
traceId,
|
|
497
|
+
method: req.method,
|
|
498
|
+
path: url.pathname,
|
|
499
|
+
ip: ipAddress
|
|
500
|
+
});
|
|
501
|
+
reqLog = { traceId, startTime: performance.now() };
|
|
502
|
+
}
|
|
503
|
+
const logDone = (status) => {
|
|
504
|
+
if (!reqLog)
|
|
505
|
+
return;
|
|
506
|
+
if (useDefaultLog)
|
|
507
|
+
logOutgoing(req, url.pathname, status, reqLog, ipAddress);
|
|
508
|
+
if (customLogger) {
|
|
509
|
+
const durationMs = Math.round(elapsedMs(reqLog.startTime));
|
|
510
|
+
const level = levelForStatus(status);
|
|
511
|
+
customLogger[level](`${req.method} ${url.pathname} ${status} ${durationMs}ms`, buildLogFields(req.method, url.pathname, status, durationMs, reqLog.traceId));
|
|
512
|
+
}
|
|
513
|
+
};
|
|
514
|
+
const respondError = async (err, errCtx, endpoint) => {
|
|
515
|
+
if (hooks?.onError) {
|
|
516
|
+
try {
|
|
517
|
+
const response = await hooks.onError(errCtx ?? buildErrorContext(req, url, traceId, clientIp), err, endpoint);
|
|
518
|
+
if (response instanceof Response) {
|
|
519
|
+
const withCors = applyCors(response, cors, req);
|
|
520
|
+
logDone(withCors.status);
|
|
521
|
+
return withCors;
|
|
522
|
+
}
|
|
523
|
+
} catch {}
|
|
524
|
+
}
|
|
525
|
+
const appErr = normalizeError(err);
|
|
526
|
+
logDone(appErr.status);
|
|
527
|
+
return json(appErr.toJSON(), appErr.status, cors, req);
|
|
528
|
+
};
|
|
529
|
+
if (cors && req.method === "OPTIONS") {
|
|
530
|
+
const res = corsPreflightResponse(cors, req);
|
|
531
|
+
logDone(204);
|
|
532
|
+
return res;
|
|
533
|
+
}
|
|
534
|
+
if (hooks?.onRequest) {
|
|
535
|
+
const earlyResponse = await hooks.onRequest(req);
|
|
536
|
+
if (earlyResponse instanceof Response) {
|
|
537
|
+
logDone(earlyResponse.status);
|
|
538
|
+
return earlyResponse;
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
if (config.rawRoutes) {
|
|
542
|
+
const rawMatch = matchRawRoute(config.rawRoutes, req.method, url.pathname);
|
|
543
|
+
if (rawMatch) {
|
|
544
|
+
try {
|
|
545
|
+
const res = await rawMatch.route.handler(req, {
|
|
546
|
+
params: rawMatch.params,
|
|
547
|
+
server,
|
|
548
|
+
ipAddress
|
|
549
|
+
});
|
|
550
|
+
const withCors = applyCors(res, cors, req);
|
|
551
|
+
logDone(withCors.status);
|
|
552
|
+
return withCors;
|
|
553
|
+
} catch (err) {
|
|
554
|
+
return respondError(err);
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
const match = matchRoute(routeMap, req.method, url.pathname);
|
|
559
|
+
if (!match) {
|
|
560
|
+
const allow = allowedMethods(routeMap, url.pathname);
|
|
561
|
+
if (allow.length > 0) {
|
|
562
|
+
const res = await respondError(new AppError("METHOD_NOT_ALLOWED", `Method ${req.method} not allowed`, 405));
|
|
563
|
+
res.headers.set("Allow", allow.join(", "));
|
|
564
|
+
return res;
|
|
565
|
+
}
|
|
566
|
+
return respondError(new AppError("NOT_FOUND", "Not found", 404));
|
|
567
|
+
}
|
|
568
|
+
const { method, pathParams, groupHooks } = match;
|
|
569
|
+
let ctx;
|
|
570
|
+
try {
|
|
571
|
+
ctx = await buildContext(req, url, method, pathParams, traceId, clientIp);
|
|
572
|
+
if (hooks?.beforeHandle) {
|
|
573
|
+
await hooks.beforeHandle(ctx, method);
|
|
574
|
+
}
|
|
575
|
+
if (groupHooks?.beforeHandle) {
|
|
576
|
+
await groupHooks.beforeHandle(ctx, method);
|
|
577
|
+
}
|
|
578
|
+
let result = await method.handler(ctx);
|
|
579
|
+
if (groupHooks?.afterHandle) {
|
|
580
|
+
const transformed = await groupHooks.afterHandle(ctx, result, method);
|
|
581
|
+
if (transformed !== undefined)
|
|
582
|
+
result = transformed;
|
|
583
|
+
}
|
|
584
|
+
if (hooks?.afterHandle) {
|
|
585
|
+
const transformed = await hooks.afterHandle(ctx, result, method);
|
|
586
|
+
if (transformed !== undefined)
|
|
587
|
+
result = transformed;
|
|
588
|
+
}
|
|
589
|
+
if (method.outputSchema) {
|
|
590
|
+
const checked = validateHandlerOutput(method.outputSchema, result);
|
|
591
|
+
if (!checked.ok) {
|
|
592
|
+
throw new AppError("INTERNAL_SERVER_ERROR", checked.message, 500);
|
|
593
|
+
}
|
|
594
|
+
result = checked.data;
|
|
595
|
+
}
|
|
596
|
+
if (result === undefined || result === null) {
|
|
597
|
+
logDone(204);
|
|
598
|
+
return new Response(null, { status: 204, headers: corsHeaders2(cors, req) });
|
|
599
|
+
}
|
|
600
|
+
logDone(200);
|
|
601
|
+
return json(result, 200, cors, req);
|
|
602
|
+
} catch (err) {
|
|
603
|
+
return respondError(err, ctx, method);
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
return async (req, server) => {
|
|
607
|
+
const url = new URL(req.url, "http://localhost");
|
|
608
|
+
const traceId = resolveId(req);
|
|
609
|
+
const clientIp = {
|
|
610
|
+
trustProxy,
|
|
611
|
+
socketIp: resolveSocketIp(req, server)
|
|
612
|
+
};
|
|
613
|
+
const response = await dispatch(req, url, traceId, server, clientIp);
|
|
614
|
+
try {
|
|
615
|
+
response.headers.set("x-request-id", traceId);
|
|
616
|
+
} catch {}
|
|
617
|
+
return response;
|
|
618
|
+
};
|
|
619
|
+
}
|
|
620
|
+
function createServer(config) {
|
|
621
|
+
const { routes, websocket, development, bun: bunExtra, port = 3000, hostname } = config;
|
|
622
|
+
const fetch = createHandler(config);
|
|
623
|
+
return websocket ? Bun.serve({
|
|
624
|
+
...bunExtra,
|
|
625
|
+
...routes && { routes },
|
|
626
|
+
...development && { development },
|
|
627
|
+
port,
|
|
628
|
+
hostname,
|
|
629
|
+
websocket,
|
|
630
|
+
fetch
|
|
631
|
+
}) : Bun.serve({
|
|
632
|
+
...bunExtra,
|
|
633
|
+
...development && { development },
|
|
634
|
+
port,
|
|
635
|
+
hostname,
|
|
636
|
+
fetch
|
|
637
|
+
});
|
|
638
|
+
}
|
|
639
|
+
function normalizeGroups(config) {
|
|
640
|
+
const result = [];
|
|
641
|
+
if (config.services) {
|
|
642
|
+
for (const service of config.services) {
|
|
643
|
+
result.push({ prefix: "", service });
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
if (config.groups) {
|
|
647
|
+
for (const group of config.groups) {
|
|
648
|
+
for (const service of group.services) {
|
|
649
|
+
result.push({ prefix: group.pathPrefix ?? "", service, hooks: group.hooks });
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
return result;
|
|
654
|
+
}
|
|
655
|
+
function corsHeaders2(cors, req) {
|
|
656
|
+
if (!cors)
|
|
657
|
+
return {};
|
|
658
|
+
return corsHeaders(cors, req.headers.get("origin"));
|
|
659
|
+
}
|
|
660
|
+
function applyCors(res, cors, req) {
|
|
661
|
+
const extra = corsHeaders2(cors, req);
|
|
662
|
+
if (Object.keys(extra).length === 0)
|
|
663
|
+
return res;
|
|
664
|
+
const headers = new Headers(res.headers);
|
|
665
|
+
for (const [key, value] of Object.entries(extra))
|
|
666
|
+
headers.set(key, value);
|
|
667
|
+
return new Response(res.body, {
|
|
668
|
+
status: res.status,
|
|
669
|
+
statusText: res.statusText,
|
|
670
|
+
headers
|
|
671
|
+
});
|
|
672
|
+
}
|
|
673
|
+
function json(data, status, cors, req) {
|
|
674
|
+
return Response.json(data, { status, headers: corsHeaders2(cors, req) });
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
export { parseMultipart, corsHeaders, corsPreflightResponse, staticRoute, createHandler, createServer };
|
|
@@ -3,11 +3,15 @@ var ALL_TRANSPORTS = ["HTTP", "MCP", "AGENT"];
|
|
|
3
3
|
function defineContract(meta, endpoints) {
|
|
4
4
|
const toolTransports = new Map;
|
|
5
5
|
for (const [key, ep] of Object.entries(endpoints)) {
|
|
6
|
+
if (ep.desc.trim() === "") {
|
|
7
|
+
throw new Error(`Contract "${meta.prefix}": endpoint "${key}" has an empty desc`);
|
|
8
|
+
}
|
|
6
9
|
if (!("toolName" in ep) || !ep.toolName)
|
|
7
10
|
continue;
|
|
8
11
|
const transports = new Set(ep.expose ? ep.expose.filter((t) => t !== "HTTP") : ["MCP", "AGENT"]);
|
|
9
|
-
if (transports.size === 0)
|
|
10
|
-
|
|
12
|
+
if (transports.size === 0) {
|
|
13
|
+
throw new Error(`Contract "${meta.prefix}": endpoint "${key}" sets toolName "${ep.toolName}" but is not exposed on any tool transport (MCP / AGENT)`);
|
|
14
|
+
}
|
|
11
15
|
const existing = toolTransports.get(ep.toolName);
|
|
12
16
|
if (existing) {
|
|
13
17
|
for (const t of transports) {
|