loading...
Cover image for Nuxt Socket.IO: How to Create a Stealth-mode Chat Application in Under 10 minutes

Nuxt Socket.IO: How to Create a Stealth-mode Chat Application in Under 10 minutes

richardeschloss profile image Richard Schloss ・7 min read

TL;DR - In the last post in this series, a new namespace configuration feature was presented. A special syntax was presented with the feature, and this article shows how to configure the module to create a simple and anonymous chat application in less than 10 minutes. The goal of this article is to illustrate how much can be done in the nuxt.config, and how much less code would be needed at the page- and component- level to accomplish the task.

Disclaimer: I am the author of the nuxt-socket-io module.


Suggested reading:

  1. Introduction to Nuxt Socket.IO - This describes the Nuxt Socket.IO and basic setup.

  2. Namespace configuration explained - Describes the feature and syntax.

The above items are good to read, however, I will try to write this article to make the example easy to follow, regardless of previous knowledge. Familiarity with Nuxt and VueJS ecosystem should be enough to help you get through the example.

Basic setup:

  1. Clone or fork my git repo: https://github.com/richardeschloss/nuxt-socket-io
  2. Install the dependencies: npm i
  3. Run the server with npm run dev:server
  4. Open at least two browser tabs and navigate to the chat rooms page at: https://localhost:3000/rooms and have fun! Simple chat messages should be sent back and forth between the clients. The rest of the article simply explains what is going on and how this was accomplished.

Configuring the namespaces:

  • First let's take a look at the namespaces config inside nuxt.config.js: Alt Text
  1. First we have a namespace for /rooms. This namespace will only care about any communication at the "rooms" level. Therefore, there is an emitter which will emit an event "getRooms" and the server will respond. When it does, we set the corresponding page's data this.rooms to that response. If we wanted to, we could also set up an extra emitter, say "crudRoom + roomInfo --> rooms" which would send an event "crudRoom" to create/update/delete the room specified in the this.roomInfo message. The response back would set this.rooms to the new response. If race conditions were a concern, we could simply change the name of the destination.

  2. Next we, have a namespace for /room. There are two emitters. One emitter will send the event "joinRoom" with the message "joinMsg". On the page, this.joinMsg will contain information about the user joining the room. The server will handle the joining of sockets to a unique namespace, as this is how the socket.io server works (socket.join is done server-side). After successful join, the server responds and this.roomInfo will get set with that data. The second emitter is there to do the opposite of joining: "leaveRoom" and send the message "leaveMsg", which would contain the user leaving the room. In order to alert other clients of the join and leave events, listeners have to be set up on the clients. Here we simply specify listeners for "joinedRoom" and "leftRoom" events, and also specifying a post hook "updateUsers" to run after receiving the new information. Like in step 1, it's possible that we may want to register even more emitters, for perhaps editing the room info, or notifying existing users about other room-wide events.

  3. Lastly, we have a namespace for /channel. What is channel? Well, it's really just like "/room" but a room inside of a room. The only difference here is we treat the channel as the namespace to allow for message sending and receiving. Therefore, there is an emitter "sendMsg" which will send the "sendMsg" event with the user's message this.userMsg. The server will echo the user's message back (to acknowledge receipt), and after the user receive's the message, the page data this.msgRxd will get set and the post hook appendChats will append the chat to that user's copy of the chat history. For others in the room to see the message, they need to listen for the "chatMessage" event and do exactly the same thing appendChats after receiving the message. Note: in my example, I don't actually use this.msgRxd, but I am still choosing to have it there in case I plan to use it.

Page structure

Here is how the pages are structured in the pages folder:

Alt Text

If you're new to Nuxt, here's the 30 second crash course on automatic route generation (and why 24.5k+ people love Nuxt): Nuxt will automatically create nested routes based on how folders and files are structured in the "pages" folder. If the pages folder contains both a vue file and folder of the same name, then the files in the folder will be treated as children of the parent vue file. The parent vue file just needs to remember to include a <nuxt-child></nuxt-child> in the template so the child pages get placed where the <nuxt-child></nuxt-child> element is. Furthermore, the underscore has a special reserved meaning in NuxtJS. It's used to indicate a parameter-based route. This is exactly what we want. When a child page wants to get the value of that route parameter, it does it by looking in $route.params[childPage]. Therefore, "_room.vue" would look at $route.params.room and "_channel.vue" would look at $route.params.channel.

Rooms Page

Here are the key parts of the rooms page, which will only care about the "rooms" namespace:

Alt Text

The main requirements are instantiating the client, specifying the namespace for the client to use, and defining properties that will expect to receive data. In this case it is this.rooms. What you'll notice is there is no need to define the getRooms method. In fact, doing so may break the plugin! You already specified it once in the nuxt config, and that's all to be done. The plugin will take care of the rest!

Room Page

Here are the key parts of the room page:

Alt Text

Here, like before, simply define the props that were entered in nuxt.config and when it is desired to use the emitter methods, just use them. The post hook "updateUsers" is the only method we need to define.

Now, I think I know what most readers will think. If the plugin can create the emitter methods, can't it also just automatically create the props to save the user one more lengthy step? Well, the answer is yes with a major caveat. For the plugin to absorb that responsibility, it would have to enforce and assume a datatype for every property, most likely an object. While it is my personal style to encapsulate all IO messages in objects, adhering to the format { err: ..., data: ..., meta: ...} all users may not want to be forced to do that. And, since I can't possibly know the requirements of all projects, I could end up alienating a large user base by enforcing that. Some users may want to send simpler data types (numbers, strings) or objects of a different format. Plus, this way, the developers also have control over the initial values for their properties.

Channel Page

Lastly, here are the key parts of the channel page:

Alt Text

Alt Text

This looks almost exactly the same as the room page! In fact, maybe with more effort, I could have reused even more code between the two pages! The only real functional difference is that it is on the channel page where we allow messages to be sent and received.

The user's inputMsg is encapsulated in the page's this.userMsg object, which will also contain the user's name when the "sendMsg" event is sent. This is primarily for illustrative purposes, but it should be noted, that one interesting thing about socket.io is that each socket gets a unique ID (both the client and server will be aware of the socket.id at initial connection). It may be more appropriate to send the socket.id instead of the user name with each event. The server could maintain an id-to-user map in this case.

Bonus (did you notice the extra goodies?)

  1. On the server-side, as a tribute to the way Nuxt does things with routes, my IO module on the back-end automatically registers namespaces based on the folder structure. Files in the "namespaces" folder will automatically accept connections to namespace matching /[filename]. This should make it easier to write the server-side logic. Simply make the methods mirror the front-end methods.

  2. Page-level tests to make testing quicker than manual testing in the browser. If you haven't experienced vue-test-utils, you may learn to love it.

  3. CSS-grid on the front end. So, if you were hoping to learn it, you can learn from these examples (just scroll down to the "style" section where it's used). Moreover, both the room.vue and channel.vue pages use it (so you can nest a CSS-grid inside another CSS-grid; originally, I thought this would break things, but apparently not)

  4. Your username is not a real name, it's a pseudo random number generated based on the time you connected to the rooms page. At any time you want to change your username, you simply click the refresh page and you get a new identity. So somewhat of a "stealth" mode (but not real security, don't rely on this entirely).

Things to Note

  • There is still plenty room for improvement in the plugin and the example. Planned for the near future may be better error handling. Currently, the developer will have to handle errors in the post-level hooks, but I have some ideas for improved solutions in future versions. The socket.io-client under the hood also provides error messages, such as "failure to connect" errors, which can probably be handled cleanly. Stay tuned.

  • The example could include support for CRUD operations so that users can create, edit or delete rooms and channels.

  • Certain parts of the page can probably go into components, such as each chat message in the chat history.

Conclusion

Ok, there you have it. In my headline, I promise "10 minutes" and, considering this was a 7 minute read, you now have 3 minutes to get it working to not make a liar out of me! :). I hope you have fun with it. No need to pay some corporation a ton of money for a chat application...you just launched one for free! And yes, I won't track your chats! Happy chatting!

Discussion

pic
Editor guide
Collapse
ahereandnow profile image
A-Here-And-Now

How do I respond directly to a client's emitter so-as to trigger the response assignement to the --> compomentProp?
I have tried:

const nsp = io.of('/usr');
nsp.on('connection', function (socket) {
    socket.on('beep', function (sock, callback) {
        socket.emit('beep', { data: "1234567890" })
        socket.emit('boop', { data: "1234567890" })
        socket.emit('bap', { data: "1234567890" })
    })
});

none of these trigger my clients special nuxt.config parameters/listeners:

namespaces: {
    '/usr': {
        emitters: ['beep --> boop'],
        listeners: ['bap --> blop']
    }
}

this is what my data looks like in the component in which I call this.socket = this.$nuxtSocket({ name: 'main', channel: '/usr'}):

data() {
    return { boop: {}, blop: {} }
}

When I call this.beep() it does trigger your module's special config to actually send the emitter, and the server gets it and runs the socket.on('beep'){} from above, but nothing else is working at all. My data props boop and blop experience no change.

EDIT: correction... the blop data was indeed filled... but still, I'm clueless as to how to reply to the 'beep' emitter directly so-as to trigger the setting of its respective componentProp (called 'boop' above).

Collapse
richardeschloss profile image
Richard Schloss Author

It would be done like this, in the current implementation:

namespaces: {
  '/usr': {
    emitters: ['beep --> boop'], // You emit 'beep' and want the response to be assigned to 'boop'
    listeners: [
      'beep', // 'beep' emitter triggers 'beep' to be emitted back
      'bap --> blop', // 'beep' also triggers 'bap', but assign 'bap' to 'blop'
    ]
  }
}

However, on the server-side you also have "callback" as an argument which can also just be called directly to callback to the emitter:

const nsp = io.of('/usr');
nsp.on('connection', function (socket) {
  socket.on('beep', function (sock, callback) { // "sock" should be "msg", contains client data
    const data = "1234567890"
    const others = ['boop', 'bap']

    others.forEach((clientEvt) => {
      socket.emit(clientEvt, { data })
    })

    // [omit]: socket.emit('beep', { data })
    // Instead use the callback argument:
    callback({ data: "1234567890" })    
  })
});
Collapse
ahereandnow profile image
A-Here-And-Now

Wow. That is exactly what I wanted. Thank you so much for the speedy reply! This helps a lot. I just have two more questions, if you can answer these as well I am pretty sure I would be all set to fully implement this in my project.

If I read the documentation correctly, with the new 'listener' you have:

listeners['beep']

this will have the corresponding response data assigned to a prop called 'beep',
due to NOT assigning a prop name with:

--> propName, 

Is that correct?

Also, for this listener you've implemented, the callback method you've shown will not trigger this listener, correct? I don't specifically need it to, just want to be clear what invoking callback() is doing.

Basically my question is... what exactly does the callback invocation do?

Just to set forth what I believe it to be based on your docs and my minor testing, it looks like the callback will go to the emitter and assign 'boop' but will not trigger the 'emit' listener on the client side. The flip side of that is that this:

socket.emit('beep', {data})

will trigger the client-side 'emit' listener but not the emitter, therefore a property of 'beep' will be assigned to the response data and 'boop' will remain empty.

With that said, when calling the 'beep' emitter method from the component like so:

this.beep();

Will that immediately trigger this 'beep' listener on the client side that you have put in place?

Do I seem to understand it fully? The callback goes directly back to the source of the request as a response and is not an emit-event at all?

Thread Thread
richardeschloss profile image
Richard Schloss Author

First statement is correct. When 'beep' event comes in, it's data will be assigned to prop 'beep' if it's defined on the component.

The callback doesn't fire an event, it only responds to the emitter directly; i.e., there's no need to have an extra listener set up to listen for the server's response to the emitted event. Just call the callback function.

Thread Thread
ahereandnow profile image
A-Here-And-Now

Perfect. One more thing:

With the this.beep() method that gets declared by your module according to my nuxt.config file, say I want to actually do stuff in my beep() method that will affect the data that is sent along with the emit event. Can I just do some processing in my beep() method and say

data = {myData:"myString"}
return data

or does the emitter fire asynchronously before the beep method processing is completed?

How does this work?

Thread Thread
ahereandnow profile image
A-Here-And-Now

Also, How does the pre-emit hook and post-emit hook work with respect to passing data into the emit event or receiving the leftover data after the componentProp setter functions are processed? Is there a way to cancel the emit propagation if in the pre-emit hook function the application finds something wrong with the data the user has provided?

Thread Thread
richardeschloss profile image
Richard Schloss Author

Actually, in your specific code snippet, the processing there is simply setting custom data, which would be specified in the nuxt.config entry as the "msg":

// nuxt.config:
emitters: [ 'beep + msg --> boop' ]

// component.vue:
data {
  return () { 
    msg: { myData: 'myString' }
  } 
}

That msg will get sent with the beep event. If you require custom processing, you do that in the pre-emit hook, and share the data using Vue's data.

With regards to cancelling an emitter event, while it's not currently implemented in the plugin, it would be done by setting a validation flag in the pre-emit hook, and watching that value. When it changes and becomes true, emit the event (i.e., call this.beep).

Thread Thread
ahereandnow profile image
A-Here-And-Now

Thank you for all of your replies, Richard. This has set me up well!

Thread Thread
richardeschloss profile image
Richard Schloss Author

Actually, I apologize for my answer to the second question. I realize I may have misspoke to quickly and perhaps stated something that could be incorrect. In the current implementation of the plugin, this is what is going on in the emitter:

  await runHook(ctx, pre) // run the pre-emit hook
  return new Promise((resolve, reject) => {
    ...
    // Emit the event "emitEvt" with msg:
    socket.emit(emitEvt, msg, (resp) => { 
      // Handle response
    })
    ...
  })

So, the plugin is currently not checking the return value of the runHook, but I guess the next version can check for a validation value (true or false). If validation fails, the emitter should not emit the event. However, to prevent breaking code for existing users of the plugin, it should only operate on the return value if it's defined (I'll keep thinking about this). The other design challenge is: do I treat the return value as a validation value or as data that should be propagated to the emit "msg"? Maybe some happy combination can be made.

But, back to your question...so for now since the plugin does't check the pre-emit hook's validation value, you would just update your code as follows:

nuxt.config:

// emitters: [ 'checkBeep] beep + msg --> resp' ] // old
emitters: [ 'beep + msg --> resp' ] // workaround

And then in your component.js:

checkBeep() { // You'd still have this method defined
  if (this.inputValid) {
    // valid input:
    this.beep() // "beep" gets emitted with "msg"
  }
}

This way, you can still have checkBeep defined, but just omit it from the nuxt.config entry so that the plugin doesn't call it. I know going forward, it will be cleaner to let the plugin do it, so I'll just need some time to think about it. (the challenge is when a lot of people have already downloaded it, there's a small risk I'll break there existing code, so I just have to take that into consideration too)

Collapse
kp profile image
KP

Thanks for this tutorial @richardeschloss ...it's exactly what I was looking for.
One question..when you say "I wont track your chats"...does the data hit your servers? Does this codebase depend on any 3rd party servers or services at all? Thx!

Collapse
richardeschloss profile image
Richard Schloss Author

I won't track your chats because I technically can't. The chats only go between your IO clients and IO server. There is no code in the plugin that relays the chats to anywhere else. I believe the same can be said for the underlying EngineIO. However, the beautiful thing with open source is, if anyone does not trust my statement, he/she is free to look in the source code (plugin.js) to search for suspicious code and comment out as much or as little of the code as desired. However, I don't have any such tracking code in my plugin.

Collapse
kp profile image
KP

@richardeschloss that makes sense, thanks for clarifying :) I'll try it out and let you know if any questions come up!

Thread Thread
richardeschloss profile image
Richard Schloss Author

@kp , If interested, here's a hosted demo. I just deployed it today.