Additives

Interaction between

An Additive is isolated, but never alone. It runs inside your app, shares its runtime, talks to its neighbours through events and queries — never through imports — can depend on other Additives, contributes its own frontend to the build and, because it carries a manifest, can be packaged and handed to someone else. This chapter is about those seams.

One shared runtime

Additives reach the same batteries your main app does. The shared registry in webfluid.core.ext is the same object everywhere, so an Additive talks to the very same database, cache and event manager — no wiring required:

additives/portal/app/index.py python
from webfluid.core.ext import db, events
from sqlalchemy import select, func

from fluid.models import MyModel


async def handle_request():
    from .. import additive

    async with db.async_executor(model=MyModel) as e:
        result = await e.exec(select(func.count(MyModel.id)), scalars=False)
        total = result.scalar()

    return await additive.render("index.html", total=total)

Talking without imports

Reaching into fluid.models like that is fine for the main app's own models, but it is exactly the kind of hard dependency that turns "modules" back into a monolith. The moment one Additive imports another, they're welded together — you can't ship one without the other, and disabling one breaks its neighbour.

So Additives talk over the EventManager instead. Events and queries are the loose-coupling contract: a producer publishes under a name, and anyone interested reacts to that name — with no shared Python symbol between them. The only thing that crosses the boundary is a string.

Scoping contracts by id

For that to be safe, those names must not collide. This is what additive.unique_name() is for: it prefixes a name with the Additive's id, giving every contract a stable, predictable address. An Additive serves its contracts under its own id namespace:

additives/portal/events/contracts.py python
from webfluid.core.ext import db, events
from sqlalchemy import select, func

from .. import additive
from fluid.models import MyModel


# A query other modules may call by name. unique_name("model_count")
# scopes it to our id, so it is served as "portal_model_count".
@events.query(additive.unique_name("model_count"))
async def model_count(_):
    async with db.async_executor(model=MyModel) as e:
        result = await e.exec(select(func.count(MyModel.id)), scalars=False)
        return result.scalar()


# A public broadcast, likewise scoped: "portal_model:created".
events.create_signal(additive.unique_name("model:created"), internal=False)

We register these from before_enable, right next to the routes, so the contracts exist as soon as the Additive is enabled:

additives/portal/__init__.py python
@additive.before_enable
def before_enable(_):
    from .app import index
    additive.app.get("/")(index)

    from .events import contracts  # noqa: registers the contracts above

Consuming by name

On the other side, a second Additive reacts to those contracts without importing portal at all. It knows portal only by its id — the same id it already declares as a dependency in its manifest — and addresses the contract by that id-scoped name:

additives/dashboard/events/listeners.py python
from webfluid.core.ext import events

# The id we depend on (declared under requires.additives in our manifest).
PORTAL = "portal"


async def overview():
    # Ask portal for its model count without importing a line of it:
    return await events.request(f"{PORTAL}_model_count")


# React to portal broadcasts, again only by name:
@events.event(f"{PORTAL}_model:created", internal=False)
async def on_created(data):
    from webfluid.utils.logging import factory as log
    log.log(f"[dashboard] portal created a model: {data}")
 

The id is the public contract. Because the consumer depends on portal by id and version in its manifest, the framework guarantees portal is present before either is enabled — so the string address is safe, and no Python import ever crosses the boundary. The same id-scoping powers url_for inside Additive templates: route names are run through unique_name too, so an Additive's reverse-url lookups never clash with anyone else's.

A local request lifecycle

Just like the main app, an Additive can hook its own request flow — but scoped to its own routes only. It offers before_request, after_request, context_processor and a route-level http_middleware. Use them to give an Additive its own guards or template defaults without touching the rest of the app:

additives/portal/__init__.py python
from webfluid import Additive
from webfluid.additives.core import import_base

additive = Additive(
    __name__,
    import_base("core"),
    required_extensions=["sqlalchemy", "events"]
)


@additive.context_processor
def defaults():
    return {"section": "portal"}


@additive.before_enable
def before_enable(_):
    from .app import index
    additive.app.get("/")(index)
 

required_extensions is the Additive's own safety net: if one of the named extensions isn't enabled in the app it is loaded into, enabling fails loudly instead of breaking at runtime. A base's requirements are merged into the child automatically.

Declaring requirements

The manifest's optional requires block is how an Additive states what it needs from the world around it: a framework version range, other Additives (by id, optionally pinned), and pip packages to install. These are checked when the Additive is enabled:

additives/portal/manifest.json json
{
  "id": "portal",
  "version": "1.0.0",
  "type": "default",
  "frontend": { "type": "vite", "framework": "vue", "typescript": false },
  "name": "Portal",
  "requires": {
    "wf": ">=1.0.0",
    "additives": { "core": ">=1.0.0" },
    "packages": ["httpx"]
  }
}

Frontends that compose

Remember how we kept the workspace list tiny in the Frontend chapter? This is where it pays off. Each Additive can carry its own Vite app in additives/<id>/frontend, and the framework's orchestrator already globs every one of them into the build alongside the main app:

package.json json
{
  "name": "myapp",
  "private": true,
  "workspaces": [
    "additives/*/frontend",
    "fluid/frontend"
  ],
  "devDependencies": {
    "vite": "^8.0.0"
  }
}

Every Additive frontend is served under its own prefix — /portal/frontend for the build, proxied through /vite-dev in debug — and they all share one dev server with hot module replacement running across the lot. The per-Additive vite.config.js only differs from the main app's in its base path, which points at the Additive's prefix instead of the root. Static files behave the same way, mounting under /<id>/static.

 

HMR across several frontends works, but more or less cleanly in this first alpha. If a reload gets confused, restarting the dev run sorts it out. It is good enough to develop against; just don't be surprised by the occasional hiccup.

Packaging and distribution

Because an Additive is fully described by its manifest, it can be shipped. Two methods make an installed Additive a good citizen of whatever project pulls it in:

  • install() — copies anything in the Additive's extract folder (templates and static the host app should own) into the main app, and installs the manifest's required packages. Existing files are never overwritten.
  • configure() — lets an Additive contribute its own questions to wf create app through a setup mapping in its config.py, so its settings land in the right app config section interactively.

Together with the version requirements, that is the groundwork for distributing Additives through a Hub — publish once, depend on by id and version, install into any WebFluid project. The plumbing for that lands as the alpha matures.

Continue reading

From here you can continue straight with the Framework utility.