@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.
- package/README.md +137 -0
- package/dist/cjs/api.types.js +38 -0
- package/dist/cjs/auth/auth-strategy.js +62 -0
- package/dist/cjs/auth/auth.types.js +2 -0
- package/dist/cjs/auth/auth.utils.js +64 -0
- package/dist/cjs/auth/bearer-auth-strategy.js +59 -0
- package/dist/cjs/config.js +29 -0
- package/dist/cjs/error-handling.js +70 -0
- package/dist/cjs/http.types.js +38 -0
- package/dist/cjs/index.js +31 -0
- package/dist/cjs/rate-limit/in-memory-rate-limiter.js +88 -0
- package/dist/cjs/rate-limit/rate-limit.js +34 -0
- package/dist/cjs/rate-limit/rate-limit.types.js +2 -0
- package/dist/cjs/route-table.js +45 -0
- package/dist/cjs/router.js +245 -0
- package/dist/cjs/thread-local/thread-local-storage-middleware.js +27 -0
- package/dist/cjs/thread-local/thread-local-storage.js +31 -0
- package/dist/cjs/utils/conversion.utils.js +26 -0
- package/dist/cjs/utils/express.utils.js +2 -0
- package/dist/esm/api.types.js +31 -0
- package/dist/esm/auth/auth-strategy.js +58 -0
- package/dist/esm/auth/auth.types.js +1 -0
- package/dist/esm/auth/auth.utils.js +59 -0
- package/dist/esm/auth/bearer-auth-strategy.js +55 -0
- package/dist/esm/config.js +24 -0
- package/dist/esm/error-handling.js +66 -0
- package/dist/esm/http.types.js +35 -0
- package/dist/esm/index.js +15 -0
- package/dist/esm/rate-limit/in-memory-rate-limiter.js +88 -0
- package/dist/esm/rate-limit/rate-limit.js +30 -0
- package/dist/esm/rate-limit/rate-limit.types.js +1 -0
- package/dist/esm/route-table.js +40 -0
- package/dist/esm/router.js +203 -0
- package/dist/esm/thread-local/thread-local-storage-middleware.js +24 -0
- package/dist/esm/thread-local/thread-local-storage.js +27 -0
- package/dist/esm/utils/conversion.utils.js +22 -0
- package/dist/esm/utils/express.utils.js +1 -0
- package/package.json +70 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Common HTTP status codes as numbers.
|
|
3
|
+
* @Internal
|
|
4
|
+
*/
|
|
5
|
+
export const INTERNAL_ERROR_STATUS = 500;
|
|
6
|
+
/**
|
|
7
|
+
* Common HTTP status codes as numbers.
|
|
8
|
+
* @Internal
|
|
9
|
+
*/
|
|
10
|
+
export const BAD_REQUEST_STATUS = 400;
|
|
11
|
+
/**
|
|
12
|
+
* Common HTTP status codes as numbers.
|
|
13
|
+
* @Internal
|
|
14
|
+
*/
|
|
15
|
+
export const UNAUTHORIZED_STATUS = 401;
|
|
16
|
+
/**
|
|
17
|
+
* Common HTTP status codes as numbers.
|
|
18
|
+
* @Internal
|
|
19
|
+
*/
|
|
20
|
+
export const FORBIDDEN_STATUS = 403;
|
|
21
|
+
/**
|
|
22
|
+
* Common HTTP status codes as numbers.
|
|
23
|
+
* @Internal
|
|
24
|
+
*/
|
|
25
|
+
export const NOT_FOUND_STATUS = 404;
|
|
26
|
+
/**
|
|
27
|
+
* Common HTTP status codes as numbers.
|
|
28
|
+
* @Internal
|
|
29
|
+
*/
|
|
30
|
+
export const OK_STATUS = 200;
|
|
31
|
+
/**
|
|
32
|
+
* Common HTTP status codes as numbers.
|
|
33
|
+
* @Internal
|
|
34
|
+
*/
|
|
35
|
+
export const TOO_MANY_REQUESTS_STATUS = 429;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export * from './api.types';
|
|
2
|
+
export * from './auth/auth-strategy';
|
|
3
|
+
export * from './auth/auth.types';
|
|
4
|
+
export * from './auth/auth.utils';
|
|
5
|
+
export * from './auth/bearer-auth-strategy';
|
|
6
|
+
export * from './config';
|
|
7
|
+
export * from './error-handling';
|
|
8
|
+
export * from './http.types';
|
|
9
|
+
export * from './rate-limit/in-memory-rate-limiter';
|
|
10
|
+
export * from './rate-limit/rate-limit.types';
|
|
11
|
+
export * from './route-table';
|
|
12
|
+
export * from './router';
|
|
13
|
+
export * from './thread-local/thread-local-storage';
|
|
14
|
+
export * from './thread-local/thread-local-storage-middleware';
|
|
15
|
+
export * from './utils/express.utils';
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { addRateLimitHeaders, msToSeconds } from './rate-limit';
|
|
2
|
+
const MILLIS_PER_SECOND = 1000;
|
|
3
|
+
/**
|
|
4
|
+
* In-memory rate limiter using sliding window counter.
|
|
5
|
+
* Tracks request counts per key with time-based window expiration.
|
|
6
|
+
*/
|
|
7
|
+
class InMemoryRateLimiter {
|
|
8
|
+
constructor(points, durationSeconds) {
|
|
9
|
+
this.limits = new Map();
|
|
10
|
+
this.points = points;
|
|
11
|
+
this.durationMs = durationSeconds * MILLIS_PER_SECOND;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Try to consume points from the rate limit.
|
|
15
|
+
* Returns result if successful, throws if limit exceeded.
|
|
16
|
+
*
|
|
17
|
+
* @param key - Unique identifier for the client (e.g., IP address)
|
|
18
|
+
* @returns Rate limit result with remaining points and ms until reset
|
|
19
|
+
* @throws RateLimitResult if limit exceeded
|
|
20
|
+
*/
|
|
21
|
+
consume(key) {
|
|
22
|
+
const now = Date.now();
|
|
23
|
+
const existing = this.limits.get(key);
|
|
24
|
+
// Reset expired entry
|
|
25
|
+
if (existing && now >= existing.resetTime) {
|
|
26
|
+
this.limits.delete(key);
|
|
27
|
+
}
|
|
28
|
+
const current = this.limits.get(key) || { count: 0, resetTime: now + this.durationMs };
|
|
29
|
+
if (current.count >= this.points) {
|
|
30
|
+
const msBeforeNext = Math.max(0, current.resetTime - now);
|
|
31
|
+
throw {
|
|
32
|
+
remainingPoints: 0,
|
|
33
|
+
msBeforeNext,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
current.count++;
|
|
37
|
+
this.limits.set(key, current);
|
|
38
|
+
const msBeforeNext = Math.max(0, current.resetTime - now);
|
|
39
|
+
return {
|
|
40
|
+
remainingPoints: this.points - current.count,
|
|
41
|
+
msBeforeNext,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Creates a rate limiter middleware using in-memory implementation.
|
|
47
|
+
*
|
|
48
|
+
* Separate limiters are used for read (GET) and write (POST/PATCH/PUT/DELETE) requests.
|
|
49
|
+
*
|
|
50
|
+
* @param config - Rate limit configuration
|
|
51
|
+
* @returns Express middleware function
|
|
52
|
+
*/
|
|
53
|
+
export async function createRateLimiterMiddleware(config) {
|
|
54
|
+
const readLimiter = new InMemoryRateLimiter(config.points.read, config.duration);
|
|
55
|
+
const writeLimiter = new InMemoryRateLimiter(config.points.write, config.duration);
|
|
56
|
+
const whitelist = config.rateLimitWhitelist || ['/v1', '/health'];
|
|
57
|
+
/**
|
|
58
|
+
* The actual middleware function.
|
|
59
|
+
*/
|
|
60
|
+
return async (req, res, next) => {
|
|
61
|
+
// Check if the path is whitelisted
|
|
62
|
+
if (whitelist.some(path => req.path.includes(path))) {
|
|
63
|
+
next();
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
const isReadRequest = req.method === 'GET';
|
|
67
|
+
const limiter = isReadRequest ? readLimiter : writeLimiter;
|
|
68
|
+
const limitPoints = isReadRequest ? config.points.read : config.points.write;
|
|
69
|
+
const clientKey = req.ip || 'unknown';
|
|
70
|
+
try {
|
|
71
|
+
const result = limiter.consume(clientKey);
|
|
72
|
+
addRateLimitHeaders(res, result, limitPoints, config.duration);
|
|
73
|
+
next();
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
const result = error;
|
|
77
|
+
addRateLimitHeaders(res, result, limitPoints, config.duration)
|
|
78
|
+
.header('Retry-After', `${msToSeconds(result.msBeforeNext)}`)
|
|
79
|
+
.status(429)
|
|
80
|
+
.send({ error: 'Too Many Requests' });
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Export the in-memory limiter class for advanced use cases where
|
|
86
|
+
* you might want direct control over rate limit management.
|
|
87
|
+
*/
|
|
88
|
+
export { InMemoryRateLimiter };
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
const MILLIS_PER_SECOND = 1000;
|
|
2
|
+
/**
|
|
3
|
+
* Adds rate limit state headers to the response.
|
|
4
|
+
* See https://datatracker.ietf.org/doc/draft-ietf-httpapi-ratelimit-headers/
|
|
5
|
+
*
|
|
6
|
+
* Headers added:
|
|
7
|
+
* - X-RateLimit-Limit: Server's quota for requests in the time window
|
|
8
|
+
* - X-RateLimit-Remaining: Remaining quota in the current window
|
|
9
|
+
* - X-RateLimit-Reset: Time remaining in the current window (in seconds)
|
|
10
|
+
* - X-RateLimit-Policy: Quota policies associated with the client
|
|
11
|
+
* @Internal
|
|
12
|
+
*/
|
|
13
|
+
export function addRateLimitHeaders(res, result, limitPoints, duration) {
|
|
14
|
+
return (res
|
|
15
|
+
// The server's quota for requests by the client in the time window.
|
|
16
|
+
.header('X-RateLimit-Limit', `${limitPoints}`)
|
|
17
|
+
// The remaining quota in the current window.
|
|
18
|
+
.header('X-RateLimit-Remaining', `${result.remainingPoints}`)
|
|
19
|
+
// The time remaining in the current window specified in seconds.
|
|
20
|
+
.header('X-RateLimit-Reset', `${Math.ceil(result.msBeforeNext / MILLIS_PER_SECOND)}`)
|
|
21
|
+
// Indicates the quota policies currently associated with the client.
|
|
22
|
+
.header('X-RateLimit-Policy', `${limitPoints};w=${duration};comment="fixed window"`));
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Converts milliseconds to seconds, rounding up.
|
|
26
|
+
* @Internal
|
|
27
|
+
*/
|
|
28
|
+
export function msToSeconds(ms) {
|
|
29
|
+
return Math.ceil(ms / MILLIS_PER_SECOND);
|
|
30
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { mountDelete, mountGet, mountPatch, mountPost, mountPut, } from './router';
|
|
2
|
+
/**
|
|
3
|
+
* Helper utility for organizing and mounting routes.
|
|
4
|
+
* Provides a fluent interface for registering multiple handlers.
|
|
5
|
+
*/
|
|
6
|
+
export class RouteTable {
|
|
7
|
+
constructor(app) {
|
|
8
|
+
this.app = app;
|
|
9
|
+
}
|
|
10
|
+
get(path, endpointOrRun) {
|
|
11
|
+
const endpoint = typeof endpointOrRun === 'function' ? { run: endpointOrRun } : endpointOrRun;
|
|
12
|
+
mountGet(this.app, path, endpoint);
|
|
13
|
+
return this;
|
|
14
|
+
}
|
|
15
|
+
post(path, endpoint) {
|
|
16
|
+
mountPost(this.app, path, endpoint);
|
|
17
|
+
return this;
|
|
18
|
+
}
|
|
19
|
+
patch(path, endpoint) {
|
|
20
|
+
mountPatch(this.app, path, endpoint);
|
|
21
|
+
return this;
|
|
22
|
+
}
|
|
23
|
+
put(path, endpoint) {
|
|
24
|
+
mountPut(this.app, path, endpoint);
|
|
25
|
+
return this;
|
|
26
|
+
}
|
|
27
|
+
delete(path, endpointOrRun) {
|
|
28
|
+
const endpoint = typeof endpointOrRun === 'function' ? { run: endpointOrRun } : endpointOrRun;
|
|
29
|
+
mountDelete(this.app, path, endpoint);
|
|
30
|
+
return this;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Factory function to create a new route table.
|
|
35
|
+
* @param app Express application instance
|
|
36
|
+
* @returns RouteTable instance with fluent API
|
|
37
|
+
*/
|
|
38
|
+
export function createRouteTable(app) {
|
|
39
|
+
return new RouteTable(app);
|
|
40
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { assertTruthy, callValueAssertion, getMessageFromError, validateObject, } from '@fishka/assertions';
|
|
2
|
+
import * as url from 'url';
|
|
3
|
+
import { HttpError, URL_PARAMETER_INFO } from './api.types';
|
|
4
|
+
import { BAD_REQUEST_STATUS, OK_STATUS } from './http.types';
|
|
5
|
+
import { catchRouteErrors } from './error-handling';
|
|
6
|
+
import { getRequestLocalStorage } from './thread-local/thread-local-storage';
|
|
7
|
+
import { wrapAsApiResponse } from './utils/conversion.utils';
|
|
8
|
+
// ============================================================================
|
|
9
|
+
// Internal implementation details
|
|
10
|
+
// ============================================================================
|
|
11
|
+
/**
|
|
12
|
+
* Registers a GET route.
|
|
13
|
+
*/
|
|
14
|
+
export const mountGet = (app, path, endpoint) => mount(app, { method: 'get', route: endpoint, path });
|
|
15
|
+
/**
|
|
16
|
+
* Registers a POST route.
|
|
17
|
+
*/
|
|
18
|
+
export const mountPost = (app, path, endpoint) => mount(app, { method: 'post', route: endpoint, path });
|
|
19
|
+
/**
|
|
20
|
+
* Registers a PATCH route.
|
|
21
|
+
*/
|
|
22
|
+
export const mountPatch = (app, path, endpoint) => mount(app, { method: 'patch', route: endpoint, path });
|
|
23
|
+
/**
|
|
24
|
+
* Registers a PUT route.
|
|
25
|
+
*/
|
|
26
|
+
export const mountPut = (app, path, endpoint) => mount(app, { method: 'put', route: endpoint, path });
|
|
27
|
+
/**
|
|
28
|
+
* Registers a DELETE route.
|
|
29
|
+
*/
|
|
30
|
+
export const mountDelete = (app, path, endpoint) => mount(app, { method: 'delete', route: endpoint, path });
|
|
31
|
+
/**
|
|
32
|
+
* Mounts a route with the given method, endpoint, and path.
|
|
33
|
+
*/
|
|
34
|
+
export function mount(app, { method, route, path }) {
|
|
35
|
+
const fullPath = `/${path}`;
|
|
36
|
+
const handler = createRouteHandler(method, route);
|
|
37
|
+
app[method](fullPath, catchRouteErrors(handler));
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* @Internal
|
|
41
|
+
* Creates a route handler from an endpoint definition.
|
|
42
|
+
*/
|
|
43
|
+
function createRouteHandler(method, endpoint) {
|
|
44
|
+
return async (req, res, _next) => {
|
|
45
|
+
let result;
|
|
46
|
+
switch (method) {
|
|
47
|
+
case 'post':
|
|
48
|
+
case 'put':
|
|
49
|
+
case 'patch':
|
|
50
|
+
result = await executeBodyEndpoint(endpoint, req, res);
|
|
51
|
+
break;
|
|
52
|
+
case 'delete':
|
|
53
|
+
result = await executeDeleteEndpoint(endpoint, req, res);
|
|
54
|
+
break;
|
|
55
|
+
case 'get':
|
|
56
|
+
result = await executeGetEndpoint(endpoint, req, res);
|
|
57
|
+
break;
|
|
58
|
+
}
|
|
59
|
+
const response = wrapAsApiResponse(result);
|
|
60
|
+
const tls = getRequestLocalStorage();
|
|
61
|
+
if (tls?.requestId) {
|
|
62
|
+
response.requestId = tls.requestId;
|
|
63
|
+
}
|
|
64
|
+
response.status = response.status || OK_STATUS;
|
|
65
|
+
res.status(response.status);
|
|
66
|
+
res.send(response);
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* @Internal
|
|
71
|
+
* Validates request parameters using custom validators.
|
|
72
|
+
*/
|
|
73
|
+
function validateUrlParameters(req, { $path, $query, }) {
|
|
74
|
+
try {
|
|
75
|
+
for (const key in req.params) {
|
|
76
|
+
const value = req.params[key];
|
|
77
|
+
// Run Global Validation if registered.
|
|
78
|
+
const globalValidator = URL_PARAMETER_INFO[key]?.validator;
|
|
79
|
+
if (globalValidator) {
|
|
80
|
+
callValueAssertion(value, globalValidator, `${BAD_REQUEST_STATUS}`);
|
|
81
|
+
}
|
|
82
|
+
// Run Local Validation.
|
|
83
|
+
const validator = $path?.[key];
|
|
84
|
+
if (validator) {
|
|
85
|
+
callValueAssertion(value, validator, `${BAD_REQUEST_STATUS}`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
const parsedUrl = url.parse(req.url, true);
|
|
89
|
+
for (const key in parsedUrl.query) {
|
|
90
|
+
const value = parsedUrl.query[key];
|
|
91
|
+
// Global Validation if registered (also applies to query params if names match).
|
|
92
|
+
const globalValidator = URL_PARAMETER_INFO[key]?.validator;
|
|
93
|
+
if (globalValidator) {
|
|
94
|
+
// Query params can be string | string[] | undefined. Global validators usually expect string.
|
|
95
|
+
// We only validate if it's a single value or handle array in validator.
|
|
96
|
+
// For simplicity, we pass value as is (unknown) to assertion.
|
|
97
|
+
callValueAssertion(value, globalValidator, `${BAD_REQUEST_STATUS}`);
|
|
98
|
+
}
|
|
99
|
+
const validator = $query?.[key];
|
|
100
|
+
if (validator) {
|
|
101
|
+
callValueAssertion(value, validator, `${BAD_REQUEST_STATUS}`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
catch (error) {
|
|
106
|
+
throw new HttpError(BAD_REQUEST_STATUS, getMessageFromError(error));
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* @Internal
|
|
111
|
+
* Runs GET handler with optional middleware.
|
|
112
|
+
*/
|
|
113
|
+
async function executeGetEndpoint(route, req, res) {
|
|
114
|
+
const requestContext = newRequestContext(undefined, req, res);
|
|
115
|
+
validateUrlParameters(req, { $path: route.$path, $query: route.$query });
|
|
116
|
+
return await executeWithMiddleware(() => route.run(requestContext), route.middlewares || [], requestContext);
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* @Internal
|
|
120
|
+
* Runs DELETE handler with optional middleware.
|
|
121
|
+
*/
|
|
122
|
+
async function executeDeleteEndpoint(route, req, res) {
|
|
123
|
+
const requestContext = newRequestContext(undefined, req, res);
|
|
124
|
+
validateUrlParameters(req, { $path: route.$path, $query: route.$query });
|
|
125
|
+
await executeWithMiddleware(() => route.run(requestContext), route.middlewares || [], requestContext);
|
|
126
|
+
return undefined;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* @Internal
|
|
130
|
+
* Runs POST/PUT/PATCH handler with optional middleware.
|
|
131
|
+
*/
|
|
132
|
+
async function executeBodyEndpoint(route, req, res) {
|
|
133
|
+
const validator = route.$body;
|
|
134
|
+
const apiRequest = req.body;
|
|
135
|
+
try {
|
|
136
|
+
// Handle validation based on whether validator is an object or function
|
|
137
|
+
if (typeof validator === 'function') {
|
|
138
|
+
// It's a ValueAssertion (function)
|
|
139
|
+
callValueAssertion(apiRequest, validator, `${BAD_REQUEST_STATUS}: request body`);
|
|
140
|
+
}
|
|
141
|
+
else {
|
|
142
|
+
// It's an ObjectAssertion - use validateObject
|
|
143
|
+
// We strictly assume it is an object because of the type definition (function | object)
|
|
144
|
+
const objectValidator = validator;
|
|
145
|
+
const isEmptyValidator = Object.keys(objectValidator).length === 0;
|
|
146
|
+
const error = validateObject(apiRequest, objectValidator, `${BAD_REQUEST_STATUS}: request body`, {
|
|
147
|
+
failOnUnknownFields: !isEmptyValidator,
|
|
148
|
+
});
|
|
149
|
+
assertTruthy(!error, error);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
catch (error) {
|
|
153
|
+
if (error instanceof HttpError)
|
|
154
|
+
throw error;
|
|
155
|
+
throw new HttpError(BAD_REQUEST_STATUS, getMessageFromError(error));
|
|
156
|
+
}
|
|
157
|
+
const requestContext = newRequestContext(apiRequest, req, res);
|
|
158
|
+
validateUrlParameters(req, { $path: route.$path, $query: route.$query });
|
|
159
|
+
requestContext.body = req.body;
|
|
160
|
+
return await executeWithMiddleware(() => route.run(requestContext), (route.middlewares || []), requestContext);
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* @Internal
|
|
164
|
+
* Executes handler with middleware chain.
|
|
165
|
+
*/
|
|
166
|
+
async function executeWithMiddleware(run, middlewares, context) {
|
|
167
|
+
const current = async (index) => {
|
|
168
|
+
if (index >= middlewares.length) {
|
|
169
|
+
const result = await run();
|
|
170
|
+
return wrapAsApiResponse(result);
|
|
171
|
+
}
|
|
172
|
+
const middleware = middlewares[index];
|
|
173
|
+
return (await middleware(() => current(index + 1), context));
|
|
174
|
+
};
|
|
175
|
+
return await current(0);
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* @Internal
|
|
179
|
+
* Creates a new RequestContext instance.
|
|
180
|
+
*/
|
|
181
|
+
function newRequestContext(requestBody, req, res) {
|
|
182
|
+
return {
|
|
183
|
+
body: requestBody,
|
|
184
|
+
req,
|
|
185
|
+
res,
|
|
186
|
+
params: {
|
|
187
|
+
get: (key) => {
|
|
188
|
+
const value = req.params[key];
|
|
189
|
+
assertTruthy(value, `Path parameter '${key}' not found`);
|
|
190
|
+
return value;
|
|
191
|
+
},
|
|
192
|
+
tryGet: (key) => req.params[key],
|
|
193
|
+
},
|
|
194
|
+
query: {
|
|
195
|
+
get: (key) => {
|
|
196
|
+
const parsedUrl = url.parse(req.url, true);
|
|
197
|
+
const value = parsedUrl.query[key];
|
|
198
|
+
return Array.isArray(value) ? value[0] : value;
|
|
199
|
+
},
|
|
200
|
+
},
|
|
201
|
+
state: new Map(),
|
|
202
|
+
};
|
|
203
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { randomUUID } from 'crypto';
|
|
2
|
+
import { getExpressApiConfig } from '../config';
|
|
3
|
+
import { runWithRequestTlsData } from './thread-local-storage';
|
|
4
|
+
/**
|
|
5
|
+
* Creates middleware that initializes thread-local storage for each request.
|
|
6
|
+
* Automatically generates a unique request ID and makes it available throughout
|
|
7
|
+
* the request lifecycle.
|
|
8
|
+
*
|
|
9
|
+
* @returns Express middleware function
|
|
10
|
+
*/
|
|
11
|
+
export function createTlsMiddleware() {
|
|
12
|
+
return async (req, _res, next) => {
|
|
13
|
+
const config = getExpressApiConfig();
|
|
14
|
+
const headerId = config.trustRequestIdHeader ? req.headers['x-request-id'] : undefined;
|
|
15
|
+
const existingId = req.requestId || headerId;
|
|
16
|
+
const requestId = typeof existingId === 'string' ? existingId : randomUUID();
|
|
17
|
+
// Run the next handler within the TLS context
|
|
18
|
+
await runWithRequestTlsData({
|
|
19
|
+
requestId,
|
|
20
|
+
}, async () => {
|
|
21
|
+
next();
|
|
22
|
+
});
|
|
23
|
+
};
|
|
24
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'async_hooks';
|
|
2
|
+
/**
|
|
3
|
+
* AsyncLocalStorage instance for managing per-request context.
|
|
4
|
+
* This ensures that each async operation associated with a request
|
|
5
|
+
* can access the request-specific data even across async boundaries.
|
|
6
|
+
* @Internal
|
|
7
|
+
*/
|
|
8
|
+
const asyncLocalStorage = new AsyncLocalStorage();
|
|
9
|
+
/**
|
|
10
|
+
* Gets all thread-local data for the current request context.
|
|
11
|
+
* Returns undefined if called outside an async context managed by API.
|
|
12
|
+
* @Internal
|
|
13
|
+
*/
|
|
14
|
+
export function getRequestLocalStorage() {
|
|
15
|
+
return asyncLocalStorage.getStore();
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Executes a callback within a request context with the given thread-local data.
|
|
19
|
+
* Used by middleware to set up the context for handlers.
|
|
20
|
+
* @Internal
|
|
21
|
+
* @param data - Thread-local data to establish
|
|
22
|
+
* @param callback - Function to execute within the context
|
|
23
|
+
* @returns Result of the callback
|
|
24
|
+
*/
|
|
25
|
+
export async function runWithRequestTlsData(data, callback) {
|
|
26
|
+
return asyncLocalStorage.run(data, callback);
|
|
27
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Converts JS timestamp or date to ISO 8601 format (without milliseconds).
|
|
3
|
+
* Example: "2012-07-20T01:19:13Z".
|
|
4
|
+
* @Internal
|
|
5
|
+
*/
|
|
6
|
+
export function toApiDateString(value) {
|
|
7
|
+
const resultWithMillis = (typeof value === 'number' ? new Date(value) : value).toISOString();
|
|
8
|
+
return `${resultWithMillis.substring(0, resultWithMillis.length - 5)}Z`;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Wraps the response into the correct API form.
|
|
12
|
+
* Add necessary fields, like 'requestId'.
|
|
13
|
+
* If the response is already in the correct form, returns it as-is.
|
|
14
|
+
* @Internal
|
|
15
|
+
*/
|
|
16
|
+
export function wrapAsApiResponse(apiResponseOrResultValue) {
|
|
17
|
+
let apiResponse = apiResponseOrResultValue;
|
|
18
|
+
apiResponse = apiResponse?.result
|
|
19
|
+
? apiResponse // The value is in the correct 'ApiResponse' form: just return it.
|
|
20
|
+
: { result: apiResponseOrResultValue }; // Wrap the raw value into the correct ApiResponse form.
|
|
21
|
+
return apiResponse;
|
|
22
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@fishka/express",
|
|
3
|
+
"version": "0.9.5",
|
|
4
|
+
"description": "Express.js extension with built-in validation, and type safety",
|
|
5
|
+
"main": "./dist/cjs/index.js",
|
|
6
|
+
"module": "./dist/esm/index.js",
|
|
7
|
+
"types": "./dist/esm/index.d.ts",
|
|
8
|
+
"private": false,
|
|
9
|
+
"license": "MIT",
|
|
10
|
+
"sideEffects": false,
|
|
11
|
+
"scripts": {
|
|
12
|
+
"prebuild": "npm run clean",
|
|
13
|
+
"build:esm": "tsc -p tsconfig.esm.json",
|
|
14
|
+
"build:cjs": "tsc -p tsconfig.cjs.json",
|
|
15
|
+
"build": "npm run build:esm && npm run build:cjs",
|
|
16
|
+
"clean": "rimraf ./dist",
|
|
17
|
+
"format": "prettier --write \"./**/*.{ts,js,md,json}\"",
|
|
18
|
+
"lint": "eslint .",
|
|
19
|
+
"test": "jest",
|
|
20
|
+
"typecheck": "tsc --noEmit",
|
|
21
|
+
"release": "npm run lint && npm run test && npm run build && npm publish"
|
|
22
|
+
},
|
|
23
|
+
"keywords": [
|
|
24
|
+
"express",
|
|
25
|
+
"expressjs",
|
|
26
|
+
"typescript",
|
|
27
|
+
"rest-api",
|
|
28
|
+
"rest"
|
|
29
|
+
],
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"@fishka/assertions": "^1.0.0",
|
|
32
|
+
"express": "^5.0.0"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@types/express": "^5.0.6",
|
|
36
|
+
"@types/jest": "^30.0.0",
|
|
37
|
+
"@types/node": "^20.19.27",
|
|
38
|
+
"@typescript-eslint/eslint-plugin": "^8.51.0",
|
|
39
|
+
"@typescript-eslint/parser": "^8.51.0",
|
|
40
|
+
"eslint": "^9.39.2",
|
|
41
|
+
"eslint-plugin-unused-imports": "^4.3.0",
|
|
42
|
+
"jest": "^30.2.0",
|
|
43
|
+
"prettier": "^3.7.4",
|
|
44
|
+
"prettier-plugin-organize-imports": "^4.3.0",
|
|
45
|
+
"rimraf": "^6.1.2",
|
|
46
|
+
"ts-jest": "^29.4.6",
|
|
47
|
+
"typescript": "^5.9.3"
|
|
48
|
+
},
|
|
49
|
+
"files": [
|
|
50
|
+
"dist"
|
|
51
|
+
],
|
|
52
|
+
"repository": {
|
|
53
|
+
"type": "git",
|
|
54
|
+
"url": "https://gitea.com/fishka/express.git"
|
|
55
|
+
},
|
|
56
|
+
"exports": {
|
|
57
|
+
".": {
|
|
58
|
+
"types": "./dist/esm/index.d.ts",
|
|
59
|
+
"import": "./dist/esm/index.js",
|
|
60
|
+
"require": "./dist/cjs/index.js"
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
"publishConfig": {
|
|
64
|
+
"access": "public"
|
|
65
|
+
},
|
|
66
|
+
"engines": {
|
|
67
|
+
"node": ">=20.0.0",
|
|
68
|
+
"npm": ">=9.0.0"
|
|
69
|
+
}
|
|
70
|
+
}
|