diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cc6f61e8c..6e4d92e73 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -140,26 +140,24 @@ jobs: - name: test-wallet-backend identifier: 'wallet:backend' path: packages/wallet/backend + # No build configuration: every URL, the theme and the feature flags + # are read from the container environment per request, 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, still published so the + # interledger.cards deployments keep resolving the name they + # reference. Retire once they repoint. + # + # This image no longer carries baked URLs. Any deployment on either + # name must supply BACKEND_URL, OPEN_PAYMENTS_HOST and AUTH_HOST, or + # the container exits at start-up naming what is missing. The Helm + # chart sets them; a deployment pinned to an older chart, which wrote + # `NEXT_PUBLIC_*` keys, must be upgraded with the image. - 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 +215,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..06e8eb1a6 100644 --- a/helm/testnet-wallet/README.md +++ b/helm/testnet-wallet/README.md @@ -21,20 +21,36 @@ 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** + +Read per request, never baked into the image: to move the wallet to a different +hostname, change the value and restart the pod. The names carry no +`NEXT_PUBLIC_` prefix because Next.js replaces those with literals at build +time. + +| Key | `values.yaml` path | +| ---------------------- | --------------------------------------- | +| `NODE_ENV` | `config.frontend.nodeEnv` | +| `PORT` | `config.frontend.port` | +| `COOKIE_NAME` | `config.frontend.cookie.name` | +| `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 absolute, 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. When it is a path, set `BACKEND_INTERNAL_URL` to the +in-cluster Service address: SSR has no origin to resolve against, and the +container refuses to start without it. + +`BACKEND_URL`, `OPEN_PAYMENTS_HOST` and `AUTH_HOST` are required. The container +checks them before accepting traffic and exits if one is missing. It also +rejects a `BACKEND_URL` or `BACKEND_INTERNAL_URL` that is neither an absolute +http(s) URL nor, for `BACKEND_URL`, a path beginning with `/`. **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..0b15d3221 --- /dev/null +++ b/helm/testnet-wallet/tests/configMap.frontend_test.yaml @@ -0,0 +1,80 @@ +# The key names are a contract with +# 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. +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 build-time names. The container does not look for them. + # USE_TEST_KYC_DATA is gone in both spellings: the frontend stopped reading it + # in #1643, when GateHub KYC replaced Rapyd. + - 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.USE_TEST_KYC_DATA + + # A path means the page origin, so the server needs the in-cluster address. + - 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..41e32d155 100644 --- a/helm/testnet-wallet/values.yaml +++ b/helm/testnet-wallet/values.yaml @@ -11,7 +11,18 @@ config: theme: 'dark' urls: + # The backend address the browser uses. Absolute where the backend has + # its own hostname. Where one hostname serves the frontend at `/` and the + # backend at a prefix, use a path (`/wallet-api`): it follows the host + # that served the page, so it survives a DNS change. Set + # `backendInternal` too, because SSR cannot resolve a path. backend: 'https://api.interledger-test.dev' + + # The backend address the frontend container uses. Point it at the + # in-cluster Service to keep SSR traffic inside the cluster. Required + # when `backend` is a path; empty reuses `backend`. + backendInternal: '' + openPaymentsHost: 'https://ilp.interledger-test.dev' authHost: 'https://auth.interledger-test.dev' @@ -19,7 +30,6 @@ config: name: 'testnet.cookie' features: - useTestKycData: true # Public features are intended for the production interledger.cards # environment only enabled: false @@ -416,6 +426,10 @@ services: # This section of the values file defines Kubernetes config maps that will be created # for each service (backend and frontend). configMaps: + # Read per request, never baked into the image: change one, restart the pod. + # The names carry no `NEXT_PUBLIC_` prefix because Next.js replaces those with + # literals at build time. See + # packages/wallet/frontend/src/lib/runtimeConfig.ts. frontend: name: 'frontend' contentMap: @@ -425,19 +439,19 @@ configMaps: valueRef: config.frontend.port - key: COOKIE_NAME valueRef: config.frontend.cookie.name - - key: NEXT_PUBLIC_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..bfa9260d1 100644 --- a/packages/wallet/frontend/.env.example +++ b/packages/wallet/frontend/.env.example @@ -1,30 +1,45 @@ -# 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 to .env.local and adjust. 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, a production image on the next pod restart. +# +# None of these names carries a `NEXT_PUBLIC_` prefix, and none should — Next.js +# replaces those with literals at build time, 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, skipping Traefik. 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; the browser reads them back. + +# The backend address the browser uses. Absolute where the backend has its own +# hostname, which is the local setup. Where one hostname serves the frontend at +# / and the backend at a prefix, use the path (BACKEND_URL=/wallet-api): it +# follows whatever host served the page, so it survives a DNS change. +# 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..3197db57a 100644 --- a/packages/wallet/frontend/.env.local +++ b/packages/wallet/frontend/.env.local @@ -1,8 +1,13 @@ COOKIE_NAME=testnet.cookie + +# Separate hosts locally, so the browser needs the absolute address. +BACKEND_URL=https://api.testnet.test + +# SSR and the middleware go direct, skipping Traefik's 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..87a52a114 100644 --- a/packages/wallet/frontend/Dockerfile.dev +++ b/packages/wallet/frontend/Dockerfile.dev @@ -23,20 +23,7 @@ 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 +# No build arguments: configuration is read per request, so pass it 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..7b34a708e 100644 --- a/packages/wallet/frontend/Dockerfile.prod +++ b/packages/wallet/frontend/Dockerfile.prod @@ -31,22 +31,10 @@ 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 per request, so one image serves every +# deployment. 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 +42,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 +51,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"] +# Requires the generated server.js, after checking the variables the frontend +# cannot run without. An incomplete ConfigMap fails the pod 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..388ecee4c 100644 --- a/packages/wallet/frontend/next.config.js +++ b/packages/wallet/frontend/next.config.js @@ -2,27 +2,10 @@ 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' - } -} +// No `env` and no `publicRuntimeConfig` on purpose: both resolve at build time, +// which is what tied a deployment URL to an image tag. The frontend reads +// `process.env` per request instead — see src/lib/runtimeConfig.ts, and +// scripts/start.js for the start-up check. /** @type {import('next').NextConfig} */ const nextConfig = { @@ -30,15 +13,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..f1cf0f1aa --- /dev/null +++ b/packages/wallet/frontend/scripts/start.js @@ -0,0 +1,68 @@ +/** + * Entry point for the production image. A missing or malformed URL is a + * deployment error, so fail here with a readable reason rather than serving + * `undefined` inside every link. Runs before the `server.js` that `next build` + * generates. + */ + +const REQUIRED = ['BACKEND_URL', 'OPEN_PAYMENTS_HOST', 'AUTH_HOST'] + +const refuse = (reason) => { + console.error(`[start] Refusing to start. ${reason}`) + console.error( + '[start] The Helm chart sets these from `config.frontend.urls` in the values file.' + ) + process.exit(1) +} + +const missing = REQUIRED.filter((name) => !process.env[name]) + +if (missing.length > 0) { + refuse(`These environment variables are not set: ${missing.join(', ')}.`) +} + +const isHttpUrl = (value) => { + try { + const { protocol } = new URL(value) + return protocol === 'http:' || protocol === 'https:' + } catch { + return false + } +} + +// A leading `//` is a protocol-relative URL pointing at another origin, not a +// path on this one. +const isOriginPath = (value) => value.startsWith('/') && !value.startsWith('//') + +const backendUrl = process.env.BACKEND_URL + +// Anything else — `api.example.test`, with no scheme — starts cleanly and then +// fails at the first request, in the browser and in SSR alike. +if (!isHttpUrl(backendUrl) && !isOriginPath(backendUrl)) { + refuse( + `BACKEND_URL is neither an absolute http(s) URL nor a path beginning with "/": ${backendUrl}` + ) +} + +// A path resolves against the page origin, which the server does not have. +if (isOriginPath(backendUrl) && !process.env.BACKEND_INTERNAL_URL) { + refuse( + `BACKEND_URL is a path (${backendUrl}), so BACKEND_INTERNAL_URL must hold the absolute in-cluster address of the backend Service.` + ) +} + +// Server-side only, so it is always absolute. +if ( + process.env.BACKEND_INTERNAL_URL && + !isHttpUrl(process.env.BACKEND_INTERNAL_URL) +) { + refuse( + `BACKEND_INTERNAL_URL is not an absolute http(s) URL: ${process.env.BACKEND_INTERNAL_URL}` + ) +} + +// AUTH_HOST and OPEN_PAYMENTS_HOST are checked for presence only. +// OPEN_PAYMENTS_HOST is displayed as a wallet-address prefix and is a payment +// pointer in some environments (`$ilp.example.dev/`), not a URL. + +require('./server.js') diff --git a/packages/wallet/frontend/src/lib/httpClient.ts b/packages/wallet/frontend/src/lib/httpClient.ts index 11f746210..4003664c0 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,10 @@ export type ErrorResponse = { errors?: T extends FieldValues ? Record, string> : undefined } -// Use internal backend URL when running on the server (SSR/middleware) +// The server prefers the in-cluster Service, which also works when the +// browser-facing value is a bare path. 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..64d6541a9 --- /dev/null +++ b/packages/wallet/frontend/src/lib/runtimeConfig.ts @@ -0,0 +1,94 @@ +/** + * Configuration read from the container environment per request, not baked in + * at build time. A deployment changes a URL by editing the ConfigMap and + * restarting the pod. + * + * The names carry no `NEXT_PUBLIC_` prefix because Next.js replaces those with + * literals at build time. The same goes for `next.config.js`'s `env` key, and + * for `publicRuntimeConfig` under `output: 'standalone'`, which serialises the + * resolved config into the generated `server.js`. + * + * The server reads `process.env`; `_document.tsx` writes the result into every + * response and the browser reads it back. That needs every page server + * rendered, which is why `_app.tsx` defines `getInitialProps`. + */ + +export const RUNTIME_CONFIG_WINDOW_KEY = '__WALLET_RUNTIME_CONFIG__' + +export type RuntimeConfig = { + /** + * The backend address the browser uses. Either absolute, or a path such as + * `/wallet-api` where one hostname serves both halves of the wallet. Server + * code must use `getServerBackendUrl()`, which cannot resolve a path. + */ + 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 +} + +// Guardrail moved from next.config.js: public features stay off in a sandbox +// unless something 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') +} + +/** Server and Edge runtimes only. Every value is empty in the browser. */ +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 backend address server-side code uses. `BACKEND_INTERNAL_URL` keeps the + * traffic in-cluster, and is required when `BACKEND_URL` is a path. + */ +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 + } + + // Only reachable if the page came from build-time HTML, which means static + // optimization is back on. + 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..731ad8284 --- /dev/null +++ b/packages/wallet/frontend/src/lib/socket.ts @@ -0,0 +1,47 @@ +import { io, type Socket } from 'socket.io-client' + +import { BACKEND_URL } from '@/utils/constants' + +const stripTrailingSlash = (path: string): string => + path === '/' ? '' : path.replace(/\/$/, '') + +/** An empty value or a bare path both mean the origin that served the 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: '' } + } +} + +/** + * socket.io does not read a URL the way `fetch` does: + * `io('https://host/wallet-api')` treats `/wallet-api` as a namespace and still + * polls the default `/socket.io` path, which routes to the frontend where one + * hostname serves both halves. The prefix belongs in `path` instead. + */ +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..73ee05a2f 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,12 @@ export default function App({ Component, pageProps }: AppPropsWithLayout) { ) } + +/** + * Defined only for its side effect: it turns off Automatic Static Optimization + * so every page is server rendered, and `_document.tsx` can read the pod + * environment. Protects `/404`, `/no-access` and `/auth/*`, which have no + * `getServerSideProps`. + */ +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..acc0601bf 100644 --- a/packages/wallet/frontend/src/pages/_document.tsx +++ b/packages/wallet/frontend/src/pages/_document.tsx @@ -1,15 +1,34 @@ -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() { + // Runs on the server per request, so it reads the pod environment. + const runtimeConfig = readRuntimeConfigFromEnv() + + // `` inside a string would close the tag early. + const serialisedConfig = JSON.stringify(runtimeConfig).replace( + / + {/* Ahead of
and , so it runs first. */} +