Skip to main content

Local Development

See also: Testing · Tooling and Scripts · Secrets Management · Mobile App

Everything runs through Bun scripts in the root package.json. Prerequisites: Bun, the Firebase CLI, and a Java runtime (for the Firestore emulator).

One-time setup

bun run setup

tools/setup-env.ts does two things:

  1. firebase login — authenticates the Firebase CLI so the local emulator can pull project secrets (e.g. ORS_API_KEY) from Firebase Secret Manager automatically. See Secrets Management.
  2. Optionally sets STORYBOOK_FUNCTIONS_BASE_URL as an OS-level user env var — only needed if you want Storybook to call real deployed Functions instead of its built-in mock.

For mobile toolchain setup (bun run mobile:setup, JDK 21 + Android SDK) see Mobile App.

The emulator

bun run emulate

tools/emulate.ts wraps firebase emulators:start with two extras:

  • State persistence — exports emulator state to emulator-data/ on exit (--export-on-exit) and re-imports it on the next start (--import). Auth users and Firestore documents survive between sessions. On the very first run emulator-data/ doesn't exist, so it starts fresh.

  • Seeded test usertest@test.com / testtest (display name test) is created automatically via tools/emulator-seed.ts, and is a super admin: every start re-asserts the admin: true custom claim, so worlds restored from emulator-data/ (created before the claim existed, or before you last changed it) get it too. The grant MERGES into the account's existing custom claims — a tier minted by POST /subscription/change-plan during a session is not clobbered by the next restart. Existing accounts are otherwise untouched: no password, display-name or data reset. A browser session that was signed in before the claim was granted keeps its old ID token — sign out and back in to see admin-only UI (e.g. /account's "View this plan").

    The seed grants admin: true and no tier, on purpose. Since the ACL content/function split (Auth and Users) the admin role unlocks admin functions (/admin/*, the plan switcher) but no member content, so the dev account lands on the honest free view and previews paid plans by switching — and the switched tier survives restarts via the merge above. The visual-diff world is the opposite case: it seeds { admin: true, tier: 'generations' } because those shots must render the fully-unlocked app.

Do not run firebase emulators:start directly — you'd lose the import/export wiring and the seeded user.

Shutdown is deliberately graceful: on Ctrl+C the script waits (up to 2 minutes) for the Firebase CLI to finish exporting before force-killing. A second Ctrl+C force-quits immediately (losing that session's export). Orphaned firebase-export-<timestamp> staging dirs from past hard kills are recovered or removed on the next startup.

Running the web app

bun run start:emulated # dev server + local emulator, one command (see below)
bun run start:dev # Angular dev server → deployed dev Functions (stretched-dev)
bun run start:prod # Angular dev server → production Firebase

start:emulated (and mobile:start:emulated) run tools/serve-emulated.ts, a self-contained orchestrator in the same spirit as storybook:emulated:

  • Emulator already running (a bun run emulate terminal): it is detected (auth emulator on :9099), reused, and left running on exit — the two-terminal workflow keeps working and dev-server restarts don't cycle the emulator.
  • No emulator running: the script builds the Functions (NX-cached), npm-installs their runtime deps into dist/apps/firebase-functions, clears stale port zombies (5001/8080/9099), starts the full emulator with the same emulator-data/ persistence and seeded user as bun run emulate, then serves. Ctrl+C stops both, waiting for the emulator's state export to finish exactly like bun run emulate does (second Ctrl+C force-quits).

The shared emulator plumbing (persistence args, graceful shutdown, orphaned export recovery, functions build) lives in tools/emulator-lifecycle.ts and is used by emulate.ts, serve-emulated.ts, and storybook-emulated.ts.

start:dev / start:prod are thin wrappers around the matching development and production serve configurations. start:prod reads the actual stretched-2c16a Remote Config values; it does not apply a local feature-flag override. The deploy path remains bun run build:prod, which uses the same production environment configuration.

PWA / service worker

The web app ships as an installable PWA (serviceWorker build option → ngsw-worker.js + ngsw.json; apps/stretched/ngsw-config.json is the cache manifest — installMode: prefetch on /*.js means the SW background-downloads every lazy chunk right after first paint). The dev server can never run it (in-memory HMR builds don't emit ngsw, and SW caching would fight live reload) — provideServiceWorker is gated on environment.isServiceWorkerEnabled, true only in the production and pwa-emulated environments.

To run the full PWA locally against the emulator:

bun run emulate # terminal 1 — the backend
bun run pwa:emulated # terminal 2 — ngsw-enabled build, served on :4280

Port 4280 is deliberate: SW registrations are sticky per origin, and on :4200 it would keep hijacking normal dev sessions. SW caching also survives reloads — DevTools → Application → Service workers → "Unregister" (+ Clear storage) to reset.

Updates: ngsw pins each session to one app version (mid-session chunk swaps would 404 old hashes) and stages a new deploy fully in the background. SwUpdateService + the shell's "A new version of Stretched is ready" banner close the gap — one click reloads into the staged version. Only assets are versioned this way; Firestore/Functions data is never SW-cached (no dataGroups) and is always live.

Storybook

bun run storybook # Storybook with DI-level mocks (no backend needed)
bun run storybook:emulated # Storybook hitting the local emulator
bun run storybook:dev # Storybook → deployed dev Functions

bun run storybook:emulated (tools/storybook-emulated.ts) builds the Firebase Functions, npm-installs their runtime deps into dist/apps/firebase-functions (the emulator resolves the SDK there, not from the workspace root), clears stale processes on ports 5001/6006/8080/9099, starts the emulators (functions + auth + firestore, with the same emulator-data/ persistence and seeded user), then launches Storybook with STORYBOOK_FUNCTIONS_BASE_URL pointed at the local Functions emulator.

Mobile app

The Ionic + Capacitor shell (apps/stretched-mobile) serves on 4300 with web-parity scripts: mobile:start:{emulated,dev,prod}, mobile:build:{emulated,dev,prod}. Full details on Mobile App.

Port map

PortWhat
4200Web app dev server (start:*)
4300Mobile app dev server (mobile:start:*)
6006Storybook (apps/storybook)
4400Per-app Storybook target inside apps/stretched (rarely used)
4000Firebase Emulator UI
5001Functions emulator
8080Firestore emulator
9099Auth emulator
9000Realtime Database emulator
9199Storage emulator
6199storybook-e2e static server (tools/storybook-e2e.ts)

Emulator ports come from firebase.json; app ports from each project's project.json.

E2E runs

bun run e2e:emulated and bun run e2e:mobile:emulated need the dev emulator stopped first — they start their own fresh emulator on the same ports and refuse to run if something is already listening on :9099. Details on Testing.

Windows / VS Code quirks

If nx or Cypress misbehave in a VS Code terminal, see Tooling and Scripts — you likely need unset NX_WORKSPACE_ROOT_PATH and/or unset ELECTRON_RUN_AS_NODE.