@fresho/router 0.4.0 → 0.6.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 +113 -6
- package/dist/auth/index.d.mts +2 -2
- package/dist/auth/index.mjs +2 -2
- package/dist/docs-B1IFBS_w.mjs +724 -0
- package/dist/docs-B1IFBS_w.mjs.map +1 -0
- package/dist/index.d.mts +90 -24
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +38 -684
- package/dist/index.mjs.map +1 -1
- package/dist/{jwt-DHM_nSwL.d.mts → jwt-B-Fqwu4E.d.mts} +52 -9
- package/dist/jwt-B-Fqwu4E.d.mts.map +1 -0
- package/dist/jwt-ikNuUBcp.mjs +373 -0
- package/dist/jwt-ikNuUBcp.mjs.map +1 -0
- package/dist/mcp/index.d.mts +63 -0
- package/dist/mcp/index.d.mts.map +1 -0
- package/dist/mcp/index.mjs +329 -0
- package/dist/mcp/index.mjs.map +1 -0
- package/dist/middleware/index.d.mts +10 -4
- package/dist/middleware/index.d.mts.map +1 -1
- package/dist/middleware/index.mjs +10 -5
- package/dist/middleware/index.mjs.map +1 -1
- package/dist/{middleware-XMbIevSv.mjs → middleware-B-_0PLA6.mjs} +1 -1
- package/dist/{middleware-XMbIevSv.mjs.map → middleware-B-_0PLA6.mjs.map} +1 -1
- package/dist/{middleware-Bxq0LHLS.d.mts → middleware-zRMBpTLe.d.mts} +23 -2
- package/dist/middleware-zRMBpTLe.d.mts.map +1 -0
- package/docs/authentication.md +89 -5
- package/package.json +21 -4
- package/dist/jwt-DHM_nSwL.d.mts.map +0 -1
- package/dist/jwt-Dab9kOC2.mjs +0 -208
- package/dist/jwt-Dab9kOC2.mjs.map +0 -1
- package/dist/middleware-Bxq0LHLS.d.mts.map +0 -1
package/dist/index.mjs
CHANGED
|
@@ -1,683 +1,6 @@
|
|
|
1
|
-
import { n as runMiddleware } from "./middleware-
|
|
1
|
+
import { n as runMiddleware } from "./middleware-B-_0PLA6.mjs";
|
|
2
|
+
import { a as isFunction, c as compileSchema, i as router, n as generateDocs, o as isRoute, r as route, s as isRouter } from "./docs-B1IFBS_w.mjs";
|
|
2
3
|
|
|
3
|
-
//#region src/schema.ts
|
|
4
|
-
/** Valid base types for primitives and arrays. */
|
|
5
|
-
const PRIMITIVE_TYPES = [
|
|
6
|
-
"string",
|
|
7
|
-
"number",
|
|
8
|
-
"boolean",
|
|
9
|
-
"unknown"
|
|
10
|
-
];
|
|
11
|
-
/** Checks if a schema field is a nested object (not a type string). */
|
|
12
|
-
function isNestedSchema(field) {
|
|
13
|
-
return typeof field === "object" && field !== null;
|
|
14
|
-
}
|
|
15
|
-
/**
|
|
16
|
-
* Validates and coerces a single primitive value.
|
|
17
|
-
* Returns [success, result, errorMessage].
|
|
18
|
-
*/
|
|
19
|
-
function validatePrimitive(value, baseType) {
|
|
20
|
-
switch (baseType) {
|
|
21
|
-
case "string": return [
|
|
22
|
-
true,
|
|
23
|
-
String(value),
|
|
24
|
-
null
|
|
25
|
-
];
|
|
26
|
-
case "number": {
|
|
27
|
-
const num = Number(value);
|
|
28
|
-
if (Number.isNaN(num)) return [
|
|
29
|
-
false,
|
|
30
|
-
void 0,
|
|
31
|
-
"Expected number"
|
|
32
|
-
];
|
|
33
|
-
return [
|
|
34
|
-
true,
|
|
35
|
-
num,
|
|
36
|
-
null
|
|
37
|
-
];
|
|
38
|
-
}
|
|
39
|
-
case "boolean":
|
|
40
|
-
if (value === "true" || value === "1" || value === true) return [
|
|
41
|
-
true,
|
|
42
|
-
true,
|
|
43
|
-
null
|
|
44
|
-
];
|
|
45
|
-
else if (value === "false" || value === "0" || value === false) return [
|
|
46
|
-
true,
|
|
47
|
-
false,
|
|
48
|
-
null
|
|
49
|
-
];
|
|
50
|
-
return [
|
|
51
|
-
false,
|
|
52
|
-
void 0,
|
|
53
|
-
"Expected boolean (true/false)"
|
|
54
|
-
];
|
|
55
|
-
case "unknown": return [
|
|
56
|
-
true,
|
|
57
|
-
value,
|
|
58
|
-
null
|
|
59
|
-
];
|
|
60
|
-
default: return [
|
|
61
|
-
false,
|
|
62
|
-
void 0,
|
|
63
|
-
`Unknown type: ${baseType}`
|
|
64
|
-
];
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
/**
|
|
68
|
-
* Validates a schema field value (primitive, array, or nested object).
|
|
69
|
-
* Returns [success, result, errors].
|
|
70
|
-
*/
|
|
71
|
-
function validateField(key, field, value, parentPath = "") {
|
|
72
|
-
const fieldPath = parentPath ? `${parentPath}.${key}` : key;
|
|
73
|
-
const errors = {};
|
|
74
|
-
if (isNestedSchema(field)) {
|
|
75
|
-
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
76
|
-
errors[fieldPath] = ["Expected object"];
|
|
77
|
-
return [
|
|
78
|
-
false,
|
|
79
|
-
void 0,
|
|
80
|
-
errors
|
|
81
|
-
];
|
|
82
|
-
}
|
|
83
|
-
const nestedResult = validateSchema(field, value, fieldPath);
|
|
84
|
-
return [
|
|
85
|
-
nestedResult.success,
|
|
86
|
-
nestedResult.data,
|
|
87
|
-
nestedResult.errors
|
|
88
|
-
];
|
|
89
|
-
}
|
|
90
|
-
const typeStr = field;
|
|
91
|
-
const isOptional = typeStr.endsWith("?");
|
|
92
|
-
const isArray = typeStr.includes("[]");
|
|
93
|
-
let baseType = isOptional ? typeStr.slice(0, -1) : typeStr;
|
|
94
|
-
if (isArray) baseType = baseType.replace("[]", "");
|
|
95
|
-
if (value === void 0 || value === "") {
|
|
96
|
-
if (!isOptional) {
|
|
97
|
-
errors[fieldPath] = ["Required"];
|
|
98
|
-
return [
|
|
99
|
-
false,
|
|
100
|
-
void 0,
|
|
101
|
-
errors
|
|
102
|
-
];
|
|
103
|
-
}
|
|
104
|
-
return [
|
|
105
|
-
true,
|
|
106
|
-
void 0,
|
|
107
|
-
{}
|
|
108
|
-
];
|
|
109
|
-
}
|
|
110
|
-
if (isArray) {
|
|
111
|
-
if (!Array.isArray(value)) {
|
|
112
|
-
errors[fieldPath] = ["Expected array"];
|
|
113
|
-
return [
|
|
114
|
-
false,
|
|
115
|
-
void 0,
|
|
116
|
-
errors
|
|
117
|
-
];
|
|
118
|
-
}
|
|
119
|
-
const resultArray = [];
|
|
120
|
-
for (let i = 0; i < value.length; i++) {
|
|
121
|
-
const [success$1, coerced$1, errorMsg$1] = validatePrimitive(value[i], baseType);
|
|
122
|
-
if (!success$1) {
|
|
123
|
-
errors[`${fieldPath}[${i}]`] = [errorMsg$1];
|
|
124
|
-
return [
|
|
125
|
-
false,
|
|
126
|
-
void 0,
|
|
127
|
-
errors
|
|
128
|
-
];
|
|
129
|
-
}
|
|
130
|
-
resultArray.push(coerced$1);
|
|
131
|
-
}
|
|
132
|
-
return [
|
|
133
|
-
true,
|
|
134
|
-
resultArray,
|
|
135
|
-
{}
|
|
136
|
-
];
|
|
137
|
-
}
|
|
138
|
-
const [success, coerced, errorMsg] = validatePrimitive(value, baseType);
|
|
139
|
-
if (!success) {
|
|
140
|
-
errors[fieldPath] = [errorMsg];
|
|
141
|
-
return [
|
|
142
|
-
false,
|
|
143
|
-
void 0,
|
|
144
|
-
errors
|
|
145
|
-
];
|
|
146
|
-
}
|
|
147
|
-
return [
|
|
148
|
-
true,
|
|
149
|
-
coerced,
|
|
150
|
-
{}
|
|
151
|
-
];
|
|
152
|
-
}
|
|
153
|
-
/**
|
|
154
|
-
* Validates a full schema against data.
|
|
155
|
-
* Returns { success, data, errors }.
|
|
156
|
-
*/
|
|
157
|
-
function validateSchema(schema, data, parentPath = "") {
|
|
158
|
-
const result = {};
|
|
159
|
-
const allErrors = {};
|
|
160
|
-
for (const [key, field] of Object.entries(schema)) {
|
|
161
|
-
const value = data[key];
|
|
162
|
-
const [success, coerced, fieldErrors] = validateField(key, field, value, parentPath);
|
|
163
|
-
if (!success) Object.assign(allErrors, fieldErrors);
|
|
164
|
-
else if (coerced !== void 0) result[key] = coerced;
|
|
165
|
-
}
|
|
166
|
-
return {
|
|
167
|
-
success: Object.keys(allErrors).length === 0,
|
|
168
|
-
data: result,
|
|
169
|
-
errors: allErrors
|
|
170
|
-
};
|
|
171
|
-
}
|
|
172
|
-
/** Validates schema definition at compile time. */
|
|
173
|
-
function validateSchemaDefinition(schema, path = "") {
|
|
174
|
-
for (const [key, field] of Object.entries(schema)) {
|
|
175
|
-
const fieldPath = path ? `${path}.${key}` : key;
|
|
176
|
-
if (isNestedSchema(field)) validateSchemaDefinition(field, fieldPath);
|
|
177
|
-
else {
|
|
178
|
-
const typeStr = field;
|
|
179
|
-
let baseType = typeStr.endsWith("?") ? typeStr.slice(0, -1) : typeStr;
|
|
180
|
-
baseType = baseType.replace("[]", "");
|
|
181
|
-
if (!PRIMITIVE_TYPES.includes(baseType)) throw new Error(`Unknown type '${typeStr}' at '${fieldPath}'`);
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
/**
|
|
186
|
-
* Compiles a schema definition into a validator.
|
|
187
|
-
*
|
|
188
|
-
* Used internally by the router to validate query parameters and request bodies.
|
|
189
|
-
* Provides a Zod-compatible `safeParse` API for consistency.
|
|
190
|
-
*
|
|
191
|
-
* @param schema - The schema definition object
|
|
192
|
-
* @returns A compiled schema with `safeParse` method
|
|
193
|
-
*
|
|
194
|
-
* @example
|
|
195
|
-
* ```typescript
|
|
196
|
-
* const userSchema = compileSchema({
|
|
197
|
-
* name: 'string',
|
|
198
|
-
* age: 'number',
|
|
199
|
-
* email: 'string?',
|
|
200
|
-
* });
|
|
201
|
-
*
|
|
202
|
-
* const result = userSchema.safeParse({ name: 'Alice', age: 30 });
|
|
203
|
-
* if (result.success) {
|
|
204
|
-
* console.log(result.data.name); // 'Alice'
|
|
205
|
-
* } else {
|
|
206
|
-
* console.log(result.error.flatten().fieldErrors);
|
|
207
|
-
* }
|
|
208
|
-
* ```
|
|
209
|
-
*/
|
|
210
|
-
function compileSchema(schema) {
|
|
211
|
-
validateSchemaDefinition(schema);
|
|
212
|
-
return { safeParse(data) {
|
|
213
|
-
const { success, data: result, errors } = validateSchema(schema, data);
|
|
214
|
-
if (!success) return {
|
|
215
|
-
success: false,
|
|
216
|
-
error: { flatten: () => ({ fieldErrors: errors }) }
|
|
217
|
-
};
|
|
218
|
-
return {
|
|
219
|
-
success: true,
|
|
220
|
-
data: result
|
|
221
|
-
};
|
|
222
|
-
} };
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
//#endregion
|
|
226
|
-
//#region src/types.ts
|
|
227
|
-
/** HTTP method property names. */
|
|
228
|
-
const HTTP_METHODS = new Set([
|
|
229
|
-
"get",
|
|
230
|
-
"post",
|
|
231
|
-
"put",
|
|
232
|
-
"patch",
|
|
233
|
-
"delete",
|
|
234
|
-
"options",
|
|
235
|
-
"head"
|
|
236
|
-
]);
|
|
237
|
-
/** Symbol to mark an object as a route definition. */
|
|
238
|
-
const ROUTE_MARKER = Symbol.for("@fresho/router:route");
|
|
239
|
-
/** Symbol to mark an object as a router. */
|
|
240
|
-
const ROUTER_MARKER = Symbol.for("@fresho/router:router");
|
|
241
|
-
/** Checks if a value is a function. */
|
|
242
|
-
function isFunction(value) {
|
|
243
|
-
return typeof value === "function";
|
|
244
|
-
}
|
|
245
|
-
/** Checks if entry is a Route (marked with ROUTE_MARKER). */
|
|
246
|
-
function isRoute(entry) {
|
|
247
|
-
return typeof entry === "object" && entry !== null && ROUTE_MARKER in entry;
|
|
248
|
-
}
|
|
249
|
-
/** Checks if entry is a Router (marked with ROUTER_MARKER). */
|
|
250
|
-
function isRouter(entry) {
|
|
251
|
-
return typeof entry === "object" && entry !== null && ROUTER_MARKER in entry;
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
//#endregion
|
|
255
|
-
//#region src/handler.ts
|
|
256
|
-
/**
|
|
257
|
-
* Escapes special regex characters in a string.
|
|
258
|
-
*/
|
|
259
|
-
function escapeRegex(str) {
|
|
260
|
-
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
261
|
-
}
|
|
262
|
-
/**
|
|
263
|
-
* Converts a path with :param segments to a regex pattern.
|
|
264
|
-
*/
|
|
265
|
-
function pathToRegex(path) {
|
|
266
|
-
const paramNames = [];
|
|
267
|
-
const parts = path.split(/:(\w+)/g);
|
|
268
|
-
let regexStr = "";
|
|
269
|
-
for (let i = 0; i < parts.length; i++) if (i % 2 === 0) regexStr += escapeRegex(parts[i]);
|
|
270
|
-
else {
|
|
271
|
-
paramNames.push(parts[i]);
|
|
272
|
-
regexStr += "([^/]+)";
|
|
273
|
-
}
|
|
274
|
-
return {
|
|
275
|
-
pattern: /* @__PURE__ */ new RegExp(`^${regexStr}$`),
|
|
276
|
-
paramNames
|
|
277
|
-
};
|
|
278
|
-
}
|
|
279
|
-
/**
|
|
280
|
-
* Converts a property name to a path segment.
|
|
281
|
-
* - `$param` becomes `:param`
|
|
282
|
-
* - Other names become literal segments
|
|
283
|
-
*/
|
|
284
|
-
function propertyToSegment$1(prop) {
|
|
285
|
-
if (prop.startsWith("$")) return `:${prop.slice(1)}`;
|
|
286
|
-
return prop;
|
|
287
|
-
}
|
|
288
|
-
/** Collects and compiles all routes from a router tree. */
|
|
289
|
-
function compileRoutes(routerDef, parentPath = "", parentMiddleware = []) {
|
|
290
|
-
const currentMiddleware = [...parentMiddleware, ...routerDef.middleware || []];
|
|
291
|
-
const compiled = [];
|
|
292
|
-
for (const [prop, entry] of Object.entries(routerDef.routes)) if (isRouter(entry)) {
|
|
293
|
-
const segment = propertyToSegment$1(prop);
|
|
294
|
-
const newPath = parentPath ? `${parentPath}/${segment}` : `/${segment}`;
|
|
295
|
-
compiled.push(...compileRoutes(entry, newPath, currentMiddleware));
|
|
296
|
-
} else if (HTTP_METHODS.has(prop)) {
|
|
297
|
-
const method = prop.toUpperCase();
|
|
298
|
-
const { pattern, paramNames } = pathToRegex(parentPath || "/");
|
|
299
|
-
if (isFunction(entry)) compiled.push({
|
|
300
|
-
method,
|
|
301
|
-
pattern,
|
|
302
|
-
paramNames,
|
|
303
|
-
handler: entry,
|
|
304
|
-
middleware: currentMiddleware
|
|
305
|
-
});
|
|
306
|
-
else if (isRoute(entry)) {
|
|
307
|
-
const routeDef = entry;
|
|
308
|
-
compiled.push({
|
|
309
|
-
method,
|
|
310
|
-
pattern,
|
|
311
|
-
paramNames,
|
|
312
|
-
handler: routeDef.handler,
|
|
313
|
-
middleware: currentMiddleware,
|
|
314
|
-
querySchema: routeDef.query ? compileSchema(routeDef.query) : void 0,
|
|
315
|
-
bodySchema: routeDef.body ? compileSchema(routeDef.body) : void 0
|
|
316
|
-
});
|
|
317
|
-
}
|
|
318
|
-
}
|
|
319
|
-
return compiled;
|
|
320
|
-
}
|
|
321
|
-
/**
|
|
322
|
-
* Strips the body from a response for HEAD requests.
|
|
323
|
-
*/
|
|
324
|
-
function stripBodyForHead(response, isHead) {
|
|
325
|
-
if (!isHead) return response;
|
|
326
|
-
return new Response(null, {
|
|
327
|
-
status: response.status,
|
|
328
|
-
statusText: response.statusText,
|
|
329
|
-
headers: response.headers
|
|
330
|
-
});
|
|
331
|
-
}
|
|
332
|
-
/**
|
|
333
|
-
* Creates a fetch handler from a router definition.
|
|
334
|
-
*
|
|
335
|
-
* @example
|
|
336
|
-
* ```typescript
|
|
337
|
-
* const api = router({
|
|
338
|
-
* health: router({
|
|
339
|
-
* get: async () => ({ status: 'ok' }),
|
|
340
|
-
* }),
|
|
341
|
-
* users: router({
|
|
342
|
-
* get: async () => [...],
|
|
343
|
-
* $id: router({
|
|
344
|
-
* get: async (c) => ({ id: c.path.id }),
|
|
345
|
-
* }),
|
|
346
|
-
* }),
|
|
347
|
-
* });
|
|
348
|
-
*
|
|
349
|
-
* export default { fetch: api.handler() };
|
|
350
|
-
* ```
|
|
351
|
-
*/
|
|
352
|
-
function createHandler(routerDef) {
|
|
353
|
-
const compiledRoutes = compileRoutes(routerDef);
|
|
354
|
-
return async (request, env, ctx) => {
|
|
355
|
-
const url = new URL(request.url);
|
|
356
|
-
const method = request.method.toUpperCase();
|
|
357
|
-
const isHead = method === "HEAD";
|
|
358
|
-
for (const compiled of compiledRoutes) {
|
|
359
|
-
if (!(compiled.method === method || method === "OPTIONS" || method === "HEAD" && compiled.method === "GET")) continue;
|
|
360
|
-
const match = compiled.pattern.exec(url.pathname);
|
|
361
|
-
if (!match) continue;
|
|
362
|
-
const path = {};
|
|
363
|
-
for (let i = 0; i < compiled.paramNames.length; i++) path[compiled.paramNames[i]] = decodeURIComponent(match[i + 1]);
|
|
364
|
-
const context = {
|
|
365
|
-
request,
|
|
366
|
-
path,
|
|
367
|
-
query: {},
|
|
368
|
-
body: {},
|
|
369
|
-
env,
|
|
370
|
-
executionCtx: ctx
|
|
371
|
-
};
|
|
372
|
-
if (compiled.middleware.length > 0 || compiled.handler || compiled.querySchema || compiled.bodySchema) return stripBodyForHead(await runMiddleware(compiled.middleware, context, async () => {
|
|
373
|
-
const routeId = `${compiled.method} ${url.pathname}`;
|
|
374
|
-
let query = {};
|
|
375
|
-
if (compiled.querySchema) {
|
|
376
|
-
const raw = Object.fromEntries(url.searchParams);
|
|
377
|
-
const result = compiled.querySchema.safeParse(raw);
|
|
378
|
-
if (!result.success) return Response.json({
|
|
379
|
-
error: "Invalid query parameters",
|
|
380
|
-
route: routeId,
|
|
381
|
-
details: result.error.flatten()
|
|
382
|
-
}, { status: 400 });
|
|
383
|
-
query = result.data;
|
|
384
|
-
}
|
|
385
|
-
let body = {};
|
|
386
|
-
if (compiled.bodySchema && [
|
|
387
|
-
"POST",
|
|
388
|
-
"PUT",
|
|
389
|
-
"PATCH"
|
|
390
|
-
].includes(method)) {
|
|
391
|
-
const raw = await request.json().catch(() => ({}));
|
|
392
|
-
const result = compiled.bodySchema.safeParse(raw);
|
|
393
|
-
if (!result.success) return Response.json({
|
|
394
|
-
error: "Invalid request body",
|
|
395
|
-
route: routeId,
|
|
396
|
-
details: result.error.flatten()
|
|
397
|
-
}, { status: 400 });
|
|
398
|
-
body = result.data;
|
|
399
|
-
}
|
|
400
|
-
context.query = query;
|
|
401
|
-
context.body = body;
|
|
402
|
-
if (compiled.handler) {
|
|
403
|
-
const handlerContext = {
|
|
404
|
-
request,
|
|
405
|
-
path,
|
|
406
|
-
query,
|
|
407
|
-
body,
|
|
408
|
-
env,
|
|
409
|
-
executionCtx: ctx
|
|
410
|
-
};
|
|
411
|
-
for (const key of Object.keys(context)) if (![
|
|
412
|
-
"request",
|
|
413
|
-
"path",
|
|
414
|
-
"query",
|
|
415
|
-
"body",
|
|
416
|
-
"env",
|
|
417
|
-
"executionCtx"
|
|
418
|
-
].includes(key)) handlerContext[key] = context[key];
|
|
419
|
-
const result = await compiled.handler(handlerContext);
|
|
420
|
-
if (result instanceof Response) return result;
|
|
421
|
-
return Response.json(result);
|
|
422
|
-
}
|
|
423
|
-
return Response.json({});
|
|
424
|
-
}), isHead);
|
|
425
|
-
return stripBodyForHead(Response.json({}), isHead);
|
|
426
|
-
}
|
|
427
|
-
return Response.json({ error: "Not Found" }, { status: 404 });
|
|
428
|
-
};
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
//#endregion
|
|
432
|
-
//#region src/core.ts
|
|
433
|
-
/**
|
|
434
|
-
* @fileoverview Core route and router creation functions.
|
|
435
|
-
*
|
|
436
|
-
* ## Path Convention
|
|
437
|
-
* - Property names = URL path segments
|
|
438
|
-
* - `$param` prefix = dynamic segment (`:param`)
|
|
439
|
-
* - `get`, `post`, `put`, `patch`, `delete` = HTTP method handlers
|
|
440
|
-
*
|
|
441
|
-
* ## Typing Best Practices
|
|
442
|
-
*
|
|
443
|
-
* **Schemas** (`query`/`body`) provide runtime validation AND type inference.
|
|
444
|
-
* Use them for any input that needs validation:
|
|
445
|
-
* ```typescript
|
|
446
|
-
* get: route({
|
|
447
|
-
* query: { limit: 'number?' },
|
|
448
|
-
* handler: async (c) => c.query.limit, // limit: number | undefined
|
|
449
|
-
* })
|
|
450
|
-
* ```
|
|
451
|
-
*
|
|
452
|
-
* **Context** (`route.ctx<T>()`) provides types only (no validation).
|
|
453
|
-
* Use it for path params, env bindings, and middleware-injected values:
|
|
454
|
-
* ```typescript
|
|
455
|
-
* interface MyContext {
|
|
456
|
-
* path: { id: string };
|
|
457
|
-
* env: { DB: Database };
|
|
458
|
-
* user: { name: string }; // from auth middleware
|
|
459
|
-
* }
|
|
460
|
-
* get: route.ctx<MyContext>()({
|
|
461
|
-
* handler: async (c) => ({ id: c.path.id, user: c.user.name }),
|
|
462
|
-
* })
|
|
463
|
-
* ```
|
|
464
|
-
*
|
|
465
|
-
* **Important:** Don't add explicit type annotations to handler parameters.
|
|
466
|
-
* Let types flow from schemas and context:
|
|
467
|
-
* ```typescript
|
|
468
|
-
* // GOOD: types inferred from schema
|
|
469
|
-
* handler: async (c) => c.query.limit
|
|
470
|
-
*
|
|
471
|
-
* // BAD: redundant type annotation
|
|
472
|
-
* handler: async (c: { query: { limit?: number } }) => c.query.limit
|
|
473
|
-
* ```
|
|
474
|
-
*/
|
|
475
|
-
/**
|
|
476
|
-
* Creates a route with query/body validation schemas.
|
|
477
|
-
*
|
|
478
|
-
* Use `route()` when you need request validation. For simple handlers
|
|
479
|
-
* without validation, you can use bare functions directly in the router.
|
|
480
|
-
*
|
|
481
|
-
* @example
|
|
482
|
-
* ```typescript
|
|
483
|
-
* // Route with query validation
|
|
484
|
-
* get: route({
|
|
485
|
-
* query: { limit: 'number?', offset: 'number?' },
|
|
486
|
-
* handler: async (c) => {
|
|
487
|
-
* const items = await db.list(c.query.limit, c.query.offset);
|
|
488
|
-
* return { items };
|
|
489
|
-
* },
|
|
490
|
-
* })
|
|
491
|
-
*
|
|
492
|
-
* // Route with body validation
|
|
493
|
-
* post: route({
|
|
494
|
-
* body: { name: 'string', email: 'string' },
|
|
495
|
-
* handler: async (c) => {
|
|
496
|
-
* const user = await db.create(c.body);
|
|
497
|
-
* return user;
|
|
498
|
-
* },
|
|
499
|
-
* })
|
|
500
|
-
*
|
|
501
|
-
* // Simple handler without validation (bare function)
|
|
502
|
-
* get: async () => ({ status: 'ok' })
|
|
503
|
-
* ```
|
|
504
|
-
*/
|
|
505
|
-
function route(definition) {
|
|
506
|
-
return {
|
|
507
|
-
...definition,
|
|
508
|
-
[ROUTE_MARKER]: true
|
|
509
|
-
};
|
|
510
|
-
}
|
|
511
|
-
/** Creates a chainable context builder. */
|
|
512
|
-
function createCtxBuilder() {
|
|
513
|
-
const builder = (definition) => ({
|
|
514
|
-
...definition,
|
|
515
|
-
[ROUTE_MARKER]: true
|
|
516
|
-
});
|
|
517
|
-
builder.ctx = () => createCtxBuilder();
|
|
518
|
-
return builder;
|
|
519
|
-
}
|
|
520
|
-
/**
|
|
521
|
-
* Creates a route with typed context for values not covered by schemas.
|
|
522
|
-
*
|
|
523
|
-
* Use `route.ctx<T>()` to type:
|
|
524
|
-
* - **Path params**: `{ path: { id: string } }` for `$id` segments
|
|
525
|
-
* - **Env bindings**: `{ env: { DB: Database } }` for runtime environment
|
|
526
|
-
* - **Middleware values**: `{ user: User }` for auth middleware, etc.
|
|
527
|
-
*
|
|
528
|
-
* Context provides types only, not runtime validation. For validated inputs,
|
|
529
|
-
* use `query` and `body` schemas instead.
|
|
530
|
-
*
|
|
531
|
-
* @example
|
|
532
|
-
* ```typescript
|
|
533
|
-
* interface AppContext {
|
|
534
|
-
* path: { id: string };
|
|
535
|
-
* env: { DB: D1Database };
|
|
536
|
-
* user: { id: string }; // injected by auth middleware
|
|
537
|
-
* }
|
|
538
|
-
*
|
|
539
|
-
* $id: router({
|
|
540
|
-
* get: route.ctx<AppContext>()({
|
|
541
|
-
* query: { include: 'string?' }, // schema for validation
|
|
542
|
-
* handler: async (c) => {
|
|
543
|
-
* // c.path.id, c.env.DB, c.user.id - from context
|
|
544
|
-
* // c.query.include - from schema
|
|
545
|
-
* return c.env.DB.get(c.path.id);
|
|
546
|
-
* },
|
|
547
|
-
* }),
|
|
548
|
-
* })
|
|
549
|
-
* ```
|
|
550
|
-
*/
|
|
551
|
-
route.ctx = () => createCtxBuilder();
|
|
552
|
-
/**
|
|
553
|
-
* Creates a router that groups paths and applies middleware.
|
|
554
|
-
*
|
|
555
|
-
* @example
|
|
556
|
-
* ```typescript
|
|
557
|
-
* const api = router({
|
|
558
|
-
* health: router({
|
|
559
|
-
* get: async () => ({ status: 'ok' }),
|
|
560
|
-
* }),
|
|
561
|
-
*
|
|
562
|
-
* users: router({
|
|
563
|
-
* // GET /users - list users
|
|
564
|
-
* get: route({
|
|
565
|
-
* query: { limit: 'number?' },
|
|
566
|
-
* handler: async (c) => db.users.list(c.query.limit),
|
|
567
|
-
* }),
|
|
568
|
-
*
|
|
569
|
-
* // POST /users - create user
|
|
570
|
-
* post: route({
|
|
571
|
-
* body: { name: 'string', email: 'string' },
|
|
572
|
-
* handler: async (c) => db.users.create(c.body),
|
|
573
|
-
* }),
|
|
574
|
-
*
|
|
575
|
-
* // /users/:id
|
|
576
|
-
* $id: router({
|
|
577
|
-
* get: async (c) => db.users.get(c.path.id),
|
|
578
|
-
* delete: async (c) => db.users.delete(c.path.id),
|
|
579
|
-
* }),
|
|
580
|
-
* }),
|
|
581
|
-
* });
|
|
582
|
-
*
|
|
583
|
-
* export default { fetch: api.handler() };
|
|
584
|
-
* ```
|
|
585
|
-
*/
|
|
586
|
-
function router(routes, ...middleware) {
|
|
587
|
-
const self = {
|
|
588
|
-
routes,
|
|
589
|
-
middleware: middleware.length > 0 ? middleware : void 0,
|
|
590
|
-
handler() {
|
|
591
|
-
return createHandler(self);
|
|
592
|
-
}
|
|
593
|
-
};
|
|
594
|
-
self[ROUTER_MARKER] = true;
|
|
595
|
-
return self;
|
|
596
|
-
}
|
|
597
|
-
|
|
598
|
-
//#endregion
|
|
599
|
-
//#region src/docs.ts
|
|
600
|
-
/** Converts property name to path segment, handling $param convention. */
|
|
601
|
-
function propertyToSegment(prop) {
|
|
602
|
-
if (prop.startsWith("$")) return `{${prop.slice(1)}}`;
|
|
603
|
-
return prop;
|
|
604
|
-
}
|
|
605
|
-
/** Collects all routes from a router tree. */
|
|
606
|
-
function collectRoutes(routerDef, parentPath = "") {
|
|
607
|
-
const collectedRoutes = [];
|
|
608
|
-
for (const [prop, entry] of Object.entries(routerDef.routes)) if (HTTP_METHODS.has(prop)) {
|
|
609
|
-
const path = parentPath || "/";
|
|
610
|
-
if (isFunction(entry)) collectedRoutes.push({
|
|
611
|
-
path,
|
|
612
|
-
method: prop,
|
|
613
|
-
route: null
|
|
614
|
-
});
|
|
615
|
-
else if (isRoute(entry)) collectedRoutes.push({
|
|
616
|
-
path,
|
|
617
|
-
method: prop,
|
|
618
|
-
route: entry
|
|
619
|
-
});
|
|
620
|
-
} else if (isRouter(entry)) {
|
|
621
|
-
const segment = propertyToSegment(prop);
|
|
622
|
-
const newPath = parentPath ? `${parentPath}/${segment}` : `/${segment}`;
|
|
623
|
-
collectedRoutes.push(...collectRoutes(entry, newPath));
|
|
624
|
-
}
|
|
625
|
-
return collectedRoutes;
|
|
626
|
-
}
|
|
627
|
-
/** Generates OpenAPI documentation from a router. */
|
|
628
|
-
function generateDocs(config) {
|
|
629
|
-
const paths = {};
|
|
630
|
-
const allRoutes = collectRoutes(config.router);
|
|
631
|
-
for (const { path, method, route: route$1 } of allRoutes) {
|
|
632
|
-
if (!paths[path]) paths[path] = {};
|
|
633
|
-
const operation = {
|
|
634
|
-
description: route$1?.description,
|
|
635
|
-
responses: {
|
|
636
|
-
200: { description: "Successful response" },
|
|
637
|
-
400: { description: "Validation error" }
|
|
638
|
-
}
|
|
639
|
-
};
|
|
640
|
-
if (route$1?.query) {
|
|
641
|
-
const querySchema = route$1.query;
|
|
642
|
-
operation.parameters = Object.entries(querySchema).filter(([, type]) => typeof type === "string").map(([name, type]) => ({
|
|
643
|
-
name,
|
|
644
|
-
in: "query",
|
|
645
|
-
required: !type.endsWith("?"),
|
|
646
|
-
schema: { type: type.replace("?", "").replace("[]", "") }
|
|
647
|
-
}));
|
|
648
|
-
}
|
|
649
|
-
if (route$1?.body && [
|
|
650
|
-
"post",
|
|
651
|
-
"put",
|
|
652
|
-
"patch"
|
|
653
|
-
].includes(method)) {
|
|
654
|
-
const bodySchema = route$1.body;
|
|
655
|
-
operation.requestBody = {
|
|
656
|
-
required: true,
|
|
657
|
-
content: { "application/json": { schema: {
|
|
658
|
-
type: "object",
|
|
659
|
-
properties: Object.fromEntries(Object.entries(bodySchema).map(([name, type]) => {
|
|
660
|
-
if (typeof type === "string") return [name, { type: type.replace("?", "").replace("[]", "") }];
|
|
661
|
-
return [name, { type: "object" }];
|
|
662
|
-
})),
|
|
663
|
-
required: Object.entries(bodySchema).filter(([, type]) => typeof type === "string" && !type.endsWith("?")).map(([name]) => name)
|
|
664
|
-
} } }
|
|
665
|
-
};
|
|
666
|
-
}
|
|
667
|
-
paths[path][method] = operation;
|
|
668
|
-
}
|
|
669
|
-
return {
|
|
670
|
-
openapi: "3.0.0",
|
|
671
|
-
info: {
|
|
672
|
-
title: config.title,
|
|
673
|
-
version: config.version,
|
|
674
|
-
description: config.description
|
|
675
|
-
},
|
|
676
|
-
paths
|
|
677
|
-
};
|
|
678
|
-
}
|
|
679
|
-
|
|
680
|
-
//#endregion
|
|
681
4
|
//#region src/client-proxy.ts
|
|
682
5
|
const PREFIXED_HTTP_METHODS = new Set([
|
|
683
6
|
"$get",
|
|
@@ -721,6 +44,22 @@ function createRecursiveProxy(config, segments = []) {
|
|
|
721
44
|
});
|
|
722
45
|
}
|
|
723
46
|
|
|
47
|
+
//#endregion
|
|
48
|
+
//#region src/client-response.ts
|
|
49
|
+
const STREAMING_CONTENT_TYPES = new Set([
|
|
50
|
+
"text/event-stream",
|
|
51
|
+
"application/x-ndjson",
|
|
52
|
+
"application/ndjson"
|
|
53
|
+
]);
|
|
54
|
+
/**
|
|
55
|
+
* Parses ordinary client responses as JSON while preserving streaming bodies.
|
|
56
|
+
*/
|
|
57
|
+
async function parseClientResponse(response, raw = false) {
|
|
58
|
+
const contentType = response.headers.get("Content-Type")?.split(";", 1)[0].trim().toLowerCase();
|
|
59
|
+
if (raw || contentType && STREAMING_CONTENT_TYPES.has(contentType)) return response;
|
|
60
|
+
return response.json();
|
|
61
|
+
}
|
|
62
|
+
|
|
724
63
|
//#endregion
|
|
725
64
|
//#region src/http-client.ts
|
|
726
65
|
/**
|
|
@@ -796,6 +135,18 @@ const BODY_METHODS = new Set([
|
|
|
796
135
|
* // GET /api/get
|
|
797
136
|
* await client.api.get.$get();
|
|
798
137
|
* ```
|
|
138
|
+
*
|
|
139
|
+
* @example Router built by a dependency-injected factory:
|
|
140
|
+
* ```typescript
|
|
141
|
+
* import type { createServer } from './server';
|
|
142
|
+
* import { createHttpClient } from '@fresho/router';
|
|
143
|
+
*
|
|
144
|
+
* type Api = ReturnType<typeof createServer>['api'];
|
|
145
|
+
*
|
|
146
|
+
* const client = createHttpClient<Api>({
|
|
147
|
+
* baseUrl: 'https://api.example.com',
|
|
148
|
+
* });
|
|
149
|
+
* ```
|
|
799
150
|
*/
|
|
800
151
|
function createHttpClient(config = {}) {
|
|
801
152
|
const sharedConfig = { current: { ...config } };
|
|
@@ -865,8 +216,7 @@ async function executeRequest(sharedConfig, segments, method, options) {
|
|
|
865
216
|
const fetchUrl = buildUrl(config, segments, options);
|
|
866
217
|
const response = await (config.fetch || fetch)(fetchUrl, init);
|
|
867
218
|
if (!response.ok) throw new Error(await response.text() || response.statusText);
|
|
868
|
-
|
|
869
|
-
return response.json();
|
|
219
|
+
return parseClientResponse(response, options?.raw);
|
|
870
220
|
}
|
|
871
221
|
|
|
872
222
|
//#endregion
|
|
@@ -1023,7 +373,7 @@ async function invokeHandler(sharedConfig, routerDef, segments, method, options)
|
|
|
1023
373
|
executionCtx: options?.ctx ?? config.ctx
|
|
1024
374
|
};
|
|
1025
375
|
const result = await routeInfo.handler(context);
|
|
1026
|
-
if (result instanceof Response) return result
|
|
376
|
+
if (result instanceof Response) return parseClientResponse(result);
|
|
1027
377
|
return result;
|
|
1028
378
|
}
|
|
1029
379
|
|
|
@@ -1046,10 +396,14 @@ function formatSSEMessage(message) {
|
|
|
1046
396
|
*
|
|
1047
397
|
* @example
|
|
1048
398
|
* ```typescript
|
|
399
|
+
* interface CounterEvents {
|
|
400
|
+
* tick: { count: number };
|
|
401
|
+
* }
|
|
402
|
+
*
|
|
1049
403
|
* const handler = async (c) => {
|
|
1050
|
-
* return sseResponse(async (send, close) => {
|
|
404
|
+
* return sseResponse<CounterEvents>(async (send, close) => {
|
|
1051
405
|
* for (let i = 0; i < 5; i++) {
|
|
1052
|
-
*
|
|
406
|
+
* send({ event: 'tick', data: { count: i } });
|
|
1053
407
|
* await new Promise(r => setTimeout(r, 1000));
|
|
1054
408
|
* }
|
|
1055
409
|
* close();
|