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 <igor.loskutoff@gmail.com>
This commit is contained in:
Igor Monadical
2025-09-24 11:15:27 -04:00
committed by GitHub
parent 0aaa42528a
commit 5bf64b5a41
23 changed files with 448 additions and 92 deletions

View File

@@ -151,7 +151,7 @@ All endpoints prefixed `/v1/`:
**Frontend** (`www/.env`): **Frontend** (`www/.env`):
- `NEXTAUTH_URL`, `NEXTAUTH_SECRET` - Authentication configuration - `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 - `REFLECTOR_DOMAIN_CONFIG` - Feature flags and domain settings
## Testing Strategy ## Testing Strategy

View File

@@ -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 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 ## Feature Flags
@@ -177,24 +184,24 @@ Reflector uses environment variable-based feature flags to control application f
| Feature Flag | Environment Variable | | Feature Flag | Environment Variable |
|-------------|---------------------| |-------------|---------------------|
| `requireLogin` | `NEXT_PUBLIC_FEATURE_REQUIRE_LOGIN` | | `requireLogin` | `FEATURE_REQUIRE_LOGIN` |
| `privacy` | `NEXT_PUBLIC_FEATURE_PRIVACY` | | `privacy` | `FEATURE_PRIVACY` |
| `browse` | `NEXT_PUBLIC_FEATURE_BROWSE` | | `browse` | `FEATURE_BROWSE` |
| `sendToZulip` | `NEXT_PUBLIC_FEATURE_SEND_TO_ZULIP` | | `sendToZulip` | `FEATURE_SEND_TO_ZULIP` |
| `rooms` | `NEXT_PUBLIC_FEATURE_ROOMS` | | `rooms` | `FEATURE_ROOMS` |
### Setting Feature Flags ### 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:** **Examples:**
```bash ```bash
# Enable user authentication requirement # Enable user authentication requirement
NEXT_PUBLIC_FEATURE_REQUIRE_LOGIN=true FEATURE_REQUIRE_LOGIN=true
# Disable browse functionality # Disable browse functionality
NEXT_PUBLIC_FEATURE_BROWSE=false FEATURE_BROWSE=false
# Enable Zulip integration # Enable Zulip integration
NEXT_PUBLIC_FEATURE_SEND_TO_ZULIP=true FEATURE_SEND_TO_ZULIP=true
``` ```

39
docker-compose.prod.yml Normal file
View File

@@ -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:

View File

@@ -39,7 +39,7 @@ services:
ports: ports:
- 6379:6379 - 6379:6379
web: web:
image: node:18 image: node:22-alpine
ports: ports:
- "3000:3000" - "3000:3000"
command: sh -c "corepack enable && pnpm install && pnpm dev" command: sh -c "corepack enable && pnpm install && pnpm dev"
@@ -50,6 +50,8 @@ services:
- /app/node_modules - /app/node_modules
env_file: env_file:
- ./www/.env.local - ./www/.env.local
environment:
- NODE_ENV=development
postgres: postgres:
image: postgres:17 image: postgres:17

View File

@@ -65,6 +65,12 @@ app.add_middleware(
allow_headers=["*"], allow_headers=["*"],
) )
@app.get("/health")
async def health():
return {"status": "healthy"}
# metrics # metrics
instrumentator = Instrumentator( instrumentator = Instrumentator(
excluded_handlers=["/docs", "/metrics"], excluded_handlers=["/docs", "/metrics"],

14
www/.dockerignore Normal file
View File

@@ -0,0 +1,14 @@
.env
.env.*
.env.local
.env.development
.env.production
node_modules
.next
.git
.gitignore
*.md
.DS_Store
coverage
.pnpm-store
*.log

View File

@@ -1,9 +1,5 @@
# Environment
ENVIRONMENT=development
NEXT_PUBLIC_ENV=development
# Site Configuration # Site Configuration
NEXT_PUBLIC_SITE_URL=http://localhost:3000 SITE_URL=http://localhost:3000
# Nextauth envs # Nextauth envs
# not used in app code but in lib code # 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 AUTHENTIK_CLIENT_SECRET=your-client-secret-here
# Feature Flags # Feature Flags
# NEXT_PUBLIC_FEATURE_REQUIRE_LOGIN=true # FEATURE_REQUIRE_LOGIN=true
# NEXT_PUBLIC_FEATURE_PRIVACY=false # FEATURE_PRIVACY=false
# NEXT_PUBLIC_FEATURE_BROWSE=true # FEATURE_BROWSE=true
# NEXT_PUBLIC_FEATURE_SEND_TO_ZULIP=true # FEATURE_SEND_TO_ZULIP=true
# NEXT_PUBLIC_FEATURE_ROOMS=true # FEATURE_ROOMS=true
# API URLs # API URLs
NEXT_PUBLIC_API_URL=http://127.0.0.1:1250 API_URL=http://127.0.0.1:1250
NEXT_PUBLIC_WEBSOCKET_URL=ws://127.0.0.1:1250 WEBSOCKET_URL=ws://127.0.0.1:1250
NEXT_PUBLIC_AUTH_CALLBACK_URL=http://localhost:3000/auth-callback AUTH_CALLBACK_URL=http://localhost:3000/auth-callback
# Sentry # Sentry
# SENTRY_DSN=https://your-dsn@sentry.io/project-id # SENTRY_DSN=https://your-dsn@sentry.io/project-id

81
www/DOCKER_README.md Normal file
View File

@@ -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
```

View File

@@ -24,7 +24,8 @@ COPY --link . .
ENV NEXT_TELEMETRY_DISABLED 1 ENV NEXT_TELEMETRY_DISABLED 1
# If using npm comment out above and use below instead # 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 # RUN npm run build
# Production image, copy all the files and run next # Production image, copy all the files and run next
@@ -51,6 +52,10 @@ USER nextjs
EXPOSE 3000 EXPOSE 3000
ENV PORT 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"] CMD ["node", "server.js"]

View File

@@ -200,7 +200,13 @@ export default function ICSSettings({
<HStack gap={0} position="relative" width="100%"> <HStack gap={0} position="relative" width="100%">
<Input <Input
ref={roomUrlInputRef} ref={roomUrlInputRef}
value={roomAbsoluteUrl(parseNonEmptyString(roomName))} value={roomAbsoluteUrl(
parseNonEmptyString(
roomName,
true,
"panic! roomName is required",
),
)}
readOnly readOnly
onClick={handleRoomUrlClick} onClick={handleRoomUrlClick}
cursor="pointer" cursor="pointer"

View File

@@ -274,15 +274,31 @@ export function RoomTable({
<IconButton <IconButton
aria-label="Force sync calendar" aria-label="Force sync calendar"
onClick={() => onClick={() =>
handleForceSync(parseNonEmptyString(room.name)) handleForceSync(
parseNonEmptyString(
room.name,
true,
"panic! room.name is required",
),
)
} }
size="sm" size="sm"
variant="ghost" variant="ghost"
disabled={syncingRooms.has( 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",
),
) ? (
<Spinner size="sm" /> <Spinner size="sm" />
) : ( ) : (
<CalendarSyncIcon /> <CalendarSyncIcon />
@@ -297,7 +313,13 @@ export function RoomTable({
<IconButton <IconButton
aria-label="Copy URL" aria-label="Copy URL"
onClick={() => onClick={() =>
onCopyUrl(parseNonEmptyString(room.name)) onCopyUrl(
parseNonEmptyString(
room.name,
true,
"panic! room.name is required",
),
)
} }
size="sm" size="sm"
variant="ghost" variant="ghost"

View File

@@ -833,7 +833,13 @@ export default function RoomsList() {
<Field.Root> <Field.Root>
<ICSSettings <ICSSettings
roomName={ roomName={
room.name ? parseNonEmptyString(room.name) : null room.name
? parseNonEmptyString(
room.name,
true,
"panic! room.name required",
)
: null
} }
icsUrl={room.icsUrl} icsUrl={room.icsUrl}
icsEnabled={room.icsEnabled} icsEnabled={room.icsEnabled}

View File

@@ -261,7 +261,11 @@ export default function Room(details: RoomDetails) {
const params = use(details.params); const params = use(details.params);
const wherebyLoaded = useWhereby(); const wherebyLoaded = useWhereby();
const wherebyRef = useRef<HTMLElement>(null); const wherebyRef = useRef<HTMLElement>(null);
const roomName = parseNonEmptyString(params.roomName); const roomName = parseNonEmptyString(
params.roomName,
true,
"panic! params.roomName is required",
);
const router = useRouter(); const router = useRouter();
const auth = useAuth(); const auth = useAuth();
const status = auth.status; const status = auth.status;
@@ -308,7 +312,14 @@ export default function Room(details: RoomDetails) {
const handleMeetingSelect = (selectedMeeting: Meeting) => { const handleMeetingSelect = (selectedMeeting: Meeting) => {
router.push( router.push(
roomMeetingUrl(roomName, parseNonEmptyString(selectedMeeting.id)), roomMeetingUrl(
roomName,
parseNonEmptyString(
selectedMeeting.id,
true,
"panic! selectedMeeting.id is required",
),
),
); );
}; };

View File

@@ -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<boolean> {
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;
}
}

View File

@@ -6,7 +6,10 @@ import ErrorMessage from "./(errors)/errorMessage";
import { RecordingConsentProvider } from "./recordingConsentContext"; import { RecordingConsentProvider } from "./recordingConsentContext";
import { ErrorBoundary } from "@sentry/nextjs"; import { ErrorBoundary } from "@sentry/nextjs";
import { Providers } from "./providers"; 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({ const poppins = Poppins({
subsets: ["latin"], subsets: ["latin"],
@@ -21,13 +24,11 @@ export const viewport: Viewport = {
maximumScale: 1, maximumScale: 1,
}; };
const NEXT_PUBLIC_SITE_URL = assertExistsAndNonEmptyString( const SITE_URL = getNextEnvVar("SITE_URL");
process.env.NEXT_PUBLIC_SITE_URL, const env = getClientEnv();
"NEXT_PUBLIC_SITE_URL required",
);
export const metadata: Metadata = { export const metadata: Metadata = {
metadataBase: new URL(NEXT_PUBLIC_SITE_URL), metadataBase: new URL(SITE_URL),
title: { title: {
template: "%s Reflector", template: "%s Reflector",
default: "Reflector - AI-Powered Meeting Transcriptions by Monadical", default: "Reflector - AI-Powered Meeting Transcriptions by Monadical",
@@ -74,15 +75,16 @@ export default async function RootLayout({
}) { }) {
return ( return (
<html lang="en" className={poppins.className} suppressHydrationWarning> <html lang="en" className={poppins.className} suppressHydrationWarning>
<body className={"h-[100svh] w-[100svw] overflow-x-hidden relative"}> <body
<RecordingConsentProvider> className={"h-[100svh] w-[100svw] overflow-x-hidden relative"}
data-env={JSON.stringify(env)}
>
<ErrorBoundary fallback={<p>"something went really wrong"</p>}> <ErrorBoundary fallback={<p>"something went really wrong"</p>}>
<ErrorProvider> <ErrorProvider>
<ErrorMessage /> <ErrorMessage />
<Providers>{children}</Providers> <Providers>{children}</Providers>
</ErrorProvider> </ErrorProvider>
</ErrorBoundary> </ErrorBoundary>
</RecordingConsentProvider>
</body> </body>
</html> </html>
); );

View File

@@ -3,21 +3,19 @@
import createClient from "openapi-fetch"; import createClient from "openapi-fetch";
import type { paths } from "../reflector-api"; import type { paths } from "../reflector-api";
import createFetchClient from "openapi-react-query"; import createFetchClient from "openapi-react-query";
import { assertExistsAndNonEmptyString, parseNonEmptyString } from "./utils"; import { parseNonEmptyString } from "./utils";
import { isBuildPhase } from "./next"; import { isBuildPhase } from "./next";
import { getSession } from "next-auth/react"; import { getSession } from "next-auth/react";
import { assertExtendedToken } from "./types"; import { assertExtendedToken } from "./types";
import { getClientEnv } from "./clientEnv";
export const API_URL = !isBuildPhase export const API_URL = !isBuildPhase
? assertExistsAndNonEmptyString( ? getClientEnv().API_URL
process.env.NEXT_PUBLIC_API_URL,
"NEXT_PUBLIC_API_URL required",
)
: "http://localhost"; : "http://localhost";
// TODO decide strict validation or not export const WEBSOCKET_URL = !isBuildPhase
export const WEBSOCKET_URL = ? getClientEnv().WEBSOCKET_URL || "ws://127.0.0.1:1250"
process.env.NEXT_PUBLIC_WEBSOCKET_URL || "ws://127.0.0.1:1250"; : "ws://localhost";
export const client = createClient<paths>({ export const client = createClient<paths>({
baseUrl: API_URL, baseUrl: API_URL,
@@ -44,7 +42,7 @@ client.use({
if (token !== null) { if (token !== null) {
request.headers.set( request.headers.set(
"Authorization", "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) // XXX Only set Content-Type if not already set (FormData will set its own boundary)

View File

@@ -18,26 +18,25 @@ import {
deleteTokenCache, deleteTokenCache,
} from "./redisTokenCache"; } from "./redisTokenCache";
import { tokenCacheRedis, redlock } from "./redisClient"; import { tokenCacheRedis, redlock } from "./redisClient";
import { isBuildPhase } from "./next";
import { sequenceThrows } from "./errorUtils"; import { sequenceThrows } from "./errorUtils";
import { featureEnabled } from "./features"; import { featureEnabled } from "./features";
import { getNextEnvVar } from "./nextBuild";
const TOKEN_CACHE_TTL = REFRESH_ACCESS_TOKEN_BEFORE; const TOKEN_CACHE_TTL = REFRESH_ACCESS_TOKEN_BEFORE;
const getAuthentikClientId = () => const getAuthentikClientId = () => getNextEnvVar("AUTHENTIK_CLIENT_ID");
assertExistsAndNonEmptyString( const getAuthentikClientSecret = () => getNextEnvVar("AUTHENTIK_CLIENT_SECRET");
process.env.AUTHENTIK_CLIENT_ID,
"AUTHENTIK_CLIENT_ID required",
);
const getAuthentikClientSecret = () =>
assertExistsAndNonEmptyString(
process.env.AUTHENTIK_CLIENT_SECRET,
"AUTHENTIK_CLIENT_SECRET required",
);
const getAuthentikRefreshTokenUrl = () => const getAuthentikRefreshTokenUrl = () =>
assertExistsAndNonEmptyString( getNextEnvVar("AUTHENTIK_REFRESH_TOKEN_URL");
process.env.AUTHENTIK_REFRESH_TOKEN_URL,
"AUTHENTIK_REFRESH_TOKEN_URL required", 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 => export const authOptions = (): AuthOptions =>
featureEnabled("requireLogin") featureEnabled("requireLogin")
@@ -45,16 +44,17 @@ export const authOptions = (): AuthOptions =>
providers: [ providers: [
AuthentikProvider({ AuthentikProvider({
...(() => { ...(() => {
const [clientId, clientSecret] = sequenceThrows( const [clientId, clientSecret, issuer] = sequenceThrows(
getAuthentikClientId, getAuthentikClientId,
getAuthentikClientSecret, getAuthentikClientSecret,
getAuthentikIssuer,
); );
return { return {
clientId, clientId,
clientSecret, clientSecret,
issuer,
}; };
})(), })(),
issuer: process.env.AUTHENTIK_ISSUER,
authorization: { authorization: {
params: { params: {
scope: "openid email profile offline_access", scope: "openid email profile offline_access",

88
www/app/lib/clientEnv.ts Normal file
View File

@@ -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;

View File

@@ -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 = [ export const FEATURES = [
"requireLogin", "requireLogin",
"privacy", "privacy",
@@ -26,26 +34,25 @@ function parseBooleanEnv(
return value.toLowerCase() === "true"; return value.toLowerCase() === "true";
} }
// WARNING: keep process.env.* as-is, next.js won't see them if you generate dynamically
const features: Features = { const features: Features = {
requireLogin: parseBooleanEnv( requireLogin: parseBooleanEnv(
process.env.NEXT_PUBLIC_FEATURE_REQUIRE_LOGIN, process.env[FEATURE_REQUIRE_LOGIN_ENV_NAME],
DEFAULT_FEATURES.requireLogin, DEFAULT_FEATURES.requireLogin,
), ),
privacy: parseBooleanEnv( privacy: parseBooleanEnv(
process.env.NEXT_PUBLIC_FEATURE_PRIVACY, process.env[FEATURE_PRIVACY_ENV_NAME],
DEFAULT_FEATURES.privacy, DEFAULT_FEATURES.privacy,
), ),
browse: parseBooleanEnv( browse: parseBooleanEnv(
process.env.NEXT_PUBLIC_FEATURE_BROWSE, process.env[FEATURE_BROWSE_ENV_NAME],
DEFAULT_FEATURES.browse, DEFAULT_FEATURES.browse,
), ),
sendToZulip: parseBooleanEnv( sendToZulip: parseBooleanEnv(
process.env.NEXT_PUBLIC_FEATURE_SEND_TO_ZULIP, process.env[FEATURE_SEND_TO_ZULIP_ENV_NAME],
DEFAULT_FEATURES.sendToZulip, DEFAULT_FEATURES.sendToZulip,
), ),
rooms: parseBooleanEnv( rooms: parseBooleanEnv(
process.env.NEXT_PUBLIC_FEATURE_ROOMS, process.env[FEATURE_ROOMS_ENV_NAME],
DEFAULT_FEATURES.rooms, DEFAULT_FEATURES.rooms,
), ),
}; };

17
www/app/lib/nextBuild.ts Normal file
View File

@@ -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);

View File

@@ -1,7 +1,3 @@
export function isDevelopment() {
return process.env.NEXT_PUBLIC_ENV === "development";
}
// Function to calculate WCAG contrast ratio // Function to calculate WCAG contrast ratio
export const getContrastRatio = ( export const getContrastRatio = (
foreground: [number, number, number], foreground: [number, number, number],
@@ -145,8 +141,15 @@ export const parseMaybeNonEmptyString = (
s = trim ? s.trim() : s; s = trim ? s.trim() : s;
return s.length > 0 ? (s as NonEmptyString) : null; return s.length > 0 ? (s as NonEmptyString) : null;
}; };
export const parseNonEmptyString = (s: string, trim = true): NonEmptyString => export const parseNonEmptyString = (
assertExists(parseMaybeNonEmptyString(s, trim), "Expected non-empty string"); s: string,
trim = true,
e?: string,
): NonEmptyString =>
assertExists(
parseMaybeNonEmptyString(s, trim),
"Expected non-empty string" + (e ? `: ${e}` : ""),
);
export const assertExists = <T>( export const assertExists = <T>(
value: T | null | undefined, value: T | null | undefined,
@@ -173,4 +176,8 @@ export const assertExistsAndNonEmptyString = (
value: string | null | undefined, value: string | null | undefined,
err?: string, err?: string,
): NonEmptyString => ): NonEmptyString =>
parseNonEmptyString(assertExists(value, err || "Expected non-empty string")); parseNonEmptyString(
assertExists(value, err || "Expected non-empty string"),
true,
err,
);

View File

@@ -10,6 +10,7 @@ import { QueryClientProvider } from "@tanstack/react-query";
import { queryClient } from "./lib/queryClient"; import { queryClient } from "./lib/queryClient";
import { AuthProvider } from "./lib/AuthProvider"; import { AuthProvider } from "./lib/AuthProvider";
import { SessionProvider as SessionProviderNextAuth } from "next-auth/react"; import { SessionProvider as SessionProviderNextAuth } from "next-auth/react";
import { RecordingConsentProvider } from "./recordingConsentContext";
const WherebyProvider = dynamic( const WherebyProvider = dynamic(
() => () =>
@@ -26,10 +27,12 @@ export function Providers({ children }: { children: React.ReactNode }) {
<SessionProviderNextAuth> <SessionProviderNextAuth>
<AuthProvider> <AuthProvider>
<ChakraProvider value={system}> <ChakraProvider value={system}>
<RecordingConsentProvider>
<WherebyProvider> <WherebyProvider>
{children} {children}
<Toaster /> <Toaster />
</WherebyProvider> </WherebyProvider>
</RecordingConsentProvider>
</ChakraProvider> </ChakraProvider>
</AuthProvider> </AuthProvider>
</SessionProviderNextAuth> </SessionProviderNextAuth>

View File

@@ -5,6 +5,7 @@
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
"build": "next build", "build": "next build",
"build-production": "next build --experimental-build-mode compile",
"start": "next start", "start": "next start",
"lint": "next lint", "lint": "next lint",
"format": "prettier --write .", "format": "prettier --write .",