diff --git a/bin/catalyst.js b/bin/catalyst.js index b5e7817b..7cc22024 100755 --- a/bin/catalyst.js +++ b/bin/catalyst.js @@ -6,11 +6,11 @@ process.on("unhandledRejection", (err) => { const { spawnSync } = require("node:child_process") const args = process.argv.slice(2) const scriptIndex = args.findIndex( - (x) => x === "build" || x === "start" || x === "serve" || x === "devBuild" || x === "devServe" + (x) => x === "build" || x === "start" || x === "serve" || x === "devBuild" || x === "devServe" || x === "cleanCache" ) const script = scriptIndex === -1 ? args[0] : args[scriptIndex] const nodeArgs = scriptIndex > 0 ? args.slice(0, scriptIndex) : [] -if (["build", "start", "serve", "devBuild", "devServe"].includes(script)) { +if (["build", "start", "serve", "devBuild", "devServe", "cleanCache"].includes(script)) { const result = spawnSync( process.execPath, nodeArgs.concat(require.resolve("../dist/scripts/" + script)).concat(args.slice(scriptIndex + 1)), @@ -34,5 +34,5 @@ if (["build", "start", "serve", "devBuild", "devServe"].includes(script)) { } process.exit(result.status) } else { - console.log('Unknown script "' + script + '".') + console.log('Unknown script "' + script + '". Available: build, start, serve, devBuild, devServe, cleanCache') } diff --git a/src/scripts/cleanCache.js b/src/scripts/cleanCache.js new file mode 100644 index 00000000..6bafcafd --- /dev/null +++ b/src/scripts/cleanCache.js @@ -0,0 +1,30 @@ +const fs = require("fs") +const path = require("path") + +function cleanCache() { + const catalystCacheDir = path.join(process.cwd(), "node_modules/catalyst-core") + const webpackCacheDir = path.join(catalystCacheDir, ".cache/webpack") + const loadableStats = path.join(catalystCacheDir, "loadable-stats.json") + + let cleaned = false + + if (fs.existsSync(webpackCacheDir)) { + fs.rmSync(webpackCacheDir, { recursive: true, force: true }) + console.log("[Catalyst] Removed webpack filesystem cache.") + cleaned = true + } + + if (fs.existsSync(loadableStats)) { + fs.rmSync(loadableStats) + console.log("[Catalyst] Removed loadable-stats.json.") + cleaned = true + } + + if (!cleaned) { + console.log("[Catalyst] Nothing to clean — cache is already empty.") + } else { + console.log("[Catalyst] Clean complete. Next dev start will be a full rebuild.") + } +} + +cleanCache() diff --git a/src/scripts/serve.js b/src/scripts/serve.js index 9e2b8a18..4023c0d7 100644 --- a/src/scripts/serve.js +++ b/src/scripts/serve.js @@ -15,6 +15,8 @@ function serve() { const command = `cross-env APPLICATION=${name || "catalyst_app"} node -r ./dist/scripts/loadScriptsBeforeServerStarts.js ${process.cwd()}/${BUILD_OUTPUT_PATH}/startServer.js` + // const command = `cross-env APPLICATION=${name || "catalyst_app"} node -r ./dist/scripts/loadScriptsBeforeServerStarts.js --inspect=0.0.0.0:9229 ${process.cwd()}/${BUILD_OUTPUT_PATH}/startServer.js` + spawnSync(command, [], { cwd: dirname, stdio: "inherit", diff --git a/src/scripts/start.js b/src/scripts/start.js index 5b2f88d6..95590a3b 100644 --- a/src/scripts/start.js +++ b/src/scripts/start.js @@ -13,12 +13,17 @@ function start() { const argumentsObject = arrayToObject(commandLineArguments) const dirname = path.resolve(__dirname, "../../") + // Run three separate processes for better memory isolation: + // 1. Client DevServer (development.client.babel) + // 2. SSR Watcher (ssr.watcher) + // 3. Node Server (startServer) const command = ` node ./dist/scripts/checkVersion - npx babel-node -r ./dist/scripts/loadScriptsBeforeServerStarts.js ./dist/webpack/development.client.babel --no-warnings=ExperimentalWarning --no-warnings=BABEL & npx babel-node -r ./dist/scripts/loadScriptsBeforeServerStarts.js ./dist/server/startServer.js --extensions .js,.ts,.jsx,.tsx --watch-path=${process.env.PWD}/server --watch-path=${process.env.PWD}/src --ignore='__IGNORE__' --no-warnings=ExperimentalWarning --no-warnings=BABEL + npx babel-node -r ./dist/scripts/loadScriptsBeforeServerStarts.js ./dist/webpack/development.client.babel --no-warnings=ExperimentalWarning --no-warnings=BABEL & npx babel-node -r ./dist/scripts/loadScriptsBeforeServerStarts.js ./dist/server/startServer.js --extensions .js,.ts,.jsx,.tsx --ignore='__IGNORE__' --no-warnings=ExperimentalWarning --no-warnings=BABEL ` if (isWindows) { + // Client DevServer spawn( `node ./dist/scripts/checkVersion && start /b npx babel-node -r ./dist/scripts/loadScriptsBeforeServerStarts.js ./dist/webpack/development.client.babel --no-warnings=ExperimentalWarning --no-warnings=BABEL`, [], @@ -38,8 +43,9 @@ function start() { } ) + // Node Server spawn( - `node ./dist/scripts/checkVersion && npx babel-node -r ./dist/scripts/loadScriptsBeforeServerStarts.js ./dist/server/startServer.js --watch-path=${process.cwd()}/server --watch-path=${process.cwd()}/src --ignore='__IGNORE__' --no-warnings=ExperimentalWarning --no-warnings=BABEL`, + `node ./dist/scripts/checkVersion && npx babel-node -r ./dist/scripts/loadScriptsBeforeServerStarts.js ./dist/server/startServer.js --extensions .js,.ts,.jsx,.tsx --ignore='__IGNORE__' --no-warnings=ExperimentalWarning --no-warnings=BABEL`, [], { cwd: dirname, diff --git a/src/server/renderer/index.js b/src/server/renderer/index.js index 6afd8923..907b1010 100644 --- a/src/server/renderer/index.js +++ b/src/server/renderer/index.js @@ -3,14 +3,8 @@ import express from "express" const router = express.Router() router.use(function rendererMiddleware(req, res, next) { - let handler = "" - - if (process.env.NODE_ENV === "production") { - handler = require("./handler").default - } else { - handler = require("../../../.catalyst-dev/server/renderer/handler.development.js").default - } + let handler = require("./handler").default if (res.locals.rendererWrapper) { logger.debug({ message: "Handler wrapped" }) res.locals.rendererWrapper(handler)(req, res, next) diff --git a/src/server/startServer.js b/src/server/startServer.js index a8a373fa..15ae6dbc 100644 --- a/src/server/startServer.js +++ b/src/server/startServer.js @@ -1,4 +1,22 @@ +/** + * startServer.js + * + * Entry point for the Catalyst Node.js server process. Handles: + * 1. Process-level error and signal handlers + * 2. Port availability check before binding + * 3. Hot-reload in development: + * - Watches loadable-stats.json (rebuilt by webpack on every client-side change) + * to know when a new bundle is ready, then clears the require cache so the + * next request picks up the latest server bundle. + * - Watches the app's /server directory and fully restarts the Express server + * whenever server-side source files change. + * 4. Safe server restart logic that prevents EADDRINUSE by nulling the instance + * reference before closing, ensuring concurrent watcher events don't each + * queue an independent startServer() callback on the same closing socket. + */ + import fs from "fs" +import net from "net" import path from "path" import util from "node:util" import chokidar from "chokidar" @@ -9,24 +27,30 @@ import { safeCall } from "@catalyst/server/utils/validator.js" const env = process.env.NODE_ENV || "development" -// function defined by user which needs to run before server starts +// Run any app-defined pre-start hook (e.g. seed config, connect to DB) safeCall(preServerInit) +// ─── Process-level error handlers ──────────────────────────────────────────── + process.on("uncaughtException", (err, origin) => { console.log(process.stderr.fd) console.log(`Caught exception: ${err}\n` + `Exception origin: ${origin}`) }) +process.on("uncaughtExceptionMonitor", (err, origin) => { + console.log(err, origin) +}) + +process.on("unhandledRejection", (err) => console.log("unhandledRejection in Catalyst", safeStringify(err))) + +// Graceful shutdown on Ctrl-C process.on("SIGINT", function (data) { console.log("SIGINT") console.log(data) process.exit(0) }) -process.on("uncaughtExceptionMonitor", (err, origin) => { - console.log(err, origin) -}) - +// Parent process can send "shutdown" to trigger a clean exit (used by cluster managers) process.on("message", function (msg) { if (msg == "shutdown") { console.log("Closing all connections...") @@ -37,8 +61,8 @@ process.on("message", function (msg) { } }) -// if (env === "development") { -// Add better stack tracing for promises in dev mode +// ─── Helpers ────────────────────────────────────────────────────────────────── + function safeStringify(err) { try { return JSON.stringify(err) @@ -48,39 +72,71 @@ function safeStringify(err) { } } -process.on("unhandledRejection", (err) => console.log("unhandledRejection in Catalyst", safeStringify(err))) -// } +// ─── Configuration ──────────────────────────────────────────────────────────── const port = process.env.NODE_SERVER_PORT ?? 3005 const host = process.env.NODE_SERVER_HOSTNAME ?? "localhost" -let statsPath = path.join( - __dirname, - `../../`, - ".catalyst-dev", - "/server", - "/renderer", - "handler.development.js" -) +// loadable-stats.json is emitted by webpack (@loadable/webpack-plugin) after every +// successful client-side build. Its presence signals that at least one build has +// completed and the server can safely start serving SSR responses. +let statsPath = path.join(__dirname, "../../", `loadable-stats.json`) if (env === "production") { statsPath = path.join(process.env.src_path, `${process.env.BUILD_OUTPUT_PATH}/public/loadable-stats.json`) } +// Watcher on loadable-stats.json — used to detect completed webpack rebuilds const watcher = chokidar.watch(statsPath, { persistent: true }) +// Holds the active http.Server instance. Kept at module scope so restartServer() +// can close the old instance before creating a new one. let serverInstance = null -const restartServer = () => { - const server = require("./expressServer.js").default - const { APPLICATION, NODE_SERVER_HOSTNAME, NODE_SERVER_PORT } = process.env - serverInstance = server.listen({ port, host }) +// ─── Cache management ───────────────────────────────────────────────────────── + +/** + * Purges all app and framework modules from Node's require cache. + * Called after every webpack rebuild so that the next require("./expressServer") + * loads the freshly compiled server bundle instead of the stale cached version. + * node_modules are intentionally left in cache to avoid re-evaluating them on + * every hot reload. + */ +const clearServerCache = (filePath = "") => { + const projectPath = process.env.src_path + Object.keys(require.cache).forEach((key) => { + if (key.startsWith(projectPath) || key.includes("catalyst-core") || key.includes(filePath)) { + delete require.cache[key] + } + }) +} - console.log("Server Restarted!") - console.log(`You can now view ${APPLICATION} in the browser.`) - console.log(util.format("Local:", cyan(`http://${NODE_SERVER_HOSTNAME}:${NODE_SERVER_PORT}`))) +// ─── Server lifecycle ───────────────────────────────────────────────────────── + +/** + * Safely restarts the Express server. + * + * Nulls out `serverInstance` before calling close() so that any watcher events + * that fire concurrently (e.g. an editor writing multiple files on save) hit the + * `!serverInstance` guard and schedule a single startServer() — rather than each + * queuing their own close() callback that would all call startServer() once the + * socket finally closes, causing EADDRINUSE on the second and later attempts. + */ +const restartServer = () => { + if (!serverInstance) { + startServer() + return + } + const closing = serverInstance + serverInstance = null + closing.close(() => startServer()) } +/** + * Requires and starts the Express server. + * expressServer.js is re-required on every call so that, combined with + * clearServerCache(), hot-reloaded changes are always picked up. + */ const startServer = () => { const server = require("./expressServer.js").default @@ -89,7 +145,6 @@ const startServer = () => { if (error) { console.log("An error occured while starting the Application server : ", error) - // function defined by user which needs to run if server fails safeCall(onServerError) return } @@ -115,36 +170,81 @@ const startServer = () => { }) } -if (fs.existsSync(statsPath)) { - // if loadable-stats.json exist this block will start the server in development environment. This happens in dev environment when loadable stats already exists and developer is making changes to the files. lodable-stats.json will be updated after every change. - watcher.on("change", () => { - watcher.close() - if (serverInstance) { - serverInstance.close(() => startServer()) - } else { - startServer() - } - }) - // this block will start the server when your files have been compiled for production and lodable-stats.json exists. - watcher.on("add", () => { - if (env === "production") { - watcher.close() - startServer() - } +// ─── Port check ─────────────────────────────────────────────────────────────── + +/** + * Verifies the target port is free before we attempt to bind. + * Probing with a temporary server gives a clear, actionable error message + * instead of a cryptic EADDRINUSE from Express. + */ +const checkPortAvailability = (port, host) => { + return new Promise((resolve, reject) => { + const tester = net + .createServer() + .once("error", (err) => { + tester.close(() => { + if (err.code === "EADDRINUSE") { + reject( + new Error( + `Port ${port} is already in use on ${host}. Please free the port or set a different NODE_SERVER_PORT.` + ) + ) + } else { + reject(err) + } + }) + }) + .once("listening", () => { + tester.close(() => resolve()) + }) + .listen(port, host) }) -} else { - // this block will start the server in development environment for the first time when loadable-stats.json does not exists. - watcher.on("add", () => { - watcher.close() - if (serverInstance) { - serverInstance.close(() => startServer()) +} + +// ─── Startup ────────────────────────────────────────────────────────────────── + +checkPortAvailability(port, host) + .then(() => { + if (process.env.NODE_ENV === "development") { + if (fs.existsSync(statsPath)) { + // loadable-stats.json already exists (e.g. dev server restarted mid-session): + // start immediately and clear the module cache on every subsequent webpack rebuild + // so SSR always uses the latest client chunks. + watcher.on("change", () => { + clearServerCache() + }) + startServer() + } else { + // First boot — webpack hasn't finished the initial build yet. + // Wait for loadable-stats.json to be created before starting the server, + // otherwise @loadable/server would fail trying to read chunk metadata. + watcher.on("add", () => { + startServer() + }) + } + + // Watch the app's /server directory for source changes. + // Any modification, addition, or deletion of a server-side file triggers + // a full server restart so the new code is loaded via a fresh require(). + const serverPath = path.join(process.env.src_path, "server") + + const serverWatcher = chokidar.watch(serverPath, { + persistent: true, + ignoreInitial: true, // Don't trigger on initial scan + ignored: /node_modules/, + }) + + serverWatcher.on("change", (filePath) => { + clearServerCache(filePath) + restartServer() + }) + serverWatcher.on("add", () => restartServer()) + serverWatcher.on("unlink", () => restartServer()) } else { startServer() } }) -} -if (fs.existsSync(statsPath)) { - if (env === "development") { - restartServer() - } -} + .catch((err) => { + console.error(`\n[Catalyst] Server startup failed: ${err.message}\n`) + process.exit(1) + }) diff --git a/src/webpack/babel.config.client.js b/src/webpack/babel.config.client.js index 4c4dad38..7bf59b44 100644 --- a/src/webpack/babel.config.client.js +++ b/src/webpack/babel.config.client.js @@ -38,6 +38,9 @@ export default { test: { presets: ["@babel/preset-react"], }, + development: { + plugins: ["react-refresh/babel"], + }, }, ignore: ["__TEST__"], } diff --git a/src/webpack/base.babel.js b/src/webpack/base.babel.js index 1e03e42b..babf7b76 100644 --- a/src/webpack/base.babel.js +++ b/src/webpack/base.babel.js @@ -69,6 +69,16 @@ if (IS_DEV_COMMAND === "true" && !isDev) { export default { context: path.resolve(process.env.src_path), mode: isDev ? "development" : "production", + // Use filesystem cache to reduce memory pressure and improve rebuild performance + cache: isDev + ? { + type: "filesystem", + buildDependencies: { + config: [__filename], + }, + cacheDirectory: path.join(process.env.src_path, "node_modules/catalyst-core/.cache/webpack"), + } + : false, entry: { app: [path.resolve(process.env.src_path, "./client/index.js")], }, @@ -115,7 +125,6 @@ export default { path.resolve(process.env.src_path, "./src/static/css/base"), ], use: [ - isDev && "css-hot-loader", !isSSR && MiniCssExtractPlugin.loader, { loader: "css-loader", @@ -154,7 +163,6 @@ export default { path.resolve(process.env.src_path, "./src/static/css/base"), ], use: [ - isDev && "css-hot-loader", !isSSR && MiniCssExtractPlugin.loader, { loader: "css-loader" }, { loader: "postcss-loader" }, diff --git a/src/webpack/development.client.babel.js b/src/webpack/development.client.babel.js index dce3c662..bb9523fb 100644 --- a/src/webpack/development.client.babel.js +++ b/src/webpack/development.client.babel.js @@ -1,14 +1,11 @@ +import net from "net" import webpack from "webpack" -import merge, { mergeWithCustomize, customizeArray, customizeObject } from "webpack-merge" +import merge from "webpack-merge" import WebpackDevServer from "webpack-dev-server" import LoadablePlugin from "@loadable/webpack-plugin" import MiniCssExtractPlugin from "mini-css-extract-plugin" import ReactRefreshWebpackPlugin from "@pmmmwh/react-refresh-webpack-plugin" import path from "path" -import nodeExternals from "webpack-node-externals" -import rootWorkspacePath from "app-root-path" -// Import the catalystResultMap for SSR support -import { catalystResultMap } from "../scripts/registerAliases.js" import catalystConfig from "@catalyst/root/config.json" import baseConfig from "@catalyst/webpack/base.babel.js" @@ -18,7 +15,8 @@ const { WEBPACK_DEV_SERVER_PORT, WEBPACK_DEV_SERVER_HOSTNAME } = process.env // Create client config const webpackClientConfig = merge(baseConfig, { - devtool: "inline-source-map", + // Use eval-cheap-module-source-map for better performance and lower memory usage + devtool: "eval-cheap-module-source-map", stats: "none", infrastructureLogging: { level: "none", @@ -79,70 +77,8 @@ const webpackClientConfig = merge(baseConfig, { }, }) -// Create SSR config -const webpackSSRConfig = mergeWithCustomize({ - customizeArray: customizeArray({ - entry: "replace", - optimization: "replace", - plugins: "prepend", - }), - customizeObject: customizeObject({ - entry: "replace", - optimization: "replace", - plugins: "prepend", - }), -})(baseConfig, { - mode: "development", - stats: "none", - target: "node", - entry: { - handler: path.resolve(__dirname, "..", "./server/renderer/handler.js"), - }, - externals: [ - /\.(html|png|gif|jpg)$/, - nodeExternals({ - modulesDir: path.resolve(process.env.src_path, "./node_modules"), - allowlist: customWebpackConfig.transpileModules ? customWebpackConfig.transpileModules : [], - }), - nodeExternals({ - modulesDir: path.join(rootWorkspacePath.path, "./node_modules"), - allowlist: customWebpackConfig.transpileModules ? customWebpackConfig.transpileModules : [], - }), - ], - resolve: { - alias: catalystResultMap, - }, - output: { - path: path.join(__dirname, "../..", ".catalyst-dev", "/server", "/renderer"), - chunkFilename: catalystConfig.chunkFileName, - filename: "handler.development.js", - libraryTarget: "commonjs", - }, - plugins: [ - new LoadablePlugin({ - filename: "loadable-stats.json", - writeToDisk: { - filename: path.join(__dirname, "../..", ".catalyst-dev", "/server", "/renderer"), - }, - }), - new MiniCssExtractPlugin({ - filename: catalystConfig.cssChunkFileName, - ignoreOrder: true, - }), - ...customWebpackConfig.ssrPlugins, - ].filter(Boolean), -}) - -// Create separate compiler for SSR that writes to disk -const ssrCompiler = webpack(webpackSSRConfig) -const watchInstance = ssrCompiler.watch({}, (err) => { - if (err) { - console.error(err) - return - } -}) - // Create dev server for client-side only +// Note: SSR compiler now runs in a separate process (ssr.watcher.js) for better memory management let devServer = new WebpackDevServer( { port: WEBPACK_DEV_SERVER_PORT, @@ -166,35 +102,46 @@ let devServer = new WebpackDevServer( webpack(webpackClientConfig) ) -devServer.startCallback(() => { - console.log("Catalyst is compiling your files.") - console.log("Please wait until bundling is finished.\n") -}) +const checkPortAvailability = (port, host) => { + return new Promise((resolve, reject) => { + const tester = net + .createServer() + .once("error", (err) => { + tester.close(() => { + if (err.code === "EADDRINUSE") { + reject( + new Error( + `Port ${port} is already in use on ${host}. Please free the port or set a different WEBPACK_DEV_SERVER_PORT.` + ) + ) + } else { + reject(err) + } + }) + }) + .once("listening", () => { + tester.close(() => resolve()) + }) + .listen(port, host) + }) +} + +checkPortAvailability(WEBPACK_DEV_SERVER_PORT, WEBPACK_DEV_SERVER_HOSTNAME) + .then(() => { + devServer.startCallback(() => { + console.log("Catalyst is compiling your files.") + console.log("Please wait until bundling is finished.\n") + }) + }) + .catch((err) => { + console.error(`\n[Catalyst] Dev server startup failed: ${err.message}\n`) + process.exit(1) + }) // Cleanup on exit const cleanup = () => { - // Close webpack watch - watchInstance.close(() => { - // Delete the development handler file - try { - // Delete the file - require("fs").unlinkSync( - path.join( - __dirname, - "../..", - ".catalyst-dev", - "/server", - "/renderer", - "handler.development.js" - ) - ) - // Try to remove the renderer directory - require("fs").rmdirSync(path.join(process.env.src_path, ".catalyst-dev", "/renderer")) - // Try to remove the parent directory - require("fs").rmdirSync(path.join(process.env.src_path, ".catalyst-dev")) - } catch (err) { - // Ignore errors during cleanup - } + console.log("[Client] Shutting down client dev server...") + devServer.stop().then(() => { process.exit() }) } @@ -202,4 +149,3 @@ const cleanup = () => { // Handle various ways the process might exit process.on("SIGINT", cleanup) // Ctrl+C process.on("SIGTERM", cleanup) // kill -process.on("exit", cleanup) // normal exit