@ndiinginc/router 4.1.3 → 4.3.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/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "@ndiinginc/router",
3
- "version": "4.1.3",
3
+ "version": "4.3.0",
4
4
  "description": "",
5
- "main": "./src/app.js",
5
+ "main": "./src/index.js",
6
6
  "scripts": {
7
7
  "test": "nodemon --exec \"node -e \\\"require('@ndiinginc/test')(require('path').resolve('tests'),{verbose:true})\\\"\"",
8
- "dev": "nodemon src/app.js",
8
+ "dev": "nodemon src/index.js",
9
9
  "prettier": "npx prettier . --write",
10
10
  "commit": "node -e \"require('@ndiinginc/commit')()\"",
11
11
  "npm:dry-run": "npm publish --dry-run",
package/src/app.js CHANGED
@@ -1,67 +1,76 @@
1
1
  const http = require("node:http");
2
- const { Response } = require("./response.js");
2
+ const { Router } = require("./router.js");
3
3
  const { Request } = require("./request.js");
4
- const { Router } = require("./Router.js");
4
+ const { Response } = require("./response.js");
5
+ const { ErrorResponse } = require("./error.js");
6
+
7
+ /**@type {import('http').ServerOptions}*/
8
+ const SERVER_OPTIONS = {
9
+ IncomingMessage: Request,
10
+ ServerResponse: Response,
11
+ };
5
12
 
6
13
  class App extends Router {
14
+ async withTimeout(handlerPromise, delay) {
15
+ let timeout;
16
+ const timeoutPromise = new Promise((_, reject) => {
17
+ timeout = setTimeout(() => reject(ErrorResponse.status(504)), delay);
18
+ });
19
+
20
+ await Promise.race([handlerPromise, timeoutPromise]).finally(() => clearTimeout(timeout));
21
+ }
22
+
7
23
  /**
8
24
  * @param {import('http').IncomingMessage} req
9
25
  * @param {import('http').ServerResponse} res
10
26
  */
11
- async handleRequest(req, res) {
12
- let err;
13
-
27
+ async handle(req, res) {
14
28
  const { params, handlers, errorHandlers } = this.find(req.method, req.path);
15
29
  req.params = params;
16
30
 
31
+ let err;
17
32
  try {
18
- for (const handler of handlers) {
19
- if (res.done) break;
20
- await handler(req, res);
21
- }
33
+ const handlerPromise = (async () => {
34
+ for (const handler of handlers) {
35
+ if (res.writableEnded) break;
36
+ await handler(req, res);
37
+ }
38
+ })();
39
+
40
+ if (req.timeout) await this.withTimeout(handlerPromise, req.timeout);
41
+ else await handlerPromise;
22
42
  } catch (error) {
23
43
  err = error;
24
44
 
25
45
  try {
26
46
  for (const handler of errorHandlers) {
27
- if (res.done) break;
47
+ if (res.writableEnded) break;
28
48
  await handler(err, req, res);
29
49
  }
30
50
  } catch (error) {
31
- err = error;
51
+ err = new Error(error.message, { cause: err });
32
52
  }
33
53
  }
34
54
 
35
- if (!res.done) {
36
- if (!err) {
37
- res.statusCode = 404;
38
- err = new Error(http.STATUS_CODES[res.statusCode]);
55
+ if (!res.writableEnded) {
56
+ if (!err) err = ErrorResponse.status(404);
57
+
58
+ if (!res.statusCode || res.statusCode < 400) {
59
+ res.statusCode = err.statusCode || 500;
39
60
  }
40
61
 
41
- res.statusCode = res.statusCode >= 200 && res.statusCode < 300 ? 500 : res.statusCode;
42
- const error = { message: err.message };
43
- if (process.env.NODE_ENV === "development") error.stack = err.stack;
44
- res.json({ error });
62
+ res.json({ error: { message: err.message, stack: err.stack } });
45
63
  }
46
64
  }
47
65
 
48
- /**
49
- * @param {import('net').ListenOptions} options
50
- * @returns {import('http').Server}
51
- */
52
- listen(...args) {
53
- const serverOptions = {
54
- IncomingMessage: Request,
55
- ServerResponse: Response,
56
- };
57
- const requestListener = (req, res) => this.handleRequest(req, res);
58
-
59
- const server = http.createServer(serverOptions, requestListener);
60
-
61
- server.listen(...args);
62
-
66
+ listen(...params) {
67
+ const requestListener = (req, res) => this.handle(req, res);
68
+ const server = http.createServer(SERVER_OPTIONS, requestListener);
69
+ server.listen(...params);
63
70
  return server;
64
71
  }
65
72
  }
66
73
 
67
- module.exports = App;
74
+ App.SERVER_OPTIONS = SERVER_OPTIONS;
75
+
76
+ exports.App = App;
package/src/cache.js CHANGED
@@ -1,18 +1,14 @@
1
1
  class Cache {
2
2
  constructor(size) {
3
3
  this.size = size;
4
-
5
4
  this.map = new Map();
6
5
  }
7
6
 
8
7
  get(key) {
9
8
  if (!this.map.has(key)) return undefined;
10
-
11
9
  const value = this.map.get(key);
12
-
13
10
  this.map.delete(key);
14
11
  this.map.set(key, value);
15
-
16
12
  return value;
17
13
  }
18
14
 
@@ -21,8 +17,8 @@ class Cache {
21
17
  else if (this.map.size >= this.size) {
22
18
  this.map.delete(this.map.keys().next().value);
23
19
  }
24
-
25
20
  this.map.set(key, value);
26
21
  }
27
22
  }
23
+
28
24
  exports.Cache = Cache;
package/src/error.js ADDED
@@ -0,0 +1,26 @@
1
+ const http = require("node:http");
2
+
3
+ // Client error responses (400 – 499)
4
+ // Server error responses (500 – 599)
5
+ class ErrorResponse extends Error {
6
+ constructor({ statusCode, message } = {}, options) {
7
+ super(message, options);
8
+
9
+ this.name = this.constructor.name;
10
+
11
+ if (Error.captureStackTrace) {
12
+ Error.captureStackTrace(this, this.constructor);
13
+ }
14
+
15
+ this.statusCode = statusCode;
16
+ }
17
+
18
+ static status(statusCode) {
19
+ return new ErrorResponse({
20
+ statusCode,
21
+ message: http.STATUS_CODES[statusCode],
22
+ });
23
+ }
24
+ }
25
+
26
+ exports.ErrorResponse = ErrorResponse;
package/src/index.js ADDED
@@ -0,0 +1,11 @@
1
+ const { inspect } = require("node:util");
2
+ const { json } = require("./middleware/json.js");
3
+ const { cors } = require("./middleware/cors.js");
4
+ const { App } = require("./app.js");
5
+ const { Router } = require("./router.js");
6
+
7
+ App.Router = Router;
8
+ App.json = json;
9
+ App.cors = cors;
10
+
11
+ module.exports = App;
@@ -0,0 +1,139 @@
1
+ function pattern(string) {
2
+ return new RegExp(
3
+ `(${String(string)
4
+ .replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
5
+ .replace(/ /g, "")
6
+ .replace(/,/g, "|")
7
+ .replace(/\\\*/g, ".*")})`,
8
+ "i",
9
+ );
10
+ }
11
+
12
+ /**
13
+ * @typedef CORSOptions
14
+ * @property {String} [origin="*"]
15
+ * @property {Boolean} [credentials=false]
16
+ * @property {String|Array} headers
17
+ * @property {Number} [maxAge=0]
18
+ * @property {String|Array} methods
19
+ */
20
+
21
+ /**
22
+ * @param {CORSOptions} options
23
+ */
24
+ function cors({ origin = "*", credentials = false, headers, maxAge = 0, methods } = {}) {
25
+ const HEADERS_PATTERN = headers && pattern(headers);
26
+ const METHODS_PATTERN = methods && pattern(methods);
27
+ const ORIGIN_PATTERN = pattern(origin);
28
+
29
+ /**
30
+ * @param {import('http').ServerResponse} res
31
+ */
32
+ function setAllowCredentials(res) {
33
+ if (credentials) {
34
+ res.setHeader("Access-Control-Allow-Credentials", "true");
35
+ }
36
+ }
37
+
38
+ /**
39
+ * @param {import('http').IncomingMessage} req
40
+ * @param {import('http').ServerResponse} res
41
+ */
42
+ function setAllowHeaders(req, res) {
43
+ const reqHeaders = req.headers["access-control-request-headers"];
44
+ if (!reqHeaders) return;
45
+
46
+ let value;
47
+ if (headers === "*" && !credentials) {
48
+ value = "*";
49
+ } else if (Array.isArray(headers) && HEADERS_PATTERN && HEADERS_PATTERN.test(reqHeaders)) {
50
+ value = headers;
51
+ }
52
+ if (value) res.setHeader("Access-Control-Allow-Headers", value);
53
+ }
54
+
55
+ /**
56
+ * @param {import('http').IncomingMessage} req
57
+ * @param {import('http').ServerResponse} res
58
+ */
59
+ function setAllowMethods(req, res) {
60
+ const reqMethod = req.headers["access-control-request-method"];
61
+ if (!reqMethod) return;
62
+
63
+ let value;
64
+ if (methods === "*" && !credentials) {
65
+ value = "*";
66
+ } else if (Array.isArray(methods) && METHODS_PATTERN && METHODS_PATTERN.test(reqMethod)) {
67
+ value = methods;
68
+ }
69
+ if (value) res.setHeader("Access-Control-Allow-Methods", value);
70
+ }
71
+
72
+ /**
73
+ * @param {import('http').IncomingMessage} req
74
+ * @param {import('http').ServerResponse} res
75
+ */
76
+ function setAllowOrigin(req, res) {
77
+ const reqOrigin = req.headers["origin"];
78
+
79
+ let value;
80
+ let explicit;
81
+ if (origin === "*") {
82
+ if (!credentials) value = "*";
83
+ else {
84
+ value = reqOrigin;
85
+ explicit = true;
86
+ }
87
+ } else if (origin === null) {
88
+ value = "null";
89
+ } else if (typeof origin === "string" && ORIGIN_PATTERN.test(reqOrigin)) {
90
+ value = origin;
91
+ explicit = true;
92
+ }
93
+ if (value) res.setHeader("Access-Control-Allow-Origin", value);
94
+ if (explicit) res.appendHeader("Vary", "Origin");
95
+ }
96
+
97
+ /**
98
+ * @param {import('http').ServerResponse} res
99
+ */
100
+ function setExposeHeaders(res) {
101
+ let value;
102
+ if (headers === "*" && !credentials) {
103
+ value = "*";
104
+ } else if (Array.isArray(headers)) {
105
+ value = headers;
106
+ }
107
+ if (value) res.setHeader("Access-Control-Expose-Headers", value);
108
+ }
109
+
110
+ /**
111
+ * @param {import('http').ServerResponse} res
112
+ */
113
+ function setMaxAge(res) {
114
+ if (maxAge > 0) {
115
+ res.setHeader("Access-Control-Max-Age", maxAge);
116
+ }
117
+ }
118
+
119
+ /**
120
+ * @param {import('http').IncomingMessage} req
121
+ * @param {import('http').ServerResponse} res
122
+ */
123
+ return (req, res) => {
124
+ setAllowCredentials(res);
125
+ setAllowOrigin(req, res);
126
+ if (req.method === "OPTIONS") {
127
+ setAllowHeaders(req, res);
128
+ setAllowMethods(req, res);
129
+ setMaxAge(res);
130
+
131
+ res.statusCode = 204;
132
+ res.setHeader("Content-Length", "0");
133
+ res.end();
134
+ } else {
135
+ setExposeHeaders(res);
136
+ }
137
+ };
138
+ }
139
+ exports.cors = cors;
@@ -1,5 +1,6 @@
1
1
  const http = require("node:http");
2
2
 
3
+ // middleware
3
4
  function json({ limit = 100 * 1024 } = {}) {
4
5
  const reviver = (key, value) => {
5
6
  if (key === "__proto__" || key === "constructor" || key === "prototype") {
@@ -25,14 +26,15 @@ function json({ limit = 100 * 1024 } = {}) {
25
26
  }
26
27
 
27
28
  let chunkLength = 0;
29
+
28
30
  const chunks = [];
31
+
29
32
  for await (const chunk of req) {
30
33
  chunkLength += chunk.length;
31
34
  if (chunkLength > limit) {
32
35
  res.statusCode = 413;
33
36
  throw new Error(http.STATUS_CODES[res.statusCode]);
34
37
  }
35
-
36
38
  chunks.push(chunk);
37
39
  }
38
40
 
@@ -49,4 +51,5 @@ function json({ limit = 100 * 1024 } = {}) {
49
51
  }
50
52
  };
51
53
  }
52
- module.exports.json = json;
54
+
55
+ exports.json = json;
package/src/node.js CHANGED
@@ -1,12 +1,11 @@
1
1
  const { Pattern } = require("./pattern.js");
2
2
 
3
3
  class Node {
4
- constructor(segment) {
4
+ constructor(segment = "") {
5
5
  this.segment = segment;
6
6
  this.pattern = new Pattern(segment);
7
- /**@type {Map<string, Node>}*/
7
+ /**@type {Map<string,Node>}*/
8
8
  this.children = new Map();
9
- /**@type {Map<string, FunctionHandler[]>}*/
10
9
  this.handlers = new Map([
11
10
  ["ALL", []],
12
11
  ["GET", []],
@@ -26,13 +25,11 @@ class Node {
26
25
  if (!this.children.has(segment)) {
27
26
  this.children.set(segment, new Node(segment));
28
27
  }
29
-
30
28
  return this.children.get(segment);
31
29
  }
32
30
 
33
31
  /**
34
32
  * @param {Node} node
35
- * @param {Number} offset
36
33
  */
37
34
  merge(node, offset) {
38
35
  for (const [method, handlers] of node.handlers) {
@@ -52,9 +49,6 @@ class Node {
52
49
  }
53
50
  }
54
51
 
55
- /**
56
- * @param {Number} offset
57
- */
58
52
  setSeqOffset(offset) {
59
53
  for (const [, handlers] of this.handlers) {
60
54
  for (const handler of handlers) {
@@ -83,4 +77,5 @@ class Node {
83
77
  return max;
84
78
  }
85
79
  }
80
+
86
81
  exports.Node = Node;
package/src/pattern.js CHANGED
@@ -1,34 +1,34 @@
1
1
  /**
2
- * @typedef {Object} PatternExec
3
- * @property {number} index - The zero-based index of the match in the input string.
4
- * @property {string} input - The original input string.
5
- * @property {Object.<string, string>} [groups] - Named capture groups, or undefined.
2
+ * @typedef PatternExecResult
3
+ * @property {number} index
4
+ * @property {string} input
5
+ * @property {Record<string, string>} [groups]
6
+ * @property {number} length
6
7
  */
7
8
  class Pattern {
8
9
  constructor(input) {
9
- const pattern = input
10
- .replace(/[.+*?^${}()|[\]\\]/g, "\\$&")
10
+ const pattern = `^${input
11
+ .replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
11
12
  .replace(/:(\w+)/g, "(?<$1>[^/]+)")
12
- .replace(/\\\*/g, ".*");
13
-
14
- Object.defineProperty(this, "re", {
15
- value: new RegExp(`^${pattern}$`),
16
- writable: true,
17
- enumerable: false,
13
+ .replace(/\\\*/g, ".*")}$`;
14
+ Object.defineProperty(this, "regex", {
15
+ value: new RegExp(pattern),
18
16
  configurable: true,
17
+ enumerable: false,
19
18
  });
20
19
  }
21
20
 
22
- test(input) {
23
- return this.re.test(input);
24
- }
25
-
26
21
  /**
27
- * @param {String} input
28
- * @returns {PatternExec | null}
22
+ * @param {string} input
23
+ * @returns {PatternExecResult | null}
29
24
  */
30
25
  exec(input) {
31
- return this.re.exec(input);
26
+ return this.regex.exec(input);
27
+ }
28
+
29
+ test(input) {
30
+ return this.regex.test(input);
32
31
  }
33
32
  }
33
+
34
34
  exports.Pattern = Pattern;
package/src/request.js CHANGED
@@ -1,43 +1,37 @@
1
1
  const http = require("node:http");
2
+ const { parseUrl, parseQueryString } = require("./util.js");
2
3
 
3
4
  class Request extends http.IncomingMessage {
4
- static base = "http://127.0.0";
5
+ _path;
6
+ _parsed;
7
+ _query;
8
+ params = {};
9
+ timeout = 0;
5
10
 
6
11
  get path() {
7
12
  if (this._path) return this._path;
8
13
 
9
- if (this.url.indexOf('?')===-1) {
14
+ if (this.url.indexOf("?") === -1) {
10
15
  this._path = this.url;
11
16
  return this._path;
12
17
  }
13
18
 
14
- this.parsed = URL.parse(this.url, Request.base);
15
- this._path = this.parsed.pathname;
19
+ this._parsed = parseUrl(this.url);
20
+ this._path = this._parsed.path;
16
21
  return this._path;
17
22
  }
18
23
 
19
24
  get query() {
20
25
  if (this._query) return this._query;
21
26
 
22
- if (!this.parsed) {
27
+ if (!this._parsed) {
23
28
  this._query = {};
24
29
  return this._query;
25
30
  }
26
31
 
27
- const query = {};
28
- for (const [name, value] of this.parsed.searchParams) {
29
- if (query[name]) {
30
- if (Array.isArray(query[name])) {
31
- query[name].push(value);
32
- } else {
33
- query[name] = [query[name], value];
34
- }
35
- } else {
36
- query[name] = value;
37
- }
38
- }
39
- this._query = query;
32
+ this._query = parseQueryString(this._parsed.queryString);
40
33
  return this._query;
41
34
  }
42
35
  }
36
+
43
37
  exports.Request = Request;
package/src/response.js CHANGED
@@ -1,14 +1,13 @@
1
1
  const http = require("node:http");
2
2
 
3
3
  class Response extends http.ServerResponse {
4
- send(string) {
5
- this.end(string);
6
- this.done = true;
4
+ send(chunk) {
5
+ this.end(chunk);
7
6
  }
8
7
 
9
- json(object) {
10
- this.setHeader("content-type", "application/json");
11
- this.send(JSON.stringify(object));
8
+ json(value) {
9
+ this.setHeader("Content-Type", "application/json");
10
+ this.end(JSON.stringify(value));
12
11
  }
13
12
  }
14
13
  exports.Response = Response;
package/src/router.js CHANGED
@@ -1,45 +1,19 @@
1
- const { Node } = require("./node");
2
- const { Cache } = require("./cache");
3
-
4
- /**
5
- * @callback Handler
6
- * @param {*} req
7
- * @param {*} res
8
- */
9
- /**
10
- * @callback Middleware
11
- * @param {*} req
12
- */
13
- /**
14
- * @callback ErrorHandler
15
- * @param {*} err
16
- * @param {*} req
17
- * @param {*} res
18
- */
19
- /**
20
- * @typedef {Handler|Middleware|ErrorHandler} FunctionHandler
21
- */
1
+ const { Node } = require("./node.js");
2
+ const { Cache } = require("./cache.js");
22
3
 
23
4
  class Router {
24
5
  constructor(options = {}) {
25
- this.cacheSize = options.cacheSize ?? 30_000;
6
+ this.cacheSize = options.size ?? 1000;
26
7
 
27
- this.root = new Node("/");
8
+ this.root = new Node();
28
9
  this.seq = 0;
29
10
  this.cache = new Cache(this.cacheSize);
30
11
  }
31
12
 
32
13
  split(path) {
33
- if (path === "/") return [];
34
-
35
14
  return path.split("/").filter(Boolean);
36
15
  }
37
16
 
38
- /**
39
- * @param {*} method
40
- * @param {*} path
41
- * @param {FunctionHandler[]} handler
42
- */
43
17
  add(method, path, ...handlers) {
44
18
  if (typeof path === "function") {
45
19
  handlers = [path, ...handlers];
@@ -120,6 +94,11 @@ class Router {
120
94
  return this;
121
95
  }
122
96
 
97
+ /**
98
+ * @param {'GET'|'QUERY'|'HEAD'|'OPTIONS'|'TRACE'|'PUT'|'DELETE'|'POST'|'PATCH'|'CONNECT'} method
99
+ * @param {String} path
100
+ * @returns {{params: {};handlers: any[];errorHandlers: any[];}}
101
+ */
123
102
  find(method, path) {
124
103
  const key = `${method} ${path}`;
125
104
 
@@ -171,9 +150,15 @@ class Router {
171
150
  }
172
151
  }
173
152
 
174
- const value = { params, handlers: Object.values(handlers), errorHandlers: Object.values(errorHandlers) };
175
- this.cache.set(key, value);
176
- return value;
153
+ const result = {
154
+ params,
155
+ handlers: Object.values(handlers),
156
+ errorHandlers: Object.values(errorHandlers),
157
+ };
158
+
159
+ this.cache.set(key, result);
160
+
161
+ return result;
177
162
  }
178
163
  }
179
- module.exports.Router = Router;
164
+ exports.Router = Router;
package/src/util.js ADDED
@@ -0,0 +1,27 @@
1
+ const URL_REGEX = /^([^?#]*)(\?([^#]*))?(#.*)?$/;
2
+ const QUERY_STRING_REGEX = /([^=&]*)?=([^&]*)?/g;
3
+
4
+ function parseQueryString(queryString) {
5
+ const result = {};
6
+
7
+ let match;
8
+ while ((match = QUERY_STRING_REGEX.exec(queryString)) !== null) {
9
+ const [, name = "", raw = ""] = match;
10
+
11
+ const value = decodeURIComponent(raw);
12
+
13
+ if (result[name]) {
14
+ if (Array.isArray(result[name])) result[name].push(value);
15
+ else result[name] = [result[name], value];
16
+ } else result[name] = value;
17
+ }
18
+
19
+ return result;
20
+ }
21
+ exports.parseQueryString = parseQueryString;
22
+
23
+ function parseUrl(url) {
24
+ const [, path = "/", , queryString = ""] = URL_REGEX.exec(url);
25
+ return { path, queryString };
26
+ }
27
+ exports.parseUrl = parseUrl;