Skip to content

feat(frontend): runtime SPA config via /config.js (build-time -> runtime) - #1748

Merged
aatchison merged 6 commits into
mainfrom
spa-runtime-config
Jul 21, 2026
Merged

feat(frontend): runtime SPA config via /config.js (build-time -> runtime)#1748
aatchison merged 6 commits into
mainfrom
spa-runtime-config

Conversation

@aatchison

@aatchison aatchison commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

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).

Draft — opened for the Appointment app team to own/complete/merge.

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-baked import.meta.env values, 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: reads window.__APP_CONFIG__, falls back to import.meta.env (dev/tests/existing-S3 keep working).
  • window.__APP_CONFIG__ is set by /config.js, loaded synchronously in index.html before the bundle.
  • /config.js is supplied per environment at run time, not rebuilt: the EKS nginx entrypoint (docker/docker-entrypoint.d/40-appointment-config.sh) generates it from APP_* pod env. (Existing S3 keeps baking values via --mode; the empty public/config.js falls through to them.)
  • Backend needs no change (FastAPI already reads runtime env).

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, via GITHUB_TOKEN. Path-filtered so each image builds only when its dir changes.
  • build-multiarch-image.yml — reusable builder (native runners, push-by-digest, imagetools create manifest list), mirroring thunderbird-accounts.

Changes

  • src/config.ts + test/config.test.ts (new); public/config.js (new); index.html loads it.
  • docker/docker-entrypoint.d/40-appointment-config.sh (new); deploy.dockerfile builds env-agnostic + wires the entrypoint.
  • Migrated all import.meta.env.VITE_* app-config call-sites to config (main.ts, App.vue, router.ts, utils.ts, 3 composables). MODE/BASE_URL left as Vite built-ins.
  • .github/workflows/{publish-images,build-multiarch-image}.yml (new). No change to deploy-staging.yml / deploy-production.yml.

Verified

npm run lint clean, npm run test 97 pass, npm run build green, both new workflows parse. Local container test (built deploy.dockerfile, ran nginx): entrypoint generates a JSON-safe config.js from APP_* (a " in a value is correctly escaped → valid JS), config.js loads before the deferred module bundle, SPA serves with client-side-route fallback (/booking/xyz → 200), and APP_API_UPSTREAM rewrites the proxy target. This caught the Node 22→24 fix (npm 10 couldn't npm ci the lockfile).

Reviewed

Ran a 20-lens parallel review and applied the fixes: Node 18→22 (Vite 8 build was broken), jq-safe config.js generation, matched-set build (both images every push so both carry :<sha>), moving tags (:latest/:v) gated to main, arm64-count guard before publish, fail-loud assertConfigured() 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)

  • EKS wiring (appointment-deploy repo / ExternalSecrets): supply the APP_* env for the frontend nginx pod, and set APP_API_UPSTREAM to the backend Service DNS (the baked 127.0.0.1:5000 is a sidecar/dev default). Note APP_API_URL is host+/api/v1 path, not just a host.
  • nginx resolves the static proxy_pass upstream 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 switch appointments.conf to a resolver + variable proxy_pass so the name resolves per-request (deploy-repo refinement).
  • Supply chain (spec Phase 4/6): cosign sign + SLSA attest the GHCR indexes; ECR pull-through cache — deliberately deferred to the later pipeline phases.
  • S3 config.js cache headers if the S3 build ever adopts runtime config (not today; fallback covers it).
  • Optional later: drop --mode from the existing frontend build and source config from /config.js there too.

Relates to thunderbird/platform-infrastructure#779, #693, #731.

aatchison and others added 4 commits July 21, 2026 12:00
…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>
@aatchison
aatchison marked this pull request as ready for review July 21, 2026 19:45
@aatchison
aatchison requested a review from davinotdavid July 21, 2026 19:47
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>
Comment thread frontend/deploy.dockerfile Outdated
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just double checking with @rwood-moz around the node version & browserstack as that's my main concern about this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@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 davinotdavid left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

@aatchison
aatchison merged commit 9fe4fd2 into main Jul 21, 2026
8 checks passed
@aatchison
aatchison deleted the spa-runtime-config branch July 21, 2026 20:35
@aatchison

Copy link
Copy Markdown
Contributor Author

Post-merge on main, both workflows are green: publish-images published the backend + frontend multi-arch (amd64+arm64) images to GHCR (validating the Node 24 build), and deploy-staging deployed stage + browserstack e2e passed. @rwood-moz — that covers the node-version + browserstack concern end-to-end on real stage.

@rwood-moz

rwood-moz commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Post-merge on main, both workflows are green: publish-images published the backend + frontend multi-arch (amd64+arm64) images to GHCR (validating the Node 24 build), and deploy-staging deployed stage + browserstack e2e passed. @rwood-moz — that covers the node-version + browserstack concern end-to-end on real stage.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants