DEV Community

Cover image for ⚠️ Don't try this at home: CSS _as_ the backend - introducing Cascading Server Sheets!
Pascal Thormeier
Pascal Thormeier

Posted on • Updated on

⚠️ Don't try this at home: CSS _as_ the backend - introducing Cascading Server Sheets!

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:

Comic about a person explaining the "deep abiding goal of every engineer": To take a thing and make it do something it wasn't supposed to do.

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
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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>';
}
Enter fullscreen mode Exit fullscreen mode

This is what you'll get:

A browser window with open inspector, showing the exact thing mentioned above.

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
Enter fullscreen mode Exit fullscreen mode

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>
  `
}
Enter fullscreen mode Exit fullscreen mode

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)
})
Enter fullscreen mode Exit fullscreen mode

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!
}
Enter fullscreen mode Exit fullscreen mode

Let's try this with a basic little index.css:

body::after {
  content: '<h1>Hello, World!</h1>';
}
Enter fullscreen mode Exit fullscreen mode

Lo and behold: It works! Puppeteer executes the CSS and displays the result:

A browser displaying the CSS above as rendered text.

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
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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)
  })
})
Enter fullscreen mode Exit fullscreen mode

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>';
}
Enter fullscreen mode Exit fullscreen mode

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:

Browser window showing a simple calculator, a terminal to the left showing the DOM.

The same calculator, showing the result

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>&copy; 2022</footer>';
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

Works like a charm:

A simple page with navigation, a title and a footer, no styling.

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!

Buy me a coffee button

Top comments (108)

Collapse
 
liviufromendtest profile image
Liviu Lupei • Edited

Developers trying to do everything with JavaScript, no one bats an eye.
Someone trying to do everything with CSS, and everyone loses their mind.

BTW, you should use Endtest to test it.

Collapse
 
liviufromendtest profile image
Liviu Lupei • Edited

Made a meme for this occasion:

css backend

Collapse
 
thormeier profile image
Pascal Thormeier

Amazing! That one gets a special place in my collection❤️

Collapse
 
oliveiracaue profile image
Cauê Oliveira

Don't judge JavaScript. it's not the fault of this madness. 😂

Collapse
 
grahamthedev profile image
GrahamTheDev

Why so serious? 😜🤣

Collapse
 
thormeier profile image
Pascal Thormeier

It's about sending a message. Over the internet. Built with CSS.

Thread Thread
 
grahamthedev profile image
GrahamTheDev • Edited

Do you want to know how I got these vars, huh? 🤣

Thread Thread
 
thormeier profile image
Pascal Thormeier

How about a magic trick? I’m gonna' make this budget disappear! TA-DAAAA!

Thread Thread
 
grahamthedev profile image
GrahamTheDev

I am a man of my (microsoft) Word!

Ok, I think I have run out of clever Joker puns as that one was just terrible!

Although for this article I think we missed the most apt quote:

“The only sensible way to live in this world is without rules.” (when he is being interrogated by Batman)

Collapse
 
posandu profile image
Posandu

Reminds me of joker. 😏😏

Collapse
 
heyclos profile image
Carlos de Santiago

Some people just want to watch my career burn.

Collapse
 
liviufromendtest profile image
Liviu Lupei

😂 nice one

Collapse
 
ankush981 profile image
Ankush Thakur

Best comment ever! :D

Collapse
 
stephenbrooks220413 profile image
Stephen A Brooks • Edited

To me that doesn't make sense why they would act that way, especially when developers and recruiters are more for making everything dynamic. If it makes it easier to load on web browsers then you'd figure it would be opposite.

Collapse
 
grahamthedev profile image
GrahamTheDev

Well I think you and I need to team up as this is the missing piece of my “abusing the Internet” series! 🤣

Absolutely incredible idea 💡

Imagine this idea combined with this one: dev.to/inhuofficial/i-built-a-3-pa... - we could change the world! 🤣

Collapse
 
thormeier profile image
Pascal Thormeier

I'm still amazed that what you built there actually works. Not sure if we would be able to implement the contact form, but the click listeners should be doable: You could resize the HTML tag to the mouse click coordinates and implement ranges via container queries, maybe? That should work...

Collapse
 
grahamthedev profile image
GrahamTheDev

Yeah I am not going to attempt it as my head would explode but I can just imagine how much damage we could do combining them 🤣

Thread Thread
 
thormeier profile image
Pascal Thormeier

Well then, here you go:

html {
    margin: 0;
    padding: 0;
    height: calc(var(--mousey) + 0px);
    width: calc(var(--mousex) + 0px);
    position: absolute;
    overflow: visible;
    display: inline-block;
    container-type: inline-size;
    container-name: mousepos;
}

body {
    position: absolute;
}

@container mousepos (min-width: 0px) and (max-width: 100px) and (min-height: 0px) and (max-height: 100px) {
    body::before {
        content: 'Clicked in 100 by 100 area';
    }
}

/* Usage: /size?mousex=12&mousey=32 */
Enter fullscreen mode Exit fullscreen mode

The amount of overhead this generates for a simple range of two numbers is hilarious, though.

Thread Thread
 
grahamthedev profile image
GrahamTheDev

Ah, I had not thought about it that way, I was thinking "you can't get the mouse position with CSS", but now I realise I hadn't thought about SERVER CSS and we can still have fun on the front end! I really do love this idea, it is completely bonkers 🤣

Thread Thread
 
thormeier profile image
Pascal Thormeier • Edited

Yeah, it takes some time to adjust to that modus operandi, I have yet to fully understand the monstrosity I created here. It definitely needs some out of the box (haha) thinking, but with a bit of creativity, I guess you can achieve a lot.

What I'm most afraid of is mixing up frontend CSS with backend CSS. I mean, there's no way of knowing which is which just from the file tree and every CSS file could technically be executed by the backend.

My brain doesn't like this.

Collapse
 
adrai profile image
Adriano Raiano

OMG!!! Why?

Collapse
 
thormeier profile image
Pascal Thormeier

Simple: To find out if it's possible! :D I'd never use this for real, but it was a fun little experiment nevertheless.

Collapse
 
adrai profile image
Adriano Raiano

You’re awesome & crazy 🤪

Thread Thread
 
thormeier profile image
Pascal Thormeier

No, you're awesome! 😄 Crazy is right, though, being normal is boring. 🤣

Collapse
 
stereoplegic profile image
Mike Bybee

Could vs. should

Collapse
 
kiyov09 profile image
Enrique Mejías

You’re insane my man! Bravo!

Collapse
 
thormeier profile image
Pascal Thormeier

Thank you! I actually draw some pride from my craziness - it makes you find - how should I say - creative solutions. 😁

Collapse
 
darylbarnes profile image
Daryl Barnes

This makes as much sense as JSX so there’s that…

Collapse
 
thormeier profile image
Pascal Thormeier

But contrary to JSX, this isn't meant to be used in production 😅

Collapse
 
emwadde profile image
emwadde

The craziest thing anyone has ever done with css. Embrace the craziness. When developing internal apps, there is this gray area between staging and production, i think I'm gonna use this crazy idea in that area

Collapse
 
thormeier profile image
Pascal Thormeier

If you actually found a use case for this thing, I'm really curious about it! My opinion still is that this should never be used in any project ever, because it would confuse the living hell out of everyone involved.

Collapse
 
chuvisco88 profile image
Fabian Schweizer

But then again, is it confusing just because no one ever used it? What if this were a de-facto standard of doing things? Basically saying it is only confusing because people are not used to it (which might be a good thing or not)

Thread Thread
 
thormeier profile image
Pascal Thormeier • Edited

Yes and no. I pointed out in some other comment that there's no real distinction between frontend CSS and backend CSS and that every valid CSS file can basically be executed by this app. If there's no convention coming from userland to distinguish things, there's really no way to tell, except for opening every single file and looking. I do recognize that this is the same issue with Node/JS, but most FE frameworks know a way around that. If using CSS for the backend indeed was the standard since ages, I fully agree with you. Those conventions probably would've emerged by now. Probably CSS would also look way different, because it would serve two purposes. I could imagine that CSS would've developed into more of a scripting language than what it is today.

Collapse
 
mistval profile image
Randall

Such an awesomely dumb idea, and great write-up!

Collapse
 
thormeier profile image
Pascal Thormeier

Thank you so much! Sometimes the seemingly dumbest ideas turn out to be the most fun!

 
thormeier profile image
Pascal Thormeier

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?

Collapse
 
adam_cyclones profile image
Adam Crockett 🌀

This reminds me of my client side only post only this is way way better!

Collapse
 
thormeier profile image
Pascal Thormeier

Glad you liked my post! I had a quick look - is it the portfolio post you meant? I've got two things to tell you. First, that's comparing apples and oranges. You've built something amazing and useful, I built a silly experiment. Second, every project is awesome, especially if you've had fun building it and learned something along the way. 😀 After all, every project has its very own set of challenges, right? 🙂

Collapse
 
adam_cyclones profile image
Adam Crockett 🌀

dev.to/adam_cyclones/client-side-o...

That one there.

The portfolio post is some really old post 😅

Thread Thread
 
thormeier profile image
Pascal Thormeier • Edited

Oh, I'm sorry, I didn't look hard enough apparently... Your approach is not so different from what I'm doing here, actually - we both use data URLs, you're just doing a lot more with it. I wonder how far you could go with that. Some bootstrapping JS encoded in na data URL that reads/writes to local storage would open up a ton of possibilities!

Collapse
 
bjkippax profile image
Ben

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.

Collapse
 
thormeier profile image
Pascal Thormeier • Edited

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.

Collapse
 
optimisedu profile image
optimisedu

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.

Collapse
 
thormeier profile image
Pascal Thormeier • Edited

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. :)

Collapse
 
thormeier profile image
Pascal Thormeier

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.

Collapse
 
amrikasir profile image
Al Amrikasir

What ??? Whyyy ??
I know.. that awesome, but.. WHY IT WORK ?😱😱😱😱

May I use CSS to add csrf to the form 🤔🤔🤔
Or, use CSS as a backdoor 😈😈😈

Collapse
 
thormeier profile image
Pascal Thormeier

Great question, actually! CSRF would require some sort of randomness to create cryptographically secure tokens, but sadly RNG is not part of CSS just yet. However, since we've got puppeteer in there, one could technically write a browser extension for that! Code injection should be possible currently, it's not doing much validation, but adding that would've exceeded the scope of the post. Then again: Does the average hacker really think about injecting CSS? :D

Collapse
 
jwp profile image
John Peters

I like your thought process. It's how people become artists at what they do. Others will say how did he do that..

Collapse
 
thormeier profile image
Pascal Thormeier

Wow, thank you so much! I like the explorative approach very much. I feel like planning everything in advance is often hindering my creativity. Solve one problem at a time, do some research, implement, on to the next part. Don't get me wrong, there's times you need to plan carefully, but for a silly experiment, creativity is much more important. I hope it's still understandable, though.