Extending Remake: The Custom Backend Tutorial š
As youāre building your web app, you may encounter times where you need to do more with your app than what Remake does for you.
If thatās the case, your app may benefit from extending Remakeās backend code.
The backend of Remake contains the JavaScript code that runs on your server, which is different from the frontend code that runs in your users' web browsers.
The backend code gives us the ability to integrate our server with other Node libraries and web APIs, allowing us greater control over Remakeās power, and opening up new app possibilities! āØ
Important: If you want to use Remake's built-in hosting service (i.e. with the command remake deploy
), you won't be able to deploy your modified backend code. So, if you to work with modified backend code, you need to host Remake yourself.
How we'll customize Remake's backend
The project we will build is a statistics panel, which will give us some stats about how our web app is being used.
Note: As with all web apps, user data privacy is important, so remember to follow any applicable user data privacy laws. Ā Privacy law compliance is a topic outside the scope of this tutorial, but do not forget that it is also a very important topic for all web app developers to understand!
After this tutorial, you should feel more comfortable extending Remakeās backend with your own backend code.
Want to skip to the code? All the source code from this tutorial
š” If you get stuck and a Google search doesnāt get you the relevant information, feel free to swing by Remake's community chat on Discord and ask for help. Ā Weāre very friendly!
Setup
Weāll start with the default Remake project, which weāll generate the normal way using the remake-cli utility.
In your terminal, create a new remake project using the following command:
npx remake create backend_project
When asked to pick a starter template, choose Default Starter.
Now inside the backend_project
folder, we have our new project files.
You can make sure the application was generated correctly by changing directory into the backend_project
folder and running npm run dev
:
cd backend_project
npm run dev
Once the server starts, navigate to http://localhost:3000/ in your web browser while itās running.
Since thereās not much there, letās fill our app/pages/app-index.hbs
file with the default todo list starter code:
<div object>
<ul array key="todos" sortable>
{{#for todo in todos}}
<li
object
key:text="@innerText"
edit:text
>{{default todo.text "New todo item"}}</li>
{{/for}}
</ul>
<button new:todo>Add Todo</button>
</div>
Refresh your page and you should see a basic todo list app after you create an account and log in.
Components
To extend Remake, weāll first start with a look at _remake/main.js
, and the files inside _remake/lib/
as well. Ā In order to modify Remake, we first have to know a bit about how it works!
Express Server (main.js)
Remakeās backend uses the Node Express framework for routing, Passport.js for authentication, and json for storing data on the server. Ā In the main.js
file, youāll find 2 function calls beneath a comment called āREMAKE CORE FRAMEWORKā, called initApiNew
and initApiSave
. Ā Those are the ones weāll be adding callbacks to in this tutorial.
API Endpoints
Remakeās frontend and backend communicate in two ways: initially when pages load, and also after the page is loaded, through endpoints. Ā API endpoints are just URLs the backend provides for the frontend to call. Ā The files that do this are within the _remake/lib
folder. Ā Donāt be afraid to open them up and look at what they do!
Here are the endpoints of interest to us:
/new/
endpoint- Called when a new item is created by the user.
- Contained in init-api-new.js
/save/
endpoint- Called when something is changed by the user.
- Contained in init-api-save.js
Callbacks
The easiest way to hook into Remakeās behavior on the backend would be to use callbacks. Unfortunately, Remake doesnāt yet have backend callbacks. Ā So letās add them right now!
Adding Callbacks
initApiNew
In the file _remake/lib/init-api-new.js
, add the following code to the function definition of initApiNew:
export function initApiNew ({app}, callback) {
Inside this function there is an app.post
call. We need to call our callback at the end of this function provided to app.post
, right below the final res.json
call:
if(callback != null)
callback({
app,
user: {
name: currentUser.details.username,
email: currentUser.details.email
},
data
});
This callback will cause Remake to provide us with the user details (name and email) and copy of the data when a user creates a new entry. Ā Weāre also passing the app itself as the first parameter, since itās always useful to have.
Now in your _remake/lib/main.js
, weāll provide our newly changed code with our own callback to log the data mentioned above:
initApiNew({ app }, ({app, user, data}) => {
console.log(āinitApiNew callback data: ā, data);
});
Save and test the above code using npm run dev. Ā Using your web app, add a new item to the todo list. Ā In the server console, youāll notice we now see the data logged.
Callbacks allow code separation, so we can more easily maintain our applicationās custom behavior across remake updates. Ā But they sometimes arenāt enough. Ā So letās explore other customization options.
initApiSave
Letās do the same thing for the save endpoint in init-api-save.js
.
export function initApiSave ({app}, callback) {
And underneath res.json({success: true}));
, Ā add the following code:
if(callback != null)
callback({
app,
user: {
name: currentUser.details.username,
email: currentUser.details.email
},
data: {
newData: existingData,
oldData
}
});
Weāll also need to get a copy of the old data, since itās changed in this function. Ā On line 36, underneath the declaration of oldData
, add:
let oldData = {...existingData};
Iāve chosen to provide not just the data in the callback, but the old data as well. This wonāt be used in our application, but would be useful for purposes where you need to know the data that changed.
Letās call the callback to check that it works. Ā Do this in main.js
:
initApiSave({ app }, ({app, user, data}) => {
console.log(āinitApiNew callback data: ā, data);
});
Since we are not using file uploads, we will not be adding a callback to the upload endpoint. Ā But you could do so if you have a need for it.
Custom Backend Code
Combining what we know from the changes above, we can replace the 3 init function calls in main.js with initBackend({app})
, a function weāll write and store in backend.js, which weāll put inside our app folder in the project directory.
First, in Remakeās main.js file, letās import our backend. Ā Beneath the other imports, add this:
let backend = null;
try {
backend = require("../app/backend"); // optional
} catch (err) {
if (err.code != āMODULE_NOT_FOUNDā) {
throw err;
}
}
Weāre going to make this import optional since requiring the backend file would break Remake if it wasnāt there.
Then, weāll replace the 2 initApi
calls in _remake/lib/main.js
with this code. Weāre also going to add an optional init function for our backend and have it run prior to the initRenderedRoutes
function, which will allow us to override Remakeās default routes.
// REMAKE CORE FRAMEWORK
initUserAccounts({app});
initApiNew({app}, backend.onNew);
initApiSave({app}, backend.onSave);
initApiUpload({app});
if (backend.init != null) {
backend.init({app});
}
initRenderedRoutes({app});
Now create a new file backend.js in your app folder with the contents. Ā Notice we are moving our callbacks into this file:
const onNew = ({app, user, data}) => {
console.log("onSave callback data:",
"\nUser:", user.name,
"\nData:", data.data
);
};
const onSave = ({app, user, data}) => {
console.log("onSave callback data:",
"\nUser:", user.name,
"\nOld Data:", data.oldData,
"\nNew Data:", data.newData
);
};
const init = ({app}) => {
console.log("Custom backend initialized...");
}
const run = ({app}) => {
console.log("Custom backend running...");
};
export { init, onNew, onSave, run };
Weāve also added another function, run, which weāll call from Remake after it has started.
Inside of Remakeās main.js
at the bottom of the the app.listen
callback, call our run function:
app.listen(PORT, () => {
console.log('\n');
showConsoleSuccess(`Visit your Remake app: http://localhost:${PORT}`);
showConsoleSuccess(`Check this log to see the requests made by the app, as you use it.`);
console.log('\n');
if (process.send) {
process.send("online");
}
backend.run({app});
});
Now would be a good time to test your application the same way as before, using npm run dev
. Ā Youāll notice that our logging still works, but it has now been moved to our own file.
Our Project
Now that our backend code is running, it would be nice to do something useful besides logging, and this is where your creativity can come in!
For this tutorial, weāll create a statistics page which will show us some useful information about how our web app is being used.
Weāll measure:
- The most active users of our web app
- The last time someone used our app
Using Callbacks to Measure User Activity
Letās use our backend callbacks to record some information about user activity. Letās count the activity of each user by counting the number of calls to new and save.
The counting is rather simple. Ā Itās just the incrementing of a value that weāll store in json. Ā Weāre also going to use Date.now() to get a timestamp of the last activity, which weāll also store.
The Stats JSON Database
Tip: If youāre using JSON as a database, as Remake does, an important thing to remember is to be careful when using async/await or callbacks. Ā If youāre reading in data, changing it, and writing data, there is a real chance the database gets opened and changed in another place in your code as well, opening up the real risk of data loss. Ā
For this reason weāre using synchronous calls when working with the JSON and being careful to make sure node is not allowed to do a context switch while in the middle of working with a file. Ā For more information about this concept, research transactional data and asynchronous programming.
For our project, weāre going to generate a new json file inside Remakeās json database folder called stats.json. Ā Once populated, the json file will look something like this:
{
"userActivity":{
"[email protected]": {
"activity": 12,
"lastUseTimestamp": 1610088558699
}
},
"lastUseTimestamp": 1610088558699,
"lastUser": "[email protected]"
}
As you can see, we will have an entry per-user, with an activity count and a date of the last modification on the user account. Ā This will allow us to see the most active users and the last time each user used the app.
Weāre also tracking the last use timestamp out of all users and the last active userās email.
Populating the Database
Weāre going to make our new stats file create itself if it doesnāt exist, and fill in fields that donāt exist as we need them.
Since weāre counting both additions and modifications, weāll need to add our code to both endpoints: new and save. Ā Since this will involve the same code, letās create a function for incrementing the count.
The following code examples in this section will occur inside a new function inside of backend.js
called incrementUserActivity
, which will take one object parameter containing the name and email of the user that did the activity.
Here is the stub:
const incrementUserActivity = ({name, email}) => {
// our new code will go here...
};
Before we start writing this function, letās add two imports and set the location for our stats.json
file weāll use at the top of the file:
const jsonfile = require("jsonfile");
const path = require("upath");
const statsFile = path.join(__dirname, "data/database/stats.json");
Reading JSON
The code below shows how to read a JSON file synchronously. Ā If the file does not exist, an exception will be thrown, and if that happens, we will create it. Ā Put the following code inside our new incrementUserActivity
function.
// open or create a stats.json file if it doesn't exist
let stats = {};
try {
stats = jsonfile.readFileSync(statsFile);
} catch (err) {
if (err instanceof Error && err.code == āENOENTā) {
// file not found? create it!
jsonfile.writeFileSync(statsFile, {});
} else {
// unknown error, rethrow
throw err;
}
}
Set Default Stats
At the first run of our application, our stats.json wonāt exist. Ā The code we wrote previously will create it, but it will still start empty. Because of this, default values are needed to be filled in if they donāt exist.
Similarly, if an entry for a specific user doesnāt yet exist, weāll create that too and set the initial activity counter to 0:
// create user activity entry in stats.json if it doesn't exist
if(stats.userActivity === undefined) {
stats.userActivity = {};
}
if(stats.userActivity[email] === undefined) {
stats.userActivity[email] = {};
}
if(stats.userActivity[email].activity === undefined) {
stats.userActivity[email].activity = 0;
}
Recording Our Data
As we talked about before, weāll increment the activity counter stored in the current user and keep a timestamp of when it happened in two places, as well as the email of the last user to use the app.
stats.userActivity[email].activity += 1;
let timestamp = Date.now();
stats.userActivity[email].lastUseTimestamp = timestamp;
stats.lastUseTimestamp = timestamp;
stats.lastUser = email;
Writing JSON
Writing our data into the stats.json file at the end of the function is simple, as it involves only one call:
jsonfile.writeFileSync(statsFile, stats, {spaces:2});
The spaces option is provided so that the json is formatted with 2 spaces per ātabā so we can read it easily.
Calling Our Function
Our incrementUserActivity
function should be called from both endpoints: new and save.
Hereās how we call our function:
const onNew = ({app, user, data}) => {
incrementUserActivity(user);
};
const onSave = ({app, user, data}) => {
incrementUserActivity(user);
};
Before you test, make sure to cause some activity to happen by adding or changing some items and viewing stats.json
to see the changes.
The Statistics Page
To make our app display the stats, we need to add a new /stats/
route inside our backendās init function.
Stats Route
The most quick and dirty approach to displaying the states would be logging them to the page and to the server console. Ā Letās try that first:
const init = ({app}) => {
// Create the route for our page at /stats
app.get("/stats", (req, res) => {
res.set('Content-Type', 'text/plain'); // plain text page
res.write("Stats:\n\n");
try {
const stats = jsonfile.readFileSync(statsFile);
console.log(stats);
res.write(JSON.stringify(stats, null, 2));
} catch (err) {
res.write("{}");
}
res.end();
});
};
Now would be a good time to test. Firstly, make sure there is some activity in your todo list to log. Then, navigate to https://localhost:3000/stats/.
Restricting the Page
Since this isnāt a frontend tutorial, and only one user will see this page, we wonāt be implementing any fancy template rendering. Ā Instead weāll render a very simple stats page which weāll restrict to a single user called admin.
When you test this code, remember to be logged in as admin, or change the code below to match your username.
Begin by wrapping the route code in the following if statement. Ā Weāll also set the page to plain text, since thatās how weāll render the stats.
app.get("/stats", (req, res) => {
res.set(āContent-Typeā, ātext/plainā); // plain text page
if(req.isAuthenticated() && req.user.details.username === "admin") {
// only admin user can see this...
}else{
res.status(403); // not authorized
res.end();
}
// ...
Read Stats
Inside the above if statement, weāll read the json stats similarly to how we did it before, and weāll check to see the stats are empty:
let stats = {};
try {
stats = jsonfile.readFileSync(statsFile);
} catch (err) {
// ignore file not found, otherwise throw
if(!(err instanceof Error) || err.code !== "ENOENT")
throw err;
}
if(stats == null || stats.userActivity == null) {
res.write(āNo stats recorded!ā);
res.end();
return;
}
This will read from our stats file and render a message to us if there are no stats recorded.
Calculate Stats
We can compute the most active user by looping through our stats object, checking each userās activity, and keeping the most active user found and the activity level. Weāll also add up the total activity level.
let mostActiveUser = null;
let totalActivity = 0;
let mostActivity = 0;
if(stats.userActivity != null) {
for(var email in stats.userActivity) {
let user = stats.userActivity[email];
if(user.activity != null) {
if(user.activity > mostActivity) {
mostActivity = user.activity;
mostActiveUser = email;
}
totalActivity += user.activity;
}
}
}
Render Stats
Here weāll render each of the stats that we have available:
if(totalActivity !== null)
res.write("Total Activity: " + totalActivity + "\n");
if(stats.lastUseTimestamp != null && stats.lastUser != null)
res.write("Last Activity: " +
(new Date(stats.lastUseTimestamp)).toString() +
" by " +
stats.lastUser + "\n"
);
if(mostActiveUser !== null)
res.write("Most Active User: " +
mostActiveUser +
" (" + mostActivity + ")\n"
);
res.end();
We show the total activity, last activity, and the most active user.
Our stats page at https://localhost:3000/stats should look something like this:
Stats
Total Activity: 18
Last Activity: Fri Jan 08 2021 01:19:50 GMT-0800 (Pacific Standard Time)
by [email protected]
Most Active User: [email protected] (14)
Conclusion
Want to view all the code at once? All the source code from this tutorial
If the code provided in this tutorial isnāt working for you, be sure to check the repository linked at the top of the page. Ā This is where youāll find the full example listing. Ā If youāre looking for the stats, make sure your username is admin.
Thanks for reading this tutorial! Ā I hope you learned that the Remake backend can be customized and expanded to open up new server-side possibilities. Ā In the future, Remakes backend customization should become easier, but the integration should be similar. Ā Be sure to watch Remakeās development closely as new things are being added frequently!
Bye for now! š