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. That is also the answer to the timing rule from the Events chapter: enabling happens from a startup hook, inside the running loop, so an Additive can simply import its contracts module there instead of wrapping everything in a register() function.

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.utils.additives 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.0b1",
    "additives": { "core": ">=1.0.0" },
    "packages": ["httpx"]
  }
}

The wf constraint and every additive constraint are ordinary version specifiers, evaluated the way pip evaluates them — so >=1.0.0b1, ~=1.2 and * all mean what you expect, prereleases included. The additives block also accepts a list if you find it shorter to read: ["core@>=1.0.0", "billing"] is the same thing, with a bare id meaning any version.

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 reliably. The one thing worth knowing is that it rides on the proxied websocket in front of the shared dev server, and that connection can die on its own — a Vite restart, a dropped connection, whatever the reason. When it does, a frontend simply stops picking up changes. You don't need to restart wf run for that: a plain browser refresh re-establishes the proxy and HMR is live again.

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() — the settling-in routine. It resolves the additives your manifest requires and pulls the missing ones from the Ocean, 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, and a dependency that pulls in dependencies of its own is followed through.
  • 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.

A setup entry is a small dictionary per config key: a type (text, password, select, checkbox, confirm, or auto for a value nobody has to be asked about), the message to show, and optional kwargs passed to the prompt. A base Additive is configured before its child, so its questions come first.

additives/portal/config.py python
setup = {
    "PORTAL_API_KEY": {
        "type": "password",
        "message": "Portal API key:"
    },
    "PORTAL_REGION": {
        "type": "select",
        "message": "Portal region:",
        "kwargs": { "choices": ["eu", "us"], "default": "eu" }
    },
    "PORTAL_ENABLED": {
        "type": "auto",
        "value": "1"
    }
}

Together with the version requirements, that is what makes Additives distributable through the Ocean hub: wf ocean publish uploads an Additive, and wf ocean install pulls it — along with the Additives it depends on by id and version — into any WebFluid project. The Ocean chapter walks through the whole flow.

 

While you develop an Additive, exporting DEV_AUTO_INSTALL=1 makes a debug run call install() on every Additive it registers, so a freshly added requirement or extract file lands without a manual step. It is a development convenience and deliberately does nothing outside debug mode.

Continue reading

From here you can continue straight with the Framework utility.