From fbc1e73d65e858dce298f3dfc2b85e6456f3f2eb Mon Sep 17 00:00:00 2001 From: Rahul Mishra Date: Thu, 13 Feb 2025 21:52:48 +0530 Subject: [PATCH] feat: type safe env (#161) --- .env.example | 7 +-- app/api/auth/early-access/route.ts | 3 +- app/api/driver.ts | 7 +-- .../mail/auth/[providerId]/callback/route.ts | 5 +-- db/index.ts | 5 ++- docker-compose.yaml | 26 ++++++++++- drizzle.config.ts | 3 +- helpers/redis.ts | 10 +---- lib/auth-client.ts | 5 +-- lib/auth.ts | 11 ++--- lib/env.ts | 28 ++++++++++++ package.json | 1 + pnpm-lock.yaml | 43 +++++++++++++++++++ 13 files changed, 123 insertions(+), 31 deletions(-) create mode 100644 lib/env.ts diff --git a/.env.example b/.env.example index 5fed39c30..a4daf8db2 100644 --- a/.env.example +++ b/.env.example @@ -9,13 +9,14 @@ BETTER_AUTH_URL=http://localhost:3000 # Change to your project's client ID and secret, these work with localhost:3000 and localhost:3001 GOOGLE_CLIENT_ID= GOOGLE_CLIENT_SECRET= +GOOGLE_REDIRECT_URI=http://localhost:3000/api/v1/mail/auth/google/callback NEXT_PUBLIC_POSTHOG_KEY= NEXT_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com -# Upstash Redis Instance -REDIS_URL="" -REDIS_TOKEN="" +# Upstash/Local Redis Instance +REDIS_URL="http://localhost:8079" +REDIS_TOKEN="upstash-local-token" # Resend API Key RESEND_API_KEY= diff --git a/app/api/auth/early-access/route.ts b/app/api/auth/early-access/route.ts index 393b68199..f56c79ad5 100644 --- a/app/api/auth/early-access/route.ts +++ b/app/api/auth/early-access/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { earlyAccess } from "@/db/schema"; import { randomUUID } from "node:crypto"; +import { env } from "@/lib/env"; import { db } from "@/db"; type PostgresError = { @@ -88,7 +89,7 @@ export async function POST(req: NextRequest) { }); // Return more detailed error in development - if (process.env.NODE_ENV === "development") { + if (env.NODE_ENV === "development") { return NextResponse.json( { error: "Internal server error", diff --git a/app/api/driver.ts b/app/api/driver.ts index 7235eac75..7df4c4c69 100644 --- a/app/api/driver.ts +++ b/app/api/driver.ts @@ -2,6 +2,7 @@ import { ParsedMessage } from "@/types"; import { google } from "googleapis"; +import { env } from "@/lib/env"; import * as he from "he"; interface MailManager { @@ -53,9 +54,9 @@ const findHtmlBody = (parts: any[]): string => { const googleDriver = async (config: IConfig): Promise => { const auth = new google.auth.OAuth2( - process.env.GOOGLE_CLIENT_ID as string, - process.env.GOOGLE_CLIENT_SECRET as string, - process.env.GOOGLE_REDIRECT_URI as string, + env.GOOGLE_CLIENT_ID as string, + env.GOOGLE_CLIENT_SECRET as string, + env.GOOGLE_REDIRECT_URI as string, ); const getScope = () => diff --git a/app/api/v1/mail/auth/[providerId]/callback/route.ts b/app/api/v1/mail/auth/[providerId]/callback/route.ts index 89d9391c7..06278935e 100644 --- a/app/api/v1/mail/auth/[providerId]/callback/route.ts +++ b/app/api/v1/mail/auth/[providerId]/callback/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { createDriver } from "@/app/api/driver"; import { connection } from "@/db/schema"; +import { env } from "@/lib/env"; import { db } from "@/db"; export async function GET( @@ -12,9 +13,7 @@ export async function GET( const state = searchParams.get("state"); if (!code || !state) { - return NextResponse.redirect( - `${process.env.NEXT_PUBLIC_APP_URL}/settings/email?error=missing_params`, - ); + return NextResponse.redirect(`${env.NEXT_PUBLIC_APP_URL}/settings/email?error=missing_params`); } const { providerId } = await params; diff --git a/db/index.ts b/db/index.ts index 6cbae451e..b61c670ad 100644 --- a/db/index.ts +++ b/db/index.ts @@ -2,6 +2,7 @@ import { drizzle } from "drizzle-orm/postgres-js"; import postgres from "postgres"; import * as schema from "./schema"; +import { env } from "@/lib/env"; /** * Cache the database connection in development. This avoids creating a new connection on every HMR @@ -11,7 +12,7 @@ const globalForDb = globalThis as unknown as { conn: postgres.Sql | undefined; }; -const conn = globalForDb.conn ?? postgres(process.env.DATABASE_URL!); -if (process.env.NODE_ENV !== "production") globalForDb.conn = conn; +const conn = globalForDb.conn ?? postgres(env.DATABASE_URL!); +if (env.NODE_ENV !== "production") globalForDb.conn = conn; export const db = drizzle(conn, { schema }); diff --git a/docker-compose.yaml b/docker-compose.yaml index 2871d7d98..da628969c 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -2,16 +2,38 @@ services: db: container_name: mail0-db image: postgres:17 - restart: always + restart: unless-stopped environment: POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres POSTGRES_DB: mail0 PGDATA: /var/lib/postgresql/data/pgdata ports: - - "5432:5432" + - 5432:5432 volumes: - postgres-data:/var/lib/postgresql/data + valkey: + container_name: mail0-redis + image: docker.io/bitnami/valkey:8.0 + environment: + - ALLOW_EMPTY_PASSWORD=yes + - VALKEY_DISABLE_COMMANDS=FLUSHDB,FLUSHALL + ports: + - 6379:6379 + volumes: + - valkey-data:/bitnami/valkey/data + + upstash-proxy: + container_name: mail0-upstash-proxy + image: hiett/serverless-redis-http:latest + environment: + SRH_MODE: env + SRH_TOKEN: upstash-local-token + SRH_CONNECTION_STRING: "redis://valkey:6379" + ports: + - 8079:80 + volumes: + valkey-data: postgres-data: diff --git a/drizzle.config.ts b/drizzle.config.ts index f2e702d1a..15af50485 100644 --- a/drizzle.config.ts +++ b/drizzle.config.ts @@ -1,10 +1,11 @@ import { type Config } from "drizzle-kit"; +import { env } from "./lib/env"; export default { schema: "./db/schema.ts", dialect: "postgresql", dbCredentials: { - url: process.env.DATABASE_URL!, + url: env.DATABASE_URL!, }, out: "./db/migrations", tablesFilter: ["mail0_*"], diff --git a/helpers/redis.ts b/helpers/redis.ts index dcc0f14d2..978f119cc 100644 --- a/helpers/redis.ts +++ b/helpers/redis.ts @@ -1,10 +1,4 @@ import { Redis } from "@upstash/redis"; +import { env } from "@/lib/env"; -const url = process.env.REDIS_URL; -const token = process.env.REDIS_TOKEN; - -if (!url || !token) { - throw new Error("Missing Redis URL or token"); -} - -export const redis = new Redis({ url, token }); +export const redis = new Redis({ url: env.REDIS_URL, token: env.REDIS_TOKEN }); diff --git a/lib/auth-client.ts b/lib/auth-client.ts index 26830e771..6d2dfe567 100644 --- a/lib/auth-client.ts +++ b/lib/auth-client.ts @@ -1,11 +1,10 @@ import { customSessionClient } from "better-auth/client/plugins"; import { createAuthClient } from "better-auth/react"; import type { auth } from "@/lib/auth"; // Import the auth instance as a type - -const BASE_URL = process.env.BASE_URL as string; +import { env } from "./env"; export const authClient = createAuthClient({ - baseURL: BASE_URL, // the base url of your auth server + baseURL: env.NEXT_PUBLIC_APP_URL, plugins: [customSessionClient()], }); diff --git a/lib/auth.ts b/lib/auth.ts index 5311ececd..0e832cfb7 100644 --- a/lib/auth.ts +++ b/lib/auth.ts @@ -5,12 +5,13 @@ import { connection, user as _user } from "@/db/schema"; import { customSession } from "better-auth/plugins"; import { eq } from "drizzle-orm"; import { Resend } from "resend"; +import { env } from "./env"; import { db } from "@/db"; // If there is no resend key, it might be a local dev environment // In that case, we don't want to send emails and just log them -const resend = process.env.RESEND_API_KEY - ? new Resend(process.env.RESEND_API_KEY) +const resend = env.RESEND_API_KEY + ? new Resend(env.RESEND_API_KEY) : { emails: { send: async (...args: any[]) => console.log(args) } }; const options = { @@ -27,8 +28,8 @@ const options = { prompt: "consent", accessType: "offline", scope: ["https://mail.google.com/"], - clientId: process.env.GOOGLE_CLIENT_ID as string, - clientSecret: process.env.GOOGLE_CLIENT_SECRET as string, + clientId: env.GOOGLE_CLIENT_ID, + clientSecret: env.GOOGLE_CLIENT_SECRET, }, }, emailAndPassword: { @@ -52,7 +53,7 @@ const options = { sendOnSignUp: true, autoSignInAfterVerification: true, sendVerificationEmail: async ({ user, token }) => { - const verificationUrl = `${process.env.BASE_URL}/api/auth/verify-email?token=${token}&callbackURL=/connect-emails`; + const verificationUrl = `${env.NEXT_PUBLIC_APP_URL}/api/auth/verify-email?token=${token}&callbackURL=/connect-emails`; await resend.emails.send({ from: "Mail0 ", diff --git a/lib/env.ts b/lib/env.ts new file mode 100644 index 000000000..f3b39385f --- /dev/null +++ b/lib/env.ts @@ -0,0 +1,28 @@ +import { createEnv } from "@t3-oss/env-nextjs"; +import { z } from "zod"; + +export const env = createEnv({ + server: { + NODE_ENV: z.enum(["development", "production", "test"]).default("development"), + DATABASE_URL: z.string().min(1), + BETTER_AUTH_SECRET: z.string().min(1), + BETTER_AUTH_URL: z.string().min(1).url(), + GOOGLE_CLIENT_ID: z.string().min(1), + GOOGLE_CLIENT_SECRET: z.string().min(1), + GOOGLE_REDIRECT_URI: z.string().min(1).url(), + REDIS_URL: z.string().min(1).url(), + REDIS_TOKEN: z.string().min(1), + RESEND_API_KEY: z.string().min(1).optional(), + }, + client: { + NEXT_PUBLIC_APP_URL: z.string().min(1).url(), + NEXT_PUBLIC_POSTHOG_KEY: z.string().optional(), + NEXT_PUBLIC_POSTHOG_HOST: z.string().optional(), + }, + experimental__runtimeEnv: { + NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL, + NEXT_PUBLIC_POSTHOG_HOST: process.env.NEXT_PUBLIC_POSTHOG_HOST, + NEXT_PUBLIC_POSTHOG_KEY: process.env.NEXT_PUBLIC_POSTHOG_KEY, + ...process.env, + }, +}); diff --git a/package.json b/package.json index 5426d307b..9d40b1929 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "@radix-ui/react-toggle-group": "^1.1.2", "@radix-ui/react-tooltip": "^1.1.8", "@radix-ui/react-visually-hidden": "^1.1.2", + "@t3-oss/env-nextjs": "^0.12.0", "@upstash/ratelimit": "^2.0.5", "@upstash/redis": "^1.34.4", "axios": "^1.7.9", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 212cda016..5290b0c06 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -62,6 +62,9 @@ importers: '@radix-ui/react-visually-hidden': specifier: ^1.1.2 version: 1.1.2(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@t3-oss/env-nextjs': + specifier: ^0.12.0 + version: 0.12.0(typescript@5.7.3)(zod@3.24.1) '@upstash/ratelimit': specifier: ^2.0.5 version: 2.0.5(@upstash/redis@1.34.4) @@ -1491,6 +1494,34 @@ packages: '@swc/helpers@0.5.15': resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} + '@t3-oss/env-core@0.12.0': + resolution: {integrity: sha512-lOPj8d9nJJTt81mMuN9GMk8x5veOt7q9m11OSnCBJhwp1QrL/qR+M8Y467ULBSm9SunosryWNbmQQbgoiMgcdw==} + peerDependencies: + typescript: '>=5.0.0' + valibot: ^1.0.0-beta.7 || ^1.0.0 + zod: ^3.24.0 + peerDependenciesMeta: + typescript: + optional: true + valibot: + optional: true + zod: + optional: true + + '@t3-oss/env-nextjs@0.12.0': + resolution: {integrity: sha512-rFnvYk1049RnNVUPvY8iQ55AuQh1Rr+qZzQBh3t++RttCGK4COpXGNxS4+45afuQq02lu+QAOy/5955aU8hRKw==} + peerDependencies: + typescript: '>=5.0.0' + valibot: ^1.0.0-beta.7 || ^1.0.0 + zod: ^3.24.0 + peerDependenciesMeta: + typescript: + optional: true + valibot: + optional: true + zod: + optional: true + '@tailwindcss/typography@0.5.16': resolution: {integrity: sha512-0wDLwCVF5V3x3b1SGXPCDcdsbDHMBe+lkFzBRaHeLvNi+nrrnZ1lA18u+OTWO8iSWU2GxUOCvlXtDuqftc1oiA==} peerDependencies: @@ -5018,6 +5049,18 @@ snapshots: dependencies: tslib: 2.8.1 + '@t3-oss/env-core@0.12.0(typescript@5.7.3)(zod@3.24.1)': + optionalDependencies: + typescript: 5.7.3 + zod: 3.24.1 + + '@t3-oss/env-nextjs@0.12.0(typescript@5.7.3)(zod@3.24.1)': + dependencies: + '@t3-oss/env-core': 0.12.0(typescript@5.7.3)(zod@3.24.1) + optionalDependencies: + typescript: 5.7.3 + zod: 3.24.1 + '@tailwindcss/typography@0.5.16(tailwindcss@3.4.17)': dependencies: lodash.castarray: 4.4.0