diff --git a/.github/workflows/cd_dev.yaml b/.github/workflows/cd_dev.yaml index 433eebb9..c11bb8e4 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 startOrReload ecosystem.config.json --env development diff --git a/.github/workflows/cd_prod.yaml b/.github/workflows/cd_prod.yaml index 597abef2..09a685c4 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 startOrReload ecosystem.config.json --env production diff --git a/bin/rerum_v1.js b/bin/rerum_v1.js index 9724270d..0585afc9 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/ecosystem.config.json b/ecosystem.config.json new file mode 100644 index 00000000..4ff8f9a7 --- /dev/null +++ b/ecosystem.config.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://json.schemastore.org/pm2-ecosystem.json", + "apps": [ + { + "name": "rerum_v1", + "script": "./bin/rerum_v1.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 00000000..eda0db68 --- /dev/null +++ b/env-loader.js @@ -0,0 +1,81 @@ +/** + * Environment Variable Loader + * + * Preloads variables from a `.env` file into `process.env` before any + * 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 under PM2. + * + * Behavior: + * - 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. + * - 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`. + */ + +import { readFileSync, existsSync } from 'node:fs' +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 __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}`) +} else { + let loaded = 0 + let skipped = 0 + 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 + 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 e69f1f99..4a6c336f 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", diff --git a/routes/__tests__/route_wrappers.test.js b/routes/__tests__/route_wrappers.test.js index a5f95d6a..e4522a83 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 d1f30193..109bd9dd 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 e5c57659..4d80970a 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