zero-http 0.2.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.
Files changed (54) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +311 -0
  3. package/documentation/controllers/cleanup.js +25 -0
  4. package/documentation/controllers/echo.js +14 -0
  5. package/documentation/controllers/headers.js +1 -0
  6. package/documentation/controllers/proxy.js +112 -0
  7. package/documentation/controllers/root.js +1 -0
  8. package/documentation/controllers/uploads.js +289 -0
  9. package/documentation/full-server.js +129 -0
  10. package/documentation/public/data/api.json +167 -0
  11. package/documentation/public/data/examples.json +62 -0
  12. package/documentation/public/data/options.json +13 -0
  13. package/documentation/public/index.html +414 -0
  14. package/documentation/public/prism-overrides.css +40 -0
  15. package/documentation/public/scripts/app.js +44 -0
  16. package/documentation/public/scripts/data-sections.js +300 -0
  17. package/documentation/public/scripts/helpers.js +166 -0
  18. package/documentation/public/scripts/playground.js +71 -0
  19. package/documentation/public/scripts/proxy.js +98 -0
  20. package/documentation/public/scripts/ui.js +210 -0
  21. package/documentation/public/scripts/uploads.js +459 -0
  22. package/documentation/public/styles.css +310 -0
  23. package/documentation/public/vendor/icons/fetch.svg +23 -0
  24. package/documentation/public/vendor/icons/plug.svg +27 -0
  25. package/documentation/public/vendor/icons/static.svg +35 -0
  26. package/documentation/public/vendor/icons/stream.svg +22 -0
  27. package/documentation/public/vendor/icons/zero.svg +21 -0
  28. package/documentation/public/vendor/prism-copy-to-clipboard.min.js +27 -0
  29. package/documentation/public/vendor/prism-javascript.min.js +1 -0
  30. package/documentation/public/vendor/prism-json.min.js +1 -0
  31. package/documentation/public/vendor/prism-okaidia.css +1 -0
  32. package/documentation/public/vendor/prism-toolbar.css +27 -0
  33. package/documentation/public/vendor/prism-toolbar.min.js +41 -0
  34. package/documentation/public/vendor/prism.min.js +1 -0
  35. package/index.js +43 -0
  36. package/lib/app.js +159 -0
  37. package/lib/body/index.js +14 -0
  38. package/lib/body/json.js +54 -0
  39. package/lib/body/multipart.js +310 -0
  40. package/lib/body/raw.js +40 -0
  41. package/lib/body/rawBuffer.js +74 -0
  42. package/lib/body/sendError.js +17 -0
  43. package/lib/body/text.js +43 -0
  44. package/lib/body/typeMatch.js +22 -0
  45. package/lib/body/urlencoded.js +166 -0
  46. package/lib/cors.js +72 -0
  47. package/lib/fetch.js +218 -0
  48. package/lib/logger.js +68 -0
  49. package/lib/rateLimit.js +64 -0
  50. package/lib/request.js +76 -0
  51. package/lib/response.js +165 -0
  52. package/lib/router.js +87 -0
  53. package/lib/static.js +196 -0
  54. package/package.json +44 -0
@@ -0,0 +1,289 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+
4
+ // --- Shared Constants & Helpers ---
5
+
6
+ const IMAGE_RE = /\.(png|jpe?g|gif|webp|svg|jfif)$/i;
7
+ const HIDDEN_DIRS = new Set(['.trash', '.thumbs']);
8
+
9
+ /** Build the thumbnail filename for a given stored name */
10
+ const thumbName = (storedName) => storedName + '-thumb.svg';
11
+
12
+ /** Ensure a directory exists (no-op if it already does) */
13
+ const ensureDir = (dir) => { try { if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); } catch (e) { } };
14
+
15
+ /** Resolve common paths relative to uploadsDir */
16
+ const dirs = (uploadsDir) => ({
17
+ thumbs: path.join(uploadsDir, '.thumbs'),
18
+ trash: path.join(uploadsDir, '.trash'),
19
+ trashThumbs: path.join(uploadsDir, '.trash', '.thumbs'),
20
+ });
21
+
22
+ /**
23
+ * Read file entries from a directory, returning metadata objects.
24
+ * Skips hidden dirs (.trash, .thumbs).
25
+ */
26
+ function readFileEntries(dir, uploadsDir)
27
+ {
28
+ if (!fs.existsSync(dir)) return [];
29
+ const { thumbs } = dirs(uploadsDir || dir);
30
+ const entries = [];
31
+ for (const fn of fs.readdirSync(dir))
32
+ {
33
+ if (HIDDEN_DIRS.has(fn)) continue;
34
+ try
35
+ {
36
+ const st = fs.statSync(path.join(dir, fn));
37
+ const isImage = IMAGE_RE.test(fn);
38
+ const tn = thumbName(fn);
39
+ const thumbExists = fs.existsSync(path.join(thumbs, tn));
40
+ entries.push({
41
+ name: fn,
42
+ url: '/uploads/' + encodeURIComponent(fn),
43
+ size: st.size,
44
+ mtime: st.mtimeMs,
45
+ isImage,
46
+ thumb: thumbExists ? '/uploads/.thumbs/' + encodeURIComponent(tn) : null,
47
+ });
48
+ } catch (e) { }
49
+ }
50
+ return entries;
51
+ }
52
+
53
+ /** Move a thumbnail between two directories (if it exists) */
54
+ function moveThumb(srcDir, destDir, storedName)
55
+ {
56
+ try
57
+ {
58
+ const tn = thumbName(storedName);
59
+ const src = path.join(srcDir, tn);
60
+ if (!fs.existsSync(src)) return;
61
+ ensureDir(destDir);
62
+ fs.renameSync(src, path.join(destDir, tn));
63
+ } catch (e) { }
64
+ }
65
+
66
+ /** Delete a thumbnail from a directory (if it exists) */
67
+ function deleteThumb(dir, storedName)
68
+ {
69
+ try
70
+ {
71
+ const p = path.join(dir, thumbName(storedName));
72
+ if (fs.existsSync(p)) fs.unlinkSync(p);
73
+ } catch (e) { }
74
+ }
75
+
76
+ // --- Exports ---
77
+
78
+ exports.ensureUploadsDir = (uploadsDir) =>
79
+ {
80
+ const { trash } = dirs(uploadsDir);
81
+ ensureDir(uploadsDir);
82
+ ensureDir(trash);
83
+ };
84
+
85
+ /** Handle multipart upload (generate SVG thumbnails for images) */
86
+ exports.upload = (uploadsDir) => (req, res) =>
87
+ {
88
+ if (req._multipartErrorHandled) return;
89
+ const { thumbs } = dirs(uploadsDir);
90
+ const files = req.body.files || {};
91
+ const outFiles = {};
92
+
93
+ for (const key of Object.keys(files))
94
+ {
95
+ const f = files[key];
96
+ outFiles[key] = {
97
+ originalFilename: f.originalFilename,
98
+ storedName: f.storedName,
99
+ size: f.size,
100
+ url: '/uploads/' + encodeURIComponent(f.storedName),
101
+ };
102
+
103
+ if (!IMAGE_RE.test(f.originalFilename || '')) continue;
104
+
105
+ // Generate a simple SVG placeholder thumbnail
106
+ try
107
+ {
108
+ ensureDir(thumbs);
109
+ const tn = thumbName(f.storedName);
110
+ const safeName = (f.originalFilename || '').replace(/[&<>"']/g, c =>
111
+ ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
112
+ const sizeText = typeof f.size === 'number' ? Math.round(f.size / 1024) + ' KB' : '';
113
+ const svg = [
114
+ '<?xml version="1.0" encoding="utf-8"?>',
115
+ '<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128">',
116
+ ' <rect width="100%" height="100%" fill="#eef2ff" rx="8" ry="8"/>',
117
+ ` <text x="50%" y="50%" font-family="Arial, Helvetica, sans-serif" font-size="12" fill="#111827" dominant-baseline="middle" text-anchor="middle">${safeName}</text>`,
118
+ ` <text x="50%" y="72%" font-family="Arial, Helvetica, sans-serif" font-size="10" fill="#6b7280" dominant-baseline="middle" text-anchor="middle">${sizeText}</text>`,
119
+ '</svg>',
120
+ ].join('\n');
121
+ fs.writeFileSync(path.join(thumbs, tn), svg, 'utf8');
122
+ outFiles[key].thumbUrl = '/uploads/.thumbs/' + encodeURIComponent(tn);
123
+ } catch (e) { }
124
+ }
125
+
126
+ return res.json({ fields: req.body.fields || {}, files: outFiles });
127
+ };
128
+
129
+ /** Move a single upload to trash */
130
+ exports.deleteUpload = (uploadsDir) => (req, res) =>
131
+ {
132
+ const name = req.params.name;
133
+ const { thumbs, trash, trashThumbs } = dirs(uploadsDir);
134
+ const src = path.join(uploadsDir, name);
135
+
136
+ if (!fs.existsSync(src)) return res.status(404).json({ error: 'Not found' });
137
+ try
138
+ {
139
+ fs.renameSync(src, path.join(trash, name));
140
+ moveThumb(thumbs, trashThumbs, name);
141
+ return res.json({ trashed: name });
142
+ } catch (e) { return res.status(500).json({ error: String(e) }); }
143
+ };
144
+
145
+ /** Delete all uploads (optionally keep first via ?keep=1) */
146
+ exports.deleteAllUploads = (uploadsDir) => (req, res) =>
147
+ {
148
+ const keep = Number(req.query.keep) || 0;
149
+ const { thumbs } = dirs(uploadsDir);
150
+
151
+ if (!fs.existsSync(uploadsDir)) return res.json({ removed: [] });
152
+ try
153
+ {
154
+ const files = fs.readdirSync(uploadsDir).filter(n => !HIDDEN_DIRS.has(n)).sort();
155
+ const removed = [];
156
+ for (let i = 0; i < files.length; i++)
157
+ {
158
+ if (keep && i === 0) continue;
159
+ try { fs.unlinkSync(path.join(uploadsDir, files[i])); removed.push(files[i]); } catch (e) { }
160
+ }
161
+ for (const n of removed) deleteThumb(thumbs, n);
162
+ return res.json({ removed });
163
+ } catch (e) { return res.status(500).json({ error: String(e) }); }
164
+ };
165
+
166
+ /** Restore a trashed file back to uploads */
167
+ exports.restoreUpload = (uploadsDir) => (req, res) =>
168
+ {
169
+ const name = req.params.name;
170
+ const { thumbs, trash, trashThumbs } = dirs(uploadsDir);
171
+ const src = path.join(trash, name);
172
+
173
+ if (!fs.existsSync(src)) return res.status(404).json({ error: 'Not found in trash' });
174
+ try
175
+ {
176
+ fs.renameSync(src, path.join(uploadsDir, name));
177
+ moveThumb(trashThumbs, thumbs, name);
178
+ return res.json({ restored: name });
179
+ } catch (e) { return res.status(500).json({ error: String(e) }); }
180
+ };
181
+
182
+ /** List files currently in trash */
183
+ exports.listTrash = (uploadsDir) => (req, res) =>
184
+ {
185
+ const { trash } = dirs(uploadsDir);
186
+ try
187
+ {
188
+ const files = !fs.existsSync(trash) ? [] :
189
+ fs.readdirSync(trash)
190
+ .filter(fn => fn !== '.thumbs')
191
+ .map(fn => ({ name: fn, url: '/uploads/.trash/' + encodeURIComponent(fn) }));
192
+ res.json({ files });
193
+ } catch (e) { res.status(500).json({ error: String(e) }); }
194
+ };
195
+
196
+ /** Permanently delete a single trash item */
197
+ exports.deleteTrashItem = (uploadsDir) => (req, res) =>
198
+ {
199
+ const name = req.params.name;
200
+ const { trash, trashThumbs } = dirs(uploadsDir);
201
+ const p = path.join(trash, name);
202
+
203
+ if (!fs.existsSync(p)) return res.status(404).json({ error: 'Not found' });
204
+ try
205
+ {
206
+ fs.unlinkSync(p);
207
+ deleteThumb(trashThumbs, name);
208
+ return res.json({ deleted: name });
209
+ } catch (e) { return res.status(500).json({ error: String(e) }); }
210
+ };
211
+
212
+ /** Empty the entire trash folder */
213
+ exports.emptyTrash = (uploadsDir) => (req, res) =>
214
+ {
215
+ const { trash, trashThumbs } = dirs(uploadsDir);
216
+ try
217
+ {
218
+ const removed = [];
219
+ if (fs.existsSync(trash))
220
+ {
221
+ for (const f of fs.readdirSync(trash))
222
+ {
223
+ if (f === '.thumbs') continue;
224
+ try { fs.unlinkSync(path.join(trash, f)); removed.push(f); } catch (e) { }
225
+ }
226
+ // clear all trash thumbnails
227
+ if (fs.existsSync(trashThumbs))
228
+ {
229
+ for (const tf of fs.readdirSync(trashThumbs))
230
+ {
231
+ try { fs.unlinkSync(path.join(trashThumbs, tf)); } catch (e) { }
232
+ }
233
+ }
234
+ }
235
+ return res.json({ removed });
236
+ } catch (e) { return res.status(500).json({ error: String(e) }); }
237
+ };
238
+
239
+ /** List uploaded files with pagination and sorting */
240
+ exports.listUploads = (uploadsDir) => (req, res) =>
241
+ {
242
+ try
243
+ {
244
+ const page = Math.max(1, Number(req.query.page) || 1);
245
+ const pageSize = Math.max(1, Math.min(200, Number(req.query.pageSize) || 20));
246
+ const sort = req.query.sort || 'mtime';
247
+ const order = (req.query.order || 'desc').toLowerCase() === 'asc' ? 'asc' : 'desc';
248
+
249
+ const list = readFileEntries(uploadsDir, uploadsDir);
250
+ list.sort((a, b) =>
251
+ {
252
+ let v = 0;
253
+ if (sort === 'name') v = a.name.localeCompare(b.name);
254
+ else if (sort === 'size') v = (a.size || 0) - (b.size || 0);
255
+ else v = (a.mtime || 0) - (b.mtime || 0);
256
+ return order === 'asc' ? v : -v;
257
+ });
258
+
259
+ const total = list.length;
260
+ const start = (page - 1) * pageSize;
261
+ res.json({ files: list.slice(start, start + pageSize), total, page, pageSize });
262
+ } catch (e) { res.status(500).json({ error: String(e) }); }
263
+ };
264
+
265
+ /** List all uploads and trash together (no pagination) for the demo UI */
266
+ exports.listAll = (uploadsDir) => (req, res) =>
267
+ {
268
+ const { trash } = dirs(uploadsDir);
269
+ try
270
+ {
271
+ const uploads = readFileEntries(uploadsDir, uploadsDir);
272
+
273
+ const trashItems = [];
274
+ if (fs.existsSync(trash))
275
+ {
276
+ for (const fn of fs.readdirSync(trash))
277
+ {
278
+ if (fn === '.thumbs') continue;
279
+ try
280
+ {
281
+ const st = fs.statSync(path.join(trash, fn));
282
+ trashItems.push({ name: fn, url: '/uploads/.trash/' + encodeURIComponent(fn), size: st.size, mtime: st.mtimeMs });
283
+ } catch (e) { }
284
+ }
285
+ }
286
+
287
+ res.json({ uploads, trash: trashItems });
288
+ } catch (e) { res.status(500).json({ error: String(e) }); }
289
+ };
@@ -0,0 +1,129 @@
1
+ /**
2
+ * zero-http full-server example
3
+ * Organized with controllers and JSDoc comments for clarity and maintainability.
4
+ */
5
+
6
+ const path = require('path');
7
+ const fs = require('fs');
8
+ const os = require('os');
9
+ const { createApp, cors, json, urlencoded, text, raw, multipart, static: serveStatic, fetch, logger } = require('..');
10
+
11
+ // --- App Initialization ---
12
+ const app = createApp();
13
+ app.use(logger({ format: 'dev' }));
14
+ app.use(cors());
15
+ app.use(json());
16
+ app.use(urlencoded());
17
+ app.use(text());
18
+ app.use(serveStatic(path.join(__dirname, 'public')));
19
+
20
+ // --- Controllers ---
21
+ const rootController = require('./controllers/root');
22
+ const headersController = require('./controllers/headers');
23
+ const echoController = require('./controllers/echo');
24
+ const uploadsController = require('./controllers/uploads');
25
+
26
+ // --- Core Routes ---
27
+ app.get('/', rootController.getRoot);
28
+ app.get('/headers', headersController.getHeaders);
29
+ app.post('/echo-json', echoController.echoJson);
30
+ app.post('/echo', echoController.echo);
31
+ app.post('/echo-urlencoded', echoController.echoUrlencoded);
32
+ app.post('/echo-text', echoController.echoText);
33
+ app.post('/echo-raw', raw(), echoController.echoRaw);
34
+
35
+ // --- Uploads and Trash ---
36
+ const uploadsDir = path.join(__dirname, 'uploads');
37
+ uploadsController.ensureUploadsDir(uploadsDir);
38
+
39
+ // Serve uploaded files from /uploads path using built-in path-prefix middleware
40
+ app.use('/uploads', serveStatic(uploadsDir));
41
+
42
+ // Upload, delete, restore, and trash routes
43
+ app.post('/upload', multipart({ maxFileSize: 5 * 1024 * 1024, dir: uploadsDir }), uploadsController.upload(uploadsDir));
44
+ app.delete('/uploads/:name', uploadsController.deleteUpload(uploadsDir));
45
+ app.delete('/uploads', uploadsController.deleteAllUploads(uploadsDir));
46
+ app.post('/uploads/:name/restore', uploadsController.restoreUpload(uploadsDir));
47
+ app.get('/uploads-trash-list', uploadsController.listTrash(uploadsDir));
48
+ app.delete('/uploads-trash/:name', uploadsController.deleteTrashItem(uploadsDir));
49
+ app.delete('/uploads-trash', uploadsController.emptyTrash(uploadsDir));
50
+
51
+ // --- Trash Retention ---
52
+ const TRASH_RETENTION_DAYS = Number(process.env.TRASH_RETENTION_DAYS || 7);
53
+ const TRASH_RETENTION_MS = TRASH_RETENTION_DAYS * 24 * 60 * 60 * 1000;
54
+
55
+ function autoEmptyTrash()
56
+ {
57
+ try
58
+ {
59
+ const trash = path.join(uploadsDir, '.trash');
60
+ if (!fs.existsSync(trash)) return;
61
+ const now = Date.now();
62
+ const removed = [];
63
+ for (const f of fs.readdirSync(trash))
64
+ {
65
+ try
66
+ {
67
+ const p = path.join(trash, f);
68
+ const st = fs.statSync(p);
69
+ if (now - st.mtimeMs > TRASH_RETENTION_MS)
70
+ {
71
+ fs.unlinkSync(p);
72
+ removed.push(f);
73
+ try { fs.unlinkSync(path.join(trash, '.thumbs', f + '-thumb.svg')); } catch (e) { }
74
+ }
75
+ } catch (e) { }
76
+ }
77
+ if (removed.length) console.log(`autoEmptyTrash: removed ${removed.length} file(s)`);
78
+ } catch (e) { console.error('autoEmptyTrash error:', e); }
79
+ }
80
+
81
+ autoEmptyTrash();
82
+ setInterval(autoEmptyTrash, 24 * 60 * 60 * 1000).unref();
83
+
84
+ // --- Uploads Listings ---
85
+ app.get('/uploads-list', uploadsController.listUploads(uploadsDir));
86
+ app.get('/uploads-all', uploadsController.listAll(uploadsDir));
87
+
88
+ // --- Temp Cleanup ---
89
+ const cleanupController = require('./controllers/cleanup');
90
+ app.post('/cleanup', cleanupController.cleanup(path.join(os.tmpdir(), 'zero-http-uploads')));
91
+
92
+ // --- Proxy ---
93
+ const proxyController = require('./controllers/proxy');
94
+ const proxyFetch = (typeof globalThis !== 'undefined' && globalThis.fetch) || fetch;
95
+ app.get('/proxy', proxyController.proxy(proxyFetch));
96
+
97
+ // --- Server Startup ---
98
+ const port = process.env.PORT || 3000;
99
+ const server = app.listen(port, () =>
100
+ {
101
+ console.log(`zero-http full-server listening on http://localhost:${port}`);
102
+ if (process.argv.includes('--test')) runTests(port).catch(console.error);
103
+ });
104
+
105
+ /** Quick smoke tests using built-in fetch */
106
+ async function runTests(port)
107
+ {
108
+ const base = `http://localhost:${port}`;
109
+ console.log('running smoke tests against', base);
110
+
111
+ const doReq = async (label, promise) =>
112
+ {
113
+ try
114
+ {
115
+ const r = await promise;
116
+ const ct = r.headers.get('content-type') || '';
117
+ const body = ct.includes('json') ? await r.json() : await r.text();
118
+ console.log(` ${label}`, r.status, JSON.stringify(body));
119
+ }
120
+ catch (e) { console.error(` ${label} error:`, e.message); }
121
+ };
122
+
123
+ await doReq('GET /', fetch(base + '/'));
124
+ await doReq('POST /echo-json', fetch(base + '/echo-json', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ a: 1 }) }));
125
+ await doReq('POST /echo-urlencoded', fetch(base + '/echo-urlencoded', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: 'foo=bar' }));
126
+ await doReq('POST /echo-text', fetch(base + '/echo-text', { method: 'POST', headers: { 'Content-Type': 'text/plain' }, body: 'hello' }));
127
+ await doReq('GET /headers', fetch(base + '/headers'));
128
+ console.log('smoke tests complete');
129
+ }
@@ -0,0 +1,167 @@
1
+ [
2
+ {
3
+ "name": "createApp()",
4
+ "description": "Returns an application instance with Express-like routing, middleware support, error handling, and an HTTP server.",
5
+ "example": "const { createApp, json, cors, logger, static: serveStatic } = require('zero-http')\nconst app = createApp()\n\napp.use(logger({ format: 'dev' }))\napp.use(cors())\napp.use(json({ limit: '10kb' }))\napp.use(serveStatic(path.join(__dirname, 'public')))\n\napp.get('/', (req, res) => res.json({ hello: 'world' }))\n\napp.onError((err, req, res, next) => {\n\tconsole.error(err)\n\tres.status(500).json({ error: err.message })\n})\n\napp.listen(3000, () => console.log('listening on :3000'))",
6
+ "methods": [
7
+ { "method": "use", "signature": "use(fn)", "description": "Register global middleware; fn(req, res, next)." },
8
+ { "method": "use", "signature": "use(prefix, fn)", "description": "Register path-scoped middleware. The prefix is stripped from req.url before calling fn." },
9
+ { "method": "get", "signature": "get(path, ...handlers)", "description": "Register GET route handlers." },
10
+ { "method": "post", "signature": "post(path, ...handlers)", "description": "Register POST route handlers." },
11
+ { "method": "put", "signature": "put(path, ...handlers)", "description": "Register PUT route handlers." },
12
+ { "method": "delete", "signature": "delete(path, ...handlers)", "description": "Register DELETE route handlers." },
13
+ { "method": "patch", "signature": "patch(path, ...handlers)", "description": "Register PATCH route handlers." },
14
+ { "method": "options", "signature": "options(path, ...handlers)", "description": "Register OPTIONS route handlers." },
15
+ { "method": "head", "signature": "head(path, ...handlers)", "description": "Register HEAD route handlers." },
16
+ { "method": "all", "signature": "all(path, ...handlers)", "description": "Register handlers for ALL HTTP methods." },
17
+ { "method": "onError", "signature": "onError(fn)", "description": "Register a global error handler: fn(err, req, res, next). Only one at a time." },
18
+ { "method": "listen", "signature": "listen(port = 3000, cb)", "description": "Start the HTTP server. Returns the http.Server instance." },
19
+ { "method": "handler", "signature": "(property)", "description": "Bound request handler suitable for http.createServer(app.handler)." }
20
+ ],
21
+ "options": []
22
+ },
23
+ {
24
+ "name": "Request (req)",
25
+ "description": "Wraps the incoming Node http.IncomingMessage with convenient properties and helpers. Populated by the middleware chain.",
26
+ "example": "app.get('/info', (req, res) => {\n\tconsole.log(req.method) // 'GET'\n\tconsole.log(req.url) // '/info?foo=bar'\n\tconsole.log(req.query) // { foo: 'bar' }\n\tconsole.log(req.ip) // '127.0.0.1'\n\tconsole.log(req.get('host')) // 'localhost:3000'\n\tconsole.log(req.is('json')) // false\n\tres.json({ ok: true })\n})",
27
+ "methods": [
28
+ { "method": "method", "signature": "(string)", "description": "HTTP method (GET, POST, etc.)." },
29
+ { "method": "url", "signature": "(string)", "description": "Request URL path + query string." },
30
+ { "method": "headers", "signature": "(object)", "description": "Raw request headers (lowercased keys)." },
31
+ { "method": "query", "signature": "(object)", "description": "Parsed query string via URLSearchParams." },
32
+ { "method": "params", "signature": "(object)", "description": "Route parameters populated by the router (e.g. { id: '42' })." },
33
+ { "method": "body", "signature": "(any)", "description": "Parsed request body (null until a body parser runs)." },
34
+ { "method": "ip", "signature": "(string|null)", "description": "Remote IP address from req.socket.remoteAddress." },
35
+ { "method": "raw", "signature": "(object)", "description": "The underlying http.IncomingMessage." },
36
+ { "method": "get", "signature": "get(name)", "description": "Get a request header (case-insensitive)." },
37
+ { "method": "is", "signature": "is(type)", "description": "Check if Content-Type contains the given type string (e.g. 'json', 'text/html')." }
38
+ ],
39
+ "options": []
40
+ },
41
+ {
42
+ "name": "Response (res)",
43
+ "description": "Wraps the outgoing Node http.ServerResponse with chainable helpers for setting status, headers, and sending responses.",
44
+ "example": "app.get('/demo', (req, res) => {\n\tres.status(200)\n\t .set('X-Custom', 'hello')\n\t .type('json')\n\t .json({ message: 'ok' })\n})\n\napp.get('/page', (req, res) => res.html('<h1>Hello</h1>'))\napp.get('/go', (req, res) => res.redirect(301, '/new-location'))",
45
+ "methods": [
46
+ { "method": "status", "signature": "status(code)", "description": "Set HTTP status code. Returns this (chainable)." },
47
+ { "method": "set", "signature": "set(name, value)", "description": "Set a response header. Returns this (chainable)." },
48
+ { "method": "get", "signature": "get(name)", "description": "Get a previously-set response header (case-insensitive)." },
49
+ { "method": "type", "signature": "type(ct)", "description": "Set Content-Type. Accepts shorthands: 'json', 'html', 'text', 'xml', 'form', 'bin'. Chainable." },
50
+ { "method": "send", "signature": "send(body)", "description": "Send the response. Auto-detects Content-Type: Buffer→octet-stream, '<…'→html, string→text, object→json." },
51
+ { "method": "json", "signature": "json(obj)", "description": "Set Content-Type to application/json and send the object." },
52
+ { "method": "text", "signature": "text(str)", "description": "Set Content-Type to text/plain and send the string." },
53
+ { "method": "html", "signature": "html(str)", "description": "Set Content-Type to text/html and send the string." },
54
+ { "method": "redirect", "signature": "redirect([status], url)", "description": "Redirect to URL. Default status is 302." }
55
+ ],
56
+ "options": []
57
+ },
58
+ {
59
+ "name": "json([opts])",
60
+ "description": "Parses JSON request bodies and populates req.body. Skips non-matching Content-Types and calls next().",
61
+ "example": "app.use(json({\n\tlimit: '10kb',\n\tstrict: true,\n\treviver: (k, v) => {\n\t\tif (typeof v === 'string' && /^\\d{4}-\\d{2}-\\d{2}T/.test(v)) return new Date(v)\n\t\treturn v\n\t},\n\ttype: 'application/json'\n}))",
62
+ "options": [
63
+ { "option": "limit", "type": "number|string", "default": "null (no limit)", "notes": "Maximum body size. Accepts bytes or unit strings like '100kb', '1mb', '1gb'." },
64
+ { "option": "strict", "type": "boolean", "default": "true", "notes": "When true, only accepts objects and arrays (rejects primitives)." },
65
+ { "option": "reviver", "type": "function", "default": "—", "notes": "Optional function passed to JSON.parse for custom reviving." },
66
+ { "option": "type", "type": "string|function", "default": "application/json", "notes": "MIME type to match. Supports wildcards ('*/*') or a custom predicate function." }
67
+ ]
68
+ },
69
+ {
70
+ "name": "urlencoded([opts])",
71
+ "description": "Parses application/x-www-form-urlencoded bodies into req.body.",
72
+ "example": "// Simple flat parsing\napp.use(urlencoded())\n\n// Extended mode with nested bracket syntax\napp.use(urlencoded({\n\textended: true,\n\tlimit: '20kb'\n}))\n// a[b][c]=1 → { a: { b: { c: '1' } } }\n// a[]=1&a[]=2 → { a: ['1', '2'] }",
73
+ "options": [
74
+ { "option": "extended", "type": "boolean", "default": "false", "notes": "When true, supports rich nested bracket syntax (a[b]=1, a[]=1). When false, returns flat key/value pairs." },
75
+ { "option": "limit", "type": "number|string", "default": "null (no limit)", "notes": "Maximum body size (bytes or unit string)." },
76
+ { "option": "type", "type": "string|function", "default": "application/x-www-form-urlencoded", "notes": "MIME type to match." }
77
+ ]
78
+ },
79
+ {
80
+ "name": "text([opts])",
81
+ "description": "Reads plain text request bodies into req.body as a string.",
82
+ "example": "app.use(text({ type: 'text/*', limit: '50kb', encoding: 'utf8' }))\n\napp.post('/echo-text', (req, res) => {\n\tres.text(req.body) // echo back the plain text\n})",
83
+ "options": [
84
+ { "option": "type", "type": "string|function", "default": "text/*", "notes": "MIME matcher — uses wildcard by default so it matches text/plain, text/html, etc." },
85
+ { "option": "limit", "type": "number|string", "default": "null (no limit)", "notes": "Maximum body size." },
86
+ { "option": "encoding", "type": "string", "default": "utf8", "notes": "Character encoding used to decode the incoming bytes." }
87
+ ]
88
+ },
89
+ {
90
+ "name": "raw([opts])",
91
+ "description": "Receives raw bytes as a Buffer on req.body. Useful for binary payloads.",
92
+ "example": "app.post('/binary', raw({ limit: '5mb' }), (req, res) => {\n\tconsole.log(req.body) // <Buffer ...>\n\tconsole.log(req.body.length) // byte count\n\tres.json({ size: req.body.length })\n})",
93
+ "options": [
94
+ { "option": "type", "type": "string|function", "default": "application/octet-stream", "notes": "MIME type to match for the raw parser." },
95
+ { "option": "limit", "type": "number|string", "default": "null (no limit)", "notes": "Maximum body size. Returns 413 if exceeded." }
96
+ ]
97
+ },
98
+ {
99
+ "name": "multipart([opts])",
100
+ "description": "Streaming multipart parser that writes file parts to disk and collects text fields. Sets req.body to { fields, files }. Files are named upload-<timestamp>-<random>.<ext> and streamed via fs.createWriteStream.",
101
+ "example": "const uploadsDir = path.join(os.tmpdir(), 'my-uploads')\n\napp.post('/upload', multipart({\n\tdir: uploadsDir,\n\tmaxFileSize: 10 * 1024 * 1024\n}), (req, res) => {\n\t// req.body.fields → { desc: 'a photo' }\n\t// req.body.files → { file: { originalFilename, storedName, path, contentType, size } }\n\tres.json({ files: req.body.files, fields: req.body.fields })\n})",
102
+ "options": [
103
+ { "option": "dir", "type": "string", "default": "os.tmpdir()/zero-http-uploads", "notes": "Directory to store uploaded files. Relative paths resolve from process.cwd(). Created automatically if missing." },
104
+ { "option": "maxFileSize", "type": "number", "default": "null (no limit)", "notes": "Maximum file size in bytes. Exceeding this aborts the upload and returns HTTP 413." }
105
+ ]
106
+ },
107
+ {
108
+ "name": "static(rootPath, [opts])",
109
+ "description": "Serves static files from a filesystem directory with Content-Type detection (60+ MIME types), dotfile policy, caching, extension fallback, and custom header hooks. Only handles GET and HEAD requests.",
110
+ "example": "// Basic static serving\napp.use(static(path.join(__dirname, 'public')))\n\n// Full options\napp.use(static(path.join(__dirname, 'public'), {\n\tindex: 'index.html',\n\tmaxAge: 3600000, // 1 hour in ms\n\tdotfiles: 'ignore',\n\textensions: ['html', 'htm'],\n\tsetHeaders: (res, filePath) => {\n\t\tres.setHeader('X-Served-By', 'zero-http')\n\t}\n}))",
111
+ "options": [
112
+ { "option": "index", "type": "string|false", "default": "index.html", "notes": "File to serve for directory requests. Set false to disable auto-index." },
113
+ { "option": "maxAge", "type": "number", "default": "0", "notes": "Cache-Control max-age in milliseconds. Converted to seconds internally." },
114
+ { "option": "dotfiles", "type": "string", "default": "ignore", "notes": "'allow' | 'deny' | 'ignore'. deny→403, ignore→skip to next middleware, allow→serve normally." },
115
+ { "option": "extensions", "type": "string[]", "default": "null", "notes": "Fallback extensions to try when a file is not found (e.g. ['html', 'htm'])." },
116
+ { "option": "setHeaders", "type": "function", "default": "null", "notes": "Hook (res, filePath) => {} to set custom headers before streaming the file." }
117
+ ]
118
+ },
119
+ {
120
+ "name": "cors([opts])",
121
+ "description": "Small CORS middleware. Handles preflight OPTIONS requests automatically with 204 and sets standard CORS headers on all responses. Supports suffix-based origin matching for subdomains.",
122
+ "example": "// Allow everything (default)\napp.use(cors())\n\n// Restrict to specific origins with credentials\napp.use(cors({\n\torigin: ['https://example.com', '.mysite.com'],\n\tcredentials: true,\n\tmethods: 'GET,POST'\n}))\n// Origins starting with '.' enable suffix matching:\n// '.mysite.com' matches 'api.mysite.com', 'www.mysite.com', etc.",
123
+ "options": [
124
+ { "option": "origin", "type": "string|string[]|falsy", "default": "'*'", "notes": "Allowed origins. '*' allows any. Array entries starting with '.' do suffix matching. Set falsy to disable." },
125
+ { "option": "methods", "type": "string", "default": "GET,POST,PUT,DELETE,OPTIONS", "notes": "Allowed HTTP methods for Access-Control-Allow-Methods." },
126
+ { "option": "allowedHeaders", "type": "string", "default": "Content-Type,Authorization", "notes": "Allowed headers for Access-Control-Allow-Headers." },
127
+ { "option": "credentials", "type": "boolean", "default": "false", "notes": "When true and a specific origin matches, sets Access-Control-Allow-Credentials: true." }
128
+ ]
129
+ },
130
+ {
131
+ "name": "fetch(url, [opts])",
132
+ "description": "Small Node HTTP/HTTPS client returning a response with status, ok, statusText, headers.get(), and helpers: text(), json(), arrayBuffer(). Supports timeouts, AbortSignal, progress callbacks, and auto-serialization of request bodies.",
133
+ "example": "const { fetch } = require('zero-http')\n\n// Simple GET with timeout\nconst r = await fetch('https://jsonplaceholder.typicode.com/todos/1', {\n\ttimeout: 5000\n})\nconst data = await r.json()\nconsole.log(r.ok, r.status, data)\n\n// POST with auto-serialized JSON body\nawait fetch('https://httpbin.org/post', {\n\tmethod: 'POST',\n\tbody: { hello: 'world' }\n})\n\n// Download with progress tracking\nawait fetch('https://example.com/big-file.zip', {\n\tonDownloadProgress: ({ loaded, total }) =>\n\t\tconsole.log(`${loaded}/${total} bytes`)\n})",
134
+ "options": [
135
+ { "option": "method", "type": "string", "default": "GET", "notes": "HTTP method (uppercased internally)." },
136
+ { "option": "headers", "type": "object", "default": "{}", "notes": "Request headers." },
137
+ { "option": "body", "type": "string|Buffer|object|URLSearchParams|Stream", "default": "undefined", "notes": "Request body. Plain objects are auto-JSON-encoded. URLSearchParams produce urlencoded bodies. Streams are piped." },
138
+ { "option": "timeout", "type": "number", "default": "undefined", "notes": "Request timeout in milliseconds. Creates an ETIMEOUT error on expiry." },
139
+ { "option": "signal", "type": "AbortSignal", "default": "undefined", "notes": "AbortController signal to cancel the request." },
140
+ { "option": "agent", "type": "http.Agent", "default": "undefined", "notes": "Optional agent for connection pooling or proxies." },
141
+ { "option": "onDownloadProgress", "type": "function", "default": "—", "notes": "Callback receiving { loaded, total } on each response chunk." },
142
+ { "option": "onUploadProgress", "type": "function", "default": "—", "notes": "Callback receiving { loaded, total } during body upload. Buffer bodies are chunked at 64KB to enable tracking." }
143
+ ]
144
+ },
145
+ {
146
+ "name": "rateLimit([opts])",
147
+ "description": "In-memory, per-IP rate-limiting middleware. Sets standard X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset headers on every response. Returns 429 JSON error when the limit is exceeded. Expired entries are cleaned up automatically.",
148
+ "example": "const { rateLimit } = require('zero-http')\n\n// 100 requests per 15 minutes\napp.use(rateLimit({\n\twindowMs: 15 * 60 * 1000,\n\tmax: 100,\n\tmessage: 'Slow down!'\n}))\n\n// Per-route limiting with custom key\napp.post('/login', rateLimit({\n\twindowMs: 60000,\n\tmax: 5,\n\tkeyGenerator: (req) => req.body?.username || req.ip\n}), (req, res) => {\n\tres.json({ ok: true })\n})",
149
+ "options": [
150
+ { "option": "windowMs", "type": "number", "default": "60000", "notes": "Time window in milliseconds (default: 1 minute)." },
151
+ { "option": "max", "type": "number", "default": "100", "notes": "Maximum requests per window per key." },
152
+ { "option": "statusCode", "type": "number", "default": "429", "notes": "HTTP status code for rate-limited responses." },
153
+ { "option": "message", "type": "string", "default": "Too many requests, please try again later.", "notes": "Error message returned in the JSON response body when limit is exceeded." },
154
+ { "option": "keyGenerator", "type": "function", "default": "(req) => req.ip || 'unknown'", "notes": "Custom function to extract a rate-limit key from the request." }
155
+ ]
156
+ },
157
+ {
158
+ "name": "logger([opts])",
159
+ "description": "Request-logging middleware that prints method, URL, status code, and response time. Supports three output formats with optional ANSI colorization. Hooks into the response 'finish' event to capture final status.",
160
+ "example": "const { logger } = require('zero-http')\n\n// Colorized dev output (default)\napp.use(logger())\n\n// Short format with custom log function\napp.use(logger({\n\tformat: 'short',\n\tlogger: (msg) => fs.appendFileSync('access.log', msg + '\\n')\n}))\n\n// Tiny format, no colors\napp.use(logger({ format: 'tiny', colors: false }))",
161
+ "options": [
162
+ { "option": "format", "type": "string", "default": "dev", "notes": "'dev' (colorized method/status/time), 'short' (IP + method + url + status + time), or 'tiny' (method + url + status + time)." },
163
+ { "option": "logger", "type": "function", "default": "console.log", "notes": "Custom log output function." },
164
+ { "option": "colors", "type": "boolean", "default": "auto (TTY detection)", "notes": "Enable or disable ANSI color codes. Auto-detected from process.stdout.isTTY." }
165
+ ]
166
+ }
167
+ ]