mikroserve 1.0.1 → 1.1.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/lib/MikroServe.js CHANGED
@@ -74,7 +74,8 @@ var RateLimiter = class {
74
74
  const now = Date.now();
75
75
  const key = ip || "unknown";
76
76
  const entry = this.requests.get(key);
77
- if (!entry || entry.resetTime < now) return Math.floor((now + this.windowMs) / 1e3);
77
+ if (!entry || entry.resetTime < now)
78
+ return Math.floor((now + this.windowMs) / 1e3);
78
79
  return Math.floor(entry.resetTime / 1e3);
79
80
  }
80
81
  cleanup() {
@@ -191,7 +192,7 @@ var Router = class {
191
192
  res,
192
193
  params,
193
194
  query,
194
- // @ts-ignore
195
+ // @ts-expect-error
195
196
  body: req.body || {},
196
197
  headers: req.headers,
197
198
  path,
@@ -332,6 +333,10 @@ var configDefaults = () => {
332
333
  sslKey: "",
333
334
  sslCa: "",
334
335
  debug: getTruthyValue(process.env.DEBUG) || false,
336
+ maxBodySize: 1024 * 1024,
337
+ // 1MB
338
+ requestTimeout: 3e4,
339
+ // 30 seconds
335
340
  rateLimit: {
336
341
  enabled: true,
337
342
  requestsPerMinute: 100
@@ -352,8 +357,18 @@ var baseConfig = (options) => ({
352
357
  options: [
353
358
  { flag: "--port", path: "port", defaultValue: defaults.port },
354
359
  { flag: "--host", path: "host", defaultValue: defaults.host },
355
- { flag: "--https", path: "useHttps", defaultValue: defaults.useHttps, isFlag: true },
356
- { flag: "--http2", path: "useHttp2", defaultValue: defaults.useHttp2, isFlag: true },
360
+ {
361
+ flag: "--https",
362
+ path: "useHttps",
363
+ defaultValue: defaults.useHttps,
364
+ isFlag: true
365
+ },
366
+ {
367
+ flag: "--http2",
368
+ path: "useHttp2",
369
+ defaultValue: defaults.useHttp2,
370
+ isFlag: true
371
+ },
357
372
  { flag: "--cert", path: "sslCert", defaultValue: defaults.sslCert },
358
373
  { flag: "--key", path: "sslKey", defaultValue: defaults.sslKey },
359
374
  { flag: "--ca", path: "sslCa", defaultValue: defaults.sslCa },
@@ -374,27 +389,148 @@ var baseConfig = (options) => ({
374
389
  defaultValue: defaults.allowedDomains,
375
390
  parser: import_mikroconf.parsers.array
376
391
  },
377
- { flag: "--debug", path: "debug", defaultValue: defaults.debug, isFlag: true }
392
+ {
393
+ flag: "--debug",
394
+ path: "debug",
395
+ defaultValue: defaults.debug,
396
+ isFlag: true
397
+ },
398
+ {
399
+ flag: "--max-body-size",
400
+ path: "maxBodySize",
401
+ defaultValue: defaults.maxBodySize
402
+ },
403
+ {
404
+ flag: "--request-timeout",
405
+ path: "requestTimeout",
406
+ defaultValue: defaults.requestTimeout
407
+ }
378
408
  ],
379
409
  config: options
380
410
  });
381
411
 
412
+ // src/utils/multipartParser.ts
413
+ function parseMultipartFormData(body, contentType) {
414
+ const boundaryMatch = contentType.match(/boundary=(?:"([^"]+)"|([^;]+))/i);
415
+ if (!boundaryMatch) {
416
+ throw new Error("Invalid multipart/form-data: missing boundary");
417
+ }
418
+ const boundary = boundaryMatch[1] || boundaryMatch[2];
419
+ const boundaryBuffer = Buffer.from(`--${boundary}`);
420
+ const endBoundaryBuffer = Buffer.from(`--${boundary}--`);
421
+ const fields = {};
422
+ const files = {};
423
+ const parts = splitByBoundary(body, boundaryBuffer);
424
+ for (const part of parts) {
425
+ if (part.length === 0) continue;
426
+ if (part.equals(endBoundaryBuffer.subarray(boundaryBuffer.length)))
427
+ continue;
428
+ const parsed = parsePart(part);
429
+ if (!parsed) continue;
430
+ const { name, filename, contentType: partContentType, data } = parsed;
431
+ if (filename) {
432
+ const file = {
433
+ filename,
434
+ contentType: partContentType || "application/octet-stream",
435
+ data,
436
+ size: data.length
437
+ };
438
+ if (files[name]) {
439
+ if (Array.isArray(files[name])) {
440
+ files[name].push(file);
441
+ } else {
442
+ files[name] = [files[name], file];
443
+ }
444
+ } else {
445
+ files[name] = file;
446
+ }
447
+ } else {
448
+ const value = data.toString("utf8");
449
+ if (fields[name]) {
450
+ if (Array.isArray(fields[name])) {
451
+ fields[name].push(value);
452
+ } else {
453
+ fields[name] = [fields[name], value];
454
+ }
455
+ } else {
456
+ fields[name] = value;
457
+ }
458
+ }
459
+ }
460
+ return { fields, files };
461
+ }
462
+ function splitByBoundary(buffer, boundary) {
463
+ const parts = [];
464
+ let start = 0;
465
+ while (start < buffer.length) {
466
+ const index = buffer.indexOf(boundary, start);
467
+ if (index === -1) break;
468
+ if (start !== index) {
469
+ parts.push(buffer.subarray(start, index));
470
+ }
471
+ start = index + boundary.length;
472
+ if (start < buffer.length && buffer[start] === 13 && buffer[start + 1] === 10) {
473
+ start += 2;
474
+ }
475
+ }
476
+ return parts;
477
+ }
478
+ function parsePart(part) {
479
+ const doubleCRLF = Buffer.from("\r\n\r\n");
480
+ const headerEndIndex = part.indexOf(doubleCRLF);
481
+ if (headerEndIndex === -1) return null;
482
+ const headersBuffer = part.subarray(0, headerEndIndex);
483
+ const dataBuffer = part.subarray(headerEndIndex + 4);
484
+ const headers = headersBuffer.toString("utf8");
485
+ const headerLines = headers.split("\r\n");
486
+ let disposition = "";
487
+ let name = "";
488
+ let filename;
489
+ let contentType;
490
+ for (const line of headerLines) {
491
+ const lowerLine = line.toLowerCase();
492
+ if (lowerLine.startsWith("content-disposition:")) {
493
+ disposition = line.substring("content-disposition:".length).trim();
494
+ const nameMatch = disposition.match(/name="([^"]+)"/);
495
+ if (nameMatch) {
496
+ name = nameMatch[1];
497
+ }
498
+ const filenameMatch = disposition.match(/filename="([^"]+)"/);
499
+ if (filenameMatch) {
500
+ filename = filenameMatch[1];
501
+ }
502
+ } else if (lowerLine.startsWith("content-type:")) {
503
+ contentType = line.substring("content-type:".length).trim();
504
+ }
505
+ }
506
+ if (!name) return null;
507
+ let data = dataBuffer;
508
+ if (data.length >= 2 && data[data.length - 2] === 13 && data[data.length - 1] === 10) {
509
+ data = data.subarray(0, data.length - 2);
510
+ }
511
+ return { disposition, name, filename, contentType, data };
512
+ }
513
+
382
514
  // src/MikroServe.ts
383
515
  var MikroServe = class {
384
516
  config;
385
517
  rateLimiter;
386
518
  router;
519
+ shutdownHandlers = [];
387
520
  /**
388
521
  * @description Creates a new MikroServe instance.
389
522
  */
390
523
  constructor(options) {
391
- const config = new import_mikroconf2.MikroConf(baseConfig(options || {})).get();
524
+ const config = new import_mikroconf2.MikroConf(
525
+ baseConfig(options || {})
526
+ ).get();
392
527
  if (config.debug) console.log("Using configuration:", config);
393
528
  this.config = config;
394
529
  this.router = new Router();
395
530
  const requestsPerMinute = config.rateLimit.requestsPerMinute || configDefaults().rateLimit.requestsPerMinute;
396
531
  this.rateLimiter = new RateLimiter(requestsPerMinute, 60);
397
- if (config.rateLimit.enabled === true) this.use(this.rateLimitMiddleware.bind(this));
532
+ if (config.rateLimit.enabled === true)
533
+ this.use(this.rateLimitMiddleware.bind(this));
398
534
  }
399
535
  /**
400
536
  * @description Register a global middleware.
@@ -475,7 +611,9 @@ var MikroServe = class {
475
611
  const boundRequestHandler = this.requestHandler.bind(this);
476
612
  if (this.config.useHttp2) {
477
613
  if (!this.config.sslCert || !this.config.sslKey)
478
- throw new Error("SSL certificate and key paths are required when useHttp2 is true");
614
+ throw new Error(
615
+ "SSL certificate and key paths are required when useHttp2 is true"
616
+ );
479
617
  try {
480
618
  const httpsOptions = {
481
619
  key: (0, import_node_fs.readFileSync)(this.config.sslKey),
@@ -485,12 +623,16 @@ var MikroServe = class {
485
623
  return import_node_http2.default.createSecureServer(httpsOptions, boundRequestHandler);
486
624
  } catch (error) {
487
625
  if (error.message.includes("key values mismatch"))
488
- throw new Error(`SSL certificate and key do not match: ${error.message}`);
626
+ throw new Error(
627
+ `SSL certificate and key do not match: ${error.message}`
628
+ );
489
629
  throw error;
490
630
  }
491
631
  } else if (this.config.useHttps) {
492
632
  if (!this.config.sslCert || !this.config.sslKey)
493
- throw new Error("SSL certificate and key paths are required when useHttps is true");
633
+ throw new Error(
634
+ "SSL certificate and key paths are required when useHttps is true"
635
+ );
494
636
  try {
495
637
  const httpsOptions = {
496
638
  key: (0, import_node_fs.readFileSync)(this.config.sslKey),
@@ -500,7 +642,9 @@ var MikroServe = class {
500
642
  return import_node_https.default.createServer(httpsOptions, boundRequestHandler);
501
643
  } catch (error) {
502
644
  if (error.message.includes("key values mismatch"))
503
- throw new Error(`SSL certificate and key do not match: ${error.message}`);
645
+ throw new Error(
646
+ `SSL certificate and key do not match: ${error.message}`
647
+ );
504
648
  throw error;
505
649
  }
506
650
  }
@@ -511,12 +655,18 @@ var MikroServe = class {
511
655
  */
512
656
  async rateLimitMiddleware(context, next) {
513
657
  const ip = context.req.socket.remoteAddress || "unknown";
514
- context.res.setHeader("X-RateLimit-Limit", this.rateLimiter.getLimit().toString());
658
+ context.res.setHeader(
659
+ "X-RateLimit-Limit",
660
+ this.rateLimiter.getLimit().toString()
661
+ );
515
662
  context.res.setHeader(
516
663
  "X-RateLimit-Remaining",
517
664
  this.rateLimiter.getRemainingRequests(ip).toString()
518
665
  );
519
- context.res.setHeader("X-RateLimit-Reset", this.rateLimiter.getResetTime(ip).toString());
666
+ context.res.setHeader(
667
+ "X-RateLimit-Reset",
668
+ this.rateLimiter.getResetTime(ip).toString()
669
+ );
520
670
  if (!this.rateLimiter.isAllowed(ip)) {
521
671
  return {
522
672
  statusCode: 429,
@@ -603,44 +753,87 @@ var MikroServe = class {
603
753
  return new Promise((resolve, reject) => {
604
754
  const bodyChunks = [];
605
755
  let bodySize = 0;
606
- const MAX_BODY_SIZE = 1024 * 1024;
607
- let rejected = false;
756
+ const maxBodySize = this.config.maxBodySize;
757
+ let settled = false;
758
+ let timeoutId = null;
608
759
  const isDebug = this.config.debug;
609
760
  const contentType = req.headers["content-type"] || "";
610
761
  if (isDebug) {
611
762
  console.log("Content-Type:", contentType);
612
763
  }
764
+ if (this.config.requestTimeout > 0) {
765
+ timeoutId = setTimeout(() => {
766
+ if (!settled) {
767
+ settled = true;
768
+ if (isDebug) console.log("Request timeout exceeded");
769
+ reject(new Error("Request timeout"));
770
+ }
771
+ }, this.config.requestTimeout);
772
+ }
773
+ const cleanup = () => {
774
+ if (timeoutId) {
775
+ clearTimeout(timeoutId);
776
+ timeoutId = null;
777
+ }
778
+ };
613
779
  req.on("data", (chunk) => {
780
+ if (settled) {
781
+ return;
782
+ }
614
783
  bodySize += chunk.length;
615
- if (isDebug) console.log(`Received chunk: ${chunk.length} bytes, total size: ${bodySize}`);
616
- if (bodySize > MAX_BODY_SIZE && !rejected) {
617
- rejected = true;
618
- if (isDebug) console.log(`Body size exceeded limit: ${bodySize} > ${MAX_BODY_SIZE}`);
784
+ if (isDebug)
785
+ console.log(
786
+ `Received chunk: ${chunk.length} bytes, total size: ${bodySize}`
787
+ );
788
+ if (bodySize > maxBodySize) {
789
+ settled = true;
790
+ cleanup();
791
+ if (isDebug)
792
+ console.log(
793
+ `Body size exceeded limit: ${bodySize} > ${maxBodySize}`
794
+ );
619
795
  reject(new Error("Request body too large"));
620
796
  return;
621
797
  }
622
- if (!rejected) bodyChunks.push(chunk);
798
+ bodyChunks.push(chunk);
623
799
  });
624
800
  req.on("end", () => {
625
- if (rejected) return;
801
+ if (settled) return;
802
+ settled = true;
803
+ cleanup();
626
804
  if (isDebug) console.log(`Request body complete: ${bodySize} bytes`);
627
805
  try {
628
806
  if (bodyChunks.length > 0) {
629
- const bodyString = Buffer.concat(bodyChunks).toString("utf8");
807
+ const bodyBuffer = Buffer.concat(bodyChunks);
630
808
  if (contentType.includes("application/json")) {
631
809
  try {
810
+ const bodyString = bodyBuffer.toString("utf8");
632
811
  resolve(JSON.parse(bodyString));
633
812
  } catch (error) {
634
- reject(new Error(`Invalid JSON in request body: ${error.message}`));
813
+ reject(
814
+ new Error(`Invalid JSON in request body: ${error.message}`)
815
+ );
635
816
  }
636
817
  } else if (contentType.includes("application/x-www-form-urlencoded")) {
818
+ const bodyString = bodyBuffer.toString("utf8");
637
819
  const formData = {};
638
820
  new URLSearchParams(bodyString).forEach((value, key) => {
639
821
  formData[key] = value;
640
822
  });
641
823
  resolve(formData);
824
+ } else if (contentType.includes("multipart/form-data")) {
825
+ try {
826
+ const parsed = parseMultipartFormData(bodyBuffer, contentType);
827
+ resolve(parsed);
828
+ } catch (error) {
829
+ reject(
830
+ new Error(`Invalid multipart form data: ${error.message}`)
831
+ );
832
+ }
833
+ } else if (this.isBinaryContentType(contentType)) {
834
+ resolve(bodyBuffer);
642
835
  } else {
643
- resolve(bodyString);
836
+ resolve(bodyBuffer.toString("utf8"));
644
837
  }
645
838
  } else {
646
839
  resolve({});
@@ -650,24 +843,61 @@ var MikroServe = class {
650
843
  }
651
844
  });
652
845
  req.on("error", (error) => {
653
- if (!rejected) reject(new Error(`Error reading request body: ${error.message}`));
846
+ if (!settled) {
847
+ settled = true;
848
+ cleanup();
849
+ reject(new Error(`Error reading request body: ${error.message}`));
850
+ }
851
+ });
852
+ req.on("close", () => {
853
+ cleanup();
654
854
  });
655
855
  });
656
856
  }
857
+ /**
858
+ * @description Checks if a content type is binary.
859
+ */
860
+ isBinaryContentType(contentType) {
861
+ const binaryTypes = [
862
+ "application/octet-stream",
863
+ "application/pdf",
864
+ "application/zip",
865
+ "application/gzip",
866
+ "application/x-tar",
867
+ "application/x-rar-compressed",
868
+ "application/x-7z-compressed",
869
+ "image/",
870
+ "video/",
871
+ "audio/",
872
+ "application/vnd.ms-excel",
873
+ "application/vnd.openxmlformats-officedocument",
874
+ "application/msword",
875
+ "application/vnd.ms-powerpoint"
876
+ ];
877
+ return binaryTypes.some((type) => contentType.includes(type));
878
+ }
657
879
  /**
658
880
  * @description CORS middleware.
659
881
  */
660
882
  setCorsHeaders(res, req) {
661
883
  const origin = req.headers.origin;
662
884
  const { allowedDomains = ["*"] } = this.config;
663
- if (!origin || allowedDomains.length === 0) res.setHeader("Access-Control-Allow-Origin", "*");
664
- else if (allowedDomains.includes("*")) res.setHeader("Access-Control-Allow-Origin", "*");
885
+ if (!origin || allowedDomains.length === 0)
886
+ res.setHeader("Access-Control-Allow-Origin", "*");
887
+ else if (allowedDomains.includes("*"))
888
+ res.setHeader("Access-Control-Allow-Origin", "*");
665
889
  else if (allowedDomains.includes(origin)) {
666
890
  res.setHeader("Access-Control-Allow-Origin", origin);
667
891
  res.setHeader("Vary", "Origin");
668
892
  }
669
- res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, PATCH, OPTIONS");
670
- res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
893
+ res.setHeader(
894
+ "Access-Control-Allow-Methods",
895
+ "GET, POST, PUT, DELETE, PATCH, OPTIONS"
896
+ );
897
+ res.setHeader(
898
+ "Access-Control-Allow-Headers",
899
+ "Content-Type, Authorization"
900
+ );
671
901
  res.setHeader("Access-Control-Max-Age", "86400");
672
902
  }
673
903
  /**
@@ -710,11 +940,15 @@ var MikroServe = class {
710
940
  else if (typeof response.body === "string") res.end(response.body);
711
941
  else res.end(JSON.stringify(response.body));
712
942
  } else {
713
- console.warn("Unexpected response object type without writeHead/end methods");
943
+ console.warn(
944
+ "Unexpected response object type without writeHead/end methods"
945
+ );
714
946
  res.writeHead?.(response.statusCode, headers);
715
- if (response.body === null || response.body === void 0) res.end?.();
947
+ if (response.body === null || response.body === void 0)
948
+ res.end?.();
716
949
  else if (response.isRaw) res.end?.(response.body);
717
- else if (typeof response.body === "string") res.end?.(response.body);
950
+ else if (typeof response.body === "string")
951
+ res.end?.(response.body);
718
952
  else res.end?.(JSON.stringify(response.body));
719
953
  }
720
954
  }
@@ -725,15 +959,46 @@ var MikroServe = class {
725
959
  const shutdown = (error) => {
726
960
  console.log("Shutting down MikroServe server...");
727
961
  if (error) console.error("Error:", error);
962
+ this.cleanupShutdownHandlers();
728
963
  server.close(() => {
729
964
  console.log("Server closed successfully");
730
- setImmediate(() => process.exit(error ? 1 : 0));
965
+ if (process.env.NODE_ENV !== "test" && process.env.VITEST !== "true") {
966
+ setImmediate(() => process.exit(error ? 1 : 0));
967
+ }
731
968
  });
732
969
  };
733
- process.on("SIGINT", () => shutdown());
734
- process.on("SIGTERM", () => shutdown());
735
- process.on("uncaughtException", shutdown);
736
- process.on("unhandledRejection", shutdown);
970
+ const sigintHandler = () => shutdown();
971
+ const sigtermHandler = () => shutdown();
972
+ const uncaughtExceptionHandler = (error) => shutdown(error);
973
+ const unhandledRejectionHandler = (error) => shutdown(error);
974
+ this.shutdownHandlers = [
975
+ sigintHandler,
976
+ sigtermHandler,
977
+ uncaughtExceptionHandler,
978
+ unhandledRejectionHandler
979
+ ];
980
+ process.on("SIGINT", sigintHandler);
981
+ process.on("SIGTERM", sigtermHandler);
982
+ process.on("uncaughtException", uncaughtExceptionHandler);
983
+ process.on("unhandledRejection", unhandledRejectionHandler);
984
+ }
985
+ /**
986
+ * @description Cleans up shutdown event listeners to prevent memory leaks.
987
+ */
988
+ cleanupShutdownHandlers() {
989
+ if (this.shutdownHandlers.length > 0) {
990
+ const [
991
+ sigintHandler,
992
+ sigtermHandler,
993
+ uncaughtExceptionHandler,
994
+ unhandledRejectionHandler
995
+ ] = this.shutdownHandlers;
996
+ process.removeListener("SIGINT", sigintHandler);
997
+ process.removeListener("SIGTERM", sigtermHandler);
998
+ process.removeListener("uncaughtException", uncaughtExceptionHandler);
999
+ process.removeListener("unhandledRejection", unhandledRejectionHandler);
1000
+ this.shutdownHandlers = [];
1001
+ }
737
1002
  }
738
1003
  };
739
1004
  // Annotate the CommonJS export names for ESM import in node:
@@ -1,10 +1,11 @@
1
1
  import {
2
2
  MikroServe
3
- } from "./chunk-N5ZQZGGT.mjs";
4
- import "./chunk-ZFBBESGU.mjs";
5
- import "./chunk-YKRH6T5M.mjs";
6
- import "./chunk-YOHL3T54.mjs";
7
- import "./chunk-JJX5XRNB.mjs";
3
+ } from "./chunk-C4IW4XUH.mjs";
4
+ import "./chunk-OF5DEOIU.mjs";
5
+ import "./chunk-7LU765PG.mjs";
6
+ import "./chunk-ZT2UGCN5.mjs";
7
+ import "./chunk-DMNHVQTU.mjs";
8
+ import "./chunk-VLQ7ZZIU.mjs";
8
9
  export {
9
10
  MikroServe
10
11
  };
@@ -57,7 +57,8 @@ var RateLimiter = class {
57
57
  const now = Date.now();
58
58
  const key = ip || "unknown";
59
59
  const entry = this.requests.get(key);
60
- if (!entry || entry.resetTime < now) return Math.floor((now + this.windowMs) / 1e3);
60
+ if (!entry || entry.resetTime < now)
61
+ return Math.floor((now + this.windowMs) / 1e3);
61
62
  return Math.floor(entry.resetTime / 1e3);
62
63
  }
63
64
  cleanup() {
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  RateLimiter
3
- } from "./chunk-ZFBBESGU.mjs";
3
+ } from "./chunk-OF5DEOIU.mjs";
4
4
  export {
5
5
  RateLimiter
6
6
  };
package/lib/Router.js CHANGED
@@ -128,7 +128,7 @@ var Router = class {
128
128
  res,
129
129
  params,
130
130
  query,
131
- // @ts-ignore
131
+ // @ts-expect-error
132
132
  body: req.body || {},
133
133
  headers: req.headers,
134
134
  path,
package/lib/Router.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  Router
3
- } from "./chunk-YKRH6T5M.mjs";
3
+ } from "./chunk-7LU765PG.mjs";
4
4
  export {
5
5
  Router
6
6
  };
@@ -1,5 +1,5 @@
1
1
  // src/Router.ts
2
- import { URL } from "node:url";
2
+ import { URL } from "url";
3
3
  var Router = class {
4
4
  routes = [];
5
5
  globalMiddlewares = [];
@@ -104,7 +104,7 @@ var Router = class {
104
104
  res,
105
105
  params,
106
106
  query,
107
- // @ts-ignore
107
+ // @ts-expect-error
108
108
  body: req.body || {},
109
109
  headers: req.headers,
110
110
  path,