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 }; }