Realtime Meteor Server

Note: If you skipped ahead to this section, click here to download a zip of the tutorial at this point.

Meteor Client Side package

We want to have Meteor essentials available in our client so we can interface with our Meteor server.

We gonna install a package called meteor-client-side which gonna provide us with them:

$ npm install --save meteor-client-side

And let's import it into our project, in the src/app/main.dev.ts file:

3.2 Import meteor client side into the project src/app/main.dev.ts
1
2
3
4
5
import 'meteor-client-side';
 
import { platformBrowserDynamic } from [email protected]/platform-browser-dynamic';
 
import { AppModule } from './app.module';

And src/app/main.prod.ts

3.3 Import meteor client side into the project, production src/app/main.prod.ts
1
2
3
4
5
import 'meteor-client-side';
 
import { platformBrowser } from [email protected]/platform-browser';
import { enableProdMode } from [email protected]/core';
 

Meteor Server

Now that we have the initial chats layout and its component, we will take it a step further by providing the chats data from a server instead of having it locally. In this step we will be implementing the API server and we will do so using Meteor.

First make sure that you have Meteor installed. If not, install it by typing the following command:

$ curl https://install.meteor.com/ | sh

We will start by creating the Meteor project which will be placed inside the api dir:

$ meteor create api

NOTE: Despite our decision to stick to Ionic's CLI, there is no other way to create a proper Meteor project except for using its CLI.

Let's start by removing the client side from the base Meteor project.

A Meteor project will contain the following dirs by default:

  • client - A dir containing all client scripts.
  • server - A dir containing all server scripts.

These scripts should be loaded automatically by their alphabetic order on their belonging platform, e.g. a script defined under the client dir should be loaded by Meteor only on the client. A script defined in neither of these folders should be loaded on both. Since we're using Ionic's CLI for the client code we have no need in the client dir in the Meteor project. Let's get rid of it:

$ rm -rf client

Now, since we will be writing our app using Typescript also in the server side, we will need to support it in our Meteor project as well, especially when the client and the server share some of the script files. To add this support let's add the following package to our Meteor project:

$ cd api
$ meteor add barbatus:typescript

And because we use TypeScript, let's change the main server file extension from .js to .ts (api/server/main.ts).

And we need to add TypeScript config file also to the server side, so let's add it under api/tsconfig.json:

3.8 Created tsconfig.json api/tsconfig.json
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
{
  "compilerOptions": {
    "target": "es5",
    "lib": [
      "es6",
      "dom"
    ],
    "module": "commonjs",
    "moduleResolution": "node",
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true,
    "sourceMap": true
  },
  "exclude": [
    "node_modules"
  ],
  "files": [
    "typings.d.ts"
  ],
  "compileOnSave": false,
  "angularCompilerOptions": {
    "genDir": "aot",
    "skipMetadataEmit": true
  }
}

Note that we declared a file called typings.d.ts which will load any external TypeScript types, so let's add the file with the required typings:

3.9 Created TypeScript typings file api/typings.d.ts
1
2
/// <reference types="meteor-typings" />
/// <reference types="@types/underscore" />

And we will also need to add some missing package for our server side, so run the following command inside api directory:

$ meteor npm install --save meteor-node-stubs zone.js meteor-rxjs meteor-typings babel-runtime

Now, in order to have access to the TypeScript interface we created in the previous step also in the server side, let's move the models directory into the api directory.

Remember the alias we created in the first step, for api directory? this is why we created it! so we can share TypeScript file between the server and the client!

And update the import path in the TypeScript config file of the client side, after moving the directory:

3.11 Updated import path src/declarations.d.ts
11
12
13
14
15
  For more info on type definition files, check out the Typescript docs here:
  https://www.typescriptlang.org/docs/handbook/declaration-files/introduction.html
*/
/// <reference path="../api/models/whatsapp-models.d.ts" />
declare module '*';

Collections

In Meteor, we keep data inside Mongo.Collections.

This collection is actually a reference to a MongoDB collection, and it is provided to us by a Meteor package called Minimongo, and it shares almost the same API as a native MongoDB collection.

We can also wrap it with RxJS' Observables using meteor-rxjs.

That package has been already installed, we installed it earlier in the server side!

Let's create a Collection of Chats and Messages:

3.12 Created Collections api/collections/whatsapp-collections.ts
1
2
3
4
import { MongoObservable } from "meteor-rxjs";
 
export const Chats = new MongoObservable.Collection("chats");
export const Messages = new MongoObservable.Collection("messages");

Data fixtures

Since we have Collections, we can now move on to fill them with data and later we will connect that data into the client side.

So first, let's add moment to the server side, run the following command inside api directory:

$ meteor npm install --save moment @types/moment

And let's create our data fixtures in the server side:

3.14 Added stub data to the collection in the server side api/server/main.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
import { Meteor } from 'meteor/meteor';
import { Chats, Messages } from "../collections/whatsapp-collections";
import * as moment from "moment";
 
Meteor.startup(() => {
  if (Chats.find({}).cursor.count() === 0) {
    let chatId;
 
    chatId = Chats.collection.insert({
      title: 'Ethan Gonzalez',
      picture: 'https://randomuser.me/api/portraits/thumb/men/1.jpg'
    });
 
    Messages.collection.insert({
      chatId: chatId,
      content: 'You on your way?',
      createdAt: moment().subtract(1, 'hours').toDate()
    });
 
    chatId = Chats.collection.insert({
      title: 'Bryan Wallace',
      picture: 'https://randomuser.me/api/portraits/thumb/lego/1.jpg'
    });
 
    Messages.collection.insert({
      chatId: chatId,
      content: 'Hey, it\'s me',
      createdAt: moment().subtract(2, 'hours').toDate()
    });
 
    chatId = Chats.collection.insert({
      title: 'Avery Stewart',
      picture: 'https://randomuser.me/api/portraits/thumb/women/1.jpg'
    });
 
    Messages.collection.insert({
      chatId: chatId,
      content: 'I should buy a boat',
      createdAt: moment().subtract(1, 'days').toDate()
    });
 
    chatId = Chats.collection.insert({
      title: 'Katie Peterson',
      picture: 'https://randomuser.me/api/portraits/thumb/women/2.jpg'
    });
 
    Messages.collection.insert({
      chatId: chatId,
      content: 'Look at my mukluks!',
      createdAt: moment().subtract(4, 'days').toDate()
    });
 
    chatId = Chats.collection.insert({
      title: 'Ray Edwards',
      picture: 'https://randomuser.me/api/portraits/thumb/men/2.jpg'
    });
 
    Messages.collection.insert({
      chatId: chatId,
      content: 'This is wicked good ice cream.',
      createdAt: moment().subtract(2, 'weeks').toDate()
    });
  }
});

Quick overview. We use .collection to get the actual Mongo.Collection instance, this way we avoid using Observables. At the beginning we check if Chats Collection is empty by using .count() operator. Then we provide few chats with one message each.

We also bundled Message with a Chat using chatId property.

Now, in order to use those Collection in the client side, we first need to make sure that we have meteor-rxjs support in the client side as well.

Add it by running the following command in the root directory:

$ npm install --save meteor-rxjs

UI

Since Meteor's API requires us to share some of the code in both client and server, we have to import all the collections on the client-side too.

We also want to provide that data to the component:

3.16 Added the chats with the last message using RxJS operators src/pages/chats/chats.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import { Component, OnInit } from [email protected]/core';
import { Observable } from "rxjs";
import { Chat } from "api/models/whatsapp-models";
import { Chats, Messages } from "api/collections/whatsapp-collections";
 
@Component({
  templateUrl: 'chats.html'
})
export class ChatsPage implements OnInit {
  chats;
 
  constructor() {
 
  }
 
  ngOnInit() {
    this.chats = Chats
      .find({})
      .mergeMap((chats: Chat[]) =>
        Observable.combineLatest(
          ...chats.map((chat: Chat) =>
            Messages
              .find({chatId: chat._id})
              .startWith(null)
              .map(messages => {
                if (messages) chat.lastMessage = messages[0];
                return chat;
              })
          )
        )
      ).zone();
  }
 
  removeChat(chat: Chat): void {

As you can see, we moved chats property initialization to ngOnInit, one of the Angular's lifehooks. It's being called when Component is initalized.

Here comes a quick lesson of RxJS.

Since Chats.find() returns an Observable we can take advantage of that and bundle it with Messages.find() to look for last messages of each chat. This way everything will work as a one body, one Observable.

So what's really going on there?

Find chats

First thing is to get all the chats by using Chats.find({}).

The result of it will be an array of Chat objects.

Let's use map operator to make a space for adding the last messages.

Chats.find({})
    .map(chats => {
        const chatsWithMessages = chats.map(chat => {
            chat.lastMessage = undefined;
            return chat;
        });

        return chatsWithMessages;
    })

Look for the last message

For each chat we need to find the last message. We can achieve this by calling Messages.find with proper selector and options.

Let's go through each element of the chats property to call Messages.find.

const chatsWithMessages = chats.map(chat => Messages.find(/* selector, options*/));

That returns an array of Observables.

We need to create a selector. We have to look for a message that is a part of required chat:

{
    chatId: chat._id
}

Okay, but we need only one, last message. Let's sort them by createdAt:

{
    sort: {
        createdAt: -1
    }
}

This way we get them sorted from newest to oldest.

We look for just one, so selector will look like this:

{
    sort: {
        createdAt: -1
    },
    limit: 1
}

Now we can add the last message to the chat.

Messages.find(/*...*/)
    .map(messages => {
        if (messages) chat.lastMessage = messages[0];
        return chat;
    })

Great! But what if there are no messages? Wouldn't it emit a value at all?

RxJS contains a operator called startWith. It allows to emit some value before Messages.find beings to emit messages. This way we avoid the waiting for non existing message.

The result:

const chatsWithMessages = chats.map(chat => {
    return Messages.find(/*...*/)
        .startWith(null)
        .map(messages => {
            if (messages) chat.lastMessage = messages[0];
            return chat;
        })
})

Combine those two

Last thing to do is to handle the array of Observables we created (chatsWithMessages).

Yet again, RxJS comes with a rescue. We will use combineLatest which takes few Observables and combines them into one Observable.

It works like this:

const source1 = /* an Observable */
const source2 = /* an Observable */

Observable.combineLatest(source1, source2);

This combination returns an array of both results (result). So the first item of that array will come from source1 (result[0]), second from source2 (result[1]).

Let's see how it applies to our example:

Observable.combineLatest(...chatsWithMessages);

We used ...array because Observable.combineLatest expects arguments, not a single one that with an array of Observables.

To merge that observable into Chats.find({}) we need to use mergeMap operator instead of map:

Chats.find({})
    .mergeMap(chats => Observable.combineLatest(...chatsWithMessages));

In Whatsapp we used chats.map(/*...*/) directly instead of creating another variables like we did with chatsWithMessages.

With all this, we have now Chats with their last messages available in the UI view.

Run the following command in the api directory before running ionic serve:

$ meteor