From 013a34a57dc92169a6eec6ae2d9bcc8a475b15ba Mon Sep 17 00:00:00 2001 From: Thibault Le Ouay Date: Mon, 7 Sep 2026 10:33:32 +0200 Subject: [PATCH] fix env (#2654) --- apps/server/src/index.ts | 2 +- apps/server/src/routes/v1/index.ts | 3 ++- packages/analytics/env.ts | 12 ++++++++++ packages/analytics/src/server.ts | 4 ++-- .../emails/src/client.integration.test.tsx | 1 + packages/emails/src/client.test.ts | 4 +--- packages/emails/src/client.tsx | 23 ++++++++++--------- packages/emails/src/env.ts | 12 ++++++++++ packages/emails/src/send.ts | 8 +++---- packages/emails/src/test-preload.ts | 10 ++++++++ packages/services/src/env.ts | 15 ++++++++++++ packages/services/src/invitation/create.ts | 3 ++- packages/tinybird/src/client.ts | 6 ++--- packages/tinybird/src/env.ts | 15 ++++++++++++ 14 files changed, 92 insertions(+), 26 deletions(-) create mode 100644 packages/emails/src/test-preload.ts create mode 100644 packages/services/src/env.ts create mode 100644 packages/tinybird/src/env.ts diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index e80e2f80a..ac01cf0ea 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -281,6 +281,6 @@ app.route("/mcp", mcpRoute); * create incidents, and send notifications. */ -if (process.env.NODE_ENV === "development") { +if (env.NODE_ENV === "development") { showRoutes(app, { verbose: true, colorize: true }); } diff --git a/apps/server/src/routes/v1/index.ts b/apps/server/src/routes/v1/index.ts index fe453af7c..5dd5fdbb1 100644 --- a/apps/server/src/routes/v1/index.ts +++ b/apps/server/src/routes/v1/index.ts @@ -4,6 +4,7 @@ import { Scalar } from "@scalar/hono-api-reference"; import { cors } from "hono/cors"; import type { RequestIdVariables } from "hono/request-id"; +import { env } from "@/env"; import { handleZodError } from "@/libs/errors"; import { authMiddleware, requireWriteScope } from "@/libs/middlewares"; @@ -36,7 +37,7 @@ api.openAPIRegistry.registerComponent("securitySchemes", "ApiKeyAuth", { "x-openstatus-key": "string", }); // this is a fix for the memory leak -if (process.env.NODE_ENV === "production") { +if (env.NODE_ENV === "production") { api.get("/openapi", (c) => c.redirect("https://api.openstatus.dev/openapi-v1.json"), ); diff --git a/packages/analytics/env.ts b/packages/analytics/env.ts index a0b3c82f3..4611ed752 100644 --- a/packages/analytics/env.ts +++ b/packages/analytics/env.ts @@ -1,9 +1,20 @@ import { createEnv } from "@t3-oss/env-core"; import { z } from "zod"; +/** + * `NODE_ENV` is read through this alias rather than as a literal + * `process.env.NODE_ENV` member access: `deno bundle` (esbuild) constant-folds + * that exact expression into a string at build time — `"development"` unless + * `--minify` is passed — which left `setupAnalytics` permanently no-op inside + * the compiled `apps/server` binary. The other keys stay as literal member + * accesses because Next.js needs them that way to inline `NEXT_PUBLIC_*`. + */ +const processEnv: Record = process.env; + export const env = createEnv({ server: { OPENPANEL_CLIENT_SECRET: z.string(), + NODE_ENV: z.string().prefault("development"), }, client: { NEXT_PUBLIC_OPENPANEL_CLIENT_ID: z.string(), @@ -11,6 +22,7 @@ export const env = createEnv({ clientPrefix: "NEXT_PUBLIC_", runtimeEnv: { OPENPANEL_CLIENT_SECRET: process.env.OPENPANEL_CLIENT_SECRET, + NODE_ENV: processEnv.NODE_ENV, NEXT_PUBLIC_OPENPANEL_CLIENT_ID: process.env.NEXT_PUBLIC_OPENPANEL_CLIENT_ID, }, diff --git a/packages/analytics/src/server.ts b/packages/analytics/src/server.ts index 799266b95..b0505169d 100644 --- a/packages/analytics/src/server.ts +++ b/packages/analytics/src/server.ts @@ -15,7 +15,7 @@ function getClient() { clientSecret: env.OPENPANEL_CLIENT_SECRET, }); client.setGlobalProperties({ - env: process.env.VERCEL_ENV || process.env.NODE_ENV || "localhost", + env: process.env.VERCEL_ENV || env.NODE_ENV || "localhost", // app_version }); } @@ -34,7 +34,7 @@ export type IdentifyProps = { }; export async function setupAnalytics(props: IdentifyProps) { - if (process.env.NODE_ENV !== "production") { + if (env.NODE_ENV !== "production") { return noop(); } diff --git a/packages/emails/src/client.integration.test.tsx b/packages/emails/src/client.integration.test.tsx index 8829ab403..6fed64ef2 100644 --- a/packages/emails/src/client.integration.test.tsx +++ b/packages/emails/src/client.integration.test.tsx @@ -1,5 +1,6 @@ /** @jsxRuntime automatic @jsxImportSource react */ +import "./test-preload.ts"; import { statusLabel } from "@openstatus/utils"; import { expect } from "@std/expect"; import { describe, test } from "@std/testing/bdd"; diff --git a/packages/emails/src/client.test.ts b/packages/emails/src/client.test.ts index aceb5291e..1e710bd17 100644 --- a/packages/emails/src/client.test.ts +++ b/packages/emails/src/client.test.ts @@ -1,3 +1,4 @@ +import "./test-preload.ts"; import { expect } from "@std/expect"; import { afterEach, beforeEach, describe, test } from "@std/testing/bdd"; import { @@ -9,9 +10,6 @@ import { import { EmailClient } from "./client"; -// sendStatusReportUpdate early-returns in development; force the real send path. -process.env.NODE_ENV = "test"; - function makeSubscribers(n: number) { return Array.from({ length: n }, (_, i) => ({ email: `user-${i}@example.com`, diff --git a/packages/emails/src/client.tsx b/packages/emails/src/client.tsx index 9aca75277..4f1a6870f 100644 --- a/packages/emails/src/client.tsx +++ b/packages/emails/src/client.tsx @@ -19,6 +19,7 @@ import type { StatusReportProps } from "../emails/status-report"; import TeamInvitationEmail from "../emails/team-invitation"; import type { TeamInvitationProps } from "../emails/team-invitation"; import { monitorAlertEmail } from "../hotfix/monitor-alert"; +import { env } from "./env"; export function statusReportSubject(req: { status: StatusReportProps["status"]; @@ -64,7 +65,7 @@ export class EmailClient { } public async sendFollowUp(req: { to: string }) { - if (process.env.NODE_ENV === "development") { + if (env.NODE_ENV === "development") { console.log(`Sending follow up email to ${req.to}`); return; } @@ -91,7 +92,7 @@ export class EmailClient { } public async sendFollowUpBatched(req: { to: string[] }) { - if (process.env.NODE_ENV === "development") { + if (env.NODE_ENV === "development") { console.log(`Sending follow up emails to ${req.to.join(", ")}`); return; } @@ -122,7 +123,7 @@ export class EmailClient { } public async sendSlackFeedback(req: { to: string }) { - if (process.env.NODE_ENV === "development") { + if (env.NODE_ENV === "development") { console.log(`Sending slack feedback email to ${req.to}`); return; } @@ -149,7 +150,7 @@ export class EmailClient { } public async sendSlackFeedbackBatched(req: { to: string[] }) { - if (process.env.NODE_ENV === "development") { + if (env.NODE_ENV === "development") { console.log(`Sending slack feedback emails to ${req.to.join(", ")}`); return; } @@ -192,7 +193,7 @@ export class EmailClient { ? `https://${req.customDomain}` : `https://${req.pageSlug}.openstatus.dev`; - if (process.env.NODE_ENV === "development") { + if (env.NODE_ENV === "development") { console.log( `Sending status report update emails to ${req.subscribers .map((s) => s.email) @@ -255,7 +256,7 @@ export class EmailClient { } public async sendTeamInvitation(req: TeamInvitationProps & { to: string }) { - if (process.env.NODE_ENV === "development") { + if (env.NODE_ENV === "development") { console.log(`Sending team invitation email to ${req.to}`); return; } @@ -285,7 +286,7 @@ export class EmailClient { } public async sendMonitorAlert(req: MonitorAlertProps & { to: string }) { - if (process.env.NODE_ENV === "development") { + if (env.NODE_ENV === "development") { console.log(`Sending monitor alert email to ${req.to}`); return; } @@ -315,7 +316,7 @@ export class EmailClient { public async sendPageSubscription( req: PageSubscriptionProps & { to: string }, ) { - if (process.env.NODE_ENV === "development") { + if (env.NODE_ENV === "development") { console.log(`Sending page subscription email to ${req.to}`); return; } @@ -343,7 +344,7 @@ export class EmailClient { public async sendStatusPageMagicLink( req: StatusPageMagicLinkProps & { to: string }, ) { - if (process.env.NODE_ENV === "development") { + if (env.NODE_ENV === "development") { console.log(`Sending status page magic link email to ${req.to}`); console.log(`>>> Magic Link: ${req.link}`); return; @@ -385,7 +386,7 @@ export class EmailClient { ? `https://${req.customDomain}` : `https://${req.pageSlug}.openstatus.dev`; - if (process.env.NODE_ENV === "development") { + if (env.NODE_ENV === "development") { console.log( `Sending maintenance notification emails to ${req.subscribers .map((s) => s.email) @@ -463,7 +464,7 @@ export class EmailClient { ? `Your private location "${req.locationName}" is unhealthy` : `Your private location "${req.locationName}" is healthy again`; - if (process.env.NODE_ENV === "development") { + if (env.NODE_ENV === "development") { console.log( `Sending private location ${req.status} email to ${req.to.join(", ")}`, ); diff --git a/packages/emails/src/env.ts b/packages/emails/src/env.ts index 1af0229b2..d378e5e88 100644 --- a/packages/emails/src/env.ts +++ b/packages/emails/src/env.ts @@ -1,11 +1,23 @@ import { createEnv } from "@t3-oss/env-core"; import { z } from "zod"; +/** + * `NODE_ENV` is read through this alias rather than as a literal + * `process.env.NODE_ENV` member access: `deno bundle` (esbuild) constant-folds + * that exact expression into a string at build time — `"development"` unless + * `--minify` is passed — which silently disabled every production guard in + * this package inside the compiled `apps/server` binary. Aliasing the object + * defeats the fold, so the value is read from the real environment at runtime. + */ +const processEnv: Record = process.env; + export const env = createEnv({ server: { RESEND_API_KEY: z.string().min(1), + NODE_ENV: z.string().prefault("development"), }, runtimeEnv: { RESEND_API_KEY: process.env.RESEND_API_KEY, + NODE_ENV: processEnv.NODE_ENV, }, }); diff --git a/packages/emails/src/send.ts b/packages/emails/src/send.ts index 3947a4c5c..a51c1e4d5 100644 --- a/packages/emails/src/send.ts +++ b/packages/emails/src/send.ts @@ -22,18 +22,18 @@ export type EmailHtml = { reply_to?: string; }; export const sendEmail = async (email: Emails) => { - if (process.env.NODE_ENV !== "production") return; + if (env.NODE_ENV !== "production") return; await resend.emails.send(email); }; export const sendBatchEmailHtml = async (emails: EmailHtml[]) => { - if (process.env.NODE_ENV !== "production") return; + if (env.NODE_ENV !== "production") return; await resend.batch.send(emails); }; // TODO: delete in favor of sendBatchEmailHtml export const sendEmailHtml = async (emails: EmailHtml[]) => { - if (process.env.NODE_ENV !== "production") return; + if (env.NODE_ENV !== "production") return; await fetch("https://api.resend.com/emails/batch", { method: "POST", @@ -46,7 +46,7 @@ export const sendEmailHtml = async (emails: EmailHtml[]) => { }; export const sendWithRender = async (email: Emails) => { - if (process.env.NODE_ENV !== "production") return; + if (env.NODE_ENV !== "production") return; const html = await render(email.react); await resend.emails.send({ ...email, diff --git a/packages/emails/src/test-preload.ts b/packages/emails/src/test-preload.ts new file mode 100644 index 000000000..16a592413 --- /dev/null +++ b/packages/emails/src/test-preload.ts @@ -0,0 +1,10 @@ +/** + * Test env setup. Import this FIRST in any test file so env vars are set + * before module-level env validations fire on import. + * + * Bun's test runner set NODE_ENV=test automatically; Deno does not, so we set + * it here. `./env` validates RESEND_API_KEY at module load and snapshots + * NODE_ENV, so both have to be in place before `./client` or `./send` load. + */ +process.env.NODE_ENV = "test"; +process.env.RESEND_API_KEY ??= "test-key"; diff --git a/packages/services/src/env.ts b/packages/services/src/env.ts new file mode 100644 index 000000000..201dc8061 --- /dev/null +++ b/packages/services/src/env.ts @@ -0,0 +1,15 @@ +/** + * `NODE_ENV`, read so that bundlers cannot fold it away. + * + * `deno bundle` (esbuild) rewrites the exact expression `process.env.NODE_ENV` + * to a string literal at build time — `"development"` unless `--minify` is + * passed, and `apps/server` bundles without it (see `apps/server/dofigen.yml`). + * Reading through an aliased object defeats that substitution, so the value + * comes from the real environment at runtime. + */ +export const env = { + get NODE_ENV(): string { + const processEnv: Record = process.env; + return processEnv.NODE_ENV ?? "development"; + }, +}; diff --git a/packages/services/src/invitation/create.ts b/packages/services/src/invitation/create.ts index 98d09f56c..d6915e45b 100644 --- a/packages/services/src/invitation/create.ts +++ b/packages/services/src/invitation/create.ts @@ -8,6 +8,7 @@ import { import { emitAudit } from "../audit"; import { requireScope } from "../auth"; import { type ServiceContext, withTransaction } from "../context"; +import { env } from "../env"; import { LimitExceededError } from "../errors"; import type { Invitation } from "../types"; import { CreateInvitationInput } from "./schemas"; @@ -85,7 +86,7 @@ export async function createInvitation(args: { // Strict equality on `"development"` so the test runner (which sets // `NODE_ENV=test`) and CI (typically `undefined` or `"test"`) don't // leak tokens into logs. - if (process.env.NODE_ENV === "development") { + if (env.NODE_ENV === "development") { console.log( `>>>> Invitation token: http://localhost:3000/invite?token=${token} <<<< `, ); diff --git a/packages/tinybird/src/client.ts b/packages/tinybird/src/client.ts index 690fb2468..dca81675b 100644 --- a/packages/tinybird/src/client.ts +++ b/packages/tinybird/src/client.ts @@ -2,6 +2,7 @@ import { Tinybird as Client, NoopTinybird } from "@chronark/zod-bird"; import { z } from "zod"; import { monitorRegions } from "../../db/src/schema/constants"; +import { env } from "./env"; import { headersSchema, timingPhasesSchema, @@ -11,7 +12,7 @@ import { const PUBLIC_CACHE = 300; // 5 * 60 = 300s = 5m const DEV_CACHE = 10 * 60; // 10m -const REVALIDATE = process.env.NODE_ENV === "development" ? DEV_CACHE : 0; +const REVALIDATE = env.NODE_ENV === "development" ? DEV_CACHE : 0; // Daily-aggregate columns shared by the external-status history pipes (page- // level and per-component). One shape so the `day` GMT-parse can't drift. @@ -311,8 +312,7 @@ export class OSTinybird { constructor(config: OSTinybirdConfig) { // Tests must never reach a real instance whatever token sits in the env — // checked here so no call site can forget it. - const noop = - process.env.NODE_ENV === "test" || noopFlagSchema.parse(config.noop); + const noop = env.NODE_ENV === "test" || noopFlagSchema.parse(config.noop); // An empty token cannot authenticate, so noop keeps pipes resolving empty // instead of throwing at every call site. if (noop || !config.token) { diff --git a/packages/tinybird/src/env.ts b/packages/tinybird/src/env.ts new file mode 100644 index 000000000..201dc8061 --- /dev/null +++ b/packages/tinybird/src/env.ts @@ -0,0 +1,15 @@ +/** + * `NODE_ENV`, read so that bundlers cannot fold it away. + * + * `deno bundle` (esbuild) rewrites the exact expression `process.env.NODE_ENV` + * to a string literal at build time — `"development"` unless `--minify` is + * passed, and `apps/server` bundles without it (see `apps/server/dofigen.yml`). + * Reading through an aliased object defeats that substitution, so the value + * comes from the real environment at runtime. + */ +export const env = { + get NODE_ENV(): string { + const processEnv: Record = process.env; + return processEnv.NODE_ENV ?? "development"; + }, +}; -- 2.54.0