From e199e49e9d47dca7dc0c3e437c9249963b7170f6 Mon Sep 17 00:00:00 2001 From: fraxken Date: Sun, 30 Nov 2025 21:47:09 +0100 Subject: [PATCH] refactor(server): remove polka and use find-my-way + native HTTP Update workspaces/server/src/endpoints/report.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Update workspaces/server/src/endpoints/util/send.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> chore: fix review --- src/commands/http.js | 4 +- test/helpers/utils.js | 4 +- workspaces/server/README.md | 130 +++++++++++------- workspaces/server/package.json | 8 +- workspaces/server/src/ALS.ts | 17 ++- workspaces/server/src/endpoints/bundle.ts | 34 ++++- workspaces/server/src/endpoints/config.ts | 29 ++-- workspaces/server/src/endpoints/data.ts | 21 +-- workspaces/server/src/endpoints/flags.ts | 50 +++++-- workspaces/server/src/endpoints/i18n.ts | 14 +- workspaces/server/src/endpoints/index.ts | 37 +++++ .../server/src/endpoints/npm-downloads.ts | 32 ++++- .../server/src/endpoints/ossf-scorecard.ts | 36 +++-- workspaces/server/src/endpoints/report.ts | 37 +++-- workspaces/server/src/endpoints/root.ts | 18 ++- workspaces/server/src/endpoints/search.ts | 44 ++++-- .../server/src/endpoints/util/bodyParser.ts | 14 ++ workspaces/server/src/endpoints/util/send.ts | 43 ++++++ workspaces/server/src/index.ts | 89 +++++------- .../server/src/middlewares/bodyParser.ts | 24 ---- workspaces/server/src/middlewares/context.ts | 43 ------ workspaces/server/src/middlewares/index.ts | 3 - workspaces/server/src/middlewares/static.ts | 22 --- workspaces/server/test/bodyParser.test.ts | 24 ++-- workspaces/server/test/httpServer.test.ts | 49 +++---- 25 files changed, 504 insertions(+), 322 deletions(-) create mode 100644 workspaces/server/src/endpoints/index.ts create mode 100644 workspaces/server/src/endpoints/util/bodyParser.ts create mode 100644 workspaces/server/src/endpoints/util/send.ts delete mode 100644 workspaces/server/src/middlewares/bodyParser.ts delete mode 100644 workspaces/server/src/middlewares/context.ts delete mode 100644 workspaces/server/src/middlewares/index.ts delete mode 100644 workspaces/server/src/middlewares/static.ts diff --git a/src/commands/http.js b/src/commands/http.js index 11e04a2c..3521a68f 100644 --- a/src/commands/http.js +++ b/src/commands/http.js @@ -64,7 +64,7 @@ export async function start( }); httpServer.listen(httpPort, async() => { - const link = `http://localhost:${httpServer.server.address().port}`; + const link = `http://localhost:${httpServer.address().port}`; console.log(kleur.magenta().bold(await i18n.getToken("cli.http_server_started")), kleur.cyan().bold(link)); open(link); @@ -77,7 +77,7 @@ export async function start( for (const eventName of ["SIGINT", "SIGTERM"]) { process.on(eventName, () => { - httpServer.server.close(); + httpServer.close(); console.log(kleur.red().bold(`${eventName} signal received.`)); process.exit(0); diff --git a/test/helpers/utils.js b/test/helpers/utils.js index b5368503..648192d7 100644 --- a/test/helpers/utils.js +++ b/test/helpers/utils.js @@ -1,11 +1,11 @@ -export function getExpectedScorecardLines(pkgName, body) { +export function getExpectedScorecardLines(packageName, body) { const { date, score: scorePkg, checks } = body; const expectedLines = [ "", " OSSF Scorecard", "", - mockScorecardCliLine("Repository", pkgName), + mockScorecardCliLine("Repository", packageName), mockScorecardCliLine("Scan at", date), mockScorecardCliLine("Score", scorePkg), "--------------------------------------------------------------------------------" diff --git a/workspaces/server/README.md b/workspaces/server/README.md index e9157eeb..69f41769 100644 --- a/workspaces/server/README.md +++ b/workspaces/server/README.md @@ -7,11 +7,11 @@ Scorecard](https://api.securityscorecards.dev/projects/github.com/NodeSecure/cli ![size](https://img.shields.io/github/languages/code-size/NodeSecure/server?style=for-the-badge) [![build](https://img.shields.io/github/actions/workflow/status/NodeSecure/cli/server.yml?style=for-the-badge)](https://github.com/NodeSecure/cli/actions?query=workflow%3A%22server+CI%22) -NodeSecure CLI's http server based on `polka`. +NodeSecure CLI's http and websocket server. ## Requirements -- [Node.js](https://nodejs.org/en/) v20 or higher +- [Node.js](https://nodejs.org/en/) v22 or higher ## Getting Started @@ -50,7 +50,7 @@ httpServer.listen(port, async() => { ### `buildServer(dataFilePath: string, options: BuildServerOptions): polka` -Creates and configures a Polka HTTP server instance for the NodeSecure platform. +Creates and configures a Node.js HTTP server instance for the NodeSecure CLI. **Parameters** - `dataFilePath` (`string`): @@ -73,106 +73,130 @@ The i18n tokens required for the interface. **Returns** - `httpServer` (`object`): -A configured **Polka** server instance with all routes and middlewares registered. +A configured Node.js server instance with all routes and middlewares registered. ## API Endpoints + The server exposes the following REST API endpoints: -- `GET /` +### `GET /` + Render and return the main HTML page for the NodeSecure UI. -- `GET /data` +### `GET /data` + Returns the current analysis payload from the cache. -- **204**: No content if running from an empty cache. -- **200**: JSON payload with analysis data. +| Status | Description | +| ------ | ----------- | +| 204 | No content if running from an empty cache | +| 200 | JSON payload with analysis data | + +### `GET /config` -- `GET /config` Fetch the current server configuration. -- `PUT /config` +### `PUT /config` + Update and save the server configuration. -**Body**: JSON configuration object. +| Body | Description | +| ---- | ----------- | +| `config` | JSON configuration object | + +### `GET /i18n` -- `GET /i18n` Returns UI translations for supported languages (English and French). -- `GET /search/:packageName` +### `GET /search/:packageName` + Search for npm packages by name. -**Params**: -- `packageName`: The name (or partial name) of the npm package to search for. +| Param | Description | +| ----- | ----------- | +| `packageName` | The name (or partial name) of the npm package to search for | -**Response**: -- `count`: Number of results. -- `result`: Array of package objects (name, version, description). +| Response Field | Description | +| -------------- | ----------- | +| `count` | Number of results | +| `result` | Array of package objects (name, version, description) | + +### `GET /search-versions/:packageName` -- `GET /search-versions/:packageName` Get all available versions for a given npm package. -**Params**: -- `packageName`: The npm package name. +| Param | Description | +| ----- | ----------- | +| `packageName` | The npm package name | -**Response**: -Array of version strings. +**Response**: Array of version strings. + +### `GET /flags` -- `GET /flags` List all available NodeSecure flags and their metadata. -- `GET /flags/description/:title` +### `GET /flags/description/:title` + Get the HTML description for a specific flag. -**Params**: -- `title`: The flag name. +| Param | Description | +| ----- | ----------- | +| `title` | The flag name | + +### `GET /bundle/:packageName` -- `GET /bundle/:pkgName` Get bundle size information for a package from Bundlephobia. -**Params**: -- `pkgName`: The npm package name. +| Param | Description | +| ----- | ----------- | +| `packageName` | The npm package name | + +### `GET /bundle/:packageName/:version` -- `GET /bundle/:pkgName/:version` Get bundle size information for a specific version of a package from Bundlephobia. -**Params**: -- `pkgName`: The npm package name. -- `version`: The package version. +| Param | Description | +| ----- | ----------- | +| `packageName` | The npm package name | +| `version` | The package version | + +### `GET /downloads/:packageName` -- `GET /downloads/:pkgName` Get npm download statistics for the last week for a package. -**Params**: -- `pkgName`: The npm package name. +| Param | Description | +| ----- | ----------- | +| `packageName` | The npm package name | + +### `GET /scorecard/:org/:packageName` -- `GET /scorecard/:org/:pkgName` Get OSSF Scorecard results for a package repository. -**Params**: -- `org`: The organization or user. -- `pkgName`: The repository name. +| Param | Description | +| ----- | ----------- | +| `org` | The organization or user | +| `packageName` | The repository name | + +| Query | Description | +| ----- | ----------- | +| `platform` *(optional)* | The platform (default: `github.com`) | -**Query**: -`platform` (*optional*): The platform (default: `github.com`). +### `POST /report` -- `POST /report` Generate a PDF report for the current analysis. -**Body**: -- `title`: Report title. -- `includesAllDeps`: Boolean, include all dependencies or only the root. -- `theme`: Report theme. +| Body Field | Description | +| ---------- | ----------- | +| `title` | Report title | +| `includesAllDeps` | Boolean, include all dependencies or only the root | +| `theme` | Report theme | -**Response**: -PDF file as binary data. +**Response**: PDF file as binary data. ### Static Files All static files (UI, assets, etc.) are served from the project root directory. -> [!NOTE] -> For more details on each endpoint, see the corresponding files in /src/endpoints. - ## Websocket commands The `WebSocketServerInstanciator` class sets up and manages a WebSocket server for real-time communication with NodeSecure clients. It provides live updates and cache management features for package analysis. diff --git a/workspaces/server/package.json b/workspaces/server/package.json index d85d7e5a..a47c3ca3 100644 --- a/workspaces/server/package.json +++ b/workspaces/server/package.json @@ -18,20 +18,18 @@ "author": "GENTILHOMME Thomas ", "license": "MIT", "devDependencies": { - "@polka/send-type": "0.5.2", - "@types/polka": "^0.5.7", "@types/server-destroy": "^1.0.4", - "@types/ws": "^8.18.1" + "@types/ws": "^8.18.1", + "server-destroy": "1.0.1" }, "dependencies": { "@nodesecure/cache": "1.0.0", "cacache": "20.0.3", "chokidar": "5.0.0", + "find-my-way": "9.3.0", "glob": "13.0.0", "pino": "10.1.0", "pino-pretty": "13.1.2", - "polka": "0.5.2", - "server-destroy": "1.0.1", "sirv": "3.0.2", "ts-pattern": "5.9.0", "ws": "8.18.3", diff --git a/workspaces/server/src/ALS.ts b/workspaces/server/src/ALS.ts index f63b1dcb..ddb67f31 100644 --- a/workspaces/server/src/ALS.ts +++ b/workspaces/server/src/ALS.ts @@ -2,6 +2,19 @@ import { AsyncLocalStorage } from "node:async_hooks"; // Import Internal Dependencies -import type { AyncStoreContext } from "./middlewares/context.ts"; +import type { ViewBuilder } from "./ViewBuilder.class.ts"; -export const context = new AsyncLocalStorage(); +export type NestedStringRecord = { + [key: string]: string | NestedStringRecord; +}; + +export interface AsyncStoreContext { + dataFilePath?: string; + i18n: { + english: NestedStringRecord; + french: NestedStringRecord; + }; + viewBuilder: ViewBuilder; +} + +export const context = new AsyncLocalStorage(); diff --git a/workspaces/server/src/endpoints/bundle.ts b/workspaces/server/src/endpoints/bundle.ts index 16d96e21..826d7c19 100644 --- a/workspaces/server/src/endpoints/bundle.ts +++ b/workspaces/server/src/endpoints/bundle.ts @@ -1,7 +1,14 @@ +// Import Node.js Dependencies +import type { + IncomingMessage, + ServerResponse +} from "node:http"; + // Import Third-party Dependencies import * as httpie from "@openally/httpie"; -import send from "@polka/send-type"; -import type { Request, Response } from "express-serve-static-core"; + +// Import Internal Dependencies +import { send } from "./util/send.ts"; // CONSTANTS const kBaseBundlePhobiaUrl = "https://bundlephobia.com/api"; @@ -15,21 +22,34 @@ interface BundlePhobiaResponse { }[]; } -export async function get(req: Request, res: Response) { - const { pkgName, version } = req.params; +export async function get( + _: IncomingMessage, + res: ServerResponse, + params: Record +) { + const { packageName, version } = params; + if (!packageName) { + return send(res, { + error: "Package name is missing." + }, { code: 400 }); + } - const pkgTemplate = version ? `${pkgName.replaceAll("%2F", "/")}@${version}` : pkgName; + const pkgTemplate = version ? + `${packageName.replaceAll("%2F", "/")}@${version}` : + packageName; try { const { data } = await httpie.get(`${kBaseBundlePhobiaUrl}/size?package=${pkgTemplate}`); const { gzip, size, dependencySizes } = data; - return send(res, 200, { + return send(res, { gzip, size, dependencySizes }); } catch (error: any) { - return send(res, error.statusCode, { error: error.statusMessage }); + return send(res, { error: error.statusMessage }, { + code: error.statusCode ?? 500 + }); } } diff --git a/workspaces/server/src/endpoints/config.ts b/workspaces/server/src/endpoints/config.ts index b4db78c8..04722226 100644 --- a/workspaces/server/src/endpoints/config.ts +++ b/workspaces/server/src/endpoints/config.ts @@ -1,20 +1,33 @@ +// Import Node.js Dependencies +import type { + IncomingMessage, + ServerResponse +} from "node:http"; + // Import Third-party Dependencies -import send from "@polka/send-type"; -import type { Request, Response } from "express-serve-static-core"; +import type { AppConfig } from "@nodesecure/cache"; // Import Internal Dependencies import * as config from "../config.ts"; -import { bodyParser } from "../middlewares/bodyParser.ts"; +import { bodyParser } from "./util/bodyParser.ts"; +import { send } from "./util/send.ts"; -export async function get(_req: Request, res: Response) { +export async function get( + _req: IncomingMessage, + res: ServerResponse +) { const result = await config.get(); - send(res, 200, result); + send(res, result); } -export async function save(req: Request, res: Response) { - const data = await bodyParser(req); +export async function save( + req: IncomingMessage, + res: ServerResponse +) { + const data = await bodyParser(req); await config.set(data); - send(res, 204); + res.statusCode = 204; + res.end(); } diff --git a/workspaces/server/src/endpoints/data.ts b/workspaces/server/src/endpoints/data.ts index e4d63e21..67365346 100644 --- a/workspaces/server/src/endpoints/data.ts +++ b/workspaces/server/src/endpoints/data.ts @@ -1,23 +1,28 @@ // Import Node.js Dependencies import fs from "node:fs"; import path from "node:path"; - -// Import Third-party Dependencies -import send from "@polka/send-type"; -import type { Request, Response } from "express-serve-static-core"; +import type { + IncomingMessage, + ServerResponse +} from "node:http"; // Import Internal Dependencies import { context } from "../ALS.ts"; import { logger } from "../logger.ts"; import { cache } from "../cache.ts"; +import { send } from "./util/send.ts"; // CONSTANTS const kDefaultPayloadPath = path.join(process.cwd(), "nsecure-result.json"); -export async function get(_req: Request, res: Response) { +export async function get( + _req: IncomingMessage, + res: ServerResponse +) { if (cache.startFromZero) { logger.info("[data|get](no content)"); - send(res, 204); + res.statusCode = 204; + res.end(); return; } @@ -27,7 +32,7 @@ export async function get(_req: Request, res: Response) { logger.info(`[data|get](current: ${current})`); logger.debug(`[data|get](lru: ${mru})`); - send(res, 200, cache.getPayload(current)); + send(res, cache.getPayload(current)); } catch { logger.error("[data|get](No cache yet. Creating one...)"); @@ -55,6 +60,6 @@ export async function get(_req: Request, res: Response) { cache.updatePayload(formatted, payload); logger.info(`[data|get](cache: created|payloadsList: ${payloadsList.lru})`); - send(res, 200, payload); + send(res, payload); } } diff --git a/workspaces/server/src/endpoints/flags.ts b/workspaces/server/src/endpoints/flags.ts index 875accbc..466845dc 100644 --- a/workspaces/server/src/endpoints/flags.ts +++ b/workspaces/server/src/endpoints/flags.ts @@ -1,26 +1,58 @@ // Import Node.js Dependencies import stream from "node:stream"; +import type { + IncomingMessage, + ServerResponse +} from "node:http"; // Import Third-party Dependencies -import send from "@polka/send-type"; -import { getManifest, lazyFetchFlagFile, getFlags } from "@nodesecure/flags"; -import type { Request, Response } from "express-serve-static-core"; +import { + getManifest, + lazyFetchFlagFile, + getFlags +} from "@nodesecure/flags"; + +// Import Internal Dependencies +import { send } from "./util/send.ts"; // CONSTANTS const kNodeSecureFlags = getFlags(); -export function getAll(_req, res) { - send(res, 200, getManifest()); +export function getAll( + _req: IncomingMessage, + res: ServerResponse +) { + send(res, getManifest()); } -export function get(req: Request, res: Response) { - if (req.params.title !== "hasDuplicate" && !kNodeSecureFlags.has(req.params.title)) { - return send(res, 404, { error: "Not Found" }); +export function get( + _: IncomingMessage, + res: ServerResponse, + params: Record +) { + const { title } = params; + if (!title) { + return send( + res, + { error: "Title is missing." }, + { code: 400 } + ); + } + + if ( + title !== "hasDuplicate" && + !kNodeSecureFlags.has(title) + ) { + return send( + res, + { error: "Not Found" }, + { code: 404 } + ); } res.writeHead(200, { "Content-Type": "text/html" }); - return stream.pipeline(lazyFetchFlagFile(req.params.title), res, (err) => { + return stream.pipeline(lazyFetchFlagFile(title), res, (err) => { if (err) { console.error(err); } diff --git a/workspaces/server/src/endpoints/i18n.ts b/workspaces/server/src/endpoints/i18n.ts index ca1366aa..20d7b659 100644 --- a/workspaces/server/src/endpoints/i18n.ts +++ b/workspaces/server/src/endpoints/i18n.ts @@ -1,15 +1,21 @@ -// Import Third-party Dependencies -import send from "@polka/send-type"; +// Import Node.js Dependencies +import type { + IncomingMessage, + ServerResponse +} from "node:http"; // Import Internal Dependencies import { context } from "../ALS.ts"; +import { send } from "./util/send.ts"; -export async function get(_req, res) { +export async function get( + _req: IncomingMessage, + res: ServerResponse +) { const { i18n } = context.getStore()!; send( res, - 200, { english: i18n.english.ui, french: i18n.french.ui diff --git a/workspaces/server/src/endpoints/index.ts b/workspaces/server/src/endpoints/index.ts new file mode 100644 index 00000000..31955012 --- /dev/null +++ b/workspaces/server/src/endpoints/index.ts @@ -0,0 +1,37 @@ +// Import Third-party Dependencies +import router from "find-my-way"; + +// Import Internal Dependencies +import * as root from "./root.ts"; +import * as data from "./data.ts"; +import * as flags from "./flags.ts"; +import * as config from "./config.ts"; +import * as search from "./search.ts"; +import * as bundle from "./bundle.ts"; +import * as npmDownloads from "./npm-downloads.ts"; +import * as scorecard from "./ossf-scorecard.ts"; +import * as locali18n from "./i18n.ts"; +import * as report from "./report.ts"; + +export function getApiRouter() { + const apiRouter = router({ + ignoreTrailingSlash: true + }); + + apiRouter.get("/", root.get); + apiRouter.get("/data", data.get); + apiRouter.get("/config", config.get); + apiRouter.put("/config", config.save); + apiRouter.get("/i18n", locali18n.get); + apiRouter.get("/search/:packageName", search.get); + apiRouter.get("/search-versions/:packageName", search.versions); + apiRouter.get("/flags", flags.getAll); + apiRouter.get("/flags/description/:title", flags.get); + apiRouter.get("/bundle/:packageName", bundle.get); + apiRouter.get("/bundle/:packageName/:version", bundle.get); + apiRouter.get("/downloads/:packageName", npmDownloads.get); + apiRouter.get("/scorecard/:org/:packageName", scorecard.get); + apiRouter.post("/report", report.post); + + return apiRouter; +} diff --git a/workspaces/server/src/endpoints/npm-downloads.ts b/workspaces/server/src/endpoints/npm-downloads.ts index 01a468f1..b6569851 100644 --- a/workspaces/server/src/endpoints/npm-downloads.ts +++ b/workspaces/server/src/endpoints/npm-downloads.ts @@ -1,17 +1,35 @@ +// Import Node.js Dependencies +import type { + IncomingMessage, + ServerResponse +} from "node:http"; + // Import Third-party Dependencies import { downloads } from "@nodesecure/npm-registry-sdk"; -import send from "@polka/send-type"; -import type { Request, Response } from "express-serve-static-core"; -export async function get(req: Request, res: Response) { - const { pkgName } = req.params; +// Import Internal Dependencies +import { send } from "./util/send.ts"; + +export async function get( + _: IncomingMessage, + res: ServerResponse, + params: Record +) { + const { packageName } = params; + if (!packageName) { + return send(res, { + error: "Package name is missing." + }, { code: 400 }); + } try { - const data = await downloads(`${pkgName.replaceAll("%2F", "/")}`, "last-week"); + const data = await downloads(`${packageName.replaceAll("%2F", "/")}`, "last-week"); - return send(res, 200, data); + return send(res, data); } catch (error: any) { - return send(res, error.statusCode, { error: error.statusMessage }); + return send(res, { error: error.statusMessage }, { + code: error.statusCode ?? 500 + }); } } diff --git a/workspaces/server/src/endpoints/ossf-scorecard.ts b/workspaces/server/src/endpoints/ossf-scorecard.ts index 8bb99612..94359aa7 100644 --- a/workspaces/server/src/endpoints/ossf-scorecard.ts +++ b/workspaces/server/src/endpoints/ossf-scorecard.ts @@ -1,37 +1,53 @@ +// Import Node.js Dependencies +import type { + IncomingMessage, + ServerResponse +} from "node:http"; + // Import Third-party Dependencies import * as scorecard from "@nodesecure/ossf-scorecard-sdk"; -import send from "@polka/send-type"; -import type { Request, Response } from "express-serve-static-core"; + +// Import Internal Dependencies +import { send } from "./util/send.ts"; interface Params { org: string; - pkgName: string; + packageName: string; } interface Query { platform?: "github.com" | "gitlab.com"; } -export async function get(req: Request, res: Response) { - const { org, pkgName } = req.params; - const { platform = "github.com" } = req.query; +// eslint-disable-next-line max-params +export async function get( + _: IncomingMessage, + res: ServerResponse, + params: Record, + _store: unknown, + querystring: Record +) { + const { org, packageName } = params as unknown as Params; + const { platform = "github.com" } = querystring as Query; try { - const data = await scorecard.result(`${org}/${pkgName}`, { + const data = await scorecard.result(`${org}/${packageName}`, { resolveOnVersionControl: Boolean(process.env.GITHUB_TOKEN), resolveOnNpmRegistry: false, platform }); - return send(res, 200, { + return send(res, { data }); } catch (error: any) { return send( res, - error.statusCode ?? 404, - { error: error.statusMessage ?? "Not Found" } + { error: error.statusMessage ?? "Not Found" }, + { + code: error.statusCode ?? 404 + } ); } } diff --git a/workspaces/server/src/endpoints/report.ts b/workspaces/server/src/endpoints/report.ts index 9eb58aee..100a4509 100644 --- a/workspaces/server/src/endpoints/report.ts +++ b/workspaces/server/src/endpoints/report.ts @@ -1,16 +1,19 @@ // Import Node.js Dependencies import fs from "node:fs"; +import type { + IncomingMessage, + ServerResponse +} from "node:http"; // Import Third-party Dependencies -import send from "@polka/send-type"; import { report } from "@nodesecure/report"; -import type { Request, Response } from "express-serve-static-core"; import type { RC } from "@nodesecure/rc"; // Import Internal Dependencies import { context } from "../ALS.ts"; import { cache } from "../cache.ts"; -import { bodyParser } from "../middlewares/bodyParser.ts"; +import { send } from "./util/send.ts"; +import { bodyParser } from "./util/bodyParser.ts"; // TODO: provide a non-file-based API on RC side ? const kReportPayload: Partial = { @@ -46,12 +49,17 @@ const kReportPayload: Partial = { ] }; -export async function post(req: Request, res: Response) { - const body = await bodyParser(req) as { - title: string; - includesAllDeps: boolean; - theme: "light" | "dark"; - }; +interface ReportRequestBody { + title: string; + includesAllDeps: boolean; + theme: "light" | "dark"; +} + +export async function post( + req: IncomingMessage, + res: ServerResponse +) { + const body = await bodyParser(req); const { title, includesAllDeps, theme } = body; const { dataFilePath } = context.getStore()!; @@ -84,10 +92,12 @@ export async function post(req: Request, res: Response) { reportPayload ); - return send(res, 200, { + return send(res, { data }, { - "Content-type": "application/pdf" + headers: { + "content-type": "application/pdf" + } }); } catch (err) { @@ -95,7 +105,10 @@ export async function post(req: Request, res: Response) { return send( res, - 500 + void 0, + { + code: 500 + } ); } } diff --git a/workspaces/server/src/endpoints/root.ts b/workspaces/server/src/endpoints/root.ts index 6144cc73..1476d9d8 100644 --- a/workspaces/server/src/endpoints/root.ts +++ b/workspaces/server/src/endpoints/root.ts @@ -1,11 +1,17 @@ -// Import Third-party Dependencies -import send from "@polka/send-type"; -import type { Request, Response } from "express-serve-static-core"; +// Import Node.js Dependencies +import type { + IncomingMessage, + ServerResponse +} from "node:http"; // Import Internal Dependencies import { context } from "../ALS.ts"; +import { send } from "./util/send.ts"; -export async function get(_req: Request, res: Response) { +export async function get( + _req: IncomingMessage, + res: ServerResponse +) { try { const { viewBuilder } = context.getStore()!; @@ -17,6 +23,8 @@ export async function get(_req: Request, res: Response) { res.end(templateStr); } catch (err: any) { - send(res, 500, { error: err.message }); + send(res, { error: err.message }, { + code: 500 + }); } } diff --git a/workspaces/server/src/endpoints/search.ts b/workspaces/server/src/endpoints/search.ts index f2c6b479..67f8c010 100644 --- a/workspaces/server/src/endpoints/search.ts +++ b/workspaces/server/src/endpoints/search.ts @@ -1,13 +1,30 @@ +// Import Node.js Dependencies +import type { + IncomingMessage, + ServerResponse +} from "node:http"; + // Import Third-party Dependencies -import send from "@polka/send-type"; import * as npm from "@nodesecure/npm-registry-sdk"; -import type { Request, Response } from "express-serve-static-core"; // Import Internal Dependencies import { logger } from "../logger.ts"; +import { send } from "./util/send.ts"; + +export async function get( + _: IncomingMessage, + res: ServerResponse, + params: Record +) { + const { packageName } = params; + if (!packageName) { + send(res, { + error: "Package name is missing." + }, { code: 400 }); + + return; + } -export async function get(req: Request, res: Response) { - const { packageName } = req.params; logger.info(`[search|get](packageName: ${packageName}|formatted: ${decodeURIComponent(packageName)})`); const { objects, total } = await npm.search({ @@ -15,7 +32,7 @@ export async function get(req: Request, res: Response) { }); logger.debug(`[search|get](npmSearchResult: ${JSON.stringify(objects.map((pkg) => pkg.package.name))})`); - send(res, 200, { + send(res, { count: total, result: objects.map((pkg) => { return { @@ -27,8 +44,19 @@ export async function get(req: Request, res: Response) { }); } -export async function versions(req: Request, res: Response) { - const { packageName } = req.params; +export async function versions( + _: IncomingMessage, + res: ServerResponse, + params: Record +) { + const { packageName } = params; + if (!packageName) { + send(res, { + error: "Package name is missing." + }, { code: 400 }); + + return; + } logger.info(`[search|versions](packageName: ${packageName}|formatted: ${decodeURIComponent(packageName)})`); @@ -38,5 +66,5 @@ export async function versions(req: Request, res: Response) { logger.info(`[search|versions](packageName: ${packageName}|versions: ${versions})`); logger.debug(`[search|versions](packument: ${packument})`); - send(res, 200, versions); + send(res, versions); } diff --git a/workspaces/server/src/endpoints/util/bodyParser.ts b/workspaces/server/src/endpoints/util/bodyParser.ts new file mode 100644 index 00000000..73dfe961 --- /dev/null +++ b/workspaces/server/src/endpoints/util/bodyParser.ts @@ -0,0 +1,14 @@ +// Import Node.js Dependencies +import type { IncomingMessage } from "node:http"; +import consumers from "node:stream/consumers"; + +export function bodyParser( + req: IncomingMessage +): Promise { + switch (req.headers["content-type"]) { + case "application/json": + return consumers.json(req) as Promise; + default: + return consumers.text(req) as Promise; + } +} diff --git a/workspaces/server/src/endpoints/util/send.ts b/workspaces/server/src/endpoints/util/send.ts new file mode 100644 index 00000000..a93cc646 --- /dev/null +++ b/workspaces/server/src/endpoints/util/send.ts @@ -0,0 +1,43 @@ +// Import Node.js Dependencies +import { + STATUS_CODES, + ServerResponse, + type OutgoingHttpHeaders +} from "node:http"; + +export interface SendOptions { + code?: number; + headers?: OutgoingHttpHeaders; +} + +type SendData = string | object; + +export function send( + res: ServerResponse, + data: SendData = "", + options: SendOptions = {} +): void { + const { code = 200, headers = {} } = options; + + let contentType = headers["content-type"] as string | undefined + ?? res.getHeader("content-type") as string | undefined; + + let body: string; + if (typeof data === "object") { + body = JSON.stringify(data); + contentType ??= "application/json;charset=utf-8"; + } + else { + body = data || STATUS_CODES[code] || ""; + contentType ??= "text/plain"; + } + + const finalHeaders = { + ...headers, + "content-type": contentType, + "content-length": Buffer.byteLength(body) + }; + + res.writeHead(code, finalHeaders); + res.end(body); +} diff --git a/workspaces/server/src/index.ts b/workspaces/server/src/index.ts index 942c0fd5..669052a5 100644 --- a/workspaces/server/src/index.ts +++ b/workspaces/server/src/index.ts @@ -1,30 +1,21 @@ // Import Node.js Dependencies import fs from "node:fs"; +import path from "node:path"; +import http from "node:http"; // Import Third-party Dependencies -import polka from "polka"; +import sirv from "sirv"; // Import Internal Dependencies -import * as root from "./endpoints/root.ts"; -import * as data from "./endpoints/data.ts"; -import * as flags from "./endpoints/flags.ts"; -import * as config from "./endpoints/config.ts"; -import * as search from "./endpoints/search.ts"; -import * as bundle from "./endpoints/bundle.ts"; -import * as npmDownloads from "./endpoints/npm-downloads.ts"; -import * as scorecard from "./endpoints/ossf-scorecard.ts"; -import * as locali18n from "./endpoints/i18n.ts"; -import * as report from "./endpoints/report.ts"; -import * as middlewares from "./middlewares/index.ts"; -import { type BuildContextMiddlewareOptions } from "./middlewares/context.ts"; -import { WebSocketServerInstanciator } from "./websocket/index.ts"; -import { logger } from "./logger.ts"; +import { getApiRouter } from "./endpoints/index.ts"; +import { ViewBuilder } from "./ViewBuilder.class.ts"; +import { + context, + type AsyncStoreContext, + type NestedStringRecord +} from "./ALS.ts"; import { cache } from "./cache.ts"; -export type NestedStringRecord = { - [key: string]: string | NestedStringRecord; -}; - export interface BuildServerOptions { hotReload?: boolean; runFromPayload?: boolean; @@ -36,7 +27,10 @@ export interface BuildServerOptions { }; } -export function buildServer(dataFilePath: string, options: BuildServerOptions) { +export function buildServer( + dataFilePath: string, + options: BuildServerOptions +) { const { hotReload = true, runFromPayload = true, @@ -45,52 +39,41 @@ export function buildServer(dataFilePath: string, options: BuildServerOptions) { i18n } = options; - const httpServer = polka(); - - const asyncStoreProperties: BuildContextMiddlewareOptions["storeProperties"] = { - i18n + const viewBuilder = new ViewBuilder({ + autoReload: hotReload, + projectRootDir, + componentsDir + }); + const store: AsyncStoreContext = { + i18n, + viewBuilder }; if (runFromPayload) { fs.accessSync(dataFilePath, fs.constants.R_OK | fs.constants.W_OK); - asyncStoreProperties.dataFilePath = dataFilePath; + store.dataFilePath = dataFilePath; } else { cache.startFromZero = true; } - httpServer.use( - middlewares.buildContextMiddleware({ - autoReload: hotReload, - storeProperties: asyncStoreProperties, - projectRootDir, - componentsDir - }) - ); - - httpServer.use(middlewares.addStaticFiles({ projectRootDir })); - httpServer.get("/", root.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); + const apiRouter = getApiRouter(); - httpServer.get("/flags", flags.getAll); - httpServer.get("/flags/description/:title", flags.get); - httpServer.get("/bundle/:pkgName", bundle.get); - httpServer.get("/bundle/:pkgName/:version", bundle.get); - httpServer.get("/downloads/:pkgName", npmDownloads.get); - // @ts-ignore - httpServer.get("/scorecard/:org/:pkgName", scorecard.get); - httpServer.post("/report", report.post); + const serving = sirv( + path.join(projectRootDir, "dist"), + { dev: true } + ); + const httpServer = http.createServer((req, res) => { + context.run(store, () => { + serving(req, res, () => apiRouter.lookup(req, res)); + }); + }); return httpServer; } +export { WebSocketServerInstanciator } from "./websocket/index.ts"; +export { logger } from "./logger.ts"; + export { - WebSocketServerInstanciator, - logger, cache }; diff --git a/workspaces/server/src/middlewares/bodyParser.ts b/workspaces/server/src/middlewares/bodyParser.ts deleted file mode 100644 index fbbd6a22..00000000 --- a/workspaces/server/src/middlewares/bodyParser.ts +++ /dev/null @@ -1,24 +0,0 @@ -// Import Node.js Dependencies -import type { IncomingMessage } from "node:http"; - -/** - * @async - * @function bodyParser - * @param {*} req - * @returns {Promise} - */ -export async function bodyParser( - req: IncomingMessage -) { - let rawBody = ""; - for await (const chunk of req) { - rawBody += chunk; - } - - switch (req.headers["content-type"]) { - case "application/json": - return JSON.parse(rawBody); - default: - return rawBody; - } -} diff --git a/workspaces/server/src/middlewares/context.ts b/workspaces/server/src/middlewares/context.ts deleted file mode 100644 index 2c745e3f..00000000 --- a/workspaces/server/src/middlewares/context.ts +++ /dev/null @@ -1,43 +0,0 @@ -// Import Third-party Dependencies -import type { Request, Response, NextFunction } from "express-serve-static-core"; - -// Import Internal Dependencies -import { context } from "../ALS.ts"; -import { ViewBuilder } from "../ViewBuilder.class.ts"; -import type { NestedStringRecord } from "../index.ts"; - -export interface AyncStoreContext { - dataFilePath?: string; - i18n: { - english: NestedStringRecord; - french: NestedStringRecord; - }; - viewBuilder: ViewBuilder; -} - -export interface BuildContextMiddlewareOptions { - autoReload?: boolean; - storeProperties: Omit; - projectRootDir: string; - componentsDir: string; -} - -export function buildContextMiddleware(options: BuildContextMiddlewareOptions) { - const { - autoReload = false, - storeProperties, - projectRootDir, - componentsDir - } = options; - - const viewBuilder = new ViewBuilder({ - autoReload, - projectRootDir, - componentsDir - }); - - return function addContext(_req: Request, _res: Response, next: NextFunction) { - const store = { ...storeProperties, viewBuilder }; - context.run(store, next); - }; -} diff --git a/workspaces/server/src/middlewares/index.ts b/workspaces/server/src/middlewares/index.ts deleted file mode 100644 index ad727c39..00000000 --- a/workspaces/server/src/middlewares/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from "./bodyParser.ts"; -export * from "./context.ts"; -export * from "./static.ts"; diff --git a/workspaces/server/src/middlewares/static.ts b/workspaces/server/src/middlewares/static.ts deleted file mode 100644 index 68628862..00000000 --- a/workspaces/server/src/middlewares/static.ts +++ /dev/null @@ -1,22 +0,0 @@ -// Import Node.js Dependencies -import path from "node:path"; - -// Import Third-party Dependencies -import sirv from "sirv"; - -export interface AddStaticFilesOptions { - projectRootDir: string; -} - -export function addStaticFiles(options: AddStaticFilesOptions) { - const { - projectRootDir - } = options; - - return sirv( - path.join(projectRootDir, "dist"), - { - dev: true - } - ); -} diff --git a/workspaces/server/test/bodyParser.test.ts b/workspaces/server/test/bodyParser.test.ts index be74d82f..95e4e953 100644 --- a/workspaces/server/test/bodyParser.test.ts +++ b/workspaces/server/test/bodyParser.test.ts @@ -1,9 +1,9 @@ // Import Node.js Dependencies -import { test } from "node:test"; +import { describe, test } from "node:test"; import assert from "node:assert"; // Import Internal Dependencies -import { bodyParser } from "../src/middlewares/bodyParser.ts"; +import { bodyParser } from "../src/endpoints/util/bodyParser.ts"; function generateFakeReq(headers = {}): any { return { @@ -16,16 +16,18 @@ function generateFakeReq(headers = {}): any { }; } -test("should parse body", async() => { - const req = generateFakeReq({ "content-type": "application/json" }); - const body = await bodyParser(req); +describe("bodyParser", () => { + test("should parse body", async() => { + const req = generateFakeReq({ "content-type": "application/json" }); + const body = await bodyParser(req); - assert.deepEqual(body, { name: "test" }); -}); + assert.deepEqual(body, { name: "test" }); + }); -test("should not parse body", async() => { - const reqWithNoHeaders = generateFakeReq(); - const body = await bodyParser(reqWithNoHeaders); + test("should not parse body", async() => { + const reqWithNoHeaders = generateFakeReq(); + const body = await bodyParser(reqWithNoHeaders); - assert.deepEqual(body, JSON.stringify({ name: "test" })); + assert.deepEqual(body, JSON.stringify({ name: "test" })); + }); }); diff --git a/workspaces/server/test/httpServer.test.ts b/workspaces/server/test/httpServer.test.ts index 1667719c..4e9f6aab 100644 --- a/workspaces/server/test/httpServer.test.ts +++ b/workspaces/server/test/httpServer.test.ts @@ -2,6 +2,7 @@ import { fileURLToPath } from "node:url"; import { after, before, describe, test } from "node:test"; import { once } from "node:events"; +import type { Server } from "node:http"; import path from "node:path"; import assert from "node:assert"; import stream from "node:stream"; @@ -11,9 +12,8 @@ import { get, post, MockAgent, getGlobalDispatcher, setGlobalDispatcher } from " import { CACHE_PATH } from "@nodesecure/cache"; import * as i18n from "@nodesecure/i18n"; import * as flags from "@nodesecure/flags"; -import enableDestroy from "server-destroy"; import cacache from "cacache"; -import type { Polka } from "polka"; +import enableDestroy from "server-destroy"; // Import Internal Dependencies import { buildServer } from "../src/index.ts"; @@ -34,7 +34,7 @@ const kProjectRootDir = path.join(import.meta.dirname, "..", "..", ".."); const kComponentsDir = path.join(kProjectRootDir, "public", "components"); describe("httpServer", { concurrency: 1 }, () => { - let httpServer: Polka; + let httpServer: Server; before(async() => { setGlobalDispatcher(kMockAgent); @@ -55,13 +55,12 @@ describe("httpServer", { concurrency: 1 }, () => { } }); httpServer.listen(kHttpPort); - await once(httpServer.server!, "listening"); - - enableDestroy(httpServer.server!); + await once(httpServer, "listening"); + enableDestroy(httpServer); }, { timeout: 5000 }); - after(async() => { - httpServer.server!.destroy(); + after(() => { + httpServer.destroy(); kBundlephobiaPool.close(); setGlobalDispatcher(kGlobalDispatcher); }); @@ -104,7 +103,11 @@ describe("httpServer", { concurrency: 1 }, () => { const logs: string[] = []; console.error = (data: string) => logs.push(data); - await flagsEndpoint.get({ params: { title: "hasWarnings" } } as any, ({ writeHead: () => true }) as any); + flagsEndpoint.get( + void 0 as any, + ({ writeHead: () => true }) as any, + { title: "hasWarnings" } + ); assert.deepEqual(logs, ["fake error"]); }); @@ -235,7 +238,7 @@ describe("httpServer", { concurrency: 1 }, () => { assert.deepEqual(keys, ["english", "french"]); }); - test("'/download/:pkgName' should return package downloads", async() => { + test("'/download/:packageName' should return package downloads", async() => { const result = await get(new URL("/downloads/fastify", kHttpURL)); assert.equal(result.statusCode, 200); @@ -245,7 +248,7 @@ describe("httpServer", { concurrency: 1 }, () => { assert.ok(result.data.end); }); - test("'/download/:pkgName' should not find package", async() => { + test("'/download/:packageName' should not find package", async() => { const wrongPackageName = "br-br-br-brah"; await assert.rejects(async() => { @@ -257,21 +260,21 @@ describe("httpServer", { concurrency: 1 }, () => { }); }); - test("'/scorecard/:org/:pkgName' should return scorecard data", async() => { + test("'/scorecard/:org/:packageName' should return scorecard data", async() => { const result = await get(new URL("/scorecard/NodeSecure/cli", kHttpURL)); assert.equal(result.statusCode, 200); assert.equal(result.data.data.repo.name, "github.com/NodeSecure/cli"); }); - test("'/scorecard/:org/:pkgName' should return scorecard data for GitLab repo", async() => { + test("'/scorecard/:org/:packageName' should return scorecard data for GitLab repo", async() => { const result = await get(new URL("/scorecard/gitlab-org/gitlab-ui?platform=gitlab.com", kHttpURL)); assert.equal(result.statusCode, 200); assert.equal(result.data.data.repo.name, "gitlab.com/gitlab-org/gitlab-ui"); }); - test("'/scorecard/:org/:pkgName' should not find repo", async() => { + test("'/scorecard/:org/:packageName' should not find repo", async() => { const wrongPackageName = "br-br-br-brah"; await assert.rejects(async() => { @@ -310,9 +313,7 @@ describe("httpServer", { concurrency: 1 }, () => { }); describe("httpServer without options", () => { - let httpServer: any; - // We want to disable WS - process.env.NODE_ENV = "test"; + let httpServer: Server; before(async() => { httpServer = buildServer(JSON_PATH, { @@ -328,23 +329,23 @@ describe("httpServer without options", () => { } }); httpServer.listen(); - await once(httpServer.server, "listening"); - enableDestroy(httpServer.server); + await once(httpServer, "listening"); + enableDestroy(httpServer); }); - after(async() => { - httpServer.server.destroy(); + after(() => { + httpServer.destroy(); }); - test("should listen on random port", async() => { - assert.ok(httpServer.server.address().port > 0); + test("should listen on random port", () => { + const address = httpServer.address(); + assert.ok(address !== null && typeof address === "object" && address.port > 0); }); }); /** * HELPERS */ - function checkBundleResponse(payload: any) { assert.ok(payload.gzip); assert.ok(payload.size);