@fresho/router 0.0.1 → 0.1.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/LICENSE +21 -0
- package/README.md +401 -28
- package/dist/index.d.mts +377 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +1046 -0
- package/dist/index.mjs.map +1 -0
- package/dist/middleware/index.d.mts +396 -0
- package/dist/middleware/index.d.mts.map +1 -0
- package/dist/middleware/index.mjs +675 -0
- package/dist/middleware/index.mjs.map +1 -0
- package/dist/middleware-BpFh0XrR.mjs +50 -0
- package/dist/middleware-BpFh0XrR.mjs.map +1 -0
- package/dist/middleware-CqtYOc6D.d.mts +382 -0
- package/dist/middleware-CqtYOc6D.d.mts.map +1 -0
- package/docs/authentication.md +385 -0
- package/docs/middleware.md +441 -0
- package/package.json +63 -5
|
@@ -0,0 +1,675 @@
|
|
|
1
|
+
import { t as compose } from "../middleware-BpFh0XrR.mjs";
|
|
2
|
+
|
|
3
|
+
//#region src/middleware/auth.ts
|
|
4
|
+
/**
|
|
5
|
+
* Creates a Basic HTTP authentication middleware.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```typescript
|
|
9
|
+
* const adminRouter = router('/admin', routes,
|
|
10
|
+
* basicAuth({
|
|
11
|
+
* verify: async (username, password) => {
|
|
12
|
+
* if (username === 'admin' && password === 'secret') {
|
|
13
|
+
* return { user: { name: username } };
|
|
14
|
+
* }
|
|
15
|
+
* return null;
|
|
16
|
+
* }
|
|
17
|
+
* })
|
|
18
|
+
* );
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
function basicAuth(options) {
|
|
22
|
+
const realm = options.realm || "Secure Area";
|
|
23
|
+
return async (context, next) => {
|
|
24
|
+
const { request } = context;
|
|
25
|
+
const authHeader = request.headers.get("Authorization");
|
|
26
|
+
if (!authHeader?.startsWith("Basic ")) return new Response("Unauthorized", {
|
|
27
|
+
status: 401,
|
|
28
|
+
headers: { "WWW-Authenticate": `Basic realm="${realm}", charset="UTF-8"` }
|
|
29
|
+
});
|
|
30
|
+
try {
|
|
31
|
+
const credentials = atob(authHeader.slice(6));
|
|
32
|
+
const colonIndex = credentials.indexOf(":");
|
|
33
|
+
if (colonIndex === -1) throw new Error("Invalid credentials format");
|
|
34
|
+
const username = credentials.substring(0, colonIndex);
|
|
35
|
+
const password = credentials.substring(colonIndex + 1);
|
|
36
|
+
const claims = await options.verify(username, password, context);
|
|
37
|
+
if (claims === null) return new Response("Invalid credentials", {
|
|
38
|
+
status: 401,
|
|
39
|
+
headers: { "WWW-Authenticate": `Basic realm="${realm}", charset="UTF-8"` }
|
|
40
|
+
});
|
|
41
|
+
Object.assign(context, claims);
|
|
42
|
+
return next();
|
|
43
|
+
} catch (_error) {
|
|
44
|
+
return new Response("Invalid credentials", {
|
|
45
|
+
status: 401,
|
|
46
|
+
headers: { "WWW-Authenticate": `Basic realm="${realm}", charset="UTF-8"` }
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
/** Maps JWT algorithm names to Web Crypto hash names. */
|
|
52
|
+
const ALGORITHM_MAP = {
|
|
53
|
+
HS256: "SHA-256",
|
|
54
|
+
HS384: "SHA-384",
|
|
55
|
+
HS512: "SHA-512"
|
|
56
|
+
};
|
|
57
|
+
/**
|
|
58
|
+
* Verifies a JWT token using Web Crypto API.
|
|
59
|
+
*
|
|
60
|
+
* Uses only Web Crypto API, compatible with Cloudflare Workers, Deno, and browsers.
|
|
61
|
+
*
|
|
62
|
+
* @param token - The JWT token string to verify
|
|
63
|
+
* @param secret - The secret used to sign the token (string, ArrayBuffer, or CryptoKey)
|
|
64
|
+
* @param options - Optional verification options
|
|
65
|
+
* @returns The decoded JWT payload
|
|
66
|
+
* @throws Error if the token is invalid, expired, or not yet valid
|
|
67
|
+
*
|
|
68
|
+
* @example
|
|
69
|
+
* ```typescript
|
|
70
|
+
* import { jwtVerify } from '@fresho/router/middleware';
|
|
71
|
+
*
|
|
72
|
+
* try {
|
|
73
|
+
* const payload = await jwtVerify(token, 'your-secret-key');
|
|
74
|
+
* console.log(payload.sub); // user ID
|
|
75
|
+
* } catch (error) {
|
|
76
|
+
* console.error('Invalid token:', error.message);
|
|
77
|
+
* }
|
|
78
|
+
*
|
|
79
|
+
* // With specific algorithms
|
|
80
|
+
* const payload = await jwtVerify(token, secret, { algorithms: ['HS512'] });
|
|
81
|
+
* ```
|
|
82
|
+
*/
|
|
83
|
+
async function jwtVerify(token, secret, options = {}) {
|
|
84
|
+
const algorithms = options.algorithms || ["HS256"];
|
|
85
|
+
const parts = token.split(".");
|
|
86
|
+
if (parts.length !== 3) throw new Error("Invalid JWT format");
|
|
87
|
+
const [headerB64, payloadB64, signatureB64] = parts;
|
|
88
|
+
const header = JSON.parse(atob(headerB64.replace(/-/g, "+").replace(/_/g, "/")));
|
|
89
|
+
if (!algorithms.includes(header.alg)) throw new Error(`Unsupported algorithm: ${header.alg}`);
|
|
90
|
+
const hashAlg = ALGORITHM_MAP[header.alg];
|
|
91
|
+
if (!hashAlg) throw new Error(`Unknown algorithm: ${header.alg}`);
|
|
92
|
+
const payload = JSON.parse(atob(payloadB64.replace(/-/g, "+").replace(/_/g, "/")));
|
|
93
|
+
const data = `${headerB64}.${payloadB64}`;
|
|
94
|
+
const signature = Uint8Array.from(atob(signatureB64.replace(/-/g, "+").replace(/_/g, "/")), (c) => c.charCodeAt(0));
|
|
95
|
+
let key;
|
|
96
|
+
if (secret instanceof CryptoKey) key = secret;
|
|
97
|
+
else {
|
|
98
|
+
const keyData = typeof secret === "string" ? new TextEncoder().encode(secret) : secret;
|
|
99
|
+
key = await crypto.subtle.importKey("raw", keyData, {
|
|
100
|
+
name: "HMAC",
|
|
101
|
+
hash: hashAlg
|
|
102
|
+
}, false, ["verify"]);
|
|
103
|
+
}
|
|
104
|
+
if (!await crypto.subtle.verify("HMAC", key, signature, new TextEncoder().encode(data))) throw new Error("Invalid signature");
|
|
105
|
+
if (payload.exp && payload.exp < Date.now() / 1e3) throw new Error("Token expired");
|
|
106
|
+
if (payload.nbf && payload.nbf > Date.now() / 1e3) throw new Error("Token not yet valid");
|
|
107
|
+
return payload;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Parses a duration string (e.g., '1h', '7d', '30m') to seconds.
|
|
111
|
+
*/
|
|
112
|
+
function parseDuration(duration) {
|
|
113
|
+
if (typeof duration === "number") return duration;
|
|
114
|
+
const match = duration.match(/^(\d+)\s*(s|m|h|d|w)$/i);
|
|
115
|
+
if (!match) throw new Error(`Invalid duration format: ${duration}. Use formats like '1h', '7d', '30m', '60s'.`);
|
|
116
|
+
const value = parseInt(match[1], 10);
|
|
117
|
+
const unit = match[2].toLowerCase();
|
|
118
|
+
switch (unit) {
|
|
119
|
+
case "s": return value;
|
|
120
|
+
case "m": return value * 60;
|
|
121
|
+
case "h": return value * 60 * 60;
|
|
122
|
+
case "d": return value * 60 * 60 * 24;
|
|
123
|
+
case "w": return value * 60 * 60 * 24 * 7;
|
|
124
|
+
default: throw new Error(`Unknown duration unit: ${unit}`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Base64url encodes a string or Uint8Array.
|
|
129
|
+
*/
|
|
130
|
+
function base64urlEncode(data) {
|
|
131
|
+
const bytes = typeof data === "string" ? new TextEncoder().encode(data) : data;
|
|
132
|
+
const binary = String.fromCharCode(...bytes);
|
|
133
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Signs a JWT token using Web Crypto API.
|
|
137
|
+
*
|
|
138
|
+
* Uses only Web Crypto API, compatible with Cloudflare Workers, Deno, and browsers.
|
|
139
|
+
*
|
|
140
|
+
* @param payload - The JWT payload (custom claims)
|
|
141
|
+
* @param secret - The signing secret (string, ArrayBuffer, or CryptoKey)
|
|
142
|
+
* @param options - Optional signing options (algorithm, expiresIn, issuer, etc.)
|
|
143
|
+
* @returns The signed JWT token string
|
|
144
|
+
*
|
|
145
|
+
* @example
|
|
146
|
+
* ```typescript
|
|
147
|
+
* // Basic usage (HS256 by default)
|
|
148
|
+
* const token = await jwtSign(
|
|
149
|
+
* { uid: 'user-123', role: 'admin' },
|
|
150
|
+
* 'your-secret-key',
|
|
151
|
+
* { expiresIn: '1h' }
|
|
152
|
+
* );
|
|
153
|
+
*
|
|
154
|
+
* // With HS512 algorithm
|
|
155
|
+
* const token = await jwtSign(
|
|
156
|
+
* { uid: 'user-123' },
|
|
157
|
+
* process.env.JWT_SECRET,
|
|
158
|
+
* { algorithm: 'HS512', expiresIn: '7d' }
|
|
159
|
+
* );
|
|
160
|
+
* ```
|
|
161
|
+
*/
|
|
162
|
+
async function jwtSign(payload, secret, options = {}) {
|
|
163
|
+
const algorithm = options.algorithm || "HS256";
|
|
164
|
+
const hashAlg = ALGORITHM_MAP[algorithm];
|
|
165
|
+
const now = options.issuedAt ? options.issuedAt instanceof Date ? Math.floor(options.issuedAt.getTime() / 1e3) : options.issuedAt : Math.floor(Date.now() / 1e3);
|
|
166
|
+
const finalPayload = {
|
|
167
|
+
...payload,
|
|
168
|
+
iat: now
|
|
169
|
+
};
|
|
170
|
+
if (options.expiresIn !== void 0) finalPayload.exp = now + parseDuration(options.expiresIn);
|
|
171
|
+
if (options.notBefore !== void 0) finalPayload.nbf = now + parseDuration(options.notBefore);
|
|
172
|
+
if (options.issuer !== void 0) finalPayload.iss = options.issuer;
|
|
173
|
+
if (options.audience !== void 0) finalPayload.aud = options.audience;
|
|
174
|
+
if (options.subject !== void 0) finalPayload.sub = options.subject;
|
|
175
|
+
const header = {
|
|
176
|
+
alg: algorithm,
|
|
177
|
+
typ: "JWT"
|
|
178
|
+
};
|
|
179
|
+
const data = `${base64urlEncode(JSON.stringify(header))}.${base64urlEncode(JSON.stringify(finalPayload))}`;
|
|
180
|
+
let key;
|
|
181
|
+
if (secret instanceof CryptoKey) key = secret;
|
|
182
|
+
else {
|
|
183
|
+
const keyData = typeof secret === "string" ? new TextEncoder().encode(secret) : secret;
|
|
184
|
+
key = await crypto.subtle.importKey("raw", keyData, {
|
|
185
|
+
name: "HMAC",
|
|
186
|
+
hash: hashAlg
|
|
187
|
+
}, false, ["sign"]);
|
|
188
|
+
}
|
|
189
|
+
const signatureBuffer = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(data));
|
|
190
|
+
return `${data}.${base64urlEncode(new Uint8Array(signatureBuffer))}`;
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Creates a JWT authentication middleware.
|
|
194
|
+
*
|
|
195
|
+
* @example
|
|
196
|
+
* ```typescript
|
|
197
|
+
* interface AppContext {
|
|
198
|
+
* env: { JWT_SECRET: string };
|
|
199
|
+
* user: { id: string; email: string };
|
|
200
|
+
* }
|
|
201
|
+
*
|
|
202
|
+
* jwtAuth<AppContext>({
|
|
203
|
+
* secret: (ctx) => ctx.env.JWT_SECRET, // ctx.env is typed
|
|
204
|
+
* claims: (payload) => ({
|
|
205
|
+
* user: { id: payload.sub, email: payload.email },
|
|
206
|
+
* }),
|
|
207
|
+
* })
|
|
208
|
+
*
|
|
209
|
+
* // In your route handler:
|
|
210
|
+
* route.ctx<AppContext>()({
|
|
211
|
+
* handler: async (c) => {
|
|
212
|
+
* return { email: c.user.email }; // Fully typed
|
|
213
|
+
* },
|
|
214
|
+
* })
|
|
215
|
+
* ```
|
|
216
|
+
*/
|
|
217
|
+
function jwtAuth(options) {
|
|
218
|
+
const getToken = options.getToken || ((req) => {
|
|
219
|
+
const auth = req.headers.get("Authorization");
|
|
220
|
+
if (auth?.startsWith("Bearer ")) return auth.slice(7);
|
|
221
|
+
const cookie = req.headers.get("Cookie");
|
|
222
|
+
if (cookie) {
|
|
223
|
+
const match = cookie.match(/(?:^|;\s*)token=([^;]*)/);
|
|
224
|
+
if (match) return match[1];
|
|
225
|
+
}
|
|
226
|
+
return null;
|
|
227
|
+
});
|
|
228
|
+
return async (context, next) => {
|
|
229
|
+
const { request } = context;
|
|
230
|
+
const token = getToken(request);
|
|
231
|
+
if (!token) return new Response("Missing token", {
|
|
232
|
+
status: 401,
|
|
233
|
+
headers: { "WWW-Authenticate": "Bearer" }
|
|
234
|
+
});
|
|
235
|
+
try {
|
|
236
|
+
const payload = await jwtVerify(token, typeof options.secret === "function" ? options.secret(context) : options.secret, { algorithms: options.algorithms });
|
|
237
|
+
const claims = options.claims(payload);
|
|
238
|
+
if (claims === null) throw new Error("Token validation failed");
|
|
239
|
+
Object.assign(context, claims);
|
|
240
|
+
return next();
|
|
241
|
+
} catch (error) {
|
|
242
|
+
const message = error instanceof Error ? error.message : "Invalid token";
|
|
243
|
+
return new Response(message, {
|
|
244
|
+
status: 401,
|
|
245
|
+
headers: { "WWW-Authenticate": `Bearer error="invalid_token", error_description="${message}"` }
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Creates a simple bearer token authentication middleware.
|
|
252
|
+
*
|
|
253
|
+
* @example
|
|
254
|
+
* ```typescript
|
|
255
|
+
* const apiRouter = router('/api', routes,
|
|
256
|
+
* bearerAuth({
|
|
257
|
+
* verify: async (token) => {
|
|
258
|
+
* if (token === process.env.API_TOKEN) {
|
|
259
|
+
* return { apiClient: 'trusted' };
|
|
260
|
+
* }
|
|
261
|
+
* return null;
|
|
262
|
+
* }
|
|
263
|
+
* })
|
|
264
|
+
* );
|
|
265
|
+
* ```
|
|
266
|
+
*/
|
|
267
|
+
function bearerAuth(options) {
|
|
268
|
+
return async (context, next) => {
|
|
269
|
+
const { request } = context;
|
|
270
|
+
const authHeader = request.headers.get("Authorization");
|
|
271
|
+
if (!authHeader?.startsWith("Bearer ")) return new Response("Missing token", {
|
|
272
|
+
status: 401,
|
|
273
|
+
headers: { "WWW-Authenticate": "Bearer" }
|
|
274
|
+
});
|
|
275
|
+
const token = authHeader.slice(7);
|
|
276
|
+
const claims = await options.verify(token, context);
|
|
277
|
+
if (claims === null) return new Response("Invalid token", {
|
|
278
|
+
status: 401,
|
|
279
|
+
headers: { "WWW-Authenticate": "Bearer error=\"invalid_token\"" }
|
|
280
|
+
});
|
|
281
|
+
Object.assign(context, claims);
|
|
282
|
+
return next();
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
//#endregion
|
|
287
|
+
//#region src/middleware/common.ts
|
|
288
|
+
/**
|
|
289
|
+
* HTTP error with status code for use with errorHandler middleware.
|
|
290
|
+
*
|
|
291
|
+
* Throw this in route handlers to return a specific HTTP status code.
|
|
292
|
+
* The errorHandler middleware will catch it and format the response.
|
|
293
|
+
*
|
|
294
|
+
* @example
|
|
295
|
+
* ```typescript
|
|
296
|
+
* import { HttpError } from '@fresho/router/middleware';
|
|
297
|
+
*
|
|
298
|
+
* const getUser = route({
|
|
299
|
+
* method: 'get',
|
|
300
|
+
* path: '/users/:id',
|
|
301
|
+
* handler: async (c) => {
|
|
302
|
+
* const user = await db.findUser(c.path.id);
|
|
303
|
+
* if (!user) throw new HttpError('User not found', 404);
|
|
304
|
+
* return user;
|
|
305
|
+
* },
|
|
306
|
+
* });
|
|
307
|
+
* ```
|
|
308
|
+
*/
|
|
309
|
+
var HttpError = class extends Error {
|
|
310
|
+
constructor(message, status) {
|
|
311
|
+
super(message);
|
|
312
|
+
this.status = status;
|
|
313
|
+
this.name = "HttpError";
|
|
314
|
+
}
|
|
315
|
+
};
|
|
316
|
+
/**
|
|
317
|
+
* Extracts HTTP status code from an error object.
|
|
318
|
+
*
|
|
319
|
+
* Supports errors with `status` or `statusCode` properties (common patterns
|
|
320
|
+
* in frameworks like Express, Koa, Hono, and custom HTTP error classes).
|
|
321
|
+
*/
|
|
322
|
+
function getErrorStatus(error) {
|
|
323
|
+
if (typeof error !== "object" || error === null) return 500;
|
|
324
|
+
if ("status" in error && typeof error.status === "number") return error.status;
|
|
325
|
+
if ("statusCode" in error && typeof error.statusCode === "number") return error.statusCode;
|
|
326
|
+
return 500;
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* Creates an error handling middleware that catches and formats errors.
|
|
330
|
+
*
|
|
331
|
+
* @example
|
|
332
|
+
* ```typescript
|
|
333
|
+
* const apiRouter = router('/api', routes, errorHandler({ expose: false }));
|
|
334
|
+
* ```
|
|
335
|
+
*/
|
|
336
|
+
function errorHandler(options = {}) {
|
|
337
|
+
return async (context, next) => {
|
|
338
|
+
try {
|
|
339
|
+
return await next();
|
|
340
|
+
} catch (error) {
|
|
341
|
+
const err = error;
|
|
342
|
+
if (options.log) await options.log(err, context);
|
|
343
|
+
else console.error(`Error in ${context.request.method} ${context.request.url}:`, err);
|
|
344
|
+
if (options.formatter) return await options.formatter(err, context);
|
|
345
|
+
const message = options.expose ? err.message : "Internal Server Error";
|
|
346
|
+
const status = getErrorStatus(error);
|
|
347
|
+
const errorBody = {
|
|
348
|
+
error: message,
|
|
349
|
+
...options.expose && {
|
|
350
|
+
stack: err.stack,
|
|
351
|
+
name: err.name
|
|
352
|
+
}
|
|
353
|
+
};
|
|
354
|
+
return new Response(JSON.stringify(errorBody), {
|
|
355
|
+
status,
|
|
356
|
+
headers: { "Content-Type": "application/json" }
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
/**
|
|
362
|
+
* Creates a request/response logging middleware.
|
|
363
|
+
*
|
|
364
|
+
* @example
|
|
365
|
+
* ```typescript
|
|
366
|
+
* const apiRouter = router('/api', routes, logger({ includeHeaders: true }));
|
|
367
|
+
* ```
|
|
368
|
+
*/
|
|
369
|
+
function logger(options = {}) {
|
|
370
|
+
const log = options.log || console.log;
|
|
371
|
+
return async (context, next) => {
|
|
372
|
+
const { request } = context;
|
|
373
|
+
const start = Date.now();
|
|
374
|
+
const url = new URL(request.url);
|
|
375
|
+
const info = {
|
|
376
|
+
method: request.method,
|
|
377
|
+
url: `${url.pathname}${url.search}`,
|
|
378
|
+
status: 0,
|
|
379
|
+
duration: 0
|
|
380
|
+
};
|
|
381
|
+
if (options.includeHeaders) info.headers = Object.fromEntries(request.headers.entries());
|
|
382
|
+
if (options.includeBody && request.body) try {
|
|
383
|
+
const text = await request.clone().text();
|
|
384
|
+
try {
|
|
385
|
+
info.body = JSON.parse(text);
|
|
386
|
+
} catch {
|
|
387
|
+
info.body = text;
|
|
388
|
+
}
|
|
389
|
+
} catch {
|
|
390
|
+
info.body = "[Body not readable]";
|
|
391
|
+
}
|
|
392
|
+
log(options.formatter ? options.formatter({
|
|
393
|
+
...info,
|
|
394
|
+
status: 0,
|
|
395
|
+
duration: 0
|
|
396
|
+
}) : `→ ${info.method} ${info.url}`);
|
|
397
|
+
try {
|
|
398
|
+
const response = await next();
|
|
399
|
+
info.status = response.status;
|
|
400
|
+
info.duration = Date.now() - start;
|
|
401
|
+
log(options.formatter ? options.formatter(info) : `← ${info.status} ${response.statusText || "OK"} (${info.duration}ms)`);
|
|
402
|
+
return response;
|
|
403
|
+
} catch (error) {
|
|
404
|
+
info.status = 500;
|
|
405
|
+
info.duration = Date.now() - start;
|
|
406
|
+
info.error = error;
|
|
407
|
+
log(options.formatter ? options.formatter(info) : `✗ ${info.status} Error: ${error.message} (${info.duration}ms)`);
|
|
408
|
+
throw error;
|
|
409
|
+
}
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
/** In-memory rate limit store with periodic cleanup of expired entries. */
|
|
413
|
+
var MemoryRateLimitStore = class {
|
|
414
|
+
counts = /* @__PURE__ */ new Map();
|
|
415
|
+
lastCleanup = Date.now();
|
|
416
|
+
/** Interval between cleanups (default: 60 seconds). */
|
|
417
|
+
cleanupIntervalMs;
|
|
418
|
+
constructor(windowMs, options) {
|
|
419
|
+
this.windowMs = windowMs;
|
|
420
|
+
this.cleanupIntervalMs = options?.cleanupIntervalMs ?? Math.max(windowMs, 6e4);
|
|
421
|
+
}
|
|
422
|
+
async increment(key) {
|
|
423
|
+
const now = Date.now();
|
|
424
|
+
if (now - this.lastCleanup > this.cleanupIntervalMs) this.cleanup(now);
|
|
425
|
+
const record = this.counts.get(key);
|
|
426
|
+
if (!record || now > record.resetAt) {
|
|
427
|
+
this.counts.set(key, {
|
|
428
|
+
count: 1,
|
|
429
|
+
resetAt: now + this.windowMs
|
|
430
|
+
});
|
|
431
|
+
return 1;
|
|
432
|
+
}
|
|
433
|
+
record.count++;
|
|
434
|
+
return record.count;
|
|
435
|
+
}
|
|
436
|
+
async decrement(key) {
|
|
437
|
+
const record = this.counts.get(key);
|
|
438
|
+
if (record && record.count > 0) record.count--;
|
|
439
|
+
}
|
|
440
|
+
async reset(key) {
|
|
441
|
+
this.counts.delete(key);
|
|
442
|
+
}
|
|
443
|
+
/** Removes all expired entries from the store. */
|
|
444
|
+
cleanup(now) {
|
|
445
|
+
for (const [key, record] of this.counts) if (now > record.resetAt) this.counts.delete(key);
|
|
446
|
+
this.lastCleanup = now;
|
|
447
|
+
}
|
|
448
|
+
/** Returns the current number of tracked keys (useful for testing). */
|
|
449
|
+
get size() {
|
|
450
|
+
return this.counts.size;
|
|
451
|
+
}
|
|
452
|
+
};
|
|
453
|
+
/**
|
|
454
|
+
* Creates a rate limiting middleware.
|
|
455
|
+
*
|
|
456
|
+
* @example
|
|
457
|
+
* ```typescript
|
|
458
|
+
* const apiRouter = router('/api', routes, rateLimit({ max: 100, windowMs: 60000 }));
|
|
459
|
+
* ```
|
|
460
|
+
*/
|
|
461
|
+
function rateLimit(options = {}) {
|
|
462
|
+
const config = {
|
|
463
|
+
windowMs: 60 * 1e3,
|
|
464
|
+
max: 100,
|
|
465
|
+
keyGenerator: (ctx) => {
|
|
466
|
+
const cfIp = ctx.request.headers.get("CF-Connecting-IP");
|
|
467
|
+
const xForwardedFor = ctx.request.headers.get("X-Forwarded-For");
|
|
468
|
+
const xRealIp = ctx.request.headers.get("X-Real-IP");
|
|
469
|
+
return cfIp || xForwardedFor?.split(",")[0] || xRealIp || "unknown";
|
|
470
|
+
},
|
|
471
|
+
...options
|
|
472
|
+
};
|
|
473
|
+
const store = config.store || new MemoryRateLimitStore(config.windowMs);
|
|
474
|
+
return async (context, next) => {
|
|
475
|
+
const key = config.keyGenerator(context);
|
|
476
|
+
const current = await store.increment(key);
|
|
477
|
+
if (current > config.max) {
|
|
478
|
+
if (config.handler) return await config.handler(context);
|
|
479
|
+
return new Response("Too many requests", {
|
|
480
|
+
status: 429,
|
|
481
|
+
headers: {
|
|
482
|
+
"Retry-After": String(Math.ceil(config.windowMs / 1e3)),
|
|
483
|
+
"X-RateLimit-Limit": String(config.max),
|
|
484
|
+
"X-RateLimit-Remaining": "0",
|
|
485
|
+
"X-RateLimit-Reset": new Date(Date.now() + config.windowMs).toISOString()
|
|
486
|
+
}
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
const response = await next();
|
|
490
|
+
if (config.skipSuccessfulRequests && response.status < 400 || config.skipFailedRequests && response.status >= 400) await store.decrement(key);
|
|
491
|
+
const headers = new Headers(response.headers);
|
|
492
|
+
headers.set("X-RateLimit-Limit", String(config.max));
|
|
493
|
+
headers.set("X-RateLimit-Remaining", String(Math.max(0, config.max - current)));
|
|
494
|
+
headers.set("X-RateLimit-Reset", new Date(Date.now() + config.windowMs).toISOString());
|
|
495
|
+
return new Response(response.body, {
|
|
496
|
+
status: response.status,
|
|
497
|
+
statusText: response.statusText,
|
|
498
|
+
headers
|
|
499
|
+
});
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
/**
|
|
503
|
+
* Creates a middleware that adds a unique request ID to each request.
|
|
504
|
+
*
|
|
505
|
+
* @example
|
|
506
|
+
* ```typescript
|
|
507
|
+
* const apiRouter = router('/api', routes, requestId({ headerName: 'X-Request-ID' }));
|
|
508
|
+
* ```
|
|
509
|
+
*/
|
|
510
|
+
function requestId(options = {}) {
|
|
511
|
+
const headerName = options.headerName || "X-Request-ID";
|
|
512
|
+
const generator = options.generator || (() => crypto.randomUUID());
|
|
513
|
+
return async (context, next) => {
|
|
514
|
+
let id = context.request.headers.get(headerName);
|
|
515
|
+
if (!id) id = generator();
|
|
516
|
+
context.requestId = id;
|
|
517
|
+
const response = await next();
|
|
518
|
+
const headers = new Headers(response.headers);
|
|
519
|
+
headers.set(headerName, id);
|
|
520
|
+
return new Response(response.body, {
|
|
521
|
+
status: response.status,
|
|
522
|
+
statusText: response.statusText,
|
|
523
|
+
headers
|
|
524
|
+
});
|
|
525
|
+
};
|
|
526
|
+
}
|
|
527
|
+
/**
|
|
528
|
+
* Creates a middleware that times out requests after a specified duration.
|
|
529
|
+
*
|
|
530
|
+
* @example
|
|
531
|
+
* ```typescript
|
|
532
|
+
* const apiRouter = router('/api', routes, timeout({ timeout: 5000 }));
|
|
533
|
+
* ```
|
|
534
|
+
*/
|
|
535
|
+
function timeout(options) {
|
|
536
|
+
return async (context, next) => {
|
|
537
|
+
const controller = new AbortController();
|
|
538
|
+
const timeoutId = setTimeout(() => controller.abort(), options.timeout);
|
|
539
|
+
try {
|
|
540
|
+
const responsePromise = next();
|
|
541
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
542
|
+
controller.signal.addEventListener("abort", () => {
|
|
543
|
+
reject(/* @__PURE__ */ new Error("Request timeout"));
|
|
544
|
+
});
|
|
545
|
+
});
|
|
546
|
+
const response = await Promise.race([responsePromise, timeoutPromise]);
|
|
547
|
+
clearTimeout(timeoutId);
|
|
548
|
+
return response;
|
|
549
|
+
} catch (error) {
|
|
550
|
+
clearTimeout(timeoutId);
|
|
551
|
+
if (error.message === "Request timeout") return new Response(options.message || "Request timeout", { status: 408 });
|
|
552
|
+
throw error;
|
|
553
|
+
}
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
/**
|
|
557
|
+
* Creates a middleware that validates request content type.
|
|
558
|
+
*
|
|
559
|
+
* @example
|
|
560
|
+
* ```typescript
|
|
561
|
+
* const apiRouter = router('/api', routes, contentType({ types: ['application/json'] }));
|
|
562
|
+
* ```
|
|
563
|
+
*/
|
|
564
|
+
function contentType(options) {
|
|
565
|
+
const skipMethods = new Set(options.skipMethods?.map((m) => m.toUpperCase()) || [
|
|
566
|
+
"GET",
|
|
567
|
+
"HEAD",
|
|
568
|
+
"OPTIONS"
|
|
569
|
+
]);
|
|
570
|
+
return async (context, next) => {
|
|
571
|
+
if (skipMethods.has(context.request.method.toUpperCase())) return next();
|
|
572
|
+
const contentType$1 = context.request.headers.get("Content-Type");
|
|
573
|
+
if (!contentType$1) return new Response(options.message || "Missing Content-Type header", { status: 415 });
|
|
574
|
+
if (!options.types.some((type) => contentType$1.includes(type))) return new Response(options.message || `Unsupported Media Type. Expected: ${options.types.join(", ")}`, { status: 415 });
|
|
575
|
+
return next();
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
//#endregion
|
|
580
|
+
//#region src/middleware/cors.ts
|
|
581
|
+
/**
|
|
582
|
+
* Determines if an origin is allowed based on the configuration.
|
|
583
|
+
*/
|
|
584
|
+
function getAllowedOrigin(requestOrigin, configOrigin) {
|
|
585
|
+
if (!requestOrigin) return "*";
|
|
586
|
+
if (!configOrigin || configOrigin === "*") return "*";
|
|
587
|
+
if (typeof configOrigin === "string") return configOrigin === requestOrigin ? configOrigin : "false";
|
|
588
|
+
if (Array.isArray(configOrigin)) return configOrigin.includes(requestOrigin) ? requestOrigin : "false";
|
|
589
|
+
if (configOrigin instanceof RegExp) return configOrigin.test(requestOrigin) ? requestOrigin : "false";
|
|
590
|
+
if (typeof configOrigin === "function") return configOrigin(requestOrigin) ? requestOrigin : "false";
|
|
591
|
+
return "false";
|
|
592
|
+
}
|
|
593
|
+
/**
|
|
594
|
+
* Creates a CORS middleware with the specified options.
|
|
595
|
+
*
|
|
596
|
+
* @example
|
|
597
|
+
* ```typescript
|
|
598
|
+
* const apiRouter = router('/api', routes, cors({ origin: 'https://example.com', credentials: true }));
|
|
599
|
+
* ```
|
|
600
|
+
*/
|
|
601
|
+
function cors(options = {}) {
|
|
602
|
+
const config = {
|
|
603
|
+
origin: "*",
|
|
604
|
+
methods: [
|
|
605
|
+
"GET",
|
|
606
|
+
"POST",
|
|
607
|
+
"PUT",
|
|
608
|
+
"PATCH",
|
|
609
|
+
"DELETE",
|
|
610
|
+
"OPTIONS",
|
|
611
|
+
"HEAD"
|
|
612
|
+
],
|
|
613
|
+
allowedHeaders: ["Content-Type", "Authorization"],
|
|
614
|
+
exposedHeaders: [],
|
|
615
|
+
credentials: false,
|
|
616
|
+
maxAge: 86400,
|
|
617
|
+
preflightContinue: false,
|
|
618
|
+
...options
|
|
619
|
+
};
|
|
620
|
+
return async (context, next) => {
|
|
621
|
+
const { request } = context;
|
|
622
|
+
const allowedOrigin = getAllowedOrigin(request.headers.get("Origin"), config.origin);
|
|
623
|
+
if (request.method === "OPTIONS") {
|
|
624
|
+
const headers$1 = {
|
|
625
|
+
"Access-Control-Allow-Origin": allowedOrigin,
|
|
626
|
+
"Access-Control-Allow-Methods": config.methods.join(", "),
|
|
627
|
+
Vary: "Origin"
|
|
628
|
+
};
|
|
629
|
+
if (config.allowedHeaders.length > 0) {
|
|
630
|
+
const requestHeaders = request.headers.get("Access-Control-Request-Headers");
|
|
631
|
+
if (requestHeaders) {
|
|
632
|
+
const requested = requestHeaders.split(",").map((h) => h.trim().toLowerCase());
|
|
633
|
+
const allowed = config.allowedHeaders.map((h) => h.toLowerCase());
|
|
634
|
+
const toAllow = requested.filter((h) => allowed.includes(h));
|
|
635
|
+
if (toAllow.length > 0) headers$1["Access-Control-Allow-Headers"] = toAllow.join(", ");
|
|
636
|
+
} else headers$1["Access-Control-Allow-Headers"] = config.allowedHeaders.join(", ");
|
|
637
|
+
}
|
|
638
|
+
if (config.exposedHeaders.length > 0) headers$1["Access-Control-Expose-Headers"] = config.exposedHeaders.join(", ");
|
|
639
|
+
if (config.credentials) headers$1["Access-Control-Allow-Credentials"] = "true";
|
|
640
|
+
if (config.maxAge) headers$1["Access-Control-Max-Age"] = String(config.maxAge);
|
|
641
|
+
const response$1 = new Response(null, {
|
|
642
|
+
status: 204,
|
|
643
|
+
headers: headers$1
|
|
644
|
+
});
|
|
645
|
+
if (config.preflightContinue) {
|
|
646
|
+
const nextResponse = await next();
|
|
647
|
+
const mergedHeaders = new Headers(nextResponse.headers);
|
|
648
|
+
Object.entries(headers$1).forEach(([key, value]) => {
|
|
649
|
+
mergedHeaders.set(key, value);
|
|
650
|
+
});
|
|
651
|
+
return new Response(nextResponse.body, {
|
|
652
|
+
status: nextResponse.status,
|
|
653
|
+
statusText: nextResponse.statusText,
|
|
654
|
+
headers: mergedHeaders
|
|
655
|
+
});
|
|
656
|
+
}
|
|
657
|
+
return response$1;
|
|
658
|
+
}
|
|
659
|
+
const response = await next();
|
|
660
|
+
const headers = new Headers(response.headers);
|
|
661
|
+
headers.set("Access-Control-Allow-Origin", allowedOrigin);
|
|
662
|
+
headers.set("Vary", "Origin");
|
|
663
|
+
if (config.credentials && allowedOrigin !== "*") headers.set("Access-Control-Allow-Credentials", "true");
|
|
664
|
+
if (config.exposedHeaders.length > 0) headers.set("Access-Control-Expose-Headers", config.exposedHeaders.join(", "));
|
|
665
|
+
return new Response(response.body, {
|
|
666
|
+
status: response.status,
|
|
667
|
+
statusText: response.statusText,
|
|
668
|
+
headers
|
|
669
|
+
});
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
//#endregion
|
|
674
|
+
export { HttpError, MemoryRateLimitStore, basicAuth, bearerAuth, compose, contentType, cors, errorHandler, jwtAuth, jwtSign, jwtVerify, logger, rateLimit, requestId, timeout };
|
|
675
|
+
//# sourceMappingURL=index.mjs.map
|