diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 44f49bf47d..7b650d08e3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -110,13 +110,16 @@ jobs: - name: Build Next.js frontend run: yarn workspace main build env: + # Build env-agnostically, exactly like the standalone Docker image. + # NEXT_BUILD_CI=1 skips validateEnv() because NEXT_PUBLIC_* values are + # injected at runtime (via PublicEnvScript), not baked in at build. + # No NEXT_PUBLIC_* vars are set here on purpose: with them unset, any + # code that reads one at module scope (e.g. a module-level + # requiredEnv(), which throws on the undefined value) fails the build. + # This gates such build-time env dependencies in CI rather than letting + # them surface only at container startup. NODE_ENV: production - NEXT_PUBLIC_ORIGIN: https://cifake.learn.mit.edu - NEXT_PUBLIC_MITOL_API_BASE_URL: https://api.cifake.learn.mit.edu - NEXT_PUBLIC_CSRF_COOKIE_NAME: cookie-monster - NEXT_PUBLIC_SITE_NAME: MIT Learn - NEXT_PUBLIC_MITOL_SUPPORT_EMAIL: help@citest.learn.mit.edu - NEXT_PUBLIC_MITX_ONLINE_LEGACY_BASE_URL: https://cifake.mitxonline.mit.edu + NEXT_BUILD_CI: "1" # do this before typecheck. See https://github.com/vercel/next.js/issues/53959#issuecomment-1735563224 - name: Typecheck @@ -146,48 +149,11 @@ jobs: - name: Build the Docker image env: - ORIGIN: http://fakelearn.odl.local:8062 - MITOL_API_BASE_URL: http://api.fakelearn.odl.local:8065 - MITX_ONLINE_LEGACY_BASE_URL: https://cifake.mitxonline.mit.edu - SITE_NAME: MIT Learn - SUPPORT_EMAIL: mitlearn-support@mit.edu - MITOL_AXIOS_WITH_CREDENTIALS: true - CSRF_COOKIE_NAME: learn_csrftoken_ci - POSTHOG_API_HOST: https://app.posthog.com - POSTHOG_UI_HOST: https://us.posthog.com - POSTHOG_PROJECT_ID: fake-posthog-project-id - POSTHOG_API_KEY: fake-posthog-api-key - SENTRY_DSN: fake-sentry-dsn - SENTRY_ENV: fake-sentry-env - SENTRY_PROFILES_SAMPLE_RATE: 0.1 - SENTRY_TRACES_SAMPLE_RATE: 0.1 - LEARN_AI_RECOMMENDATION_ENDPOINT: http://api.fakelearn.odl.local:8065/ai/http/recommendation_agent - LEARN_AI_SYLLABUS_ENDPOINT: http://api.fakelearn.odl.local:8065/ai/http/syllabus_agent - HUBSPOT_PORTAL_ID: fake-hubspot-portal-id VERSION: ${{ github.sha }} run: | docker build \ -f frontends/main/Dockerfile.web \ - --build-arg NEXT_PUBLIC_ORIGIN=$ORIGIN \ - --build-arg NEXT_PUBLIC_MITOL_API_BASE_URL=$MITOL_API_BASE_URL \ - --build-arg NEXT_PUBLIC_MITX_ONLINE_LEGACY_BASE_URL=$MITX_ONLINE_LEGACY_BASE_URL \ - --build-arg NEXT_PUBLIC_SITE_NAME="$SITE_NAME" \ - --build-arg NEXT_PUBLIC_MITOL_SUPPORT_EMAIL=$SUPPORT_EMAIL \ - --build-arg NEXT_PUBLIC_MITOL_AXIOS_WITH_CREDENTIALS=$MITOL_AXIOS_WITH_CREDENTIALS \ - --build-arg NEXT_PUBLIC_CSRF_COOKIE_NAME=$CSRF_COOKIE_NAME \ - --build-arg NEXT_PUBLIC_POSTHOG_API_HOST=$POSTHOG_API_HOST \ - --build-arg NEXT_PUBLIC_POSTHOG_UI_HOST=$POSTHOG_UI_HOST \ - --build-arg NEXT_PUBLIC_POSTHOG_PROJECT_ID=$POSTHOG_PROJECT_ID \ - --build-arg NEXT_PUBLIC_POSTHOG_API_KEY=$POSTHOG_API_KEY \ - --build-arg NEXT_PUBLIC_SENTRY_DSN=$SENTRY_DSN \ - --build-arg NEXT_PUBLIC_SENTRY_ENV=$SENTRY_ENV \ - --build-arg NEXT_PUBLIC_SENTRY_PROFILES_SAMPLE_RATE=$SENTRY_PROFILES_SAMPLE_RATE \ - --build-arg NEXT_PUBLIC_SENTRY_TRACES_SAMPLE_RATE=$SENTRY_TRACES_SAMPLE_RATE \ - --build-arg NEXT_PUBLIC_APPZI_URL=$APPZI_URL \ - --build-arg NEXT_PUBLIC_LEARN_AI_RECOMMENDATION_ENDPOINT=$LEARN_AI_RECOMMENDATION_ENDPOINT \ - --build-arg NEXT_PUBLIC_LEARN_AI_SYLLABUS_ENDPOINT=$LEARN_AI_SYLLABUS_ENDPOINT \ - --build-arg NEXT_PUBLIC_HUBSPOT_PORTAL_ID=$HUBSPOT_PORTAL_ID \ - --build-arg NEXT_PUBLIC_VERSION=$VERSION \ + --build-arg GIT_REF=$VERSION \ -t mitodl/mit-learn-frontend:$VERSION . build-storybook: diff --git a/docker-compose.apps.yml b/docker-compose.apps.yml index 338de58f24..f6211ca1b5 100644 --- a/docker-compose.apps.yml +++ b/docker-compose.apps.yml @@ -49,8 +49,6 @@ services: - "8062:8062" - "6006:6006" - "9229:9229" - environment: - - NEXT_SERVER_MITOL_API_BASE_URL=http://nginx:8063/ volumes: - .:/src links: diff --git a/env/codespaces.env b/env/codespaces.env index d90e0d0912..46e255ec17 100644 --- a/env/codespaces.env +++ b/env/codespaces.env @@ -54,4 +54,3 @@ NEXT_PUBLIC_MITOL_SUPPORT_EMAIL=${MITOL_SUPPORT_EMAIL} NEXT_PUBLIC_STAY_UPDATED_HUBSPOT_FORM_ID=${STAY_UPDATED_HUBSPOT_FORM_ID} NEXT_PUBLIC_SITE_NAME="MIT Learn" NEXT_PUBLIC_MITOL_AXIOS_WITH_CREDENTIALS=true -NEXT_SERVER_MITOL_API_BASE_URL= diff --git a/env/frontend.env b/env/frontend.env index 9b7765130c..30f75a262c 100644 --- a/env/frontend.env +++ b/env/frontend.env @@ -1,6 +1,5 @@ NODE_ENV=development PORT=8062 -NEXT_PUBLIC_OPTIMIZE_IMAGES="false" # Environment variables with `NEXT_PUBLIC_` prefix are exposed to the client side NEXT_PUBLIC_ORIGIN=${MITOL_APP_BASE_URL} @@ -30,6 +29,7 @@ NEXT_PUBLIC_LEARN_AI_RECOMMENDATION_ENDPOINT=https://api.rc.learn.mit.edu/ai/htt NEXT_PUBLIC_LEARN_AI_SYLLABUS_ENDPOINT=https://api.rc.learn.mit.edu/ai/http/syllabus_agent/ NEXT_PUBLIC_MITX_ONLINE_BASE_URL=${MITX_ONLINE_BASE_URL} +NEXT_PUBLIC_MITX_ONLINE_LEGACY_BASE_URL=http://mitxonline.odl.local:8065/ NEXT_PUBLIC_VERSION="local-dev" # Hubspot tracking - xprodev account for dev/RC environments diff --git a/frontends/.eslintrc.js b/frontends/.eslintrc.js index 5e4b7e259f..3fa9a57c30 100644 --- a/frontends/.eslintrc.js +++ b/frontends/.eslintrc.js @@ -135,33 +135,7 @@ module.exports = { }, ], quotes: ["error", "double", { avoidEscape: true }], - "no-restricted-syntax": [ - "error", - /** - * See https://eslint.org/docs/latest/rules/no-restricted-syntax - * - * The selectors use "ES Query", a css-like syntax for AST querying. A - * useful tool is https://estools.github.io/esquery/ - */ - { - selector: - "Property[key.name=fontWeight][value.raw=/\\d+/], TemplateElement[value.raw=/font-weight: \\d+/]", - message: - "Do not specify `fontWeight` manually. Prefer spreading `theme.typography.subtitle1` or similar. If you MUST use a fontWeight, refer to `fontWeights` theme object.", - }, - { - selector: - "Property[key.name=fontFamily][value.raw=/Neue Haas/], TemplateElement[value.raw=/Neue Haas/]", - message: - "Do not specify `fontFamily` manually. Prefer spreading `theme.typography.subtitle1` or similar. If using neue-haas-grotesk-text, this is ThemeProvider's default fontFamily.", - }, - { - selector: - "FunctionDeclaration[id.name='generateMetadata'] > BlockStatement > ReturnStatement[argument.type!='CallExpression'], FunctionDeclaration[id.name='generateMetadata'] > BlockStatement > ReturnStatement[argument.callee.name!='safeGenerateMetadata']", - message: - "generateMetadata functions must return safeGenerateMetadata() to ensure proper error handling and fallback metadata.", - }, - ], + ...restrictedSyntax(), }, overrides: [ { @@ -170,6 +144,22 @@ module.exports = { ...restrictedImports(), }, }, + { + // Tests/setup legitimately set & read process.env.NEXT_PUBLIC_* directly + // (jsdom). Lift only the NEXT_PUBLIC_* ban for these; keep other selectors. + // next.config.js is intentionally NOT exempt: NEXT_PUBLIC_* are absent at + // build time, so reads there are bugs except for a couple of justified + // cases (NEXT_PUBLIC_VERSION, dev-only NEXT_PUBLIC_ORIGIN) allowed inline. + files: [ + "./**/*.test.{ts,tsx}", + "./**/test-utils/**", + "./**/setupJest.{ts,tsx}", + "./**/jest-shared-setup.ts", + ], + rules: { + ...restrictedSyntax({ allowPublicEnv: true }), + }, + }, { files: ["./**/*.test.{ts,tsx}"], plugins: ["testing-library"], @@ -231,3 +221,48 @@ function restrictedImports({ paths = [], patterns = [] } = {}) { ], } } + +function restrictedSyntax({ allowPublicEnv = false } = {}) { + /** + * Shared no-restricted-syntax config. Factored into a helper (like + * restrictedImports above) so the NEXT_PUBLIC_* process.env ban can be lifted + * for tests/setup (which read process.env directly under jsdom) via an + * override without losing the other selectors. + * + * The selectors use "ES Query", a css-like syntax for AST querying. A useful + * tool is https://estools.github.io/esquery/ + * See https://eslint.org/docs/latest/rules/no-restricted-syntax + */ + const selectors = [ + { + selector: + "Property[key.name=fontWeight][value.raw=/\\d+/], TemplateElement[value.raw=/font-weight: \\d+/]", + message: + "Do not specify `fontWeight` manually. Prefer spreading `theme.typography.subtitle1` or similar. If you MUST use a fontWeight, refer to `fontWeights` theme object.", + }, + { + selector: + "Property[key.name=fontFamily][value.raw=/Neue Haas/], TemplateElement[value.raw=/Neue Haas/]", + message: + "Do not specify `fontFamily` manually. Prefer spreading `theme.typography.subtitle1` or similar. If using neue-haas-grotesk-text, this is ThemeProvider's default fontFamily.", + }, + { + selector: + "FunctionDeclaration[id.name='generateMetadata'] > BlockStatement > ReturnStatement[argument.type!='CallExpression'], FunctionDeclaration[id.name='generateMetadata'] > BlockStatement > ReturnStatement[argument.callee.name!='safeGenerateMetadata']", + message: + "generateMetadata functions must return safeGenerateMetadata() to ensure proper error handling and fallback metadata.", + }, + ] + if (!allowPublicEnv) { + selectors.push({ + // Matches `process.env.NEXT_PUBLIC_*` (dot) and + // `process.env["NEXT_PUBLIC_*"]` (string-literal bracket). Does NOT match + // `process.env[key]` with a variable key (e.g. the env() helper itself). + selector: + "MemberExpression[object.object.name='process'][object.property.name='env'][property.name=/^NEXT_PUBLIC_/], MemberExpression[object.object.name='process'][object.property.name='env'][property.value=/^NEXT_PUBLIC_/]", + message: + "Do not read NEXT_PUBLIC_* from process.env directly: values are inlined at build time and are empty in the standalone Docker image. Use env() or requiredEnv() from @/env instead.", + }) + } + return { "no-restricted-syntax": ["error", ...selectors] } +} diff --git a/frontends/api/src/test-utils/setupJest.ts b/frontends/api/src/test-utils/setupJest.ts index aa6698047c..26b0d1536d 100644 --- a/frontends/api/src/test-utils/setupJest.ts +++ b/frontends/api/src/test-utils/setupJest.ts @@ -11,18 +11,18 @@ beforeEach(() => { assertMockAdapterInstalled() }) +// Hardcoded test base URLs are the single source of truth for this workspace: +// the test URL builders (test-utils/urls.ts) read them back from the configured +// axios instance, so requests and their expected URLs stay in sync. No env vars +// are consulted — this workspace has no runtime env injection. configureApiClients({ learn: { - baseUrl: - process.env.NEXT_PUBLIC_MITOL_API_BASE_URL ?? - "http://api.test.learn.odl.local:8065", + baseUrl: "http://api.test.learn.odl.local:8065", csrfCookieName: "csrftoken", withCredentials: false, }, mitxonline: { - baseUrl: - process.env.NEXT_PUBLIC_MITX_ONLINE_BASE_URL ?? - "http://api.test.learn.odl.local:8065/mitxonline", + baseUrl: "http://api.test.learn.odl.local:8065/mitxonline", csrfCookieName: "mitxcsrftoken", withCredentials: false, }, diff --git a/frontends/jest-shared-setup.ts b/frontends/jest-shared-setup.ts index 7131aa98c4..ba3d96a412 100644 --- a/frontends/jest-shared-setup.ts +++ b/frontends/jest-shared-setup.ts @@ -18,15 +18,11 @@ expect.extend(matchers) setDefaultTimezone("UTC") -// env vars -process.env.NEXT_PUBLIC_MITOL_API_BASE_URL = - "http://api.test.learn.odl.local:8065" -process.env.NEXT_PUBLIC_MITX_ONLINE_BASE_URL = - "http://api.test.learn.odl.local:8065/mitxonline" -process.env.NEXT_PUBLIC_MITX_ONLINE_LEGACY_BASE_URL = - "http://mitxonline.odl.local:8065" -process.env.NEXT_PUBLIC_ORIGIN = "http://test.learn.odl.local:8062" -process.env.NEXT_PUBLIC_VERSION = "test-version" +// NEXT_PUBLIC_* test values are NOT set here. Only the `main` workspace's app +// code reads them (via the env() helper, which falls back to process.env under +// jsdom), so they live in main/src/test-utils/setupJest.tsx. The api workspace +// configures its clients with hardcoded test URLs in its own setup, and leaf +// packages (ol-*) don't read NEXT_PUBLIC_* at all. // Pulled from the docs - see https://jestjs.io/docs/manual-mocks#mocking-methods-which-are-not-implemented-in-jsdom diff --git a/frontends/main/Dockerfile.web b/frontends/main/Dockerfile.web index db9efa8100..1371f0e2b8 100644 --- a/frontends/main/Dockerfile.web +++ b/frontends/main/Dockerfile.web @@ -2,24 +2,12 @@ # Build: \ # docker build \ # -f frontends/main/Dockerfile.web \ -# --build-arg NEXT_PUBLIC_ORIGIN=http://api.open.odl.local:8062 \ -# --build-arg NEXT_PUBLIC_MITOL_API_BASE_URL=http://open.odl.local:8063 \ -# --build-arg NEXT_PUBLIC_SITE_NAME="MIT Learn" \ -# --build-arg NEXT_PUBLIC_MITOL_SUPPORT_EMAIL=mitlearn-support@mit.edu \ -# --build-arg NEXT_PUBLIC_MITOL_AXIOS_WITH_CREDENTIALS=true \ -# --build-arg NEXT_PUBLIC_CSRF_COOKIE_NAME=csrftoken-local \ -# --build-arg NEXT_PUBLIC_POSTHOG_API_HOST= \ -# --build-arg NEXT_PUBLIC_POSTHOG_PROJECT_ID= \ -# --build-arg NEXT_PUBLIC_POSTHOG_API_KEY= \ -# --build-arg NEXT_PUBLIC_POSTHOG_ENABLE_SESSION_RECORDING= \ -# --build-arg NEXT_PUBLIC_SENTRY_DSN= \ -# --build-arg NEXT_PUBLIC_SENTRY_ENV= \ -# --build-arg NEXT_PUBLIC_SENTRY_PROFILES_SAMPLE_RATE= \ -# --build-arg NEXT_PUBLIC_SENTRY_TRACES_SAMPLE_RATE= \ -# --build-arg NEXT_PUBLIC_APPZI_URL= \ -# --build-arg NEXT_PUBLIC_LEARN_AI_RECOMMENDATION_ENDPOINT= \ -# --build-arg NEXT_PUBLIC_LEARN_AI_SYLLABUS_ENDPOINT= \ --build-arg NEXT_PUBLIC_HUBSPOT_PORTAL_ID= \# --build-arg NEXT_PUBLIC_VERSION= \ +# --build-arg GIT_REF=$(git rev-parse HEAD) \ # -t mitodl/mit-learn-frontend:latest . +# +# NEXT_PUBLIC_* vars are NOT passed as build args — they are injected at +# runtime by Kubernetes and surfaced to the browser via PublicEnvScript. +# Only GIT_REF is needed at build time (for generateBuildId in next.config.js). # Run: @@ -81,107 +69,58 @@ WORKDIR /app/frontends/main ENV NODE_ENV=production -ARG NEXT_PUBLIC_ORIGIN -ENV NEXT_PUBLIC_ORIGIN=$NEXT_PUBLIC_ORIGIN - -ARG NEXT_PUBLIC_MITOL_API_BASE_URL -ENV NEXT_PUBLIC_MITOL_API_BASE_URL=$NEXT_PUBLIC_MITOL_API_BASE_URL - -ARG NEXT_PUBLIC_MITX_ONLINE_LEGACY_BASE_URL -ENV NEXT_PUBLIC_MITX_ONLINE_LEGACY_BASE_URL=$NEXT_PUBLIC_MITX_ONLINE_LEGACY_BASE_URL - -ARG NEXT_PUBLIC_SITE_NAME -ENV NEXT_PUBLIC_SITE_NAME=$NEXT_PUBLIC_SITE_NAME - -ARG NEXT_PUBLIC_MITOL_SUPPORT_EMAIL -ENV NEXT_PUBLIC_MITOL_SUPPORT_EMAIL=$NEXT_PUBLIC_MITOL_SUPPORT_EMAIL - -ARG NEXT_PUBLIC_SENTRY_DSN -ENV NEXT_PUBLIC_SENTRY_DSN=$NEXT_PUBLIC_SENTRY_DSN - -ARG NEXT_PUBLIC_SENTRY_ENV -ENV NEXT_PUBLIC_SENTRY_ENV=$NEXT_PUBLIC_SENTRY_ENV - -ARG NEXT_PUBLIC_VERSION -ENV NEXT_PUBLIC_VERSION=$NEXT_PUBLIC_VERSION - -ARG NEXT_PUBLIC_SENTRY_PROFILES_SAMPLE_RATE -ENV NEXT_PUBLIC_SENTRY_PROFILES_SAMPLE_RATE=$NEXT_PUBLIC_SENTRY_PROFILES_SAMPLE_RATE - -ARG NEXT_PUBLIC_SENTRY_TRACES_SAMPLE_RATE -ENV NEXT_PUBLIC_SENTRY_TRACES_SAMPLE_RATE=$NEXT_PUBLIC_SENTRY_TRACES_SAMPLE_RATE - -ARG NEXT_PUBLIC_MITOL_AXIOS_WITH_CREDENTIALS=true -ENV NEXT_PUBLIC_MITOL_AXIOS_WITH_CREDENTIALS=$NEXT_PUBLIC_MITOL_AXIOS_WITH_CREDENTIALS - -ARG NEXT_PUBLIC_CSRF_COOKIE_NAME -ENV NEXT_PUBLIC_CSRF_COOKIE_NAME=$NEXT_PUBLIC_CSRF_COOKIE_NAME - -ARG NEXT_PUBLIC_POSTHOG_API_HOST -ENV NEXT_PUBLIC_POSTHOG_API_HOST=$NEXT_PUBLIC_POSTHOG_API_HOST -ARG NEXT_PUBLIC_POSTHOG_UI_HOST -ENV NEXT_PUBLIC_POSTHOG_UI_HOST=$NEXT_PUBLIC_POSTHOG_UI_HOST -ARG NEXT_PUBLIC_POSTHOG_PROJECT_ID -ENV NEXT_PUBLIC_POSTHOG_PROJECT_ID=$NEXT_PUBLIC_POSTHOG_PROJECT_ID -ARG NEXT_PUBLIC_POSTHOG_API_KEY -ENV NEXT_PUBLIC_POSTHOG_API_KEY=$NEXT_PUBLIC_POSTHOG_API_KEY - -ARG NEXT_PUBLIC_SENTRY_DSN -ENV NEXT_PUBLIC_SENTRY_DSN=$NEXT_PUBLIC_SENTRY_DSN -ARG NEXT_PUBLIC_SENTRY_ENV -ENV NEXT_PUBLIC_SENTRY_ENV=$NEXT_PUBLIC_SENTRY_ENV -ARG NEXT_PUBLIC_VERSION -ENV NEXT_PUBLIC_VERSION=$NEXT_PUBLIC_VERSION -ARG NEXT_PUBLIC_SENTRY_PROFILES_SAMPLE_RATE -ENV NEXT_PUBLIC_SENTRY_PROFILES_SAMPLE_RATE=$NEXT_PUBLIC_SENTRY_PROFILES_SAMPLE_RATE -ARG NEXT_PUBLIC_SENTRY_TRACES_SAMPLE_RATE -ENV NEXT_PUBLIC_SENTRY_TRACES_SAMPLE_RATE=$NEXT_PUBLIC_SENTRY_TRACES_SAMPLE_RATE - -ARG NEXT_PUBLIC_APPZI_URL -ENV NEXT_PUBLIC_APPZI_URL=$NEXT_PUBLIC_APPZI_URL - -ARG NEXT_PUBLIC_LEARN_AI_CSRF_COOKIE_NAME -ENV NEXT_PUBLIC_LEARN_AI_CSRF_COOKIE_NAME=$NEXT_PUBLIC_LEARN_AI_CSRF_COOKIE_NAME -ARG NEXT_PUBLIC_LEARN_AI_RECOMMENDATION_ENDPOINT -ENV NEXT_PUBLIC_LEARN_AI_RECOMMENDATION_ENDPOINT=$NEXT_PUBLIC_LEARN_AI_RECOMMENDATION_ENDPOINT -ARG NEXT_PUBLIC_LEARN_AI_SYLLABUS_ENDPOINT -ENV NEXT_PUBLIC_LEARN_AI_SYLLABUS_ENDPOINT=$NEXT_PUBLIC_LEARN_AI_SYLLABUS_ENDPOINT - -ARG NEXT_PUBLIC_HUBSPOT_PORTAL_ID -ENV NEXT_PUBLIC_HUBSPOT_PORTAL_ID=$NEXT_PUBLIC_HUBSPOT_PORTAL_ID - -ENV NEXT_PUBLIC_DEFAULT_SEARCH_MODE="phrase" -ENV NEXT_PUBLIC_DEFAULT_SEARCH_SLOP="6" -ENV NEXT_PUBLIC_DEFAULT_SEARCH_STALENESS_PENALTY="2.5" -ENV NEXT_PUBLIC_DEFAULT_SEARCH_MINIMUM_SCORE_CUTOFF="0" -ENV NEXT_PUBLIC_DEFAULT_SEARCH_MAX_INCOMPLETENESS_PENALTY="90" - -# NEW STAGE: build_skip_yarn -# This stage is intended for scenarios where the 'yarn build' step -# is not needed (e.g., build artifacts are provided externally or -# for a development setup where 'yarn dev' might be used). -# Note: 'yarn start' in the CMD requires a prior build to have occurred -# and the '.next' directory to be present. -FROM base AS build_skip_yarn +# GIT_REF is the full commit SHA, passed by Concourse as BUILD_ARG_GIT_REF. +# It is used by next.config.js generateBuildId to produce a stable, unique +# build manifest filename that is the same across identical builds. +# All NEXT_PUBLIC_* vars are injected at runtime — NOT at build time. +ARG GIT_REF +ENV GIT_REF=$GIT_REF + +# STAGE: build +# Runs `next build` to compile the application. With output: "standalone" in +# next.config.js this emits a minimal server at .next/standalone/ that includes +# only the required runtime files and can be run with plain node. +# +# NEXT_BUILD_CI=1 skips validateEnv() in next.config.js, which would fail +# because NEXT_PUBLIC_* vars are not available at build time — they are +# injected at runtime by Kubernetes and surfaced to the browser via +# PublicEnvScript. Validation runs at server startup in instrumentation-node.ts. +FROM base AS build -EXPOSE 3000 +ENV NEXT_BUILD_CI=1 -ENV PORT=3000 -ENV HOSTNAME="0.0.0.0" +RUN yarn build -CMD ["yarn", "start"] +# STAGE: runner (default) +# Copies only the standalone server, static assets, and public directory from +# the build stage into a clean image. No workspace install is needed; the +# standalone bundle ships with the minimal node_modules required to run the +# server. No source code or build tooling is included. +# +# The standalone server is started directly with node (not `yarn start`), which +# avoids yarn overhead and is the pattern recommended by Next.js for Docker: +# https://github.com/vercel/next.js/tree/canary/examples/with-docker +FROM node:24-alpine AS runner -# DEFAULT STAGE: build -# This stage performs the 'yarn build' step and is the default target -# if no --target is specified during the Docker build command. -FROM base AS build +WORKDIR /app -RUN yarn build +# sharp (Next.js image optimisation) requires glibc-compatible libs on Alpine. +RUN apk add --no-cache libc6-compat + +# Copy the standalone server (includes a minimal node_modules) +COPY --from=build /app/frontends/main/.next/standalone ./ + +# Copy static assets served by the Next.js server under /_next/static/ +COPY --from=build /app/frontends/main/.next/static ./frontends/main/.next/static + +# Copy the public directory (robots.txt, favicon.ico, etc.) +COPY --from=build /app/frontends/main/public ./frontends/main/public EXPOSE 3000 +ENV NODE_ENV=production +ENV TZ=UTC ENV PORT=3000 ENV HOSTNAME="0.0.0.0" -# CMD ["node", "/app/frontends/main/.next/standalone/frontends/main/server.js"] -CMD ["yarn", "start"] +CMD ["node", "frontends/main/server.js"] diff --git a/frontends/main/next.config.js b/frontends/main/next.config.js index 5cb947d145..453a797611 100644 --- a/frontends/main/next.config.js +++ b/frontends/main/next.config.js @@ -1,43 +1,35 @@ // @ts-check const { validateEnv } = require("./validateEnv") -validateEnv() +// In CI Docker builds (NEXT_BUILD_CI=1), NEXT_PUBLIC_* vars are not available +// at build time — they are injected at runtime via PublicEnvScript. Skip +// build-time validation; validateEnv() runs at server startup instead (see +// src/instrumentation-node.ts). +if (!process.env.NEXT_BUILD_CI) { + validateEnv() +} -const NEXT_PUBLIC_OPTIMIZE_IMAGES = Boolean( - (process.env.NEXT_PUBLIC_OPTIMIZE_IMAGES ?? "true") === "true", -) const IS_LOCAL_DEV = process.env.NODE_ENV === "development" // Dev-server-only: allow cross-origin requests to internal dev endpoints (HMR, -const allowedDevOrigins = - IS_LOCAL_DEV && process.env.NEXT_PUBLIC_ORIGIN - ? [new URL(process.env.NEXT_PUBLIC_ORIGIN).hostname] - : undefined - -const NEXT_CACHE_S_MAXAGE_SECONDS = - process.env.NEXT_CACHE_S_MAXAGE_SECONDS || "1800" -const PAGE_CACHE_CONTROL = `s-maxage=${NEXT_CACHE_S_MAXAGE_SECONDS}, stale-if-error=86400, stale-while-revalidate=86400` - -const processFeatureFlags = () => { - const featureFlagPrefix = - process.env.NEXT_PUBLIC_POSTHOG_FEATURE_PREFIX || "FEATURE_" - const bootstrapFeatureFlags = {} - - for (const [key, value] of Object.entries(process.env)) { - if (key.startsWith(`NEXT_PUBLIC_${featureFlagPrefix}`)) { - bootstrapFeatureFlags[ - key.replace(`NEXT_PUBLIC_${featureFlagPrefix}`, "").replaceAll("_", "-") - ] = value === "True" ? true : JSON.stringify(value) - } - } - - return bootstrapFeatureFlags -} +// etc.). Reading NEXT_PUBLIC_ORIGIN from process.env is safe here — unlike app +// code, this config path only runs when NODE_ENV==="development", never in a +// production build where NEXT_PUBLIC_* are absent. +// eslint-disable-next-line no-restricted-syntax -- dev-only; see comment above +const devOrigin = IS_LOCAL_DEV ? process.env.NEXT_PUBLIC_ORIGIN : undefined +const allowedDevOrigins = devOrigin ? [new URL(devOrigin).hostname] : undefined /** @type {import('next').NextConfig} */ const nextConfig = { productionBrowserSourceMaps: true, allowedDevOrigins, + /** + * Standalone output emits a minimal self-contained server at + * .next/standalone/ with only the required runtime files. The resulting + * Docker image requires no node_modules and no yarn at startup. + * See: https://nextjs.org/docs/app/getting-started/deploying#docker + */ + output: "standalone", async rewrites() { return [ /* Static assets moved from /static, though image paths are sometimes @@ -69,31 +61,13 @@ const nextConfig = { async headers() { return [ - { - source: "/sitemaps/:path*.xml", - headers: [ - { - key: "Cache-Control", - value: PAGE_CACHE_CONTROL, - }, - ], - }, - /* This is intended to target the base HTML responses and streamed RSC - * content. Some routes are dynamically rendered, so NextJS by default - * sets no-cache. However we are currently serving public content that is - * cacheable. - * - * Excludes everything with a file extension so we're matching only on routes. + /* The "html-pages" Surrogate-Key tag (for HTML/page routes and sitemaps) + * is set at runtime in src/proxy.ts, alongside Cache-Control and driven + * by the same isPageRoute() test, so the tag and the cache policy can + * never diverge. It cannot live here because page detection (and the + * Cache-Control value) depend on runtime state that is unavailable at + * build time. The rules below are genuinely static and immutable. */ - { - source: "/((?!.*\\.[a-zA-Z0-9]{2,4}$).*)", - headers: [ - { - key: "Cache-Control", - value: PAGE_CACHE_CONTROL, - }, - ], - }, /* Images rendered with the Next.js Image component have the cache header * set on them, but CSS background images do not. @@ -122,18 +96,11 @@ const nextConfig = { transpilePackages: ["@mitodl/smoot-design/ai"], images: { - unoptimized: !NEXT_PUBLIC_OPTIMIZE_IMAGES, - dangerouslyAllowLocalIP: IS_LOCAL_DEV, - remotePatterns: [ - { - hostname: "**", - }, - ], - qualities: [25, 50, 75, 100], - }, - - env: { - FEATURE_FLAGS: JSON.stringify(processFeatureFlags()), + // Image optimisation is disabled: the app passes images through as-is. + // Production uses Fastly for image transformations. Disabling also avoids + // baking a per-environment flag (previously NEXT_PUBLIC_OPTIMIZE_IMAGES) + // into the Docker image at build time. + unoptimized: true, }, experimental: { @@ -141,9 +108,23 @@ const nextConfig = { // Explicitly enable it for clarity (optional - already default) turbopackFileSystemCacheForDev: true, }, + + /** + * Pin the build ID to the git SHA / version tag for traceability — the + * BUILD_ID embedded in manifests and page-data paths then identifies which + * commit a running pod was built from. + * + * NEXT_PUBLIC_VERSION is set as a Kubernetes env var (and as a Docker build + * arg for the standalone build). GIT_REF is the full commit SHA passed by + * Concourse. The 'dev' fallback is for local builds. + */ + generateBuildId: async () => + // eslint-disable-next-line no-restricted-syntax -- NEXT_PUBLIC_VERSION is guaranteed present at build time by devops (set as a Docker build arg); other NEXT_PUBLIC_* are not + process.env.NEXT_PUBLIC_VERSION || process.env.GIT_REF || "dev", } const { withSentryConfig } = require("@sentry/nextjs") +/** @param {import('next').NextConfig} config */ const withSentry = (config) => withSentryConfig(config, { // For all available options, see: diff --git a/frontends/main/src/app-pages/AboutPage/AboutPage.tsx b/frontends/main/src/app-pages/AboutPage/AboutPage.tsx index 5fd4196961..0d154dcdc3 100644 --- a/frontends/main/src/app-pages/AboutPage/AboutPage.tsx +++ b/frontends/main/src/app-pages/AboutPage/AboutPage.tsx @@ -1,5 +1,6 @@ "use client" +import { env } from "@/env" import { Breadcrumbs, Container, @@ -15,7 +16,7 @@ import Image from "next/image" const WHAT_IS_MIT_OPEN_FRAGMENT_IDENTIFIER = "what-is-mit-learn" const ACADEMIC_AND_PROFESSIONAL_CONTENT = "kinds-of-content" -const SITE_NAME = process.env.NEXT_PUBLIC_SITE_NAME +const SITE_NAME = env("NEXT_PUBLIC_SITE_NAME") const PageContainer = styled(Container)({ color: theme.custom.colors.darkGray2, diff --git a/frontends/main/src/app-pages/CertificatePage/CertificatePage.tsx b/frontends/main/src/app-pages/CertificatePage/CertificatePage.tsx index 59d43151a3..e69cf5592e 100644 --- a/frontends/main/src/app-pages/CertificatePage/CertificatePage.tsx +++ b/frontends/main/src/app-pages/CertificatePage/CertificatePage.tsx @@ -1,5 +1,6 @@ "use client" +import { env } from "@/env" import React, { useRef, useEffect, useCallback, useState } from "react" import { notFound } from "next/navigation" import Image from "next/image" @@ -556,7 +557,7 @@ const Certificate = ({ src={ signatory.signature_image.startsWith("http") ? signatory.signature_image - : `${process.env.NEXT_PUBLIC_MITX_ONLINE_BASE_URL}${signatory.signature_image}` + : `${env("NEXT_PUBLIC_MITX_ONLINE_BASE_URL")}${signatory.signature_image}` } alt={signatory.name} crossOrigin="anonymous" diff --git a/frontends/main/src/app-pages/ChannelPage/TopicChannelTemplate.tsx b/frontends/main/src/app-pages/ChannelPage/TopicChannelTemplate.tsx index e30ecd4727..c7bd475c1f 100644 --- a/frontends/main/src/app-pages/ChannelPage/TopicChannelTemplate.tsx +++ b/frontends/main/src/app-pages/ChannelPage/TopicChannelTemplate.tsx @@ -1,3 +1,4 @@ +import { env } from "@/env" import React from "react" import { styled, @@ -110,7 +111,7 @@ const TopicChipsInternal: React.FC = (props) => { key={topic.id} href={topic.channel_url ?? ""} onClick={() => { - if (process.env.NEXT_PUBLIC_POSTHOG_API_KEY) { + if (env("NEXT_PUBLIC_POSTHOG_API_KEY")) { posthog.capture(posthogEvent, { topic }) } }} diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/HomeEnrollmentsDisplay.tsx b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/HomeEnrollmentsDisplay.tsx index 2dc6755ba1..f1026ffb01 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/HomeEnrollmentsDisplay.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/HomeEnrollmentsDisplay.tsx @@ -1,3 +1,4 @@ +import { env } from "@/env" import React from "react" import { Collapse, @@ -89,7 +90,7 @@ const ShowAllContainer = styled.div(({ theme }) => ({ }, })) -const SUPPORT_EMAIL = process.env.NEXT_PUBLIC_MITOL_SUPPORT_EMAIL || "" +const SUPPORT_EMAIL = env("NEXT_PUBLIC_MITOL_SUPPORT_EMAIL") || "" const getResourceKey = (resource: DashboardResource): string => { if (resource.type === DashboardType.ProgramEnrollment) { diff --git a/frontends/main/src/app-pages/DashboardPage/EnrollmentRedirectAlert.tsx b/frontends/main/src/app-pages/DashboardPage/EnrollmentRedirectAlert.tsx index 77f73ac1ff..4a57990a6f 100644 --- a/frontends/main/src/app-pages/DashboardPage/EnrollmentRedirectAlert.tsx +++ b/frontends/main/src/app-pages/DashboardPage/EnrollmentRedirectAlert.tsx @@ -1,5 +1,6 @@ "use client" +import { env } from "@/env" import React from "react" import { useQuery } from "@tanstack/react-query" import { Alert } from "@mitodl/smoot-design" @@ -170,7 +171,7 @@ const parseAlertRequest = ( const EnrollmentRedirectAlert: React.FC = () => { const request = useConsumeSearchParamsOnce(parseAlertRequest) - const supportEmail = process.env.NEXT_PUBLIC_MITOL_SUPPORT_EMAIL || "" + const supportEmail = env("NEXT_PUBLIC_MITOL_SUPPORT_EMAIL") || "" const mitxOnlineUserQuery = useQuery({ ...mitxUserQueries.me(), diff --git a/frontends/main/src/app-pages/HomePage/BrowseTopicsSection.tsx b/frontends/main/src/app-pages/HomePage/BrowseTopicsSection.tsx index 88ef1a5fb5..244af42986 100644 --- a/frontends/main/src/app-pages/HomePage/BrowseTopicsSection.tsx +++ b/frontends/main/src/app-pages/HomePage/BrowseTopicsSection.tsx @@ -1,3 +1,4 @@ +import { env } from "@/env" import React from "react" import Link from "next/link" import { @@ -123,7 +124,7 @@ const BrowseTopicsSection: React.FC = () => { key={id} href={channelUrl ? new URL(channelUrl!).pathname : ""} onClick={() => { - if (process.env.NEXT_PUBLIC_POSTHOG_API_KEY) { + if (env("NEXT_PUBLIC_POSTHOG_API_KEY")) { posthog.capture(PostHogEvents.HomeTopicClicked, { topic: name, }) @@ -143,7 +144,7 @@ const BrowseTopicsSection: React.FC = () => { { - if (process.env.NEXT_PUBLIC_POSTHOG_API_KEY) { + if (env("NEXT_PUBLIC_POSTHOG_API_KEY")) { posthog.capture(PostHogEvents.HomeSeeAllTopicsClicked) } }} diff --git a/frontends/main/src/app-pages/HomePage/UAIAnnouncementCard.tsx b/frontends/main/src/app-pages/HomePage/UAIAnnouncementCard.tsx index 0d8223a9e9..dfb9b0994b 100644 --- a/frontends/main/src/app-pages/HomePage/UAIAnnouncementCard.tsx +++ b/frontends/main/src/app-pages/HomePage/UAIAnnouncementCard.tsx @@ -1,4 +1,5 @@ "use client" +import { env } from "@/env" import React from "react" import { Typography, styled } from "ol-components" import { ButtonLink } from "@mitodl/smoot-design" @@ -252,7 +253,7 @@ const UAIAnnouncementCard: React.FC = () => { }) const handleCTAClick = () => { - if (process.env.NEXT_PUBLIC_POSTHOG_API_KEY) { + if (env("NEXT_PUBLIC_POSTHOG_API_KEY")) { posthog.capture(PostHogEvents.CallToActionClicked, { label: "Learn about Universal AI", readableId: UAI_PROGRAM_READABLE_ID, diff --git a/frontends/main/src/app-pages/HonorCodePage/HonorCodePage.tsx b/frontends/main/src/app-pages/HonorCodePage/HonorCodePage.tsx index 73d98f13ca..8f8271f54f 100644 --- a/frontends/main/src/app-pages/HonorCodePage/HonorCodePage.tsx +++ b/frontends/main/src/app-pages/HonorCodePage/HonorCodePage.tsx @@ -1,5 +1,6 @@ "use client" +import { env } from "@/env" import { Breadcrumbs, Container, @@ -64,7 +65,7 @@ const UnorderedList = styled.ul(({ theme }) => ({ marginTop: "10px", })) -const SITE_NAME = process.env.NEXT_PUBLIC_SITE_NAME +const SITE_NAME = env("NEXT_PUBLIC_SITE_NAME") const HonorCodePage: React.FC = () => { return ( diff --git a/frontends/main/src/app-pages/OnboardingPage/OnboardingPage.tsx b/frontends/main/src/app-pages/OnboardingPage/OnboardingPage.tsx index 53213f3bfa..82a76ab70f 100644 --- a/frontends/main/src/app-pages/OnboardingPage/OnboardingPage.tsx +++ b/frontends/main/src/app-pages/OnboardingPage/OnboardingPage.tsx @@ -1,5 +1,6 @@ "use client" +import { env } from "@/env" import React, { useEffect, useId, useMemo } from "react" import { useRouter } from "next-nprogress-bar" import { usePostHog } from "posthog-js/react" @@ -173,7 +174,7 @@ const OnboardingPage: React.FC = () => { }) } const label = activeStep < NUM_STEPS - 1 ? "Next" : "Finish" - if (process.env.NEXT_PUBLIC_POSTHOG_API_KEY) { + if (env("NEXT_PUBLIC_POSTHOG_API_KEY")) { posthog.capture(PostHogEvents.CallToActionClicked, { label, step: activeStep + 1, diff --git a/frontends/main/src/app-pages/PrivacyPage/PrivacyPage.tsx b/frontends/main/src/app-pages/PrivacyPage/PrivacyPage.tsx index b4df60b38c..5ab03c7388 100644 --- a/frontends/main/src/app-pages/PrivacyPage/PrivacyPage.tsx +++ b/frontends/main/src/app-pages/PrivacyPage/PrivacyPage.tsx @@ -1,5 +1,6 @@ "use client" +import { env } from "@/env" import { Breadcrumbs, Container, @@ -64,8 +65,8 @@ const UnorderedList = styled.ul(({ theme }) => ({ marginTop: "10px", })) -const SITE_NAME = process.env.NEXT_PUBLIC_SITE_NAME -const MITOL_SUPPORT_EMAIL = process.env.NEXT_PUBLIC_MITOL_SUPPORT_EMAIL +const SITE_NAME = env("NEXT_PUBLIC_SITE_NAME") +const MITOL_SUPPORT_EMAIL = env("NEXT_PUBLIC_MITOL_SUPPORT_EMAIL") const PrivacyPage: React.FC = () => { return ( diff --git a/frontends/main/src/app-pages/ProductPages/CourseEnrollmentButton.tsx b/frontends/main/src/app-pages/ProductPages/CourseEnrollmentButton.tsx index 99daf0c651..d13dd7c93f 100644 --- a/frontends/main/src/app-pages/ProductPages/CourseEnrollmentButton.tsx +++ b/frontends/main/src/app-pages/ProductPages/CourseEnrollmentButton.tsx @@ -1,3 +1,4 @@ +import { env } from "@/env" import React from "react" import { styled, Stack, LoadingSpinner } from "ol-components" import { useQuery } from "@tanstack/react-query" @@ -94,7 +95,7 @@ const CourseEnrollmentButton: React.FC = ({ if (me.isLoading) { return } - if (process.env.NEXT_PUBLIC_POSTHOG_API_KEY) { + if (env("NEXT_PUBLIC_POSTHOG_API_KEY")) { posthog.capture(PostHogEvents.CallToActionClicked, { readableId: course.readable_id, resourceType: "course", diff --git a/frontends/main/src/app-pages/ProductPages/MitxOnlineResourceCard.tsx b/frontends/main/src/app-pages/ProductPages/MitxOnlineResourceCard.tsx index 2c07c18beb..e93a8f11c5 100644 --- a/frontends/main/src/app-pages/ProductPages/MitxOnlineResourceCard.tsx +++ b/frontends/main/src/app-pages/ProductPages/MitxOnlineResourceCard.tsx @@ -1,5 +1,6 @@ "use client" +import { env } from "@/env" import React from "react" import { usePostHog } from "posthog-js/react" import { BaseLearningResourceCard } from "ol-components" @@ -217,7 +218,7 @@ const MitxOnlineResourceCard: React.FC = ( ariaLabel={`${data.displayType}: ${data.title}`} list={list} onClick={() => { - if (process.env.NEXT_PUBLIC_POSTHOG_API_KEY) { + if (env("NEXT_PUBLIC_POSTHOG_API_KEY")) { posthog.capture(PostHogEvents.CourseCardClicked, { label, resourceId: props.resource?.id, diff --git a/frontends/main/src/app-pages/ProductPages/ProductPageTemplate.tsx b/frontends/main/src/app-pages/ProductPages/ProductPageTemplate.tsx index ad2c32555e..8b8400f512 100644 --- a/frontends/main/src/app-pages/ProductPages/ProductPageTemplate.tsx +++ b/frontends/main/src/app-pages/ProductPages/ProductPageTemplate.tsx @@ -1,5 +1,6 @@ "use client" +import { env } from "@/env" import React from "react" import { Container, @@ -311,7 +312,7 @@ const ProductPageTemplate: React.FC = ({ const handleStayUpdatedClick = () => { if (!showStayUpdated || !resource) return - if (process.env.NEXT_PUBLIC_POSTHOG_API_KEY) { + if (env("NEXT_PUBLIC_POSTHOG_API_KEY")) { posthog.capture(PostHogEvents.CallToActionClicked, { label: "Stay Updated", readableId: resource.readable_id, diff --git a/frontends/main/src/app-pages/ProductPages/ProgramEnrollmentButton.tsx b/frontends/main/src/app-pages/ProductPages/ProgramEnrollmentButton.tsx index 2dab57ce7b..e595d7df1d 100644 --- a/frontends/main/src/app-pages/ProductPages/ProgramEnrollmentButton.tsx +++ b/frontends/main/src/app-pages/ProductPages/ProgramEnrollmentButton.tsx @@ -1,3 +1,4 @@ +import { env } from "@/env" import React from "react" import { LoadingSpinner, Stack, theme } from "ol-components" import { @@ -90,7 +91,7 @@ const ProgramEnrollmentButton: React.FC = ({ if (enrollments.isLoading || me.isLoading) { return } - if (process.env.NEXT_PUBLIC_POSTHOG_API_KEY) { + if (env("NEXT_PUBLIC_POSTHOG_API_KEY")) { posthog.capture(PostHogEvents.CallToActionClicked, { readableId: program.readable_id, resourceType: "program", diff --git a/frontends/main/src/app-pages/TopicsListingPage/TopicsListingPage.tsx b/frontends/main/src/app-pages/TopicsListingPage/TopicsListingPage.tsx index db6bb0f58b..6540bf8a02 100644 --- a/frontends/main/src/app-pages/TopicsListingPage/TopicsListingPage.tsx +++ b/frontends/main/src/app-pages/TopicsListingPage/TopicsListingPage.tsx @@ -1,5 +1,6 @@ "use client" +import { env } from "@/env" import React, { useMemo } from "react" import { Container, @@ -34,7 +35,7 @@ const captureTopicClicked = ( event: string, topic: string, ) => { - if (process.env.NEXT_PUBLIC_POSTHOG_API_KEY) { + if (env("NEXT_PUBLIC_POSTHOG_API_KEY")) { posthog.capture(event, { topic }) } } diff --git a/frontends/main/src/app-pages/UnitsListingPage/UnitCard.tsx b/frontends/main/src/app-pages/UnitsListingPage/UnitCard.tsx index 6afee621b4..a7ef3c1167 100644 --- a/frontends/main/src/app-pages/UnitsListingPage/UnitCard.tsx +++ b/frontends/main/src/app-pages/UnitsListingPage/UnitCard.tsx @@ -1,3 +1,4 @@ +import { env } from "@/env" import React from "react" import type { OfferedByEnum } from "api" import type { UnitChannel } from "api/v0" @@ -98,7 +99,7 @@ const UnitCard: React.FC = (props) => { { - if (process.env.NEXT_PUBLIC_POSTHOG_API_KEY) { + if (env("NEXT_PUBLIC_POSTHOG_API_KEY")) { posthog.capture(PostHogEvents.ProviderLinkClicked, { provider: unit, }) diff --git a/frontends/main/src/app-pages/VideoPlaylistCollectionPage/VideoDetailPage.tsx b/frontends/main/src/app-pages/VideoPlaylistCollectionPage/VideoDetailPage.tsx index bd2f3b9084..086850fd6c 100644 --- a/frontends/main/src/app-pages/VideoPlaylistCollectionPage/VideoDetailPage.tsx +++ b/frontends/main/src/app-pages/VideoPlaylistCollectionPage/VideoDetailPage.tsx @@ -1,5 +1,6 @@ "use client" +import { env } from "@/env" import React, { useEffect, useRef, useState } from "react" import Link from "next/link" import Image from "next/image" @@ -19,7 +20,7 @@ import SharePopover from "@/components/SharePopover/SharePopover" import { buildVideoStructuredData } from "./videoStructuredData" import VideoResourcePlayer from "./VideoResourcePlayer" -const NEXT_PUBLIC_ORIGIN = process.env.NEXT_PUBLIC_ORIGIN +const NEXT_PUBLIC_ORIGIN = env("NEXT_PUBLIC_ORIGIN") const PageWrapper = styled.div({ backgroundColor: "#fff", diff --git a/frontends/main/src/app-pages/VideoPlaylistCollectionPage/YouTubeIframePlayer.tsx b/frontends/main/src/app-pages/VideoPlaylistCollectionPage/YouTubeIframePlayer.tsx index 7913907813..f8e77ea146 100644 --- a/frontends/main/src/app-pages/VideoPlaylistCollectionPage/YouTubeIframePlayer.tsx +++ b/frontends/main/src/app-pages/VideoPlaylistCollectionPage/YouTubeIframePlayer.tsx @@ -1,10 +1,7 @@ "use client" +import { requiredEnv } from "@/env" import React from "react" -import invariant from "tiny-invariant" - -const NEXT_PUBLIC_ORIGIN = process.env.NEXT_PUBLIC_ORIGIN -invariant(NEXT_PUBLIC_ORIGIN, "NEXT_PUBLIC_ORIGIN must be defined") export type YouTubeIframePlayerProps = { embedUrl: string @@ -31,7 +28,7 @@ const YouTubeIframePlayer: React.FC = ({ }) => { const url = new URL(embedUrl) url.searchParams.set("rel", "0") - url.searchParams.set("origin", NEXT_PUBLIC_ORIGIN) + url.searchParams.set("origin", requiredEnv("NEXT_PUBLIC_ORIGIN")) const src = url.toString() diff --git a/frontends/main/src/app/(site)/certificate/[certificateType]/[uuid]/page.tsx b/frontends/main/src/app/(site)/certificate/[certificateType]/[uuid]/page.tsx index c0c53d21e5..7c07074586 100644 --- a/frontends/main/src/app/(site)/certificate/[certificateType]/[uuid]/page.tsx +++ b/frontends/main/src/app/(site)/certificate/[certificateType]/[uuid]/page.tsx @@ -1,3 +1,4 @@ +import { env } from "@/env" import React from "react" import { Metadata } from "next" import CertificatePage from "@/app-pages/CertificatePage/CertificatePage" @@ -9,7 +10,7 @@ import { safeGenerateMetadata, standardizeMetadata } from "@/common/metadata" import { getQueryClient } from "@/app/getQueryClient" import { getCertificateInfo } from "@/common/certificateUtils" -const NEXT_PUBLIC_ORIGIN = process.env.NEXT_PUBLIC_ORIGIN +const NEXT_PUBLIC_ORIGIN = env("NEXT_PUBLIC_ORIGIN") enum CertificateType { Course = "course", diff --git a/frontends/main/src/app/(site)/certificate/[certificateType]/[uuid]/pdf/route.tsx b/frontends/main/src/app/(site)/certificate/[certificateType]/[uuid]/pdf/route.tsx index 54a1ccd249..13cecb72e3 100644 --- a/frontends/main/src/app/(site)/certificate/[certificateType]/[uuid]/pdf/route.tsx +++ b/frontends/main/src/app/(site)/certificate/[certificateType]/[uuid]/pdf/route.tsx @@ -1,3 +1,4 @@ +import { env } from "@/env" /* eslint-disable no-restricted-syntax */ import React from "react" import type { AxiosError } from "axios" @@ -384,7 +385,7 @@ const CertificateDoc = ({ source={ signatory.signature_image.startsWith("http") ? signatory.signature_image - : `${process.env.NEXT_PUBLIC_MITX_ONLINE_BASE_URL}${signatory.signature_image}` + : `${env("NEXT_PUBLIC_MITX_ONLINE_BASE_URL")}${signatory.signature_image}` } style={{ width: "100px", diff --git a/frontends/main/src/app/(site)/layout.tsx b/frontends/main/src/app/(site)/layout.tsx index 982058ea30..32f941e427 100644 --- a/frontends/main/src/app/(site)/layout.tsx +++ b/frontends/main/src/app/(site)/layout.tsx @@ -1,3 +1,4 @@ +import { env } from "@/env" import React from "react" import Script from "next/script" import Header from "@/page-components/Header/Header" @@ -86,13 +87,13 @@ j=d.createElement(s),dl=l!=='dataLayer'?'&l='+l:'';j.async=true;j.src=