From 2f37d8f9e80c41e6aaceda7e01f251899408d903 Mon Sep 17 00:00:00 2001 From: GENTILHOMME Thomas Date: Mon, 23 Mar 2026 23:50:42 +0100 Subject: [PATCH] refactor: improve esbuild configurations --- .changeset/tame-radios-cross.md | 5 + esbuild.common.ts | 50 ++++++++++ esbuild.config.js | 44 --------- esbuild.config.ts | 11 +++ esbuild.dev.config.ts | 158 +++++++++++--------------------- package.json | 5 +- workspaces/server/README.md | 33 ++++++- workspaces/server/src/index.ts | 23 ++++- 8 files changed, 173 insertions(+), 156 deletions(-) create mode 100644 .changeset/tame-radios-cross.md create mode 100644 esbuild.common.ts delete mode 100644 esbuild.config.js create mode 100644 esbuild.config.ts diff --git a/.changeset/tame-radios-cross.md b/.changeset/tame-radios-cross.md new file mode 100644 index 00000000..ce226528 --- /dev/null +++ b/.changeset/tame-radios-cross.md @@ -0,0 +1,5 @@ +--- +"@nodesecure/server": minor +--- + +Implement custom middleware and optional dataFilePath diff --git a/esbuild.common.ts b/esbuild.common.ts new file mode 100644 index 00000000..7c34b5c7 --- /dev/null +++ b/esbuild.common.ts @@ -0,0 +1,50 @@ +// Import Node.js Dependencies +import path from "node:path"; +import fs from "node:fs/promises"; + +// Import Third-party Dependencies +import esbuild from "esbuild"; +import { + getBuildConfiguration +} from "@nodesecure/documentation-ui/node"; + +// CONSTANTS +export const PUBLIC_DIR = path.join(import.meta.dirname, "public"); +export const OUTPUT_DIR = path.join(import.meta.dirname, "dist"); +export const NODE_MODULES_DIR = path.join(import.meta.dirname, "node_modules"); +export const IMAGES_DIR = path.join(PUBLIC_DIR, "img"); + +export function getSharedBuildOptions(): esbuild.BuildOptions { + return { + entryPoints: [ + path.join(PUBLIC_DIR, "main.js"), + path.join(PUBLIC_DIR, "main.css"), + path.join(NODE_MODULES_DIR, "highlight.js", "styles", "github.css"), + ...getBuildConfiguration().entryPoints + ], + loader: { + ".jpg": "file", + ".png": "file", + ".woff": "file", + ".woff2": "file", + ".eot": "file", + ".ttf": "file", + ".svg": "file" + }, + platform: "browser", + bundle: true, + sourcemap: true, + treeShaking: true, + outdir: OUTPUT_DIR + }; +} + +export async function copyStaticAssets(): Promise { + const imagesFiles = await fs.readdir(IMAGES_DIR); + + await Promise.all([ + ...imagesFiles + .map((name) => fs.copyFile(path.join(IMAGES_DIR, name), path.join(OUTPUT_DIR, name))), + fs.copyFile(path.join(PUBLIC_DIR, "favicon.ico"), path.join(OUTPUT_DIR, "favicon.ico")) + ]); +} diff --git a/esbuild.config.js b/esbuild.config.js deleted file mode 100644 index 7a244a62..00000000 --- a/esbuild.config.js +++ /dev/null @@ -1,44 +0,0 @@ -// Import Node.js Dependencies -import path from "node:path"; -import fs from "node:fs/promises"; - -// Import Third-party Dependencies -import esbuild from "esbuild"; -import { getBuildConfiguration } from "@nodesecure/documentation-ui/node"; - -// CONSTANTS -const kPublicDir = path.join(import.meta.dirname, "public"); -const kOutDir = path.join(import.meta.dirname, "dist"); -const kImagesDir = path.join(kPublicDir, "img"); -const kNodeModulesDir = path.join(import.meta.dirname, "node_modules"); - -await esbuild.build({ - entryPoints: [ - path.join(kPublicDir, "main.js"), - path.join(kPublicDir, "main.css"), - path.join(kNodeModulesDir, "highlight.js", "styles", "github.css"), - ...getBuildConfiguration().entryPoints - ], - loader: { - ".jpg": "file", - ".png": "file", - ".woff": "file", - ".woff2": "file", - ".eot": "file", - ".ttf": "file", - ".svg": "file" - }, - platform: "browser", - bundle: true, - sourcemap: true, - treeShaking: true, - outdir: kOutDir -}); - -const imagesFiles = await fs.readdir(kImagesDir); - -await Promise.all([ - ...imagesFiles - .map((name) => fs.copyFile(path.join(kImagesDir, name), path.join(kOutDir, name))), - fs.copyFile(path.join(kPublicDir, "favicon.ico"), path.join(kOutDir, "favicon.ico")) -]); diff --git a/esbuild.config.ts b/esbuild.config.ts new file mode 100644 index 00000000..0ec7379f --- /dev/null +++ b/esbuild.config.ts @@ -0,0 +1,11 @@ +// Import Third-party Dependencies +import esbuild from "esbuild"; + +// Import Internal Dependencies +import { + getSharedBuildOptions, + copyStaticAssets +} from "./esbuild.common.ts"; + +await esbuild.build(getSharedBuildOptions()); +await copyStaticAssets(); diff --git a/esbuild.dev.config.ts b/esbuild.dev.config.ts index a59ca108..b1b088f0 100644 --- a/esbuild.dev.config.ts +++ b/esbuild.dev.config.ts @@ -1,36 +1,31 @@ // Import Node.js Dependencies -import fsAsync from "node:fs/promises"; +import fs from "node:fs/promises"; import http from "node:http"; import path from "node:path"; // Import Third-party Dependencies -import { - getBuildConfiguration -} from "@nodesecure/documentation-ui/node"; import * as i18n from "@nodesecure/i18n"; import chokidar from "chokidar"; import esbuild from "esbuild"; import open from "open"; -import sirv from "sirv"; -import { PayloadCache } from "@nodesecure/cache"; import { WebSocketServerInstanciator, logger, - ViewBuilder, - getApiRouter, - context as als, type AsyncStoreContext + buildServer } from "@nodesecure/server"; // Import Internal Dependencies import english from "./i18n/english.js"; import french from "./i18n/french.js"; +import { + PUBLIC_DIR, + OUTPUT_DIR, + getSharedBuildOptions, + copyStaticAssets +} from "./esbuild.common.ts"; // CONSTANTS -const kPublicDir = path.join(import.meta.dirname, "public"); -const kOutDir = path.join(import.meta.dirname, "dist"); -const kImagesDir = path.join(kPublicDir, "img"); -const kNodeModulesDir = path.join(import.meta.dirname, "node_modules"); -const kComponentsDir = path.join(kPublicDir, "components"); +const kComponentsDir = path.join(PUBLIC_DIR, "components"); const kDefaultPayloadPath = path.join(process.cwd(), "nsecure-result.json"); const kDevPort = Number(process.env.DEV_PORT ?? 8080); @@ -39,69 +34,60 @@ await Promise.all([ i18n.extendFromSystemPath(path.join(import.meta.dirname, "i18n")) ]); -const imagesFiles = await fsAsync.readdir(kImagesDir); - -await Promise.all([ - ...imagesFiles - .map((name) => fsAsync.copyFile(path.join(kImagesDir, name), path.join(kOutDir, name))), - fsAsync.copyFile(path.join(kPublicDir, "favicon.ico"), path.join(kOutDir, "favicon.ico")) -]); - -const buildContext = await esbuild.context({ - entryPoints: [ - path.join(kPublicDir, "main.js"), - path.join(kPublicDir, "main.css"), - path.join(kNodeModulesDir, "highlight.js", "styles", "github.css"), - ...getBuildConfiguration().entryPoints - ], - - loader: { - ".jpg": "file", - ".png": "file", - ".woff": "file", - ".woff2": "file", - ".eot": "file", - ".ttf": "file", - ".svg": "file" - }, - platform: "browser", - bundle: true, - sourcemap: true, - treeShaking: true, - outdir: kOutDir -}); +await copyStaticAssets(); +const buildContext = await esbuild.context( + getSharedBuildOptions() +); await buildContext.watch(); const { hosts: esbuildHosts, port: esbuildPort } = await buildContext.serve({ - servedir: kOutDir + servedir: OUTPUT_DIR }); -const dataFilePath = await fsAsync.access( +const dataFilePath = await fs.access( kDefaultPayloadPath ).then(() => kDefaultPayloadPath, () => undefined); -const cache = await new PayloadCache().load(); - -if (dataFilePath === undefined) { - cache.setCurrentSpec(null); -} -else { - const payloadStr = await fsAsync.readFile(dataFilePath, "utf-8"); - const payload = JSON.parse(payloadStr); - await cache.save(payload, { useAsCurrent: true }); -} - -const store: AsyncStoreContext = { + +const { httpServer, cache, viewBuilder } = await buildServer(dataFilePath, { + projectRootDir: import.meta.dirname, + componentsDir: kComponentsDir, + runFromPayload: dataFilePath !== undefined, i18n: { english: { ui: english.ui }, french: { ui: french.ui } }, - viewBuilder: new ViewBuilder({ - projectRootDir: import.meta.dirname, - componentsDir: kComponentsDir - }), - cache -}; + middleware: (req, res, next) => { + if (req.url === "/esbuild") { + const proxyReq = http.request( + { + hostname: esbuildHosts[0], + port: esbuildPort, + path: req.url, + method: req.method, + headers: req.headers + }, + (proxyRes) => { + res.writeHead(proxyRes.statusCode!, proxyRes.headers); + proxyRes.pipe(res); + } + ); + + proxyReq.on("error", (err) => { + console.error(`[proxy/esbuild] ${err.message}`); + res.writeHead(502); + res.end("Bad Gateway"); + }); + + req.pipe(proxyReq); + + return; + } + + next(); + } +}); + const htmlWatcher = chokidar.watch(kComponentsDir, { persistent: false, awaitWriteFinish: true, @@ -110,45 +96,13 @@ const htmlWatcher = chokidar.watch(kComponentsDir, { htmlWatcher.on("change", async(filePath) => { await buildContext.rebuild().catch(console.error); - store.viewBuilder.freeCache(filePath); + viewBuilder.freeCache(filePath); }); -const serving = sirv(kOutDir, { dev: true }); - -function defaultRoute(req: http.IncomingMessage, res: http.ServerResponse) { - if (req.url === "/esbuild") { - const proxyReq = http.request( - { hostname: esbuildHosts[0], port: esbuildPort, path: req.url, method: req.method, headers: req.headers }, - (proxyRes) => { - res.writeHead(proxyRes.statusCode!, proxyRes.headers); - proxyRes.pipe(res); - } - ); - - proxyReq.on("error", (err) => { - console.error(`[proxy/esbuild] ${err.message}`); - res.writeHead(502); - res.end("Bad Gateway"); - }); - - req.pipe(proxyReq); - - return; - } - - serving(req, res, () => { - res.writeHead(404); - res.end("Not Found"); - }); -} - -const apiRouter = getApiRouter(defaultRoute); - -http.createServer((req, res) => als.run(store, () => apiRouter.lookup(req, res))) - .listen(kDevPort, () => { - console.log(`Dev server: http://localhost:${kDevPort}`); - open(`http://localhost:${kDevPort}`); - }); +httpServer.listen(kDevPort, () => { + console.log(`Dev server: http://localhost:${kDevPort}`); + open(`http://localhost:${kDevPort}`); +}); new WebSocketServerInstanciator({ cache, logger }); diff --git a/package.json b/package.json index 664e3b93..351fa827 100644 --- a/package.json +++ b/package.json @@ -16,10 +16,9 @@ "lint:css": "stylelint \"**/*.css\" \"public/**/*.js\"", "lint-fix": "npm run lint -- --fix", "prepublishOnly": "rimraf ./dist && npm run build && pkg-ok", + "dev": "npm run build:workspaces && node ./esbuild.dev.config.ts", "build": "npm run build:front && npm run build:workspaces", - "build:dev": "npm run build:workspaces && npm run build:front:dev", - "build:front": "node ./esbuild.config.js", - "build:front:dev": "node --experimental-strip-types ./esbuild.dev.config.ts", + "build:front": "node ./esbuild.config.ts", "build:workspaces": "npm run build --ws --if-present", "test": "npm run test:cli && npm run lint && npm run lint:css", "test:cli": "node --no-warnings --test test/**/*.test.js", diff --git a/workspaces/server/README.md b/workspaces/server/README.md index f7777167..b7c5632a 100644 --- a/workspaces/server/README.md +++ b/workspaces/server/README.md @@ -34,7 +34,7 @@ import { const kDataFilePath = path.join(process.cwd(), "nsecure-result.json"); -const { httpServer, cache } = await buildServer(kDataFilePath, { +const { httpServer, cache, viewBuilder } = await buildServer(kDataFilePath, { projectRootDir: path.join(import.meta.dirname, "..", ".."), componentsDir: path.join(kProjectRootDir, "public", "components") }); @@ -51,13 +51,14 @@ httpServer.listen(3000, () => { ### `buildServer(dataFilePath, options)` ```ts -buildServer(dataFilePath: string, options: BuildServerOptions): Promise<{ +buildServer(dataFilePath: string | undefined, options: BuildServerOptions): Promise<{ httpServer: http.Server; cache: PayloadCache; + viewBuilder: ViewBuilder; }> ``` -Creates and configures the HTTP server and cache for the NodeSecure CLI UI. +Creates and configures the HTTP server, cache, and view builder for the NodeSecure CLI UI. When `dataFilePath` is `undefined`, the server starts with an empty cache (equivalent to setting `runFromPayload: false`). ```ts type NestedStringRecord = { @@ -67,15 +68,41 @@ type NestedStringRecord = { interface BuildServerOptions { hotReload?: boolean; runFromPayload?: boolean; + scanType?: "cwd" | "from"; projectRootDir: string; componentsDir: string; i18n: { english: NestedStringRecord; french: NestedStringRecord; }; + /** + * Optional connect-style middleware executed before static file serving and + * the API router. Call `next()` to continue to the normal request pipeline, + * or handle the request directly without calling `next()` to short-circuit it. + */ + middleware?: ( + req: http.IncomingMessage, + res: http.ServerResponse, + next: () => void + ) => void; } ``` +The `middleware` option is useful when an additional layer needs to intercept specific requests before they reach the static file server or the API router. For example, the dev build uses it to proxy `/esbuild` SSE requests to the esbuild serve process: + +```js +const { httpServer, cache } = await buildServer(dataFilePath, { + // ... + middleware: (req, res, next) => { + if (req.url === "/esbuild") { + // proxy to esbuild dev server + return; + } + next(); + } +}); +``` + ### `WebSocketServerInstanciator` ```ts diff --git a/workspaces/server/src/index.ts b/workspaces/server/src/index.ts index 0fd466a0..f8d20c91 100644 --- a/workspaces/server/src/index.ts +++ b/workspaces/server/src/index.ts @@ -27,14 +27,20 @@ export interface BuildServerOptions { english: NestedStringRecord; french: NestedStringRecord; }; + middleware?: ( + req: http.IncomingMessage, + res: http.ServerResponse, + next: () => void + ) => void; } export async function buildServer( - dataFilePath: string, + dataFilePath: string | undefined, options: BuildServerOptions ): Promise<{ httpServer: http.Server; cache: PayloadCache; + viewBuilder: ViewBuilder; }> { const { runFromPayload = true, @@ -54,7 +60,7 @@ export async function buildServer( viewBuilder, cache }; - if (runFromPayload) { + if (runFromPayload && dataFilePath !== undefined) { const payloadStr = await fs.readFile(dataFilePath, "utf-8"); const payload = JSON.parse(payloadStr) as Payload; @@ -75,13 +81,22 @@ export async function buildServer( ); const httpServer = http.createServer((req, res) => { context.run(store, () => { - serving(req, res, () => apiRouter.lookup(req, res)); + function serve() { + serving(req, res, () => apiRouter.lookup(req, res)); + } + if (options.middleware) { + options.middleware(req, res, serve); + } + else { + serve(); + } }); }); return { httpServer, - cache + cache, + viewBuilder }; }