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
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module body/rawBuffer
|
|
3
|
+
* @description Low-level helper that collects the raw request body into a
|
|
4
|
+
* single Buffer, enforcing an optional byte-size limit.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Parse a human-readable size string (e.g. `'10kb'`, `'2mb'`) into bytes.
|
|
9
|
+
*
|
|
10
|
+
* @param {string|number|null} limit - Size limit value.
|
|
11
|
+
* @returns {number|null} Byte limit, or `null` for unlimited.
|
|
12
|
+
*/
|
|
13
|
+
function parseLimit(limit)
|
|
14
|
+
{
|
|
15
|
+
if (!limit && limit !== 0) return null;
|
|
16
|
+
if (typeof limit === 'number') return limit;
|
|
17
|
+
if (typeof limit === 'string')
|
|
18
|
+
{
|
|
19
|
+
const v = limit.trim().toLowerCase();
|
|
20
|
+
const num = Number(v.replace(/[^0-9.]/g, ''));
|
|
21
|
+
if (v.endsWith('kb')) return Math.floor(num * 1024);
|
|
22
|
+
if (v.endsWith('mb')) return Math.floor(num * 1024 * 1024);
|
|
23
|
+
if (v.endsWith('gb')) return Math.floor(num * 1024 * 1024 * 1024);
|
|
24
|
+
return Math.floor(num);
|
|
25
|
+
}
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Collect the raw request body into a Buffer.
|
|
31
|
+
* Rejects with a `{ status: 413 }` error when `opts.limit` is exceeded.
|
|
32
|
+
*
|
|
33
|
+
* @param {import('../request')} req - Wrapped request (must have `.raw` stream).
|
|
34
|
+
* @param {object} [opts]
|
|
35
|
+
* @param {string|number|null} [opts.limit] - Maximum body size.
|
|
36
|
+
* @returns {Promise<Buffer>} Resolved with the full body buffer.
|
|
37
|
+
*/
|
|
38
|
+
function rawBuffer(req, opts = {})
|
|
39
|
+
{
|
|
40
|
+
const limit = parseLimit(opts.limit);
|
|
41
|
+
return new Promise((resolve, reject) =>
|
|
42
|
+
{
|
|
43
|
+
const chunks = [];
|
|
44
|
+
let total = 0;
|
|
45
|
+
function onData(c)
|
|
46
|
+
{
|
|
47
|
+
total += c.length;
|
|
48
|
+
if (limit && total > limit)
|
|
49
|
+
{
|
|
50
|
+
// stop reading and reject with a status property
|
|
51
|
+
req.raw.removeListener('data', onData);
|
|
52
|
+
req.raw.removeListener('end', onEnd);
|
|
53
|
+
req.raw.removeListener('error', onError);
|
|
54
|
+
const err = new Error('payload too large');
|
|
55
|
+
err.status = 413;
|
|
56
|
+
return reject(err);
|
|
57
|
+
}
|
|
58
|
+
chunks.push(c);
|
|
59
|
+
}
|
|
60
|
+
function onEnd()
|
|
61
|
+
{
|
|
62
|
+
resolve(Buffer.concat(chunks));
|
|
63
|
+
}
|
|
64
|
+
function onError(e)
|
|
65
|
+
{
|
|
66
|
+
reject(e);
|
|
67
|
+
}
|
|
68
|
+
req.raw.on('data', onData);
|
|
69
|
+
req.raw.on('end', onEnd);
|
|
70
|
+
req.raw.on('error', onError);
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
module.exports = rawBuffer;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared helper for sending HTTP error responses from body parsers.
|
|
3
|
+
* Centralizes the pattern used across all parsers so changes only happen in one place.
|
|
4
|
+
*
|
|
5
|
+
* @param {object} res - The response wrapper (or raw response).
|
|
6
|
+
* @param {number} status - HTTP status code.
|
|
7
|
+
* @param {string} message - Error message string for the JSON body.
|
|
8
|
+
*/
|
|
9
|
+
function sendError(res, status, message)
|
|
10
|
+
{
|
|
11
|
+
const raw = res.raw || res;
|
|
12
|
+
raw.statusCode = status;
|
|
13
|
+
raw.setHeader('Content-Type', 'application/json');
|
|
14
|
+
raw.end(JSON.stringify({ error: message }));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
module.exports = sendError;
|
package/lib/body/text.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module body/text
|
|
3
|
+
* @description Plain-text body-parsing middleware.
|
|
4
|
+
* Reads the request body as a string and sets `req.body`.
|
|
5
|
+
*/
|
|
6
|
+
const rawBuffer = require('./rawBuffer');
|
|
7
|
+
const isTypeMatch = require('./typeMatch');
|
|
8
|
+
const sendError = require('./sendError');
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Create a plain-text body-parsing middleware.
|
|
12
|
+
*
|
|
13
|
+
* @param {object} [options]
|
|
14
|
+
* @param {string|number} [options.limit] - Max body size.
|
|
15
|
+
* @param {string} [options.encoding='utf8'] - Character encoding.
|
|
16
|
+
* @param {string|Function} [options.type='text/*'] - Content-Type to match.
|
|
17
|
+
* @returns {Function} Async middleware `(req, res, next) => void`.
|
|
18
|
+
*/
|
|
19
|
+
function text(options = {})
|
|
20
|
+
{
|
|
21
|
+
const opts = options || {};
|
|
22
|
+
const limit = opts.limit || null;
|
|
23
|
+
const encoding = opts.encoding || 'utf8';
|
|
24
|
+
const typeOpt = opts.type || 'text/*';
|
|
25
|
+
|
|
26
|
+
return async (req, res, next) =>
|
|
27
|
+
{
|
|
28
|
+
const ct = (req.headers['content-type'] || '');
|
|
29
|
+
if (!isTypeMatch(ct, typeOpt)) return next();
|
|
30
|
+
try
|
|
31
|
+
{
|
|
32
|
+
const buf = await rawBuffer(req, { limit });
|
|
33
|
+
req.body = buf.toString(encoding);
|
|
34
|
+
} catch (err)
|
|
35
|
+
{
|
|
36
|
+
if (err && err.status === 413) return sendError(res, 413, 'payload too large');
|
|
37
|
+
req.body = '';
|
|
38
|
+
}
|
|
39
|
+
next();
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
module.exports = text;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared Content-Type matching utility for body parsers.
|
|
3
|
+
*
|
|
4
|
+
* @param {string} contentType - The request Content-Type header value.
|
|
5
|
+
* @param {string|function} typeOpt - MIME pattern to match against (e.g. 'application/json', 'text/*', '*/*')
|
|
6
|
+
* or a custom predicate `(ct) => boolean`.
|
|
7
|
+
* @returns {boolean}
|
|
8
|
+
*/
|
|
9
|
+
function isTypeMatch(contentType, typeOpt)
|
|
10
|
+
{
|
|
11
|
+
if (!typeOpt) return true;
|
|
12
|
+
if (typeof typeOpt === 'function') return !!typeOpt(contentType);
|
|
13
|
+
if (!contentType) return false;
|
|
14
|
+
if (typeOpt === '*/*') return true;
|
|
15
|
+
if (typeOpt.endsWith('/*'))
|
|
16
|
+
{
|
|
17
|
+
return contentType.startsWith(typeOpt.slice(0, -1));
|
|
18
|
+
}
|
|
19
|
+
return contentType.indexOf(typeOpt) !== -1;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
module.exports = isTypeMatch;
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module body/urlencoded
|
|
3
|
+
* @description URL-encoded body-parsing middleware.
|
|
4
|
+
* Supports both flat (`URLSearchParams`) and extended
|
|
5
|
+
* (nested bracket syntax) parsing modes.
|
|
6
|
+
*/
|
|
7
|
+
const rawBuffer = require('./rawBuffer');
|
|
8
|
+
const isTypeMatch = require('./typeMatch');
|
|
9
|
+
const sendError = require('./sendError');
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Append a value to an existing key, converting to an array when needed.
|
|
13
|
+
*
|
|
14
|
+
* @param {*} prev - Previous value for the key (or `undefined`).
|
|
15
|
+
* @param {string} val - New value to append.
|
|
16
|
+
* @returns {string|string[]} Merged value.
|
|
17
|
+
*/
|
|
18
|
+
function appendValue(prev, val)
|
|
19
|
+
{
|
|
20
|
+
if (prev === undefined) return val;
|
|
21
|
+
if (Array.isArray(prev)) { prev.push(val); return prev; }
|
|
22
|
+
// convert existing scalar or object into array to hold multiple values
|
|
23
|
+
return [prev, val];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Create a URL-encoded body-parsing middleware.
|
|
28
|
+
*
|
|
29
|
+
* @param {object} [options]
|
|
30
|
+
* @param {string|number} [options.limit] - Max body size (e.g. `'10kb'`).
|
|
31
|
+
* @param {string|Function} [options.type='application/x-www-form-urlencoded'] - Content-Type to match.
|
|
32
|
+
* @param {boolean} [options.extended=false] - Use nested bracket parsing (e.g. `a[b][c]=1`).
|
|
33
|
+
* @returns {Function} Async middleware `(req, res, next) => void`.
|
|
34
|
+
*/
|
|
35
|
+
function urlencoded(options = {})
|
|
36
|
+
{
|
|
37
|
+
const opts = options || {};
|
|
38
|
+
const limit = opts.limit || null;
|
|
39
|
+
const typeOpt = opts.type || 'application/x-www-form-urlencoded';
|
|
40
|
+
const extended = !!opts.extended;
|
|
41
|
+
|
|
42
|
+
return async (req, res, next) =>
|
|
43
|
+
{
|
|
44
|
+
const ct = (req.headers['content-type'] || '');
|
|
45
|
+
if (!isTypeMatch(ct, typeOpt)) return next();
|
|
46
|
+
try
|
|
47
|
+
{
|
|
48
|
+
const buf = await rawBuffer(req, { limit });
|
|
49
|
+
const txt = buf.toString('utf8');
|
|
50
|
+
if (!extended)
|
|
51
|
+
{
|
|
52
|
+
req.body = Object.fromEntries(new URLSearchParams(txt));
|
|
53
|
+
}
|
|
54
|
+
else
|
|
55
|
+
{
|
|
56
|
+
// extended parsing: support nested bracket syntax like a[b][c]=1 and arrays a[]=1
|
|
57
|
+
const out = {};
|
|
58
|
+
if (txt.trim() === '') { req.body = out; return next(); }
|
|
59
|
+
const pairs = txt.split('&');
|
|
60
|
+
for (const p of pairs)
|
|
61
|
+
{
|
|
62
|
+
if (!p) continue;
|
|
63
|
+
const eq = p.indexOf('=');
|
|
64
|
+
let k, v;
|
|
65
|
+
if (eq === -1) { k = decodeURIComponent(p.replace(/\+/g, ' ')); v = ''; }
|
|
66
|
+
else { k = decodeURIComponent(p.slice(0, eq).replace(/\+/g, ' ')); v = decodeURIComponent(p.slice(eq + 1).replace(/\+/g, ' ')); }
|
|
67
|
+
// parse key into parts
|
|
68
|
+
const parts = [];
|
|
69
|
+
const re = /([^\[\]]+)|\[(.*?)\]/g;
|
|
70
|
+
let m;
|
|
71
|
+
while ((m = re.exec(k)) !== null)
|
|
72
|
+
{
|
|
73
|
+
parts.push(m[1] || m[2]);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// set value into out following parts
|
|
77
|
+
let cur = out;
|
|
78
|
+
for (let i = 0; i < parts.length; i++)
|
|
79
|
+
{
|
|
80
|
+
const part = parts[i];
|
|
81
|
+
const isLast = (i === parts.length - 1);
|
|
82
|
+
|
|
83
|
+
if (part === '')
|
|
84
|
+
{
|
|
85
|
+
// array push
|
|
86
|
+
if (isLast)
|
|
87
|
+
{
|
|
88
|
+
if (!Array.isArray(cur))
|
|
89
|
+
{
|
|
90
|
+
// convert existing value to array if needed
|
|
91
|
+
if (Object.prototype.hasOwnProperty.call(cur, '0'))
|
|
92
|
+
{
|
|
93
|
+
// unlikely
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
cur.push(v);
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
99
|
+
// ensure cur is array and proceed to next index object
|
|
100
|
+
if (!Array.isArray(cur))
|
|
101
|
+
{
|
|
102
|
+
// convert existing object to array if empty
|
|
103
|
+
const keys = Object.keys(cur);
|
|
104
|
+
if (keys.length === 0) cur = [];
|
|
105
|
+
else { /* leave as object */ }
|
|
106
|
+
}
|
|
107
|
+
// push an object if next part is non-empty
|
|
108
|
+
if (cur.length === 0 || typeof cur[cur.length - 1] !== 'object') cur.push({});
|
|
109
|
+
cur = cur[cur.length - 1];
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// normal key
|
|
114
|
+
if (isLast)
|
|
115
|
+
{
|
|
116
|
+
if (Array.isArray(cur))
|
|
117
|
+
{
|
|
118
|
+
// numeric key may indicate index
|
|
119
|
+
const idx = Number(part);
|
|
120
|
+
if (!Number.isNaN(idx)) cur[idx] = appendValue(cur[idx], v);
|
|
121
|
+
else cur[part] = appendValue(cur[part], v);
|
|
122
|
+
}
|
|
123
|
+
else
|
|
124
|
+
{
|
|
125
|
+
cur[part] = appendValue(cur[part], v);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
else
|
|
129
|
+
{
|
|
130
|
+
if (Array.isArray(cur))
|
|
131
|
+
{
|
|
132
|
+
const idx = Number(part);
|
|
133
|
+
if (!Number.isNaN(idx))
|
|
134
|
+
{
|
|
135
|
+
if (!cur[idx]) cur[idx] = {};
|
|
136
|
+
cur = cur[idx];
|
|
137
|
+
} else
|
|
138
|
+
{
|
|
139
|
+
// treat non-numeric into last pushed object
|
|
140
|
+
if (cur.length === 0) cur.push({});
|
|
141
|
+
if (typeof cur[cur.length - 1] !== 'object') cur.push({});
|
|
142
|
+
cur = cur[cur.length - 1];
|
|
143
|
+
if (!cur[part]) cur[part] = {};
|
|
144
|
+
cur = cur[part];
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
else
|
|
148
|
+
{
|
|
149
|
+
if (!cur[part]) cur[part] = {};
|
|
150
|
+
cur = cur[part];
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
req.body = out;
|
|
156
|
+
}
|
|
157
|
+
} catch (err)
|
|
158
|
+
{
|
|
159
|
+
if (err && err.status === 413) return sendError(res, 413, 'payload too large');
|
|
160
|
+
req.body = {};
|
|
161
|
+
}
|
|
162
|
+
next();
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
module.exports = urlencoded;
|
package/lib/cors.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module cors
|
|
3
|
+
* @description CORS middleware. Supports exact origins, wildcard `'*'`,
|
|
4
|
+
* arrays of allowed origins, and suffix matching with a leading dot
|
|
5
|
+
* (e.g. `'.example.com'` matches `sub.example.com`).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Create a CORS middleware.
|
|
10
|
+
*
|
|
11
|
+
* @param {object} [options]
|
|
12
|
+
* @param {string|string[]} [options.origin='*'] - Allowed origin(s). Use `'*'` for any,
|
|
13
|
+
* an array for a whitelist, or a string
|
|
14
|
+
* starting with `'.'` for suffix matching.
|
|
15
|
+
* @param {string} [options.methods='GET,POST,PUT,DELETE,OPTIONS'] - Allowed HTTP methods.
|
|
16
|
+
* @param {string} [options.allowedHeaders='Content-Type,Authorization'] - Allowed request headers.
|
|
17
|
+
* @param {boolean} [options.credentials=false] - Whether to set `Access-Control-Allow-Credentials`.
|
|
18
|
+
* @returns {Function} Middleware `(req, res, next) => void`.
|
|
19
|
+
*/
|
|
20
|
+
function cors(options = {})
|
|
21
|
+
{
|
|
22
|
+
const allowOrigin = (options.hasOwnProperty('origin')) ? options.origin : '*';
|
|
23
|
+
const allowMethods = (options.methods || 'GET,POST,PUT,DELETE,OPTIONS');
|
|
24
|
+
const allowHeaders = (options.allowedHeaders || 'Content-Type,Authorization');
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Resolve the Origin header value to echo back based on the configured
|
|
28
|
+
* allow-list. Returns `null` when the origin should not be allowed.
|
|
29
|
+
*
|
|
30
|
+
* @param {string|undefined} reqOrigin - The request's `Origin` header.
|
|
31
|
+
* @returns {string|null} Origin value to set, or `null`.
|
|
32
|
+
*/
|
|
33
|
+
function matchOrigin(reqOrigin)
|
|
34
|
+
{
|
|
35
|
+
if (!allowOrigin) return null; // origin explicitly disabled
|
|
36
|
+
if (typeof allowOrigin === 'string') return allowOrigin === '*' ? '*' : allowOrigin;
|
|
37
|
+
if (Array.isArray(allowOrigin))
|
|
38
|
+
{
|
|
39
|
+
if (!reqOrigin) return null;
|
|
40
|
+
for (const o of allowOrigin)
|
|
41
|
+
{
|
|
42
|
+
if (!o) continue;
|
|
43
|
+
if (o === reqOrigin) return reqOrigin;
|
|
44
|
+
// allow suffix match with leading dot (e.g. .example.com)
|
|
45
|
+
if (o[0] === '.' && reqOrigin.endsWith(o)) return reqOrigin;
|
|
46
|
+
}
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return (req, res, next) =>
|
|
53
|
+
{
|
|
54
|
+
const reqOrigin = req.headers && (req.headers.origin || req.headers.Origin);
|
|
55
|
+
const originValue = matchOrigin(reqOrigin);
|
|
56
|
+
|
|
57
|
+
if (originValue)
|
|
58
|
+
{
|
|
59
|
+
res.set('Access-Control-Allow-Origin', originValue);
|
|
60
|
+
// allow credentials when matching specific origin
|
|
61
|
+
if (options.credentials) res.set('Access-Control-Allow-Credentials', 'true');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (allowMethods) res.set('Access-Control-Allow-Methods', allowMethods);
|
|
65
|
+
if (allowHeaders) res.set('Access-Control-Allow-Headers', allowHeaders);
|
|
66
|
+
|
|
67
|
+
if (req.method === 'OPTIONS') return res.status(204).send();
|
|
68
|
+
next();
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
module.exports = cors;
|
package/lib/fetch.js
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module fetch
|
|
3
|
+
* @description Minimal, zero-dependency server-side `fetch()` replacement.
|
|
4
|
+
* Supports HTTP/HTTPS, JSON/URLSearchParams/Buffer/stream bodies,
|
|
5
|
+
* download & upload progress callbacks, timeouts, and AbortSignal.
|
|
6
|
+
*/
|
|
7
|
+
const http = require('http');
|
|
8
|
+
const https = require('https');
|
|
9
|
+
const { URL } = require('url');
|
|
10
|
+
|
|
11
|
+
const STATUS_CODES = http.STATUS_CODES;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Perform an HTTP(S) request.
|
|
15
|
+
*
|
|
16
|
+
* @param {string} url - Absolute URL to fetch.
|
|
17
|
+
* @param {object} [opts]
|
|
18
|
+
* @param {string} [opts.method='GET'] - HTTP method.
|
|
19
|
+
* @param {object} [opts.headers] - Request headers.
|
|
20
|
+
* @param {string|Buffer|object|ReadableStream} [opts.body] - Request body.
|
|
21
|
+
* @param {number} [opts.timeout] - Request timeout in ms.
|
|
22
|
+
* @param {AbortSignal} [opts.signal] - Abort signal for cancellation.
|
|
23
|
+
* @param {import('http').Agent} [opts.agent] - Custom HTTP agent.
|
|
24
|
+
* @param {Function} [opts.onDownloadProgress] - `({ loaded, total }) => void` callback.
|
|
25
|
+
* @param {Function} [opts.onUploadProgress] - `({ loaded, total }) => void` callback.
|
|
26
|
+
* @returns {Promise<{ status: number, statusText: string, ok: boolean, headers: object, arrayBuffer: Function, text: Function, json: Function }>}
|
|
27
|
+
*/
|
|
28
|
+
function miniFetch(url, opts = {})
|
|
29
|
+
{
|
|
30
|
+
return new Promise((resolve, reject) =>
|
|
31
|
+
{
|
|
32
|
+
try
|
|
33
|
+
{
|
|
34
|
+
const u = new URL(url);
|
|
35
|
+
const lib = u.protocol === 'https:' ? https : http;
|
|
36
|
+
const method = (opts.method || 'GET').toUpperCase();
|
|
37
|
+
const headers = Object.assign({}, opts.headers || {});
|
|
38
|
+
|
|
39
|
+
// Normalize body
|
|
40
|
+
let body = opts.body;
|
|
41
|
+
if (body && typeof body === 'object' && typeof body.toString === 'function' && body.constructor && body.constructor.name === 'URLSearchParams')
|
|
42
|
+
{
|
|
43
|
+
if (!headers['Content-Type'] && !headers['content-type']) headers['Content-Type'] = 'application/x-www-form-urlencoded';
|
|
44
|
+
body = body.toString();
|
|
45
|
+
}
|
|
46
|
+
else if (body && typeof body === 'object' && !Buffer.isBuffer(body) && !(body instanceof ArrayBuffer) && !(body instanceof Uint8Array) && !(body && typeof body.pipe === 'function'))
|
|
47
|
+
{
|
|
48
|
+
if (!headers['Content-Type'] && !headers['content-type']) headers['Content-Type'] = 'application/json';
|
|
49
|
+
body = Buffer.from(JSON.stringify(body), 'utf8');
|
|
50
|
+
}
|
|
51
|
+
else if (body instanceof ArrayBuffer)
|
|
52
|
+
{
|
|
53
|
+
body = Buffer.from(body);
|
|
54
|
+
}
|
|
55
|
+
else if (body instanceof Uint8Array && !Buffer.isBuffer(body))
|
|
56
|
+
{
|
|
57
|
+
body = Buffer.from(body);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Set Content-Length for known-size bodies
|
|
61
|
+
if ((Buffer.isBuffer(body) || typeof body === 'string') && !headers['Content-Length'] && !headers['content-length'])
|
|
62
|
+
{
|
|
63
|
+
headers['Content-Length'] = String(Buffer.isBuffer(body) ? body.length : Buffer.byteLength(body));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const options = { method, headers };
|
|
67
|
+
if (opts.agent) options.agent = opts.agent;
|
|
68
|
+
|
|
69
|
+
const req = lib.request(u, options, (res) =>
|
|
70
|
+
{
|
|
71
|
+
const chunks = [];
|
|
72
|
+
let downloaded = 0;
|
|
73
|
+
const total = parseInt(res.headers['content-length'] || '0', 10) || null;
|
|
74
|
+
|
|
75
|
+
res.on('data', (c) =>
|
|
76
|
+
{
|
|
77
|
+
chunks.push(c);
|
|
78
|
+
downloaded += c.length;
|
|
79
|
+
if (typeof opts.onDownloadProgress === 'function')
|
|
80
|
+
{
|
|
81
|
+
try { opts.onDownloadProgress({ loaded: downloaded, total }); } catch (e) { }
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
res.on('end', () =>
|
|
86
|
+
{
|
|
87
|
+
const buf = Buffer.concat(chunks);
|
|
88
|
+
const status = res.statusCode;
|
|
89
|
+
const rawHeaders = res.headers || {};
|
|
90
|
+
const responseHeaders = {
|
|
91
|
+
get(name)
|
|
92
|
+
{
|
|
93
|
+
if (!name) return undefined;
|
|
94
|
+
const v = rawHeaders[name.toLowerCase()];
|
|
95
|
+
return Array.isArray(v) ? v.join(', ') : v;
|
|
96
|
+
},
|
|
97
|
+
raw: rawHeaders,
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
resolve({
|
|
101
|
+
status,
|
|
102
|
+
statusText: STATUS_CODES[status] || '',
|
|
103
|
+
ok: status >= 200 && status < 300,
|
|
104
|
+
headers: responseHeaders,
|
|
105
|
+
arrayBuffer: () => Promise.resolve(buf),
|
|
106
|
+
text: () => Promise.resolve(buf.toString('utf8')),
|
|
107
|
+
json: () =>
|
|
108
|
+
{
|
|
109
|
+
try { return Promise.resolve(JSON.parse(buf.toString('utf8'))); }
|
|
110
|
+
catch (e) { return Promise.reject(e); }
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
req.on('error', reject);
|
|
117
|
+
|
|
118
|
+
// Timeout
|
|
119
|
+
if (typeof opts.timeout === 'number' && opts.timeout > 0)
|
|
120
|
+
{
|
|
121
|
+
req.setTimeout(opts.timeout, () =>
|
|
122
|
+
{
|
|
123
|
+
const err = new Error('Request timed out');
|
|
124
|
+
err.code = 'ETIMEOUT';
|
|
125
|
+
req.destroy(err);
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// AbortSignal support
|
|
130
|
+
let abortHandler;
|
|
131
|
+
if (opts.signal)
|
|
132
|
+
{
|
|
133
|
+
if (opts.signal.aborted)
|
|
134
|
+
{
|
|
135
|
+
const err = new Error('Request aborted');
|
|
136
|
+
err.name = 'AbortError';
|
|
137
|
+
req.destroy(err);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
abortHandler = () =>
|
|
141
|
+
{
|
|
142
|
+
const err = new Error('Request aborted');
|
|
143
|
+
err.name = 'AbortError';
|
|
144
|
+
req.destroy(err);
|
|
145
|
+
};
|
|
146
|
+
if (typeof opts.signal.addEventListener === 'function') opts.signal.addEventListener('abort', abortHandler);
|
|
147
|
+
else if (typeof opts.signal.on === 'function') opts.signal.on('abort', abortHandler);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Cleanup signal listener
|
|
151
|
+
const cleanup = () =>
|
|
152
|
+
{
|
|
153
|
+
if (opts.signal && abortHandler)
|
|
154
|
+
{
|
|
155
|
+
try
|
|
156
|
+
{
|
|
157
|
+
if (typeof opts.signal.removeEventListener === 'function') opts.signal.removeEventListener('abort', abortHandler);
|
|
158
|
+
else if (typeof opts.signal.off === 'function') opts.signal.off('abort', abortHandler);
|
|
159
|
+
}
|
|
160
|
+
catch (e) { }
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
req.on('close', cleanup);
|
|
164
|
+
|
|
165
|
+
// Write body
|
|
166
|
+
if (body && typeof body.pipe === 'function')
|
|
167
|
+
{
|
|
168
|
+
let uploaded = 0;
|
|
169
|
+
body.on('data', (chunk) =>
|
|
170
|
+
{
|
|
171
|
+
uploaded += chunk.length;
|
|
172
|
+
if (typeof opts.onUploadProgress === 'function')
|
|
173
|
+
{
|
|
174
|
+
try { opts.onUploadProgress({ loaded: uploaded, total: headers['Content-Length'] ? Number(headers['Content-Length']) : null }); } catch (e) { }
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
body.on('error', (err) => req.destroy(err));
|
|
178
|
+
body.pipe(req);
|
|
179
|
+
}
|
|
180
|
+
else if (Buffer.isBuffer(body) || typeof body === 'string')
|
|
181
|
+
{
|
|
182
|
+
const buf = Buffer.isBuffer(body) ? body : Buffer.from(body);
|
|
183
|
+
const total = buf.length;
|
|
184
|
+
const CHUNK = 64 * 1024;
|
|
185
|
+
let sent = 0;
|
|
186
|
+
|
|
187
|
+
function writeNext()
|
|
188
|
+
{
|
|
189
|
+
if (sent >= total) { req.end(); return; }
|
|
190
|
+
const slice = buf.slice(sent, Math.min(sent + CHUNK, total));
|
|
191
|
+
const ok = req.write(slice, () =>
|
|
192
|
+
{
|
|
193
|
+
sent += slice.length;
|
|
194
|
+
if (typeof opts.onUploadProgress === 'function')
|
|
195
|
+
{
|
|
196
|
+
try { opts.onUploadProgress({ loaded: sent, total }); } catch (e) { }
|
|
197
|
+
}
|
|
198
|
+
writeNext();
|
|
199
|
+
});
|
|
200
|
+
if (!ok) req.once('drain', writeNext);
|
|
201
|
+
}
|
|
202
|
+
writeNext();
|
|
203
|
+
}
|
|
204
|
+
else if (body == null)
|
|
205
|
+
{
|
|
206
|
+
req.end();
|
|
207
|
+
}
|
|
208
|
+
else
|
|
209
|
+
{
|
|
210
|
+
req.write(String(body));
|
|
211
|
+
req.end();
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
catch (e) { reject(e); }
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
module.exports = miniFetch;
|
package/lib/logger.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Simple request-logging middleware.
|
|
3
|
+
* Logs method, url, status code, and response time.
|
|
4
|
+
*
|
|
5
|
+
* @param {object} [opts]
|
|
6
|
+
* @param {function} [opts.logger] - Custom log function (default: console.log).
|
|
7
|
+
* @param {boolean} [opts.colors] - Colorize output (default: true when TTY).
|
|
8
|
+
* @param {string} [opts.format] - 'tiny' | 'short' | 'dev' (default: 'dev').
|
|
9
|
+
* @returns {function} Middleware function.
|
|
10
|
+
*/
|
|
11
|
+
function logger(opts = {})
|
|
12
|
+
{
|
|
13
|
+
const log = typeof opts.logger === 'function' ? opts.logger : console.log;
|
|
14
|
+
const useColors = opts.colors !== undefined ? opts.colors : (process.stdout.isTTY || false);
|
|
15
|
+
const format = opts.format || 'dev';
|
|
16
|
+
|
|
17
|
+
// ANSI color helpers
|
|
18
|
+
const c = {
|
|
19
|
+
reset: useColors ? '\x1b[0m' : '',
|
|
20
|
+
green: useColors ? '\x1b[32m' : '',
|
|
21
|
+
yellow: useColors ? '\x1b[33m' : '',
|
|
22
|
+
red: useColors ? '\x1b[31m' : '',
|
|
23
|
+
cyan: useColors ? '\x1b[36m' : '',
|
|
24
|
+
dim: useColors ? '\x1b[2m' : '',
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
function statusColor(code)
|
|
28
|
+
{
|
|
29
|
+
if (code >= 500) return c.red;
|
|
30
|
+
if (code >= 400) return c.yellow;
|
|
31
|
+
if (code >= 300) return c.cyan;
|
|
32
|
+
return c.green;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return (req, res, next) =>
|
|
36
|
+
{
|
|
37
|
+
const start = Date.now();
|
|
38
|
+
|
|
39
|
+
// Hook into the raw response 'finish' event
|
|
40
|
+
const raw = res.raw;
|
|
41
|
+
const onFinish = () =>
|
|
42
|
+
{
|
|
43
|
+
raw.removeListener('finish', onFinish);
|
|
44
|
+
const ms = Date.now() - start;
|
|
45
|
+
const status = raw.statusCode || res._status;
|
|
46
|
+
const sc = statusColor(status);
|
|
47
|
+
|
|
48
|
+
if (format === 'tiny')
|
|
49
|
+
{
|
|
50
|
+
log(`${req.method} ${req.url} ${status} - ${ms}ms`);
|
|
51
|
+
}
|
|
52
|
+
else if (format === 'short')
|
|
53
|
+
{
|
|
54
|
+
log(`${req.ip || '-'} ${req.method} ${req.url} ${sc}${status}${c.reset} ${ms}ms`);
|
|
55
|
+
}
|
|
56
|
+
else
|
|
57
|
+
{
|
|
58
|
+
// dev format
|
|
59
|
+
log(` ${c.dim}${req.method}${c.reset} ${req.url} ${sc}${status}${c.reset} ${c.dim}${ms}ms${c.reset}`);
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
raw.on('finish', onFinish);
|
|
63
|
+
|
|
64
|
+
next();
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
module.exports = logger;
|