From 2a49ef238ef605f8867cee8fc05d229d80d9c065 Mon Sep 17 00:00:00 2001 From: PierreDemailly Date: Mon, 11 Nov 2024 21:03:25 +0100 Subject: [PATCH] feat: search packages with websocket --- eslint.config.mjs | 48 ++--- i18n/english.js | 4 +- i18n/french.js | 4 +- package.json | 17 +- public/common/utils.js | 12 ++ public/components/navigation/navigation.css | 108 ++++++----- public/components/navigation/navigation.js | 5 + public/components/searchbar/searchbar.css | 97 ++++++++-- public/components/searchbar/searchbar.js | 2 +- public/components/views/search/search.css | 179 ++++++++++++++++++ public/components/views/search/search.js | 184 +++++++++++++++++++ public/components/views/settings/settings.js | 3 +- public/core/search-nav.js | 95 ++++++++++ public/main.css | 1 + public/main.js | 70 +++++-- scripts/clear-cache.js | 9 + src/commands/scorecard.js | 2 +- src/http-server/cache.js | 132 +++++++++++++ src/http-server/config.js | 46 +++-- src/http-server/endpoints/data.js | 47 ++++- src/http-server/endpoints/search.js | 41 +++++ src/http-server/index.js | 34 +++- src/http-server/logger.js | 12 ++ src/http-server/websocket/index.js | 3 + src/http-server/websocket/init.js | 33 ++++ src/http-server/websocket/remove.js | 85 +++++++++ src/http-server/websocket/search.js | 80 ++++++++ test/config.test.js | 12 +- test/httpServer.test.js | 73 ++++---- views/index.html | 66 +++++-- workspaces/size-satisfies/test/test.js | 2 - workspaces/vis-network/src/dataset.js | 5 +- workspaces/vis-network/src/network.js | 1 - 33 files changed, 1299 insertions(+), 213 deletions(-) create mode 100644 public/components/views/search/search.css create mode 100644 public/components/views/search/search.js create mode 100644 public/core/search-nav.js create mode 100644 scripts/clear-cache.js create mode 100644 src/http-server/cache.js create mode 100644 src/http-server/endpoints/search.js create mode 100644 src/http-server/logger.js create mode 100644 src/http-server/websocket/index.js create mode 100644 src/http-server/websocket/init.js create mode 100644 src/http-server/websocket/remove.js create mode 100644 src/http-server/websocket/search.js diff --git a/eslint.config.mjs b/eslint.config.mjs index c52a1664..96511cc8 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,32 +1,24 @@ -// Import Node.js Dependencies -import path from "node:path"; -import { fileURLToPath } from "node:url"; +import { ESLintConfig, globals } from "@openally/config.eslint"; -// Import Third-party Dependencies -import { FlatCompat } from "@eslint/eslintrc"; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -const compat = new FlatCompat({ - baseDirectory: __dirname -}); - -export default [{ - ignores: ["**/node_modules/", "**/tmp/", "**/dist/", "**/coverage/", "**/fixtures/"] -}, ...compat.extends("@nodesecure/eslint-config"), { - languageOptions: { - sourceType: "module", - - parserOptions: { - requireConfigFile: false +export default [ + ...ESLintConfig, + { + rules: { + "func-style": "off", + "no-invalid-this": "off", + "no-inner-declarations": "off", + "no-case-declarations": "off", + // TODO: enable this rule when migrating to @topcli/cmder + "default-param-last": "off" + }, + languageOptions: { + sourceType: "module", + globals: { + ...globals.browser + } } }, - - rules: { - "func-style": "off", - "no-invalid-this": "off", - "no-inner-declarations": "off", - "no-case-declarations": "off" + { + ignores: ["**/node_modules/", "**/tmp/", "**/dist/", "**/coverage/", "**/fixtures/"] } -}]; +]; diff --git a/i18n/english.js b/i18n/english.js index 95ecfcf0..7e351313 100644 --- a/i18n/english.js +++ b/i18n/english.js @@ -188,7 +188,9 @@ const ui = { "Node.js core modules": "Node.js core modules", "Available licenses": "Available licenses", "Available flags": "Available flags", - default: "Search options" + default: "Search options", + packagesCache: "Packages available in the cache", + noPackageFound: "No package found" }, legend: { default: "The package is fine.", diff --git a/i18n/french.js b/i18n/french.js index e1fbf517..4a1cbd0f 100644 --- a/i18n/french.js +++ b/i18n/french.js @@ -188,7 +188,9 @@ const ui = { "Node.js core modules": "Modules de base de Node.js", "Available licenses": "Licences disponibles", "Available flags": "Drapeaux disponibles", - default: "Options de recherche" + default: "Options de recherche", + packagesCache: "Packages disponibles dans le cache", + noPackageFound: "Aucun package trouvé", }, legend: { default: "Rien à signaler.", diff --git a/package.json b/package.json index 596b2550..12e479a8 100644 --- a/package.json +++ b/package.json @@ -12,13 +12,14 @@ "node": ">=18" }, "scripts": { - "eslint": "eslint bin src test workspaces", - "eslint-fix": "npm run eslint -- --fix", + "lint": "eslint bin src test workspaces", + "lint-fix": "npm run lint -- --fix", "prepublishOnly": "rimraf ./dist && npm run build && pkg-ok", "build": "node ./esbuild.config.js", - "test": "npm run test-only && npm run eslint", - "test-only": "glob -c \"node --loader=esmock --no-warnings --test\" \"test/**/*.test.js\"", - "coverage": "c8 --reporter=lcov npm run test" + "test": "npm run test-only && npm run lint", + "test-only": "glob -c \"node --loader=esmock --no-warnings --test-concurrency 1 --test\" \"test/**/*.test.js\"", + "coverage": "c8 --reporter=lcov npm run test", + "clear:cache": "node ./scripts/clear-cache.js" }, "files": [ "bin", @@ -62,14 +63,13 @@ "homepage": "https://github.com/NodeSecure/cli#readme", "devDependencies": { "@myunisoft/httpie": "^5.0.0", - "@nodesecure/eslint-config": "2.0.0-beta.0", "@nodesecure/size-satisfies": "^1.1.0", "@nodesecure/vis-network": "^1.4.0", + "@openally/config.eslint": "^1.1.0", "@types/node": "^22.2.0", "c8": "^10.1.2", "cross-env": "^7.0.3", "esbuild": "^0.23.0", - "eslint": "^9.8.0", "esmock": "^2.6.7", "glob": "^11.0.0", "http-server": "^14.1.1", @@ -104,10 +104,13 @@ "kleur": "^4.1.5", "ms": "^2.1.3", "open": "^10.1.0", + "pino": "^9.3.2", + "pino-pretty": "^11.2.2", "polka": "^0.5.2", "sade": "^1.8.1", "semver": "^7.6.3", "sirv": "^2.0.4", + "ws": "^8.18.0", "zup": "0.0.2" } } diff --git a/public/common/utils.js b/public/common/utils.js index bacfcdbc..da597cf1 100644 --- a/public/common/utils.js +++ b/public/common/utils.js @@ -250,3 +250,15 @@ export function currentLang() { return detectedLang in window.i18n ? detectedLang : "english"; } + +export function debounce(callback, delay) { + let timer; + + // eslint-disable-next-line func-names + return function() { + clearTimeout(timer); + timer = setTimeout(() => { + callback(); + }, delay); + }; +} diff --git a/public/components/navigation/navigation.css b/public/components/navigation/navigation.css index f28fc084..b7213e10 100644 --- a/public/components/navigation/navigation.css +++ b/public/components/navigation/navigation.css @@ -1,4 +1,4 @@ -nav { +nav#aside { width: 70px; flex-shrink: 0; background: var(--primary); @@ -8,60 +8,66 @@ nav { flex-direction: column; z-index: 40; } - nav > .nsecure-logo { - margin-top: 20px; - } - nav > ul { - width: inherit; - display: flex; - margin-top: 10px; - flex-direction: column; - flex-grow: 1; - margin-bottom: 20px; - } +nav#aside>.nsecure-logo { + margin-top: 20px; +} - nav > ul li { - height: 70px; - display: flex; - position: relative; - justify-content: center; - align-items: center; - } - nav > ul li+li { - margin-top: 10px; - } +nav#aside>ul { + width: inherit; + display: flex; + margin-top: 10px; + flex-direction: column; + flex-grow: 1; + margin-bottom: 20px; +} - nav > ul li:not(.active):hover { - cursor: pointer; - background: rgba(50, 200, 255, 0.085); - } +nav#aside>ul li { + height: 70px; + display: flex; + position: relative; + justify-content: center; + align-items: center; +} + +nav#aside>ul li+li { + margin-top: 10px; +} - nav > ul li.active:before { - background: var(--secondary); - position: absolute; - left: 0; - top: 17.5px; - height: 35px; - width: 4px; - border-radius: 0 4px 4px 0; - content: ""; - } +nav#aside>ul li:not(.active):hover { + cursor: pointer; + background: rgba(50, 200, 255, 0.085); +} + +nav#aside>ul li.active:before { + background: var(--secondary); + position: absolute; + left: 0; + top: 17.5px; + height: 35px; + width: 4px; + border-radius: 0 4px 4px 0; + content: ""; +} + +nav#aside>ul li>i { + font-size: 24px; +} + +nav#aside>ul li.active, +nav#aside>ul li.active span { + color: var(--secondary); +} + +nav#aside>ul li>span { + position: absolute; + left: 10px; + bottom: 5px; + font-size: 12px; + color: #FFF; + font-weight: bold; +} - nav > ul li > i { - font-size: 24px; - } - nav > ul li.active, nav > ul li.active span { - color: var(--secondary); - } - nav > ul li > span { - position: absolute; - left: 10px; - bottom: 5px; - font-size: 12px; - color: #FFF; - font-weight: bold; - } -.bottom-nav { +nav#aside>ul li.bottom-nav { margin-top: auto; } diff --git a/public/components/navigation/navigation.js b/public/components/navigation/navigation.js index 53696e47..c6b5027d 100644 --- a/public/components/navigation/navigation.js +++ b/public/components/navigation/navigation.js @@ -5,6 +5,7 @@ import { PackageInfo } from "../package/package.js"; const kAvailableView = new Set([ "network--view", "home--view", + "search--view", "settings--view" ]); @@ -50,6 +51,10 @@ export class ViewNavigation { this.onNavigationSelected(this.menus.get("settings--view")); break; } + case hotkeys.search: { + this.onNavigationSelected(this.menus.get("search--view")); + break; + } } }); } diff --git a/public/components/searchbar/searchbar.css b/public/components/searchbar/searchbar.css index b40d9d20..30fe0be5 100644 --- a/public/components/searchbar/searchbar.css +++ b/public/components/searchbar/searchbar.css @@ -1,14 +1,8 @@ #searchbar { - background: #263c46; + background: var(--primary); display: flex; - height: 40px; - position: absolute; - z-index: 40; - right: 40px; - top: 0; + height: 30px; box-sizing: border-box; - border-radius: 0 0 4px 4px; - box-shadow: -6px 4px 0px #3a00ffbd; } #searchbar>div.search-items { @@ -53,9 +47,10 @@ background: none; border: none; outline: none; - padding: 0 10px; color: #FFF; font-family: "mononoki"; + margin-bottom: 2px; + border-bottom: 1px solid #FFF; } #searchbar>input::placeholder { @@ -72,12 +67,10 @@ } div.search-result-background { - width: 100%; position: absolute; - top: 35px; - right: 0; display: none; - margin-top: 25px; + margin-top: 30px; + min-width: 360px; padding: 10px !important; background: #263238; box-shadow: 1px 1px 10px rgba(20, 20, 20, 0.4); @@ -197,3 +190,81 @@ div.search-result-pannel .package.hide { div.search-result-pannel .package+.package { margin-top: 5px; } + +#search-nav { + z-index: 200; + display: flex; + justify-content: center; + align-items: center; + position: absolute; + height: 30px; + left: 70px; + max-width: calc(100vw - 70px); + box-sizing: border-box; +} + +#search-nav .packages { + display: flex; + overflow-x: auto; + max-width: calc(100vw - 70px - 264px); +} + +#search-nav .package { + height: 30px; + color: rgb(229, 229, 229); + display: flex; + align-items: center; + padding: 0 10px; + background: rgb(88, 78, 158); +} + +#search-nav .package:hover { + background: var(--primary); + font-weight: bold; + font-size: 16px; + cursor: pointer; +} + +#search-nav .active { + background: var(--primary); + font-weight: bold; +} + +#search-nav .active:hover { + font-size: 16px; + cursor: default; +} + +#search-nav .add { + height: 30px; + background: var(--primary); + color: white; + font-size: 20px; + border: none; + background: var(--primary); + cursor: pointer; + padding-right: 9px; +} + +#search-nav button.remove { + border-radius: 50%; + border: none; + background:transparent; + position: relative; + bottom: 3px; + left: 3px; + cursor: pointer; + color: white; +} + +.search-nav-footer { + height: 30px; + width: 0; + position: relative; + border-style: solid; + border-right: 0px solid transparent; + border-left: 31px solid transparent; + border-bottom: 60px solid var(--primary); + border-top: 0; + transform: rotate(180deg); +} diff --git a/public/components/searchbar/searchbar.js b/public/components/searchbar/searchbar.js index 6f485aab..b2568551 100644 --- a/public/components/searchbar/searchbar.js +++ b/public/components/searchbar/searchbar.js @@ -378,7 +378,7 @@ export class SearchBar { const titleText = window.i18n[currentLang()].search[ Reflect.has(kHelpersTitleName, filterName) ? kHelpersTitleName[filterName] : "default" ]; - // eslint-disable-next-line max-len + // eslint-disable-next-line @stylistic/max-len this.helper.innerHTML = `

${titleText}

`; this.helper.appendChild(clone); } diff --git a/public/components/views/search/search.css b/public/components/views/search/search.css new file mode 100644 index 00000000..9d020be7 --- /dev/null +++ b/public/components/views/search/search.css @@ -0,0 +1,179 @@ +#search--view { + display: flex; + flex-direction: column; + align-items: center; + margin: auto; +} + +#search--view, +#search--view * { + box-sizing: border-box; + outline: none; +} + +#search--view .container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + width: 100%; + max-width: 600px; + min-height: 150px; + margin: 135px auto; +} + +#search--view form { + width: 100%; +} + +#search--view form input { + padding: 11px 6px; + border: none; + font-size: 16px; + width: 100%; + color: rgb(78, 76, 76); + outline: none; +} + +.result-not-found { + color: rgb(78, 76, 76); + text-align: center; +} + +#search--view form input::placeholder { + color: rgb(125, 125, 125); + font-style: italic; +} + +.result-container { + max-height: calc(100vh - 340px); + overflow-y: auto; + margin-top: 10px; + border-radius: 0 0 10px 10px; + width: 100%; +} + +.result-container::-webkit-scrollbar { + width: 0px !important; +} + +.result { + display: flex; + align-items: flex-start; + flex-direction: row; + background: white; + color: var(--primary); + padding-left: 5px; +} + +.result:nth-child(odd) { + background: #fcfcfa; +} + +.result span { + margin-top: 7px; +} + +.result:hover span { + opacity: 0.8; + cursor: pointer; + text-decoration: underline; +} + +.result:not(:last-child) { + border-bottom: 2px solid #f7f7f7; +} + +.result span, +.result select { + font-weight: bold; + font-size: 17px; + width: 100%; + cursor: pointer; + border: none; +} + +.result select { + max-width: 70px; + margin-right: 5px; +} + +.form-group { + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + margin-top: 20px; + width: 100%; + margin: auto; + padding: 10px; + border-radius: 4px; + background: white; + border: 2px solid var(--primary); +} + +#search--view .icon-search { + filter: invert(64%) sepia(100%) saturate(2094%) hue-rotate(241deg) brightness(67%) contrast(114%); + max-width: 16px; + margin-right: 10px; +} + +.scan-info { + height: 30px; + color: rgb(78, 76, 76); +} + +.spinner { + width: 56px; + height: 56px; + border-radius: 50%; + border: 9px solid #dbdcef; + border-right-color: #474bff; + animation: spinner-d3wgkg 1s infinite linear; +} + +@keyframes spinner-d3wgkg { + to { + transform: rotate(1turn); + } +} + +input:-webkit-autofill { + -webkit-background-clip: text; +} + +.package-result { + display: flex; + flex-direction: column; + align-items: flex-start; + padding-left: 5px; + width: 100%; +} + +.package-result .description { + font-size: 14px; + color: rgb(78, 76, 76); + margin-top: 5px; + margin-bottom: 8px; +} + +.cache-packages { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + margin-top: 20px; + width: 100%; + color: var(--primary); + font-weight: bold; + font-size: 17px; +} + +.cache-packages .package-result { + margin-top: 10px; + cursor: pointer; +} + +.cache-packages .package-result:hover { + opacity: 0.8; +} diff --git a/public/components/views/search/search.js b/public/components/views/search/search.js new file mode 100644 index 00000000..e3c5c123 --- /dev/null +++ b/public/components/views/search/search.js @@ -0,0 +1,184 @@ +// Import Third-party Dependencies +import { getJSON, NodeSecureDataSet, NodeSecureNetwork } from "@nodesecure/vis-network"; + +// Import Internal Dependencies +import { currentLang, debounce } from "../../../common/utils.js"; + +export class SearchView { + /** + * @type {NodeSecureDataSet} + */ + secureDataSet; + /** + * @type {NodeSecureNetwork} + */ + nsn; + + /** + * @param {!NodeSecureDataSet} secureDataSet + * @param {!NodeSecureNetwork} nsn + */ + constructor( + secureDataSet, + nsn + ) { + this.secureDataSet = secureDataSet; + this.nsn = nsn; + + this.initialize(); + } + + initialize() { + this.searchContainer = document.querySelector("#search--view .container"); + this.searchForm = document.querySelector("#search--view form"); + const input = this.searchForm.querySelector("input"); + + input.addEventListener("input", debounce(async() => { + document.querySelector(".result-container")?.remove(); + const packageName = input.value; + if (packageName.length === 0) { + return; + } + + const { result, count } = await getJSON(`/search/${encodeURIComponent(packageName)}`); + + const divResultContainer = document.createElement("div"); + divResultContainer.classList.add("result-container"); + + if (count === 0) { + const divResultElement = document.createElement("div"); + divResultElement.classList.add("result-not-found"); + divResultElement.textContent = window.i18n[lang].search.noPackageFound; + divResultContainer.appendChild(divResultElement); + this.searchForm.appendChild(divResultContainer); + + return; + } + + for (const { name, version, description } of result) { + const divResultElement = document.createElement("div"); + divResultElement.classList.add("result"); + + const pkgElement = document.createElement("div"); + pkgElement.classList.add("package-result"); + const pkgSpanElement = document.createElement("span"); + pkgSpanElement.textContent = name; + pkgSpanElement.addEventListener("click", async() => { + const packageVersion = divResultElement.querySelector("select option:checked"); + await this.fetchPackage(name, packageVersion.value); + }, { once: true }); + pkgElement.appendChild(pkgSpanElement); + const pkgDescriptionElement = document.createElement("p"); + pkgDescriptionElement.textContent = description; + pkgDescriptionElement.classList.add("description"); + pkgElement.appendChild(pkgDescriptionElement); + divResultElement.appendChild(pkgElement); + + const selectElement = document.createElement("select"); + const optionElement = document.createElement("option"); + optionElement.value = version; + optionElement.textContent = version; + selectElement.appendChild(optionElement); + selectElement.addEventListener("click", async() => { + const versions = await this.fetchPackageVersions(name); + for (const pkgVersion of versions) { + if (pkgVersion === version) { + continue; + } + const optionElement = document.createElement("option"); + optionElement.value = pkgVersion; + optionElement.textContent = pkgVersion; + selectElement.appendChild(optionElement); + } + }, { once: true }); + divResultElement.appendChild(selectElement); + divResultContainer.appendChild(divResultElement); + } + this.searchForm.parentNode.insertBefore(divResultContainer, this.searchForm.nextSibling); + }, 500)); + + this.searchForm.addEventListener("submit", (event) => { + event.preventDefault(); + }); + + const cachePackagesElement = this.searchContainer.querySelector(".cache-packages"); + if (cachePackagesElement === null) { + return; + } + if (window.scannedPackageCache.length > 0) { + cachePackagesElement.classList.remove("hidden"); + const h1Element = document.createElement("h1"); + const lang = currentLang(); + h1Element.textContent = window.i18n[lang].search.packagesCache; + cachePackagesElement.appendChild(h1Element); + + for (const pkg of window.scannedPackageCache) { + const pkgElement = document.createElement("div"); + pkgElement.classList.add("package-result"); + const pkgSpanElement = document.createElement("span"); + pkgSpanElement.textContent = pkg; + pkgSpanElement.addEventListener("click", () => { + window.socket.send(JSON.stringify({ action: "SEARCH", pkg })); + }, { once: true }); + pkgElement.appendChild(pkgSpanElement); + cachePackagesElement.appendChild(pkgElement); + } + } + else { + cachePackagesElement.classList.add("hidden"); + } + } + + async fetchPackage(packageName, version) { + const pkg = `${packageName}@${version}`; + + window.socket.send(JSON.stringify({ action: "SEARCH", pkg })); + } + + async fetchPackageVersions(packageName) { + const versions = await getJSON(`/search-versions/${encodeURIComponent(packageName)}`); + + return versions.reverse(); + } + + reset() { + const searchViewContainer = document.querySelector("#search--view .container"); + searchViewContainer.innerHTML = ""; + const form = document.createElement("form"); + const formGroup = document.createElement("div"); + formGroup.classList.add("form-group"); + const iconSearch = document.createElement("i"); + iconSearch.classList.add("icon-search"); + const input = document.createElement("input"); + input.type = "text"; + input.placeholder = "fastify, express..."; + input.name = "package"; + input.id = "package"; + formGroup.appendChild(iconSearch); + formGroup.appendChild(input); + form.appendChild(formGroup); + searchViewContainer.appendChild(form); + + const cachePackagesElement = document.createElement("div"); + cachePackagesElement.classList.add("cache-packages", "hidden"); + searchViewContainer.appendChild(cachePackagesElement); + + this.initialize(); + } + + onScan(pkg) { + const searchViewForm = document.querySelector("#search--view form"); + searchViewForm.remove(); + const containerResult = document.querySelector("#search--view .result-container"); + containerResult?.remove(); + + const searchViewContainer = document.querySelector("#search--view .container"); + const scanInfo = document.createElement("div"); + scanInfo.classList.add("scan-info"); + scanInfo.textContent = `Scanning ${pkg}.`; + const spinner = document.createElement("div"); + spinner.classList.add("spinner"); + searchViewContainer.appendChild(scanInfo); + searchViewContainer.appendChild(spinner); + } +} diff --git a/public/components/views/settings/settings.js b/public/components/views/settings/settings.js index 9a9fe494..c809e13b 100644 --- a/public/components/views/settings/settings.js +++ b/public/components/views/settings/settings.js @@ -12,7 +12,8 @@ const kDefaultHotKeys = { network: "N", settings: "S", wiki: "W", - lock: "L" + lock: "L", + search: "F" }; const kShortcutInputTargetIds = new Set(Object.keys(kDefaultHotKeys)); diff --git a/public/core/search-nav.js b/public/core/search-nav.js new file mode 100644 index 00000000..6d9e2120 --- /dev/null +++ b/public/core/search-nav.js @@ -0,0 +1,95 @@ +// Import Internal Dependencies +import { SearchBar } from "../components/searchbar/searchbar"; + +// CONSTANTS +const kSearchbarId = "searchbar-tpl"; + +export function initSearchNav(data, nsn, secureDataSet) { + const searchNavElement = document.getElementById("search-nav"); + // reset + searchNavElement.innerHTML = ""; + const pkgs = data.lru; + const hasAtLeast2Packages = pkgs.length > 1; + const hasExactly2Packages = pkgs.length === 2; + const packagesContainer = document.createElement("div"); + packagesContainer.classList.add("packages"); + + for (const pkg of pkgs) { + // Initialize Search nav + const pkgElement = document.createElement("div"); + pkgElement.classList.add("package"); + if (pkg === data.current) { + window.activePackage = pkg; + pkgElement.classList.add("active"); + } + pkgElement.textContent = pkg; + pkgElement.addEventListener("click", () => { + if (window.activePackage !== pkg) { + window.socket.send(JSON.stringify({ pkg, action: "SEARCH" })); + } + }); + + if (hasAtLeast2Packages && pkg !== data.root) { + addRemoveButton(pkgElement, { hasExactly2Packages }); + } + + packagesContainer.appendChild(pkgElement); + } + + searchNavElement.appendChild(packagesContainer); + + const plusButtonElement = document.createElement("button"); + plusButtonElement.classList.add("add"); + plusButtonElement.textContent = "+"; + plusButtonElement.addEventListener("click", () => { + window.navigation.setNavByName("search--view"); + }); + + searchNavElement.appendChild(plusButtonElement); + + const searchElement = document.getElementById(kSearchbarId); + const searchElementClone = searchElement.content.cloneNode(true); + searchNavElement.appendChild(searchElementClone); + + // Initialize searchbar + { + const dataListElement = document.getElementById("package-list"); + for (const info of secureDataSet.packages) { + const content = `

${info.flags} ${info.name}

${info.version}`; + dataListElement.insertAdjacentHTML("beforeend", `
${content}
`); + } + } + window.searchbar = new SearchBar(nsn, secureDataSet.linker); + + const footerElement = document.createElement("div"); + footerElement.classList.add("search-nav-footer"); + + searchNavElement.appendChild(footerElement); +} + +function addRemoveButton(pkgElement, options) { + const { + hasExactly2Packages + } = options; + // we allow to remove a package when at least 2 packages are present + const removeButton = document.createElement("button"); + removeButton.classList.add("remove"); + removeButton.textContent = "x"; + removeButton.addEventListener("click", (event) => { + event.stopPropagation(); + // we remove the x button from textContent + const pkgToRemove = pkgElement.textContent.slice(0, -1); + window.socket.send(JSON.stringify({ action: "REMOVE", pkg: pkgToRemove })); + + if (hasExactly2Packages) { + const allPackages = [...document.getElementById("search-nav").querySelectorAll(".package")]; + for (const pkg of allPackages) { + const removeBtn = pkg.querySelector(".remove"); + if (removeBtn) { + removeBtn.remove(); + } + } + } + }, { once: true }); + pkgElement.appendChild(removeButton); +} diff --git a/public/main.css b/public/main.css index 7e90e505..138021b3 100644 --- a/public/main.css +++ b/public/main.css @@ -16,6 +16,7 @@ @import url("./components/searchbar/searchbar.css"); @import url("./components/views/home/home.css"); @import url("./components/views/network/network.css"); +@import url("./components/views/search/search.css"); @import url("./components/views/settings/settings.css"); @import url("./components/wiki/wiki.css"); @import url("./components/gauge/gauge.css"); diff --git a/public/main.js b/public/main.js index 1e65c0cf..f60c1c9d 100644 --- a/public/main.js +++ b/public/main.js @@ -5,7 +5,6 @@ import { NodeSecureDataSet, NodeSecureNetwork } from "@nodesecure/vis-network"; import { PackageInfo } from "./components/package/package.js"; import { ViewNavigation } from "./components/navigation/navigation.js"; import { Wiki } from "./components/wiki/wiki.js"; -import { SearchBar } from "./components/searchbar/searchbar.js"; import { Popup } from "./components/popup/popup.js"; import { Locker } from "./components/locker/locker.js"; import { Legend } from "./components/legend/legend.js"; @@ -13,23 +12,61 @@ import { Legend } from "./components/legend/legend.js"; // Import Views Components import { Settings } from "./components/views/settings/settings.js"; import { HomeView } from "./components/views/home/home.js"; +import { SearchView } from "./components/views/search/search.js"; // Import Core Components import { NetworkNavigation } from "./core/network-navigation.js"; import { i18n } from "./core/i18n.js"; +import { initSearchNav } from "./core/search-nav.js"; // Import Utils import * as utils from "./common/utils.js"; +let secureDataSet; +let nsn; +let searchview; + document.addEventListener("DOMContentLoaded", async() => { + window.scannedPackageCache = []; window.locker = null; window.popup = new Popup(); window.settings = await new Settings().fetchUserConfig(); window.i18n = await new i18n().fetch(); window.navigation = new ViewNavigation(); window.wiki = new Wiki(); + + await init(); + + window.socket = new WebSocket(`ws://${window.location.hostname}:1338`); + window.socket.addEventListener("message", async(event) => { + const data = JSON.parse(event.data); + if (data.rootDependencyName) { + window.activePackage = data.rootDependencyName; + await init({ navigateToNetworkView: true }); + } + else if (data.status === "INIT" || data.status === "RELOAD") { + window.scannedPackageCache = data.older; + console.log( + "[INFO] Older packages are loaded!", + window.scannedPackageCache + ); + initSearchNav(data, nsn, secureDataSet); + searchview.reset(); + } + else if (data.status === "SCAN") { + searchview.onScan(data.pkg); + } + }); + + window.onbeforeunload = () => { + window.socket.onclose = () => void 0; + window.socket.close(); + }; +}); + +async function init(options = { navigateToNetworkView: false }) { let packageInfoOpened = false; - const secureDataSet = new NodeSecureDataSet({ + secureDataSet = new NodeSecureDataSet({ flagsToIgnore: window.settings.config.ignore.flags, warningsToIgnore: window.settings.config.ignore.warnings }); @@ -39,10 +76,11 @@ document.addEventListener("DOMContentLoaded", async() => { // Initialize vis Network NodeSecureNetwork.networkElementId = "dependency-graph"; - const nsn = new NodeSecureNetwork(secureDataSet, { i18n: window.i18n[utils.currentLang()] }); + nsn = new NodeSecureNetwork(secureDataSet, { i18n: window.i18n[utils.currentLang()] }); window.locker = new Locker(nsn); const legend = new Legend({ show: window.settings.config.showFriendlyDependencies }); new HomeView(secureDataSet, nsn); + searchview ??= new SearchView(secureDataSet, nsn); window.addEventListener("package-info-closed", () => { networkNavigation.currentNodeParams = null; @@ -86,6 +124,8 @@ document.addEventListener("DOMContentLoaded", async() => { ); const { nodes } = secureDataSet.build(); nsn.nodes.update(nodes.get()); + const rootNode = secureDataSet.linker.get(0); + window.activePackage = rootNode.name; if (networkNavigation.currentNodeParams !== null) { window.navigation.setNavByName("network--view"); @@ -101,15 +141,23 @@ document.addEventListener("DOMContentLoaded", async() => { } }); - // Initialize searchbar - { - const dataListElement = document.getElementById("package-list"); - for (const info of secureDataSet.packages) { - const content = `

${info.flags} ${info.name}

${info.version}`; - dataListElement.insertAdjacentHTML("beforeend", `
${content}
`); + if (options.navigateToNetworkView) { + window.navigation.setNavByName("network--view"); + } + + // update search nav + const searchNavElement = document.getElementById("search-nav"); + const pkgs = searchNavElement.querySelectorAll(".package"); + for (const pkg of pkgs) { + if (pkg.textContent.startsWith(window.activePackage)) { + pkg.classList.add("active"); + } + else { + pkg.classList.remove("active"); } } - window.searchbar = new SearchBar(nsn, secureDataSet.linker); + + console.log("[INFO] Node-Secure is ready!"); async function updateShowInfoMenu(params) { if (params.nodes.length === 0) { @@ -139,4 +187,4 @@ document.addEventListener("DOMContentLoaded", async() => { return void 0; } -}); +} diff --git a/scripts/clear-cache.js b/scripts/clear-cache.js new file mode 100644 index 00000000..06577b47 --- /dev/null +++ b/scripts/clear-cache.js @@ -0,0 +1,9 @@ +// Import Third-party Dependencies +import cacache from "cacache"; + +// Import Internal Dependencies +import { CACHE_PATH } from "../src/http-server/cache.js"; + +await cacache.rm.all(CACHE_PATH); + +console.log("Cache cleared successfully!"); diff --git a/src/commands/scorecard.js b/src/commands/scorecard.js index dafc2ff8..47b4d348 100644 --- a/src/commands/scorecard.js +++ b/src/commands/scorecard.js @@ -41,7 +41,7 @@ export async function main(repo, opts) { try { const [repo, vcs] = result.unwrap(); repository = repo; - platform = vcs.slice(-4) === ".com" ? vsc : `${vcs}.com`; + platform = vcs.slice(-4) === ".com" ? vcs : `${vcs}.com`; } catch (error) { console.log(white().bold(result.err)); diff --git a/src/http-server/cache.js b/src/http-server/cache.js new file mode 100644 index 00000000..ea0316b3 --- /dev/null +++ b/src/http-server/cache.js @@ -0,0 +1,132 @@ +// Import Node.js Dependencies +import os from "node:os"; +import path from "node:path"; +import fs from "node:fs"; + +// Import Third-party Dependencies +import cacache from "cacache"; + +// Import Internal Dependencies +import { logger } from "./logger.js"; + +// CONSTANTS +const kConfigCache = "___config"; +const kPayloadsCache = "___payloads"; +const kPayloadsPath = path.join(os.homedir(), ".nsecure", "payloads"); +const kMaxPayloads = 3; + +export const CACHE_PATH = path.join(os.tmpdir(), "nsecure-cli"); +export const DEFAULT_PAYLOAD_PATH = path.join(process.cwd(), "nsecure-result.json"); + +class _AppCache { + constructor() { + fs.mkdirSync(kPayloadsPath, { recursive: true }); + } + + async updateConfig(newValue) { + await cacache.put(CACHE_PATH, kConfigCache, JSON.stringify(newValue)); + } + + async getConfig() { + const { data } = await cacache.get(CACHE_PATH, kConfigCache); + + return JSON.parse(data.toString()); + } + + updatePayload(pkg, payload) { + fs.writeFileSync(path.join(kPayloadsPath, pkg), JSON.stringify(payload)); + } + + async getPayload(pkg) { + try { + return JSON.parse(fs.readFileSync(path.join(kPayloadsPath, pkg.replaceAll("/", "-")), "utf-8")); + } + catch (err) { + logger.error(`[CACHE | GET_PAYLOAD](pkg: ${pkg}|cache: not found)`); + + throw err; + } + } + + async getPayloadOrNull(pkg) { + try { + return await this.getPayload(pkg); + } + catch { + return null; + } + } + + async updatePayloadsList(payloadsList) { + await cacache.put(CACHE_PATH, kPayloadsCache, JSON.stringify(payloadsList)); + } + + async payloadsList() { + try { + const { data } = await cacache.get(CACHE_PATH, kPayloadsCache); + + return JSON.parse(data.toString()); + } + catch (err) { + logger.error(`[CACHE | PAYLOADS_LIST](cache: not found)`); + + throw err; + } + } + + async #initDefaultPayloadsList() { + const payload = JSON.parse(fs.readFileSync(DEFAULT_PAYLOAD_PATH, "utf-8")); + const version = Object.keys(payload.dependencies[payload.rootDependencyName].versions)[0]; + const formatted = `${payload.rootDependencyName}@${version}`; + const payloadsList = { + lru: [formatted], + current: formatted, + older: [], + lastUsed: { + [formatted]: Date.now() + }, + root: formatted + }; + // eslint-disable-next-line @stylistic/max-len + logger.info(`[CACHE | INIT_PAYLOADS_LIST](dep: ${formatted}|version: ${version}|rootDependencyName: ${payload.rootDependencyName})`); + await cacache.put(CACHE_PATH, kPayloadsCache, JSON.stringify(payloadsList)); + this.updatePayload(formatted.replaceAll("/", "-"), payload); + } + + async initPayloadsList() { + const packagesInFolder = fs.readdirSync(kPayloadsPath); + if (packagesInFolder.length === 0) { + this.#initDefaultPayloadsList(); + + return; + } + + const list = packagesInFolder.map(({ name }) => name); + logger.info(`[CACHE | INIT_PAYLOADS_LIST](list: ${list})`); + + await cacache.put(CACHE_PATH, kPayloadsCache, JSON.stringify({ list, current: list[0] })); + } + + removePayload(pkg) { + fs.rmSync(path.join(kPayloadsPath, pkg)); + } + + async removeLastLRU() { + const { lru, lastUsed, older, root } = await this.payloadsList(); + if (lru.length < kMaxPayloads) { + return { lru, older, lastUsed, root }; + } + const packageToBeRemoved = Object.keys(lastUsed) + .filter((key) => lru.includes(key)) + .sort((a, b) => lastUsed[a] - lastUsed[b])[0]; + + return { + lru: lru.filter((pkg) => pkg !== packageToBeRemoved), + older: [...older, packageToBeRemoved], + lastUsed, + root + }; + } +} + +export const appCache = new _AppCache(); diff --git a/src/http-server/config.js b/src/http-server/config.js index 02aa02a5..d71a4fed 100644 --- a/src/http-server/config.js +++ b/src/http-server/config.js @@ -1,32 +1,42 @@ -// Import Node.js Dependencies -import path from "node:path"; -import os from "node:os"; - -// Import Third-party Depedencies -import cacache from "cacache"; +// Import Internal Dependencies +import { appCache } from "./cache.js"; +import { logger } from "./logger.js"; // CONSTANTS -const kCachePath = path.join(os.tmpdir(), "nsecure-cli"); -const kConfigKey = "cli-config"; +const kDefaultConfig = { + defaultPackageMenu: "info", + ignore: { flags: [], warnings: [] } +}; export async function get() { try { - const { data } = await cacache.get(kCachePath, kConfigKey); + const config = await appCache.getConfig(); + + logger.info(`[CONFIG | GET](config: ${config})`); - return JSON.parse(data.toString()); + return config; } - catch { - const defaultValue = { - defaultPackageMenu: "info", - ignore: { flags: [], warnings: [] } - }; + catch (err) { + logger.error(`[CONFIG | GET](error: ${err.message})`); - await cacache.put(kCachePath, kConfigKey, JSON.stringify(defaultValue)); + await appCache.updateConfig(kDefaultConfig); - return defaultValue; + logger.info(`[CONFIG | GET](fallback to default: ${JSON.stringify(kDefaultConfig)})`); + + return kDefaultConfig; } } export async function set(newValue) { - await cacache.put(kCachePath, kConfigKey, JSON.stringify(newValue)); + logger.info(`[CONFIG | SET](config: ${newValue})`); + try { + await appCache.updateConfig(newValue); + + logger.info(`[CONFIG | SET](sucess)`); + } + catch (err) { + logger.error(`[CONFIG | SET](error: ${err.message})`); + + throw err; + } } diff --git a/src/http-server/endpoints/data.js b/src/http-server/endpoints/data.js index 6a5e34d8..06fb8f55 100644 --- a/src/http-server/endpoints/data.js +++ b/src/http-server/endpoints/data.js @@ -1,18 +1,47 @@ // Import Node.js Dependencies import fs from "node:fs"; -import { pipeline } from "node:stream"; +import path from "node:path"; + +// Import Third-party Dependencies +import send from "@polka/send-type"; // Import Internal Dependencies -import { context } from "../context.js"; +import { appCache } from "../cache.js"; +import { logger } from "../logger.js"; + +// CONSTANTS +const kDefaultPayloadPath = path.join(process.cwd(), "nsecure-result.json"); export async function get(req, res) { - const { dataFilePath } = context.getStore(); + try { + const { current, lru } = await appCache.payloadsList(); + logger.info(`[DATA | GET](current: ${current})`); + logger.debug(`[DATA | GET](lru: ${lru})`); + + const formatted = current.replaceAll("/", "-"); + send(res, 200, await appCache.getPayload(formatted)); + } + catch { + logger.error(`[DATA | GET](No cache yet. Creating one...)`); + + const payload = JSON.parse(fs.readFileSync(kDefaultPayloadPath, "utf-8")); + const version = Object.keys(payload.dependencies[payload.rootDependencyName].versions)[0]; + const formatted = `${payload.rootDependencyName}@${version}`; + const payloadsList = { + lru: [formatted], + current: formatted, + older: [], + lastUsed: { + [formatted]: Date.now() + }, + root: formatted + }; + logger.info(`[DATA | GET](dep: ${formatted}|version: ${version}|rootDependencyName: ${payload.rootDependencyName})`); - res.writeHead(200, { "Content-Type": "application/json" }); + await appCache.updatePayloadsList(payloadsList); + appCache.updatePayload(formatted.replaceAll("/", "-"), payload); + logger.info(`[DATA | GET](cache: created|payloadsList: ${payloadsList.lru})`); - pipeline(fs.createReadStream(dataFilePath), res, (err) => { - if (err) { - console.error(err); - } - }); + send(res, 200, payload); + } } diff --git a/src/http-server/endpoints/search.js b/src/http-server/endpoints/search.js new file mode 100644 index 00000000..bf0d95f5 --- /dev/null +++ b/src/http-server/endpoints/search.js @@ -0,0 +1,41 @@ +// Import Third-party Dependencies +import send from "@polka/send-type"; +import * as npm from "@nodesecure/npm-registry-sdk"; + +// Import Internal Dependencies +import { logger } from "../logger.js"; + +export async function get(req, res) { + const { packageName } = req.params; + logger.info(`[SEARCH: GET](packageName: ${packageName}|formatted: ${decodeURIComponent(packageName)})`); + + const { objects, total } = await npm.search({ + text: decodeURIComponent(packageName) + }); + logger.debug(`[SEARCH: GET](npmSearchResult: ${JSON.stringify(objects.map((pkg) => pkg.package.name))})`); + + send(res, 200, { + count: total, + result: objects.map((pkg) => { + return { + name: pkg.package.name, + version: pkg.package.version, + description: pkg.package.description + }; + }) + }); +} + +export async function versions(req, res) { + const { packageName } = req.params; + + logger.info(`[SEARCH: VERSIONS](packageName: ${packageName}|formatted: ${decodeURIComponent(packageName)})`); + + const packument = await npm.packument(decodeURIComponent(packageName)); + const versions = Object.keys(packument.versions); + + logger.info(`[SEARCH: VERSIONS](packageName: ${packageName}|versions: ${versions})`); + logger.debug(`[SEARCH: VERSIONS](packument: ${packument})`); + + send(res, 200, versions); +} diff --git a/src/http-server/index.js b/src/http-server/index.js index 93cfa9e2..ebf0b865 100644 --- a/src/http-server/index.js +++ b/src/http-server/index.js @@ -6,22 +6,27 @@ import kleur from "kleur"; import polka from "polka"; import open from "open"; import * as i18n from "@nodesecure/i18n"; +import { WebSocketServer } from "ws"; // Import Internal Dependencies import * as root from "./endpoints/root.js"; import * as data from "./endpoints/data.js"; import * as flags from "./endpoints/flags.js"; import * as config from "./endpoints/config.js"; +import * as search from "./endpoints/search.js"; import * as bundle from "./endpoints/bundle.js"; import * as npmDownloads from "./endpoints/npm-downloads.js"; import * as scorecard from "./endpoints/ossf-scorecard.js"; import * as locali18n from "./endpoints/i18n.js"; import * as report from "./endpoints/report.js"; import * as middleware from "./middleware.js"; +import * as wsHandlers from "./websocket/index.js"; +import { logger } from "./logger.js"; export function buildServer(dataFilePath, options = {}) { const httpConfigPort = typeof options.port === "number" ? options.port : 0; const openLink = typeof options.openLink === "boolean" ? options.openLink : true; + const enableWS = options.enableWS ?? process.env.NODE_ENV !== "test"; fs.accessSync(dataFilePath, fs.constants.R_OK | fs.constants.W_OK); @@ -30,12 +35,15 @@ export function buildServer(dataFilePath, options = {}) { httpServer.use(middleware.buildContextMiddleware(dataFilePath)); httpServer.use(middleware.addStaticFiles); httpServer.get("/", root.get); - httpServer.get("/data", data.get); + httpServer.get("/data", data.get); httpServer.get("/config", config.get); httpServer.put("/config", config.save); httpServer.get("/i18n", locali18n.get); + httpServer.get("/search/:packageName", search.get); + httpServer.get("/search-versions/:packageName", search.versions); + httpServer.get("/flags", flags.getAll); httpServer.get("/flags/description/:title", flags.get); httpServer.get("/bundle/:pkgName", bundle.get); @@ -54,5 +62,29 @@ export function buildServer(dataFilePath, options = {}) { } }); + if (enableWS) { + const websocket = new WebSocketServer({ port: 1338 }); + websocket.on("connection", async(socket) => { + socket.on("message", async(rawMessage) => { + const message = JSON.parse(rawMessage); + logger.info(`[WEBSOCKET](message: ${JSON.stringify(message)})`); + + if (message.action === "SEARCH") { + wsHandlers.search(socket, message.pkg); + } + else if (message.action === "REMOVE") { + wsHandlers.remove(socket, message.pkg); + } + }); + + wsHandlers.init(socket); + }); + } + return httpServer; } + +process.on("SIGINT", () => { + console.log(kleur.red().bold("SIGINT signal received.")); + process.exit(0); +}); diff --git a/src/http-server/logger.js b/src/http-server/logger.js new file mode 100644 index 00000000..a6f58926 --- /dev/null +++ b/src/http-server/logger.js @@ -0,0 +1,12 @@ +// Import Third-party Dependencies +import pino from "pino"; + +const logger = pino({ + // TODO: info + level: "debug", + transport: { + target: "pino-pretty" + } +}); + +export { logger }; diff --git a/src/http-server/websocket/index.js b/src/http-server/websocket/index.js new file mode 100644 index 00000000..9c19c095 --- /dev/null +++ b/src/http-server/websocket/index.js @@ -0,0 +1,3 @@ +export * from "./search.js"; +export * from "./remove.js"; +export * from "./init.js"; diff --git a/src/http-server/websocket/init.js b/src/http-server/websocket/init.js new file mode 100644 index 00000000..1b03068d --- /dev/null +++ b/src/http-server/websocket/init.js @@ -0,0 +1,33 @@ +// Import Internal Dependencies +import { appCache } from "../cache.js"; +import { logger } from "../logger.js"; + +export async function init(socket, lock = false) { + try { + const { current, lru, older, root } = await appCache.payloadsList(); + logger.info(`[WEBSOCKET | INIT](lru: ${lru}|older: ${older}|current: ${current}|root: ${root})`); + + if (lru === void 0 || current === void 0) { + throw new Error("Payloads list not found in cache."); + } + + socket.send(JSON.stringify({ + status: "INIT", + current, + lru, + older, + root + })); + } + catch { + logger.error(`[WEBSOCKET | INIT](No cache yet. Creating one...)`); + + if (lock) { + return; + } + + await appCache.initPayloadsList(); + + init(socket, true); + } +} diff --git a/src/http-server/websocket/remove.js b/src/http-server/websocket/remove.js new file mode 100644 index 00000000..929f0e01 --- /dev/null +++ b/src/http-server/websocket/remove.js @@ -0,0 +1,85 @@ +// Import Internal Dependencies +import { appCache } from "../cache.js"; +import { logger } from "../logger.js"; + +export async function remove(ws, pkg) { + const formattedPkg = pkg.replace("/", "-"); + logger.info(`[WEBSOCKET | REMOVE](pkg: ${pkg}|formatted: ${formattedPkg})`); + + try { + const { lru, older, current, lastUsed, root } = await appCache.payloadsList(); + logger.debug(`[WEBSOCKET | REMOVE](lru: ${lru}|current: ${current})`); + + if (lru.length === 1 && older.length === 0) { + throw new Error("Cannot remove the last package."); + } + + const lruIndex = lru.findIndex((pkgName) => pkgName === pkg); + const olderIndex = older.findIndex((pkgName) => pkgName === pkg); + + if (lruIndex === -1 && olderIndex === -1) { + throw new Error("Package not found in cache."); + } + + if (lruIndex > -1) { + logger.info(`[WEBSOCKET | REMOVE](remove from lru)`); + const updatedLru = lru.filter((pkgName) => pkgName !== pkg); + if (older.length > 0) { + // We need to move the first older package to the lru list + const olderPkg = older.sort((a, b) => { + const aDate = lastUsed[a]; + const bDate = lastUsed[b]; + + return aDate - bDate; + }); + updatedLru.push(olderPkg[0]); + older.splice(older.indexOf(olderPkg[0]), 1); + } + + const updatedList = { + lru: updatedLru, + older, + lastUsed: { + ...lastUsed, + [pkg]: void 0 + }, + current: current === pkg ? updatedLru[0] : current, + root + }; + await appCache.updatePayloadsList(updatedList); + + ws.send(JSON.stringify({ + status: "RELOAD", + ...updatedList + })); + } + else { + logger.info(`[WEBSOCKET | REMOVE](remove from older)`); + const updatedOlder = older.filter((pkgName) => pkgName !== pkg); + const updatedList = { + lru, + older: updatedOlder, + lastUsed: { + ...lastUsed, + [pkg]: void 0 + }, + current, + root + }; + await appCache.updatePayloadsList(updatedList); + + ws.send(JSON.stringify({ + status: "RELOAD", + ...updatedList + })); + } + + appCache.removePayload(formattedPkg.replaceAll("/", "-")); + } + catch (error) { + logger.error(`[WEBSOCKET | REMOVE](error: ${error.message})`); + logger.debug(error); + + throw error; + } +} diff --git a/src/http-server/websocket/search.js b/src/http-server/websocket/search.js new file mode 100644 index 00000000..9a825e77 --- /dev/null +++ b/src/http-server/websocket/search.js @@ -0,0 +1,80 @@ +// Import Third-party Dependencies +import * as Scanner from "@nodesecure/scanner"; + +// Import Internal Dependencies +import { logger } from "../logger.js"; +import { appCache } from "../cache.js"; + +export async function search(ws, pkg) { + logger.info(`[WEBSOCKET | SEARCH](pkg: ${pkg})`); + + const cache = await appCache.getPayloadOrNull(pkg); + if (cache) { + logger.info(`[WEBSOCKET | SEARCH](payload: ${pkg} found in cache)`); + const cacheList = await appCache.payloadsList(); + if (cacheList.lru.includes(pkg)) { + logger.info(`[WEBSOCKET | SEARCH](payload: ${pkg} is already in the LRU)`); + const updatedList = { + ...cacheList, + current: pkg, + lastUsed: { ...cacheList.lastUsed, [pkg]: Date.now() } + }; + await appCache.updatePayloadsList(updatedList); + ws.send(JSON.stringify(cache)); + + return; + } + const { lru, older, lastUsed, root } = await appCache.removeLastLRU(); + const updatedList = { + lru: [...new Set([...lru, pkg])], + current: pkg, + older: older.filter((pckg) => pckg !== pkg), + lastUsed: { ...lastUsed, [pkg]: Date.now() }, + root + }; + await appCache.updatePayloadsList(updatedList); + + ws.send(JSON.stringify(cache)); + ws.send(JSON.stringify({ + status: "RELOAD", + ...updatedList + })); + + return; + } + + // at this point we don't have the payload in cache so we have to scan it. + logger.info(`[WEBSOCKET | SEARCH](scan ${pkg})`); + ws.send(JSON.stringify({ status: "SCAN", pkg })); + + const payload = await Scanner.from(pkg, { maxDepth: 4 }); + const name = payload.rootDependencyName; + const version = Object.keys(payload.dependencies[name].versions)[0]; + + { + // save the payload in cache + const pkg = `${name}@${version}`; + logger.info(`[WEBSOCKET | SEARCH](scan <${pkg}> done|cache: updated|pkg: ${pkg})`); + + // update the payloads list + const { lru, older, lastUsed, root } = await appCache.removeLastLRU(); + lru.push(pkg); + appCache.updatePayload(pkg.replaceAll("/", "-"), payload); + const updatedList = { + lru: [...new Set(lru)], + older, + lastUsed: { ...lastUsed, [pkg]: Date.now() }, + current: pkg, + root + }; + await appCache.updatePayloadsList(updatedList); + + ws.send(JSON.stringify(payload)); + ws.send(JSON.stringify({ + status: "RELOAD", + ...updatedList + })); + + logger.info(`[WEBSOCKET | SEARCH](payloadsList updated|payload sent to client)`); + } +} diff --git a/test/config.test.js b/test/config.test.js index d2db3b1f..37c79d11 100644 --- a/test/config.test.js +++ b/test/config.test.js @@ -1,6 +1,4 @@ // Import Node.js Dependencies -import path from "node:path"; -import os from "node:os"; import { describe, it, before, after } from "node:test"; import assert from "node:assert"; @@ -9,12 +7,12 @@ import cacache from "cacache"; // Import Internal Dependencies import { get, set } from "../src/http-server/config.js"; +import { CACHE_PATH } from "../src/http-server/cache.js"; // CONSTANTS -const kCachePath = path.join(os.tmpdir(), "nsecure-cli"); -const kConfigKey = "cli-config"; +const kConfigKey = "___config"; -describe("config", () => { +describe("config", { concurrency: 1 }, () => { let actualConfig; before(async() => { @@ -26,7 +24,7 @@ describe("config", () => { }); it("should get default config from empty cache", async() => { - await cacache.rm(kCachePath, kConfigKey); + await cacache.rm(CACHE_PATH, kConfigKey); const value = await get(); assert.deepStrictEqual(value, { @@ -36,7 +34,7 @@ describe("config", () => { }); it("should get config from cache", async() => { - await cacache.put(kCachePath, kConfigKey, JSON.stringify({ foo: "bar" })); + await cacache.put(CACHE_PATH, kConfigKey, JSON.stringify({ foo: "bar" })); const value = await get(); assert.deepStrictEqual(value, { foo: "bar" }); diff --git a/test/httpServer.test.js b/test/httpServer.test.js index c2ae7178..f59500d4 100644 --- a/test/httpServer.test.js +++ b/test/httpServer.test.js @@ -1,10 +1,9 @@ // Import Node.js Dependencies -import { readFileSync } from "node:fs"; +import fs from "node:fs"; import { fileURLToPath } from "node:url"; import { after, before, describe, test } from "node:test"; import { once } from "node:events"; import path from "node:path"; -import os from "node:os"; import assert from "node:assert"; // Import Third-party Dependencies @@ -18,6 +17,7 @@ import cacache from "cacache"; // Require Internal Dependencies import { buildServer } from "../src/http-server/index.js"; +import { CACHE_PATH } from "../src/http-server/cache.js"; // CONSTANTS const HTTP_PORT = 17049; @@ -25,33 +25,40 @@ const HTTP_URL = new URL(`http://localhost:${HTTP_PORT}`); const __dirname = path.dirname(fileURLToPath(import.meta.url)); const JSON_PATH = path.join(__dirname, "fixtures", "httpServer.json"); -const INDEX_HTML = readFileSync(path.join(__dirname, "..", "views", "index.html"), "utf-8"); +const INDEX_HTML = fs.readFileSync(path.join(__dirname, "..", "views", "index.html"), "utf-8"); -const kCachePath = path.join(os.tmpdir(), "nsecure-cli"); -const kConfigKey = "cli-config"; +const kConfigKey = "___config"; const kGlobalDispatcher = getGlobalDispatcher(); const kMockAgent = new MockAgent(); const kBundlephobiaPool = kMockAgent.get("https://bundlephobia.com"); +const kDefaultPayloadPath = path.join(process.cwd(), "nsecure-result.json"); -describe("httpServer", () => { +describe("httpServer", { concurrency: 1 }, () => { let httpServer; before(async() => { setGlobalDispatcher(kMockAgent); + await i18n.extendFromSystemPath( + path.join(__dirname, "..", "i18n") + ); httpServer = buildServer(JSON_PATH, { port: HTTP_PORT, - openLink: false + openLink: false, + enableWS: false }); await once(httpServer.server, "listening"); - await i18n.extendFromSystemPath( - path.join(__dirname, "..", "i18n") - ); enableDestroy(httpServer.server); + + if (fs.existsSync(kDefaultPayloadPath) === false) { + // When running tests on CI, we need to create the nsecure-result.json file + const payload = fs.readFileSync(JSON_PATH, "utf-8"); + fs.writeFileSync(kDefaultPayloadPath, payload); + } }, { timeout: 5000 }); - after(() => { + after(async() => { httpServer.server.destroy(); kBundlephobiaPool.close(); setGlobalDispatcher(kGlobalDispatcher); @@ -123,7 +130,6 @@ describe("httpServer", () => { createReadStream: () => "foo" } }); - const consoleError = console.error; const logs = []; console.error = (data) => logs.push(data); @@ -135,31 +141,7 @@ describe("httpServer", () => { const result = await get(new URL("/data", HTTP_URL)); assert.equal(result.statusCode, 200); - assert.equal(result.headers["content-type"], "application/json"); - }); - - test("'/data' should fail", async() => { - const module = await esmock("../src/http-server/endpoints/data.js", { - "../src/http-server/context.js": { - context: { - getStore: () => { - return { dataFilePath: "foo" }; - } - } - }, - stream: { - pipeline: (stream, res, err) => err("fake error") - }, - fs: { - createReadStream: () => "foo" - } - }); - const consoleError = console.error; - const logs = []; - console.error = (data) => logs.push(data); - - await module.get({}, ({ writeHead: () => true })); - assert.deepEqual(logs, ["fake error"]); + assert.equal(result.headers["content-type"], "application/json;charset=utf-8"); }); test("'/bundle/:name/:version' should return the bundle size", async() => { @@ -230,7 +212,7 @@ describe("httpServer", () => { test("GET '/config' should return the config", async() => { const { data: actualConfig } = await get(new URL("/config", HTTP_URL)); - await cacache.put(kCachePath, kConfigKey, JSON.stringify({ foo: "bar" })); + await cacache.put(CACHE_PATH, kConfigKey, JSON.stringify({ foo: "bar" })); const result = await get(new URL("/config", HTTP_URL)); assert.deepEqual(result.data, { foo: "bar" }); @@ -254,7 +236,7 @@ describe("httpServer", () => { assert.equal(status, 204); - const inCache = await cacache.get(kCachePath, kConfigKey); + const inCache = await cacache.get(CACHE_PATH, kConfigKey); assert.deepEqual(JSON.parse(inCache.data.toString()), { fooz: "baz" }); await fetch(new URL("/config", HTTP_URL), { @@ -327,11 +309,22 @@ describe("httpServer", () => { const json = JSON.parse(result.data); assert.strictEqual(json.data.type, "Buffer"); }); + + test("'/search' should return the package list", async() => { + const result = await get(new URL("/search/nodesecure", HTTP_URL)); + + assert.equal(result.statusCode, 200); + assert.ok(result.data); + assert.ok(Array.isArray(result.data.result)); + assert.ok(result.data.count); + }); }); describe("httpServer without options", () => { let httpServer; let opened = false; + // We want to disable WS + process.env.NODE_ENV = "test"; before(async() => { const module = await esmock("../src/http-server/index.js", { @@ -343,7 +336,7 @@ describe("httpServer without options", () => { enableDestroy(httpServer.server); }); - after(() => { + after(async() => { httpServer.server.destroy(); }); diff --git a/views/index.html b/views/index.html index 3f11668b..e617f3e4 100644 --- a/views/index.html +++ b/views/index.html @@ -9,18 +9,19 @@ + NodeSecure
-