From 76df538c647be33964b73c7a6633387e23a55d53 Mon Sep 17 00:00:00 2001 From: Ahmad Nasriya Date: Thu, 2 Oct 2025 13:23:24 +0300 Subject: [PATCH 01/13] Installed cachify, atomix, and started drafting the static route file --- package-lock.json | 143 +++++++++++++++++++++ package.json | 4 +- src/services/routes/assets/staticRoute2.ts | 136 ++++++++++++++++++++ 3 files changed, 282 insertions(+), 1 deletion(-) create mode 100644 src/services/routes/assets/staticRoute2.ts diff --git a/package-lock.json b/package-lock.json index ff2370e..67931cb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,8 @@ "version": "1.2.9", "license": "NPCL-1", "dependencies": { + "@nasriya/atomix": "^1.0.23", + "@nasriya/cachify": "^0.0.2-beta", "ejs": "^3.1.10", "ms": "^2.1.3", "tldts": "^7.0.16" @@ -958,6 +960,75 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@nasriya/atomix": { + "version": "1.0.23", + "resolved": "https://registry.npmjs.org/@nasriya/atomix/-/atomix-1.0.23.tgz", + "integrity": "sha512-xQ/NA3Bbx/GKEvqVZNcvs2LrJTEJiA8g8NmVCNDertMyx9TCUF4+Z68T2Vs9vDfbs4YWEN5onSAMucgXd/8hkA==", + "license": "NOL-1", + "dependencies": { + "@nasriya/uuidx": "^1.0.3" + }, + "funding": { + "type": "individual", + "url": "https://fund.nasriya.net/" + } + }, + "node_modules/@nasriya/cachify": { + "version": "0.0.2-beta", + "resolved": "https://registry.npmjs.org/@nasriya/cachify/-/cachify-0.0.2-beta.tgz", + "integrity": "sha512-bOY/jGfKd9BkfqbD50NiouONFAgxpUvh7XyKAH7f6dYgA/PvVOT5xxmne2ei4HLSd265zk64TGj0uWWhK2Vweg==", + "license": "NPCL-1", + "dependencies": { + "@nasriya/atomix": "^1.0.23", + "@nasriya/cron": "^1.1.2", + "@nasriya/overwatch": "^1.1.4", + "@nasriya/uuidx": "^1.0.3" + }, + "funding": { + "type": "individual", + "url": "https://fund.nasriya.net/" + }, + "peerDependencies": { + "@aws-sdk/client-s3": "^3.876.0", + "@redis/client": "^5.8.2" + }, + "peerDependenciesMeta": { + "@aws-sdk/client-s3": { + "optional": true + }, + "@redis/client": { + "optional": true + } + } + }, + "node_modules/@nasriya/cron": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@nasriya/cron/-/cron-1.1.2.tgz", + "integrity": "sha512-X6CDFX37OLfTyLxfrhoVb6nEYc71wvhbxamaC8jNBbp68PXHBStLK4okJ+lUbdyag3Gq6M8De2xllcyVHZupHw==", + "license": "NOL-1", + "dependencies": { + "cron-time-generator": "^2.0.3", + "node-cron": "^4.2.0", + "node-schedule": "^2.1.1" + }, + "funding": { + "type": "individual", + "url": "https://fund.nasriya.net/" + } + }, + "node_modules/@nasriya/overwatch": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@nasriya/overwatch/-/overwatch-1.1.4.tgz", + "integrity": "sha512-W4PrtMqA4e8IfDGY38OrhT4Pb6KJfqpIl19my6mOjZs+00GokQJX98aTAMR0jLvFLD95CyIErPgdCOkQapVnkQ==", + "license": "NOL-1", + "dependencies": { + "@nasriya/atomix": "^1.0.1" + }, + "funding": { + "type": "individual", + "url": "https://fund.nasriya.net/" + } + }, "node_modules/@nasriya/postbuild": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/@nasriya/postbuild/-/postbuild-1.1.5.tgz", @@ -973,6 +1044,16 @@ "url": "https://fund.nasriya.net/" } }, + "node_modules/@nasriya/uuidx": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@nasriya/uuidx/-/uuidx-1.0.3.tgz", + "integrity": "sha512-sVw5/R9Gq3eaAkKTmCJ5UbV8vhh+r6auW2xFBZ+xKRx40CBcVNvxRcb2I+n5dP7osMeX5/fY9EgT5mcOCzZGQQ==", + "license": "Nasriya License", + "funding": { + "type": "individual", + "url": "https://fund.nasriya.net/" + } + }, "node_modules/@sinclair/typebox": { "version": "0.27.8", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", @@ -1810,6 +1891,24 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/cron-parser": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz", + "integrity": "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==", + "license": "MIT", + "dependencies": { + "luxon": "^3.2.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/cron-time-generator": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/cron-time-generator/-/cron-time-generator-2.0.3.tgz", + "integrity": "sha512-02Ab5okFEMpcDernEwUXY16hLCryUxATAFGYyzyLymin0xl/udC50LkBFHX+qOeXnwXMRyK+uH4doXzUSpOoQA==", + "license": "MIT" + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -3180,6 +3279,12 @@ "dev": true, "license": "MIT" }, + "node_modules/long-timeout": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/long-timeout/-/long-timeout-0.1.1.tgz", + "integrity": "sha512-BFRuQUqc7x2NWxfJBCyUrN8iYUYznzL9JROmRz1gZ6KlOIgmoD+njPVbb+VNn2nGMKggMsK79iUNErillsrx7w==", + "license": "MIT" + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -3190,6 +3295,15 @@ "yallist": "^3.0.2" } }, + "node_modules/luxon": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", + "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/make-dir": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", @@ -3309,6 +3423,15 @@ "dev": true, "license": "MIT" }, + "node_modules/node-cron": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.2.1.tgz", + "integrity": "sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==", + "license": "ISC", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -3323,6 +3446,20 @@ "dev": true, "license": "MIT" }, + "node_modules/node-schedule": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/node-schedule/-/node-schedule-2.1.1.tgz", + "integrity": "sha512-OXdegQq03OmXEjt2hZP33W2YPs/E5BcFQks46+G2gAxs4gHOIVD1u7EqlYLYSKsaIpyKCK9Gbk0ta1/gjRSMRQ==", + "license": "MIT", + "dependencies": { + "cron-parser": "^4.2.0", + "long-timeout": "0.1.1", + "sorted-array-functions": "^1.3.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", @@ -3710,6 +3847,12 @@ "node": ">=8" } }, + "node_modules/sorted-array-functions": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/sorted-array-functions/-/sorted-array-functions-1.3.0.tgz", + "integrity": "sha512-2sqgzeFlid6N4Z2fUQ1cvFmTOLRi/sEDzSQ0OKYchqgoPmQBVyM3959qYx3fpS6Esef80KjmpgPeEr028dP3OA==", + "license": "MIT" + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", diff --git a/package.json b/package.json index e36b71c..7d9adc2 100644 --- a/package.json +++ b/package.json @@ -78,8 +78,10 @@ "typescript": "^5.9.3" }, "dependencies": { + "@nasriya/atomix": "^1.0.23", + "@nasriya/cachify": "^0.0.2-beta", "ejs": "^3.1.10", "ms": "^2.1.3", "tldts": "^7.0.16" } -} \ No newline at end of file +} diff --git a/src/services/routes/assets/staticRoute2.ts b/src/services/routes/assets/staticRoute2.ts new file mode 100644 index 0000000..811a8a8 --- /dev/null +++ b/src/services/routes/assets/staticRoute2.ts @@ -0,0 +1,136 @@ +import atomix from "@nasriya/atomix"; +import cachify from "@nasriya/cachify"; +import { HyperCloudRequestHandler, StaticRouteOptions } from "../../../docs/docs"; +import { HyperCloudRequest, HyperCloudResponse } from "../../../hypercloud"; + +import fs from 'fs'; +import path from 'path'; + +class StaticRoute { + readonly #_root: string; + readonly #_configs = { + caseSensitive: false, + subDomain: '*' as '*' | string, + method: 'GET', + handler: null as unknown as HyperCloudRequestHandler, + dotfiles: 'ignore' as 'allow' | 'ignore' | 'deny', + path: [] as string[], + memoryCache: true + } + + readonly #_utils = Object.freeze({ + initialize: { + dotfiles: (options: StaticRouteOptions) => { + if ('dotfiles' in options) { + if (typeof options.dotfiles !== 'string') { throw new TypeError(`The route's dotfiles options is expecting a string value, but instead got ${typeof options.dotfiles}`) } + const values = ['allow', 'ignore', 'deny']; + if (!values.includes(options.dotfiles)) { throw new RangeError(`The route's dotfiles value that you provided is invalid. Possible values are: ${values.join(', ')}.`) } + + } + }, + path: (options: StaticRouteOptions) => { + if ('path' in options) { + if (typeof options.path !== 'string') { throw new TypeError(`The route's path only accepts a string value, but instead got ${typeof options.path}`) } + if (options.path.length === 0) { throw new SyntaxError(`The rout's path cannot be an empty string`) } + this.#_configs.path = options.path.split('/').filter(i => i.length > 0); + } + }, + subDomain: (options: StaticRouteOptions) => { + if ('subDomain' in options) { + if (typeof options.subDomain !== 'string') { throw new TypeError(`The route's subDomain option is expecting a string value, but instead got ${typeof options.subDomain}`) } + this.#_configs.subDomain = options.subDomain; + } + }, + caseSensitive: (options: StaticRouteOptions) => { + if ('caseSensitive' in options) { + if (typeof options.caseSensitive !== 'boolean') { throw new TypeError(`The Route's caseSensitive option is expecting a boolean value, but instead got ${typeof options.caseSensitive}`) } + this.#_configs.caseSensitive = options.caseSensitive; + } + }, + memoryCache: (options: StaticRouteOptions) => { + if ('memoryCache' in options) { + if (typeof options.memoryCache !== 'boolean') { throw new TypeError(`The Route's memoryCache option is expecting a boolean value, but instead got ${typeof options.memoryCache}`) } + this.#_configs.memoryCache = options.memoryCache; + } + }, + }, + cachePath: async (dir: string, setPromises: Promise[]) => { + const content = fs.readdirSync(dir, { withFileTypes: true }); + for (const item of content) { + if (item.isDirectory()) { + this.#_utils.cachePath(path.join(dir, item.name), setPromises); + } else { + setPromises.push(cachify.files.set(path.join(dir, item.name), { + preload: true, + initiator: 'warmup' + })); + } + } + }, + cacheRoot: async () => { + const stats = fs.statSync(this.#_root); + const promises: Promise[] = []; + if (stats.isDirectory()) { + this.#_utils.cachePath(this.#_root, promises); + } else { + promises.push(cachify.files.set(this.#_root, { + preload: true, + initiator: 'warmup' + })); + } + + await Promise.all(promises); + }, + validate: { + routePath: (request: HyperCloudRequest, response: HyperCloudResponse) => { + + } + } + }) + + constructor(root: string, options: StaticRouteOptions) { + atomix.fs.canAccessSync(root, { permissions: 'Read', throwError: true }); + + this.#_root = root; + this.#_utils.initialize.dotfiles(options); + this.#_utils.initialize.path(options); + this.#_utils.initialize.subDomain(options); + this.#_utils.initialize.caseSensitive(options); + this.#_utils.initialize.memoryCache(options); + + if (this.#_configs.memoryCache) { + this.#_utils.cacheRoot().then(() => { + this.#_configs.handler = (request, response, next) => { + try { + if (request.path.length < this.#_configs.path.length) { + return response.pages.serverError({ + error: new Error(`Request path is shorter than route prefix. Possible framework route-matching bug.`) + }); + } + + // Remove the initial path (the virtual path) and keep the root path + const reqPath = request.path.slice(this.#_configs.path.length, request.path.length); + + const fileRecord = cachify.files.inspect({ filePath: reqPath.join(path.sep) }); + if (!fileRecord) { + return response.pages.notFound(); + } + + response.setHeader('Content-Type', fileRecord); + } catch (error) { + console.error(error); + response.pages.serverError({ error: error as Error }); + } + } + }) + } else { + + } + } + + get subDomain(): '*' | string { return this.#_configs.subDomain } + get caseSensitive() { return this.#_configs.caseSensitive } + get method() { return this.#_configs.method } + get path() { return this.#_configs.path } + get handler() { return this.#_configs.handler } +} \ No newline at end of file From 36e2b0097bcc137f0d1e7ae4dd420388aa722238 Mon Sep 17 00:00:00 2001 From: Ahmad Nasriya Date: Mon, 6 Oct 2025 14:39:41 +0300 Subject: [PATCH 02/13] Integrate Cachify in static routing --- package-lock.json | 20 +- package.json | 4 +- src/data/{currencies.json => currencies.ts} | 8 +- src/data/extensions.json | 272 -------------------- src/data/mimes.json | 57 ---- src/docs/docs.ts | 43 +--- src/services/handler/assets/response.ts | 15 +- src/services/routes/assets/router.ts | 2 +- src/services/routes/assets/staticRoute.ts | 177 ++++++++----- src/services/routes/assets/staticRoute2.ts | 136 ---------- src/services/uploads/assets/handler.ts | 5 +- src/utils/helpers.ts | 13 +- 12 files changed, 157 insertions(+), 595 deletions(-) rename src/data/{currencies.json => currencies.ts} (94%) delete mode 100644 src/data/extensions.json delete mode 100644 src/data/mimes.json delete mode 100644 src/services/routes/assets/staticRoute2.ts diff --git a/package-lock.json b/package-lock.json index 67931cb..2058820 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,9 @@ "license": "NPCL-1", "dependencies": { "@nasriya/atomix": "^1.0.23", - "@nasriya/cachify": "^0.0.2-beta", + "@nasriya/cachify": "^0.0.6-beta", + "@nasriya/mimex": "^0.0.2-beta", + "@nasriya/overwatch": "^1.1.4", "ejs": "^3.1.10", "ms": "^2.1.3", "tldts": "^7.0.16" @@ -974,9 +976,9 @@ } }, "node_modules/@nasriya/cachify": { - "version": "0.0.2-beta", - "resolved": "https://registry.npmjs.org/@nasriya/cachify/-/cachify-0.0.2-beta.tgz", - "integrity": "sha512-bOY/jGfKd9BkfqbD50NiouONFAgxpUvh7XyKAH7f6dYgA/PvVOT5xxmne2ei4HLSd265zk64TGj0uWWhK2Vweg==", + "version": "0.0.6-beta", + "resolved": "https://registry.npmjs.org/@nasriya/cachify/-/cachify-0.0.6-beta.tgz", + "integrity": "sha512-BqivYgxN6pykRSmjnhHaZvXNBhWyyobPqhsbcPSNCcxTWeiUrt9vrtAP5R2mkmew1WjdMXavtVRVKMh2j9mL2A==", "license": "NPCL-1", "dependencies": { "@nasriya/atomix": "^1.0.23", @@ -1016,6 +1018,16 @@ "url": "https://fund.nasriya.net/" } }, + "node_modules/@nasriya/mimex": { + "version": "0.0.2-beta", + "resolved": "https://registry.npmjs.org/@nasriya/mimex/-/mimex-0.0.2-beta.tgz", + "integrity": "sha512-Z2/dqrC5T096nF5zE8tyqfmzmZ9ne1OzqeAhoRRYx02079UJDjFVi9jUffZFu5h/IWpbBVcJk/+YLjyHh3H0XA==", + "license": "NOL-1", + "funding": { + "type": "individual", + "url": "https://fund.nasriya.net/" + } + }, "node_modules/@nasriya/overwatch": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@nasriya/overwatch/-/overwatch-1.1.4.tgz", diff --git a/package.json b/package.json index 7d9adc2..adac730 100644 --- a/package.json +++ b/package.json @@ -79,7 +79,9 @@ }, "dependencies": { "@nasriya/atomix": "^1.0.23", - "@nasriya/cachify": "^0.0.2-beta", + "@nasriya/cachify": "^0.0.6-beta", + "@nasriya/mimex": "^0.0.2-beta", + "@nasriya/overwatch": "^1.1.4", "ejs": "^3.1.10", "ms": "^2.1.3", "tldts": "^7.0.16" diff --git a/src/data/currencies.json b/src/data/currencies.ts similarity index 94% rename from src/data/currencies.json rename to src/data/currencies.ts index 3a94da4..c503d24 100644 --- a/src/data/currencies.json +++ b/src/data/currencies.ts @@ -1,4 +1,4 @@ -[ +const currencies = [ "AED", "AFN", "ALL", @@ -159,4 +159,8 @@ "ZAR", "ZMW", "ZWD" -] \ No newline at end of file +] as const; + +export type Currency = typeof currencies[number]; + +export default currencies; \ No newline at end of file diff --git a/src/data/extensions.json b/src/data/extensions.json deleted file mode 100644 index 0c7588d..0000000 --- a/src/data/extensions.json +++ /dev/null @@ -1,272 +0,0 @@ -[ - { - "extension": ".webp", - "description": "Web Picture", - "mime": "image/webp" - }, - { - "extension": ".aac", - "description": "AAC audio", - "mime": "audio/aac" - }, - { - "extension": ".abw", - "description": "AbiWord document", - "mime": "application/x-abiword" - }, - { - "extension": ".arc", - "description": "Archive document (multiple files embedded)", - "mime": "application/x-freearc" - }, - { - "extension": ".avif", - "description": "AVIF image", - "mime": "image/avif" - }, - { - "extension": ".avi", - "description": "AVI: Audio Video Interleave", - "mime": "video/x-msvideo" - }, - { - "extension": ".azw", - "description": "Amazon Kindle eBook format", - "mime": "application/vnd.amazon.ebook" - }, - { - "extension": ".bin", - "description": "Any kind of binary data", - "mime": "application/octet-stream" - }, - { - "extension": ".bmp", - "description": "Windows OS/2 Bitmap Graphics", - "mime": "image/bmp" - }, - { - "extension": ".bz", - "description": "BZip archive", - "mime": "application/x-bzip" - }, - { - "extension": ".bz2", - "description": "BZip2 archive", - "mime": "application/x-bzip2" - }, - { - "extension": ".cda", - "description": "CD audio", - "mime": "application/x-cdf" - }, - { - "extension": ".csh", - "description": "C-Shell script", - "mime": "application/x-csh" - }, - { - "extension": ".css", - "description": "Cascading Style Sheets (CSS)", - "mime": "text/css" - }, - { - "extension": ".csv", - "description": "Comma-separated values (CSV)", - "mime": "text/csv" - }, - { - "extension": ".doc", - "description": "Microsoft Word", - "mime": "application/msword" - }, - { - "extension": ".docx", - "description": "Microsoft Word (OpenXML)", - "mime": "application/vnd.openxmlformats-officedocument.wordprocessingml.document" - }, - { - "extension": ".eot", - "description": "MS Embedded OpenType fonts", - "mime": "application/vnd.ms-fontobject" - }, - { - "extension": ".epub", - "description": "Electronic publication (EPUB)", - "mime": "application/epub+zip" - }, - { - "extension": ".gz", - "description": "GZip Compressed Archive", - "mime": "application/gzip" - }, - { - "extension": ".gif", - "description": "Graphics Interchange Format (GIF)", - "mime": "image/gif" - }, - { - "extension": ".htm, .html", - "description": "HyperText Markup Language (HTML)", - "mime": "text/html" - }, - { - "extension": ".ico", - "description": "Icon format", - "mime": "image/vnd.microsoft.icon" - }, - { - "extension": ".ics", - "description": "iCalendar format", - "mime": "text/calendar" - }, - { - "extension": ".jar", - "description": "Java Archive (JAR)", - "mime": "application/java-archive" - }, - { - "extension": ".jpeg, .jpg", - "description": "JPEG images", - "mime": "image/jpeg" - }, - { - "extension": ".js", - "description": "JavaScript", - "mime": "text/javascript" - }, - { - "extension": ".json", - "description": "JSON format", - "mime": "application/json" - }, - { - "extension": ".jsonld", - "description": "JSON-LD format", - "mime": "application/ld+json" - }, - { - "extension": ".mid, .midi", - "description": "Musical Instrument Digital Interface (MIDI)", - "mime": "audio/midi, audio/x-midi" - }, - { - "extension": ".mjs", - "description": "JavaScript module", - "mime": "text/javascript" - }, - { - "extension": ".mp3", - "description": "MP3 audio", - "mime": "audio/mpeg" - }, - { - "extension": ".mp4", - "description": "MP4 video", - "mime": "video/mp4" - }, - { - "extension": ".mpeg", - "description": "MPEG Video", - "mime": "video/mpeg" - }, - { - "extension": ".mpkg", - "description": "Apple Installer Package", - "mime": "application/vnd.apple.installer+xml" - }, - { - "extension": ".odp", - "description": "OpenDocument presentation document", - "mime": "application/vnd.oasis.opendocument.presentation" - }, - { - "extension": ".ods", - "description": "OpenDocument spreadsheet document", - "mime": "application/vnd.oasis.opendocument.spreadsheet" - }, - { - "extension": ".odt", - "description": "OpenDocument text document", - "mime": "application/vnd.oasis.opendocument.text" - }, - { - "extension": ".oga", - "description": "OGG audio", - "mime": "audio/ogg" - }, - { - "extension": ".ogv", - "description": "OGG video", - "mime": "video/ogg" - }, - { - "extension": ".ogx", - "description": "OGG", - "mime": "application/ogg" - }, - { - "extension": ".opus", - "description": "Opus audio", - "mime": "audio/opus" - }, - { - "extension": ".otf", - "description": "OpenType font", - "mime": "font/otf" - }, - { - "extension": ".png", - "description": "Portable Network Graphics", - "mime": "image/png" - }, - { - "extension": ".pdf", - "description": "Adobe Portable Document Format (PDF)", - "mime": "application/pdf" - }, - { - "extension": ".php", - "description": "Hypertext Preprocessor (Personal Home Page)", - "mime": "application/x-httpd-php" - }, - { - "extension": ".ppt", - "description": "Microsoft PowerPoint", - "mime": "application/vnd.ms-powerpoint" - }, - { - "extension": ".pptx", - "description": "Microsoft PowerPoint (OpenXML)", - "mime": "application/vnd.openxmlformats-officedocument.presentationml.presentation" - }, - { - "extension": ".rar", - "description": "RAR archive", - "mime": "application/vnd.rar" - }, - { - "extension": ".rtf", - "description": "Rich Text Format (RTF)", - "mime": "application/rtf" - }, - { - "extension": ".sh", - "description": "Bourne shell script", - "mime": "application/x-sh" - }, - { - "extension": ".svg", - "description": "Scalable Vector Graphics (SVG)", - "mime": "image/svg+xml" - }, - { - "extension": ".tar", - "description": "Tape Archive (TAR)", - "mime": "application/x-tar" - }, - { - "extension": ".tif, .tiff", - "description": "Tagged Image File Format (TIFF)", - "mime": "image/tiff" - } -] \ No newline at end of file diff --git a/src/data/mimes.json b/src/data/mimes.json deleted file mode 100644 index 91a0c08..0000000 --- a/src/data/mimes.json +++ /dev/null @@ -1,57 +0,0 @@ -[ - "audio/aac", - "application/x-abiword", - "application/x-freearc", - "image/avif", - "video/x-msvideo", - "application/vnd.amazon.ebook", - "application/octet-stream", - "image/bmp", - "application/x-bzip", - "application/x-bzip2", - "application/x-cdf", - "application/x-csh", - "text/css", - "text/csv", - "application/msword", - "application/vnd.openxmlformats-officedocument.wordprocessingml.document", - "application/vnd.ms-fontobject", - "application/epub+zip", - "application/gzip", - "image/gif", - "text/html", - "image/vnd.microsoft.icon", - "text/calendar", - "application/java-archive", - "image/jpeg", - "text/javascript", - "text/plain", - "application/json", - "application/ld+json", - "audio/midi, audio/x-midi", - "text/javascript", - "audio/mpeg", - "video/mp4", - "video/mpeg", - "application/vnd.apple.installer+xml", - "application/vnd.apple.mpegurl", - "application/vnd.oasis.opendocument.presentation", - "application/vnd.oasis.opendocument.spreadsheet", - "application/vnd.oasis.opendocument.text", - "audio/ogg", - "video/ogg", - "application/ogg", - "audio/opus", - "font/otf", - "image/png", - "application/pdf", - "application/x-httpd-php", - "application/vnd.ms-powerpoint", - "application/vnd.openxmlformats-officedocument.presentationml.presentation", - "application/vnd.rar", - "application/rtf", - "application/x-sh", - "image/svg+xml", - "application/x-tar", - "image/tiff" -] \ No newline at end of file diff --git a/src/docs/docs.ts b/src/docs/docs.ts index 172768b..4d1c649 100644 --- a/src/docs/docs.ts +++ b/src/docs/docs.ts @@ -6,6 +6,10 @@ import HyperCloudResponse from '../services/handler/assets/response'; import HyperCloudServer from '../server'; import HTTPError from '../utils/errors/HTTPError'; import ms from 'ms'; +import { Mime } from '@nasriya/mimex'; +import { Currency } from '../data/currencies'; + +export { Currency } from '../data/currencies'; /**The website's possible color schemes */ export type ColorScheme = 'Dark' | 'Light'; @@ -36,42 +40,9 @@ export type PageRenderingCacheAsset = Exclude; export type DeepReadonly = { readonly [P in keyof T]: DeepReadonly; }; -/**A currency code */ -export type Currency = - | 'AED' | 'AFN' | 'ALL' | 'AMD' | 'ANG' | 'AOA' | 'ARS' | 'AUD' | 'AWG' | 'AZN' - | 'BAM' | 'BBD' | 'BDT' | 'BGN' | 'BHD' | 'BIF' | 'BMD' | 'BND' | 'BOB' | 'BRL' - | 'BSD' | 'BTN' | 'BWP' | 'BYN' | 'BZD' | 'CAD' | 'CDF' | 'CHF' | 'CLP' | 'CNY' - | 'COP' | 'CRC' | 'CUP' | 'CVE' | 'CZK' | 'DJF' | 'DKK' | 'DOP' | 'DZD' | 'EGP' - | 'ERN' | 'ETB' | 'EUR' | 'FJD' | 'FKP' | 'FOK' | 'GBP' | 'GEL' | 'GGP' | 'GHS' - | 'GIP' | 'GMD' | 'GNF' | 'GTQ' | 'GYD' | 'HKD' | 'HNL' | 'HRK' | 'HTG' | 'HUF' - | 'IDR' | 'ILS' | 'IMP' | 'INR' | 'IQD' | 'IRR' | 'ISK' | 'JEP' | 'JMD' | 'JOD' - | 'JPY' | 'KES' | 'KGS' | 'KHR' | 'KID' | 'KMF' | 'KRW' | 'KWD' | 'KYD' | 'KZT' - | 'LAK' | 'LBP' | 'LKR' | 'LRD' | 'LSL' | 'LYD' | 'MAD' | 'MDL' | 'MGA' | 'MKD' - | 'MMK' | 'MNT' | 'MOP' | 'MRU' | 'MUR' | 'MVR' | 'MWK' | 'MXN' | 'MYR' | 'MZN' - | 'NAD' | 'NGN' | 'NIO' | 'NOK' | 'NPR' | 'NZD' | 'OMR' | 'PAB' | 'PEN' | 'PGK' - | 'PHP' | 'PKR' | 'PLN' | 'PYG' | 'QAR' | 'RON' | 'RSD' | 'RUB' | 'RWF' | 'SAR' - | 'SBD' | 'SCR' | 'SDG' | 'SEK' | 'SGD' | 'SHP' | 'SLL' | 'SOS' | 'SPL' | 'SRD' - | 'STN' | 'SYP' | 'SZL' | 'THB' | 'TJS' | 'TMT' | 'TND' | 'TOP' | 'TRY' | 'TTD' - | 'TVD' | 'TWD' | 'TZS' | 'UAH' | 'UGX' | 'USD' | 'UYU' | 'UZS' | 'VES' | 'VND' - | 'VUV' | 'WST' | 'XAF' | 'XCD' | 'XOF' | 'XPF' | 'YER' | 'ZAR' | 'ZMW' | 'ZWD'; /**These mime types are used when sending/receiving files */ -export type MimeType = - | "audio/aac" | "application/x-abiword" | "application/x-freearc" | "image/avif" - | "video/x-msvideo" | "application/vnd.amazon.ebook" | "application/octet-stream" - | "image/bmp" | "application/x-bzip" | "application/x-bzip2" | "application/x-cdf" - | "application/x-csh" | "text/calendar" | "text/css" | "text/plain" | "text/csv" | "application/msword" - | "application/vnd.openxmlformats-officedocument.wordprocessingml.document" - | "application/vnd.ms-fontobject" | "application/epub+zip" | "application/gzip" - | "image/gif" | "text/html" | "image/vnd.microsoft.icon" | "text/calendar" | "application/java-archive" - | "image/jpeg" | "text/javascript" | "application/json" | "application/ld+json" | "audio/midi" - | "audio/x-midi" | "audio/mpeg" | "video/mp4" | "video/mpeg" | "application/vnd.apple.installer+xml" - | "application/vnd.oasis.opendocument.presentation" | "application/vnd.oasis.opendocument.spreadsheet" - | "application/vnd.oasis.opendocument.text" | "audio/ogg" | "video/ogg" | "application/ogg" - | "audio/opus" | "font/otf" | "image/png" | "application/pdf" | "application/x-httpd-php" - | "application/vnd.ms-powerpoint" | "application/vnd.openxmlformats-officedocument.presentationml.presentation" - | "application/vnd.rar" | "application/rtf" | "application/x-sh" | "image/svg+xml" - | "application/x-tar" | "image/tiff"; +export type MimeType = Mime | 'text/plain'; export type OnRenderHandler = (locals: Record | any, include: (name: string, locals: Record) => Promise, lang: string) => string | Promise; @@ -671,15 +642,13 @@ export interface NotFoundResponseOptions { export interface StaticRouteOptions { /** The route path URL. */ - path: string; + path?: string; /** Option for serving dotfiles. Possible values are `allow`, `deny`, `ignore`. Default: `ignore`. */ dotfiles?: 'allow' | 'ignore' | 'deny'; /** The host's `subDomain` from HyperCloudRequest.subDomain. Default: `null`. */ subDomain?: string; /** This will match only if the `path` exactly matches the HyperCloudRequest.path */ caseSensitive?: boolean; - /** Whether to cache the file in memory. Default: `true` */ - memoryCache?: boolean; } export interface CookieOptions { diff --git a/src/services/handler/assets/response.ts b/src/services/handler/assets/response.ts index f865c5b..ec6bd0e 100644 --- a/src/services/handler/assets/response.ts +++ b/src/services/handler/assets/response.ts @@ -1,3 +1,4 @@ +import mimex, { mimes, Mime } from '@nasriya/mimex'; import path from 'path'; import helpers from '../../../utils/helpers'; import HyperCloudRequest from './request'; @@ -15,11 +16,6 @@ import net from 'net'; import tls from 'tls'; import { NotFoundResponseOptions, ForbiddenAndUnauthorizedOptions, ServerErrorOptions, RedirectCode, DownloadFileOptions, SendFileOptions, MimeType, ExtensionData, NextFunction, PageRenderingOptions } from '../../../docs/docs'; -const _dirname = __dirname; - -const mimes = helpers.loadJSON(path.resolve(_dirname, '../../../data/mimes.json')) as string[]; -const extensions = helpers.loadJSON(path.resolve(_dirname, '../../../data/extensions.json')) as ExtensionData[]; - interface ResponseEndOptions { data?: string | Uint8Array; encoding?: BufferEncoding; @@ -675,7 +671,8 @@ export class HyperCloudResponse { // Preparing the mime-type const exts = fileName.split('.').filter(i => i.length > 0); const extension = `.${exts[exts.length - 1]}`; - const mime = extensions.find(i => i.extension.includes(extension))?.mime as string; + const extMimes = mimex.getMimes(extension); + const mime = extMimes ? extMimes[0] : 'application/octet-stream'; // Check if the download option is triggered or not if (options && 'download' in options) { @@ -771,7 +768,7 @@ export class HyperCloudResponse { let type: MimeType = null as unknown as MimeType; if (typeof data === 'string') { - if (typeof contentType === 'string' && mimes.includes(contentType.toLowerCase())) { + if (typeof contentType === 'string' && mimes.includes(contentType.toLowerCase() as Mime)) { type = contentType; } else if (helpers.is.html(data)) { type = 'text/html'; @@ -779,14 +776,14 @@ export class HyperCloudResponse { type = 'text/plain'; } } else if (Buffer.isBuffer(data)) { - if (typeof contentType === 'string' && mimes.includes(contentType.toLowerCase())) { + if (typeof contentType === 'string' && mimes.includes(contentType.toLowerCase() as Mime)) { type = contentType; } else { type = 'application/octet-stream'; } } else if (Array.isArray(data) || (typeof data === 'object' && data !== null)) { data = JSON.stringify(data); - if (typeof contentType === 'string' && mimes.includes(contentType.toLowerCase())) { + if (typeof contentType === 'string' && mimes.includes(contentType.toLowerCase() as Mime)) { type = contentType; } else { type = 'application/json'; diff --git a/src/services/routes/assets/router.ts b/src/services/routes/assets/router.ts index c348d10..2c8d161 100644 --- a/src/services/routes/assets/router.ts +++ b/src/services/routes/assets/router.ts @@ -32,7 +32,7 @@ export class Router { createStaticRoute: (root: string, options?: StaticRouteOptions) => { const caseSensitive = options && 'caseSensitive' in options ? options.caseSensitive : this.#_defaults.caseSensitive; const subDomain = options && 'subDomain' in options ? options.subDomain : this.#_defaults.subDomain; - const userPath = options && 'path' in options ? options.path : '/'; + const userPath = options && 'path' in options && typeof options.path === 'string' ? options.path : '/'; const path = userPath.startsWith('/') ? userPath : `/${userPath}`; const dotfiles = options && 'dotfiles' in options ? options.dotfiles : 'ignore'; diff --git a/src/services/routes/assets/staticRoute.ts b/src/services/routes/assets/staticRoute.ts index d2d414d..3ce36ca 100644 --- a/src/services/routes/assets/staticRoute.ts +++ b/src/services/routes/assets/staticRoute.ts @@ -1,9 +1,14 @@ -import { StaticRouteOptions, HyperCloudRequestHandler } from '../../../docs/docs'; -import helpers from '../../../utils/helpers'; +import atomix from "@nasriya/atomix"; +import cachify from "@nasriya/cachify"; +import mimex from "@nasriya/mimex"; +import overwatch from "@nasriya/overwatch"; +import { HyperCloudRequestHandler, MimeType, StaticRouteOptions } from "../../../docs/docs"; import fs from 'fs'; import path from 'path'; +const CACHE_SCOPE = 'hypercloud_static_routes' as const; + class StaticRoute { readonly #_root: string; readonly #_configs = { @@ -13,7 +18,6 @@ class StaticRoute { handler: null as unknown as HyperCloudRequestHandler, dotfiles: 'ignore' as 'allow' | 'ignore' | 'deny', path: [] as string[], - memoryCache: true } readonly #_utils = Object.freeze({ @@ -45,17 +49,60 @@ class StaticRoute { this.#_configs.caseSensitive = options.caseSensitive; } } + }, + cache: { + createRecord: async (filePath: string) => { + const fileName = path.basename(filePath); + if (fileName.startsWith('.') && this.#_configs.dotfiles !== 'allow') { + return; + } + + return cachify.files.set(filePath, { scope: CACHE_SCOPE, ttl: 0 }); + }, + path: (dir: string, setPromises: Promise[]) => { + const content = fs.readdirSync(dir, { withFileTypes: true }); + for (const item of content) { + const contentPath = path.join(dir, item.name); + if (item.isDirectory()) { + this.#_utils.cache.path(contentPath, setPromises); + } else { + const createPromise = this.#_utils.cache.createRecord(contentPath) + setPromises.push(createPromise); + } + } + }, + route: async () => { + const stats = fs.statSync(this.#_root); + const promises: Promise[] = []; + + if (stats.isDirectory()) { + this.#_utils.cache.path(this.#_root, promises); + } else { + const createPromise = this.#_utils.cache.createRecord(this.#_root); + promises.push(createPromise); + } + + await Promise.all(promises); + } + }, + getFileMime: (filePath: string) => { + const ext = path.extname(filePath); + const mimes = mimex.getMimes(ext); + return mimes ? mimes[0] : 'text/plain'; + }, + parseFile: (_reqPath: string[]) => { + // Remove the initial path (the virtual path) and keep the root path + const reqPath = _reqPath.slice(this.#_configs.path.length, _reqPath.length).join(path.sep); + const filePath = path.join(this.#_root, reqPath); + const fileName = path.basename(filePath); + const mimeType = this.#_utils.getFileMime(filePath) as MimeType; + + return { path: filePath, name: fileName, mimeType } } }) constructor(root: string, options: StaticRouteOptions) { - const validity = helpers.checkPathAccessibility(root); - if (validity.valid !== true) { - const errors = validity.errors; - if (errors.notString) { throw new Error(`The root directory should be a string value, instead got ${typeof root}`) } - if (errors.doesntExist) { throw new Error(`The provided root directory (${root}) doesn't exist.`) } - if (errors.notAccessible) { throw new Error(`Unable to access (${root}): read permission denied.`) } - } + atomix.fs.canAccessSync(root, { permissions: 'Read', throwError: true }); this.#_root = root; this.#_utils.initialize.dotfiles(options); @@ -63,80 +110,84 @@ class StaticRoute { this.#_utils.initialize.subDomain(options); this.#_utils.initialize.caseSensitive(options); - this.#_configs.handler = (request, response, next) => { - try { - if (request.path.length < this.#_configs.path.length) { return response.status(500).end({ data: `Internal server error (500).\n\nIf you're a visitor please wait a few minutes.` }) } - // Remove the initial path (the virtual path) and keep the root path - const reqPath = request.path.slice(this.#_configs.path.length, request.path.length); + this.#_utils.cache.route().then(() => { + this.#_configs.handler = async (request, response, next) => { + try { + if (request.path.length < this.#_configs.path.length) { + return response.pages.serverError({ + error: new Error(`Request path is shorter than route prefix. Possible framework route-matching bug.`) + }); + } - for (let i = 0; i < reqPath.length; i++) { - const pathSegment = reqPath[i]; - const isLast = i + 1 >= reqPath.length; + // Parse the file from the request + const reqFile = this.#_utils.parseFile(request.path); - if (pathSegment.startsWith('.')) { + // Check the file against the policy + if (reqFile.name.startsWith('.')) { if (this.#_configs.dotfiles === 'ignore') { return next() } if (this.#_configs.dotfiles === 'deny') { return response.pages.unauthorized() } } - if (!isLast) { continue } + // Check if the file exists + const fileRecord = cachify.files.inspect({ + filePath: reqFile.path, + scope: CACHE_SCOPE, + caseSensitive: this.#_configs.caseSensitive + }); - const copy = [...reqPath]; // Create a copy of the request path array - copy.pop(); // Removes the last item (resource name) from the copy array + if (!fileRecord) { return next(); } - // Resolve the folder path from the root directory and the request path - const folder = path.resolve(path.join(this.#_root, ...copy)); - // Check folder path validity - const validity = helpers.checkPathAccessibility(folder); - if (validity.valid !== true) { return next() } + // Define headers values + const modifiedDate = new Date(fileRecord.file.stats.mtime); + const eTag = fileRecord.file.eTag; - // Check if the path is an actual directory - const folderStats = fs.statSync(folder); - if (!folderStats.isDirectory()) { return next() } + // Check for conditional headers + const ifNoneMatch = request.headers['if-none-match']; + const ifModifiedSince = request.headers['if-modified-since']; - const filename = pathSegment; - // Read the content of the folder - const content = fs.readdirSync(folder, { withFileTypes: true }); - - const file = content.find(i => { - if (this.#_configs.caseSensitive) { - if (i.name === filename) { return true } - } else { - if (i.name.toLowerCase() === filename.toLowerCase()) { return true } - } + if (ifNoneMatch || ifModifiedSince) { + // Normalize ETag (strip quotes if present) + const normalizedIfNoneMatch = ifNoneMatch?.replace(/(^"|"$)/g, ''); - return false - }) + // Validate modification date + const clientDate = ifModifiedSince ? new Date(ifModifiedSince) : null; + const isDateValid = clientDate instanceof Date && !isNaN(clientDate.getTime()); - if (!file || !file.isFile()) { return next() } + // Check for matches + const isEtagMatch = normalizedIfNoneMatch === eTag; + const isDateMatch = isDateValid && clientDate >= modifiedDate; - // Check the eTag value if it does exist - const eTagsPath = path.join(folder, 'eTags.json'); - const eTagValidity = helpers.checkPathAccessibility(eTagsPath); - if (eTagValidity.valid) { - const eTags = JSON.parse(fs.readFileSync(eTagsPath, { encoding: 'utf-8' })) - if (helpers.is.realObject(eTags)) { - if (file.name in eTags) { response.setHeader('etag', eTags[file.name]) } + // Return 304 if resource not modified + if (isEtagMatch || isDateMatch) { + return response.status(304).end(); } } - const filePath = path.join(folder, file.name); - return response.sendFile(filePath, { - lastModified: true, - acceptRanges: true, - cacheControl: true, - maxAge: '3 days' - }) + response.setHeader('etag', eTag); + response.setHeader('last-modified', modifiedDate.toUTCString()); + + const readResponse = (await cachify.files.read({ + key: fileRecord.key, + scope: CACHE_SCOPE, + caseSensitive: this.#_configs.caseSensitive + }))!; + + response.setHeader('Cachify-Status', readResponse.status) + response.send(readResponse.content, reqFile.mimeType); + } catch (error) { + console.error(error); + response.pages.serverError({ error: error as Error }); } + } + }) - next(); - } catch (error) { - console.error(error); - response.status(500).json({ type: 'server_error', code: 500, href: request.href, message: "An internal server error occurred." }) + overwatch.watch(this.#_root, { + onAdd: (event) => { + this.#_utils.cache.createRecord(event.path); } - } + }) } - get subDomain(): '*' | string { return this.#_configs.subDomain } get caseSensitive() { return this.#_configs.caseSensitive } get method() { return this.#_configs.method } diff --git a/src/services/routes/assets/staticRoute2.ts b/src/services/routes/assets/staticRoute2.ts deleted file mode 100644 index 811a8a8..0000000 --- a/src/services/routes/assets/staticRoute2.ts +++ /dev/null @@ -1,136 +0,0 @@ -import atomix from "@nasriya/atomix"; -import cachify from "@nasriya/cachify"; -import { HyperCloudRequestHandler, StaticRouteOptions } from "../../../docs/docs"; -import { HyperCloudRequest, HyperCloudResponse } from "../../../hypercloud"; - -import fs from 'fs'; -import path from 'path'; - -class StaticRoute { - readonly #_root: string; - readonly #_configs = { - caseSensitive: false, - subDomain: '*' as '*' | string, - method: 'GET', - handler: null as unknown as HyperCloudRequestHandler, - dotfiles: 'ignore' as 'allow' | 'ignore' | 'deny', - path: [] as string[], - memoryCache: true - } - - readonly #_utils = Object.freeze({ - initialize: { - dotfiles: (options: StaticRouteOptions) => { - if ('dotfiles' in options) { - if (typeof options.dotfiles !== 'string') { throw new TypeError(`The route's dotfiles options is expecting a string value, but instead got ${typeof options.dotfiles}`) } - const values = ['allow', 'ignore', 'deny']; - if (!values.includes(options.dotfiles)) { throw new RangeError(`The route's dotfiles value that you provided is invalid. Possible values are: ${values.join(', ')}.`) } - - } - }, - path: (options: StaticRouteOptions) => { - if ('path' in options) { - if (typeof options.path !== 'string') { throw new TypeError(`The route's path only accepts a string value, but instead got ${typeof options.path}`) } - if (options.path.length === 0) { throw new SyntaxError(`The rout's path cannot be an empty string`) } - this.#_configs.path = options.path.split('/').filter(i => i.length > 0); - } - }, - subDomain: (options: StaticRouteOptions) => { - if ('subDomain' in options) { - if (typeof options.subDomain !== 'string') { throw new TypeError(`The route's subDomain option is expecting a string value, but instead got ${typeof options.subDomain}`) } - this.#_configs.subDomain = options.subDomain; - } - }, - caseSensitive: (options: StaticRouteOptions) => { - if ('caseSensitive' in options) { - if (typeof options.caseSensitive !== 'boolean') { throw new TypeError(`The Route's caseSensitive option is expecting a boolean value, but instead got ${typeof options.caseSensitive}`) } - this.#_configs.caseSensitive = options.caseSensitive; - } - }, - memoryCache: (options: StaticRouteOptions) => { - if ('memoryCache' in options) { - if (typeof options.memoryCache !== 'boolean') { throw new TypeError(`The Route's memoryCache option is expecting a boolean value, but instead got ${typeof options.memoryCache}`) } - this.#_configs.memoryCache = options.memoryCache; - } - }, - }, - cachePath: async (dir: string, setPromises: Promise[]) => { - const content = fs.readdirSync(dir, { withFileTypes: true }); - for (const item of content) { - if (item.isDirectory()) { - this.#_utils.cachePath(path.join(dir, item.name), setPromises); - } else { - setPromises.push(cachify.files.set(path.join(dir, item.name), { - preload: true, - initiator: 'warmup' - })); - } - } - }, - cacheRoot: async () => { - const stats = fs.statSync(this.#_root); - const promises: Promise[] = []; - if (stats.isDirectory()) { - this.#_utils.cachePath(this.#_root, promises); - } else { - promises.push(cachify.files.set(this.#_root, { - preload: true, - initiator: 'warmup' - })); - } - - await Promise.all(promises); - }, - validate: { - routePath: (request: HyperCloudRequest, response: HyperCloudResponse) => { - - } - } - }) - - constructor(root: string, options: StaticRouteOptions) { - atomix.fs.canAccessSync(root, { permissions: 'Read', throwError: true }); - - this.#_root = root; - this.#_utils.initialize.dotfiles(options); - this.#_utils.initialize.path(options); - this.#_utils.initialize.subDomain(options); - this.#_utils.initialize.caseSensitive(options); - this.#_utils.initialize.memoryCache(options); - - if (this.#_configs.memoryCache) { - this.#_utils.cacheRoot().then(() => { - this.#_configs.handler = (request, response, next) => { - try { - if (request.path.length < this.#_configs.path.length) { - return response.pages.serverError({ - error: new Error(`Request path is shorter than route prefix. Possible framework route-matching bug.`) - }); - } - - // Remove the initial path (the virtual path) and keep the root path - const reqPath = request.path.slice(this.#_configs.path.length, request.path.length); - - const fileRecord = cachify.files.inspect({ filePath: reqPath.join(path.sep) }); - if (!fileRecord) { - return response.pages.notFound(); - } - - response.setHeader('Content-Type', fileRecord); - } catch (error) { - console.error(error); - response.pages.serverError({ error: error as Error }); - } - } - }) - } else { - - } - } - - get subDomain(): '*' | string { return this.#_configs.subDomain } - get caseSensitive() { return this.#_configs.caseSensitive } - get method() { return this.#_configs.method } - get path() { return this.#_configs.path } - get handler() { return this.#_configs.handler } -} \ No newline at end of file diff --git a/src/services/uploads/assets/handler.ts b/src/services/uploads/assets/handler.ts index 5170012..8eddbe5 100644 --- a/src/services/uploads/assets/handler.ts +++ b/src/services/uploads/assets/handler.ts @@ -7,8 +7,7 @@ import RequestBody from "../../handler/assets/requestBody"; import fs from "fs"; import helpers from "../../../utils/helpers"; import path from "path"; - -const mimes: MimeType[] = JSON.parse(fs.readFileSync(path.join(__dirname, '../../../data/mimes.json'), { encoding: 'utf8' })); +import mimex from "@nasriya/mimex"; class UploadHandler { #_currentFile: UploadedMemoryFile | UploadedStorageFile | undefined; @@ -111,7 +110,7 @@ class UploadHandler { throw new Error(`The header is invalid`) } - if (!mimes.includes(details.mime)) { + if (!mimex.isMime(details.mime)) { throw new Error(`The request mime type is not supported: ${details.mime}`) } diff --git a/src/utils/helpers.ts b/src/utils/helpers.ts index eff5989..ba5d899 100644 --- a/src/utils/helpers.ts +++ b/src/utils/helpers.ts @@ -1,17 +1,10 @@ import fs from 'fs'; import path from 'path'; import crypto from 'crypto'; -import { DeepReadonly, MimeType, RandomOptions } from '../docs/docs'; - -const _dirname = __dirname; +import currencies from '../data/currencies'; +import { Currency, DeepReadonly, MimeType, RandomOptions } from '../docs/docs'; class Helpers { - #_currencies: string[] = []; - - constructor() { - this.#_currencies = this.loadJSON(path.resolve(_dirname, '../data/currencies.json')) as string[]; - } - /** * Load a `JSON` file * @param filePath The absolute path of the `JSON` file @@ -124,7 +117,7 @@ class Helpers { currency: (currency: string): boolean => { if (typeof currency === 'string') { currency = currency.toUpperCase(); - return this.#_currencies.includes(currency); + return currencies.includes(currency as Currency); } else { return false; } From 5f30e5e1c99fb6a8259ab9c579ea001a6b534cbc Mon Sep 17 00:00:00 2001 From: Ahmad Nasriya Date: Thu, 6 Nov 2025 15:48:18 +0200 Subject: [PATCH 03/13] feat(cache): implement static route caching with Cachify integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrates Cachify into HyperCloud to handle static file caching. Static routes are now lazily loaded into memory on first request, served from cache thereafter, and automatically refreshed on file changes via Overwatch — eliminating the need to restart the server for content updates. --- .gitignore | 1 + package-lock.json | 75 ++++++---- package.json | 11 +- src/services/cache/routeCache.ts | 4 + src/services/routes/assets/router.ts | 16 ++- src/services/routes/assets/staticRoute.ts | 159 ++++++++++++---------- 6 files changed, 155 insertions(+), 111 deletions(-) create mode 100644 src/services/cache/routeCache.ts diff --git a/.gitignore b/.gitignore index f62b7b2..ae8d5e2 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ node_modules/ .vscode/ .history +.dev # Ignore build folder build/ diff --git a/package-lock.json b/package-lock.json index 2058820..b6d1e51 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,20 +10,20 @@ "license": "NPCL-1", "dependencies": { "@nasriya/atomix": "^1.0.23", - "@nasriya/cachify": "^0.0.6-beta", + "@nasriya/cachify": "^0.0.9-beta", "@nasriya/mimex": "^0.0.2-beta", "@nasriya/overwatch": "^1.1.4", "ejs": "^3.1.10", "ms": "^2.1.3", - "tldts": "^7.0.16" + "tldts": "^7.0.17" }, "devDependencies": { "@nasriya/postbuild": "^1.1.5", "@types/ejs": "^3.1.5", "@types/jest": "^30.0.0", "@types/ms": "^2.1.0", - "@types/node": "^24.6.2", - "ts-jest": "^29.4.4", + "@types/node": "^24.10.0", + "ts-jest": "^29.4.5", "typescript": "^5.9.3" }, "funding": { @@ -976,9 +976,9 @@ } }, "node_modules/@nasriya/cachify": { - "version": "0.0.6-beta", - "resolved": "https://registry.npmjs.org/@nasriya/cachify/-/cachify-0.0.6-beta.tgz", - "integrity": "sha512-BqivYgxN6pykRSmjnhHaZvXNBhWyyobPqhsbcPSNCcxTWeiUrt9vrtAP5R2mkmew1WjdMXavtVRVKMh2j9mL2A==", + "version": "0.0.9-beta", + "resolved": "https://registry.npmjs.org/@nasriya/cachify/-/cachify-0.0.9-beta.tgz", + "integrity": "sha512-+/5VUAUGSNPe+ghho/98p+n27JRbWHQ8jqmVTjOUSqkN3L2RU6hKwA9ZKVbd4smwNaaxJeI9ZdVBLNT5eyWQMA==", "license": "NPCL-1", "dependencies": { "@nasriya/atomix": "^1.0.23", @@ -991,8 +991,8 @@ "url": "https://fund.nasriya.net/" }, "peerDependencies": { - "@aws-sdk/client-s3": "^3.876.0", - "@redis/client": "^5.8.2" + "@aws-sdk/client-s3": "^3.917.0", + "@redis/client": "^5.9.0" }, "peerDependenciesMeta": { "@aws-sdk/client-s3": { @@ -1414,13 +1414,13 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "24.6.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.6.2.tgz", - "integrity": "sha512-d2L25Y4j+W3ZlNAeMKcy7yDsK425ibcAOO2t7aPTz6gNMH0z2GThtwENCDc0d/Pw9wgyRqE5Px1wkV7naz8ang==", + "version": "24.10.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.0.tgz", + "integrity": "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~7.13.0" + "undici-types": "~7.16.0" } }, "node_modules/@types/stack-utils": { @@ -2216,6 +2216,21 @@ "dev": true, "license": "ISC" }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -4022,21 +4037,21 @@ } }, "node_modules/tldts": { - "version": "7.0.16", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.16.tgz", - "integrity": "sha512-5bdPHSwbKTeHmXrgecID4Ljff8rQjv7g8zKQPkCozRo2HWWni+p310FSn5ImI+9kWw9kK4lzOB5q/a6iv0IJsw==", + "version": "7.0.17", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.17.tgz", + "integrity": "sha512-Y1KQBgDd/NUc+LfOtKS6mNsC9CCaH+m2P1RoIZy7RAPo3C3/t8X45+zgut31cRZtZ3xKPjfn3TkGTrctC2TQIQ==", "license": "MIT", "dependencies": { - "tldts-core": "^7.0.16" + "tldts-core": "^7.0.17" }, "bin": { "tldts": "bin/cli.js" } }, "node_modules/tldts-core": { - "version": "7.0.16", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.16.tgz", - "integrity": "sha512-XHhPmHxphLi+LGbH0G/O7dmUH9V65OY20R7vH8gETHsp5AZCjBk9l8sqmRKLaGOxnETU7XNSDUPtewAy/K6jbA==", + "version": "7.0.17", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.17.tgz", + "integrity": "sha512-DieYoGrP78PWKsrXr8MZwtQ7GLCUeLxihtjC1jZsW1DnvSMdKPitJSe8OSYDM2u5H6g3kWJZpePqkp43TfLh0g==", "license": "MIT" }, "node_modules/tmpl": { @@ -4060,9 +4075,9 @@ } }, "node_modules/ts-jest": { - "version": "29.4.4", - "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.4.tgz", - "integrity": "sha512-ccVcRABct5ZELCT5U0+DZwkXMCcOCLi2doHRrKy1nK/s7J7bch6TzJMsrY09WxgUUIP/ITfmcDS8D2yl63rnXw==", + "version": "29.4.5", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.5.tgz", + "integrity": "sha512-HO3GyiWn2qvTQA4kTgjDcXiMwYQt68a1Y8+JuLRVpdIzm+UOLSHgl/XqR4c6nzJkq5rOkjc02O2I7P7l/Yof0Q==", "dev": true, "license": "MIT", "dependencies": { @@ -4072,7 +4087,7 @@ "json5": "^2.2.3", "lodash.memoize": "^4.1.2", "make-error": "^1.3.6", - "semver": "^7.7.2", + "semver": "^7.7.3", "type-fest": "^4.41.0", "yargs-parser": "^21.1.1" }, @@ -4113,9 +4128,9 @@ } }, "node_modules/ts-jest/node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", "dev": true, "license": "ISC", "bin": { @@ -4191,9 +4206,9 @@ } }, "node_modules/undici-types": { - "version": "7.13.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.13.0.tgz", - "integrity": "sha512-Ov2Rr9Sx+fRgagJ5AX0qvItZG/JKKoBRAVITs1zk7IqZGTJUwgUr7qoYBpWwakpWilTZFM98rG/AFRocu10iIQ==", + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", "dev": true, "license": "MIT" }, diff --git a/package.json b/package.json index adac730..e63bee5 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,8 @@ "build:cjs": "tsc --project tsconfig.cjs.json", "postbuild-init": "postbuild-init", "test": "jest", - "deepTest": "jest --coverage" + "deepTest": "jest --coverage", + "dev": "node ./.dev" }, "maintainers": [ { @@ -73,17 +74,17 @@ "@types/ejs": "^3.1.5", "@types/jest": "^30.0.0", "@types/ms": "^2.1.0", - "@types/node": "^24.6.2", - "ts-jest": "^29.4.4", + "@types/node": "^24.10.0", + "ts-jest": "^29.4.5", "typescript": "^5.9.3" }, "dependencies": { "@nasriya/atomix": "^1.0.23", - "@nasriya/cachify": "^0.0.6-beta", + "@nasriya/cachify": "^0.0.9-beta", "@nasriya/mimex": "^0.0.2-beta", "@nasriya/overwatch": "^1.1.4", "ejs": "^3.1.10", "ms": "^2.1.3", - "tldts": "^7.0.16" + "tldts": "^7.0.17" } } diff --git a/src/services/cache/routeCache.ts b/src/services/cache/routeCache.ts new file mode 100644 index 0000000..91068d1 --- /dev/null +++ b/src/services/cache/routeCache.ts @@ -0,0 +1,4 @@ +import cachify from "@nasriya/cachify"; + +const routeCache = cachify.createClient(); +export default routeCache; \ No newline at end of file diff --git a/src/services/routes/assets/router.ts b/src/services/routes/assets/router.ts index 2c8d161..24f0e31 100644 --- a/src/services/routes/assets/router.ts +++ b/src/services/routes/assets/router.ts @@ -1,12 +1,15 @@ +import atomix from '@nasriya/atomix'; import HyperCloudServer from '../../../server'; -import { HttpMethod, HyperCloudRequestHandler, StaticRouteOptions } from '../../../docs/docs'; import Route from './route'; import StaticRoute from './staticRoute'; import helpers from '../../../utils/helpers'; +import type { HttpMethod, HyperCloudRequestHandler, StaticRouteOptions } from '../../../docs/docs'; import fs from 'fs'; import path from 'path'; +const hasOwnProp = atomix.dataTypes.record.hasOwnProperty; + export class Router { #_server: HyperCloudServer | undefined; #_routes = { static: [] as StaticRoute[], dynamic: [] as Route[] } @@ -32,11 +35,16 @@ export class Router { createStaticRoute: (root: string, options?: StaticRouteOptions) => { const caseSensitive = options && 'caseSensitive' in options ? options.caseSensitive : this.#_defaults.caseSensitive; const subDomain = options && 'subDomain' in options ? options.subDomain : this.#_defaults.subDomain; - const userPath = options && 'path' in options && typeof options.path === 'string' ? options.path : '/'; - const path = userPath.startsWith('/') ? userPath : `/${userPath}`; + const routePath = (() => { + if (options && hasOwnProp(options, 'path') && atomix.valueIs.validString(options.path)) { + return options.path?.startsWith('/') ? options.path : `/${options.path}`; + } else { + return '/'; + } + })() const dotfiles = options && 'dotfiles' in options ? options.dotfiles : 'ignore'; - const route = new StaticRoute(root, { path, subDomain, caseSensitive, dotfiles }); + const route = new StaticRoute(root, { path: routePath, subDomain, caseSensitive, dotfiles }); if (this.#_server instanceof HyperCloudServer) { this.#_server._routesManager.add(route); } else { diff --git a/src/services/routes/assets/staticRoute.ts b/src/services/routes/assets/staticRoute.ts index 3ce36ca..9482c43 100644 --- a/src/services/routes/assets/staticRoute.ts +++ b/src/services/routes/assets/staticRoute.ts @@ -1,8 +1,8 @@ import atomix from "@nasriya/atomix"; -import cachify from "@nasriya/cachify"; import mimex from "@nasriya/mimex"; import overwatch from "@nasriya/overwatch"; -import { HyperCloudRequestHandler, MimeType, StaticRouteOptions } from "../../../docs/docs"; +import routeCache from "../../cache/routeCache"; +import type { HyperCloudRequestHandler, MimeType, StaticRouteOptions } from "../../../docs/docs"; import fs from 'fs'; import path from 'path'; @@ -48,6 +48,26 @@ class StaticRoute { if (typeof options.caseSensitive !== 'boolean') { throw new TypeError(`The Route's caseSensitive option is expecting a boolean value, but instead got ${typeof options.caseSensitive}`) } this.#_configs.caseSensitive = options.caseSensitive; } + }, + route: async () => { + await this.#_utils.cache.route(); + await overwatch.watchFolder(this.#_root, { + onRemove: async (event) => { + const record = routeCache.files.inspect({ filePath: event.path, scope: CACHE_SCOPE, caseSensitive: this.#_configs.caseSensitive }); + if (!record) { return } + + await routeCache.files.remove({ + filePath: event.path, + scope: CACHE_SCOPE, + caseSensitive: this.#_configs.caseSensitive + }) + }, + onAdd: async (event) => { + await this.#_utils.cache.createRecord(event.path); + } + }); + + this.#_configs.handler = this.#_handlers.cacheHandler; } }, cache: { @@ -57,7 +77,7 @@ class StaticRoute { return; } - return cachify.files.set(filePath, { scope: CACHE_SCOPE, ttl: 0 }); + return routeCache.files.set(filePath, { scope: CACHE_SCOPE, ttl: 0 }); }, path: (dir: string, setPromises: Promise[]) => { const content = fs.readdirSync(dir, { withFileTypes: true }); @@ -101,91 +121,86 @@ class StaticRoute { } }) - constructor(root: string, options: StaticRouteOptions) { - atomix.fs.canAccessSync(root, { permissions: 'Read', throwError: true }); - - this.#_root = root; - this.#_utils.initialize.dotfiles(options); - this.#_utils.initialize.path(options); - this.#_utils.initialize.subDomain(options); - this.#_utils.initialize.caseSensitive(options); - - this.#_utils.cache.route().then(() => { - this.#_configs.handler = async (request, response, next) => { - try { - if (request.path.length < this.#_configs.path.length) { - return response.pages.serverError({ - error: new Error(`Request path is shorter than route prefix. Possible framework route-matching bug.`) - }); - } + readonly #_handlers = { + cacheHandler: (async (request, response, next) => { + try { + if (request.path.length < this.#_configs.path.length) { + return response.pages.serverError({ + error: new Error(`Request path is shorter than route prefix. Possible framework route-matching bug.`) + }); + } - // Parse the file from the request - const reqFile = this.#_utils.parseFile(request.path); + // Parse the file from the request + const reqFile = this.#_utils.parseFile(request.path); - // Check the file against the policy - if (reqFile.name.startsWith('.')) { - if (this.#_configs.dotfiles === 'ignore') { return next() } - if (this.#_configs.dotfiles === 'deny') { return response.pages.unauthorized() } - } + // Check the file against the policy + if (reqFile.name.startsWith('.')) { + if (this.#_configs.dotfiles === 'ignore') { return next() } + if (this.#_configs.dotfiles === 'deny') { return response.pages.unauthorized() } + } - // Check if the file exists - const fileRecord = cachify.files.inspect({ - filePath: reqFile.path, - scope: CACHE_SCOPE, - caseSensitive: this.#_configs.caseSensitive - }); + // Check if the file exists + const fileRecord = routeCache.files.inspect({ + filePath: reqFile.path, + scope: CACHE_SCOPE, + caseSensitive: this.#_configs.caseSensitive + }); - if (!fileRecord) { return next(); } + if (!fileRecord) { return next(); } - // Define headers values - const modifiedDate = new Date(fileRecord.file.stats.mtime); - const eTag = fileRecord.file.eTag; + // Define headers values + const modifiedDate = new Date(fileRecord.file.stats.mtime); + const eTag = fileRecord.file.eTag; - // Check for conditional headers - const ifNoneMatch = request.headers['if-none-match']; - const ifModifiedSince = request.headers['if-modified-since']; + // Check for conditional headers + const ifNoneMatch = request.headers['if-none-match']; + const ifModifiedSince = request.headers['if-modified-since']; - if (ifNoneMatch || ifModifiedSince) { - // Normalize ETag (strip quotes if present) - const normalizedIfNoneMatch = ifNoneMatch?.replace(/(^"|"$)/g, ''); + if (ifNoneMatch || ifModifiedSince) { + // Normalize ETag (strip quotes if present) + const normalizedIfNoneMatch = ifNoneMatch?.replace(/(^"|"$)/g, ''); - // Validate modification date - const clientDate = ifModifiedSince ? new Date(ifModifiedSince) : null; - const isDateValid = clientDate instanceof Date && !isNaN(clientDate.getTime()); + // Validate modification date + const clientDate = ifModifiedSince ? new Date(ifModifiedSince) : null; + const isDateValid = clientDate instanceof Date && !isNaN(clientDate.getTime()); - // Check for matches - const isEtagMatch = normalizedIfNoneMatch === eTag; - const isDateMatch = isDateValid && clientDate >= modifiedDate; + // Check for matches + const isEtagMatch = normalizedIfNoneMatch === eTag; + const isDateMatch = isDateValid && clientDate >= modifiedDate; - // Return 304 if resource not modified - if (isEtagMatch || isDateMatch) { - return response.status(304).end(); - } + // Return 304 if resource not modified + if (isEtagMatch || isDateMatch) { + return response.status(304).end(); } + } - response.setHeader('etag', eTag); - response.setHeader('last-modified', modifiedDate.toUTCString()); + response.setHeader('etag', eTag); + response.setHeader('last-modified', modifiedDate.toUTCString()); - const readResponse = (await cachify.files.read({ - key: fileRecord.key, - scope: CACHE_SCOPE, - caseSensitive: this.#_configs.caseSensitive - }))!; + const readResponse = (await routeCache.files.read({ + key: fileRecord.key, + scope: CACHE_SCOPE, + caseSensitive: this.#_configs.caseSensitive + }))!; - response.setHeader('Cachify-Status', readResponse.status) - response.send(readResponse.content, reqFile.mimeType); - } catch (error) { - console.error(error); - response.pages.serverError({ error: error as Error }); - } + response.setHeader('Cachify-Status', readResponse.status) + response.send(readResponse.content, reqFile.mimeType); + } catch (error) { + console.error(error); + response.pages.serverError({ error: error as Error }); } - }) + }) as HyperCloudRequestHandler + } - overwatch.watch(this.#_root, { - onAdd: (event) => { - this.#_utils.cache.createRecord(event.path); - } - }) + constructor(root: string, options: StaticRouteOptions) { + atomix.fs.canAccessSync(root, { permissions: 'Read', throwError: true }); + + this.#_root = root; + this.#_utils.initialize.dotfiles(options); + this.#_utils.initialize.path(options); + this.#_utils.initialize.subDomain(options); + this.#_utils.initialize.caseSensitive(options); + this.#_utils.initialize.route(); } get subDomain(): '*' | string { return this.#_configs.subDomain } From 48a580b11dc122e39fe67196a002581c931efa2d Mon Sep 17 00:00:00 2001 From: Ahmad Nasriya Date: Thu, 6 Nov 2025 16:29:28 +0200 Subject: [PATCH 04/13] Fix issue: Initialize the handler before kicking off async setup --- src/services/routes/assets/staticRoute.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/services/routes/assets/staticRoute.ts b/src/services/routes/assets/staticRoute.ts index 9482c43..afffe64 100644 --- a/src/services/routes/assets/staticRoute.ts +++ b/src/services/routes/assets/staticRoute.ts @@ -66,8 +66,6 @@ class StaticRoute { await this.#_utils.cache.createRecord(event.path); } }); - - this.#_configs.handler = this.#_handlers.cacheHandler; } }, cache: { @@ -200,7 +198,9 @@ class StaticRoute { this.#_utils.initialize.path(options); this.#_utils.initialize.subDomain(options); this.#_utils.initialize.caseSensitive(options); - this.#_utils.initialize.route(); + + this.#_configs.handler = this.#_handlers.cacheHandler; + void this.#_utils.initialize.route().catch(console.error); } get subDomain(): '*' | string { return this.#_configs.subDomain } From 583298b0dbaa7ccb5ec3d9a7676e50a7f06140a3 Mon Sep 17 00:00:00 2001 From: Ahmad Nasriya Date: Thu, 6 Nov 2025 16:37:46 +0200 Subject: [PATCH 05/13] Fix issue: Add error handling to filesystem watcher callbacks --- src/services/routes/assets/staticRoute.ts | 26 +++++++++++++++-------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/src/services/routes/assets/staticRoute.ts b/src/services/routes/assets/staticRoute.ts index afffe64..376ab7b 100644 --- a/src/services/routes/assets/staticRoute.ts +++ b/src/services/routes/assets/staticRoute.ts @@ -53,17 +53,25 @@ class StaticRoute { await this.#_utils.cache.route(); await overwatch.watchFolder(this.#_root, { onRemove: async (event) => { - const record = routeCache.files.inspect({ filePath: event.path, scope: CACHE_SCOPE, caseSensitive: this.#_configs.caseSensitive }); - if (!record) { return } - - await routeCache.files.remove({ - filePath: event.path, - scope: CACHE_SCOPE, - caseSensitive: this.#_configs.caseSensitive - }) + try { + const record = routeCache.files.inspect({ filePath: event.path, scope: CACHE_SCOPE, caseSensitive: this.#_configs.caseSensitive }); + if (!record) { return } + + await routeCache.files.remove({ + filePath: event.path, + scope: CACHE_SCOPE, + caseSensitive: this.#_configs.caseSensitive + }) + } catch (error) { + console.error(`Failed to remove ${event.path} from cache:`, error); + } }, onAdd: async (event) => { - await this.#_utils.cache.createRecord(event.path); + try { + await this.#_utils.cache.createRecord(event.path); + } catch (error) { + console.error(`Failed to add ${event.path} to cache:`, error); + } } }); } From 87cfe20eb62bad923466ae0574e00c2a9dd10725 Mon Sep 17 00:00:00 2001 From: Ahmad Nasriya Date: Thu, 6 Nov 2025 16:50:02 +0200 Subject: [PATCH 06/13] Addressing: Consider making cache TTL configurable --- src/services/cache/routeCache.ts | 4 ++++ src/services/routes/assets/staticRoute.ts | 5 ++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/services/cache/routeCache.ts b/src/services/cache/routeCache.ts index 91068d1..0c489a0 100644 --- a/src/services/cache/routeCache.ts +++ b/src/services/cache/routeCache.ts @@ -1,4 +1,8 @@ import cachify from "@nasriya/cachify"; const routeCache = cachify.createClient(); + +// On TTL expiration, remove the file content from the cache to save space +routeCache.files.configs.ttl.policy = 'keep'; + export default routeCache; \ No newline at end of file diff --git a/src/services/routes/assets/staticRoute.ts b/src/services/routes/assets/staticRoute.ts index 376ab7b..25efb97 100644 --- a/src/services/routes/assets/staticRoute.ts +++ b/src/services/routes/assets/staticRoute.ts @@ -83,7 +83,10 @@ class StaticRoute { return; } - return routeCache.files.set(filePath, { scope: CACHE_SCOPE, ttl: 0 }); + return routeCache.files.set(filePath, { + scope: CACHE_SCOPE, + ttl: 1_000 * 60 * 60 // 1 hour + }); }, path: (dir: string, setPromises: Promise[]) => { const content = fs.readdirSync(dir, { withFileTypes: true }); From 13ecad33746179267f5f808b55e6d31c91e86bc1 Mon Sep 17 00:00:00 2001 From: Ahmad Nasriya Date: Thu, 6 Nov 2025 16:56:42 +0200 Subject: [PATCH 07/13] Fix issue: Handle weak ETags in conditional request logic --- src/services/routes/assets/staticRoute.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/services/routes/assets/staticRoute.ts b/src/services/routes/assets/staticRoute.ts index 25efb97..b572263 100644 --- a/src/services/routes/assets/staticRoute.ts +++ b/src/services/routes/assets/staticRoute.ts @@ -167,7 +167,7 @@ class StaticRoute { if (ifNoneMatch || ifModifiedSince) { // Normalize ETag (strip quotes if present) - const normalizedIfNoneMatch = ifNoneMatch?.replace(/(^"|"$)/g, ''); + const normalizedIfNoneMatch = ifNoneMatch?.replace(/^W\//, '').replace(/(^"|"$)/g, ''); // Validate modification date const clientDate = ifModifiedSince ? new Date(ifModifiedSince) : null; From e9a1f6525a0ca17e5ff511b6e439c1f05c8adc08 Mon Sep 17 00:00:00 2001 From: Ahmad Nasriya Date: Thu, 6 Nov 2025 17:08:47 +0200 Subject: [PATCH 08/13] Addressing: Path traversal vulnerability - validate resolved paths --- src/services/routes/assets/staticRoute.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/services/routes/assets/staticRoute.ts b/src/services/routes/assets/staticRoute.ts index b572263..3e3792a 100644 --- a/src/services/routes/assets/staticRoute.ts +++ b/src/services/routes/assets/staticRoute.ts @@ -6,6 +6,7 @@ import type { HyperCloudRequestHandler, MimeType, StaticRouteOptions } from "../ import fs from 'fs'; import path from 'path'; +import HTTPError from "../../../utils/errors/HTTPError"; const CACHE_SCOPE = 'hypercloud_static_routes' as const; @@ -123,6 +124,14 @@ class StaticRoute { // Remove the initial path (the virtual path) and keep the root path const reqPath = _reqPath.slice(this.#_configs.path.length, _reqPath.length).join(path.sep); const filePath = path.join(this.#_root, reqPath); + + // Prevent path traversal attacks + if (!atomix.path.isSubPath(filePath, this.#_root)) { + const error = new Error(`Path traversal attack detected on path: ${filePath}`); + error.name = 'PathTraversalError'; + throw error; + } + const fileName = path.basename(filePath); const mimeType = this.#_utils.getFileMime(filePath) as MimeType; @@ -195,6 +204,11 @@ class StaticRoute { response.setHeader('Cachify-Status', readResponse.status) response.send(readResponse.content, reqFile.mimeType); } catch (error) { + if (error instanceof Error && error.name === 'PathTraversalError') { + response.pages.forbidden(); + return; + } + console.error(error); response.pages.serverError({ error: error as Error }); } From c8aad18e3c69522ef8b04ddbb4495a7cdcfc0ad1 Mon Sep 17 00:00:00 2001 From: Ahmad Nasriya Date: Thu, 6 Nov 2025 17:18:05 +0200 Subject: [PATCH 09/13] Addressing: Remove unsafe non-null assertion on cache read --- src/services/routes/assets/staticRoute.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/services/routes/assets/staticRoute.ts b/src/services/routes/assets/staticRoute.ts index 3e3792a..51e0962 100644 --- a/src/services/routes/assets/staticRoute.ts +++ b/src/services/routes/assets/staticRoute.ts @@ -195,11 +195,13 @@ class StaticRoute { response.setHeader('etag', eTag); response.setHeader('last-modified', modifiedDate.toUTCString()); - const readResponse = (await routeCache.files.read({ + const readResponse = await routeCache.files.read({ key: fileRecord.key, scope: CACHE_SCOPE, caseSensitive: this.#_configs.caseSensitive - }))!; + }); + + if (!readResponse) { return next(); } response.setHeader('Cachify-Status', readResponse.status) response.send(readResponse.content, reqFile.mimeType); From f5fe1a7d2a1e08bf679ffd53a1364c9633246be0 Mon Sep 17 00:00:00 2001 From: Ahmad Nasriya Date: Thu, 6 Nov 2025 17:20:14 +0200 Subject: [PATCH 10/13] Fixed: Using 403 Forbidden instead of 401 Unauthorized for denied dotfiles. --- src/services/routes/assets/staticRoute.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/services/routes/assets/staticRoute.ts b/src/services/routes/assets/staticRoute.ts index 51e0962..a9bc26e 100644 --- a/src/services/routes/assets/staticRoute.ts +++ b/src/services/routes/assets/staticRoute.ts @@ -154,7 +154,7 @@ class StaticRoute { // Check the file against the policy if (reqFile.name.startsWith('.')) { if (this.#_configs.dotfiles === 'ignore') { return next() } - if (this.#_configs.dotfiles === 'deny') { return response.pages.unauthorized() } + if (this.#_configs.dotfiles === 'deny') { return response.pages.forbidden() } } // Check if the file exists From 729eea71dc52353f917998ec9bb8adf911be85a3 Mon Sep 17 00:00:00 2001 From: Ahmad Nasriya Date: Thu, 6 Nov 2025 17:47:34 +0200 Subject: [PATCH 11/13] Addressing: Add quotes to ETag value to comply with RFC 7232. --- src/services/routes/assets/staticRoute.ts | 41 ++++++++++++++++------- 1 file changed, 28 insertions(+), 13 deletions(-) diff --git a/src/services/routes/assets/staticRoute.ts b/src/services/routes/assets/staticRoute.ts index a9bc26e..1c2dfda 100644 --- a/src/services/routes/assets/staticRoute.ts +++ b/src/services/routes/assets/staticRoute.ts @@ -174,25 +174,40 @@ class StaticRoute { const ifNoneMatch = request.headers['if-none-match']; const ifModifiedSince = request.headers['if-modified-since']; - if (ifNoneMatch || ifModifiedSince) { - // Normalize ETag (strip quotes if present) - const normalizedIfNoneMatch = ifNoneMatch?.replace(/^W\//, '').replace(/(^"|"$)/g, ''); + const isNotModified = (() => { + if (!ifModifiedSince && !ifNoneMatch) { return false } - // Validate modification date - const clientDate = ifModifiedSince ? new Date(ifModifiedSince) : null; - const isDateValid = clientDate instanceof Date && !isNaN(clientDate.getTime()); + if (ifNoneMatch) { + // Handle multiple ETags in header (comma-separated) + const clientEtags = ifNoneMatch.split(',').map(tag => tag.trim()); - // Check for matches - const isEtagMatch = normalizedIfNoneMatch === eTag; - const isDateMatch = isDateValid && clientDate >= modifiedDate; + // Normalize: remove weak prefix and quotes + const normalized = clientEtags.map(tag => + tag.replace(/^W\//, '').replace(/(^"|"$)/g, '') + ); - // Return 304 if resource not modified - if (isEtagMatch || isDateMatch) { - return response.status(304).end(); + // Check if any match your stored eTag + if (normalized.includes(eTag)) { + return true; + } + } + + if (ifModifiedSince) { + // Validate modification date + const clientDate = new Date(ifModifiedSince); + const isDateValid = clientDate instanceof Date && !isNaN(clientDate.getTime()); + + return isDateValid && clientDate >= modifiedDate; } + + return false; + })(); + + if (isNotModified) { + return response.status(304).end(); } - response.setHeader('etag', eTag); + response.setHeader('etag', `W/"${eTag}"`); response.setHeader('last-modified', modifiedDate.toUTCString()); const readResponse = await routeCache.files.read({ From 9c95ccddd3f48f6606b63218b4f568da27c477d5 Mon Sep 17 00:00:00 2001 From: Ahmad Nasriya Date: Thu, 6 Nov 2025 18:21:18 +0200 Subject: [PATCH 12/13] =?UTF-8?q?Addresses:=20Handle=20If-None-Match:=20*?= =?UTF-8?q?=20per=20RFC=E2=80=AF7232?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/services/routes/assets/staticRoute.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/services/routes/assets/staticRoute.ts b/src/services/routes/assets/staticRoute.ts index 1c2dfda..49ba207 100644 --- a/src/services/routes/assets/staticRoute.ts +++ b/src/services/routes/assets/staticRoute.ts @@ -187,8 +187,10 @@ class StaticRoute { ); // Check if any match your stored eTag - if (normalized.includes(eTag)) { - return true; + for (const clientETAG of normalized) { + if (clientETAG === '*' || clientETAG === eTag) { + return true + } } } From 89dca00276373e0c6de6c341c478c9f689e1f516 Mon Sep 17 00:00:00 2001 From: Ahmad Nasriya Date: Thu, 6 Nov 2025 18:25:52 +0200 Subject: [PATCH 13/13] Removed unused imports --- src/services/routes/assets/staticRoute.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/services/routes/assets/staticRoute.ts b/src/services/routes/assets/staticRoute.ts index 49ba207..94ed808 100644 --- a/src/services/routes/assets/staticRoute.ts +++ b/src/services/routes/assets/staticRoute.ts @@ -6,7 +6,6 @@ import type { HyperCloudRequestHandler, MimeType, StaticRouteOptions } from "../ import fs from 'fs'; import path from 'path'; -import HTTPError from "../../../utils/errors/HTTPError"; const CACHE_SCOPE = 'hypercloud_static_routes' as const;