From a922c4e26b9432d477ef8ec0448626f866140a19 Mon Sep 17 00:00:00 2001 From: Stephan Butler Date: Wed, 16 Sep 2026 19:36:28 +0200 Subject: [PATCH 1/3] fix: frontend configuration externalisation --- .github/workflows/release.yml | 29 ++-- helm/testnet-wallet/README.md | 47 +++++-- .../tests/configMap.frontend_test.yaml | 86 ++++++++++++ helm/testnet-wallet/values.yaml | 41 +++++- packages/wallet/frontend/.env.example | 44 ++++-- packages/wallet/frontend/.env.local | 20 ++- packages/wallet/frontend/Dockerfile.dev | 18 +-- packages/wallet/frontend/Dockerfile.prod | 43 ++---- packages/wallet/frontend/next.config.js | 42 ++---- packages/wallet/frontend/scripts/start.js | 38 ++++++ .../wallet/frontend/src/lib/httpClient.ts | 13 +- .../wallet/frontend/src/lib/runtimeConfig.ts | 128 ++++++++++++++++++ packages/wallet/frontend/src/lib/socket.ts | 60 ++++++++ packages/wallet/frontend/src/pages/_app.tsx | 22 ++- .../wallet/frontend/src/pages/_document.tsx | 31 ++++- .../src/pages/grant-interactions/index.tsx | 6 +- packages/wallet/frontend/src/pages/kyc.tsx | 4 +- .../wallet/frontend/src/pages/no-access.tsx | 4 +- .../wallet/frontend/src/utils/constants.ts | 19 ++- 19 files changed, 531 insertions(+), 164 deletions(-) create mode 100644 helm/testnet-wallet/tests/configMap.frontend_test.yaml create mode 100644 packages/wallet/frontend/scripts/start.js create mode 100644 packages/wallet/frontend/src/lib/runtimeConfig.ts create mode 100644 packages/wallet/frontend/src/lib/socket.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cc6f61e8c..cd99fb81c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -140,26 +140,20 @@ jobs: - name: test-wallet-backend identifier: 'wallet:backend' path: packages/wallet/backend + # The wallet frontend takes no build configuration. Every URL, the + # theme and the feature flags are read from the container + # environment when a request is served, so one image serves every + # deployment. The Helm chart supplies the values. - name: test-wallet-frontend identifier: 'wallet:frontend' path: packages/wallet/frontend - port: 4003 - cookie_name: testnet.cookie - backend_url: https://api-wallet.interledger-test.dev - open_payments_host: $ilp.interledger-test.dev/ - auth_host: https://auth.interledger-test.dev - gatehub_env: sandbox - theme: dark + # Byte-identical to test-wallet-frontend. It is still published so + # the interledger.cards deployments keep resolving the image name + # they already reference. Retire it once they point at + # test-wallet-frontend. - name: test-wallet-frontend-cards identifier: 'wallet:frontend' path: packages/wallet/frontend - port: 4003 - cookie_name: interledgercards.cookie - backend_url: https://api.interledger.cards - open_payments_host: $ilp.dev/ - auth_host: https://auth.interledger.cards - gatehub_env: production - theme: light - name: test-boutique-frontend identifier: 'boutique:frontend' path: packages/boutique/frontend @@ -217,11 +211,4 @@ jobs: ${{ matrix.package.identifier == 'boutique:frontend' && format('VITE_API_BASE_URL={0}', matrix.package.api_base_url) || '' }} ${{ matrix.package.identifier == 'boutique:frontend' && format('VITE_CURRENCY={0}', matrix.package.currency) || '' }} ${{ matrix.package.identifier == 'boutique:frontend' && format('VITE_THEME={0}', matrix.package.theme) || '' }} - ${{ matrix.package.identifier == 'wallet:frontend' && format('PORT={0}', matrix.package.port) || '' }} - ${{ matrix.package.identifier == 'wallet:frontend' && format('COOKIE_NAME={0}', matrix.package.cookie_name) || '' }} - ${{ matrix.package.identifier == 'wallet:frontend' && format('NEXT_PUBLIC_BACKEND_URL={0}', matrix.package.backend_url) || '' }} - ${{ matrix.package.identifier == 'wallet:frontend' && format('NEXT_PUBLIC_OPEN_PAYMENTS_HOST={0}', matrix.package.open_payments_host) || '' }} - ${{ matrix.package.identifier == 'wallet:frontend' && format('NEXT_PUBLIC_AUTH_HOST={0}', matrix.package.auth_host) || '' }} - ${{ matrix.package.identifier == 'wallet:frontend' && format('NEXT_PUBLIC_GATEHUB_ENV={0}', matrix.package.gatehub_env) || '' }} - ${{ matrix.package.identifier == 'wallet:frontend' && format('NEXT_PUBLIC_THEME={0}', matrix.package.theme) || '' }} tags: ${{ steps.image_tags.outputs.tags }} diff --git a/helm/testnet-wallet/README.md b/helm/testnet-wallet/README.md index 1e5226019..982cd9f76 100644 --- a/helm/testnet-wallet/README.md +++ b/helm/testnet-wallet/README.md @@ -21,20 +21,39 @@ All application configuration is driven through `values.yaml`. Environment varia Defined under `configMaps.backend.contentMap` and `configMaps.frontend.contentMap`. Keys map directly to environment variable names injected into each container via `envFrom`. -**Frontend ConfigMap keys** (`NEXT_PUBLIC_*` vars + runtime config): - -| Key | `values.yaml` path | -| -------------------------------- | ----------------------------------------- | -| `NODE_ENV` | `config.frontend.nodeEnv` | -| `PORT` | `config.frontend.port` | -| `COOKIE_NAME` | `config.frontend.cookie.name` | -| `NEXT_PUBLIC_USE_TEST_KYC_DATA` | `config.frontend.features.useTestKycData` | -| `NEXT_PUBLIC_BACKEND_URL` | `config.frontend.urls.backend` | -| `NEXT_PUBLIC_OPEN_PAYMENTS_HOST` | `config.frontend.urls.openPaymentsHost` | -| `NEXT_PUBLIC_AUTH_HOST` | `config.frontend.urls.authHost` | -| `NEXT_PUBLIC_THEME` | `config.frontend.theme` | -| `NEXT_PUBLIC_GATEHUB_ENV` | `config.frontend.gatehub.env` | -| `NEXT_PUBLIC_FEATURES_ENABLED` | `config.frontend.features.enabled` | +**Frontend ConfigMap keys** + +The frontend reads all of these when it serves a request. None of them is +baked into the image. To move the wallet to a different hostname, change the +value and restart the pod. No rebuild, and no new image tag. + +The names carry no `NEXT_PUBLIC_` prefix on purpose. Next.js replaces every +`process.env.NEXT_PUBLIC_*` expression with a literal when it builds, so a +prefixed name cannot change afterwards. + +| Key | `values.yaml` path | +| ---------------------- | ----------------------------------------- | +| `NODE_ENV` | `config.frontend.nodeEnv` | +| `PORT` | `config.frontend.port` | +| `COOKIE_NAME` | `config.frontend.cookie.name` | +| `USE_TEST_KYC_DATA` | `config.frontend.features.useTestKycData` | +| `BACKEND_URL` | `config.frontend.urls.backend` | +| `BACKEND_INTERNAL_URL` | `config.frontend.urls.backendInternal` | +| `OPEN_PAYMENTS_HOST` | `config.frontend.urls.openPaymentsHost` | +| `AUTH_HOST` | `config.frontend.urls.authHost` | +| `THEME` | `config.frontend.theme` | +| `GATEHUB_ENV` | `config.frontend.gatehub.env` | +| `FEATURES_ENABLED` | `config.frontend.features.enabled` | + +`BACKEND_URL` may be an absolute URL, or a path such as `/wallet-api` where one +hostname serves the frontend at `/` and the backend at a prefix. A path needs no +edit when DNS changes. Set `BACKEND_INTERNAL_URL` to the in-cluster Service +address whenever `BACKEND_URL` is a path: server-side rendering has no origin to +resolve a path against, and the container refuses to start without it. + +`BACKEND_URL`, `OPEN_PAYMENTS_HOST` and `AUTH_HOST` are required. The container +checks them before the server accepts traffic and exits with a readable message +if one is missing. **Backend ConfigMap keys** (see `configMaps.backend.contentMap` in `values.yaml` for the full list — covers `NODE_ENV`, `PORT`, cookie settings, GateHub config, Rafiki endpoints, Stripe flags, rate limiting, card URLs, and more). diff --git a/helm/testnet-wallet/tests/configMap.frontend_test.yaml b/helm/testnet-wallet/tests/configMap.frontend_test.yaml new file mode 100644 index 000000000..515add119 --- /dev/null +++ b/helm/testnet-wallet/tests/configMap.frontend_test.yaml @@ -0,0 +1,86 @@ +# The frontend reads these keys from its environment when it serves a request, +# so the key names are a contract between this chart and +# packages/wallet/frontend/src/lib/runtimeConfig.ts. A rename on one side alone +# leaves the container with nothing to read, and it exits at start-up. +# +# None of these names carries a `NEXT_PUBLIC_` prefix. Next.js replaces every +# `process.env.NEXT_PUBLIC_*` expression with a literal when it builds, so a +# prefixed name could not be changed by a deployment afterwards. +suite: testnet-wallet frontend - runtime configuration +templates: + - configMap.frontend.yaml +set: + fullnameOverride: test-testnet-wallet +release: + name: test-testnet-wallet +tests: + - it: 'emits the runtime keys the frontend reads' + set: + config: + frontend: + theme: dark + urls: + backend: https://api.example.test + openPaymentsHost: https://ilp.example.test + authHost: https://auth.example.test + asserts: + - equal: + path: data.BACKEND_URL + value: https://api.example.test + - equal: + path: data.OPEN_PAYMENTS_HOST + value: https://ilp.example.test + - equal: + path: data.AUTH_HOST + value: https://auth.example.test + - equal: + path: data.THEME + value: dark + # Booleans must reach the ConfigMap as strings. + - equal: + path: data.FEATURES_ENABLED + value: 'false' + - equal: + path: data.NODE_ENV + value: production + # Numbers must reach the ConfigMap as strings. + - equal: + path: data.PORT + value: '4003' + + # The prefixed names are what the frontend used to read at build time. None + # of them may come back: the container does not look for them. + - it: 'emits no NEXT_PUBLIC_ key' + asserts: + - notExists: + path: data.NEXT_PUBLIC_BACKEND_URL + - notExists: + path: data.NEXT_PUBLIC_OPEN_PAYMENTS_HOST + - notExists: + path: data.NEXT_PUBLIC_AUTH_HOST + - notExists: + path: data.NEXT_PUBLIC_THEME + - notExists: + path: data.NEXT_PUBLIC_GATEHUB_ENV + - notExists: + path: data.NEXT_PUBLIC_FEATURES_ENABLED + - notExists: + path: data.NEXT_PUBLIC_USE_TEST_KYC_DATA + + # A path means "the same origin as the page", which is what one hostname + # serving the frontend at / and the backend at a prefix needs. The server + # cannot resolve a path, so it takes the in-cluster address instead. + - it: 'carries a path backend address and the in-cluster address together' + set: + config: + frontend: + urls: + backend: /wallet-api + backendInternal: http://test-testnet-wallet-backend-service:4003 + asserts: + - equal: + path: data.BACKEND_URL + value: /wallet-api + - equal: + path: data.BACKEND_INTERNAL_URL + value: http://test-testnet-wallet-backend-service:4003 diff --git a/helm/testnet-wallet/values.yaml b/helm/testnet-wallet/values.yaml index 9c1c02eab..01b38dc07 100644 --- a/helm/testnet-wallet/values.yaml +++ b/helm/testnet-wallet/values.yaml @@ -11,7 +11,24 @@ config: theme: 'dark' urls: + # The address the browser uses for the backend. + # + # Use a path, such as `/wallet-api`, where one hostname serves the + # frontend at `/` and the backend at a prefix. A path follows the + # hostname the page was served from, so it survives a DNS change and a + # new environment without an edit. Set `backendInternal` as well when + # you do, because server-side rendering cannot resolve a path. + # + # Use an absolute URL where the backend has a hostname of its own. backend: 'https://api.interledger-test.dev' + + # The address the frontend container itself uses for the backend. Leave + # it empty to reuse `backend`. + # + # Point it at the in-cluster Service to keep server-side traffic inside + # the cluster. It is required when `backend` is a path. + backendInternal: '' + openPaymentsHost: 'https://ilp.interledger-test.dev' authHost: 'https://auth.interledger-test.dev' @@ -416,6 +433,14 @@ services: # This section of the values file defines Kubernetes config maps that will be created # for each service (backend and frontend). configMaps: + # The frontend reads every one of these when it serves a request. None of + # them is baked into the image, so a change here plus a pod restart is the + # whole procedure for moving the wallet to a different hostname. + # + # The names carry no `NEXT_PUBLIC_` prefix on purpose. Next.js replaces every + # `process.env.NEXT_PUBLIC_*` expression with a literal at build time, so a + # prefixed name can never be changed afterwards. See + # packages/wallet/frontend/src/lib/runtimeConfig.ts. frontend: name: 'frontend' contentMap: @@ -425,19 +450,21 @@ configMaps: valueRef: config.frontend.port - key: COOKIE_NAME valueRef: config.frontend.cookie.name - - key: NEXT_PUBLIC_USE_TEST_KYC_DATA + - key: USE_TEST_KYC_DATA valueRef: config.frontend.features.useTestKycData - - key: NEXT_PUBLIC_BACKEND_URL + - key: BACKEND_URL valueRef: config.frontend.urls.backend - - key: NEXT_PUBLIC_OPEN_PAYMENTS_HOST + - key: BACKEND_INTERNAL_URL + valueRef: config.frontend.urls.backendInternal + - key: OPEN_PAYMENTS_HOST valueRef: config.frontend.urls.openPaymentsHost - - key: NEXT_PUBLIC_AUTH_HOST + - key: AUTH_HOST valueRef: config.frontend.urls.authHost - - key: NEXT_PUBLIC_THEME + - key: THEME valueRef: config.frontend.theme - - key: NEXT_PUBLIC_GATEHUB_ENV + - key: GATEHUB_ENV valueRef: config.frontend.gatehub.env - - key: NEXT_PUBLIC_FEATURES_ENABLED + - key: FEATURES_ENABLED valueRef: config.frontend.features.enabled backend: name: 'backend' diff --git a/packages/wallet/frontend/.env.example b/packages/wallet/frontend/.env.example index 3806aff25..73a3588cf 100644 --- a/packages/wallet/frontend/.env.example +++ b/packages/wallet/frontend/.env.example @@ -1,30 +1,48 @@ -# You can copy this file to .env.local and adjust values to run the wallet frontend locally. -# If you create both .env.local and .env, values in .env override values from .env.local. -# Keep NEXT_PUBLIC_* URLs aligned with the local testnet domains. +# Copy this file to .env.local and adjust the values to run the wallet frontend +# locally. If you create both .env.local and .env, values in .env win. +# +# Every variable here is read when the server answers a request, not when the +# bundle is built. `pnpm wallet:frontend dev` picks up an edit on the next +# request, and a production image picks it up on the next pod restart. +# +# None of these names carries a `NEXT_PUBLIC_` prefix, and none should. Next.js +# replaces every `process.env.NEXT_PUBLIC_*` expression with a literal when it +# builds, which is what used to tie a deployment URL to an image tag. See +# src/lib/runtimeConfig.ts. ########## SESSION AND INTERNAL BACKEND ######################################################### # COOKIE_NAME=testnet.cookie + +# The address this process uses for the backend. It keeps server-side rendering +# and the middleware off Traefik and its self-signed certificate. It is +# required when BACKEND_URL is a path. # BACKEND_INTERNAL_URL=http://localhost:3003 -########## PUBLIC FRONTEND CONFIG ################################################################ -## NEXT_PUBLIC_* variables are exposed to the browser. +########## BROWSER-FACING CONFIG ################################################################# +## The server writes these into every page, and the browser reads them back. + +# The address the browser uses for the backend. Absolute where the backend has +# a hostname of its own, which is the local setup. Where one hostname serves +# the frontend at / and the backend at a prefix, use the path instead +# (BACKEND_URL=/wallet-api): a path follows whatever host served the page, so +# it survives a DNS change without an edit. +# BACKEND_URL=https://api.testnet.test -# NEXT_PUBLIC_BACKEND_URL=https://api.testnet.test -# NEXT_PUBLIC_AUTH_HOST=https://auth.testnet.test -# NEXT_PUBLIC_OPEN_PAYMENTS_HOST=https://rafiki-backend.testnet.test +# AUTH_HOST=https://auth.testnet.test +# OPEN_PAYMENTS_HOST=https://rafiki-backend.testnet.test -# NEXT_PUBLIC_THEME=light +# THEME=light ######### Controlling branding and features ###################################################### -# NEXT_PUBLIC_FEATURES_ENABLED controls the wallet UI mode (frontend only): +# FEATURES_ENABLED controls the wallet UI mode (frontend only): # - true: enables "Interledger Wallet" mode (wallet branding, card/deposit-first flows, # cards menu visibility, card-related labels in transactions, and production-style auth copy) # - false: enables "Test Wallet" mode (test branding, test-only hints, and cards visibility # controlled by per-user settings instead of forced-on) -# If omitted, next.config.js defaults to false only when NODE_ENV=production and NEXT_PUBLIC_GATEHUB_ENV=sandbox +# If omitted, it defaults to false only when NODE_ENV=production and GATEHUB_ENV=sandbox, # otherwise it defaults to true. # NODE_ENV=production -# NEXT_PUBLIC_GATEHUB_ENV=production -# NEXT_PUBLIC_FEATURES_ENABLED=true +# GATEHUB_ENV=production +# FEATURES_ENABLED=true diff --git a/packages/wallet/frontend/.env.local b/packages/wallet/frontend/.env.local index a485c1d8f..c69395431 100644 --- a/packages/wallet/frontend/.env.local +++ b/packages/wallet/frontend/.env.local @@ -1,8 +1,16 @@ COOKIE_NAME=testnet.cookie + +# The frontend is served at https://testnet.test and the backend at +# https://api.testnet.test, so the browser needs the absolute address. A bare +# path would only work if one host served both. +BACKEND_URL=https://api.testnet.test + +# Server-side rendering and the middleware talk to the backend directly, over +# plain HTTP, and skip Traefik and its self-signed certificate. BACKEND_INTERNAL_URL=http://localhost:3003 -NEXT_PUBLIC_BACKEND_URL=https://api.testnet.test -NEXT_PUBLIC_AUTH_HOST=https://auth.testnet.test -NEXT_PUBLIC_OPEN_PAYMENTS_HOST=https://rafiki-backend.testnet.test -NEXT_PUBLIC_GATEHUB_ENV=sandbox -NEXT_PUBLIC_THEME=dark -NEXT_PUBLIC_FEATURES_ENABLED=false + +AUTH_HOST=https://auth.testnet.test +OPEN_PAYMENTS_HOST=https://rafiki-backend.testnet.test +GATEHUB_ENV=sandbox +THEME=dark +FEATURES_ENABLED=false diff --git a/packages/wallet/frontend/Dockerfile.dev b/packages/wallet/frontend/Dockerfile.dev index e54565e4b..baabb81d1 100644 --- a/packages/wallet/frontend/Dockerfile.dev +++ b/packages/wallet/frontend/Dockerfile.dev @@ -23,20 +23,8 @@ ADD . ./ # Install packages from virtual store RUN pnpm install --filter @wallet/frontend... --offline -# Accept build arguments for Next.js public environment variables -ARG NEXT_PUBLIC_BACKEND_URL -ARG NEXT_PUBLIC_AUTH_HOST -ARG NEXT_PUBLIC_OPEN_PAYMENTS_HOST -ARG NEXT_PUBLIC_GATEHUB_ENV -ARG NEXT_PUBLIC_THEME -ARG NEXT_PUBLIC_FEATURES_ENABLED - -# Make them available as environment variables during build -ENV NEXT_PUBLIC_BACKEND_URL=$NEXT_PUBLIC_BACKEND_URL -ENV NEXT_PUBLIC_AUTH_HOST=$NEXT_PUBLIC_AUTH_HOST -ENV NEXT_PUBLIC_OPEN_PAYMENTS_HOST=$NEXT_PUBLIC_OPEN_PAYMENTS_HOST -ENV NEXT_PUBLIC_GATEHUB_ENV=$NEXT_PUBLIC_GATEHUB_ENV -ENV NEXT_PUBLIC_THEME=$NEXT_PUBLIC_THEME -ENV NEXT_PUBLIC_FEATURES_ENABLED=$NEXT_PUBLIC_FEATURES_ENABLED +# This image takes no build arguments. The frontend reads every URL, the theme +# and the feature flags from the environment when it answers a request, so pass +# them to the container instead. See packages/wallet/frontend/.env.example. CMD ["pnpm", "wallet:frontend", "dev"] diff --git a/packages/wallet/frontend/Dockerfile.prod b/packages/wallet/frontend/Dockerfile.prod index c329cf4a4..b2407a8c1 100644 --- a/packages/wallet/frontend/Dockerfile.prod +++ b/packages/wallet/frontend/Dockerfile.prod @@ -31,22 +31,13 @@ COPY --from=base /testnet/node_modules ./node_modules COPY --from=base /testnet/packages/wallet/frontend/node_modules ./packages/wallet/frontend/node_modules COPY --from=base /testnet/packages/wallet/shared/node_modules ./packages/wallet/shared/node_modules -ARG PORT -ARG COOKIE_NAME -ARG NEXT_PUBLIC_BACKEND_URL -ARG NEXT_PUBLIC_OPEN_PAYMENTS_HOST -ARG NEXT_PUBLIC_AUTH_HOST -ARG NEXT_PUBLIC_GATEHUB_ENV -ARG NEXT_PUBLIC_THEME - +# This build takes no configuration. +# +# Every URL, the theme and the feature flags are read from the environment when +# the container serves a request. The image is therefore the same for every +# deployment, and a URL changes without a rebuild. See +# packages/wallet/frontend/src/lib/runtimeConfig.ts. ENV NODE_ENV=production -ENV PORT=$PORT \ - COOKIE_NAME=$COOKIE_NAME \ - NEXT_PUBLIC_BACKEND_URL=$NEXT_PUBLIC_BACKEND_URL \ - NEXT_PUBLIC_OPEN_PAYMENTS_HOST=$NEXT_PUBLIC_OPEN_PAYMENTS_HOST \ - NEXT_PUBLIC_AUTH_HOST=$NEXT_PUBLIC_AUTH_HOST \ - NEXT_PUBLIC_GATEHUB_ENV=$NEXT_PUBLIC_GATEHUB_ENV \ - NEXT_PUBLIC_THEME=$NEXT_PUBLIC_THEME RUN pnpm wallet:frontend build @@ -54,22 +45,7 @@ FROM node:24-alpine AS runner WORKDIR /testnet -ARG PORT -ARG COOKIE_NAME -ARG NEXT_PUBLIC_BACKEND_URL -ARG NEXT_PUBLIC_OPEN_PAYMENTS_HOST -ARG NEXT_PUBLIC_AUTH_HOST -ARG NEXT_PUBLIC_GATEHUB_ENV -ARG NEXT_PUBLIC_THEME - ENV NODE_ENV=production -ENV PORT=$PORT \ - COOKIE_NAME=$COOKIE_NAME \ - NEXT_PUBLIC_BACKEND_URL=$NEXT_PUBLIC_BACKEND_URL \ - NEXT_PUBLIC_OPEN_PAYMENTS_HOST=$NEXT_PUBLIC_OPEN_PAYMENTS_HOST \ - NEXT_PUBLIC_AUTH_HOST=$NEXT_PUBLIC_AUTH_HOST \ - NEXT_PUBLIC_GATEHUB_ENV=$NEXT_PUBLIC_GATEHUB_ENV \ - NEXT_PUBLIC_THEME=$NEXT_PUBLIC_THEME COPY --from=builder /testnet/packages/wallet/frontend/.next/standalone ./ COPY --from=builder /testnet/packages/wallet/frontend/public ./packages/wallet/frontend/public @@ -78,4 +54,9 @@ COPY --from=builder /testnet/packages/wallet/frontend/.next/static ./packages/wa COPY --from=builder /testnet/packages/wallet/shared/dist ./packages/wallet/shared/dist COPY --from=builder /testnet/packages/wallet/shared/package.json ./packages/wallet/shared/package.json -CMD ["node", "packages/wallet/frontend/server.js"] +# Sits next to the generated server.js and requires it. It checks that the +# deployment supplied the variables the frontend cannot run without, so a pod +# with an incomplete ConfigMap fails to start instead of serving broken links. +COPY --from=builder /testnet/packages/wallet/frontend/scripts/start.js ./packages/wallet/frontend/start.js + +CMD ["node", "packages/wallet/frontend/start.js"] diff --git a/packages/wallet/frontend/next.config.js b/packages/wallet/frontend/next.config.js index 40205174b..b6c1579e1 100644 --- a/packages/wallet/frontend/next.config.js +++ b/packages/wallet/frontend/next.config.js @@ -2,27 +2,17 @@ const withBundleAnalyzer = require('@next/bundle-analyzer')({ enabled: process.env.ANALYZE === 'true' }) -if (!process.env.NEXT_PUBLIC_BACKEND_URL) { - throw new Error( - 'Missing required environment variable: NEXT_PUBLIC_BACKEND_URL' - ) -} - -// Default to env override; fall back to previous production/sandbox rule, then to 'true' -let NEXT_PUBLIC_FEATURES_ENABLED = process.env.NEXT_PUBLIC_FEATURES_ENABLED - -// This is a gaurdrail to prevent accidentally enabling features in production when the -// env variable is not set. -if (!NEXT_PUBLIC_FEATURES_ENABLED) { - if ( - process.env.NODE_ENV === 'production' && - process.env.NEXT_PUBLIC_GATEHUB_ENV === 'sandbox' - ) { - NEXT_PUBLIC_FEATURES_ENABLED = 'false' - } else { - NEXT_PUBLIC_FEATURES_ENABLED = 'true' - } -} +// This file deliberately declares no `env` and no `publicRuntimeConfig`. +// +// Both are resolved when the image is built. `env` is inlined into the bundles +// by webpack, and with `output: 'standalone'` the whole resolved config — +// `publicRuntimeConfig` included — is serialised into the generated +// `server.js`. Either one would tie a URL to an image tag again. +// +// The frontend reads its configuration from `process.env` while it serves a +// request instead. See `src/lib/runtimeConfig.ts`, and +// `scripts/start.js` for the check that runs before the server accepts +// traffic. /** @type {import('next').NextConfig} */ const nextConfig = { @@ -30,15 +20,7 @@ const nextConfig = { poweredByHeader: false, // ESLint 9.x removed options (useEslintrc, extensions) that Next.js 14 // passes internally. Linting is handled separately via `pnpm lint:check`. - eslint: { ignoreDuringBuilds: true }, - env: { - NEXT_PUBLIC_BACKEND_URL: process.env.NEXT_PUBLIC_BACKEND_URL, - NEXT_PUBLIC_OPEN_PAYMENTS_HOST: process.env.NEXT_PUBLIC_OPEN_PAYMENTS_HOST, - NEXT_PUBLIC_AUTH_HOST: process.env.NEXT_PUBLIC_AUTH_HOST, - NEXT_PUBLIC_THEME: process.env.NEXT_PUBLIC_THEME || 'light', - NEXT_PUBLIC_GATEHUB_ENV: process.env.NEXT_PUBLIC_GATEHUB_ENV || 'sandbox', - NEXT_PUBLIC_FEATURES_ENABLED - } + eslint: { ignoreDuringBuilds: true } } module.exports = withBundleAnalyzer(nextConfig) diff --git a/packages/wallet/frontend/scripts/start.js b/packages/wallet/frontend/scripts/start.js new file mode 100644 index 000000000..7c91aa859 --- /dev/null +++ b/packages/wallet/frontend/scripts/start.js @@ -0,0 +1,38 @@ +/** + * Entry point for the production image. + * + * The frontend reads its URLs from the environment when it serves a request, + * so a missing variable is a deployment error, not a build error. This check + * turns that error into a pod that fails to start with a readable reason. The + * alternative is a pod that starts and serves `undefined` inside every link. + * + * Run before `server.js`, which is the file `next build` generates for + * `output: 'standalone'`. + */ + +// Read by the browser, through the script `_document.tsx` writes into the page. +const REQUIRED = ['BACKEND_URL', 'OPEN_PAYMENTS_HOST', 'AUTH_HOST'] + +const missing = REQUIRED.filter((name) => !process.env[name]) + +if (missing.length > 0) { + console.error( + `[start] Refusing to start. These environment variables are not set: ${missing.join(', ')}.\n` + + '[start] The Helm chart sets them from `config.frontend.urls` in the values file.' + ) + process.exit(1) +} + +// A path means "the same origin as the page". The browser resolves it, the +// server cannot, so the server needs an absolute address of its own. +if ( + process.env.BACKEND_URL.startsWith('/') && + !process.env.BACKEND_INTERNAL_URL +) { + console.error( + `[start] Refusing to start. BACKEND_URL is a path (${process.env.BACKEND_URL}), so BACKEND_INTERNAL_URL must hold the absolute in-cluster address of the backend Service.` + ) + process.exit(1) +} + +require('./server.js') diff --git a/packages/wallet/frontend/src/lib/httpClient.ts b/packages/wallet/frontend/src/lib/httpClient.ts index 11f746210..ebaf2e009 100644 --- a/packages/wallet/frontend/src/lib/httpClient.ts +++ b/packages/wallet/frontend/src/lib/httpClient.ts @@ -1,5 +1,7 @@ import ky, { HTTPError } from 'ky' +import { getRuntimeConfig, getServerBackendUrl } from '@/lib/runtimeConfig' + import type { FieldPath, FieldValues } from 'react-hook-form' const requestStartTimes = new WeakMap() @@ -34,13 +36,12 @@ export type ErrorResponse = { errors?: T extends FieldValues ? Record, string> : undefined } -// Use internal backend URL when running on the server (SSR/middleware) +// The server and the browser reach the backend at different addresses. The +// server prefers the in-cluster Service, which keeps the traffic inside the +// cluster and works even when the browser-facing value is a bare path. The +// browser uses the value `_document.tsx` wrote into the page. const isServer = typeof window === 'undefined' -const baseUrl = isServer - ? process.env.BACKEND_INTERNAL_URL || - process.env.BACKEND_URL || - process.env.NEXT_PUBLIC_BACKEND_URL - : process.env.NEXT_PUBLIC_BACKEND_URL +const baseUrl = isServer ? getServerBackendUrl() : getRuntimeConfig().backendUrl export const httpClient = ky.extend({ prefixUrl: baseUrl, diff --git a/packages/wallet/frontend/src/lib/runtimeConfig.ts b/packages/wallet/frontend/src/lib/runtimeConfig.ts new file mode 100644 index 000000000..a20f7d1a7 --- /dev/null +++ b/packages/wallet/frontend/src/lib/runtimeConfig.ts @@ -0,0 +1,128 @@ +/** + * Runtime configuration for the wallet frontend. + * + * Every value here is read from the container environment when a request is + * served, not when the image is built. A deployment changes a URL by editing + * the ConfigMap and restarting the pod. The image never has to be rebuilt. + * + * Why these names carry no `NEXT_PUBLIC_` prefix + * --------------------------------------------- + * Next.js replaces every `process.env.NEXT_PUBLIC_*` expression with a string + * literal at build time, in the client bundle and in the server bundle. A + * prefixed name can therefore never change after `next build`. These names are + * invisible to that substitution, so the server reads the real environment. + * + * The same applies to the `env` key in `next.config.js`, which is why that key + * is gone. With `output: 'standalone'` the whole resolved config is serialised + * into the generated `server.js`, so `publicRuntimeConfig` would freeze at + * build time as well. Reading `process.env` directly is the only mechanism + * that survives a standalone build. + * + * How a value reaches the browser + * ------------------------------- + * The server reads `process.env`. `_document.tsx` writes the result into the + * HTML of every response, and the browser reads it back from + * `window.__WALLET_RUNTIME_CONFIG__`. + * + * This works only while every page is server rendered. `_app.tsx` defines + * `getInitialProps` for that reason: it turns off Automatic Static + * Optimization, which would otherwise bake the HTML of the pages that have no + * `getServerSideProps` — `/404`, `/no-access` and `/auth/*` — at build time. + */ + +export const RUNTIME_CONFIG_WINDOW_KEY = '__WALLET_RUNTIME_CONFIG__' + +export type RuntimeConfig = { + /** + * The address the browser uses for the wallet backend. + * + * This may be an absolute URL (`https://api.example.dev`) when the backend + * has a hostname of its own, or a path (`/wallet-api`) when one hostname + * serves the frontend and the backend. A path needs no change when DNS + * changes, so prefer it wherever the ingress routes both on one host. + * + * Server-side code must not use a path. Use `getServerBackendUrl()`, which + * prefers the in-cluster address. + */ + backendUrl: string + openPaymentsHost: string + authHost: string + theme: string + gatehubEnv: string + featuresEnabled: boolean +} + +const EMPTY_CONFIG: RuntimeConfig = { + backendUrl: '', + openPaymentsHost: '', + authHost: '', + theme: 'light', + gatehubEnv: 'sandbox', + featuresEnabled: false +} + +/** + * The guardrail that used to live in `next.config.js`. It stops the public + * features turning themselves on in a sandbox environment when nobody sets + * `FEATURES_ENABLED`. + */ +const resolveFeaturesEnabled = (gatehubEnv: string): boolean => { + const explicit = process.env.FEATURES_ENABLED + + if (explicit) { + return explicit === 'true' + } + + return !(process.env.NODE_ENV === 'production' && gatehubEnv === 'sandbox') +} + +/** + * Reads the configuration from the environment. Server and Edge runtimes only. + * On the client every value is empty, because the browser has no `process.env` + * and Next.js inlines none of these names. + */ +export const readRuntimeConfigFromEnv = (): RuntimeConfig => { + const gatehubEnv = process.env.GATEHUB_ENV || 'sandbox' + + return { + backendUrl: process.env.BACKEND_URL ?? '', + openPaymentsHost: process.env.OPEN_PAYMENTS_HOST ?? '', + authHost: process.env.AUTH_HOST ?? '', + theme: process.env.THEME || 'light', + gatehubEnv, + featuresEnabled: resolveFeaturesEnabled(gatehubEnv) + } +} + +/** + * The address server-side code uses for the wallet backend. + * + * `BACKEND_INTERNAL_URL` keeps this traffic inside the cluster, and it is + * required when `BACKEND_URL` is a path: server-side `fetch` cannot resolve a + * path against an origin it does not have. + */ +export const getServerBackendUrl = (): string => + process.env.BACKEND_INTERNAL_URL || process.env.BACKEND_URL || '' + +export const getRuntimeConfig = (): RuntimeConfig => { + if (typeof window === 'undefined') { + return readRuntimeConfigFromEnv() + } + + const injected = ( + window as unknown as Record + )[RUNTIME_CONFIG_WINDOW_KEY] + + if (injected) { + return injected + } + + // Reached only if a page was served from HTML that `next build` produced, + // which means Automatic Static Optimization came back on. See the note about + // `_app.tsx` above. + console.error( + `[runtimeConfig] window.${RUNTIME_CONFIG_WINDOW_KEY} is missing. This page was not server rendered, so it has no configuration.` + ) + + return EMPTY_CONFIG +} diff --git a/packages/wallet/frontend/src/lib/socket.ts b/packages/wallet/frontend/src/lib/socket.ts new file mode 100644 index 000000000..522f69967 --- /dev/null +++ b/packages/wallet/frontend/src/lib/socket.ts @@ -0,0 +1,60 @@ +import { io, type Socket } from 'socket.io-client' + +import { BACKEND_URL } from '@/utils/constants' + +const stripTrailingSlash = (path: string): string => + path === '/' ? '' : path.replace(/\/$/, '') + +/** + * Splits the browser-facing backend address into the parts socket.io needs. + * + * An empty value and a value that is only a path both mean "the same origin as + * this page". + */ +const splitBackendUrl = ( + backendUrl: string +): { origin?: string; prefix: string } => { + if (!backendUrl) { + return { prefix: '' } + } + + if (backendUrl.startsWith('/')) { + return { prefix: stripTrailingSlash(backendUrl) } + } + + try { + const url = new URL(backendUrl) + return { origin: url.origin, prefix: stripTrailingSlash(url.pathname) } + } catch { + console.error( + `[socket] BACKEND_URL is neither a path nor an absolute URL: ${backendUrl}` + ) + return { prefix: '' } + } +} + +/** + * Opens the socket.io connection to the wallet backend. + * + * socket.io does not read a URL the way `fetch` does. `io('https://host/wallet-api')` + * sets the origin to `https://host` and treats `/wallet-api` as a *namespace*, + * while the transport still polls the default path, `/socket.io`. Where one + * hostname serves both halves of the wallet, `/socket.io` routes to the + * frontend, so the connection never reaches the backend and the client retries + * forever. + * + * The prefix belongs in `path`. The browser then polls `/wallet-api/socket.io`, + * the ingress strips `/wallet-api`, and the backend sees the default path its + * socket.io server is mounted on. + */ +export const connectToBackend = (): Socket => { + const { origin, prefix } = splitBackendUrl(BACKEND_URL) + + const options = { + path: `${prefix}/socket.io`, + withCredentials: true, + transports: ['websocket', 'polling'] + } + + return origin ? io(origin, options) : io(options) +} diff --git a/packages/wallet/frontend/src/pages/_app.tsx b/packages/wallet/frontend/src/pages/_app.tsx index ea9921e6d..3ce21b3cd 100644 --- a/packages/wallet/frontend/src/pages/_app.tsx +++ b/packages/wallet/frontend/src/pages/_app.tsx @@ -5,7 +5,9 @@ import { AppProvider } from '@/components/providers' import { Progress } from '@/ui/Progress' import type { AppPropsWithLayout } from '@/lib/types/app' import { MoneyBird } from '@/components/icons/MoneyBird' -import { io, Socket } from 'socket.io-client' +import type { Socket } from 'socket.io-client' +import NextApp, { type AppContext, type AppInitialProps } from 'next/app' +import { connectToBackend } from '@/lib/socket' import { useEffect } from 'react' import { updateBalance } from '@/lib/balance' import { formatAmount } from '@/utils/helpers' @@ -35,10 +37,7 @@ export default function App({ Component, pageProps }: AppPropsWithLayout) { useEffect(() => { let socket: Socket | null = null // Connect to the Socket.IO server - socket = io(process.env.NEXT_PUBLIC_BACKEND_URL ?? '', { - withCredentials: true, - transports: ['websocket', 'polling'] - }) + socket = connectToBackend() // Event listeners socket?.on('MONEY_RECEIVED', (account, amount) => { @@ -153,3 +152,16 @@ export default function App({ Component, pageProps }: AppPropsWithLayout) { ) } + +/** + * Defined only for its side effect: a custom App with `getInitialProps` turns + * off Automatic Static Optimization, so Next.js server renders every page. + * + * The runtime configuration depends on this. `_document.tsx` reads the pod + * environment while it renders, and a page whose HTML came from `next build` + * would carry the values of the build machine instead. `/404`, `/no-access` + * and the `/auth/*` pages have no `getServerSideProps`, so they are the ones + * this protects. + */ +App.getInitialProps = async (context: AppContext): Promise => + await NextApp.getInitialProps(context) diff --git a/packages/wallet/frontend/src/pages/_document.tsx b/packages/wallet/frontend/src/pages/_document.tsx index 8cf0e94a9..8eecbf79f 100644 --- a/packages/wallet/frontend/src/pages/_document.tsx +++ b/packages/wallet/frontend/src/pages/_document.tsx @@ -1,15 +1,40 @@ -import { THEME } from '@/utils/constants' +import { + RUNTIME_CONFIG_WINDOW_KEY, + readRuntimeConfigFromEnv +} from '@/lib/runtimeConfig' import { Html, Head, Main, NextScript } from 'next/document' export default function Document() { + // This runs on the server for every request, so it reads the environment the + // pod runs with. `_app.tsx` keeps it that way by turning off Automatic + // Static Optimization. + const runtimeConfig = readRuntimeConfigFromEnv() + + // `` inside a string would close this tag early. Escaping `<` is + // the standard defence, and it keeps the JSON valid. + const serialisedConfig = JSON.stringify(runtimeConfig).replace( + / + {/* + Written ahead of
and so the value is present + before any application module reads it. + */} + ` inside a string would close this tag early. Escaping `<` is - // the standard defence, and it keeps the JSON valid. + // `` inside a string would close the tag early. const serialisedConfig = JSON.stringify(runtimeConfig).replace( / - {/* - Written ahead of
and so the value is present - before any application module reads it. - */} + {/* Ahead of
and , so it runs first. */}