fix(config): exit on a failed boot check instead of serving 500s
The check added in 1ee4b5d did not do what its commit message claimed. It said
"refuses to start"; it did not. Throwing from `register()` does not stop the
server — the port is already bound by then, so Next logs "Failed to prepare
server" and the process STAYS ALIVE, answering 500 to every route.
That is worse than the problem it replaced. A TCP or HTTP healthcheck sees an
open port and reports the container healthy, so a dead deploy goes green and
keeps receiving traffic, and the operator sees 500s on /login AND
/favicon.ico AND everything else with no indication of why.
Measured, both before and after:
before port LISTENING, GET /login -> 500, GET /favicon.ico -> 500
after nothing listening, connection refused, EXIT_CODE=1
A non-zero exit is what a deployment platform reads as a failed deploy. The
numbered problem list goes to stderr before exiting, so every restart attempt
reprints the reason.
The Node-only half moves to src/instrumentation-node.ts, reached by dynamic
import. instrumentation.ts is compiled for EVERY runtime the app uses, and
src/proxy.ts makes this app compile an Edge one, where process.exit does not
exist — Turbopack flagged it ("A Node.js API is used (process.exit) which is
not supported in the Edge Runtime") even though the call sits behind a
NEXT_RUNTIME guard that can never let it run there. A runtime guard is not a
bundling boundary; a separate module behind a dynamic import is. The build is
warning-free again.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
90
src/instrumentation-node.ts
Normal file
90
src/instrumentation-node.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import {resolvePlatformOrigin} from '@/shared/config/platformApi';
|
||||||
|
import {ConfigError} from '@/shared/errors/configError';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Node half of the boot-time configuration check.
|
||||||
|
*
|
||||||
|
* ── Why this is a separate file ──────────────────────────────────────────
|
||||||
|
* `instrumentation.ts` is compiled for EVERY runtime the app uses, and
|
||||||
|
* src/proxy.ts makes this app compile an Edge one. `process.exit` does not
|
||||||
|
* exist there, so Turbopack statically flagged it — "A Node.js API is used
|
||||||
|
* (process.exit) which is not supported in the Edge Runtime" — even though the
|
||||||
|
* call sits behind a NEXT_RUNTIME guard and could never run in that bundle.
|
||||||
|
*
|
||||||
|
* A runtime guard is not a bundling boundary. A separate module reached through
|
||||||
|
* a dynamic import IS one, so the Edge bundle never contains this code at all.
|
||||||
|
*
|
||||||
|
* These imports are static rather than dynamic because this module is only ever
|
||||||
|
* loaded from the Node branch. Neither is `server-only`: apiClient is, and that
|
||||||
|
* package resolves to a module which THROWS ON IMPORT outside a react-server
|
||||||
|
* condition, which this bundle is not. shared/config/platformApi exists so the
|
||||||
|
* boot check and the request path can share one validator without that hazard.
|
||||||
|
*/
|
||||||
|
export async function checkConfiguration() {
|
||||||
|
const isProduction = process.env.NODE_ENV === 'production';
|
||||||
|
const problems: string[] = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the platform origin exactly the way a request would — same
|
||||||
|
* function, same validation, same allowlist. A check that reimplemented the
|
||||||
|
* rules would be a second source of truth and would drift from the real one.
|
||||||
|
*/
|
||||||
|
let platform: string | null = null;
|
||||||
|
try {
|
||||||
|
platform = resolvePlatformOrigin();
|
||||||
|
} catch (err) {
|
||||||
|
if (!(err instanceof ConfigError)) throw err;
|
||||||
|
problems.push(err.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AUTH_SECRET is checked by presence rather than by calling the signing
|
||||||
|
* functions, which would have to be handed a throwaway payload to probe.
|
||||||
|
* Presence is the whole rule — sessionToken and tokenStore both refuse the
|
||||||
|
* development key in production and accept anything else.
|
||||||
|
*/
|
||||||
|
if (isProduction && !process.env.AUTH_SECRET) {
|
||||||
|
problems.push(
|
||||||
|
'AUTH_SECRET is required in production — it signs the session cookie and ' +
|
||||||
|
'encrypts the platform token bundle. Generate one with: openssl rand -base64 48',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (problems.length > 0) {
|
||||||
|
// Numbered, because a container missing its environment is usually missing
|
||||||
|
// more than one variable, and finding that out one deploy at a time is the
|
||||||
|
// slow way.
|
||||||
|
const detail = problems.map((p, i) => ` ${i + 1}. ${p}`).join('\n');
|
||||||
|
console.error(
|
||||||
|
`\n[loyaly] refusing to start — ${problems.length} configuration problem(s):\n${detail}\n` +
|
||||||
|
'\nSet these as environment variables on the container (Dokploy → Environment). ' +
|
||||||
|
'LOYALY_API_BASE also ships in the committed .env; AUTH_SECRET never does.\n',
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exit, rather than throw.
|
||||||
|
*
|
||||||
|
* Throwing from `register()` does NOT stop the server. Measured against a
|
||||||
|
* real standalone build: the port is already bound by the time this runs,
|
||||||
|
* so Next logs "Failed to prepare server" and the process STAYS ALIVE,
|
||||||
|
* answering 500 to every route — /login, /favicon.ico, everything. That is
|
||||||
|
* the worst of both worlds. A TCP or HTTP healthcheck sees an open port and
|
||||||
|
* calls the container healthy, so a dead deploy shows up green and keeps
|
||||||
|
* receiving traffic.
|
||||||
|
*
|
||||||
|
* Exiting non-zero makes the container die, which is what a deployment
|
||||||
|
* platform reads as a failed deploy. The message above is already on
|
||||||
|
* stderr, so each restart attempt reprints the reason.
|
||||||
|
*/
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The healthy path says what it resolved, so a log reader can confirm the
|
||||||
|
// host WITHOUT having to trigger a request. Never prints AUTH_SECRET, only
|
||||||
|
// whether one was supplied.
|
||||||
|
console.log(
|
||||||
|
`[loyaly] config ok — platform ${platform}, ` +
|
||||||
|
`auth secret ${process.env.AUTH_SECRET ? 'set' : 'using development key'}, ` +
|
||||||
|
`NODE_ENV=${process.env.NODE_ENV}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -33,68 +33,13 @@ export async function register() {
|
|||||||
/**
|
/**
|
||||||
* Node only. `register()` is invoked once per runtime, and src/proxy.ts makes
|
* Node only. `register()` is invoked once per runtime, and src/proxy.ts makes
|
||||||
* this app compile an Edge one too — without this guard the same check would
|
* this app compile an Edge one too — without this guard the same check would
|
||||||
* run and log twice per boot. The node server is the process that serves
|
* run and log twice on every boot. The Node server is the process that serves
|
||||||
* every route handler, so validating there is what matters.
|
* every route handler, so validating there is what matters.
|
||||||
*/
|
*/
|
||||||
if (process.env.NEXT_RUNTIME !== 'nodejs') return;
|
if (process.env.NEXT_RUNTIME !== 'nodejs') return;
|
||||||
|
|
||||||
const isProduction = process.env.NODE_ENV === 'production';
|
// Dynamic, so the Edge bundle never pulls in the Node-only module. See that
|
||||||
|
// file for why a runtime guard alone was not enough.
|
||||||
/**
|
const {checkConfiguration} = await import('./instrumentation-node');
|
||||||
* shared/config/platformApi, NOT apiClient. apiClient is `server-only`, and
|
await checkConfiguration();
|
||||||
* that package resolves to a module which throws on import outside a
|
|
||||||
* react-server condition — which this bundle is not. Importing it here would
|
|
||||||
* crash every boot, correctly configured or not.
|
|
||||||
*/
|
|
||||||
const {resolvePlatformOrigin} = await import('@/shared/config/platformApi');
|
|
||||||
const {ConfigError} = await import('@/shared/errors/configError');
|
|
||||||
|
|
||||||
const problems: string[] = [];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve the platform origin exactly the way a request would — same
|
|
||||||
* function, same validation, same allowlist. A check that reimplemented the
|
|
||||||
* rules would be a second source of truth and would drift.
|
|
||||||
*/
|
|
||||||
let platform: string | null = null;
|
|
||||||
try {
|
|
||||||
platform = resolvePlatformOrigin();
|
|
||||||
} catch (err) {
|
|
||||||
if (!(err instanceof ConfigError)) throw err;
|
|
||||||
problems.push(err.message);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* AUTH_SECRET is checked by presence rather than by calling the signing
|
|
||||||
* functions, because those are pure and would have to be handed a throwaway
|
|
||||||
* payload to probe. Presence is the whole rule — sessionToken and tokenStore
|
|
||||||
* both refuse the development key in production and accept anything else.
|
|
||||||
*/
|
|
||||||
if (isProduction && !process.env.AUTH_SECRET) {
|
|
||||||
problems.push(
|
|
||||||
'AUTH_SECRET is required in production — it signs the session cookie and ' +
|
|
||||||
'encrypts the platform token bundle. Generate one with: openssl rand -base64 48',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (problems.length > 0) {
|
|
||||||
// Numbered, because a container missing its environment is usually missing
|
|
||||||
// more than one variable, and fixing them one deploy at a time is the slow
|
|
||||||
// way to find that out.
|
|
||||||
const detail = problems.map((p, i) => ` ${i + 1}. ${p}`).join('\n');
|
|
||||||
throw new ConfigError(
|
|
||||||
`refusing to start — ${problems.length} configuration problem(s):\n${detail}\n` +
|
|
||||||
'\nSet these as environment variables on the container (Dokploy → Environment). ' +
|
|
||||||
'LOYALY_API_BASE also ships in the committed .env; AUTH_SECRET never does.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// The healthy path says what it resolved, so a log reader can confirm the
|
|
||||||
// host WITHOUT having to trigger a request. Never prints AUTH_SECRET, only
|
|
||||||
// whether one was supplied.
|
|
||||||
console.log(
|
|
||||||
`[loyaly] config ok — platform ${platform}, ` +
|
|
||||||
`auth secret ${process.env.AUTH_SECRET ? 'set' : 'using development key'}, ` +
|
|
||||||
`NODE_ENV=${process.env.NODE_ENV}`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user