Deploy the web app
The scaffolded Flutter app is an Android and web app. The default production
deployment uses one canonical origin such as https://rps.example.com:
| Path | Served by |
|---|---|
/, /home, and other app routes | Flutter web through Workers Static Assets |
/join/:code, /game/:id | Worker-enriched Flutter shell with dynamic share metadata |
/api/*, /health, /avatars/* | Worker |
/.well-known/* | Worker-generated native app-link verification |
/terms, /privacy, /delete-account | Worker-generated legal pages |
/download | Server-rendered native app download page |
eigeninteractive.com remains the engine documentation/company site; it does
not host an implementor's game. A custom game may use any domain.
Web uses the same widgets, Riverpod state, generated API client and game module as Android; only the browser integration points differ:
- Firebase Auth opens Google's Firebase-managed popup, and only the popup;
- REST, avatars, and sockets are same-origin in production;
- the game feed uses
wss://…?ticket=…, with a short-lived ticket minted over authenticated HTTPS because browser WebSocket upgrades cannot set anAuthorizationheader; - Firebase Messaging runs background delivery in a service worker;
- the app's own service worker, generated by Workbox, precaches the build so the app opens with no network;
- the device replica lives in the browser's IndexedDB (or OPFS), hosted by one
shared worker for every tab, with drift's web runtime served from
eigen_flutter's package assets.
1. Use a stable local origin
OAuth and Worker origin policy both match origins, not arbitrary development ports. Run Flutter on the scaffold's fixed port:
cd app
flutter run -d chrome --web-hostname localhost --web-port 7357 \
--dart-define-from-file=app-config.json
Local development deliberately uses two origins, so Flutter can hot reload independently of Wrangler. The Worker template starts with:
"vars": {
"WEB_APP_ORIGIN": "http://localhost:7357"
}
The engine automatically trusts that exact origin for browser REST and
WebSocket requests. Set it to the game's canonical origin in production; it
also supplies the absolute HTTPS target for background notification clicks.
Same-origin browser requests are accepted automatically. Configure
clientOrigins in createEngine only to replace this convention with multiple
or otherwise non-standard origins.
2. Configure Firebase for web
From the generated repository root, configure Android, Flutter Web, and the messaging worker together:
pnpm firebase:configure
# or: npm run firebase:configure
For an app maintained in its own repository, run
dart run eigen_firebase:configure_firebase from the Flutter root. Both forms
run FlutterFire for Android and Web, then derive the service worker
configuration from the Web app FlutterFire selected.
The first run prompts you to pick a Firebase project, or create one, since there is nothing yet to tell it which. Name the project instead to skip the prompts, which is also how you drive this from CI:
pnpm firebase:configure -- --project my-project-id
# or: npm run firebase:configure -- --project my-project-id
After that first run there is nothing to pass: the project is recorded in
app/firebase.json and reused, so re-running to pick up a new configuration is
non-interactive. --account <email> picks the Google account on a machine
signed in to several, and --help lists the lot. Scaffolding runs this step
before the scaffold commit, so on a project created with the tooling in place
this is the command for changing the configuration, not establishing it.
Platforms are not configurable here: the app is Android and Web, and the
service worker configuration is derived from the Web app. For anything outside
that, run flutterfire configure yourself. Use the generated options in
initializeEigenFirebase:
firebaseOptions: DefaultFirebaseOptions.currentPlatform,
Enable Google in Firebase Authentication, then add both
localhost and the production app domain under Authentication → Settings →
Authorized domains. The Google OAuth web client must list the same values as
authorized JavaScript origins.
firebase_options.dart contains public app identifiers, not a service-account
secret. It may be committed, or generated per environment in CI. The Worker
service-account private key remains a secret.
3. Finish Web Push
The scaffold contains:
web/firebase-messaging-sw.js, which receives background messages;web/firebase-config.js, generated from FlutterFire's selected Web app;web/flutter_bootstrap.js, which starts Flutter and registers that worker and the app's own beside it;- an
app-config.jsoncontaining the publicFIREBASE_VAPID_KEYconsumed byEngineConfig.
Do not copy Firebase identifiers into the service worker. It imports the
generated firebase-config.js; rerunning firebase:configure updates both it
and firebase_options.dart from the same selected Firebase app. Both generated
files contain public application identifiers, not Firebase Admin credentials.
Generate or copy the public Web Push certificate key from Firebase Console →
Project Settings → Cloud Messaging → Web configuration, then put it in
app-config.json before running the command from step 1.
The VAPID key is public but required for the engine's web target. An empty key stops web startup with an actionable configuration error; it is not treated as a player-facing “notifications unavailable” state. A browser that does not support Web Push still degrades gracefully. On a supported browser, the app requests permission only after the player taps Enable notifications in the contextual multiplayer waiting-room explanation (or the secondary action in Settings); initialization never opens the browser prompt.
After a grant, the app creates the FCM subscription and registers its Firebase
Installation ID with the Worker. Installation rotation, sign-in and tab resume
all retry that reconciliation. If permission is later revoked, the stale Worker
registration is removed on resume. The app uses Firebase's register API and
never requests or stores the deprecated registration token.
Background notifications display through Firebase's service worker integration.
For web installations the Worker resolves the relative deepLink against its
HTTPS WEB_APP_ORIGIN and sends the result as
webpush.fcm_options.link. A /game/:id notification therefore opens that
absolute app URL; the SPA fallback then serves Flutter and the router restores
the route. Local HTTP development intentionally omits the click action because
FCM requires a secure URL.
eigen_flutter bundles the pinned Cropper.js JavaScript, stylesheet, and MIT
license required by image_cropper, and loads the browser assets only when
avatar editing starts. The app needs no Cropper.js files or web/index.html
tags, and has no runtime CDN dependency.
4. Build and deploy one artifact
Edit the scaffolded app/app-config.json. These are public build-time values
shared by Android and web:
{
"API_BASE_URL": "https://rps.example.com",
"APP_HOST": "rps.example.com",
"GOOGLE_WEB_CLIENT_ID": "…apps.googleusercontent.com",
"FIREBASE_VAPID_KEY": "…"
}
Attach the Worker itself to that hostname in server/wrangler.jsonc:
"workers_dev": false,
"preview_urls": false,
"routes": [
{ "pattern": "rps.example.com", "custom_domain": true }
]
A Custom Domain is the right Cloudflare routing mode here because the Worker is
the origin for the whole hostname; Cloudflare creates the DNS record and
certificate. Keep workers.dev during initial development if useful, then turn
it off in committed production config so a later Wrangler deploy cannot
silently re-enable a second public origin.
pnpm run deploy
The root script runs flutter build web --release directly into
server/public/ with --dart-define-from-file=app-config.json, generates the
service worker with workbox generateSW workbox-config.cjs, applies D1
migrations, then deploys the Worker and assets together. Wrangler's single-page-application fallback
handles clean Flutter paths. Its selective run_worker_first list keeps exact
static files on Cloudflare's asset path while reserving dynamic engine routes
for Worker code. There is no second hosting product or second deployment URL
to coordinate.
The service worker and updates
workbox-config.cjs precaches everything the app is built from when the worker
installs: the entry points, assets/ (drift's runtime included), and the icons.
CanvasKit and the Firebase JS SDK load from Google's CDN at URLs that name their
version, and are cached as they load. So after one online visit the app opens
with no network at all, and it always runs one consistent build rather than a
mix of cached and fresh files.
A deploy changes sw.js. The browser installs the new worker beside the old
one, precaching the new build, and it waits: a tab already open keeps running
the build it started with, and a player's next visit after closing every tab
gets the new one. When the engine reports that a client is too old, the
update-required button asks the waiting worker to take over and then reloads,
so the reload lands on the new build.
Two things keep this correct:
run_worker_firstand the worker'snavigateFallbackDenylistmust agree. A path the Worker renders (/terms,/download, the API) belongs in the denylist, so a navigation to it reaches the Worker instead of the precached app./join/*and/game/*do not, so they open the app, offline included. A new Worker route goes in both.- Deploy the Worker and its assets together, which
deploydoes, and servesw.jswith revalidation. Workers Static Assets does both by default; a separate static host must too.
The combined deploy removes the usual ordering race for web. Android still needs version ordering: publish a compatible Play build before server behavior that requires it.
The hostname players see when they sign in
On the web, sign-in runs through Firebase's popup, which loads
https://<authDomain>/__/auth/handler, and Google's account chooser names that
host. By default it reads:
Sign in to continue to my-project.firebaseapp.com
This is fine, and most games should leave it alone. It is cosmetic, it does not change what sign-in does, and Android never shows it, because the native Google flow is used there instead.
If you do want your own name on that screen, it is two independent changes and either works without the other:
| To change | Where | Needs a domain |
|---|---|---|
| The product name and logo | Google Cloud Console → APIs & Services → OAuth consent screen | No |
| The hostname in the sentence | AUTH_DOMAIN in app-config.json | Yes |
The consent-screen half is the cheaper win and needs no configuration at all.
The hostname half needs a domain Firebase Hosting answers for, because that
is what serves /__/auth/handler. It cannot be APP_HOST: that name is your
Worker. The two are siblings, auth.rps.example.com beside rps.example.com.
Add the custom domain under Hosting in the Firebase console, add it under
Identity Platform → Identity providers → Project settings → Add Domain, then
set it:
{
"APP_HOST": "rps.example.com",
"AUTH_DOMAIN": "auth.rps.example.com"
}
Leave AUTH_DOMAIN empty and the app uses the project's own domain, which is
the scaffold's state and needs no Firebase Hosting at all. The engine applies
the value at startup rather than expecting you to edit firebase_options.dart,
which is generated and would lose the edit on the next
pnpm firebase:configure. A value Firebase Hosting does not answer for breaks
web sign-in outright, so set it once the domain resolves, not before.
Deliberately splitting the origins
A separate static host remains supported. Point API_BASE_URL at the Worker,
set APP_HOST and WEB_APP_ORIGIN to the public web host, and configure that
host's SPA fallback yourself. Use clientOrigins only if more browser origins
must be trusted. You then own CORS, two deployments, cache policy, and ensuring
notification links land on the web app. This is an advanced topology, not the
scaffold default.
5. Verify the browser, not only the compiler
Before release, test at the production origin:
- Google sign-in and guest upgrade;
- an authenticated REST request and avatar upload;
- a live game through
wss; - a worker-served relative avatar URL;
- notification opt-in plus background receipt and display;
- a background notification click opens the exact absolute
/game/:idURL, including when no app tab is already open; - direct navigation and refresh at
/game/:idor/join/:code; - the update-required reload after replacing the deployed bundle;
- with the network off, reopening the app after a single online visit, and
opening a
/game/:idlink.
The engine CI runs its browser socket and integration tests, then builds the
RPS reference app for the web and generates its service worker, checking that
the worker precaches the entry points and drift's runtime. The scaffolded CI
runs your project's own build:web with app-config.json,
firebase_options.dart, and the generated firebase-config.js; add
credentialed browser integration tests for the flows above.