Learning a new programming language can be a very difficult task. Where should you start? How do I improve my skills from "Hello, World!" to building complete applications? It helps to have a starter project. One of my favourites is building a web app. I have been learning the Nix package manager for a few weeks now - starting with creating a dynamic version system - and I think its the perfect time to write a web application with it (Even though I probably shouldn't).
But wait, isn't Nix a package manager and reproducible build system? Am I going to write an entire post on how to package a PHP app with Nix and run it? Well, yes and no. Nix is indeed a build system, but Nix packages are configured using a functional programming language (also named Nix) created specifically for the Nix package manager. Since Nix is a complete programming language, this means we can execute it without building a package thanks to the interpreter built into the Nix package manager itself. Clearly, Nix was not built for web development, but let's see how far we can take it.
Hello, World!
In the long list of reasons why Nix was not created for building web applications, we have the reality that starting a web server that executes Nix code through Nix itself is not feasible at the moment. Nix is not the type of application that can be configured to wait for calls on some hostname/port and execute arbitrary code when a client connects. It is much easier to use something like nix eval
through a more standard web server, so we'll do just that.
Let's start by creating a mandatory "Hello, World!" example with Nix. For this post, I have created all the code inside of a Nix repl on replit.com. Feel free to follow along in that repl or to create your own.
Create a new repl using the nix (beta) language to get started, click on the three dots icon next to the Files header in the filetree and select "Show config files". Once the config files are visible, open the replit.nix
file and replace pkgs.cowsay
with pkgs.python3
, we will need python later. Now that the repl is configured, create a default.nix
file inside of a directory named app
and write the following code in it.
# ./app/default.nix
{ }:
rec {
get = ''
<html>
<head>
<title>Nix web server - Home</title>
</head>
<body>
<h1>Hello from Nix!</h1>
</body>
</html>
'';
}
The code looks a lot like a standard derivation file. The basis of the default.nix
file is a function (The signature for a Nix function is arg: body
) that takes an attribute set as its single argument and should return _something. Like most functional programming languages, Nix automatically uses the last statement of a function as the return value for that function. In fact, Nix only allows a single statement per function, purely functional. When executed, the code in this default file will return an attribute set with a single attribute: get
. This get attribute contains our "Hello, World!" HTML code.
Now, how do we run this code through a web server? For this post, we will be using a python web server, but you could use anything you like, even bash. Here is the code for our web server, paste it in a file named server.py
in the repl root directory (not in ./app
).
# ./server.py
from http.server import BaseHTTPRequestHandler, HTTPServer
import subprocess
hostName = "0.0.0.0"
serverPort = 8080
class NixServer(BaseHTTPRequestHandler):
def do_GET(self):
self.send_response(200)
self.send_header("Content-type", "text/html")
self.end_headers()
command = 'nix eval --raw -f ./app get'
# Run nix
process = subprocess.Popen(command.split(), stdout=subprocess.PIPE)
output, _ = process.communicate()
self.wfile.write(output)
if __name__ == "__main__":
webServer = HTTPServer((hostName, serverPort), NixServer)
print("Server started http://%s:%s" % (hostName, serverPort))
try:
webServer.serve_forever()
except KeyboardInterrupt:
pass
webServer.server_close()
print("Server stopped.")
The important part here is line 14: command = 'nix eval --raw -f ./app get'
. Using the subprocess
package, we trigger the nix eval
command. The --raw
option tells nix to return the result as an unquoted string, which makes things simpler for this server. We then use the -f
option to tell Nix to use the default.nix
file from the app
directory. Finally, the last option tells nix which attribute from the returned attribute set to print. Combined, this will interpret our ./app/default.nix
file - thus executing the function in that file - and get the content of the get
attribute, printing the HTML code as a raw string. Type python ./server.py
in the repl console and wait for the web preview to appear, you should see the "Hello, World!" message displayed on screen.
Adding a router
Displaying HTML is all fine and good, but most web applications include more than a single page of content. Nix can't access anything from the web server or the environment, it is fully isolated. We need to find some way to know which URL the user accessed in our web app to display the correct page. We could use the python server to handle calling different Nix files based on which path the user access, but we want to do as much as possible directly in Nix. Instead of trying to frontload all the work to the python server, let's get the path from the url in python and give it to Nix as an argument. Update the do_GET
method with this new code.
# ./server.py
def do_GET(self):
self.send_response(200)
self.send_header("Content-type", "text/html")
self.end_headers()
# Update the command to add the --arg option
command = f'nix eval --arg route "{self.path}" --raw -f ./app get'
# Run nix
process = subprocess.Popen(command.split(), stdout=subprocess.PIPE)
output, _ = process.communicate()
self.wfile.write(output)
We can extend the nix eval
command to pass arguments to Nix using the --arg
option. Here, we pass a route
attribute as the value of the path from the web server. Let's see how we can use this in the default.nix
file.
# ./app/default.nix
{ route ? "/" }:
let
routes = {
"/" = ''
<html>
<head>
<title>Nix web server - Home</title>
</head>
<body>
<h1>Hello from Nix!</h1>
<ul>
<li><a href="/nix">Nix info</a></li>
<li><a href="/404">404</a></li>
</ul>
</body>
</html>
'';
"/nix" = ''
<html>
<head>
<title>Nix web server - Nix</title>
</head>
<body>
<h1>Nix info</h1>
<p>
Nix is running on ${builtins.currentSystem}
<p>
</p>
<i>With the caveat that a python web server is serving everything...</i>
</p>
</body>
</html>
'';
};
in rec {
get = if builtins.hasAttr route routes then routes."${route}" else ''
<html>
<head>
<title>Nix web server - Not found</title>
</head>
<body>
<h1>404 Page ${route} not found</h1>
</body>
</html>
'';
}
Let's unpack this. On line 2, we can see the route
argument added as a key from the attribute set. Anything given to the --arg
option of the nix eval
command will be added here using the format --arg key value
. On line 3, we create a let..in
block that defines another attribute set with our possible routes, each returning some HTML. Remember when I said that Nix function could only contain one statement? The let..in
block is a special expression that allows us to bypass this limitation by defining variables in this block and passing them to whatever statement follows the in
. For example:
{ entry ? 0 }:
let
foo = "bar";
bar = 1;
file = builtins.readFile ./some-file.txt;
in if entry == 1 then foo else bar
This statement defines three variables and uses two of them in an if
statement. If
statements must always have a then
block and a else
block. No single line if
or else if
here. We can do a lot more with the let
statement, but the one thing we cannot do is to trigger side effects. Nix is incredibly lazy and does not allow code that does something without assigning it to a variable. Furthermore, it will not interpret unused code. For example, the file
variable above will not be interpreted and the some-file.txt
file will not be read, even though we assign its content to a variable. The ?
on line 1 character defines the default value of an attribute in an attribute set. If the entry
key is not set when the function is called, it will default to 0
.
To go back to our own code, the core of the router is on line 36 were we use an if
statement to set the value of the get
attribute. The condition checks if the routes
set from line 4 has a key equal to the passed route using builtins.hasAttr
. If yes, it fetches the value for that attribute with the dot
operator and returns the HTML. If not, it will instead return the content of a 404 page. This is code acts as a sort of switch
statement and is one of the best way to implement an else if
statement in Nix.
Restart the by typing CTRL+C then typing python ./server.py
. You should now see the "Hello, World!" message printed on the web preview, with some links. Clicking on those links will route you to the pages we have defined.
Adding an API
We now have a very nice set of web pages, and it's still fairly fast considering how we're running it. Yet, it's all static content. No web application would be complete without some sort of data management! It is time to take our app to the next level by adding a TODO API.
Let's start with some refactoring. Create a ./app/get.nix
file, we will move the code from ./app/default.nix
there with a few modifications.
# ./app/get.nix
{ }:
let
routes = {
"/" = ''
<html>
<head>
<title>Nix web server - Home</title>
</head>
<body>
<h1>Hello from Nix!</h1>
<ul>
<li><a href="/nix">Nix info</a></li>
<li><a href="/404">404</a></li>
</ul>
</body>
</html>
'';
"/nix" = ''
<html>
<head>
<title>Nix web server - Nix</title>
</head>
<body>
<h1>Nix info</h1>
<p>
Nix is running on ${builtins.currentSystem}
<p>
</p>
<i>With the caveat that a python web server is serving everything...</i>
</p>
</body>
</html>
'';
};
getContent = route: if builtins.hasAttr route routes then routes."${route}" else ''
<html>
<head>
<title>Nix web server - Not found</title>
</head>
<body>
<h1>404 Page ${route} not found</h1>
</body>
</html>
'';
in getContent
The main difference here compared to default.nix
is that we return a function for the router rather than call it through an attribute set.
Going back to default.nix
, we can update the code there to import
this function and use it rather than have the router live in the default file. It makes our code a little easier to manage and will keep the file size small as we add more code. import
is a special keyword that will execute the function in the given file (hence why we give it an attribute set as its second argument) and return its return value. In this case, ./get.nix
returns the getContent
function and that's what we receive from import
.
# ./app/default.nix
{ route ? "/" }:
let
getContent = import ./get.nix {};
in rec {
get = getContent route;
}
Now, let's add a ./app/post.nix
file and get started building a small API. We will be using the Replit database in this post, but feel free to use any database system you want.
# ./app/post.nix
{ pkgs ? import <nixpkgs>{}, replit_db_url ? "" }:
let
postApi = route: body: let
setTodos = builtins.readFile (pkgs.runCommand "setTodos" {
buildInputs = [ pkgs.cacert pkgs.curl ];
} ''
curl ${replit_db_url} -d 'todos=${body}' | tr -d '\n' > $out
'');
routes = {
"/api/todos" = setTodos;
};
in if builtins.hasAttr route routes then routes."${route}" else ''
Not found
'';
in postApi
Let's go over this code. First, on line 2, we define our function with two attributes: the base nix packages and a replit_db_url
string. We will get back to the replit_db_url
attribute later. The nix packages include all the utility packages provided by Nix, which can be found in their own repo. In the case of the pkgs
attribute, it means Nix will automatically fetch all packages even if we do not pass it in the attribute set.
In the let..in
block, we define our router function like in the ./app/get.nix
file with an added body
parameter. The router only defines one route, which triggers a function called setTodos
when the route is accessed. We define everything inside of the postApi
function to make sure the setTodos
function has access to the body
attribute.
This setTodos
function is where the magic happens. We use the pkgs.runCommand
function to trigger a bash script on the server. This script will call the curl
command and add data into the Replit database through its HTTP API. We then assign the result of this command, cleaned of all line breaks, to the magic $out
variable. runCommand
will write everything added to $out
to its derivation file, which we then need to read using builtins.readFile
. Since runCommand
does not have anything installed by default, we also give it a few tools to make sure curl works.
Remember when I said that Nix does not allow side effects? runCommand
is the best way to work around that. Nix will still not execute the bash script if the result is not read through a variable, but, as long as we make sure it is, we can trigger any side effect we want in the bash script.
We can use the runCommand
code to write a GET
version of this as well, let's return to ./app/get.nix
and add a few more lines of code.
# ./app/get.nix
{ pkgs ? import <nixpkgs>{}, replit_db_url ? "" }:
let
getTodos = builtins.readFile (pkgs.runCommand "getTodos" {
buildInputs = [ pkgs.cacert pkgs.curl ];
dummy = builtins.currentTime;
} ''
curl ${replit_db_url}/todos | tr -d '\n' > $out
'');
routes = {
# Same routes, content cut for brevity
...
"/api/todos" = getTodos;
};
getContent = ...; # Same code, content cut for brevity
in getContent
The code is very similar to the ./app/post.nix
code, but we fetch the todos data with curl rather than set them.
You may notice the dummy = builtins.currentTime
line in runCommand
, what's up with that? Nix is very good at avoiding unnecessary executions. It checks the content of the shell script and all the attributes given as the second parameter to determine if anything changes. If Nix decides the previous execution of runCommand
has all the data needed already, it will not execute the bash script and instead resolve to the result of the previous execution. This was less of a problem with our POST
code since Nix will always re-execute the code if its content changes (Which it will when the POST
body data is different, unless the user adds the same TODO twice), but our GET
code has no changing dependencies. We need to make sure to tell Nix to always rerun that script. The dummy
attribute does so by creating a dependency to the current time, meaning it is sure to change between each execution.
Let's update the ./app/default.nix
file to connect those changes and expose our API endpoints.
# ./app/default.nix
{ pkgs ? import <nixpkgs>{}, route ? "/", body ? "{}", replit_db_url ? "" }:
let
getContent = import ./get.nix { inherit pkgs; inherit replit_db_url; };
postApi = import ./post.nix { inherit pkgs; inherit replit_db_url; };
in rec {
get = getContent route;
post = postApi route body;
}
We added a few attributes to the attribute set of our function and now expose a second attribute to our returned set for POST
calls. We use the inherit
keyword to pass attributes to our imported packages without having to type pkgs = pkgs
, it is the equivalent of doing { foo: foo }
in an object in JavaScript for example. We need to also update the python web server to provide those as arguments, replace the NixServer
class with the following code.
# ./server.py
class NixServer(BaseHTTPRequestHandler):
def do_GET(self):
self.send_response(200)
self.send_header("Content-type", "text/html")
self.end_headers()
replit_db_url = os.getenv("REPLIT_DB_URL")
command = f'nix eval --arg route "{self.path}" --arg replit_db_url "{replit_db_url}" --raw -f ./app get'
# Run nix
process = subprocess.Popen(command.split(), stdout=subprocess.PIPE)
output, _ = process.communicate()
self.wfile.write(output)
def do_POST(self):
self.send_response(200)
self.send_header("Content-type", "text/html")
self.end_headers()
content_length = int(self.headers['Content-Length'])
post_data = self.rfile.read(content_length)
post_body = json.dumps(post_data.decode('utf-8'))
replit_db_url = os.getenv("REPLIT_DB_URL")
command = [
"nix",
"eval",
"--arg",
"route",
f'"{self.path}"',
"--arg",
"body",
f'{post_body}',
"--arg",
"replit_db_url",
f'"{replit_db_url}"',
"--raw",
"-f",
"./app",
"post"
]
# Run nix
process = subprocess.Popen(command, stdout=subprocess.PIPE)
output, _ = process.communicate()
self.wfile.write(output)
The URL for the Replit database is available as an environment variable for all repls. We can fetch it and pass it to the nix eval
command using the --arg
option. We also added a do_POST
method to the class to process POST
calls with the post
attribute returned from Nix. This method will pass all the attributes we expect, plus the decoded body from the POST
call. Remember that pkgs
has a default value that sets it to a link to all Nix packages, so we can omit it from our nix eval
call.
Restart the server and try accessing these two endpoints using Curl or Postman with the repl URL (Available in the web preview). The GET
endpoint should return the data saved by the POST
endpoint!
Adding a web app
We now have static content and a dynamic API, all that's left for our TODO app is to connect to the two together! For this application, I decided to go framework-less and use a web component. A web component is a custom HTML element managed through JavaScript. Think React, but without all its JSX fancyness.
Create a ./app/app.js
file and copy the following code in there:
// ./app/app.js
class TodoList extends HTMLElement {
constructor() {
super();
this.todos = null;
}
// Called when the component connects to the DOM. I.E. When it is rendered for the first time.
connectedCallback() {
this.render();
this.fetchTodos();
}
fetchTodos() {
fetch('/api/todos').then(data => data.json()).then(result => {
this.todos = result;
this.render();
}).catch(() => {
// This block exist to handle cases were the API returns an empty string (no data)
this.todos = [];
this.render();
});
}
saveTodos(event) {
// Don't actually submit the form.
event.preventDefault();
const newTodo = this.querySelector("#todo-form #new-todo").value;
fetch('/api/todos', {
method: 'POST',
body: JSON.stringify(this.todos.concat({ task: newTodo })),
}).then(() => {
this.fetchTodos();
});
}
render() {
if (!this.todos) {
this.innerHTML = '<i>Loading...</i>';
return;
}
this.innerHTML = `
<div>
<ul>
${this.todos.map(todo => `
<li>
${todo.task}
</li>
`).join('')}
</ul>
<form id="todo-form">
<input placeholder="Add a task" id="new-todo" name="new-todo" />
<button type="submit">Add</button>
</form>
</div>
`;
this.querySelector("#todo-form").addEventListener('submit', this.saveTodos.bind(this));
}
}
customElements.define('todo-list', TodoList);
I won't go into too much details about web components in this article. What's important here is that connectedCallback
is triggered when the component is picked up by the browser and rendered on screen. We tell that component to render a loading indicator and start fetching todos using our GET
API from the previous section. Once fetched, we rerender and display the list of todos plus a small form instead of the loading indicator. Creating a todo using the form will call the POST
API endpoint, then trigger a refetch of the GET
endpoint, and finally a rerender.
Let's update the routes
set of our ./app/get.nix
file to use this component, replace the first route with the following code.
"/" = ''
<html>
<head>
<title>Nix web server - Home</title>
</head>
<body>
<h1>Hello from Nix!</h1>
<ul>
<li><a href="/todos">Todo list page</a></li>
<li><a href="/nix">Nix info</a></li>
</ul>
</body>
</html>
'';
"/todos" = ''
<html>
<head>
<title>Nix web server - Todos</title>
</head>
<body>
<h1>Listing todos</h1>
<todo-list></todo-list>
<script>
${builtins.readFile ./app.js}
</script>
</body>
</html>
'';
We added a link to the new /todos
route in our home page. This /todos
route adds the web component we created and copies the JavaScript code into a script tag by reading the JavaScript file. Nix knows to copy unquoted paths like these to its store and can access our JavaScript file no problem.
Refresh the web preview (no need to reset the server since we didn't change any of the python code) and you should see the new link. Clicking on the todo list link will trigger the web component code and show you an empty list of todos. Try adding a new todo using the form, you'll see the list refreshing after around 5 seconds. Be patient, it is very slow.
Conclusion
What did we learn today? We learned the hard way that Nix isn't suited to web development - which was to be expected - but also that Nix can be very slow for what seems to be fairly basic operations. Nix is a build system first, while it has many optimizations (Which we had to work around) to make things faster, it's not made to provide the type of performance web applications need. The runCommand
function creates a new build derivation that configures a lot of things and creates new processes. It's meant to take some time as Nix figures out what it needs to do. builtins.readFile
adds another layer of complexity as Nix creates yet another process to fetch and read the file.
But what about fetchUrl
? Could it be faster and simpler than runCommand
? Unfortunately, fetchUrl
uses a bash script and curl behind the scene, which ends up being very similar in structure and performance to our runCommand
setup, but without our dummy
attribute. This leads to caching issues where adding more than one todo won't change the result of the GET
call. fetchUrl
is meant to fetch and unpack archives for using them in build processes, not make GET
/POST
calls to an HTTP API.
I think there might be some way to hack Nix a little bit more by making use of the runCommand
cache we had to work around with the dummy
attribute. There might be some way to use that to our advantage to always return the latest version of the saved data when calling the GET
endpoint unless a POST
call has happened. This could lead to a slow first GET
call, but instant subsequent calls. Until a todo is added (Maybe we'll explore that in a part 2!).
What I love about the Nix language is how simple it is. There is very little bloat and the core of the language can be learned really quickly (we only missed the with
keyword in this post, we covered everything else!). There is definitely some potential for writing applications with this language. This was a fun adventure and a very good way to learn the Nix language, but it seems my dream of a Nix powered web application will have to wait until an interpreter makes its way on the Internets.