Skip to content

feat: recipe demo app on the raw Firebase JS SDK - #3752

Open
armando-navarro wants to merge 17 commits into
angular:mainfrom
armando-navarro:recipe-demo
Open

feat: recipe demo app on the raw Firebase JS SDK#3752
armando-navarro wants to merge 17 commits into
angular:mainfrom
armando-navarro:recipe-demo

Conversation

@armando-navarro

Copy link
Copy Markdown
Collaborator

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.
  • The server renders signed-in visitors as themselves. The browser mirrors the ID token into a __session cookie, the server reads it with cookie-parser and passes it to initializeServerApp, and an app initializer waits for authStateReady() so the served HTML carries the signed-in header, likes and Delete buttons rather than adding them after hydration.
  • /signin signs in or creates an account with email and password.
  • /create-recipe generates a recipe with Firebase AI Logic and writes it into the public list. Signed-out visitors are redirected to /signin.
  • Recipes can be deleted by the account that created them, and only that account.

The stack:

  • Angular 21 with SSR on and hydration with event replay.
  • firebase 12.18.0, used directly.
  • js-cookie and cookie-parser for the ID-token session cookie behind the signed-in server render.
  • Firebase Authentication with the email and password provider.
  • Firestore for recipes and per-user likes.
  • Firebase AI Logic on the Agent Platform backend for recipe generation.
  • App Check with reCAPTCHA v3, enforced on AI Logic only.
  • Pico CSS for styling, with semantic HTML and no component library.

Running it

Deployed instance: https://raw-sdk--recipe-demo-97859.us-central1.hosted.app

recipe-demo/README.md has 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.

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.
@armando-navarro armando-navarro added comp: other Doesn't clearly map to a module or cross-cutting area. type: chore Maintenance with no user-facing behavior change. and removed type: chore Maintenance with no user-facing behavior change. comp: other Doesn't clearly map to a module or cross-cutting area. labels Aug 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant