WebFluid Documentation
This is the first beta of WebFluid. Beta 1 adds no new batteries — instead it takes everything the two alphas grew and rebuilds it properly: the core and every extension went through a SOLID refactor, the request path was measured and the slow parts removed, and the framework finally has a test suite standing behind it. A handful of names moved on the way, so read the breaking changes before you upgrade. The shape of the framework is settled now; pinning your version is still the safe habit while the beta runs.
New Features
A measured request path
The pipeline no longer runs on Starlette's BaseHTTPMiddleware, Jinja stops
stat-ing every template outside debug mode, and url_for resolves through a
name index. Rendering a page got roughly 3.8x faster, current_user went from
seven queries to three, and the require_admin gate from seven to one.
An async translation API
babel.agettext, angettext, apgettext and
anpgettext read uncached keys through an async session instead of blocking
the loop. The synchronous API is unchanged, and the template callables deliberately stay
synchronous.
Tests and budgets
136 tests cover the lifecycle contracts, the request pipeline, version resolution,
additive enabling, the events socket against a real uvicorn, the security gates' query
counts and both translation paths. benchmarks/bench.py pins import, render
and request time to budgets so the numbers above cannot quietly regress.
Rows that outlive their executor
Both session factories now run with expire_on_commit=False. A row read
through db.executor(...) stays usable after the block closes, exactly like
the identical read through db.async_executor(...) always did.
Additives that pull their dependencies
Additive.install() now resolves the additives a manifest requires and pulls
the missing ones from the Ocean before extracting files and installing packages.
wf ocean install runs that step for everything it fetched.
Bearer grants next to session gates
requirement_or_grant and requirement_and_grant let a guarded
route accept either a logged-in session or a JWT carrying the named grant, so one
endpoint can serve a browser and a machine without a second implementation.
Surfaces that follow their theme
The bundled stylesheets no longer bottom out in a hardcoded navy. Page and error
backgrounds, the nav, footer, cards and traceback frames derive from four surface tokens
on :root, so a theme that is not blue-grey no longer ends in a blue-grey
lower half.
One seam for forks
webfluid.core.identity holds the framework id, name, abbreviation, urls and
hub endpoints. Everything that used to hardcode one of them — the environment
prefix, the logger names, the entry-point group, the base template, the static mount
— now derives from that single module.
Fix Fixes
Additive request hooks
Additive.before_request and Additive.after_request raised
AttributeError. The three lifecycle implementations had drifted apart during
the refactor and now share one Phase type.
The events socket
/ws/events closed immediately under uvicorn because the socket handler was
spawned as a task while the endpoint returned. It is awaited now, and a test drives it
against a real server.
Version requirements
check_required_version compared only the numeric release parts, so
>1.0.0a1 did not match 1.0.0a2 and ==1.0.0a1 did
match 1.0.0b1. Version handling runs on packaging now.
Startup with Babel enabled
Every application with EXT_BABEL died in its startup hook once the
relationships were tightened, because the bulk catalog load read a related column as an
attribute. It joins and eager-loads that key explicitly, which also replaced an
EXISTS subquery with a plain join.
Translated model properties
Babel.domain_context wrapped every decorated callable in an async wrapper,
so a @property over it yielded a coroutine on attribute access. It branches
on the wrapped function again, the way it did in alpha 2.
Console output under wf run
The application's output was streamed through a text-mode pipe, so a progress bar that redraws itself in place arrived as one line per frame. The pipe is read as bytes now, terminal geometry and encoding are handed to the child, and the reader runs to EOF so nothing is lost between the stop message and the last shutdown hook.
Tailwind without themes
The frontend loader probes for the stylesheet that matches the switch you set, so a
surface shipping only tailwind_no_themes.css keeps its link.
Frontend.include no longer raises when Tailwind is enabled on an uncovered
frontend, and cover_additive no longer corrupts its own prefix when it is
called twice.
Server-side theme switching
fluid.set_theme() validated the name as if it were registering a new theme
and rejected every theme that actually existed. It now checks the opposite direction and
selects your registered themes.
Migrations that mind your app
The generated Alembic environment skips the Additives that are disabled for the app you
are migrating, so autogenerated revisions stop collecting foreign tables. It also
resolves *_FILE config values the same way wf run does, so
file-backed secrets reach the migration.
Smaller sharp edges
Manifest.check_requirements emptied its own requirement dictionary, so a
second check reported success. try_import re-raised when a parent package was
missing, which broke projects generated with --skip-defaults. And
clear_logs deleted the log file it was writing to.
Bug Known issues
One translation key per source string
The translation key table is unique on the source string alone, not on the pair of
string and domain. If two domains declare the same source string, the second one writes
its messages against the first domain's key and never finds them again. Keep your keys
distinct across domains — the framework's own catalog uses screaming-snake keys
like ERROR_TITLE for exactly that reason.
Rate limits answer with a 500
The rate limit handler is installed without the limiter it reads its response headers
from, so a request that actually exceeds a limit raises inside the handler and comes back
as a 500 instead of a 429. Rate limiting itself works — the request is
rejected, just with the wrong status. Until this is released, catch
RateLimitExceeded yourself if the status code matters to your clients.
SECURITY_CSRF_COOKIE_NAME is ignored
The value is read from your config and then never used — both sides of the
double-submit check hardcode csrf_token. Setting a different name does not
break anything, it simply has no effect, so leave it at the default for now.
An empty stylesheet link
When WF_TAILWIND is on but a surface has no Tailwind entry point of its own,
the frontend() helper still emits a
<link rel="stylesheet"> with an empty href, which
makes the browser fetch the page again as a stylesheet. Keep a raw stylesheet in the
surface's static/css, or leave WF_TAILWIND off for apps that do
not use it.
wf migrate init crashes
A NameError, every time. The helper class it builds internally shadows
project_root in its own body before Python can resolve it. No workaround
besides patching migrate.py — fixed next release.
Migrate template selection
wf migrate init chooses the single- or multi-database template from
SQLALCHEMY_BINDS in your config. Binds attached at runtime through
Model.set_bind are not detected and need a manual template.
Event backpressure
Broadcasts use a bounded per-listener buffer sized by EVENTS_EVENT_QUEUE_SIZE.
A consumer that falls behind still loses its oldest events — it is logged as a
warning now instead of happening silently, but delivery stays best-effort either way.
Hooks live in mix(), not in the lifespan
Startup and shutdown hooks run from fluid.mix() rather than through the ASGI
lifespan protocol, so they do not run when an external ASGI server imports and serves
your app. Run your apps through wf run for now.
Signals need a running loop
events.create_signal and events.event have to be called from
inside the running event loop, because the broadcaster loop is created eagerly. Register
them from an enable hook or a startup hook, not at import time.
Uncached translations still block
A key you explicitly opted out of the cache is read through a synchronous session on the
gettext path. agettext offers a non-blocking alternative, but
the callables installed into Jinja stay synchronous on purpose.
HMR relies on a proxied websocket that can die
Hot reload across the main app and multiple Additive frontends rides on the websocket
proxy in front of the shared dev server. That connection can drop on its own — a
Vite restart is the usual trigger — and when it does, the affected frontend simply
stops picking up changes. A browser refresh re-establishes the proxy immediately; there is
no need to restart wf run for it.
Break Breaking Changes
Relationships no longer load themselves
Every model relationship in the security and babel extensions was audited against how the
framework actually reads it. Only User.totp_secret and
User.webauthn_credentials stay eager; everything else —
User.roles, Role.permissions, User.identities,
User.backup_codes and every reverse side — is
lazy="raise_on_sql". Reading user.roles in a handler or
template is the most likely thing to break; load it with selectinload() or a
separate query. The cascades are unchanged.
Renamed on the Fluid instance
app_root is now project_root (the old name still works and
warns). app_static and framework_static became
static_files, state.limiter became limit, and
static_prefixes is an object with add() and
matches() instead of a set. asgi_app is gone entirely —
PROXY_FIX adds a normal middleware now.
Rendering a string
is_string is gone. fluid.render() and
additive.render() no longer inspect it, so passing it now reaches the
template as an ordinary variable while your source string is looked up as a template
name. Use fluid.render_string(source, ...) instead.
Sending mail
mail.send_async(...) is now mail.asend(...), matching the
a-prefix every other async pair in the framework uses. The synchronous
send and both client context managers are unchanged.
Versions are packaging versions
FluidVersion and AdditiveVersion are gone;
webfluid.version() and Additive.version hand you a
packaging.version.Version subclass built from Version(*parts).
Additive.version is a plain attribute now, not a property, and
check_required_version(..., "wf") became
"framework" — which is also the default.
The lifecycle API
HookPhase.add_hook and RequestPhase.add_processor are one
Phase.add, and Lifecycle.run_hooks() split into
run_startup(), run_shutdown(), run_before() and
run_after(). The decorators on your app and your Additives are unchanged.
Config building
core.config.ConfigMeta is gone and build_config() returns a
plain dict; configs are merged as dictionaries.
DefaultConfig is the single source of truth for every default, so
fluid.config["KEY"] is always populated and consumers no longer
repeat defaults inline.
Constants and endpoints
WF_STATIC is FRAMEWORK_STATIC, WF_OCEAN and
OCEAN_AUTH are HUB_API and HUB_AUTH, and
FRAMEWORK_ROOT moved to core.identity. The
AUTH_API environment variable is now OCEAN_AUTH. Internally,
core.processing.error.add_exception_handler became
install_error_handler and core.fluid.middleware.http became
middleware.request.
The app factory has a name
The migration environment looks for prepare_fluid() in your
main.py (falling back to a module level fluid), and that is what
wf create project generates. If you carried an alpha project with a
create_app() factory, rename it.
Reaching request-scoped data
FluidContext.get_ctx_data is gone. Use
FluidContext.cached_or(key, factory) for per-request memoisation, or read
the extension's own config.