@fishka/express 0.9.5

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.
Files changed (38) hide show
  1. package/README.md +137 -0
  2. package/dist/cjs/api.types.js +38 -0
  3. package/dist/cjs/auth/auth-strategy.js +62 -0
  4. package/dist/cjs/auth/auth.types.js +2 -0
  5. package/dist/cjs/auth/auth.utils.js +64 -0
  6. package/dist/cjs/auth/bearer-auth-strategy.js +59 -0
  7. package/dist/cjs/config.js +29 -0
  8. package/dist/cjs/error-handling.js +70 -0
  9. package/dist/cjs/http.types.js +38 -0
  10. package/dist/cjs/index.js +31 -0
  11. package/dist/cjs/rate-limit/in-memory-rate-limiter.js +88 -0
  12. package/dist/cjs/rate-limit/rate-limit.js +34 -0
  13. package/dist/cjs/rate-limit/rate-limit.types.js +2 -0
  14. package/dist/cjs/route-table.js +45 -0
  15. package/dist/cjs/router.js +245 -0
  16. package/dist/cjs/thread-local/thread-local-storage-middleware.js +27 -0
  17. package/dist/cjs/thread-local/thread-local-storage.js +31 -0
  18. package/dist/cjs/utils/conversion.utils.js +26 -0
  19. package/dist/cjs/utils/express.utils.js +2 -0
  20. package/dist/esm/api.types.js +31 -0
  21. package/dist/esm/auth/auth-strategy.js +58 -0
  22. package/dist/esm/auth/auth.types.js +1 -0
  23. package/dist/esm/auth/auth.utils.js +59 -0
  24. package/dist/esm/auth/bearer-auth-strategy.js +55 -0
  25. package/dist/esm/config.js +24 -0
  26. package/dist/esm/error-handling.js +66 -0
  27. package/dist/esm/http.types.js +35 -0
  28. package/dist/esm/index.js +15 -0
  29. package/dist/esm/rate-limit/in-memory-rate-limiter.js +88 -0
  30. package/dist/esm/rate-limit/rate-limit.js +30 -0
  31. package/dist/esm/rate-limit/rate-limit.types.js +1 -0
  32. package/dist/esm/route-table.js +40 -0
  33. package/dist/esm/router.js +203 -0
  34. package/dist/esm/thread-local/thread-local-storage-middleware.js +24 -0
  35. package/dist/esm/thread-local/thread-local-storage.js +27 -0
  36. package/dist/esm/utils/conversion.utils.js +22 -0
  37. package/dist/esm/utils/express.utils.js +1 -0
  38. package/package.json +70 -0
package/README.md ADDED
@@ -0,0 +1,137 @@
1
+ # Express API
2
+
3
+ Type-safe Express.js routing with clean, minimal API.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install @fishka/express
9
+ ```
10
+
11
+ ## Quick Start
12
+
13
+ ```typescript
14
+ import express from 'express';
15
+ import { createRouteTable } from '@fishka/express';
16
+
17
+ const app = express();
18
+ app.use(express.json());
19
+
20
+ const routes = createRouteTable(app);
21
+
22
+ // GET /users/:id - using function shorthand
23
+ routes.get<{ id: string; name: string }>('users/:id', async ctx => ({
24
+ id: ctx.params.get('id'),
25
+ name: 'John',
26
+ }));
27
+
28
+ // GET /users - using full endpoint object
29
+ routes.get<Array<{ id: string; name: string }>>('users', async () => [
30
+ { id: '1', name: 'John' },
31
+ { id: '2', name: 'Jane' },
32
+ ]);
33
+
34
+ // POST /users
35
+ routes.post<{ name: string }, { id: string }>('users', {
36
+ $body: { name: v => assertString(v, '400: name required') },
37
+ run: async ctx => ({ id: '1' }),
38
+ });
39
+
40
+ // DELETE /users/:id - using function shorthand
41
+ routes.delete('users/:id', async () => {
42
+ // Delete user logic
43
+ });
44
+
45
+ app.listen(3000);
46
+ ```
47
+
48
+ ## URL Parameters
49
+
50
+ Global validation can be enforced for specific URL parameters (e.g., `:id`, `:orgId`) across all routes.
51
+
52
+ ```typescript
53
+ import { registerUrlParameter } from '@fishka/express';
54
+ import { assertString } from '@fishka/assertions';
55
+
56
+ // Register parameters with optional validation
57
+ registerUrlParameter('orgId', {
58
+ validator: (val) => {
59
+ assertString(val);
60
+ if (!val.startsWith('org-')) throw new Error('400: Invalid Organization ID');
61
+ }
62
+ });
63
+
64
+ // Now /orgs/:orgId will automatically validate that orgId starts with 'org-'
65
+ ```
66
+
67
+ ## Authentication
68
+
69
+ ```typescript
70
+ import { createAuthMiddleware, BasicAuthStrategy, getAuthUser } from '@fishka/express';
71
+
72
+ const auth = new BasicAuthStrategy(async (user, pass) =>
73
+ user === 'admin' && pass === 'secret' ? { id: '1', role: 'admin' } : null,
74
+ );
75
+
76
+ routes.get('profile', {
77
+ middlewares: [createAuthMiddleware(auth)],
78
+ run: async ctx => {
79
+ const user = getAuthUser(ctx);
80
+ return { id: user.id };
81
+ },
82
+ });
83
+ ```
84
+
85
+ ## Rate Limiting
86
+
87
+ ```typescript
88
+ import { createRateLimiterMiddleware } from '@fishka/express';
89
+
90
+ app.use(
91
+ await createRateLimiterMiddleware({
92
+ points: { read: 100, write: 50 },
93
+ duration: 60,
94
+ }),
95
+ );
96
+ ```
97
+
98
+ ## Complete Example
99
+
100
+ Here is a full initialization including TLS context, global validation, and proper error handling.
101
+
102
+ ```typescript
103
+ import express from 'express';
104
+ import {
105
+ createRouteTable,
106
+ createTlsMiddleware,
107
+ catchAllMiddleware,
108
+ registerUrlParameter
109
+ } from '@fishka/express';
110
+ import { assertString } from '@fishka/assertions';
111
+
112
+ const app = express();
113
+
114
+ // 1. Basic express middleware
115
+ app.use(express.json());
116
+
117
+ // 2. Initialize TLS context (Request IDs, etc.)
118
+ app.use(createTlsMiddleware());
119
+
120
+ // 3. Register global URL parameters
121
+ registerUrlParameter('id', {
122
+ validator: (val) => assertString(val)
123
+ });
124
+
125
+ // 4. Define routes
126
+ const routes = createRouteTable(app);
127
+ routes.get('health', async () => ({ status: 'UP' }));
128
+
129
+ // 5. Global error handler (Must be after route definitions)
130
+ app.use(catchAllMiddleware);
131
+
132
+ app.listen(3000);
133
+ ```
134
+
135
+ ## License
136
+
137
+ MIT
@@ -0,0 +1,38 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.URL_PARAMETER_INFO = exports.HttpError = void 0;
4
+ exports.response = response;
5
+ exports.registerUrlParameter = registerUrlParameter;
6
+ exports.assertUrlParameter = assertUrlParameter;
7
+ const assertions_1 = require("@fishka/assertions");
8
+ class HttpError extends Error {
9
+ constructor(status, message, details) {
10
+ super(message);
11
+ this.status = status;
12
+ this.details = details;
13
+ // Restore prototype chain for instanceof checks
14
+ Object.setPrototypeOf(this, HttpError.prototype);
15
+ }
16
+ }
17
+ exports.HttpError = HttpError;
18
+ /** Converts an API response value into a standardized ApiResponse structure. */
19
+ function response(result) {
20
+ return { result };
21
+ }
22
+ /**
23
+ * Default documentation and validation for URL parameters.
24
+ * @Internal
25
+ */
26
+ exports.URL_PARAMETER_INFO = {};
27
+ /** Registers a new URL parameter. */
28
+ function registerUrlParameter(name, info) {
29
+ exports.URL_PARAMETER_INFO[name] = info;
30
+ }
31
+ /**
32
+ * Asserts that the value is a registered URL parameter name.
33
+ * @Internal
34
+ */
35
+ function assertUrlParameter(name) {
36
+ (0, assertions_1.assertString)(name, 'Url parameter name must be a string');
37
+ (0, assertions_1.assertTruthy)(exports.URL_PARAMETER_INFO[name], `Invalid URL parameter: '${name}'. Please register it using 'registerUrlParameter('${name}', ...)'`);
38
+ }
@@ -0,0 +1,62 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.BasicAuthStrategy = void 0;
4
+ const api_types_1 = require("../api.types");
5
+ const http_types_1 = require("../http.types");
6
+ /**
7
+ * Basic authentication strategy using username/password validation.
8
+ * Parses HTTP Basic Authorization header and validates credentials.
9
+ *
10
+ * Example usage:
11
+ * ```
12
+ * const strategy = new BasicAuthStrategy(
13
+ * async (username, password) => {
14
+ * const user = await db.users.findByUsername(username);
15
+ * if (user && await bcrypt.compare(password, user.hash)) {
16
+ * return user;
17
+ * }
18
+ * return null;
19
+ * }
20
+ * );
21
+ * ```
22
+ */
23
+ class BasicAuthStrategy {
24
+ constructor(verifyFn) {
25
+ this.verifyFn = verifyFn;
26
+ }
27
+ /**
28
+ * Extracts username and password from Basic auth header.
29
+ * Expected format: "Basic base64(username:password)"
30
+ * Returns undefined if header is missing or not Basic.
31
+ */
32
+ extractCredentials(req) {
33
+ const authHeaderValue = req.header('Authorization');
34
+ if (!authHeaderValue || !authHeaderValue.startsWith('Basic ')) {
35
+ return undefined;
36
+ }
37
+ try {
38
+ const decoded = Buffer.from(authHeaderValue.substring(6), 'base64').toString('utf-8');
39
+ const [username, password] = decoded.split(':');
40
+ // If format is "Basic base64(:)", it might mean empty username/password which is technically valid syntax but usually useless.
41
+ // However, split might return undefined for password if ":" is missing.
42
+ if (!username || password === undefined) {
43
+ return undefined;
44
+ }
45
+ return { username, password };
46
+ }
47
+ catch {
48
+ return undefined;
49
+ }
50
+ }
51
+ /**
52
+ * Validates the extracted credentials using the provided validation function.
53
+ */
54
+ async validateCredentials({ username, password }) {
55
+ const user = await this.verifyFn(username, password);
56
+ if (!user) {
57
+ throw new api_types_1.HttpError(http_types_1.UNAUTHORIZED_STATUS, 'Invalid username or password');
58
+ }
59
+ return user;
60
+ }
61
+ }
62
+ exports.BasicAuthStrategy = BasicAuthStrategy;
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -0,0 +1,64 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createAuthMiddleware = createAuthMiddleware;
4
+ exports.getAuthUser = getAuthUser;
5
+ exports.tryGetAuthUser = tryGetAuthUser;
6
+ const api_types_1 = require("../api.types");
7
+ const http_types_1 = require("../http.types");
8
+ /**
9
+ * Creates a middleware that enforces authentication using the provided strategy.
10
+ * The authenticated user is stored in the context under the 'authUser' key.
11
+ *
12
+ * @template User - Type of the authenticated user
13
+ * @param strategy - Authentication strategy to use
14
+ * @param onSuccess - Optional callback to process authenticated user
15
+ * @returns a middleware that enforces authentication
16
+ */
17
+ function createAuthMiddleware(strategy, onSuccess) {
18
+ return async (handler, context) => {
19
+ // Extract credentials from request
20
+ const credentials = strategy.extractCredentials(context.req);
21
+ // If no credentials found (and strategy returned undefined), we must deny access here.
22
+ // In a composite strategy scenario, we might want to try the next strategy, but this helper is for a single strategy enforcement.
23
+ if (!credentials) {
24
+ throw new api_types_1.HttpError(http_types_1.UNAUTHORIZED_STATUS, 'No credentials provided or invalid format');
25
+ }
26
+ // Validate credentials and get authenticated user
27
+ const user = await strategy.validateCredentials(credentials);
28
+ // Store authenticated user in state for the handler to access
29
+ context.authUser = user;
30
+ // Optional: Call success callback
31
+ if (onSuccess) {
32
+ onSuccess(user, context);
33
+ }
34
+ // Execute the actual handler
35
+ return handler();
36
+ };
37
+ }
38
+ /**
39
+ * Extracts the authenticated user from the request context.
40
+ * Throws if the user is not present (i.e., authentication was not performed).
41
+ *
42
+ * @template User - Type of the authenticated user
43
+ * @param context - Request context
44
+ * @returns The authenticated user
45
+ * @throws Error if user is not found in context
46
+ */
47
+ function getAuthUser(context) {
48
+ const user = context.authUser;
49
+ if (!user) {
50
+ throw new api_types_1.HttpError(http_types_1.UNAUTHORIZED_STATUS, 'User not found in context. Did you add auth middleware?');
51
+ }
52
+ return user;
53
+ }
54
+ /**
55
+ * Safely extracts the authenticated user from the request context.
56
+ * Returns undefined if the user is not present.
57
+ *
58
+ * @template User - Type of the authenticated user
59
+ * @param context - Request context
60
+ * @returns The authenticated user, or undefined if not found
61
+ */
62
+ function tryGetAuthUser(context) {
63
+ return context.authUser;
64
+ }
@@ -0,0 +1,59 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.BearerAuthStrategy = void 0;
4
+ const api_types_1 = require("../api.types");
5
+ const http_types_1 = require("../http.types");
6
+ /**
7
+ * Bearer authentication strategy (commonly used for JWTs).
8
+ * Extracts the token from the 'Authorization: Bearer <token>' header.
9
+ *
10
+ * The validation logic is delegated to the `verifyFn`, which can:
11
+ * - Validate a JWT signature locally.
12
+ * - Call an external API/website to verify the token (Introspection/UserInfo).
13
+ *
14
+ * Example usage:
15
+ * ```ts
16
+ * const strategy = new BearerAuthStrategy(async (token) => {
17
+ * // Call external website to validate
18
+ * const response = await fetch('https://auth.example.com/verify', {
19
+ * headers: { Authorization: `Bearer ${token}` }
20
+ * });
21
+ * if (!response.ok) return null;
22
+ * return await response.json();
23
+ * });
24
+ * ```
25
+ */
26
+ class BearerAuthStrategy {
27
+ /**
28
+ * @param verifyFn Function to validate the token. Returns the user if valid, or null if invalid.
29
+ */
30
+ constructor(verifyFn) {
31
+ this.verifyFn = verifyFn;
32
+ }
33
+ /**
34
+ * Extracts the Bearer token from the Authorization header.
35
+ * Returns undefined if the header is missing or not a Bearer token.
36
+ */
37
+ extractCredentials(req) {
38
+ const authHeader = req.header('Authorization');
39
+ if (!authHeader || !authHeader.startsWith('Bearer ')) {
40
+ return undefined;
41
+ }
42
+ const token = authHeader.substring(7).trim();
43
+ if (!token) {
44
+ return undefined;
45
+ }
46
+ return token;
47
+ }
48
+ /**
49
+ * Validates the extracted token using the provided verification function.
50
+ */
51
+ async validateCredentials(token) {
52
+ const user = await this.verifyFn(token);
53
+ if (!user) {
54
+ throw new api_types_1.HttpError(http_types_1.UNAUTHORIZED_STATUS, 'Invalid token');
55
+ }
56
+ return user;
57
+ }
58
+ }
59
+ exports.BearerAuthStrategy = BearerAuthStrategy;
@@ -0,0 +1,29 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.configureExpressApi = configureExpressApi;
4
+ exports.getExpressApiConfig = getExpressApiConfig;
5
+ exports.resetExpressApiConfig = resetExpressApiConfig;
6
+ const defaultConfig = {
7
+ trustRequestIdHeader: true,
8
+ };
9
+ let currentConfig = { ...defaultConfig };
10
+ /**
11
+ * Configure global @fishka/express settings.
12
+ * @param config Partial configuration to merge with current settings
13
+ */
14
+ function configureExpressApi(config) {
15
+ currentConfig = { ...currentConfig, ...config };
16
+ }
17
+ /**
18
+ * Get current Express API configuration.
19
+ */
20
+ function getExpressApiConfig() {
21
+ return currentConfig;
22
+ }
23
+ /**
24
+ * Reset API configuration to defaults.
25
+ * Useful for testing.
26
+ */
27
+ function resetExpressApiConfig() {
28
+ currentConfig = { ...defaultConfig };
29
+ }
@@ -0,0 +1,70 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.catchRouteErrors = catchRouteErrors;
4
+ exports.catchAllMiddleware = catchAllMiddleware;
5
+ const assertions_1 = require("@fishka/assertions");
6
+ const api_types_1 = require("./api.types");
7
+ const http_types_1 = require("./http.types");
8
+ const thread_local_storage_1 = require("./thread-local/thread-local-storage");
9
+ const conversion_utils_1 = require("./utils/conversion.utils");
10
+ function buildApiResponse(error) {
11
+ const tls = (0, thread_local_storage_1.getRequestLocalStorage)();
12
+ const requestId = tls?.requestId;
13
+ let response;
14
+ if (error instanceof api_types_1.HttpError) {
15
+ response = {
16
+ ...(0, conversion_utils_1.wrapAsApiResponse)(undefined),
17
+ error: error.message,
18
+ status: error.status,
19
+ details: error.details,
20
+ };
21
+ }
22
+ else {
23
+ const errorMessage = (0, assertions_1.getMessageFromError)(error, '');
24
+ response = {
25
+ ...(0, conversion_utils_1.wrapAsApiResponse)(undefined),
26
+ error: errorMessage && errorMessage.length > 0 ? errorMessage : 'Internal error',
27
+ status: http_types_1.INTERNAL_ERROR_STATUS,
28
+ };
29
+ }
30
+ if (requestId) {
31
+ response.requestId = requestId;
32
+ }
33
+ return response;
34
+ }
35
+ /** Catches all kinds of unprocessed exceptions thrown from a single route. */
36
+ function catchRouteErrors(fn) {
37
+ return async (req, res, next) => {
38
+ try {
39
+ await fn(req, res, next);
40
+ }
41
+ catch (error) {
42
+ const apiResponse = buildApiResponse(error);
43
+ if (apiResponse.status >= http_types_1.INTERNAL_ERROR_STATUS) {
44
+ console.error(`catchRouteErrors: ${req.path}`, error);
45
+ }
46
+ else {
47
+ console.log(`catchRouteErrors: ${req.path}`, error);
48
+ }
49
+ res.status(apiResponse.status);
50
+ res.send(apiResponse);
51
+ }
52
+ };
53
+ }
54
+ /**
55
+ * Catches all errors in Express.js and is installed as global middleware.
56
+ * Note that individual routes are wrapped with 'catchRouteErrors' middleware.
57
+ */
58
+ async function catchAllMiddleware(error, _, res, next) {
59
+ if (!error) {
60
+ next();
61
+ return;
62
+ }
63
+ // Report as critical. This kind of error should never happen.
64
+ console.error('catchAllMiddleware:', (0, assertions_1.getMessageFromError)(error));
65
+ const apiResponse = error instanceof SyntaxError // JSON body parsing error.
66
+ ? buildApiResponse(`${http_types_1.BAD_REQUEST_STATUS}: Failed to parse request: ${error.message}`)
67
+ : buildApiResponse(error);
68
+ res.status(apiResponse.status);
69
+ res.send(apiResponse);
70
+ }
@@ -0,0 +1,38 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.TOO_MANY_REQUESTS_STATUS = exports.OK_STATUS = exports.NOT_FOUND_STATUS = exports.FORBIDDEN_STATUS = exports.UNAUTHORIZED_STATUS = exports.BAD_REQUEST_STATUS = exports.INTERNAL_ERROR_STATUS = void 0;
4
+ /**
5
+ * Common HTTP status codes as numbers.
6
+ * @Internal
7
+ */
8
+ exports.INTERNAL_ERROR_STATUS = 500;
9
+ /**
10
+ * Common HTTP status codes as numbers.
11
+ * @Internal
12
+ */
13
+ exports.BAD_REQUEST_STATUS = 400;
14
+ /**
15
+ * Common HTTP status codes as numbers.
16
+ * @Internal
17
+ */
18
+ exports.UNAUTHORIZED_STATUS = 401;
19
+ /**
20
+ * Common HTTP status codes as numbers.
21
+ * @Internal
22
+ */
23
+ exports.FORBIDDEN_STATUS = 403;
24
+ /**
25
+ * Common HTTP status codes as numbers.
26
+ * @Internal
27
+ */
28
+ exports.NOT_FOUND_STATUS = 404;
29
+ /**
30
+ * Common HTTP status codes as numbers.
31
+ * @Internal
32
+ */
33
+ exports.OK_STATUS = 200;
34
+ /**
35
+ * Common HTTP status codes as numbers.
36
+ * @Internal
37
+ */
38
+ exports.TOO_MANY_REQUESTS_STATUS = 429;
@@ -0,0 +1,31 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
+ for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
+ };
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ __exportStar(require("./api.types"), exports);
18
+ __exportStar(require("./auth/auth-strategy"), exports);
19
+ __exportStar(require("./auth/auth.types"), exports);
20
+ __exportStar(require("./auth/auth.utils"), exports);
21
+ __exportStar(require("./auth/bearer-auth-strategy"), exports);
22
+ __exportStar(require("./config"), exports);
23
+ __exportStar(require("./error-handling"), exports);
24
+ __exportStar(require("./http.types"), exports);
25
+ __exportStar(require("./rate-limit/in-memory-rate-limiter"), exports);
26
+ __exportStar(require("./rate-limit/rate-limit.types"), exports);
27
+ __exportStar(require("./route-table"), exports);
28
+ __exportStar(require("./router"), exports);
29
+ __exportStar(require("./thread-local/thread-local-storage"), exports);
30
+ __exportStar(require("./thread-local/thread-local-storage-middleware"), exports);
31
+ __exportStar(require("./utils/express.utils"), exports);
@@ -0,0 +1,88 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.InMemoryRateLimiter = void 0;
4
+ exports.createRateLimiterMiddleware = createRateLimiterMiddleware;
5
+ const rate_limit_1 = require("./rate-limit");
6
+ const MILLIS_PER_SECOND = 1000;
7
+ /**
8
+ * In-memory rate limiter using sliding window counter.
9
+ * Tracks request counts per key with time-based window expiration.
10
+ */
11
+ class InMemoryRateLimiter {
12
+ constructor(points, durationSeconds) {
13
+ this.limits = new Map();
14
+ this.points = points;
15
+ this.durationMs = durationSeconds * MILLIS_PER_SECOND;
16
+ }
17
+ /**
18
+ * Try to consume points from the rate limit.
19
+ * Returns result if successful, throws if limit exceeded.
20
+ *
21
+ * @param key - Unique identifier for the client (e.g., IP address)
22
+ * @returns Rate limit result with remaining points and ms until reset
23
+ * @throws RateLimitResult if limit exceeded
24
+ */
25
+ consume(key) {
26
+ const now = Date.now();
27
+ const existing = this.limits.get(key);
28
+ // Reset expired entry
29
+ if (existing && now >= existing.resetTime) {
30
+ this.limits.delete(key);
31
+ }
32
+ const current = this.limits.get(key) || { count: 0, resetTime: now + this.durationMs };
33
+ if (current.count >= this.points) {
34
+ const msBeforeNext = Math.max(0, current.resetTime - now);
35
+ throw {
36
+ remainingPoints: 0,
37
+ msBeforeNext,
38
+ };
39
+ }
40
+ current.count++;
41
+ this.limits.set(key, current);
42
+ const msBeforeNext = Math.max(0, current.resetTime - now);
43
+ return {
44
+ remainingPoints: this.points - current.count,
45
+ msBeforeNext,
46
+ };
47
+ }
48
+ }
49
+ exports.InMemoryRateLimiter = InMemoryRateLimiter;
50
+ /**
51
+ * Creates a rate limiter middleware using in-memory implementation.
52
+ *
53
+ * Separate limiters are used for read (GET) and write (POST/PATCH/PUT/DELETE) requests.
54
+ *
55
+ * @param config - Rate limit configuration
56
+ * @returns Express middleware function
57
+ */
58
+ async function createRateLimiterMiddleware(config) {
59
+ const readLimiter = new InMemoryRateLimiter(config.points.read, config.duration);
60
+ const writeLimiter = new InMemoryRateLimiter(config.points.write, config.duration);
61
+ const whitelist = config.rateLimitWhitelist || ['/v1', '/health'];
62
+ /**
63
+ * The actual middleware function.
64
+ */
65
+ return async (req, res, next) => {
66
+ // Check if the path is whitelisted
67
+ if (whitelist.some(path => req.path.includes(path))) {
68
+ next();
69
+ return;
70
+ }
71
+ const isReadRequest = req.method === 'GET';
72
+ const limiter = isReadRequest ? readLimiter : writeLimiter;
73
+ const limitPoints = isReadRequest ? config.points.read : config.points.write;
74
+ const clientKey = req.ip || 'unknown';
75
+ try {
76
+ const result = limiter.consume(clientKey);
77
+ (0, rate_limit_1.addRateLimitHeaders)(res, result, limitPoints, config.duration);
78
+ next();
79
+ }
80
+ catch (error) {
81
+ const result = error;
82
+ (0, rate_limit_1.addRateLimitHeaders)(res, result, limitPoints, config.duration)
83
+ .header('Retry-After', `${(0, rate_limit_1.msToSeconds)(result.msBeforeNext)}`)
84
+ .status(429)
85
+ .send({ error: 'Too Many Requests' });
86
+ }
87
+ };
88
+ }
@@ -0,0 +1,34 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.addRateLimitHeaders = addRateLimitHeaders;
4
+ exports.msToSeconds = msToSeconds;
5
+ const MILLIS_PER_SECOND = 1000;
6
+ /**
7
+ * Adds rate limit state headers to the response.
8
+ * See https://datatracker.ietf.org/doc/draft-ietf-httpapi-ratelimit-headers/
9
+ *
10
+ * Headers added:
11
+ * - X-RateLimit-Limit: Server's quota for requests in the time window
12
+ * - X-RateLimit-Remaining: Remaining quota in the current window
13
+ * - X-RateLimit-Reset: Time remaining in the current window (in seconds)
14
+ * - X-RateLimit-Policy: Quota policies associated with the client
15
+ * @Internal
16
+ */
17
+ function addRateLimitHeaders(res, result, limitPoints, duration) {
18
+ return (res
19
+ // The server's quota for requests by the client in the time window.
20
+ .header('X-RateLimit-Limit', `${limitPoints}`)
21
+ // The remaining quota in the current window.
22
+ .header('X-RateLimit-Remaining', `${result.remainingPoints}`)
23
+ // The time remaining in the current window specified in seconds.
24
+ .header('X-RateLimit-Reset', `${Math.ceil(result.msBeforeNext / MILLIS_PER_SECOND)}`)
25
+ // Indicates the quota policies currently associated with the client.
26
+ .header('X-RateLimit-Policy', `${limitPoints};w=${duration};comment="fixed window"`));
27
+ }
28
+ /**
29
+ * Converts milliseconds to seconds, rounding up.
30
+ * @Internal
31
+ */
32
+ function msToSeconds(ms) {
33
+ return Math.ceil(ms / MILLIS_PER_SECOND);
34
+ }
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });