feat: recipe demo app on the raw Firebase JS SDK - #3752
Open
armando-navarro wants to merge 17 commits into
Open
Conversation
The like counter moves by exactly one, and only in the commit that adds or removes the caller's own like document. Each of the two rules carries half of that check: the recipe's rule looks at the like document before and after, and the like document's rule looks at the counter. Either half alone is walked around by resetting the other side in a separate write. A recipe create must name the caller as owner, start the counter at zero, and carry exactly the seven expected fields with the expected types. Recipes are world readable and only the owner may delete. The seed script exits when its credentials are missing, since signing in with undefined reads like a project misconfiguration rather than an unset environment variable.
The CLI scaffold predates the .prettierrc in this app, so four generated files disagreed with it on trailing commas and on arrowParens: avoid. Formatting only, no behavior change.
Four InjectionTokens carry the Firebase handles so components inject them
instead of importing shared singleton instances from a helper file. The
shared app.config provides Firestore and Auth, which derive from whichever
FIREBASE_APP the platform supplies. The client config initializes a normal
app plus App Check and adds FIREBASE_AI, the server config uses
initializeServerApp and deletes the app on DestroyRef so each render
releases its handle. FIREBASE_AI is client only, so server-side injection
sites need { optional: true }.
FIREBASE_AI names the Agent Platform backend explicitly. Plain getAI()
selects the Gemini Developer API, whose separate prepay balance cannot be
funded by Google Cloud credit. Agent Platform bills as ordinary Cloud usage
and defaults to the global location, which is where this app's model runs.
The browser mirrors the ID token into a __session cookie and the server passes it to initializeServerApp, so per-user UI is in the served HTML instead of appearing only after hydration. An app initializer waits for authStateReady() before rendering. Without it a route with no Firestore read renders signed out despite a valid token, and the home page only renders signed in by winning a race against its own recipes read.
AuthStore exposes the signed-in user as a signal and wraps the sign-in, account-creation, and sign-out calls. The sign-in page takes an email and password and either signs in or creates an account. Failures appear inline, and a wrong email and a wrong password read the same, so the form never reveals which half failed. The header swaps between a sign-in link and the signed-in email with a sign-out button, replacing the scaffold placeholder and its inline styles.
The scaffold shipped an empty allowedHosts list, so every request failed host validation and the server quietly served the client-side page instead. Listing localhost restores server rendering, and an unknown host now returns a 400 rather than a silent downgrade. A deployed host has to be added to this list too.
Every component here sets OnPush by hand, so the generator should produce it rather than leaving it to be remembered each time.
The store reads through a Firestore converter and wraps the query in a resource that reads once on the server, because a listener would never settle and the render has to finish, then subscribes for live updates in the browser. The server hands its rendered list to the browser, which uses it until the listener responds. Without that the first client render painted an empty list over the server's, blanking the page for roughly half a second. Reads and writes do not share a model, and a converter is typed with one model for both directions, so each direction gets a converter over a shared core of fields. CUISINES is typed readonly string[] rather than a const tuple, so a plain string can be checked against it. Each option binds its own selected state rather than binding value on the select, which is applied before the loop creates the options and silently dropped any non-default selection on a client-side navigation. The filter controls sit in a search landmark and the cards are list items, so the collection is navigable.
A like writes a document under users/{uid}/likes and moves the recipe's
likeCount in the same batch, so the two cannot disagree. The liked set
arrives through a resource with a stream loader. Undefined params leave
it idle on the server, and signing out sends null instead, because
Angular does not abort the previous load when params go undefined, which
would leave the listener open.
An unlike skips the decrement when the count is already zero, because
the rules reject a negative count and would fail the whole batch.
The button waits for the first likes snapshot before enabling, since
liking earlier would add a second count to an already liked recipe, and
carries aria-label so its name does not change with the count.
Delete renders only on cards whose createdBy matches the signed-in uid, and the deployed rule refuses a delete from anyone else. A confirmation modal opens when delete is clicked. A failed delete names the recipe in the same alert paragraph the list and like errors use. Each action clears the other's message first, since that paragraph shows one message at a time and a stale like error would otherwise hide the delete that just failed. Other users' like documents are not cleaned up when a recipe goes. A real app would clear them from a Cloud Functions trigger.
The route guard on /create-recipe always allows the server render, because the server has no signed-in user and deciding there would send every visitor an HTTP redirect to /signin. The server returns the page shell and the browser then chooses between showing the page and redirecting, after Firebase has restored any saved session. The guard waits for that with authStateReady(). Reading the currentUser signal before the restore finishes would see null and send an already signed-in visitor to /signin instead of the page they asked for. The guard also puts the requested path in a returnUrl parameter so sign-in returns there. Gemini returns only the four fields the response schema asks for, with no createdBy, and toRecipe fills createdBy from whatever object it is given, so the validated result carries an empty string there. The draft is therefore assembled field by field with createdBy taken from the signed-in user, since the create rule requires it to equal the requesting uid. This route never shows the recipe list, so the resource now idles until a page asks for it rather than making every server render here wait on a Firestore read it discards. Failures become readable messages instead of raw SDK text.
App Hosting fronts the app with a proxy that attaches the full X-Forwarded-* set. Angular's server engine trusts only x-forwarded-host and x-forwarded-proto by default, and one untrusted header makes it drop the render and return the client-side shell at HTTP 200. The deployed hostnames join allowedHosts because the engine validates both Host and X-Forwarded-Host against that list and answers an unlisted host with a 400. The four listed match what the Firebase CLI configures for an Angular backend.
The guard returned true on the server and left the browser to redirect, so an anonymous request for a protected route got a rendered page and a bounce a moment later. Since the server learned who the visitor is, it can answer properly. Removing the platform branch lets the guard run on both platforms. Angular SSR notices the router ended somewhere other than the requested URL, abandons the render, and returns a 302 with the location, so an anonymous request now gets one redirect to the sign-in page instead of a page nobody can use.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This builds a small recipe app on the Firebase JS SDK directly.
What the app does
/renders the recipe list on the server, with a cuisine filter, Newest and A-Z sort, and per-user likes. Each recipe stores its own like count rather than counting like documents on every render.__sessioncookie, the server reads it withcookie-parserand passes it toinitializeServerApp, and an app initializer waits forauthStateReady()so the served HTML carries the signed-in header, likes and Delete buttons rather than adding them after hydration./signinsigns in or creates an account with email and password./create-recipegenerates a recipe with Firebase AI Logic and writes it into the public list. Signed-out visitors are redirected to/signin.The stack:
firebase12.18.0, used directly.js-cookieandcookie-parserfor the ID-token session cookie behind the signed-in server render.Running it
Deployed instance: https://raw-sdk--recipe-demo-97859.us-central1.hosted.app
recipe-demo/README.mdhas the Firebase project setup, the run commands, the design decisions that may be hard to deduce from the code, and a list of intentional gaps a real production app would close. One thing worth knowing before a local run: a fresh browser profile prints an App Check debug token that has to be registered in the project console first, and the README says where.What happens to this PR
This PR should never be merged. It exists as a reference demo.