I hacked Firebase with Redux to get free Web socket hosting (bye Pusher)

Last updated on August 13, 2024
I hacked Firebase with Redux to get free Web socket hosting (bye Pusher)

I was planning a powerful real-time app so Web Sockets was essential.

Unfortunately, all the Web Socket hosting options I found were too costly or complex to set up.

So, I hacked Firebase to get Web Sockets for free with an innovative trick from Redux.

Web sockets great cause unlike our classic HTTP request-response style, the web socket server can send several messages to multiple connected clients in real time without any need for a request.

Firebase Firestore is free and has this powerful real-time ability by default, but there was a major problem.

Firestore is data-centric and client-centric

But Web Sockets are action-centric and server-centric.

As a client in Web Sockets, you send event messages through channels and the server uses them to decide what to do with the data.

It has complete control, and there's no room for malicious manipulation from any user.

// channel to listen for events in server
channel.bind('sendChatMessage', () => {
  // modify remote database
  // client doesn't know what's happening
});

But in Firestore, you dump the data in the DB and you're done. The client can store whatever they want. Anyone can access anything in your DB once they have the URL.

// client can do anything
const handleSendChatMessage = ({ content, senderId }) => {
  const messagesRef = collection(
    `users/${userId}/messages`
  );
  addDoc(messagesRef, {
    content: 'whatever I want',
    senderId: 'whoever I want',
    timestamp: new Date(),
  });
};

Sure, you can add "security rules" to protect certain data paths:

But it's woefully inadequate compared to the flexibility and remote control that real Web Socket servers like Pusher provide.

And yes there was Pusher, but it only allowed a measly amount of free concurrent connections, and in this app, all my users needed to be permanently connected to the server, including when they closed the app.

My delusions of grandeur told me I'd be paying quite a lot when thousands and millions of people start using the app.

But what if I could make Firebase Firestore act like a real server and have complete control of the data?

I'd enjoy the generous free limits and have 1 million concurrent connections.

What I did

I needed to transform Firestore from data-centric to action-centric.

But how exactly could I do this? How could I bring channels to Firestore and create some sort of "server" with full power to regulate the data?

The answer: Redux.

But how? How does Redux have anything to do with Firebase?

Well, it was Redux that helped transform vanilla React from data-centric:

const handleSendChatMessage = (content, senderId) => {
  // sets messages directly
  setMessages((prev) => [...prev, { content, senderId }]);
};

To action-centric:

const handleSendChatMessage = (content, senderId) => {
  const action = {
    type: 'sendChatMessage',
    payload: { content, senderId },
  };
  dispatch(action);
};

Now the responsibility for modifying the data is in the hands of the reducers, just like in a Web Socket or HTTP server.

  • Actions: Sending a real-time message in a channel from client to server
  • Reducer: Handling the message and modifying the data in the Web Socket server

So I needed to bring actions and reducers to Firestore somehow. And eventually, I saw that it all came down to the schema.

Actions

To replicate actions and action dispatching I created a Firestore collection of channels for different topics.

Every channel is a Firestore document with its own subcollections for each user to receive real-time messages from them.

To send an event through the channel, the client simply adds it to its own subcollection in the channel.

const handleSendChatMessage = async ({ content }) => {
  const channel = 'chat1';
  const actionsRef = collection(
    getFirestore(),
    `channels/${channel}/${userId}`
  );
  await addDoc(actionsRef, {
    channel: 'sendChatMessage',
    payload: {
      content,
    },
  });
};

We can abstract this into a function to make it easier to reuse:

const handleSendChatMessage = async ({ content }) => {
  send('sendChatMessage', { content });
};
async function send(channel, data) {
  const actionsRef = collection(
    getFirestore(),
    `channels/${channel}/${userId}`
  );
  await addDoc(actionsRef, {
    channel: channel,
    payload: data,
  });
}

Reducers

Now I needed to add the action handling to modify the data.

I did this by creating a Firebase Function triggered anytime a client adds a new action to the collection stream:

exports.handleEvent = onDocumentCreated(
  'channels/{channelId}/{userId}/{eventId}',
  (snap, context) => {
    const event = snap.data();
    const {
      payload: { content },
    } = event;
    const { channelId, userId } = context.params;
    switch (event.type) {
      case 'sendChatMessage':
        // 👇 actual data modification
        db.collection(`chats/${channelId}/messages`).add({
          content,
          senderId: userId,
          timestamp: FieldValue.serverTimestamp()
        });
    }
  }
);

So the data would live side-by-side with the action stream collection in the same Firestore DB:

No user will ever be able to access this data directly; Our security rules will only ever them to send messages through their subcollection in the channels collection.

Receiving real-time messages from the server

I create a special subcollection within every channel, exclusively for events from server to clients.

Here I relay the new message to other users in the chat after storing the data.

exports.handleEvent = onDocumentCreated(
  'channels/{channelId}/{userId}/{eventId}',
  async (snap, context) => {
    // ...
    switch (channel) {
      case 'sendChatMessage':
        // ...
        const channelRef = db.doc(`channels/${channelId}`);
        const otherUserIds = (await channelRef.get())
          .data()
          .userIds.filter((id) => id != senderId);
        const serverEventsRef = db.collection(
          `channels/${channelId}/server`
        );
        serverEventsRef.add({
          type: 'sendChatMessage',
          targetIds: otherUserIds,
        });
    }
  }
);

Now just like I added Cloud Function triggers in the server, I add client-side Firestore listeners for the server sub-collection:

One key difference is the filtering by targetIds to only get the messages meant for this client:

useEffect(() => {
  onSnapshot(
    query(
      collection(`channels/${chatId}/server`),
      // ✅ filter by targetId
      where('targetIds', 'array-contains', userId)
    ),
    (snapshot) => {
      snapshot.docs.forEach((doc) => {
        const action = doc.data();
        switch (action.type) {
          case 'sendChatMessage':
          // add message to list
        }
      });
    }
  );
}, []);

And I could also abstract this logic into a function to use it several times:

useEffect(() => {
  listen('sendChatMessage', (data) => {
    console.log(data);
  });
}, []);
function listen(channel, callback) {
  onSnapshot(
    query(
      collection(`channels/${channel}/server`),
      where('targetIds', 'array-contains', userId),
      // ✅ filter by type
      where('type', '==', channel)
    ),
    (snapshot) => {
      snapshot.docs.forEach((doc) => {
        const event = doc.data();
        callback(event);
      });
    }
  );
}

So with this, I'd fully replicated real-time server-centric Web Socket functionality in Firebase without spending a dime.

Would work perfectly in Realtime Database too.

Coding Beauty Assistant logo

Try Coding Beauty AI Assistant for VS Code

Meet the new intelligent assistant: tailored to optimize your work efficiency with lightning-fast code completions, intuitive AI chat + web search, reliable human expert help, and more.

See also