typesafe env variables

This commit is contained in:
Nate Kelley 2025-06-24 10:08:27 -06:00
parent e70cd9d4a9
commit a89d701795
No known key found for this signature in database
GPG Key ID: FD90372AB8D98B4F
22 changed files with 225 additions and 37 deletions

View File

@ -1 +0,0 @@
NODE_OPTIONS='--no-deprecation'

9
apps/web/.env.example Normal file
View File

@ -0,0 +1,9 @@
NEXT_PUBLIC_API_URL="http://127.0.0.1:3001"
NEXT_PUBLIC_API2_URL="http://127.0.0.1:3002"
NEXT_PUBLIC_WEB_SOCKET_URL="ws://127.0.0.1:3001"
NEXT_PUBLIC_URL="http://localhost:3000"
NEXT_PUBLIC_SUPABASE_URL="http://127.0.0.1:54321"
NEXT_PUBLIC_SUPABASE_ANON_KEY=""
NEXT_SLACK_APP_SUPPORT_URL=""
NEXT_PUBLIC_POSTHOG_KEY=""
NEXT_PUBLIC_POSTHOG_HOST=""

View File

@ -1,20 +1,15 @@
import path, { dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import env from './src/config/env.mjs';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const apiUrl = process.env.NEXT_PUBLIC_API_URL
? new URL(process.env.NEXT_PUBLIC_API_URL).origin
: '';
const wsUrl = process.env.NEXT_PUBLIC_WEB_SOCKET_URL
? new URL(process.env.NEXT_PUBLIC_WEB_SOCKET_URL).origin
.replace('https', 'wss')
.replace('http', 'ws')
: '';
const api2Url = process.env.NEXT_PUBLIC_API2_URL
? new URL(process.env.NEXT_PUBLIC_API2_URL).origin
: '';
const apiUrl = new URL(env.NEXT_PUBLIC_API_URL).origin;
const api2Url = new URL(env.NEXT_PUBLIC_API2_URL).origin;
const wsUrl = new URL(env.NEXT_PUBLIC_WEB_SOCKET_URL).origin
.replace('https', 'wss')
.replace('http', 'ws');
// Function to create CSP header with dynamic API URLs
const createCspHeader = (isEmbed = false) => {

View File

@ -60,9 +60,6 @@ export async function login(page: Page) {
await page.goto('http://localhost:3000/auth/login');
// Add your login logic here, for example:
// await page.fill('input[name="email"]', process.env.TEST_USER_EMAIL || 'test@example.com');
// await page.fill('input[name="password"]', process.env.TEST_USER_PASSWORD || 'password123');
// await page.click('button[type="submit"]');
await page.getByText('Sign in').click();
await page.getByText(`Don't already have an account?`).click();

View File

@ -1,4 +1,6 @@
export const BASE_API_URL = `${process.env.NEXT_PUBLIC_API_URL}`;
export const BASE_API_URL_V2 = `${process.env.NEXT_PUBLIC_API2_URL}`;
import env from '@/config/envClient';
export const BASE_API_URL = `${env.NEXT_PUBLIC_API_URL}`;
export const BASE_API_URL_V2 = `${env.NEXT_PUBLIC_API2_URL}`;
export const BASE_URL = `${BASE_API_URL}/api/v1`;
export const BASE_URL_V2 = `${BASE_API_URL_V2}/api/v2`;

View File

@ -1,6 +1,7 @@
import { createAxiosInstance } from '../createAxiosInstance';
import env from '@/config/envClient';
const nextApi = createAxiosInstance(process.env.NEXT_PUBLIC_URL || '');
const nextApi = createAxiosInstance(env.NEXT_PUBLIC_API_URL || '');
export default nextApi;

View File

@ -1,4 +1,6 @@
const SUPABASE_CONNECTION_URL = `${process.env.NEXT_PUBLIC_SUPABASE_URL}/functions/v1/connect-supabase/login`;
import env from '@/config/envClient';
const SUPABASE_CONNECTION_URL = `${env.NEXT_PUBLIC_SUPABASE_URL}/functions/v1/connect-supabase/login`;
export const connectSupabaseToBuster = async () => {
window.location.href = SUPABASE_CONNECTION_URL;

View File

@ -1,10 +1,11 @@
'use server';
import env from '@/config/env';
import { type NextRequest, NextResponse } from 'next/server';
import type { AppSupportRequest } from '@/api/buster_rest/nextjs/support';
import { createClient } from '@/lib/supabase/server';
const slackHookURL = process.env.NEXT_SLACK_APP_SUPPORT_URL || '';
const slackHookURL = env.NEXT_SLACK_APP_SUPPORT_URL || '';
const STORAGE_BUCKET = 'support-screenshots'; // Using the default public bucket that usually exists
export async function POST(request: NextRequest) {

View File

@ -25,10 +25,11 @@ import {
import { inputHasText } from '@/lib/text';
import { BusterRoutes, createBusterRoute } from '@/routes/busterRoutes';
import { PolicyCheck } from './PolicyCheck';
import env from '@/config/envClient';
const DEFAULT_CREDENTIALS = {
email: process.env.NEXT_PUBLIC_USER || '',
password: process.env.NEXT_PUBLIC_USER_PASSWORD || ''
email: env.NEXT_PUBLIC_USER || '',
password: env.NEXT_PUBLIC_USER_PASSWORD || ''
};
export const LoginForm: React.FC = () => {

View File

@ -1 +1,3 @@
export const isDev = process.env.NODE_ENV === 'development';
import env from './envClient';
export const isDev = env.NODE_ENV === 'development';

11
apps/web/src/config/env.d.ts vendored Normal file
View File

@ -0,0 +1,11 @@
import { ClientEnv } from './envClient';
export interface ServerEnv {
NEXT_SLACK_APP_SUPPORT_URL?: string;
}
export interface Env extends ClientEnv, ServerEnv {}
// Main env (client + server)
declare const env: Env;
export default env;

View File

@ -0,0 +1,57 @@
import { z } from 'zod';
import clientEnv from './envClient.mjs';
import { isServer } from '@tanstack/react-query';
if (!isServer) {
throw new Error('env.mjs is only meant to be used on the server');
}
const serverEnvSchema = z.object({
// Slack integration (private)
NEXT_SLACK_APP_SUPPORT_URL: z
.string()
.url({ message: 'NEXT_SLACK_APP_SUPPORT_URL must be a valid URL' })
.optional()
});
// Parse and validate server-only environment variables
let serverEnv;
try {
serverEnv = serverEnvSchema.parse(process.env);
} catch (error) {
console.error('❌ Server environment validation failed!');
console.error('');
if (error instanceof z.ZodError) {
console.error('The following private environment variables are invalid or missing:');
console.error('');
error.errors.forEach((err) => {
const path = err.path.join('.');
console.error(`${path}: ${err.message}`);
});
console.error('');
console.error(
'Please check your .env file and ensure all required private variables are set correctly.'
);
} else {
console.error('Unexpected error during server environment validation:', error);
}
console.error('');
console.error('Build cannot continue with invalid server environment configuration.');
// Throw error to prevent build from continuing
process.exit(1);
}
// Combine client and server environment variables
const env = {
...clientEnv,
...serverEnv
};
export { env };
export default env;

17
apps/web/src/config/envClient.d.ts vendored Normal file
View File

@ -0,0 +1,17 @@
export interface ClientEnv {
NODE_ENV: 'development' | 'production' | 'test';
NEXT_PUBLIC_API_URL: string;
NEXT_PUBLIC_API2_URL: string;
NEXT_PUBLIC_WEB_SOCKET_URL: string;
NEXT_PUBLIC_URL: string;
NEXT_PUBLIC_SUPABASE_URL: string;
NEXT_PUBLIC_SUPABASE_ANON_KEY: string;
NEXT_PUBLIC_POSTHOG_KEY?: string;
NEXT_PUBLIC_POSTHOG_HOST?: string;
NEXT_PUBLIC_USER?: string;
NEXT_PUBLIC_USER_PASSWORD?: string;
}
// Client-only env for frontend usage
declare const clientEnv: ClientEnv;
export default clientEnv;

View File

@ -0,0 +1,83 @@
import { z } from 'zod';
const clientEnvSchema = z.object({
// Node environment
NODE_ENV: z
.enum(['development', 'production', 'test'], {
errorMap: () => ({ message: 'NODE_ENV must be development, production, or test' })
})
.default('development'),
// API URLs
NEXT_PUBLIC_API_URL: z
.string()
.min(1, { message: 'NEXT_PUBLIC_API_URL is required' })
.url({ message: 'NEXT_PUBLIC_API_URL must be a valid URL' }),
NEXT_PUBLIC_API2_URL: z
.string()
.min(1, { message: 'NEXT_PUBLIC_API2_URL is required' })
.url({ message: 'NEXT_PUBLIC_API2_URL must be a valid URL' }),
NEXT_PUBLIC_WEB_SOCKET_URL: z
.string()
.min(1, { message: 'NEXT_PUBLIC_WEB_SOCKET_URL is required' })
.url({ message: 'NEXT_PUBLIC_WEB_SOCKET_URL must be a valid URL' }),
NEXT_PUBLIC_URL: z
.string()
.min(1, { message: 'NEXT_PUBLIC_URL is required' })
.url({ message: 'NEXT_PUBLIC_URL must be a valid URL' }),
// Supabase configuration
NEXT_PUBLIC_SUPABASE_URL: z
.string()
.min(1, { message: 'NEXT_PUBLIC_SUPABASE_URL is required' })
.url({ message: 'NEXT_PUBLIC_SUPABASE_URL must be a valid URL' }),
NEXT_PUBLIC_SUPABASE_ANON_KEY: z
.string()
.min(1, { message: 'NEXT_PUBLIC_SUPABASE_ANON_KEY is required' }),
// PostHog analytics
NEXT_PUBLIC_POSTHOG_KEY: z.string().optional(),
NEXT_PUBLIC_POSTHOG_HOST: z
.string()
.url({ message: 'NEXT_PUBLIC_POSTHOG_HOST must be a valid URL' })
.optional(),
// Development/Testing credentials
NEXT_PUBLIC_USER: z.string().optional(),
NEXT_PUBLIC_USER_PASSWORD: z.string().optional()
});
// Parse and validate client environment variables
let clientEnv;
try {
clientEnv = clientEnvSchema.parse(process.env);
} catch (error) {
console.error('❌ Client environment validation failed!');
console.error('');
if (error instanceof z.ZodError) {
console.error('The following public environment variables are invalid or missing:');
console.error('');
error.errors.forEach((err) => {
const path = err.path.join('.');
console.error(`${path}: ${err.message}`);
});
console.error('');
console.error(
'Please check your .env file and ensure all required public variables are set correctly.'
);
} else {
console.error('Unexpected error during client environment validation:', error);
}
console.error('');
console.error('Build cannot continue with invalid client environment configuration.');
// Throw error to prevent build from continuing
process.exit(1);
}
export default clientEnv;

View File

@ -1 +1,3 @@
export const BUSTER_WS_URL = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/ws`;
import env from '@/config/envClient';
export const BUSTER_WS_URL = `${env.NEXT_PUBLIC_WEB_SOCKET_URL}/api/v1/ws`;

View File

@ -13,8 +13,9 @@ import { useMemoizedFn } from '@/hooks';
import { useWebSocket } from '@/hooks/useWebSocket';
import type { SupabaseContextReturnType } from '../../Supabase';
import { useSupabaseContext } from '../../Supabase';
import env from '@/config/envClient';
const BUSTER_WS_URL = `${process.env.NEXT_PUBLIC_WEB_SOCKET_URL}/api/v1/ws`;
const BUSTER_WS_URL = `${env.NEXT_PUBLIC_WEB_SOCKET_URL}/api/v1/ws`;
export type BusterOnCallback = {
callback: BusterSocketResponse['callback'];

View File

@ -8,8 +8,9 @@ import React, { type PropsWithChildren, useEffect } from 'react';
import type { BusterUserTeam } from '@/api/asset_interfaces';
import { isDev } from '@/config';
import { useUserConfigContextSelector } from '../Users';
import env from '@/config/envClient';
const POSTHOG_KEY = process.env.NEXT_PUBLIC_POSTHOG_KEY;
const POSTHOG_KEY = env.NEXT_PUBLIC_POSTHOG_KEY;
export const BusterPosthogProvider: React.FC<PropsWithChildren> = React.memo(({ children }) => {
if (isDev || !POSTHOG_KEY) {
@ -21,7 +22,7 @@ export const BusterPosthogProvider: React.FC<PropsWithChildren> = React.memo(({
BusterPosthogProvider.displayName = 'BusterPosthogProvider';
const options: Partial<PostHogConfig> = {
api_host: process.env.NEXT_PUBLIC_POSTHOG_HOST,
api_host: env.NEXT_PUBLIC_POSTHOG_HOST,
person_profiles: 'always',
session_recording: {
recordBody: true

View File

@ -1,8 +1,9 @@
import { createBrowserClient } from '@supabase/ssr';
import env from '@/config/envClient';
export function createClient() {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
const supabaseUrl = env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseAnonKey = env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
if (!supabaseUrl || !supabaseAnonKey) {
throw new Error('Missing Supabase environment variables');

View File

@ -2,8 +2,9 @@
import { BusterRoutes, createBusterRoute } from '@/routes';
import { createClient } from './server';
import env from '@/config/env';
const authURLFull = `${process.env.NEXT_PUBLIC_URL}${createBusterRoute({
const authURLFull = `${env.NEXT_PUBLIC_URL}${createBusterRoute({
route: BusterRoutes.AUTH_CALLBACK
})}`;

View File

@ -1,17 +1,20 @@
'use server';
import { type CookieOptions, createServerClient } from '@supabase/ssr';
import { cookies } from 'next/headers';
import env from '@/config/env';
export const COOKIE_OPTIONS: CookieOptions = {
path: '/',
secure: process.env.NODE_ENV === 'production', // Only use secure in production
secure: env.NODE_ENV === 'production', // Only use secure in production
sameSite: 'lax', // Type assertion to fix the error
httpOnly: true, // Make cookies HttpOnly
maxAge: 60 * 60 * 24 * 7 // 1 week
};
export async function createClient() {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
const supabaseUrl = env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseAnonKey = env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
if (!supabaseUrl || !supabaseAnonKey) {
throw new Error('Missing Supabase environment variables');

View File

@ -4,8 +4,9 @@ import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
import { BusterRoutes, createBusterRoute } from '@/routes';
import { createClient } from './server';
import env from '@/config/env';
const authURLFull = `${process.env.NEXT_PUBLIC_URL}${createBusterRoute({
const authURLFull = `${env.NEXT_PUBLIC_URL}${createBusterRoute({
route: BusterRoutes.AUTH_CALLBACK
})}`;
@ -102,7 +103,7 @@ export const signUp = async ({ email, password }: { email: string; password: str
const authURL = createBusterRoute({
route: BusterRoutes.AUTH_CONFIRM
});
const authURLFull = `${process.env.NEXT_PUBLIC_URL}${authURL}`;
const authURLFull = `${env.NEXT_PUBLIC_URL}${authURL}`;
const { error } = await supabase.auth.signUp({
email,

View File

@ -1,6 +1,7 @@
import { createServerClient } from '@supabase/ssr';
import type { User } from '@supabase/supabase-js';
import { type NextRequest, NextResponse } from 'next/server';
import env from '@/config/env';
export async function updateSession(request: NextRequest) {
let supabaseResponse = NextResponse.next({
@ -8,8 +9,8 @@ export async function updateSession(request: NextRequest) {
});
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL || '',
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || '',
env.NEXT_PUBLIC_SUPABASE_URL || '',
env.NEXT_PUBLIC_SUPABASE_ANON_KEY || '',
{
cookies: {
getAll() {