What Comes After Serverless: How About Codeless?
Serverless lets you forget about servers; what if you could forget about the code too?
Yes, this joke has been made before, but I’m serious. In the same way that serverless lets backend developers “forget” about servers, what if “codeless” did the same, but for your application code.
In other words, what if API requests to your service included the code that the caller wants to be executed on the server?
Your backend could essentially become a “runtime” for client-provided code. GraphQL APIs in some ways approach this - the backend is an “execution environment” for a (well defined and structured) GraphQL query. What if the “query language” became another programming language instead?
Example
A simple example is fairly easy to implement in Deno, which has built-in sandboxing features.
import { listenAndServe } from "https://deno.land/std@0.91.0/http/server.ts" | |
import { Status } from "https://deno.land/std@0.91.0/http/http_status.ts" | |
const HTTP_PORT = 8080; | |
const options = { hostname: "0.0.0.0", port: HTTP_PORT } | |
console.log(`HTTP server running on localhost:${HTTP_PORT}`) | |
listenAndServe(options, (request) => { | |
if (request.method !== "GET") { | |
request.respond({ status: Status.MethodNotAllowed, body: "must GET" }) | |
return | |
} | |
const start = new Date() | |
const url = `https://${request.url.substring(1)}` | |
let completed = false | |
const p = Deno.run({ | |
cmd: [ "deno", "run", url ], | |
stdout: "piped", | |
stderr: "null" | |
}); | |
// pipe the stdout of the process to the http response | |
request.respond({ body: p.stdout }) | |
// whenever the process exits, mark it as done | |
p.status().finally(() => { | |
completed = true | |
let elapsed = (new Date()).getTime() - start.getTime() | |
console.log({ url, time: `${elapsed}ms` }) | |
}) | |
// once the request is done (or canceled) | |
Deno.readAll(request.r).then(async () => { | |
// if the process hasn't completed, end it | |
if (!completed) p.kill(2) | |
}) | |
// don't let requests run indefinitely | |
setTimeout(() => { | |
if (!completed) p.kill(2) | |
}, 10*1000) | |
}); |
This is just an example to illustrate the point, use at your own risk!
deno run --allow-net --allow-run --unstable main.ts
Will start an HTTP server that responds to GET
requests, parses the path, and runs a Deno subprocess (deno run
with no additional permissions) pointed at the remote code specified by the request path. In other words, you’ll be able to:
> curl localhost:8080/raw.githubusercontent.com/patrickdevivo/codeless/main/examples/hello_x.ts
hello, world!
hello, patrick!
hello, deno!
hello, reader!
Where the path (with an https://
preceding it) resolves to a file with the following contents (which is a Deno script):
const names = ["world", "patrick", "deno", "reader"] | |
names.forEach(n => console.log(`hello, ${n}!`)) |
If you don’t want to run locally, try:
curl https://codeless-deno-ex-vgetngw32a-uc.a.run.app/raw.githubusercontent.com/patrickdevivo/codeless/main/examples/hello_x.ts
Which will hit an instance of this service running in Google Cloud Run (a container based serverless platform).
Why…?
Why not? As more tools make it easier and possible to sandbox external code, maybe this isn’t such a weird (or dangerous) idea.
You only need to update your API runtime, changes to backend business logic can be made in client implementations, which means that…
Clients can call very specific versions of code since the code-to-run is described with the request (pin to a git SHA, version tag, etc.)
Clients can arbitrarily shape the output to their need (only fetch the data they care about, one of the benefits of GraphQL)
No need to agree on explicit frontend <> backend API contracts (beyond the runtime) - frontends can incrementally change the behavior of their backend calls without synchronizing with the API maintainers
Additional Ideas
Be stricter about API resource consumption (could even be based on request status: auth vs unauthed, paying vs non-paying), timeout calls after N seconds, limit memory usage - treat more like a “cloud service”
Map HTTP headers to ENV variables (or some other place) in the execution environment for contextual information like API keys or auth tokens
Enable (permissioned) access to other backend services - like a database - to use directly in the sandboxed code
Support execution in different languages (WebAssemby?)
From a security point of view : this is a nightmare
I made an environment like this! https://observablehq.com/@endpointservices/serverless-cells