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:
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.- Optionally sets
STORYBOOK_FUNCTIONS_BASE_URLas 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 runemulator-data/doesn't exist, so it starts fresh. -
Seeded test user —
test@test.com/testtest(display nametest) is created automatically viatools/emulator-seed.ts, and is a super admin: every start re-asserts theadmin: truecustom claim, so worlds restored fromemulator-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 — atierminted byPOST /subscription/change-planduring 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: trueand 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 emulateterminal): 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 sameemulator-data/persistence and seeded user asbun run emulate, then serves. Ctrl+C stops both, waiting for the emulator's state export to finish exactly likebun run emulatedoes (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
| Port | What |
|---|---|
| 4200 | Web app dev server (start:*) |
| 4300 | Mobile app dev server (mobile:start:*) |
| 6006 | Storybook (apps/storybook) |
| 4400 | Per-app Storybook target inside apps/stretched (rarely used) |
| 4000 | Firebase Emulator UI |
| 5001 | Functions emulator |
| 8080 | Firestore emulator |
| 9099 | Auth emulator |
| 9000 | Realtime Database emulator |
| 9199 | Storage emulator |
| 6199 | storybook-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.