feat(frontend): runtime SPA config via /config.js (build-time -> runtime) - #1748
Conversation
…ime) Read app config at runtime from window.__APP_CONFIG__ (injected by /config.js, loaded before the bundle) with fallback to import.meta.env for dev/tests, so one built bundle runs unchanged in every environment. Adds a per-env config.js: uploaded by the S3/CloudFront deploy, or generated from APP_* pod env by the nginx entrypoint on EKS. Migrates all import.meta.env.VITE_* app-config call-sites to the new config accessor; builds env-agnostic (drops --mode stage). Prerequisite for the Appointment-on-EKS matched-set promotion pipeline (thunderbird/platform-infrastructure#779). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ds compatible) The staging + release(prod) frontend builds now generate dist/config.js from the same .env.stage/.env.prod values (via scripts/env-to-config-js.mjs) before the existing `aws s3 sync`. This exercises the runtime-config path in the existing CloudFront/S3 environments without breaking them: the --mode build is retained, so the bundle also keeps the baked values as a fallback. No change to deploy-production.yml (it syncs the dist that now contains config.js). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… untouched Add a decoupled publish-images.yml (+ reusable build-multiarch-image.yml, mirroring thunderbird-accounts) that builds the backend and frontend (nginx SPA) images as native amd64+arm64 manifest lists and pushes them to GHCR (:<sha>/:v<version>/:latest) via GITHUB_TOKEN -- no cloud creds, no Pulumi, no S3/ECS. This is the image source for the EKS/Kargo path. Revert the earlier deploy-staging.yml change and drop scripts/env-to-config-js.mjs: the existing env-specific S3/CloudFront + ECS builds stay untouched. The SPA source change is backwards compatible on its own -- with an empty /config.js the runtime accessor falls back to the --mode-baked import.meta.env values, so the existing deploy is unaffected. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ld, guards) Applied after a 20-lens parallel review of this PR: - deploy.dockerfile: Node 18 -> 22 (Vite 8 needs >=20.19; build was broken), add jq, npm install -> npm ci. - entrypoint: build config.js with jq (JSON-safe; a quote/backslash/newline in an APP_* value can no longer emit invalid JS); parameterize the nginx API upstream via APP_API_UPSTREAM (127.0.0.1:5000 default is a sidecar/dev-only fallback and won't reach a separate backend Service on EKS); secret banner. - config.ts: add assertConfigured() (fail loud at boot when unconfigured -- the container path has no baked fallback); correct comments (S3 stays --mode-baked, only the container bundle is byte-identical); main.ts calls it. - config.test.ts: table-driven test over all 15 keys (runtime-wins + own-env-var fallback) to guard the near-identical getters against copy-paste/rebase errors. - publish-images.yml: build BOTH images every push so both carry the same :<sha> (matched-set invariant; drop the path-filter); harden the jq version read. - build-multiarch-image.yml: gate moving tags (:latest/:v) behind moving-tags (main only), and assert exactly 2 arch digests before publishing (arm64 guard). - public/config.js: secret banner; accurate backwards-compat comment. 97 tests pass, lint clean, build green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Without it, `COPY . /build/frontend` drags the host node_modules/dist into the deploy.dockerfile build -- bloating the image, risking cross-arch native binary contamination, and desyncing `npm ci`. Excludes node_modules, dist, .git, logs, coverage, and .env (keeping *.example). Flagged by the 20-lens review. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
| RUN mkdir -p /etc/apt/keyrings | ||
| RUN curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg | ||
| RUN echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_18.x nodistro main" | tee /etc/apt/sources.list.d/nodesource.list | ||
| RUN echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_22.x nodistro main" | tee /etc/apt/sources.list.d/nodesource.list |
There was a problem hiding this comment.
Just double checking with @rwood-moz around the node version & browserstack as that's my main concern about this.
There was a problem hiding this comment.
Thanks @davinotdavid! Clarifications on both fronts:
Node version — this deploy.dockerfile is the new, additive image for the EKS/GHCR path (built by publish-images.yml); it isn't used by the current stage/prod deploy, which builds the frontend on the CI runner's Node (setup-node → Node 24). So the Node here only affects the static Vite build — nginx serves the built assets at runtime, no Node involved. I've bumped it 22 → 24 to match CI: it's actually required, because Node 22's npm 10 rejects this lockfileVersion: 3 lockfile as out-of-sync (npm ci EUSAGE), while npm 11 (Node 24) installs it cleanly. Verified the image builds + serves locally.
Browserstack — the stage e2e run against the deployed stage app in real browsers and are unaffected: the existing stage deploy is untouched (still npm run build -- --mode), and the SPA runtime-config change is backwards-compatible — with the committed empty public/config.js, the app falls back to the --mode-baked import.meta.env values exactly as before. The GHCR publish is decoupled and doesn't touch the stage pipeline.
Happy to walk through it with you and @rwood-moz if that helps.
There was a problem hiding this comment.
@rwood-moz — flagging you directly per David's note. The two questions are (1) the Node version (now node_24.x, matching CI, and only for this new additive EKS/GHCR image — the current stage/prod build is untouched) and (2) browserstack (stage e2e run against the deployed app and are unaffected, since the SPA change is backwards-compatible and the GHCR publish is decoupled from the stage pipeline). Would appreciate your read on whether that puts your concern to rest.
…pm ci) Local container testing revealed the deploy image failed to build on Node 22: its npm 10 rejects this lockfileVersion-3 package-lock.json as out-of-sync (`npm ci` EUSAGE), while npm 11 (Node 24, matching CI) installs it cleanly. The GHCR publish (publish-images.yml builds via this Dockerfile) would have failed on merge to main -- the PR checks don't exercise it. Bump to node_24.x. Verified end-to-end: image builds; the entrypoint generates a JSON-safe config.js from APP_* (a quote in a value is correctly escaped -> valid JS); /config.js loads before the deferred module bundle; the SPA serves with client-side-route fallback; and APP_API_UPSTREAM rewrites the nginx proxy target. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
davinotdavid
left a comment
There was a problem hiding this comment.
lgtm but honestly I am not super familiar with the intricacies of the change when it touches the infra / devops land so I'd say let's give it a try!
reviewing the code diff seems correct / reasonable to me and running it locally didn't produce any errors on my end
|
Post-merge on |
Nice! Yeah when running on BrowserStack we don't need to install the playwright browsers, that's where the issue is with Node 24 and Playwright 1.59. |
Summary
Parameterize the SPA so one built bundle runs unchanged in every environment, configured at runtime instead of build time, and add a decoupled multi-arch GHCR image publish for the EKS/Kargo path. Prerequisite for the Appointment-on-EKS matched-set promotion pipeline (thunderbird/platform-infrastructure#779, design in
docs/superpowers/specs/2026-07-20-appointment-eks-kargo-promotion-pipeline-design.md).Design principle: additive, existing pipeline untouched
The existing env-specific ECS + S3/CloudFront deploy (
deploy-staging.yml/deploy-production.yml) is left untouched. The SPA source change is backwards compatible on its own: with an empty/config.js, the runtime accessor falls back to the--mode-bakedimport.meta.envvalues, so the existing S3 deploy behaves exactly as before. The EKS path gets images from a separate, additive GHCR publish workflow (no cloud creds, no Pulumi, no S3/ECS).Runtime config mechanism
src/config.ts— typed accessor: readswindow.__APP_CONFIG__, falls back toimport.meta.env(dev/tests/existing-S3 keep working).window.__APP_CONFIG__is set by/config.js, loaded synchronously inindex.htmlbefore the bundle./config.jsis supplied per environment at run time, not rebuilt: the EKS nginx entrypoint (docker/docker-entrypoint.d/40-appointment-config.sh) generates it fromAPP_*pod env. (Existing S3 keeps baking values via--mode; the emptypublic/config.jsfalls through to them.)Additive GHCR image publish
publish-images.yml— on merge to main, builds the backend and frontend (nginx SPA) as native amd64+arm64 manifest lists → GHCR (ghcr.io/thunderbird/thunderbird-appointment[-frontend]), tagged:<sha>/:v<version>/:latest, viaGITHUB_TOKEN. Path-filtered so each image builds only when its dir changes.build-multiarch-image.yml— reusable builder (native runners, push-by-digest,imagetools createmanifest list), mirroring thunderbird-accounts.Changes
src/config.ts+test/config.test.ts(new);public/config.js(new);index.htmlloads it.docker/docker-entrypoint.d/40-appointment-config.sh(new);deploy.dockerfilebuilds env-agnostic + wires the entrypoint.import.meta.env.VITE_*app-config call-sites toconfig(main.ts, App.vue, router.ts, utils.ts, 3 composables).MODE/BASE_URLleft as Vite built-ins..github/workflows/{publish-images,build-multiarch-image}.yml(new). No change todeploy-staging.yml/deploy-production.yml.Verified
npm run lintclean,npm run test97 pass,npm run buildgreen, both new workflows parse. Local container test (builtdeploy.dockerfile, ran nginx): entrypoint generates a JSON-safeconfig.jsfromAPP_*(a"in a value is correctly escaped → valid JS),config.jsloads before the deferred module bundle, SPA serves with client-side-route fallback (/booking/xyz→ 200), andAPP_API_UPSTREAMrewrites the proxy target. This caught the Node 22→24 fix (npm 10 couldn'tnpm cithe lockfile).Reviewed
Ran a 20-lens parallel review and applied the fixes: Node 18→22 (Vite 8 build was broken),
jq-safeconfig.jsgeneration, matched-set build (both images every push so both carry:<sha>), moving tags (:latest/:v) gated tomain, arm64-count guard before publish, fail-loudassertConfigured()on the container path, table-driven tests over all 15 config keys, and corrected/accurate comments. Backwards-compat of the untouched S3/ECS deploy, supply-chain/token scopes, and secrets exposure all reviewed clean.Remaining (for the app team / later phases)
APP_*env for the frontend nginx pod, and setAPP_API_UPSTREAMto the backend Service DNS (the baked127.0.0.1:5000is a sidecar/dev default). NoteAPP_API_URLis host+/api/v1path, not just a host.proxy_passupstream at startup — if the backend Service DNS isn't resolvable when the frontend pod boots, nginx crashloops. On EKS either guarantee the backend Service exists first, or switchappointments.confto aresolver+ variableproxy_passso the name resolves per-request (deploy-repo refinement).config.jscache headers if the S3 build ever adopts runtime config (not today; fallback covers it).--modefrom the existing frontend build and source config from/config.jsthere too.Relates to thunderbird/platform-infrastructure#779, #693, #731.