Skip to main content

Overview

Discord events flow from the gateway through Serenity, into the runtime’s dispatch system, and finally to JavaScript event handlers in guild isolates.

High-Level Flow

Step-by-Step Flow

1. Gateway Event Reception

Discord sends events over WebSocket to Serenity’s gateway client. Source: apps/runtime/src/discord_handler.rs:26

2. Event Handler Dispatch

Serenity calls DiscordHandler::dispatch() with a FullEvent.
async fn dispatch(&self, _ctx: &Context, event: &FullEvent) {
    match event {
        FullEvent::Message { new_message: msg, .. } => {
            let payload = EventMessage::from(msg);
            let guild_id = msg.guild_id.map(|g| g.get().to_string());
            // ...
        }
        // ... other events
    }
}
Source: apps/runtime/src/discord_handler.rs:26

3. Payload Conversion

Discord types are converted to serializable payloads using the expose_payload macro.
#[expose_payload]
pub struct EventMessage {
    id: String,
    channel_id: String,
    guild_id: Option<String>,
    content: String,
    author: EventUser,
    member: Option<EventMember>,
}
Source: apps/runtime/src/discord_handler.rs:426
The expose_payload macro auto-generates serde::Serialize and TypeScript type definitions.

4. Guild Filtering

flora is guild-only. Events without a guild_id are dropped.
let guild_id = msg.guild_id.map(|guild| guild.get().to_string());
if guild_id.is_none() {
    return; // Drop non-guild events
}
Source: apps/runtime/src/discord_handler.rs:74

5. Event Dispatch to Runtime

The handler calls BotRuntime::dispatch_js_event() with event name, guild ID, and payload.
self.runtime
    .dispatch_js_event("messageCreate", guild_id, value)
    .await
Source: apps/runtime/src/discord_handler.rs:78

6. Worker Selection

The runtime routes the event to the appropriate worker using consistent hashing.
pub async fn dispatch_js_event(
    &self,
    event: &str,
    guild_id: Option<String>,
    payload: Value,
) -> Result<(), AnyError> {
    match &guild_id {
        Some(gid) => {
            let worker_idx = self.worker_for_guild(gid);
            self.workers[worker_idx]
                .dispatch(guild_id, event.to_string(), payload)
                .await
        }
        None => {
            // Broadcast to all workers
        }
    }
}
Source: apps/runtime/src/runtime/mod.rs:179 Guild routing:
  • Hash guild_id using DefaultHasher
  • Modulo by worker count to get index
  • Guilds always route to same worker (sticky routing)
Source: apps/runtime/src/runtime/mod.rs:252

7. Migration Queue Check

If the guild is migrating, events are queued instead of dispatched.
if self.enqueue_migrating_event(gid, QueuedGuildEvent { event, payload }).await {
    return Ok(()); // Event queued during migration
}
Source: apps/runtime/src/runtime/mod.rs:189

8. Backlog Check

Droppable events (typing, presence) are dropped if worker backlog is high.
if is_droppable_event(event)
    && worker.backlog.load(Ordering::Relaxed) >= MAX_DROPPABLE_BACKLOG
{
    info!("dropping event due to backlog");
    return Ok(());
}
Source: apps/runtime/src/runtime/mod.rs:202 Droppable events:
  • Typing indicators
  • Presence updates
  • Other non-critical events
Source: apps/runtime/src/runtime/constants.rs

9. Worker Command Send

The runtime sends a WorkerCommand::DispatchEvent to the worker’s channel.
pub async fn dispatch(
    &self,
    guild_id: Option<String>,
    event: String,
    payload: Value,
) -> Result<(), AnyError> {
    let (tx, rx) = oneshot::channel();
    self.backlog.fetch_add(1, Ordering::Relaxed);
    self.sender.send(WorkerCommand::DispatchEvent {
        guild_id,
        event,
        payload,
        respond_to: tx,
    })?;
    rx.await?
}
Source: apps/runtime/src/runtime/types.rs

10. Worker Thread Processing

The worker thread receives the command and processes it.
WorkerCommand::DispatchEvent { guild_id, event, payload, respond_to } => {
    let result = dispatch_to_worker(
        &mut guild_runtimes,
        &mut default_runtime,
        guild_id,
        event,
        payload,
        worker_id,
        &limits,
    ).await;
    let _ = respond_to.send(result);
}
Source: apps/runtime/src/runtime/worker.rs:175

11. Runtime Lookup

The worker finds the guild’s JavaScript runtime.
let runtime = match guild_id {
    Some(ref gid) => guild_runtimes
        .get_mut(gid)
        .ok_or_else(|| AnyError::msg("No runtime available for guild"))?,
    None => default_runtime
        .as_mut()
        .ok_or_else(|| AnyError::msg("No default runtime available"))?,
};
Source: apps/runtime/src/runtime/worker.rs:738

12. Dispatch into V8

The event is dispatched into the V8 isolate with timeout.
pub async fn dispatch_into_runtime(
    js_state: &mut JsRuntimeState,
    event: String,
    payload: Value,
    worker_id: usize,
    limits: &RuntimeLimits,
) -> Result<(), AnyError> {
    let _secret_scope = SecretScope::enter(js_state.secrets.clone());
    let dispatch_fn = js_state.dispatch_fn.as_ref().unwrap().clone();

    // Convert event and payload to V8 values
    let event_value = serde_v8::to_v8(scope, &event)?;
    let payload_value = serde_v8::to_v8(scope, &payload)?;

    // Call dispatch function
    let call = js_state.runtime_mut()
        .call_with_args(&dispatch_fn, &[event_value, payload_value]);

    // Run with timeout
    with_timeout(limits.dispatch_timeout, async {
        js_state.runtime_mut()
            .with_event_loop_promise(call, PollEventLoopOptions::default())
            .await
    }, "dispatch").await
}
Source: apps/runtime/src/runtime/worker.rs:777

13. Secret Scope Entry

Secrets are made available via thread-local storage during dispatch.
let _secret_scope = SecretScope::enter(js_state.secrets.clone());
This allows flora_secrets ops to access guild secrets safely. Source: apps/runtime/src/runtime/secrets.rs

14. JavaScript Handler Execution

The SDK’s dispatch function routes to registered handlers.
function dispatch(event: string, payload: any) {
    const handlers = eventHandlers.get(event) || []
    for (const handler of handlers) {
        await handler(payload)
    }
}

15. Promise Resolution

The V8 event loop runs until the dispatch promise resolves or times out.
with_timeout(limits.dispatch_timeout, async {
    js_state.runtime_mut()
        .with_event_loop_promise(call, PollEventLoopOptions::default())
        .await
}, "dispatch").await
Source: apps/runtime/src/runtime/worker.rs:809

16. Timeout Handling

If dispatch times out, the runtime is terminated to prevent stale state.
if let Err(ref err) = result {
    if err.is::<RuntimeTimeout>() {
        metrics().timeout_error();
        terminate_runtime(js_state.runtime_mut(), worker_id, "dispatch").await;
    }
}
Source: apps/runtime/src/runtime/worker.rs:826

17. Metrics Recording

Dispatch success/failure is recorded for observability.
match &result {
    Ok(_) => metrics().dispatch_success(start.elapsed()),
    Err(_) => metrics().dispatch_error(),
}
Source: apps/runtime/src/runtime/worker.rs:835

Timeout Cascade

Each phase has configurable timeouts:
PhaseConfigDefaultEffect
Bootstrapboot_timeout_secs5sRuntime init timeout
Loadload_timeout_secs30sScript load timeout
Dispatchdispatch_timeout_secs3sPer-event timeout
Croncron_timeout_secs5sCron handler timeout
Migrationmigration_timeout_ms500msMigration quiesce timeout
Timeouts that expire trigger runtime termination to prevent inconsistent state.

Error Propagation

Errors flow backward through the chain:
  1. JavaScript throws error
  2. V8 promise rejects
  3. Deno Core returns AnyError
  4. Worker sends error via oneshot channel
  5. BotRuntime receives error
  6. Error logged via tracing

Special Event Types

Broadcast Events

Events without a guild_id are broadcast to all workers and runtimes.
None => {
    let futures: Vec<_> = self.workers.iter()
        .map(|w| w.broadcast(event.to_string(), payload.clone()))
        .collect();
    futures::future::try_join_all(futures).await?;
}
Source: apps/runtime/src/runtime/mod.rs:220

Cron Events

Cron jobs dispatch synthetic events with the pattern __cron:<name>.
let event_name = format!("__cron:{}", cron_name);
let payload = serde_json::json!({
    "name": cron_name,
    "scheduledAt": now.to_rfc3339(),
});
Source: apps/runtime/src/runtime/worker.rs:306

Backpressure Mechanisms

Worker Backlog

Each worker tracks pending events. Droppable events are dropped when backlog exceeds threshold.
const MAX_DROPPABLE_BACKLOG: usize = 1000;
Source: apps/runtime/src/runtime/constants.rs

Migration Queuing

During migration, events are queued and replayed after migration completes.
async fn enqueue_migrating_event(&self, guild_id: &str, event: QueuedGuildEvent) -> bool {
    let mut queues = self.migration_queues.lock().await;
    let Some(queue) = queues.get_mut(guild_id) else {
        return false; // Not migrating
    };
    queue.push(event);
    true
}
Source: apps/runtime/src/runtime/mod.rs:275

Performance Considerations

  • Unbounded channels for worker commands (backpressure via backlog counter)
  • Oneshot channels for response synchronization
  • Parking lot mutexes for low-contention locks
  • Lazy V8 compilation (scripts compiled on first load)
  • Event loop reuse (isolates persist across events)

Build docs developers (and LLMs) love