Skip to content

feat: Implement path based router for host with multi-component routing capability within a workload#5

Draft
Aditya1404Sal wants to merge 1 commit intobettyblocks:mainfrom
Aditya1404Sal:feat/path-routing
Draft

feat: Implement path based router for host with multi-component routing capability within a workload#5
Aditya1404Sal wants to merge 1 commit intobettyblocks:mainfrom
Aditya1404Sal:feat/path-routing

Conversation

@Aditya1404Sal
Copy link

@Aditya1404Sal Aditya1404Sal commented Feb 13, 2026

image

interface_config vs Named Interfaces: Path-Based Routing

The Problem

A workload contains multiple components that each need to handle different HTTP paths. The runtime needs to route incoming requests to the correct component based on the URL path.

Two approaches exist for passing config to plugins: Named Interfaces (upstream wasmCloud PR) and interface_config (per-component config). This document explains why interface_config is the right fit for path-based routing.

The Two Approaches

Named Interfaces (Workload-Level)

Config lives on hostInterfaces at the workload level. Each named entry represents a separate instance of the same interface type. The component selects which instance to use via an identifier (e.g. store::open("cache")).

apiVersion: runtime.wasmcloud.dev/v1alpha1
kind: WorkloadDeployment
metadata:
  name: my-app
spec:
  template:
    spec:
      components:
        - name: my-component
          image: ghcr.io/example/my-component:0.1.0
      hostInterfaces:
        # Named: NATS-backed keyvalue for caching
        - name: cache
          namespace: wasi
          package: keyvalue
          interfaces: [store, atomics, batch]
          config:
            backend: nats
            bucket: cache-kv
        # Named: Redis-backed keyvalue for sessions
        - name: sessions
          namespace: wasi
          package: keyvalue
          interfaces: [store]
          config:
            backend: redis
            url: redis://redis:6379
        # Unnamed: HTTP (backwards compatible, no name needed)
        - namespace: wasi
          package: http
          interfaces: [incoming-handler]
          config:
            host: my-app.localhost

interface_config (Per-Component)

Config lives on each component. Different components declare their own config for the same interface type. The plugin reads per-component config during binding and routes accordingly.

apiVersion: runtime.wasmcloud.dev/v1alpha1
kind: WorkloadDeployment
metadata:
  name: multi-component-routing
spec:
  replicas: 1
  template:
    spec:
      hostSelector:
        hostgroup: public-ingress
        router-mode: path
      components:
        - name: user
          image: ghcr.io/aditya1404sal/components/user:0.1.0
          interfaceConfig:
            "wasi:http/incoming-handler":
              path: "/user/{id}"
        - name: admin
          image: ghcr.io/aditya1404sal/components/admin:0.1.0
          interfaceConfig:
            "wasi:http/incoming-handler":
              path: "/admin"
      # Workload-level: declares HTTP is needed
      # Per-component interfaceConfig provides the path routing
      hostInterfaces:
        - namespace: wasi
          package: http
          interfaces:
            - incoming-handler

Why interface_config Is Better for Path Routing

1. Direction of Control

The fundamental difference is who initiates the call:

Named Interfaces interface_config
Direction Component → Runtime Runtime → Component
Example store::open("cache") HTTP request arrives, plugin routes to component
Who decides The component picks the backend The plugin picks the component

Path routing is inherently runtime-to-component: a request arrives, the runtime examines the path, and forwards it to the right component. The component doesn't choose — it just handles what it receives.

Named interfaces are designed for the opposite direction — the component explicitly selects which backend instance to use. There's no equivalent of store::open("name") for incoming-handler because the component doesn't initiate incoming requests.

2. Config Granularity

Named interfaces attach config at the workload level — shared across all components. But path routing needs config at the component level — each component declares its own path.

With named interfaces, you'd need a separate named entry per path, then an additional mapping from name → component. That mapping doesn't exist in the spec today.

With interface_config, the mapping is implicit: each component declares its own path. No extra indirection.

3. Manifest Clarity

Named interfaces for path routing would look awkward:

# Awkward: named interfaces for path routing
hostInterfaces:
  - name: user-route
    namespace: wasi
    package: http
    interfaces: [incoming-handler]
    config:
      path: /user/{id}
      target-component: user    # doesn't exist in the spec
  - name: admin-route
    namespace: wasi
    package: http
    interfaces: [incoming-handler]
    config:
      path: /admin
      target-component: admin   # doesn't exist in the spec

You'd need a target-component field that doesn't exist in the spec. This breaks the named interfaces model, where the component picks the instance — not the other way around. If a host interface points to more than one component for a single-use resource like incoming-handler, the linkage fails because two components can't both listen on the same endpoint.

With interface_config, the intent is clear: the config sits right next to the component it belongs to. The plugin builds a straight path → component lookup. Named interfaces would add an unnecessary name → path → component indirection.

When to Use Which

Use Case Right Approach
One component, two keyvalue backends (NATS + Redis) Named Interfaces
One component, two message queues Named Interfaces
Multiple components, different HTTP paths interface_config
Multiple components, different message routing keys interface_config
Per-component config for any interface interface_config

Named Interfaces: Same component needs multiple instances of the same interface (multi-backend).

interface_config: Different components need different config for the same interface (per-component routing).

They solve different problems and can coexist.

@Aditya1404Sal Aditya1404Sal force-pushed the feat/path-routing branch 4 times, most recently from 0bb8ea0 to 8559b39 Compare February 13, 2026 11:59
@Aditya1404Sal Aditya1404Sal force-pushed the feat/path-routing branch 7 times, most recently from 431bc01 to f5fb3be Compare March 6, 2026 14:51
@Aditya1404Sal Aditya1404Sal changed the title feat: Implement path based router for host feat: Implement path based router for host with multi-component routing capability within a workload Mar 6, 2026
@Aditya1404Sal Aditya1404Sal force-pushed the feat/path-routing branch 3 times, most recently from ed8e0a0 to 51ad9c8 Compare March 9, 2026 08:16
}

fn route_incoming_request(&self, req: &Request<Incoming>) -> Result<String> {
let lock = self.router.try_read()?;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

try_read?

dont we want to wait until the router is rebuild? (I would assume that that doesnt take long)

Copy link
Collaborator

@Mees-Molenaar Mees-Molenaar left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks promising 🚀

|| interface_name.starts_with("wasi:http/")
{
trace!(name, "skipping export from linking");
continue;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we always want to skip this? Or only if there are components that have specific configurations?
If we want this, maybe add a descriptive comment that the plugin export for wasi:http is handled later

Comment on lines +1483 to +1484
let components_arc = resolved_workload.components();
let components = components_arc.read().await;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I dont like adding the type in the variable name, either just do it in one go, or shadow components

let components = resolved_workload.components();
'let components = components.read().await;`

Comment on lines +1480 to +1481
// Notify HTTP handler for each HTTP-exporting component
if workload_requests_http {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let wrap this in a function with a clear name so that you can read what the code is doing based on the function call instead of the comment

) -> anyhow::Result<()>;

/// Pick a workload ID based on the incoming request
/// Route an incoming request to a specific (workload, component) pair
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is (almost) the name of the function. These kind of comments just gives more to read while adding nothing (in my opinion)

Comment on lines +212 to +216
if routes.is_empty() {
let mut router_guard = self.router.write().map_err(|e| anyhow::anyhow!("{e}"))?;
*router_guard = None;
return Ok(());
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is unnecessary, lets just create an empty router if there are no routes

Comment on lines +280 to +292
let mut routes = self.routes.write().await;
if let Some(existing) = routes.get(&path_pattern) {
tracing::warn!(
path = %path_pattern,
existing_workload = %existing.workload_id,
existing_component = %existing.component_id,
new_workload = %workload_id,
new_component = %component_id,
"path pattern already registered — overwriting previous route"
);
}
routes.insert(path_pattern, target);
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I dont think we want to overwrite, I think we want to error when it already exists. Although I am not sure if we can do that, because probably a new workload is (trying) to be started and only when it is successfull it will delete the old one?

Any ideas / thoughts on this?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the scenario of a running workload, as you correctly pointed out : erroring is the correct choice.

about your concern icw the new workload starting before the old one is deleted : the old workload is always stopped first, which clears all de component-> path registrations. Only then the new workload starts and registers its paths. So till the time the new workload resolves, the paths will be free.


for component_id in http_component_ids {
if let Err(e) = http_handler
.on_workload_resolved(&resolved_workload, &component_id)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We now call on_workload_resolved multiple times, which confused me when I was reading the on_workload_resolved implementation.

I think it would be clearer that we call on_workload_resolved once and then have the logic of adding the logic to determine which paths for which components need to be added completely handled there.

Comment on lines +321 to +323
let Some(router) = lock.as_ref() else {
anyhow::bail!("No routes configured yet");
};
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's create a default empty router, and then rebuild will also just return an empty router and then we can either specify the error more if we have no match for the path and if the routes are empty that the log is no routes configured yet and otherwise no route found for path.

Comment on lines +39 to +52
let engine = Engine::builder().build()?;

let port = find_available_port().await?;
let addr: SocketAddr = format!("127.0.0.1:{port}").parse().unwrap();
let http_handler = PathRouter::default();
let http_plugin = HttpServer::new(http_handler, addr);

let logging_plugin = WasiLogging {};

let host = HostBuilder::new()
.with_engine(engine.clone())
.with_http_handler(Arc::new(http_plugin))
.with_plugin(Arc::new(logging_plugin))?
.build()?;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's create a test helper for that, because it is not interesting to read for each test

}
}

// Convert per-component interface config (Option C)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Uh?

@Aditya1404Sal Aditya1404Sal force-pushed the feat/path-routing branch 5 times, most recently from c45a8ed to a87b7ca Compare March 12, 2026 13:23
Signed-off-by: Aditya <aditya.salunkh919@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants