8 min read

Using Supabase's Edge Runtime to Supercharge Your App

Using Supabase's Edge Runtime to Supercharge Your App
Photo by SpaceX / Unsplash
This article was written for Supabase's Launch Week 7 where they launched a bunch of awesome stuff. I took the opportunity to play around with Deno and Supabase's new open-sourced edge runtime.

Over ten years ago, I worked at a small backend as a service startup. We mostly catered to mobile developers and built something similar to Firebase and Parse. Our core platform was a Rails app built around MongoDB.

Pretty, uh, similar to Supabase, actually.

Anyways, the product mostly focused on CRUD APIs that would allow mobile developers to focus on building their apps. But it quickly became apparent that a very important feature was to allow the developers to execute code server side and not the client side.

Client-side code is insecure; it's running in an untrusted environment. Almost every application needs to run some amount of code in a secure environment to ensure it hasn't been tampered with.

To enable this functionality, we built a solution around Node.js' vm module, which allowed us to run customer code in its own context.

Yeah, this warning? Don't worry about it. It's all gonna be fine.

This... worked, but it wasn't a great solution. Not only was it insecure, but we had to manually add NPM modules to the root project and allowlist them into the vm context. Updating the modules became a nightmare because customers started to rely on specific versions.

Updating Node.js versions was basically impossible.

Oh, the woes of 2014.

It wasn't pretty. But that's how it worked for many years until the company shut down.

For the next few years, I've come back to this idea of running untrusted customer code in the cloud and didn't have many breakthroughs. It's a challenging problem; you want to give a lot of flexibility and power to your customers, but that comes with security risks.

A heavy approach would be to dockerize untrusted code and run it within a docker container. Again, this works but is expensive and requires a large amount of resources.

Scaling that is painful.

And then along came Deno.

One of Deno's core functions is the ability to run untrusted code. Not only can the process be sandboxed to not have network or disk access, but it's very easy to spin up new V8 isolated contexts to run code in a secure way.

Running these functions in the cloud is also very easy with Deno Deploy, so you could scale out the code.

But what if you want to run these functions within your own infrastructure? There hasn't been a high-performance way to scale that out.

And then, Supabase released its open-source edge runtime during its Launch Week.

The edge runtime is a Rust project more than a JavaScript one. It's a high-performance HTTP proxy that delegates the logic to a Deno function.

That Deno function is called the main function and runs in the standard runtime API. This means it can access the full environment, but still is restricted to what the Deno runtime offers (no writing files)!

However, to allow for unknown user code to execute in a secure manner, the main function can proxy the request to a user runtime.

That user runtime has strict limits placed on it in terms of memory and CPU usage. You allowlist the environment variables it has access to, which allows you to protect server secrets and only let it access user environment variables.

To make creating the user isolate easy, the Supabase edge runtime provides an EdgeRuntime object that allows Deno to call out and run a worker that is in its own v8 isolate.

Unlock custom code for every SaaS app, no matter where it's hosted.

What we can now do is unlock the ability for your users to write custom code that can be executed easily on the server within your own infrastructure. This unlocks an incredible "pressure escape valve" to allow your customers to implement their own (complex) functionality if they need to.

As your SaaS targets enterprise customers, you'll find that these customers have demands that are very... specific. And often complicated. And it's common for them to only impact their tenant, so it's hard to build a feature around it.

You don't want to spend time crafting one-off solutions.

Instead, you want to allow your most demanding customers powerful features that they can customize as needed.

So let's build it out!  

I don't have a current SaaS app to add this to, so we're going to use a hypothetical one.

Let's say you are building a customer relationship management (CRM) SaaS app and saving customer information.

But an enterprise customer (your customer, not one in the CRM) wants to add some custom validation to their tenant. You kept your data structure flexible, so you don't have fields marked as required. But the user wants to ensure phone numbers are added, and only a subset of area codes are allowed.

You brainstorm how you can add a bunch of custom validation logic that your customers can implement. You briefly consider making your own RegEx parser. You go down a dark rabbit hole of validation libraries and resurface only to decide the only viable strategy is to write your own. In its own DSL. In Rust.

Or... you can allow the customer to write some custom code so they can implement whatever fun logic they need.

You get... Salesforce in a bottle! Or something.

And with the new edge runtime Supabase open sourced... you can do that trivially.

Let's go!

Show me the code!

The repo with the files can be found here.

💻
I've found a few small issues with the edge runtime documentation as I went through this exercise. I'm making pull requests to fix the issues.

In this CRM, the customer wants to run validation on create or update. You share the documentation which shows what the Customer data structure looks like.

type Customer = {
  id?: string;
  name?: string;
  accountId?: string;
  email?: string;
  phone?: string;
  mailingStreet?: string;
  mailingCity?: string;
  mailingState?: string;
  mailingPostalCode?: string;
  mailingCountry?: string;
  createdDate?: Date;
  lastModifiedDate?: Date;
};

This data is fed into your Postgres database (what else?) and saved. Since you want it to be flexible, you've kept them all nullable. This keeps your small business customers happy, who can quickly dump in partial data.

To allow the customer to write custom validation logic in whatever way they want, they can write a function that will execute upon customer creation and update. The function takes in an HTTP request that has the customer as the JSON payload. Your documentation says the user should return a 200 OK with the data they want to be saved, or an HTTP code greater than 399 and an error.

Here's what the enterprise customer could write:

import { serve } from 'https://deno.land/std@0.131.0/http/server.ts'

type Customer = {
  id?: string
  name?: string
  accountId?: string
  email?: string
  phone?: string
  mailingStreet?: string
  mailingCity?: string
  mailingState?: string
  mailingPostalCode?: string
  mailingCountry?: string
  createdDate?: string
  lastModifiedDate?: string
}

console.log('validate create started')

serve(
  async (req: Request) => {
    const customer: Customer = await req.json()

    // write whatever custom logic you want to your heart's content

    const validPhone = customer.phone && customer.phone.startsWith('802')
    if (!validPhone) {
      return new Response(JSON.stringify({ error: 'Invalid phone number.' }), {
        status: 400,
        headers: {
          'Content-Type': 'application/json'
        }
      })
    }

    return new Response(JSON.stringify(customer), {
      headers: { 'Content-Type': 'application/json', Connection: 'keep-alive' }
    })
  },
  { port: 9005 }
)
validate.ts

The customer object is read from the request, and the phone number is examined. If the phone number is falsey or does not start with 802, it's marked as invalid.

To make this easier, you could create a Deno library of common types and functions, but for this example, all the code is in the file.

This is so powerful because the customer can write anything they need. They could even fetch resources and do lookups to ensure the data is valid. They can even add dependencies they want, not just the ones that are safelisted.

Take that 2014 me!

So how do we run this?

Fire up the edge runtime.

The key to making this scaleable and painless is to have the edge runtime easy to run and not rebuild anything when the customer updates their custom code. Rebuilding (like a docker image) is time and resource consuming.

Instead, using the edge runtime and a docker volume mount, we can write the custom code in a directory and execute it without restarting a single process.

The canonical user code can live in a database or object storage, but the edge runtime needs to execute a file. It can't execute a string containing the code. You'll need to write it to disk first.

So first...

Run the runtime

  1. Clone the edge-runtime repo:
https://github.com/supabase/edge-runtime

2. cd into the directory and build the docker image:

docker build -t edge-runtime .

3. Run it:

mkdir -p /tmp/functions

# Copy in the main function as a starting point
cp -R examples/main /tmp/functions

docker run -it --rm -p 9000:9000 -v /tmp/functions:/usr/services edge-runtime start --main-service /usr/services/main

Here were are mounting /tmp/functions to /usr/services which is where the service will look for the functions. You can mount any directory you want or use a Kubernetes volume. Also, make sure you move or create the main-service function you want to use. Here, I move over the Supabase main function to serve as the entry point.

Now all you need to do to create new functions dynamically is to write to that directory. You could do that with a simple Deno web server:

import { Application, Router } from 'https://deno.land/x/oak/mod.ts'

const app = new Application()
const router = new Router()

router.post('/upload', async (context) => {
  const result = context.request.body({ type: 'json' })
  const { script, uuid } = await result.value

  const path = `/tmp/functions/${uuid}/index.ts`

  // Ensure directory exists
  await Deno.mkdir(`/tmp/functions/${uuid}`, { recursive: true })

  // Save script to disk
  await Deno.writeTextFile(path, script)

  context.response.status = 200
  context.response.body = {
    success: true,
    message: 'Script saved successfully',
    path
  }
})

app.use(router.routes())
app.use(router.allowedMethods())

console.log('Listening on http://localhost:8000')
await app.listen({ port: 8000 })
server.ts

Make sure you run it with the correct permission:

deno run --allow-net --allow-read --allow-write server.ts

Now to tie it all together, you can create a basic function via your Deno server and then immediately call it via the edge runtime:

# `validate.ts` is the validate customer code from above.

curl -X POST -H "Content-Type: application/json" \
-d @- http://localhost:8000/upload <<EOF
{
    "uuid": "validate-customer-123",
    "script": $(jq -Rs '.' ./validate.ts)
}
EOF

And then call it!

curl --request POST 'http://localhost:9000/validate-customer-123' \
-H 'Content-Type: application/json' \
-d '{ "name": "Ethan Mick" }'

# Result
# {"error":"Invalid phone number."}

Your main application could call these functions, or you could expose them as needed.

Congratulations! You're now allowing customers to write custom logic that your app can easily hook into during API lifecycles. With the AI tooling floating around, customers could augment their data with GPT-led insights trivially.

Unlocking superpowers for everyone

The power of open-source, and why I really respect what Supabase is doing here, is that it unlocks incredible power across the entire ecosystem. Instead of keeping the new edge runtime closed source, anyone can now use the runtime to add amazing powers to their app.

Here, just like my old startup of a decade ago, we've walked through how you can add the ability for all your customers to run custom code on your infrastructure securely and easily.

I can't wait to see what you'll build.