24 Листопада, 2017 від Yurii Vlasiuk

How we built AI Chatbot Using JavaScript and ChatScript

How we built AI Chatbot Using JavaScript and ChatScript

The main task was to implement engine for natural language recognition to develop artificial intelligence (AI) for conversational chatbot to be used by the insurance company in the US. By the time we started our work, the customer had already had UI represented by the button menu inside the Facebook chat. We needed to extend this part with possibility for a bot to understand the user’s message and to get information from input depending on the phrase. As a solution, we decided to divide all possible phrases in this topic by possible user’s intents and implement a mechanism of this intents recognition from the set of phrases. Some of the intents could also have different parameters (e.g. house area, date, car model) that needed to be recognized by the chatbot.

For a start, let’s look closer at ChatScript. CS is chatbot engine that won the Loebner’s 4 times. It was created by Sue and Bruce Wilcox. It is rule-based, and it might resemble a declarative programming approach similar to writing a configuration file or grammar for an interpreter. Yet, CS is more imperative since you should also use commands to tell how to react to this or that message. It is written in C++ and has binary builds for Windows, Linux, and MacOS platforms.

Bulding a basic bot example from scratch

The best way to understand how to work with a tool is to try it in practice by writing something small and easy to understand. To start with CS, I suggest reading an article on how to build your first chatbot using ChatScript or trying a CS tutorial.

First of all to make coding on CS more comfortable, I suggest install highlinting CS language. Here you can find plugins for Sublime Text 3Visual Studio Code and Atom. I was using first one, because it was needed to open huge files from time to time and Sublime was fast in this cases, but you can use any editor you like.

To clarify the basic moments, I will go through all the steps needed to have a chatbot up and running. For my example, I’ve used Ubuntu 16.04 and CS 7.4, but you can run it on any of the supported platforms. The steps of the process:

1. Clone CS repository from GitHub:

git clone https://github.com/bwilcox-1234/ChatScript.git

2. Go to CS directory and create a folder for your chatbot with a file for the main topic, and filesfood.txt describing a list of topics to be included:


cd ChatScript/RAWDATA

mkdir FOOD

touch FOOD/food.top

touch filesfood.txt

3. Copy the topic simplecontrol.top from RAWDATA/HARRY into the FOOD folder (This is a necessary script to interact with our bot. Optionally you can change in simplecontrol.top in 9th line value of $botprompt variable to string you want show before each bot message. But you can leave it as it is already set to HARRY: this part will not change behaviour at all. In my example I’ve used next one prompt $botprompt = ^"fastfood> "):


cp HARRY/simplecontrol.top FOOD/simplecontrol.top

4. Add this code to the file food.top:

topic: ~fastfood keep repeat []

t: Hello in our online fastfood. Please make your order.

u: BURGER (I [want need take] _[burger potato ice-cream])

  $order = _0

  Okay, you want $order . Something else?

5. Add the list of files you want to filesfood.txt to build your bot:



6. Finally, build and run your bot. Run command from ChatScript directory:

./BINARIES/ChatScript local

7. Prompt any username you like. Now when you are in CS console, run two commands (first build 0 layer of chatbot, then comes your chatbot for fastfood):

:build 0

:build food

When it’s all done with this ChatScript tutorial, you have a working chatbot. For now, it can handle a few phrases only (I need a burger, I want ice-cream, …), but you can extend them adding new rules and topics. After any change made to food.top, you should run :build food command again. Later on, we are going to discuss more details about the syntax and constructs used in this example.

Main ChatScript constructs

Now you are ready to start coding in CS. First, I suggest learning a bit more about main CS constructs from the official documentation. In this article, I am presenting my personal notes on the topic.


Topic is a collection of rules which you want to use together. If you tell a system to execute the exact topic, only rules from it will be acting as long as you are staying in this topic. Declare the topic using a keyword (topic:), the name starting with “~” (~fastfood) and a list of functions (keep repeat) to be used for all rules inside of it (keep and repeat are needed to return to this topic after firing each rule inside of it) and [ ] :

topic: ~fastfood keep repeat []

Each topic usually includes a bunch of rules related to it. Switching between topics or, in other words, calling one topic from another is performed by ^respond method (more details will follow in the next section).


Rule is an action firing when a pattern inside it matches the input sent to chatbot. It should be placed after topic declaration.

u: BURGER (I want ari-burger) Okay, your order is hamburger

Rules usually include type (u:), label (BURGER – it is optional but useful for debugging and self-documenting your code), pattern (everything inside parenthesis), and output (everything after parenthesis). Rule can also be split into multiple lines to make code more readable – CS doesn’t care about new lines, it simply looks for declaration of a new rule or topic and only that is signal that this rule is ended. Rule can move us to another topic using CS function ^respond:

u: BURGER (I want ari-burger)


In this case, the input will be handled by the rules from the topic passed to ^respond function. This approach can give as an opportunity to divide your CS script into separate parts or, for instance, move formatting answer for phrases to a separate topic.


Variables are a mechanism to store information from users input. There are match variables (or short-term memory – clears values after exiting from patterns), and user variables (or long-term memory – store value till you reset it). Here is an example of our rule extended with memorization:

u: ORDER (I want _)

   $order = _0

   Okay, your order is $order

Underscore _ is a match variable ( you can specify how many words you want to remember using wildcard _* for all words or _*2 for two words and so on). As a result, the word in input after “I want” will be stored in short-term memory. To access this value, _0 is used in the second line, so the pattern could include as many match variables as needed (actually up to 20, but it’s really enough), while getting values from them is performed via using underscore and the order number of match inside the pattern. In this example, $order is a user variable and will store what was inside _0.


Patterns mean described order and sets of words which are expected in an input. The most powerful thing for me: there is no need in CS to add all forms of each word to your pattern. It is already an inbuilt feature allowing for all forms of the word to be found if the pattern includes only one of them.

For example, if we take a verb “be”, adding it to the pattern will also include matches of this word in all its forms: am, is, are, was, were, been. However, for input with auxiliary verb (will, have, do, …), you should still add them – CS only cares about forms of a separate word. For this purpose, I suggest using another pattern feature – optional word in curly brackets :

u: BURGER (I {will} take _burger)

The same with nouns and pronouns – it’s enough to add nominative singular form of it, then finding match for all other word variations CS will perform under the hood. In pattern, you can also extend variation of phrases that match using sets of words on some position in pattern. Extending our example, we can add something like this:

u: BURGER (I {will} [want need take] [_burger hamburger potato ice-cream])

   $order = _0

   Okay, your order is $order

This rule will match all inputs with combinations of these words (e.g. “I want a hamburger”, “I will take a potato”, “I need ice-cream”, …). Another important issue with memorization, which I haven’t found in the official CS docs, is how match variables work in sets (correct me if you find this in their manual). Actually, in this case, it will store in short-term memory any matched word from this set in _0 . $order will have value in any case when the matching pattern fires.

You can also control the beginning and ending of input using < and > respectively:

u: BURGER (< I {will} [want need take] [_burger hamburger potato ice-cream] >) 

You can do even more cool features inside the patterns – check values to match some criteria. If you want only numbers inside the specified range:

u: OLD_ENOUGH (I be _~number _0>21 _0<120)

   You are old enough for this.

u: TOO_YOUNG (I be _~number _0<21)

   $missed_age = 21 - _0

   You are too young for this, come after $missed_age years. 


Concept is a set of words or word combinations bound to one keyword (concept name). Declaration is similar to the topic and should be outside of it. List all the words related to this concept in square brackets.

concept: ~food_type [burger potato salad ice-cream]

After declaring concept, it could be used as alias in rules like this (now our pattern will match inputs that include only the specified words in concept ~food_type):

u: BURGER (I want _~food_type)

 $order = _0

 Okay, your order is $order

And even more, you can nest one concept within another to create additional abstraction level grouping different sets of words in one parent concept:

concept: ~dessert [ice-cream sweets cookie]

concept: ~burger [burger hamburger cheeseburger vegeterainburger]

concept: ~food_type [~burger ~dessert potato salad]

If you want to find out to which concept the matched value belongs, you can use pattern construct. In the example below, the value stored in $drink variable checks if it meets the concept ~alcohol. For this purpose, we use pattern and ? in if-condition keyword between value and target for search concept (yes, CS supports if-else statements):

concept: ~drink_type [~alcohol ~non_alcohol]

concept: ~alcohol [rum gean wiskey vodka]

concept: ~non_alcohol [cola juice milk water]

u: DRINK (^want(_~drink_type))

   $drink = _0

   if (pattern $drink?~alcohol) {


   } else {

       Ok, take and drink your $drink .


If you want to match not only single words, but also word combinations you can also add them to concept, putting them in quotes or using underscores among separate words in one phrase (this approach is also used when you need match phrase some punctuation signs):

concept: ~vegburger ["vegeterian burger" "vegeterian’s burger" vegan_burger vegan_’s_burger]

These approaches are similar but I prefer to use quotes, since they make it easier to read the code.

Another cool CS feature is engine-defined concepts. It provides you with already prepared sets for most commonly used in natural language phrases and single words with the same or similar meanings. There are concepts ~yes and ~no, which include such kind of answers that could be interpreted in live language as confirmation or rejection. For example, words and phrases yes, yeah, ok, okay, sure, of_course, alright and many others (183 different phrases for ~yes and 138 for ~no are included in these concepts) are already there. Other concepts I found useful for our project are:

  • ~number (helps match any number)
  • ~yearnumber (subset of ~number containing only values between 999 and 10000)
  • ~dateinfo (matches any date using date format with slashes mm_dd_yy or mm_dd_yyyy – thу matched value will be returned as string “mm / dd / yy” or “mm / dd / yyyy”)
  • ~timeword (will match full date like “1 July 2017” as well as “July 1 2017” will return “July 1 2017” in both cases. This concept also contains a huge set of time-related words e.g. second, yesterday, already, etc.)

Find all inbuilt concepts here.

If for some reason you want to extend the existing engine-defined concept, you may add records in LIVEDATA_ENGLISH_SUBSTITUTES/interjections.txt

<roger_that> ~yes 

and simply restart chatscript engine – this will add phrase “roger that” to ~yes, brackets mean that it will match only these two words in input and nothing else.

Additionally, the existing concepts can be extended in another way – you can add new values to an already existing one using MORE:

concept: ~food [burger potato]

concept: ~food MORE [ice-cream]


To make reusing the code possible, CS has macros – custom functions called to generate output or reuse them in your patterns. In our project, we worked with JSON, so the output should be formatted in the proper way to be further parsed in JS class-wrapper. For this purpose, I decided to prepare a string which could be easily parsed in JavaScript. Each time having an output, I formed it as a JSON string. CS, however, have methods to work with JSON. If connect this with using outputmacro, we can get an elegant way to format output for api on the application backend:

outputmacro: ^formated_in_json(^param_from_rule)


   $_result = ^jsoncreate(object)

   $_result.first_level_param = ^param_from_rule

   $_result.nested_object = ^jsoncreate(object)



Then, using macro in rule is the same as in the case with CS native ^respond function:

u: FOOD (I want _~food_type)


As you can write macro for formatting multiple outputs depending on the parameter, you are also able to create it for patterns with similar constructs for a bunch of rules using patternmacro:

patternmacro: ^want(^appendix)

   [i we] * [want need take] ^appendix

A part of the pattern returning by macro can be reused in multiple patterns, making your script less tangled:

u: FOOD (^want(_~food_type))

   If you want _0, you should get \_0 .

u: DRINK (^want(_~drink_type))

   Ok, take and drink your _0 .

ChatBot schema

To illustrate usage and place each of the described above CS constructs, you can look at this schema.

Figure 1 – ChatBot schema

As you can see, topics, concepts and macros should be declared on the top layer of Chatbot. Then rules are nested inside the topics. Each rule have pattern as an entry point, and some kind of responder or body of rule, which executes only when the current pattern fires. Concepts and pattern macros declared outside the topic then are used inside the rule with short-term memory variables to save values needed for response. At the same time, output macro can be called in responder. Long-term memory variables are used to pass values from short-term to another topics in case the responder just calls ^respond(~another_topic). It means that for output to user will take care rules in the outer topic.

ChatScript environment

ChatScript CLI

CS provides CLI which helps in development and needed to build new chatbot versions. First commands you will need are of course :build, and first it requires building 0 layer to use system predefined concepts (e.g. ~yes, ~timeinfo, ~number) and then your custom bot specifying the same name, you’ve used for fileXXX.txt (where XXX is the name) with the list of topics used in the script.

Another useful command is :reset – it returns the bot to initial state and also erases all long-term variables (these values are stored even after build).

Using :trace can show full stack trace of rules and topics fired during evaluating the latest input.

Another useful command :why shows rules causing most recent output.

For switching between users, there is :user command which takes username as a parameter.

:quit will stop CS process.

While you add new rules, it can be very useful to check if it’s not breaking patterns you have made before. For this purpose, CS gives us a handy :verify command. What is needed from developer is to add before each pattern set of phrases which must be fired with it. Syntax for this verification is set as such:

#! I want burger

#! I will take salad

#! I need icecream

u: BURGER (I {will} [want need take] ~food_type

Execution of the command will give us a detailed info about the number of inputs that have passed and which ones have failed:

fastfood> :verify fastfood

VERIFYING ~fastfood ......

Pattern failed to match 1 ~fastfood.1.0:  I need icecream => u: FOOD ( ^want ( _~food_type ) )

   Adjusted Input: I need ice_cream

   Canonical Input: I need ice_cream

1 verify findings of 3 trials.


CS is a light-weight process, so it is possible to run a few instances of it on one machine. More than that, such operations as pattern comparison and concept search are well optimized, work really fast and can handle multiple connections. To check if this is really true, we made our own small benchmark to test how CS will act in high load situation. Our custom JS class-wrapper sent a collection of 10K inputs and measured the time needed for engine to respond to them. View the test results below:

Total messages count:  10000

Chunks count:  100

Chunks size:  100

Percentage of the requests served within a certain time (ms)

10%:  10

20%:  13

30%:  15

40%:  17

50%:  19

60%:  21

70%:  23

80%:  25

90%:  27

100%:  29

Time taken for tests:  19.972  seconds.

Requests per second:  500.70098137392347 [#/sec]

Time per request:  1.9972 [ms]

The request per second is less than the time for each one of them. This happened because the test chunks of messages were sent asynchronously resolving portions of 100 promises with sending messages – that’s why the summary time is smaller than sum of times for each message.

CI in CS

When you extend bot implementation, it’s good to be sure that anything written before did not get broken due to the last updates (which actually occurs frequently, especially when the patterns you write are too general and can match not only inputs you’ve expected). For this purpose, CS has mechanisms to perform regression during development. To prepare dataset for this, you should do the following:

  • Make a fresh build with stable version of your chatbot. In CS CLI:
./BINARIES/ChatScript local debug=":build 0" > /dev/null

./BINARIES/ChatScript local debug=":build food" > /dev/null
  • Create a file with the list of all phrases you want to check during regression. You can place it anywhere you want, but I prefer to store them in REGRESS directory of the ChatScript project. This list should start with setting a user to be used for regress and made reset to clear state if something has been already set to long-term memory. For our fastfood chatbot from example, it may look like this:
:user test :reset I want burger I will take salad I need ice-cream :reset
  • Add the outputs from regress dataset to the user log. It will take your inputs and generate outputs sending them one by one to chatbot. Command must include the specified username for the user who will perform regress and the location of snapshot output. Again, you can place it anywhere you want, but I suggest to keep this sample output in RAWDATA_YOURBOT directory. You can create a separate folder for this purpose, in our case we’ll have RAWDATA_FOOD/TEST. Generating output can be performed with the following command :
./BINARIES/ChatScript local login=test_user source=REGRESS/food.txt > /dev/null
  • Initialize the regress file (type :quit in CS console after it finishes):
./BINARIES/ChatScript local login=test_user debug=":regress init test_user RAWDATA/FOOD/TEST/food.txt
  • Check your regress test (whether it’s passed or not):
./BINARIES/ChatScript local login=test_user debug=":regress RAWDATA/TEST/food.txt"

My approaches and suggestions

Resolving collisions in concepts

Writing a bot to handle inputs related to one theme, you must create custom concepts. It is a good practice to group the related by meaning words and phrases only – it helps to reuse concepts in different patterns and you’ll always know what terms in the concept mean. In the ideal world, if all words are different the mechanism works great. Yet, when you work with big lists of terms, same words might appear in different concepts. It’s okay not to use these concepts in one pattern. Well, I was not so lucky and actually I had to match and get values from concepts which have collisions in one kind of phrases. The main problem was that we actually didn’t know anything about the order of terms from the concept in a phrase. That is why I have to use << … >> construct in the pattern to match words in any order:

u: ANY_ORDER_MATCH (I want << {_~concept _A} {_~concept_B} >>) 

This pattern works in such a way: after CS engine finds the first value in ~concept_A of concepts then it won’t search for it in ~concept_B. This brings us to type I errors or false positive match. Since we did not want to lose correct values that did not matched because of collision, I found how resolve this issue in three ways.

First approach is to exclude values that are in collision between ~concept_A and ~concept_B. This will not be suitable if you need extra accurate results since you’ll lose a set of values.

Second option is to suggest the user to use parameters in the same order they are mentioned in your pattern. This can be used only if you have the defined workflow of chatting with the user and know beforehand when he will type a certain phrase.

Finally, the third way is to organize the sets of terms moving all collision values to a separate concept (fig. 1), then adding the additional concept with all collisions to the pattern will give matches if values are in collision and not:

u: ANY_ORDER_MATCH (I want << {_~concept_A} {_~concept_B} {_~concept_A_collision} >>) 

Multiple themes in one chatbot vs multiple themes in multiple chatbots

In our project, I faced the challenge to implement the chatbot for a few different themes. Each of them could have the same input messages, but they must be handled with another input depending on which theme you are talking about. To solve this issue, I found two approaches. First option lies in the message layer. We could specify an additional prefix that will be related to a separate theme and add it to each message which should be processed in the context of this theme. For example, we have vegetarian fast food, and a common one. In this case, the question “which salads do you have?” should have different answers. With this approach, the rules will look like this:

u: (< vegetarian fastfood what salads)

   We have $list_of_vegeterain_salads

u: (< common fastfood what salads)

   We have $list_of_salads_with_meat 

As we can see, the phrases “vegetarian fastfood” and “common fastfood” will be added to the message before sending to CS. The main benefit of such an approach is that we have only one instance of chatbot that will handle all possible themes. But at the same time, it adds more difficulties to the development process because the script becomes bigger, harder to extend and maintain in the future, it could also create additional collisions between different themes at some stage.

Another approach that could be used in such a situation is to move themes with the same inputs to different separate chatbots. In this case, before starting a bot on the server, each of them needs to have folders for storing CS builds and users data. Each bot will be running independently on a different port. Before starting each instance, you need to specify the location of its folders with build, users state, and port number as additional parameters for CS binary (e.g. ./BINARIES/ChatScript topic=./TOPIC_VEGETARIAN users=./USERS_VEGETARIAN port=1045)

Such an approach makes it easier to extend each chatbot when they are split in several instances. Negative side is that this will create additional load on your server.

Creator of chatcript assert that actually you don’t need to put your different bots on different ports and he is right. Bots can reside all together in a single build. In my business logic I’ve used different ports approach due to make separation of instances and make possible to disable if needed part of chatscript logic easier (e.g. some customers will not need whole support of every theme for each I’ve implemented bot< but only few of them). So different ports for each chatbot is optional, but still can be used in such situation I faced with.

Handling misspells

Mechanism for handling mistyped words is already built in CS engine. Usually long words can be matched even if you make a typo. However, it could happen that a frequent typo is not recognized by the engine. There are a few ways how handle this in CS.

First approach is to describe the word inside the pattern using a wildcard for the part where a misspell can appear:

u: TYPO_PATTERN (I [want need will] use first app*ach)

This pattern will match words starting with “app” and ending with “ach” no matter how many and which characters are among them. So both valid and invalid variants will pass the rule.

Second way is creating a concept for a word with frequently happening typos and include all of them in it. Then you are able to use this concept in patterns instead of a word:

concept: ~frequency IGNORESPELLING [frequency ferquency freuqency]

topic: ~topic_with_typos keep repeat []

u: TYPO_CONCEPT (I know ~frequency of typo)

Note that I’ve added IGNORESPELLING flag – it helps omit warnings about mistakes in the concept during the build process.

Third variant seems the best for me. You can extend the existing spell fix base of engine. To achieve that, the file ChatScript_LIVEDATA_ENGLISH_SUBSTITUTES_spellfix.txt must contain the line with a word and the list typos for it:

misspell mispell

After this, you should merely restart the engine.

Connecting ChatScript with JavaScript

Implementing the logic for handling phrases and learning information about the user is easy in CS, but anyway if you want to use it in the WEB, you need to establish a connection with your back-end somehow. Unfortunately, CS doesn’t provide the possibility to send requests via http. Still, you can use tcp sockets for this purpose. To establish a connection with NodeJS, you require 'net' package and to form a special kind of string before sending it to CS. First of all, you must add prefix and postfix to you message. Prefix consists of a username and name of outputmacro used in your control script with special division characters among them. In our example, we used simplecontrol.top from Harry chatbot, and macro is named “harry” there. To have the connection held, the bot must be running without the “local” parameter (by default it is on 1024 port, but can be changed specifying in command port=PORT_NUMBER, e.g. ./BINARIES/ChatScript port=1055)

Basically, all connections with chatbot from JS will look like this:

const net    = require('net');

const prefix = 'username\x00harry\x00';

const post   = '\x00'

const client = new net.Socket();

client.connect(1024, '', (err) => {

   client.write(prefix + ‘some message’ + post);


client.on('data', (data) => response.toString());

For additional abstraction using Socket object methods you can write JS wrapper Class to speak with CS. It’s enough to have the asynchronous send method and the constructor to set server host and port before starting interaction with chatbot engine. As an additional parameter, you may pass username – CS will know how to answer to different users, because it is stateful and keeps all information about the user learned and written to long-term memory.

Architecture of integrating CS with JS

To clearly understand how to integrate ChatScript with JavaScript, and the architecture in JavaScript web applications that will use ChatScript as an engine for human speech recognition, have a look at the diagram below.

As you can see, the workflow begins in UI. This part depends on the technologies you prefer to use implementing the chat interface for communication of the user with a bot. Then comes the layer of REST API (in our case, it was NodeJS BE). Back-end is directly using JS class-wrapper for CS described in the previous chapter. This part will actually send the user’s messages using tcp sockets in one direction and handle recognized and unrecognized responses from the chatbot in another. The next component is CS implementation that actually performs user input recognition. It could be a huge topic with all the rules in one or the logic separated into multiple chunks – again, it all depends on the approach you choose. For performing this part of functionality, the ChatBot will use CS Engine to make the matching patterns and search in knowledge base and dictionaries. You can also add an additional feature: the custom concepts with the terms related to business logic of your application to make possible giving definitions and answers to FAQ.


When you created a lot of rules in your script it could be a bit hard to find out which exact rule fired the current input in the situation when two patterns are crossing. In this case, the creator of CS suggested using :verify blocking which will show if any topics are crossing. There was another handy approach for me. I decided to create a variable which will store the same name as it was in the label. Then I just attached this variable to all the outputs – this will give an opportunity to find out immediately which exact pattern creates conflict with the current input. In code, it can look like this:

u: BURGER (I {will} [want need take] _~burger)

   $rule = BURGER

   $order = _0


u: DRINKS (I {will} [want need take] _~drink_type)

   $rule = DRINKS

   $order = _0


topic: ~make_order keep repeat nostay []

u: ()

   Okay, you can take your $order. (Catched in rule $rule)

Project structure

In our small example, all code was in one file. But when it gets bigger it will make sense to split the script into parts. That’s why I decided to create directories for concepts, responds, and tests:




   - TESTS

   - food.top

   - simplecontroll.top

The main script that has rules related to one respond can be replaced with the empty pattern rule and ^respond method to call topic from another file (this another topic should have the flag nostay, because we want to return to the main one after handling response):

# main topic in file RAWDATA/FOOD/food.top

topic: ~food keep repeat []

u: DRINKS ()


u: MEALS ()

# topic in file RAWDATA/FOOD/RESPONDERS/drinks.top

topic: ~drinks keep repeat nostay []

u: DRINK (^want(_~drink_type))

   Ok, take and drink your _0 .

If concepts include a huge amount of terms they can be declared in separate files too. It will make the project easier to maintain and extend in future.

# concepts in file RAWDATA/FOOD/CONCEPTS/food_concepts.top

concept: ~food_type [burger potato salad ice-cream vegan_'s_burger]

concept: ~drink_type [~alcohol ~non_alcohol]

concept: ~non_alcohol [cola juice milk water]

concept: ~alcohol [rum gean wiskey vodka]

Useful information sources

I strongly recommend to start from this part of documentation:

Yurii Vlasiuk
Yurii Vlasiuk
Опубліковано: 24 Листопада, 2017
Вам також може сподобатися
How We Used Redux on Backend and Got Offline-First Mobile App as a Result


How We Used Redux on Backend and...

In this article, we will share our experience of building offline-first React-Native application, using Redux-like approach on our NodeJS backend server.

22 Червня, 2018 від Artem Tymchenko