Search code examples
angularfirebasegoogle-cloud-firestoreangular18angular-ssr

Angular V18, SSR and requests to Firestore


I know in advance that I'm gonna get lynched over this but I've tried to find a solution for many moons now and I'm still struggling with this. Here's the deal: I have an Angular App that I created with the CLI and I chose to activate SSR for this project for SEO purposes. Great thing with Angular18 and SSR is that it gets setup automatically and fast. I'm using for this project, Firebase (particularly the emulator for dev purposes) and Firestore to store some blog articles. To handle fetching these documents, without SSR, I would typically use AngularFire calling "collectionData()" to get an Observable.

I've setup a basic "test" project to try to find a solution easily (congrats to me for that: No solution in sight):

Basic service:

import { inject, Injectable } from '@angular/core';
import { collection, collectionData, Firestore } from '@angular/fire/firestore';

@Injectable({
  providedIn: 'root'
})
export class TestServiceService {
  private firestore = inject(Firestore);

  constructor() { }

  getTests(){
    return collectionData(collection(this.firestore, 'tests'));
  }
}

to app.component.ts:

import { Component, inject, OnInit, signal } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { TestServiceService } from './test-service.service';
import { JsonPipe } from '@angular/common';

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [RouterOutlet, JsonPipe],
  templateUrl: './app.component.html',
  styleUrl: './app.component.scss'
})
export class AppComponent implements OnInit {
  private testService = inject(TestServiceService);
  tests = signal<any[]>([]);

  ngOnInit() {
    this.testService.getTests().subscribe((tests: any) => {
        this.tests.set(tests);
      });
    }
}

With the config file on the client:

import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
import { provideRouter } from '@angular/router';

import { routes } from './app.routes';
import { provideClientHydration } from '@angular/platform-browser';
import { initializeApp, provideFirebaseApp } from '@angular/fire/app';
import { getAuth, provideAuth } from '@angular/fire/auth';
import { getFirestore, provideFirestore } from '@angular/fire/firestore';
import { getFunctions, provideFunctions } from '@angular/fire/functions';
import { getStorage, provideStorage } from '@angular/fire/storage';
import { environment } from '../environments/environment';

export const appConfig: ApplicationConfig = {
  providers: [
    provideZoneChangeDetection({ eventCoalescing: true }), 
    provideRouter(routes), 
    provideClientHydration(), 
    provideFirebaseApp(() => initializeApp(environment.firebaseConfig)), 
    provideAuth(() => getAuth()), 
    provideFirestore(() => getFirestore()), 
    provideFunctions(() => getFunctions()), 
    provideStorage(() => getStorage())
  ]
};

On the server:

import { mergeApplicationConfig, ApplicationConfig } from '@angular/core';
import { provideServerRendering } from '@angular/platform-server';
import { appConfig } from './app.config';

const serverConfig: ApplicationConfig = {
  providers: [
    provideServerRendering(),
  ]
};

export const config = mergeApplicationConfig(appConfig, serverConfig);

Instead of subscribing in the component I've tried also to resolve through the route:

import { Routes } from '@angular/router';
import { AppComponent } from './app.component';

export const routes: Routes = [
    {
        path: '',
        component: AppComponent,
        // resolve: {
        //     tests: () => inject(TestServiceService).getTests()
        // }
    }
];

But I get this error in firestore: ERROR FirebaseError: Missing or insufficient permissions. Followed after a while by this warning: [2024-09-23T15:32:16.455Z] @firebase/firestore: Firestore (10.13.2): GrpcConnection RPC 'Listen' stream 0x1b30eee5 error. Code: 1 Message: 1 CANCELLED: Disconnecting idle stream. Timed out waiting for new target

My firestore rules are supposedly sufficiently opened:

service cloud.firestore {
  match /databases/{database}/documents {
    match /{document=**} {
      // This rule allows anyone with your database reference to view, edit,
      // and delete all data in your database. It is useful for getting
      // started, but it is configured to expire after 30 days because it
      // leaves your app open to attackers. At that time, all client
      // requests to your database will be denied.
      //
      // Make sure to write security rules for your app before that time, or
      // else all client requests to your database will be denied until you
      // update your rules.
      allow read, write: if request.time < timestamp.date(2024, 10, 22);
    }
  }
}

I've tried then to use the AdminSDK but it's worse. I've got some error message from "vite" [vite] that says that the modules are not accessible...

Here's the server.ts (automatically generated by the CLI):

import { APP_BASE_HREF } from '@angular/common';
import { CommonEngine } from '@angular/ssr';
import express from 'express';
import { fileURLToPath } from 'node:url';
import { dirname, join, resolve } from 'node:path';
import bootstrap from './src/main.server';

// The Express app is exported so that it can be used by serverless Functions.
export function app(): express.Express {
  const server = express();
  const serverDistFolder = dirname(fileURLToPath(import.meta.url));
  const browserDistFolder = resolve(serverDistFolder, '../browser');
  const indexHtml = join(serverDistFolder, 'index.server.html');

  const commonEngine = new CommonEngine();

  server.set('view engine', 'html');
  server.set('views', browserDistFolder);

  // Example Express Rest API endpoints
  // server.get('/api/**', (req, res) => { });
  // Serve static files from /browser
  server.get('**', express.static(browserDistFolder, {
    maxAge: '1y',
    index: 'index.html',
  }));

  // All regular routes use the Angular engine
  server.get('**', (req, res, next) => {
    const { protocol, originalUrl, baseUrl, headers } = req;

    commonEngine
      .render({
        bootstrap,
        documentFilePath: indexHtml,
        url: `${protocol}://${headers.host}${originalUrl}`,
        publicPath: browserDistFolder,
        providers: [{ provide: APP_BASE_HREF, useValue: baseUrl }],
      })
      .then((html) => res.send(html))
      .catch((err) => next(err));
  });

  return server;
}

function run(): void {
  const port = process.env['PORT'] || 4000;

  // Start up the Node server
  const server = app();
  server.listen(port, () => {
    console.log(`Node Express server listening on http://localhost:${port}`);
  });
}

run();

And the 2 mains: Client:

import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { AppComponent } from './app/app.component';

bootstrapApplication(AppComponent, appConfig)
  .catch((err) => console.error(err));

Server:

import { bootstrapApplication } from '@angular/platform-browser';
import { AppComponent } from './app/app.component';
import { config } from './app/app.config.server';

const bootstrap = () => bootstrapApplication(AppComponent, config);

export default bootstrap;

Can someone please point me in the right direction for this?


Solution

  • So. For those of us that didn't quite get all the troubles behind Angular SSR in version 18 here's my take on the problem: The server-side doesn't permits the use of libraries such as Firebase-Admin SDK or AngularFire. Therefore, all the requests have to be done the "old fashion way". Particularly by using the HttpClient to you'll have to provide in your config file:

    export const appConfig: ApplicationConfig = {
      providers: [
        provideRouter(routes, withComponentInputBinding()), 
        provideClientHydration(withHttpTransferCacheOptions({
          includePostRequests: true
        })), 
        provideAnimationsAsync(), 
        provideHttpClient(withFetch()), // <--- This line right here
    ...
    

    As you can see I'm using withFecth() because of an Angular warning (recommendation) in the console if it isn't used.

    As @Pieterjan indicated in the comments above, we could connect directly to firestore via the REST service. However, at least for me, it seems too complex for what I wanted to achieve. Therefore, in order to manage the calls efficiently, I've created a bunch of Google Functions that use Firebase Admin SDK.

    The service therefore could look something like:

    private http: HttpClient = inject(HttpClient);
    
      fetchBlogArticles() {
        return this.http.get(`${environment.httpApiUrls.blogArticles}/fetchBlogPosts`);
      };
    
      fetchBlogArticleById(id: string) {
        return this.http.get(`${environment.httpApiUrls.blogArticles}/fetchBlogPostById?id=${id}`);
      };
    

    Which return (the http.get) an Observable that then can be easily used in the component.

    Here's an example of the Firebase Function:

    fetchBlogPosts = onRequest( async (req, res) => {
        const snapshot = await getFirestore().collection('articles').get();
        const posts = snapshot.docs.map(doc => doc.data());
        logger.info(`Success! Found ${posts.length} articles in db.`);
        return res.status(200).json({ 
            success: true,
            message: `Found ${posts.length} articles.`,
            data: posts.length > 0 ? posts : [],
        });
    });
    

    My objective is to use this for items that I want rendered for SEO purposes and use AngularFire to handle the rest: Auth, Storage, etc... (hoping that it's gonna work and not generate any conflicts).

    At least for now, Angular serves the content in the HTML and the app gets fully hydrated, which is what I was looking for :D

    Thanks @Pieterjan for pointing me in the right direction!