diff --git a/CLAUDE.md b/CLAUDE.md index 22a99171..202fba4c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -151,7 +151,7 @@ All endpoints prefixed `/v1/`: **Frontend** (`www/.env`): - `NEXTAUTH_URL`, `NEXTAUTH_SECRET` - Authentication configuration -- `NEXT_PUBLIC_REFLECTOR_API_URL` - Backend API endpoint +- `REFLECTOR_API_URL` - Backend API endpoint - `REFLECTOR_DOMAIN_CONFIG` - Feature flags and domain settings ## Testing Strategy diff --git a/README.md b/README.md index ebb91fcb..d6bdb86e 100644 --- a/README.md +++ b/README.md @@ -168,6 +168,13 @@ You can manually process an audio file by calling the process tool: uv run python -m reflector.tools.process path/to/audio.wav ``` +## Build-time env variables + +Next.js projects are more used to NEXT_PUBLIC_ prefixed buildtime vars. We don't have those for the reason we need to serve a ccustomizable prebuild docker container. + +Instead, all the variables are runtime. Variables needed to the frontend are served to the frontend app at initial render. + +It also means there's no static prebuild and no static files to serve for js/html. ## Feature Flags @@ -177,24 +184,24 @@ Reflector uses environment variable-based feature flags to control application f | Feature Flag | Environment Variable | |-------------|---------------------| -| `requireLogin` | `NEXT_PUBLIC_FEATURE_REQUIRE_LOGIN` | -| `privacy` | `NEXT_PUBLIC_FEATURE_PRIVACY` | -| `browse` | `NEXT_PUBLIC_FEATURE_BROWSE` | -| `sendToZulip` | `NEXT_PUBLIC_FEATURE_SEND_TO_ZULIP` | -| `rooms` | `NEXT_PUBLIC_FEATURE_ROOMS` | +| `requireLogin` | `FEATURE_REQUIRE_LOGIN` | +| `privacy` | `FEATURE_PRIVACY` | +| `browse` | `FEATURE_BROWSE` | +| `sendToZulip` | `FEATURE_SEND_TO_ZULIP` | +| `rooms` | `FEATURE_ROOMS` | ### Setting Feature Flags -Feature flags are controlled via environment variables using the pattern `NEXT_PUBLIC_FEATURE_{FEATURE_NAME}` where `{FEATURE_NAME}` is the SCREAMING_SNAKE_CASE version of the feature name. +Feature flags are controlled via environment variables using the pattern `FEATURE_{FEATURE_NAME}` where `{FEATURE_NAME}` is the SCREAMING_SNAKE_CASE version of the feature name. **Examples:** ```bash # Enable user authentication requirement -NEXT_PUBLIC_FEATURE_REQUIRE_LOGIN=true +FEATURE_REQUIRE_LOGIN=true # Disable browse functionality -NEXT_PUBLIC_FEATURE_BROWSE=false +FEATURE_BROWSE=false # Enable Zulip integration -NEXT_PUBLIC_FEATURE_SEND_TO_ZULIP=true +FEATURE_SEND_TO_ZULIP=true ``` diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 00000000..9b032e40 --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,39 @@ +# Production Docker Compose configuration for Frontend +# Usage: docker compose -f docker-compose.prod.yml up -d + +services: + web: + build: + context: ./www + dockerfile: Dockerfile + image: reflector-frontend:latest + environment: + - KV_URL=${KV_URL:-redis://redis:6379} + - SITE_URL=${SITE_URL} + - API_URL=${API_URL} + - WEBSOCKET_URL=${WEBSOCKET_URL} + - NEXTAUTH_URL=${NEXTAUTH_URL:-http://localhost:3000} + - NEXTAUTH_SECRET=${NEXTAUTH_SECRET:-changeme-in-production} + - AUTHENTIK_ISSUER=${AUTHENTIK_ISSUER} + - AUTHENTIK_CLIENT_ID=${AUTHENTIK_CLIENT_ID} + - AUTHENTIK_CLIENT_SECRET=${AUTHENTIK_CLIENT_SECRET} + - AUTHENTIK_REFRESH_TOKEN_URL=${AUTHENTIK_REFRESH_TOKEN_URL} + - SENTRY_DSN=${SENTRY_DSN} + - SENTRY_IGNORE_API_RESOLUTION_ERROR=${SENTRY_IGNORE_API_RESOLUTION_ERROR:-1} + depends_on: + - redis + restart: unless-stopped + + redis: + image: redis:7.2-alpine + restart: unless-stopped + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 30s + timeout: 3s + retries: 3 + volumes: + - redis_data:/data + +volumes: + redis_data: \ No newline at end of file diff --git a/compose.yml b/docker-compose.yml similarity index 94% rename from compose.yml rename to docker-compose.yml index acbfd3b5..2fd4543d 100644 --- a/compose.yml +++ b/docker-compose.yml @@ -39,7 +39,7 @@ services: ports: - 6379:6379 web: - image: node:18 + image: node:22-alpine ports: - "3000:3000" command: sh -c "corepack enable && pnpm install && pnpm dev" @@ -50,6 +50,8 @@ services: - /app/node_modules env_file: - ./www/.env.local + environment: + - NODE_ENV=development postgres: image: postgres:17 diff --git a/server/reflector/app.py b/server/reflector/app.py index e1d07d20..609474a2 100644 --- a/server/reflector/app.py +++ b/server/reflector/app.py @@ -65,6 +65,12 @@ app.add_middleware( allow_headers=["*"], ) + +@app.get("/health") +async def health(): + return {"status": "healthy"} + + # metrics instrumentator = Instrumentator( excluded_handlers=["/docs", "/metrics"], diff --git a/www/.dockerignore b/www/.dockerignore new file mode 100644 index 00000000..c2d061c7 --- /dev/null +++ b/www/.dockerignore @@ -0,0 +1,14 @@ +.env +.env.* +.env.local +.env.development +.env.production +node_modules +.next +.git +.gitignore +*.md +.DS_Store +coverage +.pnpm-store +*.log \ No newline at end of file diff --git a/www/.env.example b/www/.env.example index 77017d91..da46b513 100644 --- a/www/.env.example +++ b/www/.env.example @@ -1,9 +1,5 @@ -# Environment -ENVIRONMENT=development -NEXT_PUBLIC_ENV=development - # Site Configuration -NEXT_PUBLIC_SITE_URL=http://localhost:3000 +SITE_URL=http://localhost:3000 # Nextauth envs # not used in app code but in lib code @@ -18,16 +14,16 @@ AUTHENTIK_CLIENT_ID=your-client-id-here AUTHENTIK_CLIENT_SECRET=your-client-secret-here # Feature Flags -# NEXT_PUBLIC_FEATURE_REQUIRE_LOGIN=true -# NEXT_PUBLIC_FEATURE_PRIVACY=false -# NEXT_PUBLIC_FEATURE_BROWSE=true -# NEXT_PUBLIC_FEATURE_SEND_TO_ZULIP=true -# NEXT_PUBLIC_FEATURE_ROOMS=true +# FEATURE_REQUIRE_LOGIN=true +# FEATURE_PRIVACY=false +# FEATURE_BROWSE=true +# FEATURE_SEND_TO_ZULIP=true +# FEATURE_ROOMS=true # API URLs -NEXT_PUBLIC_API_URL=http://127.0.0.1:1250 -NEXT_PUBLIC_WEBSOCKET_URL=ws://127.0.0.1:1250 -NEXT_PUBLIC_AUTH_CALLBACK_URL=http://localhost:3000/auth-callback +API_URL=http://127.0.0.1:1250 +WEBSOCKET_URL=ws://127.0.0.1:1250 +AUTH_CALLBACK_URL=http://localhost:3000/auth-callback # Sentry # SENTRY_DSN=https://your-dsn@sentry.io/project-id diff --git a/www/DOCKER_README.md b/www/DOCKER_README.md new file mode 100644 index 00000000..59d4c8ac --- /dev/null +++ b/www/DOCKER_README.md @@ -0,0 +1,81 @@ +# Docker Production Build Guide + +## Overview + +The Docker image builds without any environment variables and requires all configuration to be provided at runtime. + +## Environment Variables (ALL Runtime) + +### Required Runtime Variables + +```bash +API_URL # Backend API URL (e.g., https://api.example.com) +WEBSOCKET_URL # WebSocket URL (e.g., wss://api.example.com) +NEXTAUTH_URL # NextAuth base URL (e.g., https://app.example.com) +NEXTAUTH_SECRET # Random secret for NextAuth (generate with: openssl rand -base64 32) +KV_URL # Redis URL (e.g., redis://redis:6379) +``` + +### Optional Runtime Variables + +```bash +SITE_URL # Frontend URL (defaults to NEXTAUTH_URL) + +AUTHENTIK_ISSUER # OAuth issuer URL +AUTHENTIK_CLIENT_ID # OAuth client ID +AUTHENTIK_CLIENT_SECRET # OAuth client secret +AUTHENTIK_REFRESH_TOKEN_URL # OAuth token refresh URL + +FEATURE_REQUIRE_LOGIN=false # Require authentication +FEATURE_PRIVACY=true # Enable privacy features +FEATURE_BROWSE=true # Enable browsing features +FEATURE_SEND_TO_ZULIP=false # Enable Zulip integration +FEATURE_ROOMS=true # Enable rooms feature + +SENTRY_DSN # Sentry error tracking +AUTH_CALLBACK_URL # OAuth callback URL +``` + +## Building the Image + +### Option 1: Using Docker Compose + +1. Build the image (no environment variables needed): + +```bash +docker compose -f docker-compose.prod.yml build +``` + +2. Create a `.env` file with runtime variables + +3. Run with environment variables: + +```bash +docker compose -f docker-compose.prod.yml --env-file .env up -d +``` + +### Option 2: Using Docker CLI + +1. Build the image (no build args): + +```bash +docker build -t reflector-frontend:latest ./www +``` + +2. Run with environment variables: + +```bash +docker run -d \ + -p 3000:3000 \ + -e API_URL=https://api.example.com \ + -e WEBSOCKET_URL=wss://api.example.com \ + -e NEXTAUTH_URL=https://app.example.com \ + -e NEXTAUTH_SECRET=your-secret \ + -e KV_URL=redis://redis:6379 \ + -e AUTHENTIK_ISSUER=https://auth.example.com/application/o/reflector \ + -e AUTHENTIK_CLIENT_ID=your-client-id \ + -e AUTHENTIK_CLIENT_SECRET=your-client-secret \ + -e AUTHENTIK_REFRESH_TOKEN_URL=https://auth.example.com/application/o/token/ \ + -e FEATURE_REQUIRE_LOGIN=true \ + reflector-frontend:latest +``` diff --git a/www/Dockerfile b/www/Dockerfile index 68c23e33..65729046 100644 --- a/www/Dockerfile +++ b/www/Dockerfile @@ -24,7 +24,8 @@ COPY --link . . ENV NEXT_TELEMETRY_DISABLED 1 # If using npm comment out above and use below instead -RUN pnpm build +# next.js has the feature of excluding build step planned https://github.com/vercel/next.js/discussions/46544 +RUN pnpm build-production # RUN npm run build # Production image, copy all the files and run next @@ -51,6 +52,10 @@ USER nextjs EXPOSE 3000 ENV PORT 3000 -ENV HOSTNAME localhost +ENV HOSTNAME 0.0.0.0 + +HEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://127.0.0.1:3000/api/health \ + || exit 1 CMD ["node", "server.js"] diff --git a/www/app/(app)/rooms/_components/ICSSettings.tsx b/www/app/(app)/rooms/_components/ICSSettings.tsx index 9b45ff33..58f5db98 100644 --- a/www/app/(app)/rooms/_components/ICSSettings.tsx +++ b/www/app/(app)/rooms/_components/ICSSettings.tsx @@ -200,7 +200,13 @@ export default function ICSSettings({ - handleForceSync(parseNonEmptyString(room.name)) + handleForceSync( + parseNonEmptyString( + room.name, + true, + "panic! room.name is required", + ), + ) } size="sm" variant="ghost" disabled={syncingRooms.has( - parseNonEmptyString(room.name), + parseNonEmptyString( + room.name, + true, + "panic! room.name is required", + ), )} > - {syncingRooms.has(parseNonEmptyString(room.name)) ? ( + {syncingRooms.has( + parseNonEmptyString( + room.name, + true, + "panic! room.name is required", + ), + ) ? ( ) : ( @@ -297,7 +313,13 @@ export function RoomTable({ - onCopyUrl(parseNonEmptyString(room.name)) + onCopyUrl( + parseNonEmptyString( + room.name, + true, + "panic! room.name is required", + ), + ) } size="sm" variant="ghost" diff --git a/www/app/(app)/rooms/page.tsx b/www/app/(app)/rooms/page.tsx index 723d698a..a7a68d2f 100644 --- a/www/app/(app)/rooms/page.tsx +++ b/www/app/(app)/rooms/page.tsx @@ -833,7 +833,13 @@ export default function RoomsList() { (null); - const roomName = parseNonEmptyString(params.roomName); + const roomName = parseNonEmptyString( + params.roomName, + true, + "panic! params.roomName is required", + ); const router = useRouter(); const auth = useAuth(); const status = auth.status; @@ -308,7 +312,14 @@ export default function Room(details: RoomDetails) { const handleMeetingSelect = (selectedMeeting: Meeting) => { router.push( - roomMeetingUrl(roomName, parseNonEmptyString(selectedMeeting.id)), + roomMeetingUrl( + roomName, + parseNonEmptyString( + selectedMeeting.id, + true, + "panic! selectedMeeting.id is required", + ), + ), ); }; diff --git a/www/app/api/health/route.ts b/www/app/api/health/route.ts new file mode 100644 index 00000000..80a58b7c --- /dev/null +++ b/www/app/api/health/route.ts @@ -0,0 +1,38 @@ +import { NextResponse } from "next/server"; + +export async function GET() { + const health = { + status: "healthy", + timestamp: new Date().toISOString(), + uptime: process.uptime(), + environment: process.env.NODE_ENV, + checks: { + redis: await checkRedis(), + }, + }; + + const allHealthy = Object.values(health.checks).every((check) => check); + + return NextResponse.json(health, { + status: allHealthy ? 200 : 503, + }); +} + +async function checkRedis(): Promise { + try { + if (!process.env.KV_URL) { + return false; + } + + const { tokenCacheRedis } = await import("../../lib/redisClient"); + const testKey = `health:check:${Date.now()}`; + await tokenCacheRedis.setex(testKey, 10, "OK"); + const value = await tokenCacheRedis.get(testKey); + await tokenCacheRedis.del(testKey); + + return value === "OK"; + } catch (error) { + console.error("Redis health check failed:", error); + return false; + } +} diff --git a/www/app/layout.tsx b/www/app/layout.tsx index 175b7cbc..5fc01ebe 100644 --- a/www/app/layout.tsx +++ b/www/app/layout.tsx @@ -6,7 +6,10 @@ import ErrorMessage from "./(errors)/errorMessage"; import { RecordingConsentProvider } from "./recordingConsentContext"; import { ErrorBoundary } from "@sentry/nextjs"; import { Providers } from "./providers"; -import { assertExistsAndNonEmptyString } from "./lib/utils"; +import { getNextEnvVar } from "./lib/nextBuild"; +import { getClientEnv } from "./lib/clientEnv"; + +export const dynamic = "force-dynamic"; const poppins = Poppins({ subsets: ["latin"], @@ -21,13 +24,11 @@ export const viewport: Viewport = { maximumScale: 1, }; -const NEXT_PUBLIC_SITE_URL = assertExistsAndNonEmptyString( - process.env.NEXT_PUBLIC_SITE_URL, - "NEXT_PUBLIC_SITE_URL required", -); +const SITE_URL = getNextEnvVar("SITE_URL"); +const env = getClientEnv(); export const metadata: Metadata = { - metadataBase: new URL(NEXT_PUBLIC_SITE_URL), + metadataBase: new URL(SITE_URL), title: { template: "%s – Reflector", default: "Reflector - AI-Powered Meeting Transcriptions by Monadical", @@ -74,15 +75,16 @@ export default async function RootLayout({ }) { return ( - - - "something went really wrong"

}> - - - {children} - -
-
+ + "something went really wrong"

}> + + + {children} + +
); diff --git a/www/app/lib/apiClient.tsx b/www/app/lib/apiClient.tsx index a5cec06b..442d2f42 100644 --- a/www/app/lib/apiClient.tsx +++ b/www/app/lib/apiClient.tsx @@ -3,21 +3,19 @@ import createClient from "openapi-fetch"; import type { paths } from "../reflector-api"; import createFetchClient from "openapi-react-query"; -import { assertExistsAndNonEmptyString, parseNonEmptyString } from "./utils"; +import { parseNonEmptyString } from "./utils"; import { isBuildPhase } from "./next"; import { getSession } from "next-auth/react"; import { assertExtendedToken } from "./types"; +import { getClientEnv } from "./clientEnv"; export const API_URL = !isBuildPhase - ? assertExistsAndNonEmptyString( - process.env.NEXT_PUBLIC_API_URL, - "NEXT_PUBLIC_API_URL required", - ) + ? getClientEnv().API_URL : "http://localhost"; -// TODO decide strict validation or not -export const WEBSOCKET_URL = - process.env.NEXT_PUBLIC_WEBSOCKET_URL || "ws://127.0.0.1:1250"; +export const WEBSOCKET_URL = !isBuildPhase + ? getClientEnv().WEBSOCKET_URL || "ws://127.0.0.1:1250" + : "ws://localhost"; export const client = createClient({ baseUrl: API_URL, @@ -44,7 +42,7 @@ client.use({ if (token !== null) { request.headers.set( "Authorization", - `Bearer ${parseNonEmptyString(token)}`, + `Bearer ${parseNonEmptyString(token, true, "panic! token is required")}`, ); } // XXX Only set Content-Type if not already set (FormData will set its own boundary) diff --git a/www/app/lib/authBackend.ts b/www/app/lib/authBackend.ts index 5e9767c9..a44f1d36 100644 --- a/www/app/lib/authBackend.ts +++ b/www/app/lib/authBackend.ts @@ -18,26 +18,25 @@ import { deleteTokenCache, } from "./redisTokenCache"; import { tokenCacheRedis, redlock } from "./redisClient"; -import { isBuildPhase } from "./next"; import { sequenceThrows } from "./errorUtils"; import { featureEnabled } from "./features"; +import { getNextEnvVar } from "./nextBuild"; const TOKEN_CACHE_TTL = REFRESH_ACCESS_TOKEN_BEFORE; -const getAuthentikClientId = () => - assertExistsAndNonEmptyString( - process.env.AUTHENTIK_CLIENT_ID, - "AUTHENTIK_CLIENT_ID required", - ); -const getAuthentikClientSecret = () => - assertExistsAndNonEmptyString( - process.env.AUTHENTIK_CLIENT_SECRET, - "AUTHENTIK_CLIENT_SECRET required", - ); +const getAuthentikClientId = () => getNextEnvVar("AUTHENTIK_CLIENT_ID"); +const getAuthentikClientSecret = () => getNextEnvVar("AUTHENTIK_CLIENT_SECRET"); const getAuthentikRefreshTokenUrl = () => - assertExistsAndNonEmptyString( - process.env.AUTHENTIK_REFRESH_TOKEN_URL, - "AUTHENTIK_REFRESH_TOKEN_URL required", - ); + getNextEnvVar("AUTHENTIK_REFRESH_TOKEN_URL"); + +const getAuthentikIssuer = () => { + const stringUrl = getNextEnvVar("AUTHENTIK_ISSUER"); + try { + new URL(stringUrl); + } catch (e) { + throw new Error("AUTHENTIK_ISSUER is not a valid URL: " + stringUrl); + } + return stringUrl; +}; export const authOptions = (): AuthOptions => featureEnabled("requireLogin") @@ -45,16 +44,17 @@ export const authOptions = (): AuthOptions => providers: [ AuthentikProvider({ ...(() => { - const [clientId, clientSecret] = sequenceThrows( + const [clientId, clientSecret, issuer] = sequenceThrows( getAuthentikClientId, getAuthentikClientSecret, + getAuthentikIssuer, ); return { clientId, clientSecret, + issuer, }; })(), - issuer: process.env.AUTHENTIK_ISSUER, authorization: { params: { scope: "openid email profile offline_access", diff --git a/www/app/lib/clientEnv.ts b/www/app/lib/clientEnv.ts new file mode 100644 index 00000000..04797ce2 --- /dev/null +++ b/www/app/lib/clientEnv.ts @@ -0,0 +1,88 @@ +import { + assertExists, + assertExistsAndNonEmptyString, + NonEmptyString, + parseNonEmptyString, +} from "./utils"; +import { isBuildPhase } from "./next"; +import { getNextEnvVar } from "./nextBuild"; + +export const FEATURE_REQUIRE_LOGIN_ENV_NAME = "FEATURE_REQUIRE_LOGIN" as const; +export const FEATURE_PRIVACY_ENV_NAME = "FEATURE_PRIVACY" as const; +export const FEATURE_BROWSE_ENV_NAME = "FEATURE_BROWSE" as const; +export const FEATURE_SEND_TO_ZULIP_ENV_NAME = "FEATURE_SEND_TO_ZULIP" as const; +export const FEATURE_ROOMS_ENV_NAME = "FEATURE_ROOMS" as const; + +const FEATURE_ENV_NAMES = [ + FEATURE_REQUIRE_LOGIN_ENV_NAME, + FEATURE_PRIVACY_ENV_NAME, + FEATURE_BROWSE_ENV_NAME, + FEATURE_SEND_TO_ZULIP_ENV_NAME, + FEATURE_ROOMS_ENV_NAME, +] as const; + +export type EnvFeaturePartial = { + [key in (typeof FEATURE_ENV_NAMES)[number]]: boolean; +}; + +// CONTRACT: isomorphic with JSON.stringify +export type ClientEnvCommon = EnvFeaturePartial & { + API_URL: NonEmptyString; + WEBSOCKET_URL: NonEmptyString | null; +}; + +let clientEnv: ClientEnvCommon | null = null; +export const getClientEnvClient = (): ClientEnvCommon => { + if (typeof window === "undefined") { + throw new Error( + "getClientEnv() called during SSR - this should only be called in browser environment", + ); + } + if (clientEnv) return clientEnv; + clientEnv = assertExists( + JSON.parse( + assertExistsAndNonEmptyString( + document.body.dataset.env, + "document.body.dataset.env is missing", + ), + ), + "document.body.dataset.env is parsed to nullish", + ); + return clientEnv!; +}; + +const parseBooleanString = (str: string | undefined): boolean => { + return str === "true"; +}; + +export const getClientEnvServer = (): ClientEnvCommon => { + if (typeof window !== "undefined") { + throw new Error( + "getClientEnv() not called during SSR - this should only be called in server environment", + ); + } + if (clientEnv) return clientEnv; + + const features = FEATURE_ENV_NAMES.reduce((acc, x) => { + acc[x] = parseBooleanString(process.env[x]); + return acc; + }, {} as EnvFeaturePartial); + + if (isBuildPhase) { + return { + API_URL: getNextEnvVar("API_URL"), + WEBSOCKET_URL: getNextEnvVar("WEBSOCKET_URL"), + ...features, + }; + } + + clientEnv = { + API_URL: getNextEnvVar("API_URL"), + WEBSOCKET_URL: getNextEnvVar("WEBSOCKET_URL"), + ...features, + }; + return clientEnv; +}; + +export const getClientEnv = + typeof window === "undefined" ? getClientEnvServer : getClientEnvClient; diff --git a/www/app/lib/features.ts b/www/app/lib/features.ts index 7684c8e0..a96e23ef 100644 --- a/www/app/lib/features.ts +++ b/www/app/lib/features.ts @@ -1,3 +1,11 @@ +import { + FEATURE_BROWSE_ENV_NAME, + FEATURE_PRIVACY_ENV_NAME, + FEATURE_REQUIRE_LOGIN_ENV_NAME, + FEATURE_ROOMS_ENV_NAME, + FEATURE_SEND_TO_ZULIP_ENV_NAME, +} from "./clientEnv"; + export const FEATURES = [ "requireLogin", "privacy", @@ -26,26 +34,25 @@ function parseBooleanEnv( return value.toLowerCase() === "true"; } -// WARNING: keep process.env.* as-is, next.js won't see them if you generate dynamically const features: Features = { requireLogin: parseBooleanEnv( - process.env.NEXT_PUBLIC_FEATURE_REQUIRE_LOGIN, + process.env[FEATURE_REQUIRE_LOGIN_ENV_NAME], DEFAULT_FEATURES.requireLogin, ), privacy: parseBooleanEnv( - process.env.NEXT_PUBLIC_FEATURE_PRIVACY, + process.env[FEATURE_PRIVACY_ENV_NAME], DEFAULT_FEATURES.privacy, ), browse: parseBooleanEnv( - process.env.NEXT_PUBLIC_FEATURE_BROWSE, + process.env[FEATURE_BROWSE_ENV_NAME], DEFAULT_FEATURES.browse, ), sendToZulip: parseBooleanEnv( - process.env.NEXT_PUBLIC_FEATURE_SEND_TO_ZULIP, + process.env[FEATURE_SEND_TO_ZULIP_ENV_NAME], DEFAULT_FEATURES.sendToZulip, ), rooms: parseBooleanEnv( - process.env.NEXT_PUBLIC_FEATURE_ROOMS, + process.env[FEATURE_ROOMS_ENV_NAME], DEFAULT_FEATURES.rooms, ), }; diff --git a/www/app/lib/nextBuild.ts b/www/app/lib/nextBuild.ts new file mode 100644 index 00000000..b2e13797 --- /dev/null +++ b/www/app/lib/nextBuild.ts @@ -0,0 +1,17 @@ +import { isBuildPhase } from "./next"; +import { assertExistsAndNonEmptyString, NonEmptyString } from "./utils"; + +const _getNextEnvVar = (name: string, e?: string): NonEmptyString => + isBuildPhase + ? (() => { + throw new Error( + "panic! getNextEnvVar called during build phase; we don't support build envs", + ); + })() + : assertExistsAndNonEmptyString( + process.env[name], + `${name} is required; ${e}`, + ); + +export const getNextEnvVar = (name: string, e?: string): NonEmptyString => + _getNextEnvVar(name, e); diff --git a/www/app/lib/utils.ts b/www/app/lib/utils.ts index 11939cdb..e9260a9b 100644 --- a/www/app/lib/utils.ts +++ b/www/app/lib/utils.ts @@ -1,7 +1,3 @@ -export function isDevelopment() { - return process.env.NEXT_PUBLIC_ENV === "development"; -} - // Function to calculate WCAG contrast ratio export const getContrastRatio = ( foreground: [number, number, number], @@ -145,8 +141,15 @@ export const parseMaybeNonEmptyString = ( s = trim ? s.trim() : s; return s.length > 0 ? (s as NonEmptyString) : null; }; -export const parseNonEmptyString = (s: string, trim = true): NonEmptyString => - assertExists(parseMaybeNonEmptyString(s, trim), "Expected non-empty string"); +export const parseNonEmptyString = ( + s: string, + trim = true, + e?: string, +): NonEmptyString => + assertExists( + parseMaybeNonEmptyString(s, trim), + "Expected non-empty string" + (e ? `: ${e}` : ""), + ); export const assertExists = ( value: T | null | undefined, @@ -173,4 +176,8 @@ export const assertExistsAndNonEmptyString = ( value: string | null | undefined, err?: string, ): NonEmptyString => - parseNonEmptyString(assertExists(value, err || "Expected non-empty string")); + parseNonEmptyString( + assertExists(value, err || "Expected non-empty string"), + true, + err, + ); diff --git a/www/app/providers.tsx b/www/app/providers.tsx index 020112ac..37b37a0e 100644 --- a/www/app/providers.tsx +++ b/www/app/providers.tsx @@ -10,6 +10,7 @@ import { QueryClientProvider } from "@tanstack/react-query"; import { queryClient } from "./lib/queryClient"; import { AuthProvider } from "./lib/AuthProvider"; import { SessionProvider as SessionProviderNextAuth } from "next-auth/react"; +import { RecordingConsentProvider } from "./recordingConsentContext"; const WherebyProvider = dynamic( () => @@ -26,10 +27,12 @@ export function Providers({ children }: { children: React.ReactNode }) { - - {children} - - + + + {children} + + + diff --git a/www/package.json b/www/package.json index c93a9554..5169dbe2 100644 --- a/www/package.json +++ b/www/package.json @@ -5,6 +5,7 @@ "scripts": { "dev": "next dev", "build": "next build", + "build-production": "next build --experimental-build-mode compile", "start": "next start", "lint": "next lint", "format": "prettier --write .",