a while ago i found pylon. the pitch is simple: write typescript, deploy, and your discord bot runs somewhere else. no server to babysit, no process to keep alive.
i liked the idea enough that i wanted to understand how it worked. eventually that turned into the usual bad decision: instead of just reading about it, i built my own.
i called it weeble.
The problem
a discord bot is easy until the code is not yours.
connecting to discord's gateway is the boring part. you open a websocket, receive events, and send requests back to discord. the weird part starts when you want other people to write the code that handles those events.
you can't just eval() random typescript and hope everyone behaves. user code needs to run in a place where it only gets the APIs you give it. it needs limits. and when it gets stuck, you need a way to stop it without taking the whole host process with it.
the route i took was V8 isolates. active deployments run inside their own V8 isolate and their own javascript heap. deployments don't share JS objects with each other or with the host's JS environment.
that does not make isolates magic containers. the actual boundary is the isolate plus the host API you expose to it. if an op lets user code do something dangerous, the isolate will not save you from that. but isolates are a good place to start drawing the line.
i did not want node's vm module here. it is useful for some sandbox-shaped problems, but it is not the boundary i wanted for running other people's bot code. embedding V8 directly made the boundary more explicit: javascript gets an isolate, and every useful thing it can do has to pass through a Rust op.
weeble uses deno_core to embed V8 from Rust. it handles the V8 lifecycle, the event loop, and the op system, which is how javascript calls back into Rust.
The constraint that shaped everything
early on i hit a wall: JsRuntime is not Send.
that means you can't just move a V8 runtime across tokio tasks on a multithreaded runtime. tokio::spawn wants tasks that can move between worker threads, and JsRuntime cannot do that. it wants one owner.
the design i ended up with is simple: each active deployment gets a resident worker. in my version, that worker is a dedicated OS thread. it owns the JsRuntime for the lifetime of the deployment, runs a current_thread tokio runtime, and waits for work.
the gateway sends dispatch work to the runtime. inside the runtime, events go to the resident worker over an mpsc channel. the worker takes one event at a time, dispatches it into V8, and sends the result back over a oneshot channel.
so the runtime is basically a tiny actor: messages go in, javascript runs, replies come out.
when a deployment is warm, the isolate stays resident between events. that means your bot's global state carries over between calls, and we skip the cost of rebuilding V8 every time an event arrives.
there is a tradeoff, though. if a handler mutates global state in a bad way, the next event inherits that mess. that is useful when you want state, and annoying when a buggy handler poisons the runtime until it gets restarted.
timeouts are also less simple than they look.
for cooperative async code, tokio::time::timeout can catch handlers that take too long. if the handler is waiting on an op, or otherwise yields back to the Rust event loop, the timeout has a chance to fire.
but this does not stop CPU-bound javascript:
while (true) {}
that loop never yields. tokio does not get control back, so a normal async timeout is not enough. for that case, the host needs to terminate execution through V8 itself, usually from another thread using an isolate handle.
How user code actually runs
before any event gets dispatched, the runtime boots the isolate in three steps.
first it loads the prelude: globals, polyfills, and the dispatch entry point. then it loads the SDK bundle. then it loads the user's script.
after that, the runtime asks the isolate for the command manifest. that is the list of slash commands the deployment registered. the host stores that manifest so slash commands can be synced and command dispatch can be mapped back to the deployment.
when an event arrives, Rust calls __weebleDispatch inside V8 with the event name and a JSON payload. from there, the SDK routes the event to whatever handler the user registered.
if the handler calls something like message.reply("pong"), it goes through the SDK's op layer:
const ops = Deno.core.ops;
export const rest = {
send_message: (args: RawMessage): Promise<Json> =>
ops.op_send_message(args),
};
Deno.core.ops is the bridge between javascript and Rust. every op is a Rust function registered when the runtime starts.
when JS calls ops.op_send_message, control crosses back into Rust. Rust validates the request, makes the actual Discord HTTP request, and returns the result to javascript.
user code does not get unrestricted network access. direct Discord access goes through host ops, and general fetch() is wrapped with timeouts, response-size limits, and internal-network blocking.
that is the part i like about this model. V8 gives each deployment its own JS world, and deno_core gives the host a narrow bridge into that world. weeble decides what gets to cross the bridge.
Where things stand
weeble runs on my own server and handles a few guilds. it works, which still surprises me a little.
the hard parts are no longer "can it run a bot?" the hard parts are making the boundary boring: predictable timeouts, resource limits that are visible to users, runtime recycling that does not feel random, and ops that are scoped tightly enough that a bad script cannot become everyone else's problem.
that is the part i am still working on.