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
package/dist/server/index.js
CHANGED
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
import {
|
|
2
2
|
parseSSE,
|
|
3
3
|
streamSSE
|
|
4
|
-
} from "../index-
|
|
4
|
+
} from "../index-4b1j82gp.js";
|
|
5
5
|
import {
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
corsHeaders,
|
|
7
|
+
corsPreflightResponse,
|
|
8
|
+
createHandler,
|
|
9
|
+
createServer,
|
|
10
|
+
parseMultipart,
|
|
11
|
+
staticRoute
|
|
12
|
+
} from "../index-dpj1jcys.js";
|
|
13
|
+
import"../index-q6ja2qwq.js";
|
|
14
|
+
import"../index-0t460v7k.js";
|
|
8
15
|
import {
|
|
9
16
|
AppError,
|
|
10
17
|
appError,
|
|
@@ -14,14 +21,21 @@ import {
|
|
|
14
21
|
notFound,
|
|
15
22
|
rateLimited,
|
|
16
23
|
unauthorized
|
|
17
|
-
} from "../index-
|
|
24
|
+
} from "../index-kckky6zw.js";
|
|
18
25
|
import {
|
|
19
26
|
extractIp,
|
|
20
27
|
generateTraceId,
|
|
21
28
|
getClientInfo,
|
|
22
|
-
|
|
29
|
+
resolveSocketIp,
|
|
23
30
|
resolveTraceId
|
|
24
|
-
} from "../index-
|
|
31
|
+
} from "../index-mwmpw6j1.js";
|
|
32
|
+
import {
|
|
33
|
+
isUnsafeKey,
|
|
34
|
+
safeJsonParse
|
|
35
|
+
} from "../index-kzfs85xp.js";
|
|
36
|
+
import {
|
|
37
|
+
isRecord
|
|
38
|
+
} from "../index-809wc1tt.js";
|
|
25
39
|
// src/server/swept-map.ts
|
|
26
40
|
function createSweptMap(options) {
|
|
27
41
|
const store = new Map;
|
|
@@ -79,579 +93,8 @@ function cacheHeaders(maxAge, scope = "public") {
|
|
|
79
93
|
"Cache-Control": `${scope}, max-age=${maxAge}`
|
|
80
94
|
};
|
|
81
95
|
}
|
|
82
|
-
// src/server/multipart.ts
|
|
83
|
-
var DEFAULT_MAX_UPLOAD_BYTES = 25 * 1024 * 1024;
|
|
84
|
-
async function parseMultipart(req, fileField, fieldsSchema, maxBytes = DEFAULT_MAX_UPLOAD_BYTES) {
|
|
85
|
-
const declared = Number(req.headers.get("content-length") ?? 0);
|
|
86
|
-
if (declared > maxBytes) {
|
|
87
|
-
badRequest(`Upload exceeds the ${Math.round(maxBytes / 1024 / 1024)} MB limit`);
|
|
88
|
-
}
|
|
89
|
-
const formData = await req.formData();
|
|
90
|
-
const file = formData.get(fileField);
|
|
91
|
-
if (!file || !(file instanceof File)) {
|
|
92
|
-
badRequest(`Missing file field: ${fileField}`);
|
|
93
|
-
}
|
|
94
|
-
if (file.size > maxBytes) {
|
|
95
|
-
badRequest(`Upload exceeds the ${Math.round(maxBytes / 1024 / 1024)} MB limit`);
|
|
96
|
-
}
|
|
97
|
-
const fields = {};
|
|
98
|
-
for (const [key, value] of formData.entries()) {
|
|
99
|
-
if (key === fileField)
|
|
100
|
-
continue;
|
|
101
|
-
if (typeof value === "string") {
|
|
102
|
-
try {
|
|
103
|
-
fields[key] = JSON.parse(value);
|
|
104
|
-
} catch {
|
|
105
|
-
fields[key] = value;
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
const parsed = fieldsSchema ? fieldsSchema.parse(fields) : fields;
|
|
110
|
-
return { file, fields: parsed };
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
// src/server/context.ts
|
|
114
|
-
var RESERVED_KEYS = new Set([
|
|
115
|
-
"params",
|
|
116
|
-
"input",
|
|
117
|
-
"source",
|
|
118
|
-
"req",
|
|
119
|
-
"url",
|
|
120
|
-
"headers",
|
|
121
|
-
"traceId",
|
|
122
|
-
"spanId",
|
|
123
|
-
"ipAddress",
|
|
124
|
-
"userAgent"
|
|
125
|
-
]);
|
|
126
|
-
async function readJsonBody(req) {
|
|
127
|
-
const text = await req.text();
|
|
128
|
-
if (text.trim() === "")
|
|
129
|
-
return {};
|
|
130
|
-
try {
|
|
131
|
-
return JSON.parse(text);
|
|
132
|
-
} catch {
|
|
133
|
-
badRequest("Invalid JSON body");
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
async function buildContext(req, url, method, pathParams, traceId) {
|
|
137
|
-
const parsedParams = method.paramsSchema ? method.paramsSchema.parse(pathParams) : undefined;
|
|
138
|
-
let parsedInput;
|
|
139
|
-
let file;
|
|
140
|
-
if (method.multipart) {
|
|
141
|
-
const multipart = await parseMultipart(req, method.multipart, method.inputSchema);
|
|
142
|
-
parsedInput = multipart.fields;
|
|
143
|
-
file = multipart.file;
|
|
144
|
-
} else if (method.inputSchema) {
|
|
145
|
-
if (req.method === "GET") {
|
|
146
|
-
parsedInput = method.inputSchema.parse(parseQueryParams(url));
|
|
147
|
-
} else if (req.method === "DELETE") {
|
|
148
|
-
const ct = req.headers.get("content-type");
|
|
149
|
-
if (ct?.includes("application/json")) {
|
|
150
|
-
parsedInput = method.inputSchema.parse(await readJsonBody(req));
|
|
151
|
-
} else {
|
|
152
|
-
parsedInput = method.inputSchema.parse(parseQueryParams(url));
|
|
153
|
-
}
|
|
154
|
-
} else {
|
|
155
|
-
parsedInput = method.inputSchema.parse(await readJsonBody(req));
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
const safePathParams = {};
|
|
159
|
-
for (const [k, v] of Object.entries(pathParams)) {
|
|
160
|
-
if (!RESERVED_KEYS.has(k))
|
|
161
|
-
safePathParams[k] = v;
|
|
162
|
-
}
|
|
163
|
-
return {
|
|
164
|
-
params: parsedParams,
|
|
165
|
-
input: parsedInput,
|
|
166
|
-
...file && { file },
|
|
167
|
-
source: "http",
|
|
168
|
-
req,
|
|
169
|
-
url,
|
|
170
|
-
headers: req.headers,
|
|
171
|
-
...safePathParams,
|
|
172
|
-
traceId,
|
|
173
|
-
...getClientInfo(req)
|
|
174
|
-
};
|
|
175
|
-
}
|
|
176
|
-
function buildErrorContext(req, url, traceId) {
|
|
177
|
-
return {
|
|
178
|
-
params: undefined,
|
|
179
|
-
input: undefined,
|
|
180
|
-
source: "http",
|
|
181
|
-
req,
|
|
182
|
-
url,
|
|
183
|
-
headers: req.headers,
|
|
184
|
-
traceId,
|
|
185
|
-
...getClientInfo(req)
|
|
186
|
-
};
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
// src/server/logger.ts
|
|
190
|
-
var c = {
|
|
191
|
-
reset: "\x1B[0m",
|
|
192
|
-
dim: "\x1B[2m",
|
|
193
|
-
red: "\x1B[31m",
|
|
194
|
-
green: "\x1B[32m",
|
|
195
|
-
yellow: "\x1B[33m",
|
|
196
|
-
blue: "\x1B[34m",
|
|
197
|
-
magenta: "\x1B[35m",
|
|
198
|
-
cyan: "\x1B[36m",
|
|
199
|
-
gray: "\x1B[90m"
|
|
200
|
-
};
|
|
201
|
-
var METHOD_COLOR = {
|
|
202
|
-
GET: c.green,
|
|
203
|
-
POST: c.yellow,
|
|
204
|
-
PUT: c.blue,
|
|
205
|
-
PATCH: c.cyan,
|
|
206
|
-
DELETE: c.red,
|
|
207
|
-
OPTIONS: c.gray
|
|
208
|
-
};
|
|
209
|
-
var isProd = false;
|
|
210
|
-
function timestamp() {
|
|
211
|
-
const now = new Date;
|
|
212
|
-
const t = now.toLocaleTimeString("en-US", {
|
|
213
|
-
hour12: false,
|
|
214
|
-
hour: "2-digit",
|
|
215
|
-
minute: "2-digit",
|
|
216
|
-
second: "2-digit"
|
|
217
|
-
});
|
|
218
|
-
return `${t}.${now.getMilliseconds().toString().padStart(3, "0")}`;
|
|
219
|
-
}
|
|
220
|
-
function elapsedMs(startTime) {
|
|
221
|
-
return Number(process.hrtime.bigint() - startTime) / 1e6;
|
|
222
|
-
}
|
|
223
|
-
function levelForStatus(status) {
|
|
224
|
-
if (status >= 500)
|
|
225
|
-
return "error";
|
|
226
|
-
if (status >= 400)
|
|
227
|
-
return "warn";
|
|
228
|
-
return "info";
|
|
229
|
-
}
|
|
230
|
-
function buildLogFields(method, path, status, durationMs, traceId) {
|
|
231
|
-
return { traceId, method, path, status, durationMs };
|
|
232
|
-
}
|
|
233
|
-
function formatMs(ms) {
|
|
234
|
-
if (ms >= 1000)
|
|
235
|
-
return `${(ms / 1000).toFixed(2)}s`;
|
|
236
|
-
if (ms >= 1)
|
|
237
|
-
return `${Math.round(ms)}ms`;
|
|
238
|
-
return `${Math.round(ms * 1000)}µs`;
|
|
239
|
-
}
|
|
240
|
-
function durationColor(ms) {
|
|
241
|
-
if (ms >= 1000)
|
|
242
|
-
return c.red;
|
|
243
|
-
if (ms > 300)
|
|
244
|
-
return c.yellow;
|
|
245
|
-
return c.green;
|
|
246
|
-
}
|
|
247
|
-
function statusColor(status) {
|
|
248
|
-
if (status >= 500)
|
|
249
|
-
return c.red;
|
|
250
|
-
if (status >= 400)
|
|
251
|
-
return c.yellow;
|
|
252
|
-
if (status >= 300)
|
|
253
|
-
return c.cyan;
|
|
254
|
-
return c.green;
|
|
255
|
-
}
|
|
256
|
-
function ipLabel(ip) {
|
|
257
|
-
if (!ip || ip === "::1" || ip === "127.0.0.1")
|
|
258
|
-
return `${c.magenta}local${c.reset}`;
|
|
259
|
-
return `${c.dim}${ip}${c.reset}`;
|
|
260
|
-
}
|
|
261
|
-
var SKIP_PREFIXES = ["/_bun/", "/_bundle", "/favicon"];
|
|
262
|
-
function shouldLog(pathname, method) {
|
|
263
|
-
if (method === "OPTIONS")
|
|
264
|
-
return false;
|
|
265
|
-
return !SKIP_PREFIXES.some((prefix) => pathname.startsWith(prefix));
|
|
266
|
-
}
|
|
267
|
-
function logIncoming(req, pathname, traceId) {
|
|
268
|
-
const log = { traceId, startTime: process.hrtime.bigint() };
|
|
269
|
-
if (!isProd) {
|
|
270
|
-
const mc = METHOD_COLOR[req.method] ?? c.dim;
|
|
271
|
-
console.log(`${c.gray}[${timestamp()}]${c.reset} ${mc}${req.method}${c.reset} ${c.dim}${traceId}${c.reset} ${c.cyan}→${c.reset} ${pathname} ${ipLabel(extractIp(req))}`);
|
|
272
|
-
}
|
|
273
|
-
return log;
|
|
274
|
-
}
|
|
275
|
-
function logOutgoing(req, pathname, status, log) {
|
|
276
|
-
const ms = elapsedMs(log.startTime);
|
|
277
|
-
if (isProd) {
|
|
278
|
-
console.log(JSON.stringify({
|
|
279
|
-
ts: new Date().toISOString(),
|
|
280
|
-
level: levelForStatus(status),
|
|
281
|
-
msg: `${req.method} ${pathname} ${status}`,
|
|
282
|
-
...buildLogFields(req.method, pathname, status, Math.round(ms), log.traceId),
|
|
283
|
-
ip: extractIp(req) || undefined
|
|
284
|
-
}));
|
|
285
|
-
return;
|
|
286
|
-
}
|
|
287
|
-
const mc = METHOD_COLOR[req.method] ?? c.dim;
|
|
288
|
-
console.log(`${c.gray}[${timestamp()}]${c.reset} ${mc}${req.method}${c.reset} ${c.dim}${log.traceId}${c.reset} ${c.cyan}←${c.reset} ${pathname} ${statusColor(status)}${status}${c.reset} ${durationColor(ms)}${formatMs(ms)}${c.reset} ${ipLabel(extractIp(req))}`);
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
// src/server/middleware/cors.ts
|
|
292
|
-
function corsHeaders(config, requestOrigin) {
|
|
293
|
-
let allowOrigin;
|
|
294
|
-
if (config.origin === undefined || config.origin === "*") {
|
|
295
|
-
allowOrigin = config.credentials ? requestOrigin ?? undefined : "*";
|
|
296
|
-
} else if (Array.isArray(config.origin)) {
|
|
297
|
-
allowOrigin = requestOrigin && config.origin.includes(requestOrigin) ? requestOrigin : undefined;
|
|
298
|
-
} else {
|
|
299
|
-
allowOrigin = config.origin;
|
|
300
|
-
}
|
|
301
|
-
const headers = {
|
|
302
|
-
"Access-Control-Allow-Methods": config.methods ?? "GET, POST, PUT, PATCH, DELETE, OPTIONS",
|
|
303
|
-
"Access-Control-Allow-Headers": config.headers ?? "Content-Type, Authorization, X-Trace-Id"
|
|
304
|
-
};
|
|
305
|
-
if (allowOrigin !== undefined) {
|
|
306
|
-
headers["Access-Control-Allow-Origin"] = allowOrigin;
|
|
307
|
-
}
|
|
308
|
-
if (config.credentials) {
|
|
309
|
-
headers["Access-Control-Allow-Credentials"] = "true";
|
|
310
|
-
}
|
|
311
|
-
const variesByOrigin = Array.isArray(config.origin) || (config.origin === undefined || config.origin === "*") && Boolean(config.credentials);
|
|
312
|
-
if (variesByOrigin) {
|
|
313
|
-
headers.Vary = "Origin";
|
|
314
|
-
}
|
|
315
|
-
return headers;
|
|
316
|
-
}
|
|
317
|
-
function corsPreflightResponse(config, req) {
|
|
318
|
-
return new Response(null, {
|
|
319
|
-
status: 204,
|
|
320
|
-
headers: corsHeaders(config, req.headers.get("origin"))
|
|
321
|
-
});
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
// src/server/router.ts
|
|
325
|
-
import { resolve, sep } from "node:path";
|
|
326
|
-
function joinPath(...parts) {
|
|
327
|
-
const joined = parts.filter(Boolean).map((part) => part.replace(/^\/+|\/+$/g, "")).filter(Boolean).join("/");
|
|
328
|
-
return `/${joined}`;
|
|
329
|
-
}
|
|
330
|
-
function matchSegments(patternSegments, requestSegments) {
|
|
331
|
-
if (patternSegments.length !== requestSegments.length)
|
|
332
|
-
return null;
|
|
333
|
-
const params = {};
|
|
334
|
-
for (const [i, pattern] of patternSegments.entries()) {
|
|
335
|
-
const actual = requestSegments[i];
|
|
336
|
-
if (actual === undefined)
|
|
337
|
-
return null;
|
|
338
|
-
if (pattern.startsWith(":")) {
|
|
339
|
-
params[pattern.slice(1)] = decodeURIComponent(actual);
|
|
340
|
-
} else if (pattern !== actual) {
|
|
341
|
-
return null;
|
|
342
|
-
}
|
|
343
|
-
}
|
|
344
|
-
return params;
|
|
345
|
-
}
|
|
346
|
-
function buildRouteMap(groups) {
|
|
347
|
-
const map = new Map;
|
|
348
|
-
for (const { prefix, service, hooks } of groups) {
|
|
349
|
-
for (const [, method] of Object.entries(service.methods)) {
|
|
350
|
-
if (method.expose && !method.expose.includes("HTTP"))
|
|
351
|
-
continue;
|
|
352
|
-
const servicePath = joinPath("/", service.prefix, method.path === "/" ? "" : method.path);
|
|
353
|
-
const fullPath = prefix ? joinPath(prefix, servicePath) : servicePath;
|
|
354
|
-
const segments = fullPath.split("/").filter(Boolean);
|
|
355
|
-
const entries = map.get(method.method) ?? [];
|
|
356
|
-
entries.push({ method, service, pattern: fullPath, segments, groupHooks: hooks });
|
|
357
|
-
map.set(method.method, entries);
|
|
358
|
-
}
|
|
359
|
-
}
|
|
360
|
-
for (const [, entries] of map) {
|
|
361
|
-
entries.sort((a, b) => {
|
|
362
|
-
const len = Math.min(a.segments.length, b.segments.length);
|
|
363
|
-
for (let i = 0;i < len; i++) {
|
|
364
|
-
const aIsParam = a.segments[i]?.startsWith(":");
|
|
365
|
-
const bIsParam = b.segments[i]?.startsWith(":");
|
|
366
|
-
if (aIsParam !== bIsParam)
|
|
367
|
-
return aIsParam ? 1 : -1;
|
|
368
|
-
}
|
|
369
|
-
return a.segments.length - b.segments.length;
|
|
370
|
-
});
|
|
371
|
-
}
|
|
372
|
-
return map;
|
|
373
|
-
}
|
|
374
|
-
function matchRoute(routeMap, httpMethod, pathname) {
|
|
375
|
-
const entries = routeMap.get(httpMethod);
|
|
376
|
-
if (!entries)
|
|
377
|
-
return null;
|
|
378
|
-
const requestSegments = pathname.split("/").filter(Boolean);
|
|
379
|
-
for (const entry of entries) {
|
|
380
|
-
const pathParams = matchSegments(entry.segments, requestSegments);
|
|
381
|
-
if (pathParams) {
|
|
382
|
-
return {
|
|
383
|
-
method: entry.method,
|
|
384
|
-
service: entry.service,
|
|
385
|
-
pathParams,
|
|
386
|
-
groupHooks: entry.groupHooks
|
|
387
|
-
};
|
|
388
|
-
}
|
|
389
|
-
}
|
|
390
|
-
return null;
|
|
391
|
-
}
|
|
392
|
-
function allowedMethods(routeMap, pathname) {
|
|
393
|
-
const requestSegments = pathname.split("/").filter(Boolean);
|
|
394
|
-
const methods = [];
|
|
395
|
-
for (const [method, entries] of routeMap) {
|
|
396
|
-
for (const entry of entries) {
|
|
397
|
-
if (matchSegments(entry.segments, requestSegments)) {
|
|
398
|
-
methods.push(method);
|
|
399
|
-
break;
|
|
400
|
-
}
|
|
401
|
-
}
|
|
402
|
-
}
|
|
403
|
-
return methods;
|
|
404
|
-
}
|
|
405
|
-
function validateRoutes(routeMap) {
|
|
406
|
-
for (const [method, entries] of routeMap) {
|
|
407
|
-
const seen = new Map;
|
|
408
|
-
for (const entry of entries) {
|
|
409
|
-
const normalized = entry.segments.map((s) => s.startsWith(":") ? ":param" : s).join("/");
|
|
410
|
-
const key = `${method} /${normalized}`;
|
|
411
|
-
const existing = seen.get(key);
|
|
412
|
-
if (existing) {
|
|
413
|
-
throw new Error(`Duplicate route: ${method} ${entry.pattern} conflicts with ${existing}`);
|
|
414
|
-
}
|
|
415
|
-
seen.set(key, entry.pattern);
|
|
416
|
-
}
|
|
417
|
-
}
|
|
418
|
-
}
|
|
419
|
-
function matchRawRoute(rawRoutes, httpMethod, pathname) {
|
|
420
|
-
for (const route of rawRoutes) {
|
|
421
|
-
if (route.method !== "ALL" && route.method !== httpMethod)
|
|
422
|
-
continue;
|
|
423
|
-
if (route.path.endsWith("/*")) {
|
|
424
|
-
const prefix = route.path.slice(0, -2);
|
|
425
|
-
if (pathname === prefix || pathname.startsWith(`${prefix}/`)) {
|
|
426
|
-
return { route, params: {} };
|
|
427
|
-
}
|
|
428
|
-
continue;
|
|
429
|
-
}
|
|
430
|
-
if (route.path.includes("/:")) {
|
|
431
|
-
const routeSegs = route.path.split("/").filter(Boolean);
|
|
432
|
-
const pathSegs = pathname.split("/").filter(Boolean);
|
|
433
|
-
const params = matchSegments(routeSegs, pathSegs);
|
|
434
|
-
if (params)
|
|
435
|
-
return { route, params };
|
|
436
|
-
continue;
|
|
437
|
-
}
|
|
438
|
-
if (route.path === pathname)
|
|
439
|
-
return { route, params: {} };
|
|
440
|
-
}
|
|
441
|
-
return null;
|
|
442
|
-
}
|
|
443
|
-
function staticRoute(prefix, dir) {
|
|
444
|
-
const cleanPrefix = prefix.replace(/\/+$/, "");
|
|
445
|
-
const cleanDir = dir.replace(/\/+$/, "");
|
|
446
|
-
return {
|
|
447
|
-
method: "GET",
|
|
448
|
-
path: `${cleanPrefix}/*`,
|
|
449
|
-
handler: async (req) => {
|
|
450
|
-
const pathname = new URL(req.url).pathname;
|
|
451
|
-
const rel = pathname.slice(cleanPrefix.length).replace(/^\/+/, "");
|
|
452
|
-
const root = resolve(cleanDir);
|
|
453
|
-
const target = resolve(root, rel);
|
|
454
|
-
if (target !== root && !target.startsWith(root + sep)) {
|
|
455
|
-
return new Response("Forbidden", { status: 403 });
|
|
456
|
-
}
|
|
457
|
-
const file = Bun.file(target);
|
|
458
|
-
if (!await file.exists()) {
|
|
459
|
-
return new Response("Not found", { status: 404 });
|
|
460
|
-
}
|
|
461
|
-
return new Response(file);
|
|
462
|
-
}
|
|
463
|
-
};
|
|
464
|
-
}
|
|
465
|
-
|
|
466
|
-
// src/server/create.ts
|
|
467
|
-
function createHandler(config) {
|
|
468
|
-
const { cors, hooks, logging = false } = config;
|
|
469
|
-
const customLogger = typeof logging === "object" ? logging : null;
|
|
470
|
-
const useDefaultLog = logging === true;
|
|
471
|
-
const resolveId = config.traceId ?? resolveTraceId;
|
|
472
|
-
const routeMap = buildRouteMap(normalizeGroups(config));
|
|
473
|
-
validateRoutes(routeMap);
|
|
474
|
-
async function dispatch(req, url, traceId, server) {
|
|
475
|
-
const shouldLogRequest = logging && shouldLog(url.pathname, req.method);
|
|
476
|
-
let reqLog;
|
|
477
|
-
if (shouldLogRequest && useDefaultLog) {
|
|
478
|
-
reqLog = logIncoming(req, url.pathname, traceId);
|
|
479
|
-
}
|
|
480
|
-
if (shouldLogRequest && customLogger) {
|
|
481
|
-
customLogger.debug(`${req.method} ${url.pathname}`, {
|
|
482
|
-
traceId,
|
|
483
|
-
method: req.method,
|
|
484
|
-
path: url.pathname,
|
|
485
|
-
ip: extractIp(req) || undefined
|
|
486
|
-
});
|
|
487
|
-
reqLog = { traceId, startTime: process.hrtime.bigint() };
|
|
488
|
-
}
|
|
489
|
-
const logDone = (status) => {
|
|
490
|
-
if (!reqLog)
|
|
491
|
-
return;
|
|
492
|
-
if (useDefaultLog)
|
|
493
|
-
logOutgoing(req, url.pathname, status, reqLog);
|
|
494
|
-
if (customLogger) {
|
|
495
|
-
const durationMs = Math.round(elapsedMs(reqLog.startTime));
|
|
496
|
-
const level = levelForStatus(status);
|
|
497
|
-
customLogger[level](`${req.method} ${url.pathname} ${status} ${durationMs}ms`, buildLogFields(req.method, url.pathname, status, durationMs, reqLog.traceId));
|
|
498
|
-
}
|
|
499
|
-
};
|
|
500
|
-
const respondError = async (err, errCtx, endpoint) => {
|
|
501
|
-
if (hooks?.onError) {
|
|
502
|
-
try {
|
|
503
|
-
const response = await hooks.onError(errCtx ?? buildErrorContext(req, url, traceId), err, endpoint);
|
|
504
|
-
if (response instanceof Response) {
|
|
505
|
-
const withCors = applyCors(response, cors, req);
|
|
506
|
-
logDone(withCors.status);
|
|
507
|
-
return withCors;
|
|
508
|
-
}
|
|
509
|
-
} catch {}
|
|
510
|
-
}
|
|
511
|
-
const appErr = normalizeError(err);
|
|
512
|
-
logDone(appErr.status);
|
|
513
|
-
return json(appErr.toJSON(), appErr.status, cors, req);
|
|
514
|
-
};
|
|
515
|
-
if (cors && req.method === "OPTIONS") {
|
|
516
|
-
const res = corsPreflightResponse(cors, req);
|
|
517
|
-
logDone(204);
|
|
518
|
-
return res;
|
|
519
|
-
}
|
|
520
|
-
if (hooks?.onRequest) {
|
|
521
|
-
const earlyResponse = await hooks.onRequest(req);
|
|
522
|
-
if (earlyResponse instanceof Response) {
|
|
523
|
-
logDone(earlyResponse.status);
|
|
524
|
-
return earlyResponse;
|
|
525
|
-
}
|
|
526
|
-
}
|
|
527
|
-
if (config.rawRoutes) {
|
|
528
|
-
const rawMatch = matchRawRoute(config.rawRoutes, req.method, url.pathname);
|
|
529
|
-
if (rawMatch) {
|
|
530
|
-
try {
|
|
531
|
-
const res = await rawMatch.route.handler(req, {
|
|
532
|
-
params: rawMatch.params,
|
|
533
|
-
server
|
|
534
|
-
});
|
|
535
|
-
const withCors = applyCors(res, cors, req);
|
|
536
|
-
logDone(withCors.status);
|
|
537
|
-
return withCors;
|
|
538
|
-
} catch (err) {
|
|
539
|
-
return respondError(err);
|
|
540
|
-
}
|
|
541
|
-
}
|
|
542
|
-
}
|
|
543
|
-
const match = matchRoute(routeMap, req.method, url.pathname);
|
|
544
|
-
if (!match) {
|
|
545
|
-
const allow = allowedMethods(routeMap, url.pathname);
|
|
546
|
-
if (allow.length > 0) {
|
|
547
|
-
const res = await respondError(new AppError("METHOD_NOT_ALLOWED", `Method ${req.method} not allowed`, 405));
|
|
548
|
-
res.headers.set("Allow", allow.join(", "));
|
|
549
|
-
return res;
|
|
550
|
-
}
|
|
551
|
-
return respondError(new AppError("NOT_FOUND", "Not found", 404));
|
|
552
|
-
}
|
|
553
|
-
const { method, pathParams, groupHooks } = match;
|
|
554
|
-
let ctx;
|
|
555
|
-
try {
|
|
556
|
-
ctx = await buildContext(req, url, method, pathParams, traceId);
|
|
557
|
-
if (hooks?.beforeHandle) {
|
|
558
|
-
await hooks.beforeHandle(ctx, method);
|
|
559
|
-
}
|
|
560
|
-
if (groupHooks?.beforeHandle) {
|
|
561
|
-
await groupHooks.beforeHandle(ctx, method);
|
|
562
|
-
}
|
|
563
|
-
let result = await method.handler(ctx);
|
|
564
|
-
if (groupHooks?.afterHandle) {
|
|
565
|
-
const transformed = await groupHooks.afterHandle(ctx, result, method);
|
|
566
|
-
if (transformed !== undefined)
|
|
567
|
-
result = transformed;
|
|
568
|
-
}
|
|
569
|
-
if (hooks?.afterHandle) {
|
|
570
|
-
const transformed = await hooks.afterHandle(ctx, result, method);
|
|
571
|
-
if (transformed !== undefined)
|
|
572
|
-
result = transformed;
|
|
573
|
-
}
|
|
574
|
-
if (method.outputSchema) {
|
|
575
|
-
result = method.outputSchema.parse(result);
|
|
576
|
-
}
|
|
577
|
-
if (result === undefined || result === null) {
|
|
578
|
-
logDone(204);
|
|
579
|
-
return new Response(null, { status: 204, headers: corsHeaders2(cors, req) });
|
|
580
|
-
}
|
|
581
|
-
logDone(200);
|
|
582
|
-
return json(result, 200, cors, req);
|
|
583
|
-
} catch (err) {
|
|
584
|
-
return respondError(err, ctx, method);
|
|
585
|
-
}
|
|
586
|
-
}
|
|
587
|
-
return async (req, server) => {
|
|
588
|
-
const url = new URL(req.url);
|
|
589
|
-
const traceId = resolveId(req);
|
|
590
|
-
const response = await dispatch(req, url, traceId, server);
|
|
591
|
-
if (!response.headers.has("x-request-id")) {
|
|
592
|
-
response.headers.set("x-request-id", traceId);
|
|
593
|
-
}
|
|
594
|
-
return response;
|
|
595
|
-
};
|
|
596
|
-
}
|
|
597
|
-
function createServer(config) {
|
|
598
|
-
const { routes, websocket, development, bun: bunExtra, port = 3000, hostname } = config;
|
|
599
|
-
const fetch = createHandler(config);
|
|
600
|
-
return websocket ? Bun.serve({
|
|
601
|
-
...bunExtra,
|
|
602
|
-
...routes && { routes },
|
|
603
|
-
...development && { development },
|
|
604
|
-
port,
|
|
605
|
-
hostname,
|
|
606
|
-
websocket,
|
|
607
|
-
fetch
|
|
608
|
-
}) : Bun.serve({
|
|
609
|
-
...bunExtra,
|
|
610
|
-
...development && { development },
|
|
611
|
-
port,
|
|
612
|
-
hostname,
|
|
613
|
-
fetch
|
|
614
|
-
});
|
|
615
|
-
}
|
|
616
|
-
function normalizeGroups(config) {
|
|
617
|
-
const result = [];
|
|
618
|
-
if (config.services) {
|
|
619
|
-
for (const service of config.services) {
|
|
620
|
-
result.push({ prefix: "", service });
|
|
621
|
-
}
|
|
622
|
-
}
|
|
623
|
-
if (config.groups) {
|
|
624
|
-
for (const group of config.groups) {
|
|
625
|
-
for (const service of group.services) {
|
|
626
|
-
result.push({ prefix: group.pathPrefix ?? "", service, hooks: group.hooks });
|
|
627
|
-
}
|
|
628
|
-
}
|
|
629
|
-
}
|
|
630
|
-
return result;
|
|
631
|
-
}
|
|
632
|
-
function corsHeaders2(cors, req) {
|
|
633
|
-
if (!cors)
|
|
634
|
-
return {};
|
|
635
|
-
return corsHeaders(cors, req.headers.get("origin"));
|
|
636
|
-
}
|
|
637
|
-
function applyCors(res, cors, req) {
|
|
638
|
-
const extra = corsHeaders2(cors, req);
|
|
639
|
-
if (Object.keys(extra).length === 0)
|
|
640
|
-
return res;
|
|
641
|
-
const headers = new Headers(res.headers);
|
|
642
|
-
for (const [key, value] of Object.entries(extra))
|
|
643
|
-
headers.set(key, value);
|
|
644
|
-
return new Response(res.body, {
|
|
645
|
-
status: res.status,
|
|
646
|
-
statusText: res.statusText,
|
|
647
|
-
headers
|
|
648
|
-
});
|
|
649
|
-
}
|
|
650
|
-
function json(data, status, cors, req) {
|
|
651
|
-
return Response.json(data, { status, headers: corsHeaders2(cors, req) });
|
|
652
|
-
}
|
|
653
96
|
// src/server/event-bus.ts
|
|
654
|
-
function createEventBus() {
|
|
97
|
+
function createEventBus(options = {}) {
|
|
655
98
|
const subscriptions = new Map;
|
|
656
99
|
function add(event, fn, once) {
|
|
657
100
|
let set = subscriptions.get(event);
|
|
@@ -675,11 +118,12 @@ function createEventBus() {
|
|
|
675
118
|
set.delete(sub);
|
|
676
119
|
try {
|
|
677
120
|
const result = sub.fn(data);
|
|
678
|
-
if (result instanceof Promise)
|
|
679
|
-
result.catch(() =>
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
121
|
+
if (result instanceof Promise) {
|
|
122
|
+
result.catch((err) => options.onListenerError?.(err, event));
|
|
123
|
+
}
|
|
124
|
+
} catch (err) {
|
|
125
|
+
options.onListenerError?.(err, event);
|
|
126
|
+
}
|
|
683
127
|
}
|
|
684
128
|
},
|
|
685
129
|
on(event, handler) {
|
|
@@ -738,14 +182,17 @@ function parseCookies(header) {
|
|
|
738
182
|
return {};
|
|
739
183
|
const cookies = {};
|
|
740
184
|
for (const pair of header.split(";")) {
|
|
741
|
-
const
|
|
742
|
-
if (
|
|
185
|
+
const eq = pair.indexOf("=");
|
|
186
|
+
if (eq === -1)
|
|
743
187
|
continue;
|
|
744
|
-
const
|
|
188
|
+
const key = pair.slice(0, eq).trim();
|
|
189
|
+
if (!key || isUnsafeKey(key))
|
|
190
|
+
continue;
|
|
191
|
+
const raw = pair.slice(eq + 1).trim();
|
|
745
192
|
try {
|
|
746
|
-
cookies[key
|
|
193
|
+
cookies[key] = decodeURIComponent(raw);
|
|
747
194
|
} catch {
|
|
748
|
-
cookies[key
|
|
195
|
+
cookies[key] = raw;
|
|
749
196
|
}
|
|
750
197
|
}
|
|
751
198
|
return cookies;
|
|
@@ -782,26 +229,37 @@ function defineCookie(config) {
|
|
|
782
229
|
}
|
|
783
230
|
|
|
784
231
|
// src/server/middleware/auth.ts
|
|
232
|
+
var MAX_TOKEN_BYTES = 8192;
|
|
785
233
|
function decodeBase64Url(segment) {
|
|
234
|
+
if (!/^[A-Za-z0-9_-]*$/.test(segment) || segment.length % 4 === 1) {
|
|
235
|
+
throw new Error("invalid base64url segment");
|
|
236
|
+
}
|
|
786
237
|
const b64 = segment.replace(/-/g, "+").replace(/_/g, "/");
|
|
787
238
|
const padded = b64.padEnd(Math.ceil(b64.length / 4) * 4, "=");
|
|
788
|
-
return Uint8Array.from(atob(padded), (
|
|
239
|
+
return Uint8Array.from(atob(padded), (c) => c.charCodeAt(0));
|
|
789
240
|
}
|
|
790
|
-
async function verifyJwt(token, secret) {
|
|
241
|
+
async function verifyJwt(token, secret, options = {}) {
|
|
242
|
+
if (!secret)
|
|
243
|
+
throw new Error("verifyJwt: a non-empty secret is required");
|
|
244
|
+
if (token.length > MAX_TOKEN_BYTES)
|
|
245
|
+
throw unauthorized("Token too large");
|
|
791
246
|
const [headerB64, payloadB64, signatureB64] = token.split(".");
|
|
792
247
|
if (!headerB64 || !payloadB64 || !signatureB64)
|
|
793
248
|
throw unauthorized("Invalid token format");
|
|
794
249
|
let header;
|
|
795
|
-
let
|
|
250
|
+
let payloadRaw;
|
|
796
251
|
let signature;
|
|
797
252
|
try {
|
|
798
253
|
const decoder = new TextDecoder;
|
|
799
254
|
header = JSON.parse(decoder.decode(decodeBase64Url(headerB64)));
|
|
800
|
-
|
|
255
|
+
payloadRaw = safeJsonParse(decoder.decode(decodeBase64Url(payloadB64)));
|
|
801
256
|
signature = decodeBase64Url(signatureB64);
|
|
802
257
|
} catch {
|
|
803
258
|
throw unauthorized("Malformed token");
|
|
804
259
|
}
|
|
260
|
+
if (!isRecord(payloadRaw))
|
|
261
|
+
throw unauthorized("Malformed token");
|
|
262
|
+
const payload = payloadRaw;
|
|
805
263
|
if (header.alg !== "HS256")
|
|
806
264
|
throw unauthorized("Unsupported token algorithm");
|
|
807
265
|
const encoder = new TextEncoder;
|
|
@@ -810,12 +268,28 @@ async function verifyJwt(token, secret) {
|
|
|
810
268
|
const valid = await crypto.subtle.verify("HMAC", key, signature, data);
|
|
811
269
|
if (!valid)
|
|
812
270
|
throw unauthorized("Invalid token signature");
|
|
271
|
+
const leeway = options.leewaySeconds ?? 60;
|
|
813
272
|
const now = Date.now() / 1000;
|
|
814
|
-
if (
|
|
815
|
-
|
|
273
|
+
if ("exp" in payload) {
|
|
274
|
+
if (typeof payload.exp !== "number")
|
|
275
|
+
throw unauthorized("Malformed token");
|
|
276
|
+
if (payload.exp < now - leeway)
|
|
277
|
+
throw unauthorized("Token expired");
|
|
278
|
+
}
|
|
279
|
+
if ("nbf" in payload) {
|
|
280
|
+
if (typeof payload.nbf !== "number")
|
|
281
|
+
throw unauthorized("Malformed token");
|
|
282
|
+
if (payload.nbf > now + leeway)
|
|
283
|
+
throw unauthorized("Token not yet valid");
|
|
284
|
+
}
|
|
285
|
+
if (options.issuer !== undefined && payload.iss !== options.issuer) {
|
|
286
|
+
throw unauthorized("Token issuer mismatch");
|
|
816
287
|
}
|
|
817
|
-
if (
|
|
818
|
-
|
|
288
|
+
if (options.audience !== undefined) {
|
|
289
|
+
const aud = payload.aud;
|
|
290
|
+
const ok = Array.isArray(aud) ? aud.includes(options.audience) : aud === options.audience;
|
|
291
|
+
if (!ok)
|
|
292
|
+
throw unauthorized("Token audience mismatch");
|
|
819
293
|
}
|
|
820
294
|
return payload;
|
|
821
295
|
}
|
|
@@ -833,15 +307,16 @@ function createAuthHook(config) {
|
|
|
833
307
|
const onAnonymous = config.onAnonymous ?? (() => unauthorized());
|
|
834
308
|
const onForbidden = config.onForbidden ?? (() => forbidden());
|
|
835
309
|
return async (ctx, endpoint) => {
|
|
836
|
-
|
|
837
|
-
return;
|
|
838
|
-
const identity = await config.resolve(ctx);
|
|
310
|
+
const identity = ctx.source === "http" ? await config.resolve(ctx) : await config.resolveFromContext?.(ctx) ?? null;
|
|
839
311
|
config.inject?.(ctx, identity);
|
|
840
312
|
const scope = endpoint.scope ?? config.defaultScope;
|
|
841
313
|
if (!scope)
|
|
842
314
|
return;
|
|
843
315
|
const rule = config.rules[scope];
|
|
844
|
-
if (!rule
|
|
316
|
+
if (!rule) {
|
|
317
|
+
throw new Error(`[stitchkit] auth: no rule for scope "${scope}"`);
|
|
318
|
+
}
|
|
319
|
+
if (rule === "public")
|
|
845
320
|
return;
|
|
846
321
|
if (!identity) {
|
|
847
322
|
onAnonymous();
|
|
@@ -867,6 +342,7 @@ function refillBucket(entry, config, now) {
|
|
|
867
342
|
entry.tokens = Math.min(config.max, entry.tokens + elapsed / config.window * config.max);
|
|
868
343
|
entry.lastRefill = now;
|
|
869
344
|
}
|
|
345
|
+
var MAX_BUCKETS = 1e5;
|
|
870
346
|
function createRateLimiter() {
|
|
871
347
|
const { store: buckets, destroy } = createSweptMap({
|
|
872
348
|
intervalMs: 60000,
|
|
@@ -876,15 +352,26 @@ function createRateLimiter() {
|
|
|
876
352
|
destroy,
|
|
877
353
|
check(key, config) {
|
|
878
354
|
const now = Date.now();
|
|
879
|
-
|
|
880
|
-
if (
|
|
881
|
-
|
|
355
|
+
const entry = buckets.get(key);
|
|
356
|
+
if (entry) {
|
|
357
|
+
buckets.delete(key);
|
|
882
358
|
buckets.set(key, entry);
|
|
359
|
+
refillBucket(entry, config, now);
|
|
360
|
+
if (entry.tokens < 1)
|
|
361
|
+
return false;
|
|
362
|
+
entry.tokens -= 1;
|
|
363
|
+
return true;
|
|
364
|
+
}
|
|
365
|
+
if (buckets.size >= MAX_BUCKETS) {
|
|
366
|
+
const lru = buckets.keys().next().value;
|
|
367
|
+
if (lru !== undefined)
|
|
368
|
+
buckets.delete(lru);
|
|
883
369
|
}
|
|
884
|
-
|
|
885
|
-
|
|
370
|
+
const fresh = { tokens: config.max, lastRefill: now };
|
|
371
|
+
buckets.set(key, fresh);
|
|
372
|
+
if (fresh.tokens < 1)
|
|
886
373
|
return false;
|
|
887
|
-
|
|
374
|
+
fresh.tokens -= 1;
|
|
888
375
|
return true;
|
|
889
376
|
},
|
|
890
377
|
remaining(key, config) {
|
|
@@ -934,6 +421,7 @@ export {
|
|
|
934
421
|
staticRoute,
|
|
935
422
|
serializeCookie,
|
|
936
423
|
resolveTraceId,
|
|
424
|
+
resolveSocketIp,
|
|
937
425
|
rateLimited,
|
|
938
426
|
parseSSE,
|
|
939
427
|
parseMultipart,
|