a normal Discord bot trusts its own code. Weeble has a worse arrangement: it runs code uploaded by other people on my server.
that code can loop forever, fill its heap, call APIs it should not have access to, or leave broken global state behind for the next event. one bad deployment should not take the rest with it.
this is the problem that shaped Weeble.
i first got the idea from Pylon. its pitch was simple: write TypeScript, deploy it, and stop thinking about the server underneath.
i wanted to know how that worked. reading about it would have been reasonable, so naturally i built my own version instead.
TypeScript does not go straight into V8
when someone publishes a deployment, Weeble bundles the project with esbuild. imports are resolved, TypeScript becomes JavaScript, and the result is stored as a deployment revision.
the runtime never sees the original project tree. it gets the finished JavaScript bundle, the SDK, and enough context to know which bot and guild the code belongs to.
this also keeps build work away from the runtime. a warm deployment should be ready to handle an event, not compile a project first.
running somebody else's JavaScript
the quick version would be eval().
the quick version would also be a terrible idea.
user code needs its own JavaScript environment and a small set of things it is allowed to do. reading host files, contacting private services, or reaching into another deployment should be impossible unless i expose an API that permits it.
i considered Node's vm module, but its own documentation is fairly direct: "The node:vm module is not a security mechanism. Do not use it to run untrusted code."
Weeble uses V8 isolates through deno_core. each active deployment has its own isolate and JavaScript heap. deployments cannot pass JS objects to each other or access the host's JS environment directly.
an isolate is still not a container. if i expose an op that accepts any guild ID and forget to check which guild owns the deployment, V8 will happily call it.
the boundary is the isolate plus every Rust op connected to it.
the constraint that decided the design
fairly early on, i ran into this:
JsRuntime is not Send
a V8 runtime cannot move freely between Tokio worker threads. tokio::spawn expects a future that may move, while JsRuntime needs one owner.
a warm Weeble deployment gets a resident worker on its own OS thread. that thread creates a single-threaded Tokio runtime, owns the JsRuntime, and waits for requests over a channel.
events are handled one at a time. the caller waits for the result through a oneshot channel.
it is basically an actor, although i did not start the project thinking i was going to build one.
keeping the isolate resident avoids rebuilding V8 for every Discord event. it also means global state survives:
let messagesSeen = 0;
discord.on(discord.events.MESSAGE_CREATE, async () => {
messagesSeen++;
console.log(messagesSeen);
});
the next event sees the updated value. that is useful until a handler corrupts something and every event after it inherits the damage.
Weeble eventually recycles residents even when they appear healthy. the current default is 10,000 dispatches. idle residents are removed after 15 minutes.
a runtime terminated by its CPU, wall-time, or heap limit is marked as poisoned and removed immediately.
one event through the system
a MESSAGE_CREATE event takes this path:
the gateway receives the event and looks up the deployment attached to that bot and guild. it sends the event name and payload to the runtime service.
the resident worker takes the request from its queue. inside V8, Rust calls:
globalThis.__weebleDispatch("MESSAGE_CREATE", payload);
the SDK finds the handlers registered for that event and calls them.
if a handler replies:
await message.reply("pong");
the SDK eventually reaches a host op:
await Deno.core.ops.op_send_message(args);
control is back in Rust at that point. Rust checks the deployment context, validates the request, and sends the Discord HTTP request through the host.
the response crosses the bridge in the other direction and becomes a JavaScript value again.
during startup, the isolate loads the runtime prelude, the SDK, approved package modules, and finally the user's bundle. once the bundle has registered everything, the host reads its slash-command and scheduled-task manifests.
the gateway can sync commands from those manifests without trying to inspect the user's source code.
stopping code that does not want to stop
async timeouts only solve part of the problem.
if a handler is waiting on an HTTP request or another async op, it yields control to the event loop. a wall timeout can notice that the request took too long.
this is different:
while (true) {}
the loop never yields. the thread running V8 cannot stop itself because JavaScript is still holding it.
Weeble keeps a V8 isolate handle in a watchdog outside the resident thread. when the deployment exceeds its CPU allowance, the watchdog calls terminate_execution() on the isolate.
the resident is poisoned after that. i throw it away. trying to keep using an isolate after forced termination is the kind of optimization i would rather not debug at 3 AM.
these are the current runtime defaults:
CPU time per dispatch 500 ms
Wall time per dispatch 10 s
Maximum V8 heap 64 MiB
Maximum fetch response 10 MiB
Resident idle lifetime 15 min
Resident recycle limit 10,000 dispatches
they are limits, not performance measurements. i still need proper numbers for cold startup time, warm dispatch latency, and memory use under real deployments.
outside the isolate
V8 is the closest boundary around user code, but it is not the only one.
the runtime itself runs inside a read-only container. Linux capabilities are dropped, no-new-privileges is enabled, and the only writable area is a temporary filesystem. the container also has its own memory limit.
general fetch() is available inside deployments, but the host wraps it. requests have deadlines and response-size limits. loopback addresses, private network ranges, and cloud metadata endpoints are blocked.
most SDK methods end at a Rust op. sending messages, fetching guild members, using KV, and calling Discord all cross that boundary.
there is still an uncomfortable detail here. deployments have separate V8 isolates, but resident isolates live inside the same native runtime process. a V8 escape would be more serious than a normal SDK permission bug. the container protects the host, but it does not magically turn every isolate into its own machine.
Cloudflare's Workers runtime uses more layers around V8, including process-level sandboxing and separation between groups of isolates. Weeble is not at that level. pretending otherwise would make the design harder to improve.
for now, i treat every exposed op as security-sensitive and keep the runtime container away from anything it does not need.
where it is now
Weeble runs on my server and handles real deployments across a few guilds.
getting JavaScript to execute was the fun part. the work now is slower: making limits predictable, exposing useful errors, checking every operation against its deployment, and deciding how much isolation is enough before more people use it.
the runtime works. i am still working on making its failures boring.