From bc141e58c758490069b4b40d9f77da319e5c8bc2 Mon Sep 17 00:00:00 2001 From: Bryan Haberberger Date: Wed, 20 May 2026 12:38:58 -0500 Subject: [PATCH 1/6] Introduce ecosystem file and env-loader for RHEL --- .github/workflows/cd_dev.yaml | 2 +- .github/workflows/cd_prod.yaml | 2 +- ecosystem.config.json | 23 +++++++++++++ env-loader.js | 62 ++++++++++++++++++++++++++++++++++ package.json | 12 +++---- 5 files changed, 93 insertions(+), 8 deletions(-) create mode 100644 ecosystem.config.json create mode 100644 env-loader.js diff --git a/.github/workflows/cd_dev.yaml b/.github/workflows/cd_dev.yaml index 433eebb..ac5bb75 100644 --- a/.github/workflows/cd_dev.yaml +++ b/.github/workflows/cd_dev.yaml @@ -56,4 +56,4 @@ jobs: git stash git pull npm install - pm2 start --node-args="--env-file-if-exists=.env" -i max bin/rerum_v1.js + pm2 start ecosystem.config.json diff --git a/.github/workflows/cd_prod.yaml b/.github/workflows/cd_prod.yaml index 597abef..c2f671f 100644 --- a/.github/workflows/cd_prod.yaml +++ b/.github/workflows/cd_prod.yaml @@ -55,4 +55,4 @@ jobs: git stash git pull npm install - pm2 start --node-args="--env-file-if-exists=.env" -i max bin/rerum_v1.js + pm2 start ecosystem.config.json diff --git a/ecosystem.config.json b/ecosystem.config.json new file mode 100644 index 0000000..e4ba79c --- /dev/null +++ b/ecosystem.config.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/pm2-ecosystem.json", + "apps": [ + { + "name": "rerum_v1", + "script": "./bin/rerum_v1.js", + "node_args": "--import ./env-loader.js", + "instances": "max", + "exec_mode": "cluster", + "env_development": { "NODE_ENV": "development" }, + "env_production": { "NODE_ENV": "production" }, + "listen_timeout": 10000, + "kill_timeout": 5000, + "wait_ready": false, + "max_memory_restart": "500M", + "log_date_format": "YYYY-MM-DD HH:mm:ss Z", + "merge_logs": true, + "autorestart": true, + "max_restarts": 10, + "min_uptime": "10s" + } + ] +} diff --git a/env-loader.js b/env-loader.js new file mode 100644 index 0000000..2826c19 --- /dev/null +++ b/env-loader.js @@ -0,0 +1,62 @@ +/** + * Environment Variable Loader + * + * Preloads variables from a `.env` file into `process.env` before any + * application module is imported. Used via Node's `--import` flag so that + * it runs synchronously at process startup — works identically under + * `node`, `c8`, `npm test`, and PM2 cluster workers (via `node_args` in + * `ecosystem.config.json`). + * + * Replaces the previous `--env-file-if-exists=.env` Node CLI flag, which + * was unreliable on the RHEL servers (vlcdhp02 / vlcdhprdp02) under PM2. + * + * Behavior: + * - Resolves `.env` against `process.cwd()` (same semantics as the + * deprecated flag). + * - Permissive: if `.env` is missing, logs a warning and continues + * with whatever is already in `process.env`. + * - Non-destructive: does NOT overwrite keys already set in + * `process.env`, so PM2-injected env, CI secrets, and shell exports + * still take precedence. + * - No external dependency — uses only `node:fs` and `node:path`. + */ + +import { readFileSync, existsSync } from 'node:fs' +import { resolve } from 'node:path' + +const RESET = '\x1b[0m' +const YELLOW = '\x1b[33m' +const GREEN = '\x1b[32m' +const CYAN = '\x1b[36m' + +const envPath = resolve(process.cwd(), '.env') + +if (!existsSync(envPath)) { + console.warn(`${YELLOW}[env-loader] .env not found at ${envPath} — continuing with existing process.env${RESET}`) +} else { + let loaded = 0 + let skipped = 0 + const contents = readFileSync(envPath, 'utf8') + for (const raw of contents.split(/\r?\n/)) { + const line = raw.trim() + if (!line || line.startsWith('#')) continue + const eq = line.indexOf('=') + if (eq === -1) continue + const key = line.slice(0, eq).trim() + if (!key) continue + let val = line.slice(eq + 1).trim() + if ( + (val.startsWith('"') && val.endsWith('"')) || + (val.startsWith("'") && val.endsWith("'")) + ) { + val = val.slice(1, -1) + } + if (key in process.env) { + skipped++ + continue + } + process.env[key] = val + loaded++ + } + console.log(`${GREEN}[env-loader] loaded ${loaded} vars from ${envPath}${RESET}${skipped ? ` ${CYAN}(skipped ${skipped} already set)${RESET}` : ''}`) +} diff --git a/package.json b/package.json index e69f1f9..4a6c336 100644 --- a/package.json +++ b/package.json @@ -24,12 +24,12 @@ "npm": ">=11.0.0" }, "scripts": { - "start": "node --env-file-if-exists=.env ./bin/rerum_v1.js", - "test": "node --env-file-if-exists=.env --import ./test/bootstrap.js --test --test-skip-pattern='@e2e' __tests__/*.test.js routes/__tests__/*.test.js auth/__tests__/*.test.js", - "test:ci": "node --env-file-if-exists=.env --import ./test/bootstrap.js --test __tests__/*.test.js routes/__tests__/*.test.js auth/__tests__/*.test.js", - "test:e2e": "node --env-file-if-exists=.env --import ./test/bootstrap.js --test --test-name-pattern='@e2e' __tests__/*.test.js routes/__tests__/*.test.js auth/__tests__/*.test.js", - "coverage": "c8 --reporter=html --reporter=text --include='db-controller.js' --include='routes/**/*.js' --exclude='**/__tests__/**' node --env-file-if-exists=.env --import ./test/bootstrap.js --test --test-skip-pattern='@e2e' __tests__/*.test.js routes/__tests__/*.test.js auth/__tests__/*.test.js", - "coverage:ci": "c8 --reporter=html --reporter=json --reporter=text --include='db-controller.js' --include='routes/**/*.js' --exclude='**/__tests__/**' node --env-file-if-exists=.env --import ./test/bootstrap.js --test __tests__/*.test.js routes/__tests__/*.test.js auth/__tests__/*.test.js" + "start": "node --import ./env-loader.js ./bin/rerum_v1.js", + "test": "node --import ./env-loader.js --import ./test/bootstrap.js --test --test-skip-pattern='@e2e' __tests__/*.test.js routes/__tests__/*.test.js auth/__tests__/*.test.js", + "test:ci": "node --import ./env-loader.js --import ./test/bootstrap.js --test __tests__/*.test.js routes/__tests__/*.test.js auth/__tests__/*.test.js", + "test:e2e": "node --import ./env-loader.js --import ./test/bootstrap.js --test --test-name-pattern='@e2e' __tests__/*.test.js routes/__tests__/*.test.js auth/__tests__/*.test.js", + "coverage": "c8 --reporter=html --reporter=text --include='db-controller.js' --include='routes/**/*.js' --exclude='**/__tests__/**' node --import ./env-loader.js --import ./test/bootstrap.js --test --test-skip-pattern='@e2e' __tests__/*.test.js routes/__tests__/*.test.js auth/__tests__/*.test.js", + "coverage:ci": "c8 --reporter=html --reporter=json --reporter=text --include='db-controller.js' --include='routes/**/*.js' --exclude='**/__tests__/**' node --import ./env-loader.js --import ./test/bootstrap.js --test __tests__/*.test.js routes/__tests__/*.test.js auth/__tests__/*.test.js" }, "dependencies": { "cookie-parser": "~1.4.7", From 7d1ea2a91d6766927f8cf27980937ba7fe14f224 Mon Sep 17 00:00:00 2001 From: Bryan Haberberger Date: Wed, 20 May 2026 12:53:57 -0500 Subject: [PATCH 2/6] Introduce ecosystem file and env-loader for RHEL --- .github/workflows/cd_dev.yaml | 2 +- .github/workflows/cd_prod.yaml | 2 +- env-loader.js | 16 +++++++++++----- 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/.github/workflows/cd_dev.yaml b/.github/workflows/cd_dev.yaml index ac5bb75..d455148 100644 --- a/.github/workflows/cd_dev.yaml +++ b/.github/workflows/cd_dev.yaml @@ -56,4 +56,4 @@ jobs: git stash git pull npm install - pm2 start ecosystem.config.json + pm2 start ecosystem.config.json --env development diff --git a/.github/workflows/cd_prod.yaml b/.github/workflows/cd_prod.yaml index c2f671f..e2a6024 100644 --- a/.github/workflows/cd_prod.yaml +++ b/.github/workflows/cd_prod.yaml @@ -55,4 +55,4 @@ jobs: git stash git pull npm install - pm2 start ecosystem.config.json + pm2 start ecosystem.config.json --env production diff --git a/env-loader.js b/env-loader.js index 2826c19..87c1b81 100644 --- a/env-loader.js +++ b/env-loader.js @@ -11,25 +11,31 @@ * was unreliable on the RHEL servers (vlcdhp02 / vlcdhprdp02) under PM2. * * Behavior: - * - Resolves `.env` against `process.cwd()` (same semantics as the - * deprecated flag). + * - Resolves `.env` against this file's own directory (via + * `import.meta.url`), NOT `process.cwd()`. PM2 cluster workers on + * RHEL have shown inconsistent cwd handling; anchoring to the file + * location guarantees the same `.env` is found regardless of where + * the process was launched from. * - Permissive: if `.env` is missing, logs a warning and continues * with whatever is already in `process.env`. * - Non-destructive: does NOT overwrite keys already set in * `process.env`, so PM2-injected env, CI secrets, and shell exports * still take precedence. - * - No external dependency — uses only `node:fs` and `node:path`. + * - No external dependency — uses only `node:fs`, `node:path`, and + * `node:url`. */ import { readFileSync, existsSync } from 'node:fs' -import { resolve } from 'node:path' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' const RESET = '\x1b[0m' const YELLOW = '\x1b[33m' const GREEN = '\x1b[32m' const CYAN = '\x1b[36m' -const envPath = resolve(process.cwd(), '.env') +const __dirname = dirname(fileURLToPath(import.meta.url)) +const envPath = resolve(__dirname, '.env') if (!existsSync(envPath)) { console.warn(`${YELLOW}[env-loader] .env not found at ${envPath} — continuing with existing process.env${RESET}`) From 0575968652b9c87eb1412c33695db47f5d66e1f9 Mon Sep 17 00:00:00 2001 From: Bryan Haberberger Date: Wed, 20 May 2026 13:17:11 -0500 Subject: [PATCH 3/6] changes from testing --- .github/workflows/cd_dev.yaml | 2 +- .github/workflows/cd_prod.yaml | 2 +- ecosystem.config.json | 3 +-- env-loader.js | 5 ++++- 4 files changed, 7 insertions(+), 5 deletions(-) diff --git a/.github/workflows/cd_dev.yaml b/.github/workflows/cd_dev.yaml index d455148..c11bb8e 100644 --- a/.github/workflows/cd_dev.yaml +++ b/.github/workflows/cd_dev.yaml @@ -56,4 +56,4 @@ jobs: git stash git pull npm install - pm2 start ecosystem.config.json --env development + pm2 startOrReload ecosystem.config.json --env development diff --git a/.github/workflows/cd_prod.yaml b/.github/workflows/cd_prod.yaml index e2a6024..09a685c 100644 --- a/.github/workflows/cd_prod.yaml +++ b/.github/workflows/cd_prod.yaml @@ -55,4 +55,4 @@ jobs: git stash git pull npm install - pm2 start ecosystem.config.json --env production + pm2 startOrReload ecosystem.config.json --env production diff --git a/ecosystem.config.json b/ecosystem.config.json index e4ba79c..7f58153 100644 --- a/ecosystem.config.json +++ b/ecosystem.config.json @@ -4,7 +4,6 @@ { "name": "rerum_v1", "script": "./bin/rerum_v1.js", - "node_args": "--import ./env-loader.js", "instances": "max", "exec_mode": "cluster", "env_development": { "NODE_ENV": "development" }, @@ -12,7 +11,7 @@ "listen_timeout": 10000, "kill_timeout": 5000, "wait_ready": false, - "max_memory_restart": "500M", + "max_memory_restart": "2G", "log_date_format": "YYYY-MM-DD HH:mm:ss Z", "merge_logs": true, "autorestart": true, diff --git a/env-loader.js b/env-loader.js index 87c1b81..addad2c 100644 --- a/env-loader.js +++ b/env-loader.js @@ -21,6 +21,8 @@ * - Non-destructive: does NOT overwrite keys already set in * `process.env`, so PM2-injected env, CI secrets, and shell exports * still take precedence. + * - Strips a leading UTF-8 BOM (U+FEFF) if present, so `.env` files + * saved by Windows editors do not lose their first key. * - No external dependency — uses only `node:fs`, `node:path`, and * `node:url`. */ @@ -42,7 +44,8 @@ if (!existsSync(envPath)) { } else { let loaded = 0 let skipped = 0 - const contents = readFileSync(envPath, 'utf8') + let contents = readFileSync(envPath, 'utf8') + if (contents.charCodeAt(0) === 0xFEFF) contents = contents.slice(1) for (const raw of contents.split(/\r?\n/)) { const line = raw.trim() if (!line || line.startsWith('#')) continue From d10e48dad4c2eece0fe18479d14d05020711aa47 Mon Sep 17 00:00:00 2001 From: Bryan Haberberger Date: Wed, 20 May 2026 13:30:35 -0500 Subject: [PATCH 4/6] cmon RHEL --- bin/rerum_v1.js | 5 +++++ env-loader.js | 20 +++++++++++++++----- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/bin/rerum_v1.js b/bin/rerum_v1.js index 9724270..0585afc 100644 --- a/bin/rerum_v1.js +++ b/bin/rerum_v1.js @@ -1,5 +1,10 @@ #!/usr/bin/env node +// Must be the first import: populates process.env from .env synchronously +// before any other module reads it. See env-loader.js for why this lives +// here instead of being injected via PM2 `node_args`. +import '../env-loader.js' + /** * Module dependencies. */ diff --git a/env-loader.js b/env-loader.js index addad2c..eda0db6 100644 --- a/env-loader.js +++ b/env-loader.js @@ -2,13 +2,23 @@ * Environment Variable Loader * * Preloads variables from a `.env` file into `process.env` before any - * application module is imported. Used via Node's `--import` flag so that - * it runs synchronously at process startup — works identically under - * `node`, `c8`, `npm test`, and PM2 cluster workers (via `node_args` in - * `ecosystem.config.json`). + * application module reads them. Runs synchronously as a side-effect import. + * + * Two entry points use this loader, by design: + * 1. The app entry script `bin/rerum_v1.js` imports this file FIRST, + * before any other module. Because ES module imports evaluate in + * source order, this guarantees `process.env` is populated before + * `app.js` (or anything it pulls in) reads it. This is the path used + * under PM2 in development and production, and it is independent of + * PM2's `node_args` / cluster-worker `execArgv` plumbing — which was + * observed not to fire reliably on the RHEL servers + * (vlcdhp02 / vlcdhprdp02). + * 2. The test/coverage scripts in `package.json` pass it via + * `node --import ./env-loader.js`, because tests do not go through + * `bin/rerum_v1.js`. * * Replaces the previous `--env-file-if-exists=.env` Node CLI flag, which - * was unreliable on the RHEL servers (vlcdhp02 / vlcdhprdp02) under PM2. + * was unreliable on the RHEL servers under PM2. * * Behavior: * - Resolves `.env` against this file's own directory (via From 3d89d94715b86ab768ff52d3f4f51c6456c95ec2 Mon Sep 17 00:00:00 2001 From: Bryan Haberberger Date: Wed, 20 May 2026 13:51:55 -0500 Subject: [PATCH 5/6] back to 500M --- ecosystem.config.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ecosystem.config.json b/ecosystem.config.json index 7f58153..4ff8f9a 100644 --- a/ecosystem.config.json +++ b/ecosystem.config.json @@ -11,7 +11,7 @@ "listen_timeout": 10000, "kill_timeout": 5000, "wait_ready": false, - "max_memory_restart": "2G", + "max_memory_restart": "500M", "log_date_format": "YYYY-MM-DD HH:mm:ss Z", "merge_logs": true, "autorestart": true, From 4cb3e60a6147b4cfe866c72d3042feb8a37fce5a Mon Sep 17 00:00:00 2001 From: Bryan Haberberger Date: Thu, 21 May 2026 09:33:46 -0500 Subject: [PATCH 6/6] 100% coverage --- routes/__tests__/route_wrappers.test.js | 6 +++++- routes/_gog_fragments_from_manuscript.js | 3 +-- routes/_gog_glosses_from_manuscript.js | 3 +-- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/routes/__tests__/route_wrappers.test.js b/routes/__tests__/route_wrappers.test.js index a5f95d6..e4522a8 100644 --- a/routes/__tests__/route_wrappers.test.js +++ b/routes/__tests__/route_wrappers.test.js @@ -22,6 +22,8 @@ import searchRouter from '../search.js' import queryRouter from '../query.js' import releaseRouter from '../release.js' import apiRoutesRouter from '../api-routes.js' +import gogFragmentsRouter from '../_gog_fragments_from_manuscript.js' +import gogGlossesRouter from '../_gog_glosses_from_manuscript.js' function getRoute(router, path) { const routeLayer = router.stack.find(layer => layer.route?.path === path) @@ -206,7 +208,9 @@ describe('unsupported-method 405 fallbacks', () => { { label: '/delete/:_id', router: deleteRouter, path: '/:_id' }, { label: '/history/:_id', router: historyRouter, path: '/:_id' }, { label: '/id/:_id', router: idRouter, path: '/:_id' }, - { label: '/since/:_id', router: sinceRouter, path: '/:_id' } + { label: '/since/:_id', router: sinceRouter, path: '/:_id' }, + { label: '/_gog_fragments_from_manuscript', router: gogFragmentsRouter, path: '/' }, + { label: '/_gog_glosses_from_manuscript', router: gogGlossesRouter, path: '/' } ] for (const { label, router, path } of cases) { diff --git a/routes/_gog_fragments_from_manuscript.js b/routes/_gog_fragments_from_manuscript.js index d1f3019..109bd9d 100644 --- a/routes/_gog_fragments_from_manuscript.js +++ b/routes/_gog_fragments_from_manuscript.js @@ -8,8 +8,7 @@ router.route('/') .post(auth.checkJwt, controller._gog_fragments_from_manuscript) .all((req, res, next) => { res.statusMessage = 'Improper request method. Please use POST.' - res.status(405) - next(res) + res.status(405).end() }) export default router diff --git a/routes/_gog_glosses_from_manuscript.js b/routes/_gog_glosses_from_manuscript.js index e5c5765..4d80970 100644 --- a/routes/_gog_glosses_from_manuscript.js +++ b/routes/_gog_glosses_from_manuscript.js @@ -8,8 +8,7 @@ router.route('/') .post(auth.checkJwt, controller._gog_glosses_from_manuscript) .all((req, res, next) => { res.statusMessage = 'Improper request method. Please use POST.' - res.status(405) - next(res) + res.status(405).end() }) export default router \ No newline at end of file