diff --git a/src/instrumentation-node.ts b/src/instrumentation-node.ts new file mode 100644 index 0000000..a7abc91 --- /dev/null +++ b/src/instrumentation-node.ts @@ -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}`, + ); +} diff --git a/src/instrumentation.ts b/src/instrumentation.ts index a1cd307..9507839 100644 --- a/src/instrumentation.ts +++ b/src/instrumentation.ts @@ -33,68 +33,13 @@ export async function register() { /** * 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 - * 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. */ if (process.env.NEXT_RUNTIME !== 'nodejs') return; - const isProduction = process.env.NODE_ENV === 'production'; - - /** - * shared/config/platformApi, NOT apiClient. apiClient is `server-only`, and - * 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}`, - ); + // 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'); + await checkConfiguration(); }