From 5bf64b5a41f64535e22849b4bb11734d4dbb4aae Mon Sep 17 00:00:00 2001
From: Igor Monadical
Date: Wed, 24 Sep 2025 11:15:27 -0400
Subject: [PATCH] feat: docker-compose for production frontend (#664)
* docker-compose for production frontend
* fix: Remove external Redis port mapping for Coolify compatibility
Redis should only be accessible within the internal Docker network in Coolify deployments to avoid port conflicts with other applications.
* fix: Remove external port mapping for web service in Coolify
Coolify handles port exposure through its proxy (Traefik), so services should not expose ports directly in the docker-compose file.
* server side client envs
* missing vars
* nextjs experimental
* fix claude 'fix'
* remove build env vars compose
* docker
* remove ports for coolify
* review
* cleanup
---------
Co-authored-by: Igor Loskutov
---
CLAUDE.md | 2 +-
README.md | 25 ++++--
docker-compose.prod.yml | 39 ++++++++
compose.yml => docker-compose.yml | 4 +-
server/reflector/app.py | 6 ++
www/.dockerignore | 14 +++
www/.env.example | 22 ++---
www/DOCKER_README.md | 81 +++++++++++++++++
www/Dockerfile | 9 +-
.../(app)/rooms/_components/ICSSettings.tsx | 8 +-
www/app/(app)/rooms/_components/RoomTable.tsx | 30 ++++++-
www/app/(app)/rooms/page.tsx | 8 +-
www/app/[roomName]/room.tsx | 15 +++-
www/app/api/health/route.ts | 38 ++++++++
www/app/layout.tsx | 32 +++----
www/app/lib/apiClient.tsx | 16 ++--
www/app/lib/authBackend.ts | 34 +++----
www/app/lib/clientEnv.ts | 88 +++++++++++++++++++
www/app/lib/features.ts | 19 ++--
www/app/lib/nextBuild.ts | 17 ++++
www/app/lib/utils.ts | 21 +++--
www/app/providers.tsx | 11 ++-
www/package.json | 1 +
23 files changed, 448 insertions(+), 92 deletions(-)
create mode 100644 docker-compose.prod.yml
rename compose.yml => docker-compose.yml (94%)
create mode 100644 www/.dockerignore
create mode 100644 www/DOCKER_README.md
create mode 100644 www/app/api/health/route.ts
create mode 100644 www/app/lib/clientEnv.ts
create mode 100644 www/app/lib/nextBuild.ts
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 .",