Browse Source

feat: adds "trailingSlash" option

e22m4u 4 hours ago
parent
commit
ac1c705e9e
10 changed files with 506 additions and 98 deletions
  1. 20 0
      README.md
  2. 198 37
      dist/cjs/index.cjs
  3. 1 1
      example/server.js
  4. 1 1
      package.json
  5. 47 0
      src/http-static-route.d.ts
  6. 73 0
      src/http-static-route.js
  7. 7 9
      src/http-static-router.d.ts
  8. 157 50
      src/http-static-router.js
  9. 1 0
      src/index.d.ts
  10. 1 0
      src/index.js

+ 20 - 0
README.md

@@ -68,6 +68,26 @@ server.listen(3000, () => {
 });
 ```
 
+### trailingSlash
+
+Так как в HTML обычно используются относительные пути, чтобы файлы
+стилей и изображений загружались относительно текущего уровня вложенности,
+а не обращались на уровень выше, может потребоваться параметр `trailingSlash`
+для принудительного добавления косой черты в конце адреса.
+
+```js
+import http from 'http';
+import {HttpStaticRouter} from '@e22m4u/js-http-static-router';
+
+const staticRouter = new HttpStaticRouter({
+  trailingSlash: true, // <= добавлять косую черту (для директорий)
+});
+
+// теперь, при обращении к директориям без закрывающего
+// слеша будет выполняться принудительный редирект (302)
+// /dir => /dir/
+```
+
 ## Тесты
 
 ```bash

+ 198 - 37
dist/cjs/index.cjs

@@ -31,14 +31,86 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
 // src/index.js
 var index_exports = {};
 __export(index_exports, {
+  HttpStaticRoute: () => HttpStaticRoute,
   HttpStaticRouter: () => HttpStaticRouter
 });
 module.exports = __toCommonJS(index_exports);
 
+// src/http-static-route.js
+var import_js_format = require("@e22m4u/js-format");
+var _HttpStaticRoute = class _HttpStaticRoute {
+  /**
+   * Remote path.
+   *
+   * @type {string}
+   */
+  remotePath;
+  /**
+   * Resource path.
+   *
+   * @type {string}
+   */
+  resourcePath;
+  /**
+   * RegExp.
+   *
+   * @type {RegExp}
+   */
+  regexp;
+  /**
+   * Is file.
+   *
+   * @type {boolean}
+   */
+  isFile;
+  /**
+   * Constructor.
+   *
+   * @param {string} remotePath
+   * @param {string} resourcePath
+   * @param {RegExp} regexp
+   * @param {boolean} isFile
+   */
+  constructor(remotePath, resourcePath, regexp, isFile) {
+    if (typeof remotePath !== "string") {
+      throw new import_js_format.InvalidArgumentError(
+        'Parameter "remotePath" must be a String, but %v was given.',
+        remotePath
+      );
+    }
+    if (typeof resourcePath !== "string") {
+      throw new import_js_format.InvalidArgumentError(
+        'Parameter "resourcePath" must be a String, but %v was given.',
+        resourcePath
+      );
+    }
+    if (!(regexp instanceof RegExp)) {
+      throw new import_js_format.InvalidArgumentError(
+        'Parameter "regexp" must be an instance of RegExp, but %v was given.',
+        regexp
+      );
+    }
+    if (typeof isFile !== "boolean") {
+      throw new import_js_format.InvalidArgumentError(
+        'Parameter "isFile" must be a String, but %v was given.',
+        isFile
+      );
+    }
+    this.remotePath = remotePath;
+    this.resourcePath = resourcePath;
+    this.regexp = regexp;
+    this.isFile = isFile;
+  }
+};
+__name(_HttpStaticRoute, "HttpStaticRoute");
+var HttpStaticRoute = _HttpStaticRoute;
+
 // src/http-static-router.js
 var import_path = __toESM(require("path"), 1);
 var import_mime_types = __toESM(require("mime-types"), 1);
 var import_fs = __toESM(require("fs"), 1);
+var import_http = require("http");
+var import_js_format2 = require("@e22m4u/js-format");
 
 // src/utils/escape-regexp.js
 function escapeRegexp(input) {
@@ -58,24 +130,48 @@ __name(normalizePath, "normalizePath");
 
 // src/http-static-router.js
 var import_js_service = require("@e22m4u/js-service");
-var import_js_format = require("@e22m4u/js-format");
 var _HttpStaticRouter = class _HttpStaticRouter extends import_js_service.DebuggableService {
   /**
    * Routes.
    *
    * @protected
+   * @type {HttpStaticRoute[]}
    */
   _routes = [];
+  /**
+   * Options.
+   *
+   * @type {object}
+   */
+  _options = {};
   /**
    * Constructor.
    *
-   * @param {import('@e22m4u/js-service').ServiceContainer} container
+   * @param {object} options
    */
-  constructor(container) {
-    super(container, {
+  constructor(options = {}) {
+    if ((0, import_js_service.isServiceContainer)(options)) {
+      options = {};
+    }
+    super(void 0, {
       noEnvironmentNamespace: true,
       namespace: "jsHttpStaticRouter"
     });
+    if (!options || typeof options !== "object" || Array.isArray(options)) {
+      throw new import_js_format2.InvalidArgumentError(
+        'Parameter "options" must be an Object, but %v was given.',
+        options
+      );
+    }
+    if (options.trailingSlash !== void 0) {
+      if (typeof options.trailingSlash !== "boolean") {
+        throw new import_js_format2.InvalidArgumentError(
+          'Option "trailingSlash" must be a Boolean, but %v was given.',
+          options.trailingSlash
+        );
+      }
+    }
+    this._options = { ...options };
   }
   /**
    * Add route.
@@ -85,6 +181,18 @@ var _HttpStaticRouter = class _HttpStaticRouter extends import_js_service.Debugg
    * @returns {object}
    */
   addRoute(remotePath, resourcePath) {
+    if (typeof remotePath !== "string") {
+      throw new import_js_format2.InvalidArgumentError(
+        "Remote path must be a String, but %v was given.",
+        remotePath
+      );
+    }
+    if (typeof resourcePath !== "string") {
+      throw new import_js_format2.InvalidArgumentError(
+        "Resource path must be a String, but %v was given.",
+        resourcePath
+      );
+    }
     const debug = this.getDebuggerFor(this.addRoute);
     resourcePath = import_path.default.resolve(resourcePath);
     debug("Adding a new route.");
@@ -95,8 +203,8 @@ var _HttpStaticRouter = class _HttpStaticRouter extends import_js_service.Debugg
       stats = import_fs.default.statSync(resourcePath);
     } catch (error) {
       console.error(error);
-      throw new import_js_format.InvalidArgumentError(
-        "Static resource path does not exist %v.",
+      throw new import_js_format2.InvalidArgumentError(
+        "Resource path %v does not exist.",
         resourcePath
       );
     }
@@ -104,8 +212,8 @@ var _HttpStaticRouter = class _HttpStaticRouter extends import_js_service.Debugg
     debug("Resource type is %s.", isFile ? "File" : "Folder");
     const normalizedRemotePath = normalizePath(remotePath);
     const escapedRemotePath = escapeRegexp(normalizedRemotePath);
-    const regexp = isFile ? new RegExp(`^${escapedRemotePath}$`) : new RegExp(`^${escapedRemotePath}(?:$|\\/)`);
-    const route = { remotePath, resourcePath, regexp, isFile };
+    const regexp = isFile ? new RegExp(`^${escapedRemotePath}/*$`) : new RegExp(`^${escapedRemotePath}(?:$|\\/)`);
+    const route = new HttpStaticRoute(remotePath, resourcePath, regexp, isFile);
     this._routes.push(route);
     this._routes.sort((a, b) => b.remotePath.length - a.remotePath.length);
     return this;
@@ -113,10 +221,16 @@ var _HttpStaticRouter = class _HttpStaticRouter extends import_js_service.Debugg
   /**
    * Match route.
    *
-   * @param {import('http').IncomingMessage} req
+   * @param {IncomingMessage} req
    * @returns {object|undefined}
    */
   matchRoute(req) {
+    if (!(req instanceof import_http.IncomingMessage)) {
+      throw new import_js_format2.InvalidArgumentError(
+        'Parameter "req" must be an instance of IncomingMessage, but %v was given.',
+        req
+      );
+    }
     const debug = this.getDebuggerFor(this.matchRoute);
     debug("Matching routes with incoming request.");
     const url = (req.url || "/").replace(/\?.*$/, "");
@@ -138,13 +252,45 @@ var _HttpStaticRouter = class _HttpStaticRouter extends import_js_service.Debugg
   /**
    * Send file by route.
    *
-   * @param {object} route
+   * @param {HttpStaticRoute} route
    * @param {import('http').IncomingMessage} req
    * @param {import('http').ServerResponse} res
    */
   sendFileByRoute(route, req, res) {
+    if (!(route instanceof HttpStaticRoute)) {
+      throw new import_js_format2.InvalidArgumentError(
+        'Parameter "route" must be an instance of HttpStaticRoute, but %v was given.',
+        route
+      );
+    }
+    if (!(req instanceof import_http.IncomingMessage)) {
+      throw new import_js_format2.InvalidArgumentError(
+        'Parameter "req" must be an instance of IncomingMessage, but %v was given.',
+        req
+      );
+    }
+    if (!(res instanceof import_http.ServerResponse)) {
+      throw new import_js_format2.InvalidArgumentError(
+        'Parameter "res" must be an instance of ServerResponse, but %v was given.',
+        res
+      );
+    }
     const reqUrl = req.url || "/";
     const reqPath = reqUrl.replace(/\?.*$/, "");
+    if (!this._options.trailingSlash && reqPath !== "/" && /\/$/.test(reqPath)) {
+      const searchMatch = reqUrl.match(/\?.*$/);
+      const search = searchMatch ? searchMatch[0] : "";
+      const normalizedPath = reqPath.replace(/\/{2,}/g, "/").replace(/\/+$/, "");
+      res.writeHead(302, { location: `${normalizedPath}${search}` });
+      res.end();
+      return;
+    }
+    if (/\/{2,}/.test(reqUrl)) {
+      const normalizedUrl = reqUrl.replace(/\/{2,}/g, "/");
+      res.writeHead(302, { location: normalizedUrl });
+      res.end();
+      return;
+    }
     let targetPath = route.resourcePath;
     if (!route.isFile) {
       const relativePath = reqPath.replace(route.regexp, "");
@@ -159,30 +305,35 @@ var _HttpStaticRouter = class _HttpStaticRouter extends import_js_service.Debugg
     }
     import_fs.default.stat(targetPath, (statsError, stats) => {
       if (statsError) {
-        return _handleFsError(statsError, res);
+        return this._handleFsError(statsError, res);
       }
       if (stats.isDirectory()) {
-        if (/[^/]$/.test(reqPath)) {
+        if (this._options.trailingSlash) {
+          if (/[^/]$/.test(reqPath)) {
+            const searchMatch = reqUrl.match(/\?.*$/);
+            const search = searchMatch ? searchMatch[0] : "";
+            const normalizedPath = reqPath.replace(/\/{2,}/g, "/");
+            res.writeHead(302, { location: `${normalizedPath}/${search}` });
+            res.end();
+            return;
+          }
+        }
+        targetPath = import_path.default.join(targetPath, "index.html");
+      } else {
+        if (reqPath !== "/" && /\/$/.test(reqPath)) {
           const searchMatch = reqUrl.match(/\?.*$/);
           const search = searchMatch ? searchMatch[0] : "";
-          const normalizedPath = reqUrl.replace(/\/{2,}/g, "/");
-          res.writeHead(302, { location: `${normalizedPath}/${search}` });
+          const normalizedPath = reqPath.replace(/\/{2,}/g, "/").replace(/\/+$/, "");
+          res.writeHead(302, { location: `${normalizedPath}${search}` });
           res.end();
           return;
         }
-        if (/\/{2,}/.test(reqUrl)) {
-          const normalizedUrl = reqUrl.replace(/\/{2,}/g, "/");
-          res.writeHead(302, { location: normalizedUrl });
-          res.end();
-          return;
-        }
-        targetPath = import_path.default.join(targetPath, "index.html");
       }
       const extname = import_path.default.extname(targetPath);
       const contentType = import_mime_types.default.contentType(extname) || "application/octet-stream";
       const fileStream = (0, import_fs.createReadStream)(targetPath);
       fileStream.on("error", (error) => {
-        _handleFsError(error, res);
+        this._handleFsError(error, res);
       });
       fileStream.on("open", () => {
         res.writeHead(200, { "content-type": contentType });
@@ -192,27 +343,37 @@ var _HttpStaticRouter = class _HttpStaticRouter extends import_js_service.Debugg
         }
         fileStream.pipe(res);
       });
+      req.on("close", () => {
+        fileStream.destroy();
+      });
     });
   }
+  /**
+   * Handle filesystem error.
+   *
+   * @param {object} error
+   * @param {object} res
+   * @returns {undefined}
+   */
+  _handleFsError(error, res) {
+    if (res.headersSent) {
+      return;
+    }
+    if ("code" in error && error.code === "ENOENT") {
+      res.writeHead(404, { "content-type": "text/plain" });
+      res.write("404 Not Found");
+      res.end();
+    } else {
+      res.writeHead(500, { "content-type": "text/plain" });
+      res.write("500 Internal Server Error");
+      res.end();
+    }
+  }
 };
 __name(_HttpStaticRouter, "HttpStaticRouter");
 var HttpStaticRouter = _HttpStaticRouter;
-function _handleFsError(error, res) {
-  if (res.headersSent) {
-    return;
-  }
-  if ("code" in error && error.code === "ENOENT") {
-    res.writeHead(404, { "content-type": "text/plain" });
-    res.write("404 Not Found");
-    res.end();
-  } else {
-    res.writeHead(500, { "content-type": "text/plain" });
-    res.write("500 Internal Server Error");
-    res.end();
-  }
-}
-__name(_handleFsError, "_handleFsError");
 // Annotate the CommonJS export names for ESM import in node:
 0 && (module.exports = {
+  HttpStaticRoute,
   HttpStaticRouter
 });

+ 1 - 1
example/server.js

@@ -36,6 +36,6 @@ server.on('request', (req, res) => {
 server.listen(3000, () => {
   console.log('Server is running on http://localhost:3000');
   console.log('Try to open:');
-  console.log('http://localhost:3000/static/');
+  console.log('http://localhost:3000/static');
   console.log('http://localhost:3000/file.txt');
 });

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "@e22m4u/js-http-static-router",
-  "version": "0.0.1",
+  "version": "0.0.2",
   "description": "HTTP-маршрутизатор статичных ресурсов для Node.js",
   "author": "Mikhail Evstropov <e22m4u@yandex.ru>",
   "license": "MIT",

+ 47 - 0
src/http-static-route.d.ts

@@ -0,0 +1,47 @@
+/**
+ * Static file route.
+ */
+export class HttpStaticRoute {
+  /**
+   * Remote path.
+   *
+   * @type {string}
+   */
+  readonly remotePath: string;
+
+  /**
+   * Resource path.
+   *
+   * @type {string}
+   */
+  readonly resourcePath: string;
+
+  /**
+   * RegExp.
+   *
+   * @type {RegExp}
+   */
+  readonly regexp: RegExp;
+
+  /**
+   * Is file.
+   *
+   * @type {boolean}
+   */
+  readonly isFile: boolean;
+
+  /**
+   * Constructor.
+   * 
+   * @param remotePath 
+   * @param resourcePath 
+   * @param regexp 
+   * @param isFile 
+   */
+  constructor(
+    remotePath: string,
+    resourcePath: string,
+    regexp: RegExp,
+    isFile: boolean,
+  );
+}

+ 73 - 0
src/http-static-route.js

@@ -0,0 +1,73 @@
+import {InvalidArgumentError} from '@e22m4u/js-format';
+
+/**
+ * Static file route.
+ */
+export class HttpStaticRoute {
+  /**
+   * Remote path.
+   *
+   * @type {string}
+   */
+  remotePath;
+
+  /**
+   * Resource path.
+   *
+   * @type {string}
+   */
+  resourcePath;
+
+  /**
+   * RegExp.
+   *
+   * @type {RegExp}
+   */
+  regexp;
+
+  /**
+   * Is file.
+   *
+   * @type {boolean}
+   */
+  isFile;
+
+  /**
+   * Constructor.
+   *
+   * @param {string} remotePath
+   * @param {string} resourcePath
+   * @param {RegExp} regexp
+   * @param {boolean} isFile
+   */
+  constructor(remotePath, resourcePath, regexp, isFile) {
+    if (typeof remotePath !== 'string') {
+      throw new InvalidArgumentError(
+        'Parameter "remotePath" must be a String, but %v was given.',
+        remotePath,
+      );
+    }
+    if (typeof resourcePath !== 'string') {
+      throw new InvalidArgumentError(
+        'Parameter "resourcePath" must be a String, but %v was given.',
+        resourcePath,
+      );
+    }
+    if (!(regexp instanceof RegExp)) {
+      throw new InvalidArgumentError(
+        'Parameter "regexp" must be an instance of RegExp, but %v was given.',
+        regexp,
+      );
+    }
+    if (typeof isFile !== 'boolean') {
+      throw new InvalidArgumentError(
+        'Parameter "isFile" must be a String, but %v was given.',
+        isFile,
+      );
+    }
+    this.remotePath = remotePath;
+    this.resourcePath = resourcePath;
+    this.regexp = regexp;
+    this.isFile = isFile;
+  }
+}

+ 7 - 9
src/http-static-router.d.ts

@@ -1,16 +1,14 @@
 import {ServerResponse} from 'node:http';
 import {IncomingMessage} from 'node:http';
+import {HttpStaticRoute} from './http-static-route.js';
 import {DebuggableService, ServiceContainer} from '@e22m4u/js-service';
 
 /**
- * Static file route.
+ * Http static router options.
  */
-export type HttpStaticRoute = {
-  remotePath: string;
-  resourcePath: string;
-  regexp: RegExp;
-  isFile: boolean;
-};
+export type HttpStaticRouterOptions = {
+  trailingSlash?: boolean;
+}
 
 /**
  * Http static router.
@@ -19,9 +17,9 @@ export class HttpStaticRouter extends DebuggableService {
   /**
    * Constructor.
    * 
-   * @param container 
+   * @param options
    */
-  constructor(container?: ServiceContainer);
+  constructor(options?: HttpStaticRouterOptions);
 
   /**
    * Add route.

+ 157 - 50
src/http-static-router.js

@@ -1,9 +1,11 @@
 import path from 'path';
 import mimeTypes from 'mime-types';
 import fs, {createReadStream} from 'fs';
-import {escapeRegexp, normalizePath} from './utils/index.js';
-import {DebuggableService} from '@e22m4u/js-service';
+import {IncomingMessage, ServerResponse} from 'http';
 import {InvalidArgumentError} from '@e22m4u/js-format';
+import {HttpStaticRoute} from './http-static-route.js';
+import {escapeRegexp, normalizePath} from './utils/index.js';
+import {DebuggableService, isServiceContainer} from '@e22m4u/js-service';
 
 /**
  * Http static router.
@@ -13,19 +15,45 @@ export class HttpStaticRouter extends DebuggableService {
    * Routes.
    *
    * @protected
+   * @type {HttpStaticRoute[]}
    */
   _routes = [];
 
+  /**
+   * Options.
+   *
+   * @type {object}
+   */
+  _options = {};
+
   /**
    * Constructor.
    *
-   * @param {import('@e22m4u/js-service').ServiceContainer} container
+   * @param {object} options
    */
-  constructor(container) {
-    super(container, {
+  constructor(options = {}) {
+    if (isServiceContainer(options)) {
+      options = {};
+    }
+    super(undefined, {
       noEnvironmentNamespace: true,
       namespace: 'jsHttpStaticRouter',
     });
+    if (!options || typeof options !== 'object' || Array.isArray(options)) {
+      throw new InvalidArgumentError(
+        'Parameter "options" must be an Object, but %v was given.',
+        options,
+      );
+    }
+    if (options.trailingSlash !== undefined) {
+      if (typeof options.trailingSlash !== 'boolean') {
+        throw new InvalidArgumentError(
+          'Option "trailingSlash" must be a Boolean, but %v was given.',
+          options.trailingSlash,
+        );
+      }
+    }
+    this._options = {...options};
   }
 
   /**
@@ -36,6 +64,18 @@ export class HttpStaticRouter extends DebuggableService {
    * @returns {object}
    */
   addRoute(remotePath, resourcePath) {
+    if (typeof remotePath !== 'string') {
+      throw new InvalidArgumentError(
+        'Remote path must be a String, but %v was given.',
+        remotePath,
+      );
+    }
+    if (typeof resourcePath !== 'string') {
+      throw new InvalidArgumentError(
+        'Resource path must be a String, but %v was given.',
+        resourcePath,
+      );
+    }
     const debug = this.getDebuggerFor(this.addRoute);
     resourcePath = path.resolve(resourcePath);
     debug('Adding a new route.');
@@ -49,7 +89,7 @@ export class HttpStaticRouter extends DebuggableService {
       // это может быть ошибкой конфигурации
       console.error(error);
       throw new InvalidArgumentError(
-        'Static resource path does not exist %v.',
+        'Resource path %v does not exist.',
         resourcePath,
       );
     }
@@ -58,9 +98,9 @@ export class HttpStaticRouter extends DebuggableService {
     const normalizedRemotePath = normalizePath(remotePath);
     const escapedRemotePath = escapeRegexp(normalizedRemotePath);
     const regexp = isFile
-      ? new RegExp(`^${escapedRemotePath}$`)
+      ? new RegExp(`^${escapedRemotePath}/*$`)
       : new RegExp(`^${escapedRemotePath}(?:$|\\/)`);
-    const route = {remotePath, resourcePath, regexp, isFile};
+    const route = new HttpStaticRoute(remotePath, resourcePath, regexp, isFile);
     this._routes.push(route);
     // самые длинные пути проверяются первыми,
     // чтобы избежать коллизий при поиске маршрута
@@ -71,10 +111,17 @@ export class HttpStaticRouter extends DebuggableService {
   /**
    * Match route.
    *
-   * @param {import('http').IncomingMessage} req
+   * @param {IncomingMessage} req
    * @returns {object|undefined}
    */
   matchRoute(req) {
+    if (!(req instanceof IncomingMessage)) {
+      throw new InvalidArgumentError(
+        'Parameter "req" must be an instance of IncomingMessage, ' +
+          'but %v was given.',
+        req,
+      );
+    }
     const debug = this.getDebuggerFor(this.matchRoute);
     debug('Matching routes with incoming request.');
     const url = (req.url || '/').replace(/\?.*$/, '');
@@ -99,13 +146,59 @@ export class HttpStaticRouter extends DebuggableService {
   /**
    * Send file by route.
    *
-   * @param {object} route
+   * @param {HttpStaticRoute} route
    * @param {import('http').IncomingMessage} req
    * @param {import('http').ServerResponse} res
    */
   sendFileByRoute(route, req, res) {
+    if (!(route instanceof HttpStaticRoute)) {
+      throw new InvalidArgumentError(
+        'Parameter "route" must be an instance of HttpStaticRoute, ' +
+          'but %v was given.',
+        route,
+      );
+    }
+    if (!(req instanceof IncomingMessage)) {
+      throw new InvalidArgumentError(
+        'Parameter "req" must be an instance of IncomingMessage, ' +
+          'but %v was given.',
+        req,
+      );
+    }
+    if (!(res instanceof ServerResponse)) {
+      throw new InvalidArgumentError(
+        'Parameter "res" must be an instance of ServerResponse, ' +
+          'but %v was given.',
+        res,
+      );
+    }
     const reqUrl = req.url || '/';
     const reqPath = reqUrl.replace(/\?.*$/, '');
+    // если параметр "trailingSlash" не активен, и адрес запроса
+    // не указывает на корень, но содержит косую черту в конце пути,
+    // то косая черта принудительно удаляется и выполняется редирект
+    if (
+      !this._options.trailingSlash &&
+      reqPath !== '/' &&
+      /\/$/.test(reqPath)
+    ) {
+      const searchMatch = reqUrl.match(/\?.*$/);
+      const search = searchMatch ? searchMatch[0] : '';
+      const normalizedPath = reqPath
+        .replace(/\/{2,}/g, '/') // удаление дублирующих слешей
+        .replace(/\/+$/, ''); // удаление завершающего слеша
+      res.writeHead(302, {location: `${normalizedPath}${search}`});
+      res.end();
+      return;
+    }
+    // если адрес запроса содержит дублирующие слеши,
+    // то адрес нормализуется и выполняется редирект
+    if (/\/{2,}/.test(reqUrl)) {
+      const normalizedUrl = reqUrl.replace(/\/{2,}/g, '/');
+      res.writeHead(302, {location: normalizedUrl});
+      res.end();
+      return;
+    }
     // если ресурс ссылается на папку, то из адреса запроса
     // извлекается дополнительная часть (если присутствует),
     // и добавляется к адресу ресурса
@@ -132,32 +225,43 @@ export class HttpStaticRouter extends DebuggableService {
     // установка заголовков и отправка потока
     fs.stat(targetPath, (statsError, stats) => {
       if (statsError) {
-        return _handleFsError(statsError, res);
+        return this._handleFsError(statsError, res);
       }
       if (stats.isDirectory()) {
-        // так как в html обычно используются относительные пути,
-        // то адрес директории статических ресурсов должен завершаться
-        // косой чертой, чтобы файлы стилей и изображений загружались
-        // именно из нее, а не обращались на уровень выше
-        if (/[^/]$/.test(reqPath)) {
+        // если активен параметр "trailingSlash", и адрес директории
+        // не содержит косую черту в конце пути, то косая черта
+        // добавляется принудительно и выполняется редирект
+        if (this._options.trailingSlash) {
+          // так как в html обычно используются относительные пути,
+          // то адрес директории статических ресурсов должен завершаться
+          // косой чертой, чтобы файлы стилей и изображений загружались
+          // из текущего уровня, а не обращались на уровень выше
+          if (/[^/]$/.test(reqPath)) {
+            const searchMatch = reqUrl.match(/\?.*$/);
+            const search = searchMatch ? searchMatch[0] : '';
+            const normalizedPath = reqPath.replace(/\/{2,}/g, '/');
+            res.writeHead(302, {location: `${normalizedPath}/${search}`});
+            res.end();
+            return;
+          }
+        }
+        // если целевой путь указывает на папку,
+        // то подставляется index.html
+        targetPath = path.join(targetPath, 'index.html');
+      } else {
+        // если адрес файла не указывает на корень и в конце пути
+        // содержит косую черту, то косая черта принудительно
+        // удаляется и выполняется редирект
+        if (reqPath !== '/' && /\/$/.test(reqPath)) {
           const searchMatch = reqUrl.match(/\?.*$/);
           const search = searchMatch ? searchMatch[0] : '';
-          const normalizedPath = reqUrl.replace(/\/{2,}/g, '/');
-          res.writeHead(302, {location: `${normalizedPath}/${search}`});
-          res.end();
-          return;
-        }
-        // если адрес запроса содержит дублирующие слеши,
-        // то адрес нормализуется и выполняется редирект
-        if (/\/{2,}/.test(reqUrl)) {
-          const normalizedUrl = reqUrl.replace(/\/{2,}/g, '/');
-          res.writeHead(302, {location: normalizedUrl});
+          const normalizedPath = reqPath
+            .replace(/\/{2,}/g, '/') // удаление дублирующих слешей
+            .replace(/\/+$/, ''); // удаление завершающего слеша
+          res.writeHead(302, {location: `${normalizedPath}${search}`});
           res.end();
           return;
         }
-        // если целевой путь указывает на папку,
-        // то подставляется index.html
-        targetPath = path.join(targetPath, 'index.html');
       }
       // формирование заголовка "content-type"
       // в зависимости от расширения файла
@@ -168,7 +272,7 @@ export class HttpStaticRouter extends DebuggableService {
       // что значительно снижает использование памяти
       const fileStream = createReadStream(targetPath);
       fileStream.on('error', error => {
-        _handleFsError(error, res);
+        this._handleFsError(error, res);
       });
       // отправка заголовка 200, только после
       // этого начинается отдача файла
@@ -182,28 +286,31 @@ export class HttpStaticRouter extends DebuggableService {
         }
         fileStream.pipe(res);
       });
+      req.on('close', () => {
+        fileStream.destroy();
+      });
     });
   }
-}
 
-/**
- * Handle filesystem error.
- *
- * @param {object} error
- * @param {object} res
- * @returns {undefined}
- */
-function _handleFsError(error, res) {
-  if (res.headersSent) {
-    return;
-  }
-  if ('code' in error && error.code === 'ENOENT') {
-    res.writeHead(404, {'content-type': 'text/plain'});
-    res.write('404 Not Found');
-    res.end();
-  } else {
-    res.writeHead(500, {'content-type': 'text/plain'});
-    res.write('500 Internal Server Error');
-    res.end();
+  /**
+   * Handle filesystem error.
+   *
+   * @param {object} error
+   * @param {object} res
+   * @returns {undefined}
+   */
+  _handleFsError(error, res) {
+    if (res.headersSent) {
+      return;
+    }
+    if ('code' in error && error.code === 'ENOENT') {
+      res.writeHead(404, {'content-type': 'text/plain'});
+      res.write('404 Not Found');
+      res.end();
+    } else {
+      res.writeHead(500, {'content-type': 'text/plain'});
+      res.write('500 Internal Server Error');
+      res.end();
+    }
   }
 }

+ 1 - 0
src/index.d.ts

@@ -1 +1,2 @@
+export * from './http-static-route.js';
 export * from './http-static-router.js';

+ 1 - 0
src/index.js

@@ -1 +1,2 @@
+export * from './http-static-route.js';
 export * from './http-static-router.js';