Getting My Feet Wet With `Plumber` and JavaScript
By Ken Koon Wong in r R plumber javascript log systemctl
April 14, 2025
Tried out plumber and a bit of JavaScript to build a simple local API for logging migraine events 🧠💻. Just a quick tap on my phone now records the time to a CSV—pretty handy! 📱✅
Motivation
After our previous blog on barometric pressure monitoring, my friend Alec Wong said ‘Won’t it be great if we can just hit a button and it will record an event?".
In this case the reason for recording barometric pressure is to see if there is a link between migraine event and barometric pressure values/change etc. And yes, it would be great if we can create an app of something sort to make recording much easier!
There are many ways to do this. The way where we can maximize learning within R environment is to use plumber
to create an API for us to interact and record event! Our use case is actually quite straight forward. We just need something that record a current timestamp when a button is clicked. Simple!
But since I’ve never used plumber
before, this is a great opportunity to explore it! And also a bit of JavaScript too. Again, this blog is more for my benefit where it serves as a note for myself. Here we go!
Objectives:
Big Picture
As the image above shows, we want an app on our phone that once clicked will somehow change a csv dataframe. All these can be done by
plumber
setting an API to the csv
. Since I just want to be able to do this on a local network of a different device (e.g. raspberrypi), we don’t need to deploy this to digital ocean or a server per se. We can run it in the background and set systemctl
in case rpi restarts, point it to 0.0.0.0
and we can GET/POST via the device’s IP.
Yes, unfortunately this will not work if we’re no longer on local network, which at least from my utility, it will be just fine. No need to expose port forwarding. The safer way would be to use digital ocean droplet to do this, so you’re not exposing your own IP and open port to the public. That also means, you may have to pay some 💰 (e.g. ~$5/month). May someday when it can incorporate the barometric pressure and/or other metrics then
plumber.R
library(plumber)
library(readr)
file <- "migraine.csv"
if (file.exists(file)) {
df <- read_csv(file)
} else {
df <- tibble(date=as.POSIXct(character()))
}
#* @apiTitle Migraine logger
#* @apiDescription A simple API to log migraine events
#* Return HTML content
#* @get /
#* @serializer html
function() {
# Return HTML code with the log button
html_content <- '
<!DOCTYPE html>
<html>
<head>
<title>Migraine Logger</title>
</head>
<body>
<h1>Migraine Logger</h1>
<button id="submit">Oh No, Migraine Today!</button>
<div id="result" style="display: none;"></div>
<script>
document.getElementById("submit").onclick = function() {
fetch("/log", {
method : "post"
})
.then(response => response.json())
.then(data => {
const resultDiv = document.getElementById("result");
resultDiv.textContent = data[0];
resultDiv.style.display = "block";
})
.catch(error => {
const resultDiv = document.getElementById("result");
resultDiv.textContent = error.message
})
};
</script>
</body>
</html>
'
return(html_content)
}
#* logging
#* @post /log
function(){
date_now <- tibble(date=Sys.time())
df <<- rbind(df,date_now)
write_csv(df, "migraine.csv")
list(paste0("you have logged ", date_now$date[1], " to migraine.csv"))
}
#* download data
#* @get /download
#* @serializer csv
function(){
df
}
Alright, let’s explore the code one by one. Again, as a note for my benefit.
Load libraries, load data, metadata
library(plumber)
library(readr)
file <- "migraine.csv"
if (file.exists(file)) {
df <- read_csv(file)
} else {
df <- tibble(date=as.POSIXct(character()))
}
#* @apiTitle Migraine logger
#* @apiDescription A simple API to log migraine events
The above is quite self-explainatory. Point to a file, if it exists, read it, if not create an empty dataframe. The title and description of this API is described as such.
Let’s Write Out HTML & Javascript
#* Return HTML content
#* @get /
#* @serializer html
function() {
# Return HTML code with the log button
html_content <- '
<!DOCTYPE html>
<html>
<head>
<title>Migraine Logger</title>
</head>
<body>
<h1>Migraine Logger</h1>
<button id="submit">Oh No, Migraine Today!</button>
<div id="result" style="display: none;"></div>
<script>
document.getElementById("submit").onclick = function() {
fetch("/log", {
method : "post"
})
.then(response => response.json())
.then(data => {
const resultDiv = document.getElementById("result");
resultDiv.textContent = data[0];
resultDiv.style.display = "block";
})
.catch(error => {
const resultDiv = document.getElementById("result");
resultDiv.textContent = error.message
})
};
</script>
</body>
</html>
'
return(html_content)
}
- The skeleton #*, first is comment, 2nd is
GET /
(HTTP method), 3rd isTurn this function into HTML output
Serializer. Basically means if we go tohttp://localhost:8000/
, it will return this HTML. Now if we setGET /hello
, then html will also show if you go tohttp://localhost:8000/hello
- Next is the HTML (without the Javascript, which is between ). Basically, write a heading, create a button, and a div to return result.
- The Javascript:
document.getElementById("submit").onclick
: when thesubmit
button has been clicked, run the functionfetch("/log", { method : "post" })
: this is the part where it will call thePOST /log
function (see below) and run it..then(response => response.json())
: This is a Promise chain. After the fetch request completes, this takes the response from the server and calls the .json() method on it, which parses the JSON response body into a JavaScript object. This method also returns a Promise that resolves to the parsed JSON data..then(data => { const resultDiv = document.getElementById("result"); resultDiv.textContent = data[0]; resultDiv.style.display = "block";})
: This is the next step in the Promise chain. Once the JSON is parsed, It finds the HTML element with the ID “result”. Sets its text content to be the first item in the data array (data[0]). Makes the element visible by setting its CSS display property to “block”.catch(error => { const resultDiv = document.getElementById("result"); resultDiv.textContent = error.message })};
This catches any errors that might occur during the fetch operation or when processing the response. If an error happens, it finds the HTML element with ID “result”. Sets its text content to the error message.
The interesting thing I’ve not come across is the arrow function. response => response.json()
means function(response) { return response.json() }
.
More Plumber API functions:
#* logging
#* @post /log
function(){
date_now <- tibble(date=Sys.time())
df <<- rbind(df,date_now)
write_csv(df, "migraine.csv")
list(paste0("you have logged ", date_now$date[1], " to migraine.csv"))
}
#* download data
#* @get /download
#* @serializer csv
function(){
df
}
-
The
POST /log
function is where the magic happens. When the button is clicked, it will run this function. It will create a new row with the current timestamp and append it to the dataframe. Then write it out tomigraine.csv
. The<<-
operator is used to assign a value to a variable in the parent environment (in this case, the global environment). This allows us to modify thedf
variable defined outside of the function. Thelist(paste0("you have logged ", date_now$date[1], " to migraine.csv"))
will return a message to the user that the event has been logged. This is what will be displayed in the div with ID “result” in the HTML. -
The
GET /download
function is to download the data. It will return the dataframe as a CSV file when you go tohttp://localhost:8000/download
. The@serializer csv
line tells plumber to serialize the output as a CSV file.
OK, Let’s Check It Out! Click that Run API
button for A Test Run!
We should see something like this. You can test it via Swagger UI or you can go to the address without
__doc__
to get to the html directly.
Hurray! It works, locally… now let’s see if it works if it’s on a different device.
How To Run It?
- Transfer
plumber.R
or whatever file you saved to, to your device of choice. - Install packages, of course
- Then run the following code
Rscript -e "pr <- plumber::plumb('plumber.R'); pr |> pr_run(port=8000,host='0.0.0.0')"
What it does it it’ll run the plumber API. And use a different device in the same network, then go to http://your-local-device-ip:8000/
and you should see something like the following
Hurray! It works! Now, let’s make sure we run it in the background and if rpi restarts, it will re-run the script by using systemctl
. All of the code below are to be run in bash
sudo nano /etc/systemd/system/migraine-logger.service
Paste this in the migraine-logger.service
[Unit]
Description=Migraine Logger Plumber API
After=network.target
[Service]
Type=simple
User=pi
WorkingDirectory=/path/to/your/app
ExecStart=/usr/bin/Rscript -e "pr <- plumber::plumb('plumber.R'); pr |> pr_run(port=8000, host='0.0.0.0')"
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
- Change
/path/to/your/app
to the directory where yourplumber.R
file is located.
Enable, Start, Check Status
sudo systemctl enable migraine-logger.service
sudo systemctl start migraine-logger.service
sudo systemctl status migraine-logger.service
Hurray !!!
One Click On iOS?
Use your browser on iOS to go to your device’s IP and port e.g. http://192.168.1.11:8000
, then click on share and create shortcut homescreen, like so
Then you can have a shortcut on your iOS device that will open the app and click the button for you!
Opportunities For Improvement
- This only works if you’re on the local network, could potentially expand this to digital ocean droplet, especially if we add more features (e.g., post old log if we had forgotten to record one, show 10 latest data, show barometric data etc.)
- need to learn more node.js/javascript, really enjoyed using Positron and
Code Runner
to be able to quickly callnode
and run the entire script on console - need to learn more about
plumber
, e.g. how to deploy it to digital ocean - a python part we could translate to is FastAPI, need to learn that as well, but implementation & code structure should be quite similar
Lessons Learnt
- Learnt some simple GET/POST plumber API
- Learnt some simple JavaScript, found this has major potential for future projects.
- Learnt
systemctl
If you like this article:
- please feel free to send me a comment or visit my other blogs
- please feel free to follow me on BlueSky, twitter, GitHub or Mastodon
- if you would like collaborate please feel free to contact me
- Posted on:
- April 14, 2025
- Length:
- 8 minute read, 1649 words