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:preview # emulators + web preview on 0.0.0.0:4200, with Firebase proxy
bun run start:dev # Angular dev server → deployed dev Functions (stretched-dev)
bun run start:prod # Angular dev server → production Firebase
start:emulated, start:preview, 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.
For a browser accessing the server through a container IP or forwarded port, use
bun run start:preview. Its preview configuration binds to 0.0.0.0:4200 and
loads apps/stretched/proxy.preview.json. Auth, Firestore, and Functions requests
use the page's origin, and the dev server forwards them to 127.0.0.1 on ports
9099, 8080, and 5001. Only port 4200 needs to be forwarded to the browser.
The command seeds test@test.com / testtest with admin access and keeps the
emulators it starts running until you stop the command.
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.