Here we go again! Another one of these, and promise, you will be questioning my sanity after this one.
I was just getting groceries. I walked down the street to the local shop when it hit me. Cascading... Server Sheets!
Today, we'll use CSS as a server-side language. That's right. Use CSS to declare routing, do maths, heck, even use CSS to do templating! And we're not using anything like SASS or LESS (pff, we don't need no stinkin' loops!), but plain ol' CSS.
What?? Why??
SMBC has lately put it quite well, although it's part of a comic about quantum computers:
Imagine changing a tire with the Hubble telescope. Doesn't exactly work out, does it? Well, how awesome would it feel if you managed to do it, though? And that's what I'm after. Hey, maybe I'm starting a new trend here, who knows! Even if the trend is just laughing at my silly ideas and never taking me seriously ever again.
You might know the saying that "people were so obsessed with wether they could that they forgot to ask if they should". I'm well aware of that fact that I probably shouldn't, but the question is could I?
This tool will be something I'll never ever ever use in production, and you, dear reader, should not do it either. Please. There. You've been warned.
Ok, Cascading St... Server Sheets it is.
First, let's define how this thing will even work. I was thinking about an interface to Express. Basically define a catch-all route in Express, load the CSS file, parse and interpret the styles (this part will be fun, I guess) and shoot whatever DOM emerges over the wire.
To do that, let's first install Express. Please note that I'm using nvm to switch between Node versions here.
echo "14" > .nvmrc
nvm use
npm init # Hit enter a few times
npm i express
Awesome! Now let's create a little app and add a start script to the package.json
:
{
"name": "css-server",
"version": "1.0.0",
"description": "A bad idea.",
"main": "index.js",
"scripts": {
"start": "node ./css-server.js"
},
"author": "Pascal Thormeier",
"license": "donttrythisathome",
"dependencies": {
"express": "^4.17.2"
}
}
In the express app, we define a catch-all route that tries to figure out if a given route corresponds to a CSS file or not. If it exists, it simply returns the content of this file, if not, a 404 will be thrown.
const express = require('express')
const bodyParser = require('body-parser')
const path = require('path')
const fs = require('fs')
const app = express()
// Allows to get POST bodies as JSON
app.use(bodyParser.urlencoded({ extended: true }))
// Catch-all route
app.use((req, res) => {
let cssFile = req.path
// So `index.css` works.
if (cssFile.endsWith('/')) {
cssFile += 'index'
}
const cssFilePath = path.resolve('./app' + cssFile + '.css')
try {
const css = fs.readFileSync(cssFilePath, 'utf8')
res.send(css)
} catch (e) {
// Any error of the file system will
// be caught and treated as "not found"
res.sendStatus(404)
}
})
app.listen(3000)
A quick test shows that everything, except a small index.css
file yields a 404; the CSS file gets shown.
Evaluating CSS - Thinking aloud
Ok, here's the fun part. We somehow need to figure out how to execute the CSS server-side and take whatever it outputs as the apps response.
The first thing that comes to mind for rendering is to simply use the CSS content
rule to render - well - content. It can use CSS variables and counters, so we can technically even do math with it. There's just one problem: The browser evaluates counters and vars on the fly, so we cannot just evaluate the CSS, take whatever is in the content
and output that. So, the "computed style" approach doesn't work. (Believe me, I tried...)
Basically, you'll get what you see in the "CSS" tab of your dev tools.
Imagine this piece of CSS:
body {
--num1: 12;
--num2: 13;
counter-set: sum 15;
}
body::before {
content: '<h1>The sum is ' counter(sum) '</h1>';
}
This is what you'll get:
Hm. So why don't we use a browser to do just that? The browser does evaluate this stuff somehow, right? The only issue is, that we're shifting the problem here. There are Node implementations of CSS. They offer computed styles and the browser we would be using would only offer the same thing, right? If only there was a way to let the computer "read" what's on screen.
Ideally, the browser would load the CSS file and we wouldn't inline anything; otherwise we cannot really use stuff like @import
. So we need another controller that loads CSS files.
Anyways, sounds a lot like a "future me" problem. Let's first introduce puppeteer and make it execute the CSS.
Adding puppeteer
Straight forward:
npm i -s puppeteer
To load the CSS, we need some HTML. We can create that on the fly, inject the loaded CSS as a <link>
, base64 encode the entire blob and make the browser parse that:
const escapeVarValue = value => {
if (!isNaN(value)){
return value
}
return `'${value}'`
}
const createDOM = (cssFilePath, method, args) => {
const varifiedArgs = Object.entries(args).map(([key, value]) => `--${key}: ${escapeVarValue(value)};\n`).join("\n")
const dataifiedArgs = Object.entries(args).map(([key, value]) => `data-${key}="${value}"`).join(' ')
return `
<!DOCTYPE html>
<html data-http-method="${method.toUpperCase()}">
<head>
<style>
:root {
${varifiedArgs}
}
</style>
<!-- Load the actual CSS -->
<link rel="stylesheet" href="${cssFilePath}">
</head>
<body ${dataifiedArgs}>
</body>
</html>
`
}
Note how we already added the HTTP method as a data attribute and any args as CSS variables and data attributes.
Next, we add the _internal
route to our express app that serves the requested CSS file:
app.get('/_internal/*', (req, res) => {
const appPath = req.path.replace('_internal', 'app')
if (appPath.includes('..') || !appPath.endsWith('.css')) {
res.send('Invalid file')
return
}
const internalFilePath = path.resolve('.' + appPath)
res.sendFile(internalFilePath)
})
A request to /_internal/index.css
would then load app/index.css
and serve it. Puppeteer can now load our apps code and execute it. We could do more validation here, but I kept it basic here for the sake of simplicity.
Now to get puppeteer into the game:
const getContent = async (cssPath, method, args) => {
const dom = createDOM(cssPath, method, args)
const browser = await puppeteer.launch({
headless: true,
args: ['--no-sandbox', '--disable-setuid-sandbox'],
})
const page = await browser.newPage()
const base64Html = Buffer.from(dom).toString('base64')
await page.goto('data:text\/html;base64;charset=UTF-8,' + base64Html, {
waitUntil: 'load',
timeout: 300000,
waitFor: 30000,
})
// Magic!
}
Let's try this with a basic little index.css
:
body::after {
content: '<h1>Hello, World!</h1>';
}
Lo and behold: It works! Puppeteer executes the CSS and displays the result:
Neat side effect: Changing headless: true
to false
allows us to debug the CSS. An out of the box debugger is definitely a nice thing.
Extracting the content
Remember the "future me" problem? Yeah.
We know that we cannot use computed styles to get any element's content
, especially if it contains variables or counters. We also cannot select and copy/paste the rendered text since Chromium cannot do that. So, how do we get the rendered, evaluated text?
Ever downloaded a website as PDF? The evaluated text gets selectable. Can puppeteer create a PDF from a website? Yes, it can. Can we somehow parse the PDF to get the text? Of course we can!
npm i -s pdf-parse
This library lets us parse any given PDF and extract its text. We're not doing any shenanigans with images, layouts and whatnot here. We only render out plain ol' HTML as an unparsed string. We can copy/paste that:
const pdf = require('pdf-parse')
const getContent = async (cssPath, method, args) => {
const dom = createDOM(cssPath, method, args)
const browser = await puppeteer.launch({
headless: true,
args: ['--no-sandbox', '--disable-setuid-sandbox'],
})
const page = await browser.newPage()
const base64Html = Buffer.from(dom).toString('base64')
await page.goto('data:text\/html;base64;charset=UTF-8,' + base64Html,{
waitUntil: 'load',
timeout: 300000,
waitFor: 30000,
})
// Get a PDF buffer
const pdfBuffer = await page.pdf()
// Parse the PDF
const renderedData = await pdf(pdfBuffer)
// Get the PDFs text
return Promise.resolve(renderedData.text)
}
And as a last step, let's adjust the catch-all route to get the text:
// Catch-all route
app.use((req, res) => {
let cssFile = req.path
// So `index.css` works.
if (cssFile.endsWith('/')) {
cssFile += 'index'
}
cssFile += '.css'
// File doesn't exist, so we break here
if (!fs.existsSync(path.resolve('./app/' + cssFile))) {
res.sendStatus(404)
return
}
const cssFilePath = 'http://localhost:3000/_internal' + cssFile
getContent(cssFilePath, req.method, {
...req.query, // GET parameters
...req.body, // POST body
}).then(content => {
res.send(content)
})
})
That should do the trick.
Demo time!
Let's put this thing to test.
Calculator using a form
A basic "Hello World" is simple enough. Let's build a CSS calculator:
body {
--title: '<h1>Calculator:</h1>';
--form: '<form method="POST" action="/"><div><label for="num1">Number 1</label><input id="num1" name="num1"></div><div><label for="num2">Number 2</label><input id="num2" name="num2"></div><button type="submit">Add two numbers</button></form>';
}
[data-http-method="POST"] body {
counter-set: sum var(--num1, 0) val1 var(--num1, 0) val2 var(--num2, 0);
}
[data-http-method="GET"] body::before {
content: var(--title) var(--form);
}
[data-http-method="POST"] body::before {
--form: '<form method="POST" action="/"><div><label for="num1">Number 1</label><input id="num1" name="num1" value="' counter(val1) '"></div><div><label for="num2">Number 2</label><input id="num2" name="num2" value="' counter(val2) '"></div><button type="submit">Add two numbers</button></form>';
counter-increment: sum var(--num2, 0);
content: var(--title) var(--form) '<div>Result: ' counter(sum) '</div>';
}
This calculator uses multiple features:
- Reacting to GET vs POST
- Doing maths
- Displaying the result
So, what does this actually do?
We render a title and a form with two input fields called num1
and num2
. If the "app" encounters a POST request, it displays the result, which is calculated via a CSS counter. The CSS counter is first set to num1
and later on increased by num2
, yielding the sum of the two numbers. Hence: A basic addition calculator.
Does it work? Indeed it does:
Simple two page app with navigation
Let's abstract away some header and some footer into a globals.css
file:
:root {
--navigation: '<ul><li><a href="/">Home</a></li><li><a href="/about">About</a></li></ul>';
--footer: '<footer>© 2022</footer>';
}
We can then use it in a index.css
like so:
@import "./globals.css";
body::after {
content: var(--navigation) '<h1>Hello, World!</h1>' var(--footer);
}
Works like a charm:
Phew. What a ride.
Edit: So, since this apparently sparked some confusion, let me explain why I used mainly JS for this project, even though it says CSS in the title. Every programming language that's executed runs through an interpreter or compiler written in some other language. NodeJS, for example, was originally written in C/C++. The CSS server I've built here would be the equivalent: I use JS to execute CSS. CSS is the userland code for the CSS server, just like JS is the userland code for Node.
I hope you enjoyed reading this article as much as I enjoyed writing it! If so, leave a ❤️ or a 🦄! I write tech articles in my free time and like to drink coffee every once in a while.
If you want to support my efforts, you can offer me a coffee ☕ or follow me on Twitter 🐦! You can also support me directly via Paypal!
Latest comments (108)
This makes as much sense as JSX so there’s that…
But contrary to JSX, this isn't meant to be used in production 😅
You’re insane my man! Bravo!
Thank you! I actually draw some pride from my craziness - it makes you find - how should I say - creative solutions. 😁
Did you mean to
return Promise.resolve(x)
instead ofreturn x
in anasync
function?That must've been a leftover from a previous version of the code, thanks for the find. It doesn't really make much of a difference, as simply returning a value is equivalent to returning a fulfilled promise explicitly.
This is crazy and interesting at the same time 😄. Is it hosted?
I'm highly conflicted by this article.
On the one hand I am impressed that you made it work but on the other, I have an inkling that because it "can" be done, then some poor soul is likely going to do it in a serious manner.
You're likely right, some person will try this in a serious manner eventually. I mentioned several times that they shouldn't, though, and my hope is that this is enough to keep people from using this productively. However, there's also some technical limitations. For example, I haven't quite figured out yet how to do multiplications, let alone trigonometry or even things like powers or roots. CSS currently lacks some fundamental features that make it very unattractive to use for those things without having to sacrifice a ton of time upfront. I hope that people will notice that they're implementing stuff that's already there in JS and just use node instead.
I'm actually really thinking about this right now. Ok, I do consider using Paint API to get JS into the game via CSS a tiny bit of cheating, since it's not "plain" CSS, but, alas, some database connection would be handy, wouldn't it?
I'm so proud of this community. 🫀
Very, very, very cooool :-)
Do you have Git repo?
Not a publice one, no. However, you can recreate this using the code and the steps described in the post :)
I love this. It fits very well with the YouTube presentation "code is art" - sometimes we see stuff which isn't front end, back end, performant or even something you would call a hack, but it is so conveluted that you can only call it art because it takes so much skill and reminds people passion projects are always worth the journey.
Thank you so much! It really was just a silly experiment to see if I could do it and what it takes to get there. I learned a ton along the way, actually, and even though I made something that is utterly useless to the real world, in my opinion, it has fulfilled its purpose by teaching a thing or two. :)
Why are you using a try catch instead of checking if the file exists?
A very good question! I used try/catch as a way to catch all possible file/disk IO errors that might occur. A good example would be permission errors.
Consider this bash code:
It creates a file called
someFile.txt
and changes its mode to no read/write/execute permissions for anyone, not even the file owner. So noone can open the file withoutsudo
or being root.Now, let's check if Node can tell if the file exists and if it can open it:
Shows this:
So, while checking for file existence already catches the most obvious error, using try/catch helps me work around a lot more errors that I did not anticipate.
I'm not sure this works out of the box with my implementation here. The CSS SQL adapter (I honestly never thought that I would write these two words so close together) uses the CSS paint API, so it's creating an image. My server relies on fetching actual machine readable text via PDF, it cannot handle images. However, if we can somehow replace the PDF approach with OCR and simply parse whatever text is on a - say - screenshot of the page, that would open up a ton of possibilities! I was actually thinking about using OCR to begin with but deemed it a total overkill for a simple PoC.
I just cannot believe it!! This is incredible !! A stroke of genius!
Perfectly recommend this post to participants of hackathons and codeathons which do not allow JS.
Haha, even though I said to not try this at home, I would very much like to see the baffled faces of the judges when they see this monstrosity :D
Thank you for the good info!
You're welcome! Glad you liked it :)
You're a mad man. 😂
Being normal is boring, though, isn't it? 😁
try it without javascript tho
Given that, at least as far as I've heard, Node was invented by ripping out Chromiums JS engine and making it executable, I think it should be possible to do the same with the CSS/rendering engine. Might not even need the PDF with that approach.
The main reason I used JS for this, is that I needed some language to carry out the execution. It's basically the same as with Node and C++, only that here it's JS to run CSS, not C++ to run JS.