Extending and Embedding Spin

Extending Spin with a Custom Trigger

The complete example for a custom trigger can be found on GitHub.

Spin currently implements triggers and application models for:

The Spin internals and execution context (the part of Spin executing components) are agnostic of the event source and application model. In this document, we will explore how to extend Spin with custom event sources (triggers) and application models built on top of the WebAssembly component model, as well as how to embed Spin in your application.

In this article, we will build a Spin trigger to run the applications based on a timer, executing Spin components at a configured time interval.

Custom triggers are an experimental feature. The trigger APIs are not stabilized, and you may need to tweak your trigger code as you update to new Spin versions.

Application entry points are defined using WebAssembly Interface (WIT). Here’s the entry point for HTTP triggers:

interface incoming-handler {
  use types.{incoming-request, response-outparam}

  handle: func(
    request: incoming-request,
    response-out: response-outparam
  )
}

The entry point we want to execute for our timer trigger takes no input and doesn’t return anything. This is purposefully chosen to be a simple function signature. For simplicity, we allow guest code to use Spin’s application variables API but not other Spin APIs.

Custom trigger guest code can’t currently use the Spin SDK and must refer to the Spin WITs directly.

Here is the resulting timer WIT:

// examples/spin-timer/spin-timer.wit
package fermyon:example

world spin-timer {
  import fermyon:spin/variables@2.0.0
  export handle-timer-request: func()
}

handle-timer-request is the function that all components executed by the timer trigger must implement, and which is used by the timer executor when instantiating and invoking the component.

The timer trigger itself is a Spin plugin whose name is trigger-timer. The first part must be trigger and the second part is the trigger type.

You can see the full timer trigger code at the link above but here are some key features.

Implement the Trigger World

The timer trigger implements the WIT world described in spin-timer.wit. To do that, it uses the wasmtime binding generator — this generates code that allows the trigger to invoke the guest’s entry point, and allows the guest to invoke the Spin APIs available in the world.

// examples/spin-timer/src/main.rs
wasmtime::component::bindgen!({
    path: ".",
    world: "spin-timer",
    async: true
});

The Trigger Implements the Trigger Trait

Using Trigger allows the trigger to offload a great deal of boilerplate loader work to the spin_trigger crate and the FactorsTriggerCommand CLI helper.

struct TimerTrigger {
    speedup: u64,
    component_timings: HashMap<String, u64>,
}

#[async_trait]
impl<F: RuntimeFactors> Trigger<F> for TimerTrigger {
    // ...
}

The Trigger trait is generic in the set of factors supported by the trigger - this is roughly the set of APIs available to guest code. In most circumstances, your implementation should also be generic, as shown, because your trigger is only concerned with detecting events, and can do that regardless of what APIs are available to the guests that handle those events.

The Trigger is an Executable

A trigger is a separate program, so that it can be installed as a plugin. So it is a Rust bin project and has a main function. It can be useful to also provide a library crate, so that projects that embed Spin can load it in process if desired, but the timer sample doesn’t currently show that.

type Command = FactorsTriggerCommand<TimerTrigger, FactorsBuilder>;
//                                         |              |
//           the trigger type you created above           |
//                      the factors (APIs) available to guests

#[tokio::main]
async fn main() -> Result<(), Error> {
    let t = Command::parse();
    t.run().await
}

FactorsTriggerCommand allows you to configure the set of factors. spin_runtime_factors::FactorsBuilder will give you a set that matches the Spin CLI, which is almost always the right choice for a trigger plugin. If other hosts link your trigger, they will initialize it with the factors they support instead. (This is why it’s desirable for your Trigger implementation to be as generic as possible!)

The Trigger Detects Events…

In this case the trigger “detects” events by running a timer. In most cases, the trigger detects events by listening on a socket, completion port, or other mechanism, or by polling a resource such as a directory or an HTTP endpoint.

for (component_id, interval_secs) in &self.component_timings {
    scope.spawn(async {
        let duration =
            tokio::time::Duration::from_millis(*interval_secs * 1000 / speedup);
        loop {
            tokio::time::sleep(duration).await;

            self.handle_timer_event(&trigger_app, component_id)
                .await
                .unwrap();
        }
    });
}

…and Invokes the Guest

The Trigger and FactorsTriggerCommand infrastructure provides the trigger with a TriggerApp representing the configured WebAssembly environment, already initialized with the guest Wasm for each component. When an event occurs, the trigger creates a component instance from the TriggerApp and invokes it via the WIT interfaces.

async fn handle_timer_event<F: RuntimeFactors>(
    &self,
    trigger_app: &TriggerApp<Self, F>,
    component_id: &str,
) -> anyhow::Result<()> {
    // Obtain a component instance from the app
    let instance_builder = trigger_app.prepare(component_id)?;
    let (instance, mut store) = instance_builder.instantiate(()).await?;
    // Wrap the instance in the generated WIT bindings
    let timer = SpinTimer::new(&mut store, &instance)?;
    // Invoke the guest entry point via the bindings
    timer.call_handle_timer_request(&mut store).await
}

Other Ways to Extend and Use Spin

Besides building custom triggers, the internals of Spin could also be used independently:

  • The Spin execution context can be used entirely without a spin.toml application manifest — for embedding scenarios, the configuration for the execution can be constructed without a spin.toml, for example by running applications only from a registry.
  • You can create a custom Spin runtime with a different set of factors to target specific scenarios.