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.
- package/LICENSE +21 -0
- package/README.md +311 -0
- package/documentation/controllers/cleanup.js +25 -0
- package/documentation/controllers/echo.js +14 -0
- package/documentation/controllers/headers.js +1 -0
- package/documentation/controllers/proxy.js +112 -0
- package/documentation/controllers/root.js +1 -0
- package/documentation/controllers/uploads.js +289 -0
- package/documentation/full-server.js +129 -0
- package/documentation/public/data/api.json +167 -0
- package/documentation/public/data/examples.json +62 -0
- package/documentation/public/data/options.json +13 -0
- package/documentation/public/index.html +414 -0
- package/documentation/public/prism-overrides.css +40 -0
- package/documentation/public/scripts/app.js +44 -0
- package/documentation/public/scripts/data-sections.js +300 -0
- package/documentation/public/scripts/helpers.js +166 -0
- package/documentation/public/scripts/playground.js +71 -0
- package/documentation/public/scripts/proxy.js +98 -0
- package/documentation/public/scripts/ui.js +210 -0
- package/documentation/public/scripts/uploads.js +459 -0
- package/documentation/public/styles.css +310 -0
- package/documentation/public/vendor/icons/fetch.svg +23 -0
- package/documentation/public/vendor/icons/plug.svg +27 -0
- package/documentation/public/vendor/icons/static.svg +35 -0
- package/documentation/public/vendor/icons/stream.svg +22 -0
- package/documentation/public/vendor/icons/zero.svg +21 -0
- package/documentation/public/vendor/prism-copy-to-clipboard.min.js +27 -0
- package/documentation/public/vendor/prism-javascript.min.js +1 -0
- package/documentation/public/vendor/prism-json.min.js +1 -0
- package/documentation/public/vendor/prism-okaidia.css +1 -0
- package/documentation/public/vendor/prism-toolbar.css +27 -0
- package/documentation/public/vendor/prism-toolbar.min.js +41 -0
- package/documentation/public/vendor/prism.min.js +1 -0
- package/index.js +43 -0
- package/lib/app.js +159 -0
- package/lib/body/index.js +14 -0
- package/lib/body/json.js +54 -0
- package/lib/body/multipart.js +310 -0
- package/lib/body/raw.js +40 -0
- package/lib/body/rawBuffer.js +74 -0
- package/lib/body/sendError.js +17 -0
- package/lib/body/text.js +43 -0
- package/lib/body/typeMatch.js +22 -0
- package/lib/body/urlencoded.js +166 -0
- package/lib/cors.js +72 -0
- package/lib/fetch.js +218 -0
- package/lib/logger.js +68 -0
- package/lib/rateLimit.js +64 -0
- package/lib/request.js +76 -0
- package/lib/response.js +165 -0
- package/lib/router.js +87 -0
- package/lib/static.js +196 -0
- package/package.json +44 -0
package/lib/rateLimit.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory rate-limiting middleware.
|
|
3
|
+
* Limits requests per IP address within a sliding window.
|
|
4
|
+
*
|
|
5
|
+
* @param {object} [opts]
|
|
6
|
+
* @param {number} [opts.windowMs=60000] - Time window in milliseconds.
|
|
7
|
+
* @param {number} [opts.max=100] - Maximum requests per window per IP.
|
|
8
|
+
* @param {string} [opts.message] - Custom error message.
|
|
9
|
+
* @param {number} [opts.statusCode=429] - HTTP status for rate-limited responses.
|
|
10
|
+
* @param {function} [opts.keyGenerator] - (req) => string; custom key extraction (default: req.ip).
|
|
11
|
+
* @returns {function} Middleware function.
|
|
12
|
+
*/
|
|
13
|
+
function rateLimit(opts = {})
|
|
14
|
+
{
|
|
15
|
+
const windowMs = opts.windowMs || 60_000;
|
|
16
|
+
const max = opts.max || 100;
|
|
17
|
+
const statusCode = opts.statusCode || 429;
|
|
18
|
+
const message = opts.message || 'Too many requests, please try again later.';
|
|
19
|
+
const keyGenerator = typeof opts.keyGenerator === 'function' ? opts.keyGenerator : (req) => req.ip || 'unknown';
|
|
20
|
+
|
|
21
|
+
const hits = new Map(); // key → { count, resetTime }
|
|
22
|
+
|
|
23
|
+
// Periodic cleanup to prevent memory leaks
|
|
24
|
+
const cleanupInterval = setInterval(() =>
|
|
25
|
+
{
|
|
26
|
+
const now = Date.now();
|
|
27
|
+
for (const [key, entry] of hits)
|
|
28
|
+
{
|
|
29
|
+
if (now >= entry.resetTime) hits.delete(key);
|
|
30
|
+
}
|
|
31
|
+
}, windowMs);
|
|
32
|
+
if (cleanupInterval.unref) cleanupInterval.unref();
|
|
33
|
+
|
|
34
|
+
return (req, res, next) =>
|
|
35
|
+
{
|
|
36
|
+
const key = keyGenerator(req);
|
|
37
|
+
const now = Date.now();
|
|
38
|
+
let entry = hits.get(key);
|
|
39
|
+
|
|
40
|
+
if (!entry || now >= entry.resetTime)
|
|
41
|
+
{
|
|
42
|
+
entry = { count: 0, resetTime: now + windowMs };
|
|
43
|
+
hits.set(key, entry);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
entry.count++;
|
|
47
|
+
|
|
48
|
+
// Set rate-limit headers
|
|
49
|
+
const remaining = Math.max(0, max - entry.count);
|
|
50
|
+
res.set('X-RateLimit-Limit', String(max));
|
|
51
|
+
res.set('X-RateLimit-Remaining', String(remaining));
|
|
52
|
+
res.set('X-RateLimit-Reset', String(Math.ceil(entry.resetTime / 1000)));
|
|
53
|
+
|
|
54
|
+
if (entry.count > max)
|
|
55
|
+
{
|
|
56
|
+
res.set('Retry-After', String(Math.ceil(windowMs / 1000)));
|
|
57
|
+
return res.status(statusCode).json({ error: message });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
next();
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
module.exports = rateLimit;
|
package/lib/request.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module request
|
|
3
|
+
* @description Lightweight wrapper around Node's `IncomingMessage`.
|
|
4
|
+
* Provides parsed query string, params, body, and convenience helpers.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Wrapped HTTP request.
|
|
9
|
+
*
|
|
10
|
+
* @property {import('http').IncomingMessage} raw - Original Node request.
|
|
11
|
+
* @property {string} method - HTTP method (e.g. 'GET').
|
|
12
|
+
* @property {string} url - Full request URL including query string.
|
|
13
|
+
* @property {object} headers - Lower-cased request headers.
|
|
14
|
+
* @property {object} query - Parsed query-string key/value pairs.
|
|
15
|
+
* @property {object} params - Route parameters populated by the router.
|
|
16
|
+
* @property {*} body - Request body (set by body-parsing middleware).
|
|
17
|
+
* @property {string|null} ip - Remote IP address.
|
|
18
|
+
*/
|
|
19
|
+
class Request
|
|
20
|
+
{
|
|
21
|
+
/**
|
|
22
|
+
* @param {import('http').IncomingMessage} req - Raw Node incoming message.
|
|
23
|
+
*/
|
|
24
|
+
constructor(req)
|
|
25
|
+
{
|
|
26
|
+
this.raw = req;
|
|
27
|
+
this.method = req.method;
|
|
28
|
+
this.url = req.url;
|
|
29
|
+
this.headers = req.headers;
|
|
30
|
+
this.query = this._parseQuery();
|
|
31
|
+
this.params = {};
|
|
32
|
+
this.body = null;
|
|
33
|
+
this.ip = req.socket ? req.socket.remoteAddress : null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Parse the query string from `this.url` into a plain object.
|
|
38
|
+
*
|
|
39
|
+
* @private
|
|
40
|
+
* @returns {Object<string, string>} Parsed key-value pairs.
|
|
41
|
+
*/
|
|
42
|
+
_parseQuery()
|
|
43
|
+
{
|
|
44
|
+
const idx = this.url.indexOf('?');
|
|
45
|
+
if (idx === -1) return {};
|
|
46
|
+
return Object.fromEntries(new URLSearchParams(this.url.slice(idx + 1)));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Get a specific request header (case-insensitive).
|
|
51
|
+
* @param {string} name
|
|
52
|
+
* @returns {string|undefined}
|
|
53
|
+
*/
|
|
54
|
+
get(name)
|
|
55
|
+
{
|
|
56
|
+
return this.headers[name.toLowerCase()];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Check if the request Content-Type matches the given type.
|
|
61
|
+
* @param {string} type - e.g. 'json', 'html', 'application/json'
|
|
62
|
+
* @returns {boolean}
|
|
63
|
+
*/
|
|
64
|
+
is(type)
|
|
65
|
+
{
|
|
66
|
+
const ct = this.headers['content-type'] || '';
|
|
67
|
+
if (type.indexOf('/') === -1)
|
|
68
|
+
{
|
|
69
|
+
// shorthand: 'json' → 'application/json', 'html' → 'text/html'
|
|
70
|
+
return ct.indexOf(type) !== -1;
|
|
71
|
+
}
|
|
72
|
+
return ct.indexOf(type) !== -1;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
module.exports = Request;
|
package/lib/response.js
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module response
|
|
3
|
+
* @description Lightweight wrapper around Node's `ServerResponse`.
|
|
4
|
+
* Provides chainable helpers for status, headers, and body output.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Wrapped HTTP response.
|
|
9
|
+
*
|
|
10
|
+
* @property {import('http').ServerResponse} raw - Original Node response.
|
|
11
|
+
*/
|
|
12
|
+
class Response
|
|
13
|
+
{
|
|
14
|
+
/**
|
|
15
|
+
* @param {import('http').ServerResponse} res - Raw Node server response.
|
|
16
|
+
*/
|
|
17
|
+
constructor(res)
|
|
18
|
+
{
|
|
19
|
+
this.raw = res;
|
|
20
|
+
/** @type {number} */
|
|
21
|
+
this._status = 200;
|
|
22
|
+
/** @type {Object<string, string>} */
|
|
23
|
+
this._headers = {};
|
|
24
|
+
/** @type {boolean} */
|
|
25
|
+
this._sent = false;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Set HTTP status code. Chainable.
|
|
30
|
+
*
|
|
31
|
+
* @param {number} code - HTTP status code (e.g. 200, 404).
|
|
32
|
+
* @returns {Response} `this` for chaining.
|
|
33
|
+
*/
|
|
34
|
+
status(code) { this._status = code; return this; }
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Set a response header. Chainable.
|
|
38
|
+
*
|
|
39
|
+
* @param {string} name - Header name.
|
|
40
|
+
* @param {string} value - Header value.
|
|
41
|
+
* @returns {Response} `this` for chaining.
|
|
42
|
+
*/
|
|
43
|
+
set(name, value) { this._headers[name] = value; return this; }
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Get a previously-set response header (case-insensitive).
|
|
47
|
+
*
|
|
48
|
+
* @param {string} name - Header name.
|
|
49
|
+
* @returns {string|undefined}
|
|
50
|
+
*/
|
|
51
|
+
get(name)
|
|
52
|
+
{
|
|
53
|
+
const key = Object.keys(this._headers).find(k => k.toLowerCase() === name.toLowerCase());
|
|
54
|
+
return key ? this._headers[key] : undefined;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Set the Content-Type header.
|
|
59
|
+
* Accepts a shorthand alias (`'json'`, `'html'`, `'text'`, etc.) or
|
|
60
|
+
* a full MIME string. Chainable.
|
|
61
|
+
*
|
|
62
|
+
* @param {string} ct - MIME type or shorthand alias.
|
|
63
|
+
* @returns {Response} `this` for chaining.
|
|
64
|
+
*/
|
|
65
|
+
type(ct)
|
|
66
|
+
{
|
|
67
|
+
const map = {
|
|
68
|
+
json: 'application/json',
|
|
69
|
+
html: 'text/html',
|
|
70
|
+
text: 'text/plain',
|
|
71
|
+
xml: 'application/xml',
|
|
72
|
+
form: 'application/x-www-form-urlencoded',
|
|
73
|
+
bin: 'application/octet-stream',
|
|
74
|
+
};
|
|
75
|
+
this._headers['Content-Type'] = map[ct] || ct;
|
|
76
|
+
return this;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Send a response body and finalise the response.
|
|
81
|
+
* Auto-detects Content-Type (Buffer → octet-stream, string → text or
|
|
82
|
+
* HTML, object → JSON) when not explicitly set.
|
|
83
|
+
*
|
|
84
|
+
* @param {string|Buffer|object|null} body - Response payload.
|
|
85
|
+
*/
|
|
86
|
+
send(body)
|
|
87
|
+
{
|
|
88
|
+
if (this._sent) return;
|
|
89
|
+
const res = this.raw;
|
|
90
|
+
|
|
91
|
+
Object.entries(this._headers).forEach(([k, v]) => res.setHeader(k, v));
|
|
92
|
+
res.statusCode = this._status;
|
|
93
|
+
|
|
94
|
+
if (body === undefined || body === null)
|
|
95
|
+
{
|
|
96
|
+
res.end();
|
|
97
|
+
this._sent = true;
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Auto-detect Content-Type if not already set
|
|
102
|
+
const hasContentType = Object.keys(this._headers).some(k => k.toLowerCase() === 'content-type');
|
|
103
|
+
|
|
104
|
+
if (Buffer.isBuffer(body))
|
|
105
|
+
{
|
|
106
|
+
if (!hasContentType) res.setHeader('Content-Type', 'application/octet-stream');
|
|
107
|
+
res.end(body);
|
|
108
|
+
}
|
|
109
|
+
else if (typeof body === 'string')
|
|
110
|
+
{
|
|
111
|
+
if (!hasContentType)
|
|
112
|
+
{
|
|
113
|
+
// Heuristic: if it looks like HTML, set text/html
|
|
114
|
+
res.setHeader('Content-Type', body.trimStart().startsWith('<') ? 'text/html' : 'text/plain');
|
|
115
|
+
}
|
|
116
|
+
res.end(body);
|
|
117
|
+
}
|
|
118
|
+
else
|
|
119
|
+
{
|
|
120
|
+
// Object / array → JSON
|
|
121
|
+
if (!hasContentType) res.setHeader('Content-Type', 'application/json');
|
|
122
|
+
res.end(JSON.stringify(body));
|
|
123
|
+
}
|
|
124
|
+
this._sent = true;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Send a JSON response. Sets `Content-Type: application/json`.
|
|
129
|
+
*
|
|
130
|
+
* @param {*} obj - Value to serialise as JSON.
|
|
131
|
+
*/
|
|
132
|
+
json(obj) { this.set('Content-Type', 'application/json'); return this.send(obj); }
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Send a plain-text response. Sets `Content-Type: text/plain`.
|
|
136
|
+
*
|
|
137
|
+
* @param {string} str - Text payload.
|
|
138
|
+
*/
|
|
139
|
+
text(str) { this.set('Content-Type', 'text/plain'); return this.send(String(str)); }
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Send an HTML response. Sets `Content-Type: text/html`.
|
|
143
|
+
*
|
|
144
|
+
* @param {string} str - HTML payload.
|
|
145
|
+
*/
|
|
146
|
+
html(str) { this.set('Content-Type', 'text/html'); return this.send(String(str)); }
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Redirect to the given URL with an optional status code (default 302).
|
|
150
|
+
* @param {number|string} statusOrUrl - Status code or URL.
|
|
151
|
+
* @param {string} [url] - URL if first arg was status code.
|
|
152
|
+
*/
|
|
153
|
+
redirect(statusOrUrl, url)
|
|
154
|
+
{
|
|
155
|
+
if (this._sent) return;
|
|
156
|
+
let code = 302;
|
|
157
|
+
let target = statusOrUrl;
|
|
158
|
+
if (typeof statusOrUrl === 'number') { code = statusOrUrl; target = url; }
|
|
159
|
+
this._status = code;
|
|
160
|
+
this.set('Location', target);
|
|
161
|
+
this.send('');
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
module.exports = Response;
|
package/lib/router.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module router
|
|
3
|
+
* @description Simple pattern-matching router with named parameters,
|
|
4
|
+
* wildcard catch-alls, and sequential handler chains.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Convert a route path pattern into a RegExp and extract named parameter keys.
|
|
9
|
+
* Supports `:param` segments and trailing `*` wildcards.
|
|
10
|
+
*
|
|
11
|
+
* @param {string} path - Route pattern (e.g. '/users/:id', '/api/*').
|
|
12
|
+
* @returns {{ regex: RegExp, keys: string[] }} Compiled regex and ordered parameter names.
|
|
13
|
+
*/
|
|
14
|
+
function pathToRegex(path)
|
|
15
|
+
{
|
|
16
|
+
// Wildcard catch-all: /api/*
|
|
17
|
+
if (path.endsWith('*'))
|
|
18
|
+
{
|
|
19
|
+
const prefix = path.slice(0, -1); // e.g. "/api/"
|
|
20
|
+
const escaped = prefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
21
|
+
return { regex: new RegExp('^' + escaped + '(.*)$'), keys: ['0'] };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const parts = path.split('/').filter(Boolean);
|
|
25
|
+
const keys = [];
|
|
26
|
+
const pattern = parts.map(p =>
|
|
27
|
+
{
|
|
28
|
+
if (p.startsWith(':')) { keys.push(p.slice(1)); return '([^/]+)'; }
|
|
29
|
+
return p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
30
|
+
}).join('/');
|
|
31
|
+
return { regex: new RegExp('^/' + pattern + '/?$'), keys };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
class Router
|
|
35
|
+
{
|
|
36
|
+
/** Create a new Router with an empty route table. */
|
|
37
|
+
constructor() { this.routes = []; }
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Register a route.
|
|
41
|
+
*
|
|
42
|
+
* @param {string} method - HTTP method (e.g. 'GET') or 'ALL' to match any.
|
|
43
|
+
* @param {string} path - Route pattern.
|
|
44
|
+
* @param {Function[]} handlers - One or more handler functions `(req, res, next) => void`.
|
|
45
|
+
*/
|
|
46
|
+
add(method, path, handlers)
|
|
47
|
+
{
|
|
48
|
+
const { regex, keys } = pathToRegex(path);
|
|
49
|
+
this.routes.push({ method: method.toUpperCase(), path, regex, keys, handlers });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Match an incoming request against the route table and execute the first
|
|
54
|
+
* matching handler chain. Sends a 404 JSON response when no route matches.
|
|
55
|
+
*
|
|
56
|
+
* @param {import('./request')} req - Wrapped request.
|
|
57
|
+
* @param {import('./response')} res - Wrapped response.
|
|
58
|
+
*/
|
|
59
|
+
handle(req, res)
|
|
60
|
+
{
|
|
61
|
+
const method = req.method.toUpperCase();
|
|
62
|
+
const url = req.url.split('?')[0];
|
|
63
|
+
for (const r of this.routes)
|
|
64
|
+
{
|
|
65
|
+
// ALL matches any method
|
|
66
|
+
if (r.method !== 'ALL' && r.method !== method) continue;
|
|
67
|
+
const m = url.match(r.regex);
|
|
68
|
+
if (!m) continue;
|
|
69
|
+
req.params = {};
|
|
70
|
+
r.keys.forEach((k, i) => req.params[k] = decodeURIComponent(m[i + 1] || ''));
|
|
71
|
+
// run handlers sequentially
|
|
72
|
+
let idx = 0;
|
|
73
|
+
const next = () =>
|
|
74
|
+
{
|
|
75
|
+
if (idx < r.handlers.length)
|
|
76
|
+
{
|
|
77
|
+
const h = r.handlers[idx++];
|
|
78
|
+
return h(req, res, next);
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
return next();
|
|
82
|
+
}
|
|
83
|
+
res.status(404).json({ error: 'Not Found' });
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
module.exports = Router;
|
package/lib/static.js
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module static
|
|
3
|
+
* @description Static file-serving middleware with MIME detection, directory
|
|
4
|
+
* index files, extension fallbacks, dotfile policies, caching,
|
|
5
|
+
* and custom header hooks.
|
|
6
|
+
*/
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
const path = require('path');
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Extension → MIME-type lookup table.
|
|
12
|
+
* @type {Object<string, string>}
|
|
13
|
+
*/
|
|
14
|
+
const MIME = {
|
|
15
|
+
// Text
|
|
16
|
+
'.html': 'text/html',
|
|
17
|
+
'.htm': 'text/html',
|
|
18
|
+
'.css': 'text/css',
|
|
19
|
+
'.txt': 'text/plain',
|
|
20
|
+
'.csv': 'text/csv',
|
|
21
|
+
'.xml': 'application/xml',
|
|
22
|
+
'.json': 'application/json',
|
|
23
|
+
'.jsonld': 'application/ld+json',
|
|
24
|
+
|
|
25
|
+
// JavaScript / WASM
|
|
26
|
+
'.js': 'application/javascript',
|
|
27
|
+
'.mjs': 'application/javascript',
|
|
28
|
+
'.wasm': 'application/wasm',
|
|
29
|
+
|
|
30
|
+
// Images
|
|
31
|
+
'.png': 'image/png',
|
|
32
|
+
'.jpg': 'image/jpeg',
|
|
33
|
+
'.jpeg': 'image/jpeg',
|
|
34
|
+
'.gif': 'image/gif',
|
|
35
|
+
'.webp': 'image/webp',
|
|
36
|
+
'.avif': 'image/avif',
|
|
37
|
+
'.svg': 'image/svg+xml',
|
|
38
|
+
'.ico': 'image/x-icon',
|
|
39
|
+
'.bmp': 'image/bmp',
|
|
40
|
+
'.tiff': 'image/tiff',
|
|
41
|
+
'.tif': 'image/tiff',
|
|
42
|
+
|
|
43
|
+
// Fonts
|
|
44
|
+
'.woff': 'font/woff',
|
|
45
|
+
'.woff2': 'font/woff2',
|
|
46
|
+
'.ttf': 'font/ttf',
|
|
47
|
+
'.otf': 'font/otf',
|
|
48
|
+
'.eot': 'application/vnd.ms-fontobject',
|
|
49
|
+
|
|
50
|
+
// Audio
|
|
51
|
+
'.mp3': 'audio/mpeg',
|
|
52
|
+
'.ogg': 'audio/ogg',
|
|
53
|
+
'.wav': 'audio/wav',
|
|
54
|
+
'.flac': 'audio/flac',
|
|
55
|
+
'.aac': 'audio/aac',
|
|
56
|
+
'.m4a': 'audio/mp4',
|
|
57
|
+
|
|
58
|
+
// Video
|
|
59
|
+
'.mp4': 'video/mp4',
|
|
60
|
+
'.webm': 'video/webm',
|
|
61
|
+
'.ogv': 'video/ogg',
|
|
62
|
+
'.avi': 'video/x-msvideo',
|
|
63
|
+
'.mov': 'video/quicktime',
|
|
64
|
+
|
|
65
|
+
// Documents / Archives
|
|
66
|
+
'.pdf': 'application/pdf',
|
|
67
|
+
'.zip': 'application/zip',
|
|
68
|
+
'.gz': 'application/gzip',
|
|
69
|
+
'.tar': 'application/x-tar',
|
|
70
|
+
'.7z': 'application/x-7z-compressed',
|
|
71
|
+
|
|
72
|
+
// Other
|
|
73
|
+
'.map': 'application/json',
|
|
74
|
+
'.yaml': 'text/yaml',
|
|
75
|
+
'.yml': 'text/yaml',
|
|
76
|
+
'.md': 'text/markdown',
|
|
77
|
+
'.sh': 'application/x-sh',
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Stream a file to the raw Node response, setting Content-Type and
|
|
82
|
+
* Content-Length headers from the extension and optional `stat` result.
|
|
83
|
+
*
|
|
84
|
+
* @param {import('./response')} res - Wrapped response object.
|
|
85
|
+
* @param {string} filePath - Absolute path to the file.
|
|
86
|
+
* @param {import('fs').Stats} [stat] - Pre-fetched `fs.Stats` (for Content-Length).
|
|
87
|
+
*/
|
|
88
|
+
function sendFile(res, filePath, stat)
|
|
89
|
+
{
|
|
90
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
91
|
+
const ct = MIME[ext] || 'application/octet-stream';
|
|
92
|
+
const raw = res.raw;
|
|
93
|
+
try
|
|
94
|
+
{
|
|
95
|
+
raw.setHeader('Content-Type', ct);
|
|
96
|
+
if (stat && stat.size) raw.setHeader('Content-Length', stat.size);
|
|
97
|
+
}
|
|
98
|
+
catch (e) { /* best-effort */ }
|
|
99
|
+
const stream = fs.createReadStream(filePath);
|
|
100
|
+
stream.on('error', () => { try { raw.statusCode = 404; raw.end(); } catch (e) { } });
|
|
101
|
+
stream.pipe(raw);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Create a static-file-serving middleware.
|
|
106
|
+
*
|
|
107
|
+
* @param {string} root - Root directory to serve files from.
|
|
108
|
+
* @param {object} [options]
|
|
109
|
+
* @param {string|false} [options.index='index.html'] - Default file for directory requests, or `false` to disable.
|
|
110
|
+
* @param {number} [options.maxAge=0] - `Cache-Control` max-age in **milliseconds**.
|
|
111
|
+
* @param {string} [options.dotfiles='ignore'] - Dotfile policy: `'allow'` | `'deny'` | `'ignore'`.
|
|
112
|
+
* @param {string[]} [options.extensions] - Array of fallback extensions (e.g. `['html', 'htm']`).
|
|
113
|
+
* @param {Function} [options.setHeaders] - `(res, filePath) => void` hook to set custom headers.
|
|
114
|
+
* @returns {Function} Middleware `(req, res, next) => void`.
|
|
115
|
+
*/
|
|
116
|
+
function serveStatic(root, options = {})
|
|
117
|
+
{
|
|
118
|
+
root = path.resolve(root);
|
|
119
|
+
const index = options.hasOwnProperty('index') ? options.index : 'index.html';
|
|
120
|
+
const maxAge = options.hasOwnProperty('maxAge') ? options.maxAge : 0;
|
|
121
|
+
const dotfiles = options.hasOwnProperty('dotfiles') ? options.dotfiles : 'ignore'; // allow|deny|ignore
|
|
122
|
+
const extensions = Array.isArray(options.extensions) ? options.extensions : null;
|
|
123
|
+
const setHeaders = typeof options.setHeaders === 'function' ? options.setHeaders : null;
|
|
124
|
+
|
|
125
|
+
function isDotfile(p)
|
|
126
|
+
{
|
|
127
|
+
return path.basename(p).startsWith('.');
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function applyHeaders(res, filePath)
|
|
131
|
+
{
|
|
132
|
+
if (maxAge) try { res.raw.setHeader('Cache-Control', 'max-age=' + Math.floor(Number(maxAge) / 1000)); } catch (e) { }
|
|
133
|
+
if (setHeaders) try { setHeaders(res, filePath); } catch (e) { }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return (req, res, next) =>
|
|
137
|
+
{
|
|
138
|
+
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' });
|
|
142
|
+
|
|
143
|
+
if (isDotfile(file) && dotfiles === 'deny') return res.status(403).json({ error: 'Forbidden' });
|
|
144
|
+
|
|
145
|
+
fs.stat(file, (err, st) =>
|
|
146
|
+
{
|
|
147
|
+
if (err)
|
|
148
|
+
{
|
|
149
|
+
// try extensions fallback
|
|
150
|
+
if (extensions && !urlPath.endsWith('/'))
|
|
151
|
+
{
|
|
152
|
+
(function tryExt(i)
|
|
153
|
+
{
|
|
154
|
+
if (i >= extensions.length) return next();
|
|
155
|
+
const ext = extensions[i].startsWith('.') ? extensions[i] : '.' + extensions[i];
|
|
156
|
+
const f = file + ext;
|
|
157
|
+
fs.stat(f, (e2, st2) =>
|
|
158
|
+
{
|
|
159
|
+
if (!e2 && st2 && st2.isFile())
|
|
160
|
+
{
|
|
161
|
+
if (isDotfile(f) && dotfiles === 'deny') return res.status(403).json({ error: 'Forbidden' });
|
|
162
|
+
applyHeaders(res, f);
|
|
163
|
+
return sendFile(res, f, st2);
|
|
164
|
+
}
|
|
165
|
+
tryExt(i + 1);
|
|
166
|
+
});
|
|
167
|
+
})(0);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
return next();
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (st.isDirectory())
|
|
174
|
+
{
|
|
175
|
+
if (!index) return next();
|
|
176
|
+
const idxFile = path.join(file, index);
|
|
177
|
+
fs.stat(idxFile, (err2, st2) =>
|
|
178
|
+
{
|
|
179
|
+
if (err2) return next();
|
|
180
|
+
if (isDotfile(idxFile) && dotfiles === 'deny') return res.status(403).json({ error: 'Forbidden' });
|
|
181
|
+
applyHeaders(res, idxFile);
|
|
182
|
+
sendFile(res, idxFile, st2);
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
else
|
|
186
|
+
{
|
|
187
|
+
if (isDotfile(file) && dotfiles === 'ignore') return next();
|
|
188
|
+
if (isDotfile(file) && dotfiles === 'deny') return res.status(403).json({ error: 'Forbidden' });
|
|
189
|
+
applyHeaders(res, file);
|
|
190
|
+
sendFile(res, file, st);
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
module.exports = serveStatic;
|
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "zero-http",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Zero-dependency, minimal Express-like HTTP server and tiny fetch replacement",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"files": [
|
|
7
|
+
"lib",
|
|
8
|
+
"documentation",
|
|
9
|
+
"index.js",
|
|
10
|
+
"README.md",
|
|
11
|
+
"LICENSE"
|
|
12
|
+
],
|
|
13
|
+
"scripts": {
|
|
14
|
+
"start": "node documentation/full-server.js",
|
|
15
|
+
"docs": "node documentation/full-server.js",
|
|
16
|
+
"test": "node --test"
|
|
17
|
+
},
|
|
18
|
+
"keywords": [
|
|
19
|
+
"http",
|
|
20
|
+
"server",
|
|
21
|
+
"router",
|
|
22
|
+
"middleware",
|
|
23
|
+
"fetch",
|
|
24
|
+
"multipart",
|
|
25
|
+
"static",
|
|
26
|
+
"zero-http"
|
|
27
|
+
],
|
|
28
|
+
"author": "Anthony Wiedman",
|
|
29
|
+
"license": "MIT",
|
|
30
|
+
"repository": {
|
|
31
|
+
"type": "git",
|
|
32
|
+
"url": "git+https://github.com/tonywied17/zero-http-npm.git"
|
|
33
|
+
},
|
|
34
|
+
"bugs": {
|
|
35
|
+
"url": "https://github.com/tonywied17/zero-http-npm/issues"
|
|
36
|
+
},
|
|
37
|
+
"homepage": "https://zero-http.molex.cloud",
|
|
38
|
+
"engines": {
|
|
39
|
+
"node": ">=14.0.0"
|
|
40
|
+
},
|
|
41
|
+
"publishConfig": {
|
|
42
|
+
"access": "public"
|
|
43
|
+
}
|
|
44
|
+
}
|