zero-http 0.2.5 → 0.3.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.
Files changed (89) hide show
  1. package/README.md +1250 -283
  2. package/documentation/config/db.js +25 -0
  3. package/documentation/config/middleware.js +44 -0
  4. package/documentation/config/tls.js +12 -0
  5. package/documentation/controllers/cookies.js +34 -0
  6. package/documentation/controllers/tasks.js +108 -0
  7. package/documentation/full-server.js +25 -184
  8. package/documentation/models/Task.js +21 -0
  9. package/documentation/public/data/api.json +404 -24
  10. package/documentation/public/data/docs.json +1139 -0
  11. package/documentation/public/data/examples.json +80 -2
  12. package/documentation/public/data/options.json +23 -8
  13. package/documentation/public/index.html +138 -99
  14. package/documentation/public/scripts/app.js +1 -3
  15. package/documentation/public/scripts/custom-select.js +189 -0
  16. package/documentation/public/scripts/data-sections.js +233 -250
  17. package/documentation/public/scripts/playground.js +270 -0
  18. package/documentation/public/scripts/ui.js +4 -3
  19. package/documentation/public/styles.css +56 -5
  20. package/documentation/public/vendor/icons/compress.svg +17 -17
  21. package/documentation/public/vendor/icons/database.svg +21 -0
  22. package/documentation/public/vendor/icons/env.svg +21 -0
  23. package/documentation/public/vendor/icons/fetch.svg +11 -14
  24. package/documentation/public/vendor/icons/security.svg +15 -0
  25. package/documentation/public/vendor/icons/sse.svg +12 -13
  26. package/documentation/public/vendor/icons/static.svg +12 -26
  27. package/documentation/public/vendor/icons/stream.svg +7 -13
  28. package/documentation/public/vendor/icons/validate.svg +17 -0
  29. package/documentation/routes/api.js +41 -0
  30. package/documentation/routes/core.js +20 -0
  31. package/documentation/routes/playground.js +29 -0
  32. package/documentation/routes/realtime.js +49 -0
  33. package/documentation/routes/uploads.js +71 -0
  34. package/index.js +62 -1
  35. package/lib/app.js +200 -8
  36. package/lib/body/json.js +28 -5
  37. package/lib/body/multipart.js +29 -1
  38. package/lib/body/raw.js +1 -1
  39. package/lib/body/sendError.js +1 -0
  40. package/lib/body/text.js +1 -1
  41. package/lib/body/typeMatch.js +6 -2
  42. package/lib/body/urlencoded.js +5 -2
  43. package/lib/debug.js +345 -0
  44. package/lib/env/index.js +440 -0
  45. package/lib/errors.js +231 -0
  46. package/lib/http/request.js +219 -1
  47. package/lib/http/response.js +410 -6
  48. package/lib/middleware/compress.js +39 -6
  49. package/lib/middleware/cookieParser.js +237 -0
  50. package/lib/middleware/cors.js +13 -2
  51. package/lib/middleware/csrf.js +135 -0
  52. package/lib/middleware/errorHandler.js +90 -0
  53. package/lib/middleware/helmet.js +176 -0
  54. package/lib/middleware/index.js +7 -2
  55. package/lib/middleware/rateLimit.js +12 -1
  56. package/lib/middleware/requestId.js +54 -0
  57. package/lib/middleware/static.js +95 -11
  58. package/lib/middleware/timeout.js +72 -0
  59. package/lib/middleware/validator.js +257 -0
  60. package/lib/orm/adapters/json.js +215 -0
  61. package/lib/orm/adapters/memory.js +383 -0
  62. package/lib/orm/adapters/mongo.js +444 -0
  63. package/lib/orm/adapters/mysql.js +272 -0
  64. package/lib/orm/adapters/postgres.js +394 -0
  65. package/lib/orm/adapters/sql-base.js +142 -0
  66. package/lib/orm/adapters/sqlite.js +311 -0
  67. package/lib/orm/index.js +276 -0
  68. package/lib/orm/model.js +895 -0
  69. package/lib/orm/query.js +807 -0
  70. package/lib/orm/schema.js +172 -0
  71. package/lib/router/index.js +136 -47
  72. package/lib/sse/stream.js +15 -3
  73. package/lib/ws/connection.js +19 -3
  74. package/lib/ws/handshake.js +3 -0
  75. package/lib/ws/index.js +3 -1
  76. package/lib/ws/room.js +222 -0
  77. package/package.json +15 -5
  78. package/types/app.d.ts +120 -0
  79. package/types/env.d.ts +80 -0
  80. package/types/errors.d.ts +147 -0
  81. package/types/fetch.d.ts +43 -0
  82. package/types/index.d.ts +135 -0
  83. package/types/middleware.d.ts +292 -0
  84. package/types/orm.d.ts +610 -0
  85. package/types/request.d.ts +99 -0
  86. package/types/response.d.ts +142 -0
  87. package/types/router.d.ts +78 -0
  88. package/types/sse.d.ts +78 -0
  89. package/types/websocket.d.ts +119 -0
@@ -1,12 +1,17 @@
1
1
  /**
2
2
  * @module middleware
3
3
  * @description Built-in middleware for zero-http.
4
- * Re-exports cors, logger, rateLimit, compress, and static file serving.
4
+ * Re-exports all middleware.
5
5
  */
6
6
  const cors = require('./cors');
7
7
  const logger = require('./logger');
8
8
  const rateLimit = require('./rateLimit');
9
9
  const compress = require('./compress');
10
10
  const serveStatic = require('./static');
11
+ const helmet = require('./helmet');
12
+ const timeout = require('./timeout');
13
+ const requestId = require('./requestId');
14
+ const cookieParser = require('./cookieParser');
15
+ const errorHandler = require('./errorHandler');
11
16
 
12
- module.exports = { cors, logger, rateLimit, compress, static: serveStatic };
17
+ module.exports = { cors, logger, rateLimit, compress, static: serveStatic, helmet, timeout, requestId, cookieParser, errorHandler };
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * In-memory rate-limiting middleware.
3
- * Limits requests per IP address within a sliding window.
3
+ * Limits requests per IP address within a fixed time window.
4
4
  *
5
5
  * @param {object} [opts]
6
6
  * @param {number} [opts.windowMs=60000] - Time window in milliseconds.
@@ -8,8 +8,12 @@
8
8
  * @param {string} [opts.message] - Custom error message.
9
9
  * @param {number} [opts.statusCode=429] - HTTP status for rate-limited responses.
10
10
  * @param {function} [opts.keyGenerator] - (req) => string; custom key extraction (default: req.ip).
11
+ * @param {function} [opts.skip] - (req) => boolean; return true to skip rate limiting.
12
+ * @param {function} [opts.handler] - (req, res) => void; custom handler for rate-limited requests.
11
13
  * @returns {function} Middleware function.
12
14
  */
15
+ const log = require('../debug')('zero:rateLimit');
16
+
13
17
  function rateLimit(opts = {})
14
18
  {
15
19
  const windowMs = opts.windowMs || 60_000;
@@ -17,6 +21,8 @@ function rateLimit(opts = {})
17
21
  const statusCode = opts.statusCode || 429;
18
22
  const message = opts.message || 'Too many requests, please try again later.';
19
23
  const keyGenerator = typeof opts.keyGenerator === 'function' ? opts.keyGenerator : (req) => req.ip || 'unknown';
24
+ const skipFn = typeof opts.skip === 'function' ? opts.skip : null;
25
+ const handlerFn = typeof opts.handler === 'function' ? opts.handler : null;
20
26
 
21
27
  const hits = new Map(); // key → { count, resetTime }
22
28
 
@@ -33,6 +39,9 @@ function rateLimit(opts = {})
33
39
 
34
40
  return (req, res, next) =>
35
41
  {
42
+ // Allow skipping rate limit for certain requests
43
+ if (skipFn && skipFn(req)) return next();
44
+
36
45
  const key = keyGenerator(req);
37
46
  const now = Date.now();
38
47
  let entry = hits.get(key);
@@ -53,7 +62,9 @@ function rateLimit(opts = {})
53
62
 
54
63
  if (entry.count > max)
55
64
  {
65
+ log.warn('rate limit exceeded for %s', key);
56
66
  res.set('Retry-After', String(Math.ceil(windowMs / 1000)));
67
+ if (handlerFn) return handlerFn(req, res);
57
68
  return res.status(statusCode).json({ error: message });
58
69
  }
59
70
 
@@ -0,0 +1,54 @@
1
+ /**
2
+ * @module requestId
3
+ * @description Request ID middleware.
4
+ * Assigns a unique identifier to each incoming request for
5
+ * tracing and debugging. Sets the ID on both the request
6
+ * object and as a response header.
7
+ */
8
+ const crypto = require('crypto');
9
+
10
+ /**
11
+ * Create a request ID middleware.
12
+ *
13
+ * @param {object} [opts]
14
+ * @param {string} [opts.header='X-Request-Id'] - Response header name.
15
+ * @param {Function} [opts.generator] - Custom ID generator `() => string`.
16
+ * @param {boolean} [opts.trustProxy=false] - Trust incoming X-Request-Id header from proxy.
17
+ * @returns {Function} Middleware `(req, res, next) => void`.
18
+ *
19
+ * @example
20
+ * app.use(requestId());
21
+ * app.get('/', (req, res) => {
22
+ * console.log(req.id); // e.g. '7f3a2b1c-...'
23
+ * });
24
+ */
25
+ function requestId(opts = {})
26
+ {
27
+ const headerName = opts.header || 'X-Request-Id';
28
+ const trustProxy = !!opts.trustProxy;
29
+ const generator = typeof opts.generator === 'function'
30
+ ? opts.generator
31
+ : () => crypto.randomUUID();
32
+
33
+ return (req, res, next) =>
34
+ {
35
+ let id;
36
+
37
+ if (trustProxy)
38
+ {
39
+ const existing = req.headers[headerName.toLowerCase()];
40
+ if (existing && typeof existing === 'string' && existing.length <= 128)
41
+ {
42
+ id = existing;
43
+ }
44
+ }
45
+
46
+ if (!id) id = generator();
47
+
48
+ req.id = id;
49
+ res.set(headerName, id);
50
+ next();
51
+ };
52
+ }
53
+
54
+ module.exports = requestId;
@@ -6,6 +6,7 @@
6
6
  */
7
7
  const fs = require('fs');
8
8
  const path = require('path');
9
+ const log = require('../debug')('zero:static');
9
10
 
10
11
  /**
11
12
  * Extension → MIME-type lookup table.
@@ -78,14 +79,25 @@ const MIME = {
78
79
  };
79
80
 
80
81
  /**
81
- * Stream a file to the raw Node response, setting Content-Type and
82
- * Content-Length headers from the extension and optional `stat` result.
82
+ * Generate a weak ETag from file stats (mtime + size).
83
+ * @param {import('fs').Stats} stat
84
+ * @returns {string}
85
+ */
86
+ function generateETag(stat)
87
+ {
88
+ return 'W/"' + stat.size.toString(16) + '-' + stat.mtimeMs.toString(16) + '"';
89
+ }
90
+
91
+ /**
92
+ * Stream a file to the raw Node response, setting Content-Type,
93
+ * Content-Length, ETag, and Last-Modified headers.
83
94
  *
84
95
  * @param {import('./response')} res - Wrapped response object.
85
96
  * @param {string} filePath - Absolute path to the file.
86
97
  * @param {import('fs').Stats} [stat] - Pre-fetched `fs.Stats` (for Content-Length).
98
+ * @param {import('./request')} [req] - Wrapped request (for conditional checks).
87
99
  */
88
- function sendFile(res, filePath, stat)
100
+ function sendFile(res, filePath, stat, req)
89
101
  {
90
102
  const ext = path.extname(filePath).toLowerCase();
91
103
  const ct = MIME[ext] || 'application/octet-stream';
@@ -93,11 +105,77 @@ function sendFile(res, filePath, stat)
93
105
  try
94
106
  {
95
107
  raw.setHeader('Content-Type', ct);
96
- if (stat && stat.size) raw.setHeader('Content-Length', stat.size);
108
+ if (stat)
109
+ {
110
+ if (stat.size) raw.setHeader('Content-Length', stat.size);
111
+ // ETag and Last-Modified for caching
112
+ const etag = generateETag(stat);
113
+ raw.setHeader('ETag', etag);
114
+ raw.setHeader('Last-Modified', stat.mtime.toUTCString());
115
+ raw.setHeader('Accept-Ranges', 'bytes');
116
+
117
+ // Conditional request handling (304 Not Modified)
118
+ if (req)
119
+ {
120
+ const ifNoneMatch = req.headers['if-none-match'];
121
+ const ifModifiedSince = req.headers['if-modified-since'];
122
+ if (ifNoneMatch && ifNoneMatch === etag)
123
+ {
124
+ raw.statusCode = 304;
125
+ raw.end();
126
+ return;
127
+ }
128
+ if (ifModifiedSince && !ifNoneMatch)
129
+ {
130
+ const since = Date.parse(ifModifiedSince);
131
+ if (!isNaN(since) && stat.mtimeMs <= since)
132
+ {
133
+ raw.statusCode = 304;
134
+ raw.end();
135
+ return;
136
+ }
137
+ }
138
+
139
+ // Range request support (HTTP 206)
140
+ const rangeHeader = req.headers['range'];
141
+ if (rangeHeader && stat.size > 0)
142
+ {
143
+ const match = /^bytes=(\d*)-(\d*)$/.exec(rangeHeader);
144
+ if (match)
145
+ {
146
+ let start = match[1] ? parseInt(match[1], 10) : 0;
147
+ let end = match[2] ? parseInt(match[2], 10) : stat.size - 1;
148
+ if (!match[1] && match[2])
149
+ {
150
+ // suffix range: bytes=-500 means last 500 bytes
151
+ start = Math.max(0, stat.size - parseInt(match[2], 10));
152
+ end = stat.size - 1;
153
+ }
154
+ if (start > end || start >= stat.size || end >= stat.size)
155
+ {
156
+ raw.statusCode = 416;
157
+ raw.setHeader('Content-Range', 'bytes */' + stat.size);
158
+ raw.setHeader('Content-Length', 0);
159
+ raw.end();
160
+ return;
161
+ }
162
+ raw.statusCode = 206;
163
+ raw.setHeader('Content-Range', 'bytes ' + start + '-' + end + '/' + stat.size);
164
+ raw.setHeader('Content-Length', end - start + 1);
165
+ const stream = fs.createReadStream(filePath, { start, end });
166
+ stream.on('error', (err) => { log.warn('file read error %s: %s', filePath, err.message); try { raw.statusCode = 404; raw.end(); } catch (e) { } });
167
+ log.debug('serving %s (range %d-%d)', filePath, start, end);
168
+ stream.pipe(raw);
169
+ return;
170
+ }
171
+ }
172
+ }
173
+ }
97
174
  }
98
175
  catch (e) { /* best-effort */ }
99
176
  const stream = fs.createReadStream(filePath);
100
- stream.on('error', () => { try { raw.statusCode = 404; raw.end(); } catch (e) { } });
177
+ stream.on('error', (err) => { log.warn('file read error %s: %s', filePath, err.message); try { raw.statusCode = 404; raw.end(); } catch (e) { } });
178
+ log.debug('serving %s', filePath);
101
179
  stream.pipe(raw);
102
180
  }
103
181
 
@@ -136,9 +214,15 @@ function serveStatic(root, options = {})
136
214
  return (req, res, next) =>
137
215
  {
138
216
  if (req.method !== 'GET' && req.method !== 'HEAD') return next();
139
- const urlPath = decodeURIComponent(req.url.split('?')[0]);
140
- let file = path.join(root, urlPath);
141
- if (!file.startsWith(root)) return res.status(403).json({ error: 'Forbidden' });
217
+ let urlPath;
218
+ try { urlPath = decodeURIComponent(req.url.split('?')[0]); } catch (e) { return res.status(400).json({ error: 'Bad Request' }); }
219
+
220
+ // Block null bytes (poison byte attack)
221
+ if (urlPath.indexOf('\0') !== -1) return res.status(400).json({ error: 'Bad Request' });
222
+
223
+ let file = path.resolve(root, '.' + path.sep + urlPath);
224
+ // Normalize and verify the resolved path is within root (prevents path traversal)
225
+ if (!file.startsWith(root + path.sep) && file !== root) return res.status(403).json({ error: 'Forbidden' });
142
226
 
143
227
  if (isDotfile(file) && dotfiles === 'deny') return res.status(403).json({ error: 'Forbidden' });
144
228
 
@@ -160,7 +244,7 @@ function serveStatic(root, options = {})
160
244
  {
161
245
  if (isDotfile(f) && dotfiles === 'deny') return res.status(403).json({ error: 'Forbidden' });
162
246
  applyHeaders(res, f);
163
- return sendFile(res, f, st2);
247
+ return sendFile(res, f, st2, req);
164
248
  }
165
249
  tryExt(i + 1);
166
250
  });
@@ -179,7 +263,7 @@ function serveStatic(root, options = {})
179
263
  if (err2) return next();
180
264
  if (isDotfile(idxFile) && dotfiles === 'deny') return res.status(403).json({ error: 'Forbidden' });
181
265
  applyHeaders(res, idxFile);
182
- sendFile(res, idxFile, st2);
266
+ sendFile(res, idxFile, st2, req);
183
267
  });
184
268
  }
185
269
  else
@@ -187,7 +271,7 @@ function serveStatic(root, options = {})
187
271
  if (isDotfile(file) && dotfiles === 'ignore') return next();
188
272
  if (isDotfile(file) && dotfiles === 'deny') return res.status(403).json({ error: 'Forbidden' });
189
273
  applyHeaders(res, file);
190
- sendFile(res, file, st);
274
+ sendFile(res, file, st, req);
191
275
  }
192
276
  });
193
277
  };
@@ -0,0 +1,72 @@
1
+ /**
2
+ * @module timeout
3
+ * @description Request timeout middleware.
4
+ * Automatically sends a 408 response if the handler doesn't
5
+ * respond within the configured time limit.
6
+ * Helps prevent Slowloris-style attacks and hung requests.
7
+ */
8
+
9
+ /**
10
+ * Create a request timeout middleware.
11
+ *
12
+ * @param {number} [ms=30000] - Timeout in milliseconds (default 30s).
13
+ * @param {object} [opts]
14
+ * @param {number} [opts.status=408] - HTTP status code for timeout responses.
15
+ * @param {string} [opts.message='Request Timeout'] - Error message body.
16
+ * @returns {Function} Middleware `(req, res, next) => void`.
17
+ *
18
+ * @example
19
+ * app.use(timeout(5000)); // 5 second timeout
20
+ * app.use(timeout(10000, { message: 'Too slow' }));
21
+ */
22
+ const log = require('../debug')('zero:timeout');
23
+
24
+ function timeout(ms = 30000, opts = {})
25
+ {
26
+ if (typeof ms === 'object') { opts = ms; ms = 30000; }
27
+
28
+ const statusCode = opts.status || 408;
29
+ const message = opts.message || 'Request Timeout';
30
+
31
+ return (req, res, next) =>
32
+ {
33
+ let timedOut = false;
34
+
35
+ const timer = setTimeout(() =>
36
+ {
37
+ timedOut = true;
38
+ req._timedOut = true;
39
+ log.warn('request timed out after %dms: %s %s', ms, req.method, req.url);
40
+
41
+ // Only send response if headers haven't been sent yet
42
+ if (!res.headersSent && !res._sent)
43
+ {
44
+ res.status(statusCode).json({ error: message });
45
+ }
46
+ }, ms);
47
+
48
+ // Unref so the timer doesn't keep the process alive
49
+ if (timer.unref) timer.unref();
50
+
51
+ // Clear timeout when response finishes
52
+ const raw = res.raw;
53
+ const onFinish = () =>
54
+ {
55
+ clearTimeout(timer);
56
+ raw.removeListener('finish', onFinish);
57
+ raw.removeListener('close', onFinish);
58
+ };
59
+ raw.on('finish', onFinish);
60
+ raw.on('close', onFinish);
61
+
62
+ // Expose timedOut check on request
63
+ Object.defineProperty(req, 'timedOut', {
64
+ get() { return timedOut; },
65
+ configurable: true,
66
+ });
67
+
68
+ next();
69
+ };
70
+ }
71
+
72
+ module.exports = timeout;
@@ -0,0 +1,257 @@
1
+ /**
2
+ * @module middleware/validator
3
+ * @description Request validation middleware.
4
+ * Validates `req.body`, `req.query`, and `req.params` against a
5
+ * schema object. Returns 422 with detailed errors on failure.
6
+ *
7
+ * @example
8
+ * const { createApp, validate } = require('zero-http');
9
+ * const app = createApp();
10
+ *
11
+ * app.post('/users', validate({
12
+ * body: {
13
+ * name: { type: 'string', required: true, minLength: 1, maxLength: 100 },
14
+ * email: { type: 'string', required: true, match: /^[^@]+@[^@]+\.[^@]+$/ },
15
+ * age: { type: 'integer', min: 0, max: 150 },
16
+ * },
17
+ * query: {
18
+ * format: { type: 'string', enum: ['json', 'xml'], default: 'json' },
19
+ * },
20
+ * }), (req, res) => {
21
+ * // req.body / req.query are now validated and sanitised
22
+ * });
23
+ */
24
+
25
+ /**
26
+ * Supported shorthand types for validation rules.
27
+ * @private
28
+ */
29
+ const COERCE = {
30
+ string(v) { return v == null ? v : String(v); },
31
+ integer(v) { const n = parseInt(v, 10); return Number.isNaN(n) ? v : n; },
32
+ number(v) { const n = Number(v); return Number.isNaN(n) ? v : n; },
33
+ float(v) { const n = parseFloat(v); return Number.isNaN(n) ? v : n; },
34
+ boolean(v)
35
+ {
36
+ if (typeof v === 'boolean') return v;
37
+ if (typeof v === 'string')
38
+ {
39
+ const l = v.toLowerCase();
40
+ if (l === 'true' || l === '1' || l === 'yes' || l === 'on') return true;
41
+ if (l === 'false' || l === '0' || l === 'no' || l === 'off') return false;
42
+ }
43
+ return v;
44
+ },
45
+ array(v)
46
+ {
47
+ if (Array.isArray(v)) return v;
48
+ if (typeof v === 'string')
49
+ {
50
+ try { const p = JSON.parse(v); if (Array.isArray(p)) return p; } catch {}
51
+ return v.split(',').map(s => s.trim());
52
+ }
53
+ return v;
54
+ },
55
+ json(v)
56
+ {
57
+ if (typeof v === 'string') { try { return JSON.parse(v); } catch {} }
58
+ return v;
59
+ },
60
+ date(v)
61
+ {
62
+ if (v instanceof Date) return v;
63
+ const d = new Date(v);
64
+ return Number.isNaN(d.getTime()) ? v : d;
65
+ },
66
+ uuid(v) { return v == null ? v : String(v); },
67
+ email(v) { return v == null ? v : String(v).trim().toLowerCase(); },
68
+ url(v) { return v == null ? v : String(v).trim(); },
69
+ };
70
+
71
+ /**
72
+ * Validate a single value against a rule definition.
73
+ *
74
+ * @param {*} value - Raw input value.
75
+ * @param {object} rule - Rule definition.
76
+ * @param {string} field - Field name (for error messages).
77
+ * @returns {{ value: *, error: string|null }}
78
+ * @private
79
+ */
80
+ function validateField(value, rule, field)
81
+ {
82
+ // Apply default
83
+ if ((value === undefined || value === null || value === '') && rule.default !== undefined)
84
+ {
85
+ value = typeof rule.default === 'function' ? rule.default() : rule.default;
86
+ }
87
+
88
+ // Required check
89
+ if (rule.required && (value === undefined || value === null || value === ''))
90
+ {
91
+ return { value, error: `${field} is required` };
92
+ }
93
+
94
+ // If not required and absent, skip further checks
95
+ if (value === undefined || value === null) return { value, error: null };
96
+
97
+ // Type coercion
98
+ if (rule.type && COERCE[rule.type]) value = COERCE[rule.type](value);
99
+
100
+ // Type validation
101
+ if (rule.type)
102
+ {
103
+ switch (rule.type)
104
+ {
105
+ case 'string':
106
+ if (typeof value !== 'string') return { value, error: `${field} must be a string` };
107
+ break;
108
+ case 'integer':
109
+ if (!Number.isInteger(value)) return { value, error: `${field} must be an integer` };
110
+ break;
111
+ case 'number':
112
+ case 'float':
113
+ if (typeof value !== 'number' || Number.isNaN(value)) return { value, error: `${field} must be a number` };
114
+ break;
115
+ case 'boolean':
116
+ if (typeof value !== 'boolean') return { value, error: `${field} must be a boolean` };
117
+ break;
118
+ case 'array':
119
+ if (!Array.isArray(value)) return { value, error: `${field} must be an array` };
120
+ break;
121
+ case 'date':
122
+ if (!(value instanceof Date)) return { value, error: `${field} must be a valid date` };
123
+ break;
124
+ case 'email':
125
+ if (typeof value !== 'string' || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value))
126
+ return { value, error: `${field} must be a valid email` };
127
+ break;
128
+ case 'url':
129
+ try { new URL(value); }
130
+ catch { return { value, error: `${field} must be a valid URL` }; }
131
+ break;
132
+ case 'uuid':
133
+ if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value))
134
+ return { value, error: `${field} must be a valid UUID` };
135
+ break;
136
+ }
137
+ }
138
+
139
+ // Constraints
140
+ if (rule.minLength !== undefined && typeof value === 'string' && value.length < rule.minLength)
141
+ return { value, error: `${field} must be at least ${rule.minLength} characters` };
142
+ if (rule.maxLength !== undefined && typeof value === 'string' && value.length > rule.maxLength)
143
+ return { value, error: `${field} must be at most ${rule.maxLength} characters` };
144
+ if (rule.min !== undefined && typeof value === 'number' && value < rule.min)
145
+ return { value, error: `${field} must be >= ${rule.min}` };
146
+ if (rule.max !== undefined && typeof value === 'number' && value > rule.max)
147
+ return { value, error: `${field} must be <= ${rule.max}` };
148
+ if (rule.match && typeof value === 'string' && !rule.match.test(value))
149
+ return { value, error: `${field} format is invalid` };
150
+ if (rule.enum && !rule.enum.includes(value))
151
+ return { value, error: `${field} must be one of: ${rule.enum.join(', ')}` };
152
+ if (rule.minItems !== undefined && Array.isArray(value) && value.length < rule.minItems)
153
+ return { value, error: `${field} must have at least ${rule.minItems} items` };
154
+ if (rule.maxItems !== undefined && Array.isArray(value) && value.length > rule.maxItems)
155
+ return { value, error: `${field} must have at most ${rule.maxItems} items` };
156
+
157
+ // Custom validator function
158
+ if (typeof rule.validate === 'function')
159
+ {
160
+ const msg = rule.validate(value);
161
+ if (typeof msg === 'string') return { value, error: msg };
162
+ }
163
+
164
+ return { value, error: null };
165
+ }
166
+
167
+ /**
168
+ * Validate an object against a schema.
169
+ *
170
+ * @param {object} data - Input data.
171
+ * @param {object} schema - { fieldName: ruleObject }
172
+ * @param {object} [opts]
173
+ * @param {boolean} [opts.stripUnknown=true] - Remove fields not in schema.
174
+ * @returns {{ sanitized: object, errors: string[] }}
175
+ * @private
176
+ */
177
+ function validateObject(data, schema, opts = {})
178
+ {
179
+ const errors = [];
180
+ const sanitized = {};
181
+ const stripUnknown = opts.stripUnknown !== false;
182
+ const source = data || {};
183
+
184
+ for (const [field, rule] of Object.entries(schema))
185
+ {
186
+ const { value, error } = validateField(source[field], rule, field);
187
+ if (error) errors.push(error);
188
+ else if (value !== undefined) sanitized[field] = value;
189
+ }
190
+
191
+ // Preserve unknown fields if not stripping
192
+ if (!stripUnknown)
193
+ {
194
+ for (const key of Object.keys(source))
195
+ {
196
+ if (!(key in schema)) sanitized[key] = source[key];
197
+ }
198
+ }
199
+
200
+ return { sanitized, errors };
201
+ }
202
+
203
+ /**
204
+ * Create a validation middleware.
205
+ *
206
+ * @param {object} schema
207
+ * @param {object} [schema.body] - Rules for req.body fields.
208
+ * @param {object} [schema.query] - Rules for req.query fields.
209
+ * @param {object} [schema.params] - Rules for req.params fields.
210
+ * @param {object} [options]
211
+ * @param {boolean} [options.stripUnknown=true] - Remove fields not in schema.
212
+ * @param {Function} [options.onError] - Custom error handler `(errors, req, res) => {}`.
213
+ * @returns {Function} Middleware function.
214
+ */
215
+ function validate(schema, options = {})
216
+ {
217
+ return function validatorMiddleware(req, res, next)
218
+ {
219
+ const allErrors = [];
220
+
221
+ if (schema.body)
222
+ {
223
+ const { sanitized, errors } = validateObject(req.body, schema.body, options);
224
+ if (errors.length) allErrors.push(...errors.map(e => `body.${e}`));
225
+ else req.body = sanitized;
226
+ }
227
+
228
+ if (schema.query)
229
+ {
230
+ const { sanitized, errors } = validateObject(req.query, schema.query, options);
231
+ if (errors.length) allErrors.push(...errors.map(e => `query.${e}`));
232
+ else req.query = sanitized;
233
+ }
234
+
235
+ if (schema.params)
236
+ {
237
+ const { sanitized, errors } = validateObject(req.params, schema.params, options);
238
+ if (errors.length) allErrors.push(...errors.map(e => `params.${e}`));
239
+ else req.params = sanitized;
240
+ }
241
+
242
+ if (allErrors.length > 0)
243
+ {
244
+ if (options.onError) return options.onError(allErrors, req, res);
245
+ res.status(422).json({ errors: allErrors });
246
+ return;
247
+ }
248
+
249
+ next();
250
+ };
251
+ }
252
+
253
+ // Also export helpers for standalone use
254
+ validate.field = validateField;
255
+ validate.object = validateObject;
256
+
257
+ module.exports = validate;