shokupan 0.6.0 → 0.6.1
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 +2 -0
- package/dist/context.d.ts +63 -2
- package/dist/index.cjs +84 -24
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +85 -25
- package/dist/index.js.map +1 -1
- package/dist/plugins/proxy.d.ts +2 -0
- package/dist/plugins/rate-limit.d.ts +1 -0
- package/dist/router.d.ts +95 -36
- package/dist/shokupan.d.ts +64 -0
- package/dist/types.d.ts +33 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -7,6 +7,8 @@ Shokupan is designed to make building APIs delightful again. With zero-config de
|
|
|
7
7
|
|
|
8
8
|
### Note: Shokupan is still in alpha and is not guaranteed to be stable. Please use with caution. We will be adding more features and APIs in the future. Please file an issue if you find any bugs or have suggestions for improvement.
|
|
9
9
|
|
|
10
|
+
📚 **[Full documentation available at https://shokupan.dev](https://shokupan.dev)**
|
|
11
|
+
|
|
10
12
|
## ✨ Features
|
|
11
13
|
|
|
12
14
|
- 🎯 **TypeScript First** - End-to-end type safety with decorators and generics. No manual types needed.
|
package/dist/context.d.ts
CHANGED
|
@@ -18,12 +18,73 @@ export interface DebugCollector {
|
|
|
18
18
|
setNode(id: string): void;
|
|
19
19
|
getCurrentNode(): string | undefined;
|
|
20
20
|
}
|
|
21
|
-
|
|
21
|
+
/**
|
|
22
|
+
* Shokupan Request Context
|
|
23
|
+
*
|
|
24
|
+
* The context object passed to all middleware and route handlers.
|
|
25
|
+
* Provides access to request data, response helpers, and typed state management.
|
|
26
|
+
*
|
|
27
|
+
* @template State - The shape of `ctx.state` for type-safe state access across middleware.
|
|
28
|
+
* @template Params - The shape of `ctx.params` based on the route path pattern.
|
|
29
|
+
*
|
|
30
|
+
* @example Basic Usage
|
|
31
|
+
* ```typescript
|
|
32
|
+
* app.get('/hello', (ctx) => {
|
|
33
|
+
* return ctx.json({ message: 'Hello' });
|
|
34
|
+
* });
|
|
35
|
+
* ```
|
|
36
|
+
*
|
|
37
|
+
* @example Typed State
|
|
38
|
+
* ```typescript
|
|
39
|
+
* interface AppState {
|
|
40
|
+
* userId: string;
|
|
41
|
+
* requestId: string;
|
|
42
|
+
* }
|
|
43
|
+
*
|
|
44
|
+
* const app = new Shokupan<AppState>();
|
|
45
|
+
*
|
|
46
|
+
* app.use((ctx, next) => {
|
|
47
|
+
* ctx.state.requestId = crypto.randomUUID(); // ✓ Type-safe
|
|
48
|
+
* return next();
|
|
49
|
+
* });
|
|
50
|
+
* ```
|
|
51
|
+
*
|
|
52
|
+
* @example Typed Path Parameters
|
|
53
|
+
* ```typescript
|
|
54
|
+
* app.get('/users/:userId/posts/:postId', (ctx) => {
|
|
55
|
+
* // ctx.params is automatically typed as { userId: string; postId: string }
|
|
56
|
+
* const { userId, postId } = ctx.params;
|
|
57
|
+
* return ctx.json({ userId, postId });
|
|
58
|
+
* });
|
|
59
|
+
* ```
|
|
60
|
+
*
|
|
61
|
+
* @example Full Type Safety (State + Params)
|
|
62
|
+
* ```typescript
|
|
63
|
+
* interface RequestState {
|
|
64
|
+
* userId: string;
|
|
65
|
+
* permissions: string[];
|
|
66
|
+
* }
|
|
67
|
+
*
|
|
68
|
+
* const app = new Shokupan<RequestState>();
|
|
69
|
+
*
|
|
70
|
+
* app.get('/admin/users/:userId', (ctx) => {
|
|
71
|
+
* // Both typed!
|
|
72
|
+
* const { userId } = ctx.params; // ✓ From path
|
|
73
|
+
* const { permissions } = ctx.state; // ✓ From state
|
|
74
|
+
*
|
|
75
|
+
* if (!permissions.includes('admin')) {
|
|
76
|
+
* return ctx.json({ error: 'Forbidden' }, 403);
|
|
77
|
+
* }
|
|
78
|
+
* return ctx.json({ userId });
|
|
79
|
+
* });
|
|
80
|
+
* ```
|
|
81
|
+
*/
|
|
82
|
+
export declare class ShokupanContext<State extends Record<string, any> = Record<string, any>, Params extends Record<string, string> = Record<string, string>> {
|
|
22
83
|
readonly request: ShokupanRequest<any>;
|
|
23
84
|
readonly server?: Server;
|
|
24
85
|
readonly app?: Shokupan;
|
|
25
86
|
readonly signal?: AbortSignal;
|
|
26
|
-
params:
|
|
87
|
+
params: Params;
|
|
27
88
|
state: State;
|
|
28
89
|
handlerStack: HandlerStackItem[];
|
|
29
90
|
readonly response: ShokupanResponse;
|
package/dist/index.cjs
CHANGED
|
@@ -119,6 +119,15 @@ class ShokupanResponse {
|
|
|
119
119
|
return this._headers !== null;
|
|
120
120
|
}
|
|
121
121
|
}
|
|
122
|
+
function isValidCookieDomain(domain, currentHost) {
|
|
123
|
+
const hostWithoutPort = currentHost.split(":")[0];
|
|
124
|
+
if (domain === hostWithoutPort) return true;
|
|
125
|
+
if (domain.startsWith(".")) {
|
|
126
|
+
const domainWithoutDot = domain.slice(1);
|
|
127
|
+
return hostWithoutPort.endsWith(domainWithoutDot);
|
|
128
|
+
}
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
122
131
|
const VALID_HTTP_STATUSES = /* @__PURE__ */ new Set([
|
|
123
132
|
100,
|
|
124
133
|
101,
|
|
@@ -278,16 +287,20 @@ class ShokupanContext {
|
|
|
278
287
|
*/
|
|
279
288
|
get query() {
|
|
280
289
|
if (this._cachedQuery) return this._cachedQuery;
|
|
281
|
-
const q =
|
|
290
|
+
const q = /* @__PURE__ */ Object.create(null);
|
|
291
|
+
const blocklist = ["__proto__", "constructor", "prototype"];
|
|
282
292
|
const entries = Object.entries(this.url.searchParams);
|
|
283
293
|
for (let i = 0; i < entries.length; i++) {
|
|
284
294
|
const [key, value] = entries[i];
|
|
285
|
-
if (
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
295
|
+
if (blocklist.includes(key)) continue;
|
|
296
|
+
if (Object.prototype.hasOwnProperty.call(q, key)) {
|
|
297
|
+
if (Array.isArray(q[key])) {
|
|
298
|
+
q[key].push(value);
|
|
299
|
+
} else {
|
|
300
|
+
q[key] = [q[key], value];
|
|
301
|
+
}
|
|
289
302
|
} else {
|
|
290
|
-
q[key] =
|
|
303
|
+
q[key] = value;
|
|
291
304
|
}
|
|
292
305
|
}
|
|
293
306
|
this._cachedQuery = q;
|
|
@@ -364,6 +377,12 @@ class ShokupanContext {
|
|
|
364
377
|
* @param options Cookie options
|
|
365
378
|
*/
|
|
366
379
|
setCookie(name, value, options = {}) {
|
|
380
|
+
if (options.domain) {
|
|
381
|
+
const currentHost = this.hostname;
|
|
382
|
+
if (!isValidCookieDomain(options.domain, currentHost)) {
|
|
383
|
+
throw new Error(`Invalid cookie domain: ${options.domain} for host ${currentHost}`);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
367
386
|
let cookie = `${encodeURIComponent(name)}=${encodeURIComponent(value)}`;
|
|
368
387
|
if (options.maxAge) cookie += `; Max-Age=${Math.floor(options.maxAge)}`;
|
|
369
388
|
if (options.domain) cookie += `; Domain=${options.domain}`;
|
|
@@ -657,11 +676,24 @@ function RateLimitMiddleware(options = {}) {
|
|
|
657
676
|
const statusCode = options.statusCode || 429;
|
|
658
677
|
const headers = options.headers !== false;
|
|
659
678
|
const mode = options.mode || "user";
|
|
679
|
+
const trustedProxies = options.trustedProxies || [];
|
|
660
680
|
const keyGenerator = options.keyGenerator || ((ctx) => {
|
|
661
681
|
if (mode === "absolute") {
|
|
662
682
|
return "global";
|
|
663
683
|
}
|
|
664
|
-
|
|
684
|
+
const xForwardedFor = ctx.headers.get("x-forwarded-for");
|
|
685
|
+
if (xForwardedFor && trustedProxies.length > 0) {
|
|
686
|
+
const ips = xForwardedFor.split(",").map((ip) => ip.trim());
|
|
687
|
+
for (let i = ips.length - 1; i >= 0; i--) {
|
|
688
|
+
const ip = ips[i];
|
|
689
|
+
if (!trustedProxies.includes(ip)) {
|
|
690
|
+
if (/^[\d.:a-fA-F]+$/.test(ip)) {
|
|
691
|
+
return ip;
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
return ctx.server?.requestIP?.(ctx.request)?.address || "unknown";
|
|
665
697
|
});
|
|
666
698
|
const skip = options.skip || (() => false);
|
|
667
699
|
const hits = /* @__PURE__ */ new Map();
|
|
@@ -1337,12 +1369,23 @@ function serveStatic(config, prefix) {
|
|
|
1337
1369
|
let relative = ctx.path.slice(normalizedPrefix.length);
|
|
1338
1370
|
if (!relative.startsWith("/") && relative.length > 0) relative = "/" + relative;
|
|
1339
1371
|
if (relative.length === 0) relative = "/";
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1372
|
+
if (relative.includes("\0")) {
|
|
1373
|
+
return ctx.json({ error: "Forbidden" }, 403);
|
|
1374
|
+
}
|
|
1375
|
+
try {
|
|
1376
|
+
relative = decodeURIComponent(relative);
|
|
1377
|
+
} catch (e) {
|
|
1378
|
+
return ctx.json({ error: "Bad Request" }, 400);
|
|
1379
|
+
}
|
|
1380
|
+
if (relative.includes("\0")) {
|
|
1381
|
+
return ctx.json({ error: "Forbidden" }, 403);
|
|
1382
|
+
}
|
|
1383
|
+
if (relative.includes("../") || relative.includes("..\\")) {
|
|
1343
1384
|
return ctx.json({ error: "Forbidden" }, 403);
|
|
1344
1385
|
}
|
|
1345
|
-
|
|
1386
|
+
const requestPath = path.resolve(path.join(rootPath, relative));
|
|
1387
|
+
const normalizedRoot = path.resolve(rootPath);
|
|
1388
|
+
if (!requestPath.startsWith(normalizedRoot + path.sep) && requestPath !== normalizedRoot) {
|
|
1346
1389
|
return ctx.json({ error: "Forbidden" }, 403);
|
|
1347
1390
|
}
|
|
1348
1391
|
if (config.hooks?.onRequest) {
|
|
@@ -2191,10 +2234,13 @@ class ShokupanRouter {
|
|
|
2191
2234
|
}
|
|
2192
2235
|
parsePath(path2) {
|
|
2193
2236
|
const keys = [];
|
|
2237
|
+
if (path2.length > 2048) {
|
|
2238
|
+
throw new Error("Path too long");
|
|
2239
|
+
}
|
|
2194
2240
|
const pattern = path2.replace(/:([a-zA-Z0-9_]+)/g, (_, key) => {
|
|
2195
2241
|
keys.push(key);
|
|
2196
|
-
return "([^/]
|
|
2197
|
-
}).replace(/\*\*/g, "
|
|
2242
|
+
return "([^/]{1,255})";
|
|
2243
|
+
}).replace(/\*\*/g, ".{0,1000}").replace(/\*/g, "[^/]{1,255}");
|
|
2198
2244
|
return {
|
|
2199
2245
|
regex: new RegExp(`^${pattern}$`),
|
|
2200
2246
|
keys
|
|
@@ -2982,9 +3028,10 @@ class AuthPlugin extends ShokupanRouter {
|
|
|
2982
3028
|
} else {
|
|
2983
3029
|
return ctx.text("Provider config error", 500);
|
|
2984
3030
|
}
|
|
2985
|
-
|
|
3031
|
+
const isSecure = ctx.secure;
|
|
3032
|
+
ctx.res.headers.set("Set-Cookie", `oauth_state=${state}; Path=/; HttpOnly; SameSite=Lax${isSecure ? "; Secure" : ""}; Max-Age=600`);
|
|
2986
3033
|
if (codeVerifier) {
|
|
2987
|
-
ctx.res.headers.append("Set-Cookie", `oauth_verifier=${codeVerifier}; Path=/; HttpOnly; Max-Age=600`);
|
|
3034
|
+
ctx.res.headers.append("Set-Cookie", `oauth_verifier=${codeVerifier}; Path=/; HttpOnly; SameSite=Lax${isSecure ? "; Secure" : ""}; Max-Age=600`);
|
|
2988
3035
|
}
|
|
2989
3036
|
return ctx.redirect(url.toString());
|
|
2990
3037
|
});
|
|
@@ -3025,7 +3072,7 @@ class AuthPlugin extends ShokupanRouter {
|
|
|
3025
3072
|
return ctx.json({ token: jwt, user });
|
|
3026
3073
|
} catch (e) {
|
|
3027
3074
|
console.error("Auth Error", e);
|
|
3028
|
-
return ctx.text("Authentication failed
|
|
3075
|
+
return ctx.text("Authentication failed. Please try again.", 500);
|
|
3029
3076
|
}
|
|
3030
3077
|
});
|
|
3031
3078
|
}
|
|
@@ -3236,14 +3283,21 @@ function Cors(options = {}) {
|
|
|
3236
3283
|
const origin = ctx.headers.get("origin");
|
|
3237
3284
|
const set = (k, v) => headers.set(k, v);
|
|
3238
3285
|
const append = (k, v) => headers.append(k, v);
|
|
3286
|
+
if (origin === "null" && opts.origin !== "null") {
|
|
3287
|
+
return next();
|
|
3288
|
+
}
|
|
3239
3289
|
if (opts.origin === "*") {
|
|
3240
3290
|
set("Access-Control-Allow-Origin", "*");
|
|
3241
3291
|
} else if (typeof opts.origin === "string") {
|
|
3242
3292
|
set("Access-Control-Allow-Origin", opts.origin);
|
|
3243
3293
|
} else if (Array.isArray(opts.origin)) {
|
|
3244
|
-
if (origin
|
|
3245
|
-
|
|
3246
|
-
|
|
3294
|
+
if (origin) {
|
|
3295
|
+
const normalizedOrigin = origin.toLowerCase();
|
|
3296
|
+
const normalizedAllowed = opts.origin.map((o) => o.toLowerCase());
|
|
3297
|
+
if (normalizedAllowed.includes(normalizedOrigin)) {
|
|
3298
|
+
set("Access-Control-Allow-Origin", origin);
|
|
3299
|
+
append("Vary", "Origin");
|
|
3300
|
+
}
|
|
3247
3301
|
}
|
|
3248
3302
|
} else if (typeof opts.origin === "function") {
|
|
3249
3303
|
const allowed = opts.origin(ctx);
|
|
@@ -3930,11 +3984,17 @@ function unsign(input, secret) {
|
|
|
3930
3984
|
if (typeof secret !== "string") throw new TypeError("Secret string must be provided.");
|
|
3931
3985
|
const tentValue = input.slice(0, input.lastIndexOf("."));
|
|
3932
3986
|
const expectedInput = sign(tentValue, secret);
|
|
3933
|
-
const
|
|
3934
|
-
const
|
|
3935
|
-
|
|
3936
|
-
|
|
3937
|
-
|
|
3987
|
+
const maxLength = Math.max(expectedInput.length, input.length);
|
|
3988
|
+
const paddedExpected = Buffer.alloc(maxLength);
|
|
3989
|
+
const paddedInput = Buffer.alloc(maxLength);
|
|
3990
|
+
Buffer.from(expectedInput).copy(paddedExpected);
|
|
3991
|
+
Buffer.from(input).copy(paddedInput);
|
|
3992
|
+
try {
|
|
3993
|
+
const valid = require("crypto").timingSafeEqual(paddedExpected, paddedInput);
|
|
3994
|
+
return valid ? tentValue : false;
|
|
3995
|
+
} catch {
|
|
3996
|
+
return false;
|
|
3997
|
+
}
|
|
3938
3998
|
}
|
|
3939
3999
|
function Session(options) {
|
|
3940
4000
|
const store = options.store || new MemoryStore();
|