Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions bin/catalyst.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
Expand All @@ -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')
}
30 changes: 30 additions & 0 deletions src/scripts/cleanCache.js
Original file line number Diff line number Diff line change
@@ -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()
2 changes: 2 additions & 0 deletions src/scripts/serve.js
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
10 changes: 8 additions & 2 deletions src/scripts/start.js
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
[],
Expand All @@ -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,
Expand Down
8 changes: 1 addition & 7 deletions src/server/renderer/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
206 changes: 153 additions & 53 deletions src/server/startServer.js
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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...")
Expand All @@ -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)
Expand All @@ -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

Expand All @@ -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
}
Expand All @@ -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)
})
3 changes: 3 additions & 0 deletions src/webpack/babel.config.client.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ export default {
test: {
presets: ["@babel/preset-react"],
},
development: {
plugins: ["react-refresh/babel"],
},
},
ignore: ["__TEST__"],
}
Loading