WebFluid Documentation
Beta 2 is a stabilisation release. It adds no batteries and moves almost no names — instead it walks back through everything beta 1 shipped and closes the holes it left, most of them in the request path. A single missing dunder was quietly dropping the locale from every rendered page; three characters in a query string could crash one; a proxied response could reach the browser broken. Those are fixed, and so is most of the known-issues list beta 1 published. Three small breaking changes ride along, all of them in the Babel helpers. The shape of the framework has not moved since beta 1, so upgrading should cost you nothing beyond reading those three.
New Features
Input that cannot take the page down
The locale and timezone selectors now treat everything they read from a request as
untrusted. A ?lang= value, a lang cookie, an
Accept-Language header, a tz cookie or an
X-Timezone header that does not resolve is skipped rather than raised,
and the next candidate gets its turn. The chain always ends at your configured default.
A proxy that passes bodies through intact
get_proxy drops the hop-by-hop and encoding headers of the upstream response
and recomputes the length for the body it actually forwards, while repeated headers such
as Set-Cookie survive as separate lines. What arrives at the browser is now
consistent with what it is given.
UTF-8 wherever the framework writes
Every manifest, package.json, vite config and generated source file is read
and written as explicit UTF-8. A project scaffolded on a Windows console no longer
produces files that only that console can read back.
Stubs that match the runtime
scripts/check_stubs.py compares module layout and __all__, not
signatures, so the stub tree had drifted where it does not look.
LogService.drain, console_encoding,
Model.__tablename__ and __bind_key__ are declared,
clear_logs lost the parameter it lost in beta 1, and
Fluid.app_root is a @deprecated property instead of a type
error.
One version, asserted
The version lived in three places and the Dockerfile was stale. The image
takes a WEBFLUID_VERSION build argument now, and a test asserts that the
runtime, the stubs, their cross dependencies and the image all agree before a release
can go out.
Release scripts that stop when a step fails
publish.bat and publish.sh ran every step unconditionally, so a
failed build or a rejected upload still went on to publish the stubs. Each step is checked,
a leftover tmp clone is removed before cloning, and both refuse to publish
when the freshly cloned branch is not the commit in the working tree — the case
where the release commit was never pushed.
More tests, in the places that broke
160 tests now, up from 136. The new ones cover the Babel helpers this release changed, the request pipeline, version resolution and the lifecycle contracts — the four areas that produced the fixes below.
Fix Fixes
A context that was falsy
FluidContext.__len__ reports the size of its handler data, so a plain request
context — the kind RequestMiddleware builds — was falsy, and every
caller asking if ctx read it as no context at all. get_locale was
the loud casualty: ?lang=, the lang cookie and
Accept-Language were all dropped and every page fell back to
BABEL_DEFAULT_LOCALE, while event and query handlers kept working because
their contexts carry data. The same check cost Themes.get the session theme
and country_from_request its headers. The context is always truthy now, and
the call sites test against None.
A shutdown that always finishes
Anything that stopped the server task other than a signal hung wf run
forever, because Server._start waited on the shutdown flag alone and a task
that died — most often uvicorn calling sys.exit(1) on a bound port
— left nothing to set it. The wait covers the server task too, the failure is
re-raised, and shutdown hooks run through a finally on every path.
Three characters that crashed every page
?lang=xx, a lang cookie or an Accept-Language value
that Locale.parse rejects raised straight out of the context processor, and a
tz cookie or X-Timezone header that is not a zone name did the
same through ZoneInfo. Both selectors skip what they cannot resolve now.
Errors that said too much
The 500 handler put str(exc) into its JSON body outside debug mode, handing
callers whatever the exception happened to say — SQL fragments, file system paths,
connection strings. The message, its type and the traceback are debug only; production
answers with the status text alone.
Rate limits answer with a 429
slowapi's handler reads the limiter off app.state to inject its
headers, and it was installed without one being seated there. Every request that actually
hit a limit raised inside the handler and came back as a 500. The limiter is on the state
now and the status is the one you configured for.
One translation key per domain
I18nKey.key was unique across the whole table instead of per domain, so two
domains declaring the same source string shared one row and the second domain's messages
were written against the first domain's key. I18nKey carries a
(key, domain) constraint now and both kid and
resolve_keys filter on the domain. Existing databases need a migration to
replace the old index.
SECURITY_CSRF_COOKIE_NAME is used
The value was read into the token service and then ignored — both
csrf_response and csrf_protect hardcoded csrf_token.
Configuring a different name does what it says now.
A proxy that no longer breaks compressed responses
The HTTP proxy forwarded the upstream's Content-Encoding and
Content-Length next to a body httpx had already decoded, so a
compressed upstream reached the browser as a broken response. The websocket proxy replaced
every http in the target URL, mangling any path or query string that
contained the word.
wf migrate init runs
It raised NameError on every call: the throwaway class it built to hand
project_root to init_configs was declared inside the function
that owned that name, which Python resolves against the class's own empty namespace. The
class is a module-level helper now.
An alembic.ini that follows your prefix
The generated file logged under a hardcoded [WF] prefix that no rebrand could
reach, because .mako is not among the suffixes the rebrand script rewrites.
The prefix is a template field filled from ENV_PREFIX. The multi-database
template also stopped describing itself as a single database configuration.
Arrow keys in interactive mode
Pressing an arrow or function key under wf run --interactive killed the CLI
with UnicodeDecodeError. Windows reports those as a two byte sequence starting
with a null or 0xE0 byte, and read_key decoded the first byte as
UTF-8. The prefix is consumed and decoding no longer raises.
Smaller sharp edges
POST /url-for answered an unknown endpoint name with a 500 and a logged
traceback; NoMatchFound maps to a 404 with UNKNOWN_ENDPOINT.
Frontend.include no longer emits a stylesheet link with an empty
href when a surface has no Tailwind entry point, which used to make the
browser fetch the page again as a stylesheet. And to_user_timezone lost a
dead branch that re-stamped UTC over a zone it had already resolved.
Bug Known issues
Database URIs are rewritten everywhere, not just in front
database_uris inserts the driver with a plain string replace, so every
occurrence of the scheme word in your URI is rewritten, not only the leading one.
sqlite:///data/sqlite/app.db becomes
sqlite+aiosqlite:///data/sqlite+aiosqlite/app.db on the async side, and a
MySQL database literally named mysql_prod gets the same treatment. Keep the
scheme word out of your paths, database names, users and passwords for now.
The synchronous mail client ignores MAIL_USE_TLS
SyncManager stores the flag and then opens a plain connection; only
MAIL_USE_STARTTLS is honoured on that path. So mail.send()
against a server configured for implicit TLS talks plaintext, with your credentials in it.
The async client passes both flags to aiosmtplib correctly. Until this is
released, pair port 587 with MAIL_USE_STARTTLS, or use
mail.asend(). The shipped defaults — port 587 with
MAIL_USE_TLS on and MAIL_USE_STARTTLS off — are the wrong
pair for both clients.
App configs are still written in your console encoding
Beta 2 made every generated file explicitly UTF-8 and missed one:
wf create app writes app_configs/<name>.ini in the
interpreter's locale encoding, and wf run reads it back the same way. That is
symmetric on one machine and breaks the moment a config with a non-ASCII value — a
mail username, a password with an umlaut — is written on Windows and read anywhere
else. Keep app config values ASCII, or move them behind the *_FILE
indirection.
RATELIMIT_DEFAULT never applies
slowapi evaluates application-wide default limits only from its own middleware, and the
framework installs the exception handler without that middleware. So a route without a
fluid.limit decorator is never checked at all, and a decorated route replaces
the defaults rather than adding to them, because the decorator overrides them by default.
Put the limit you want on the route; read RATELIMIT_DEFAULT as intent, not as
enforcement.
Additive after-request processors do not receive a response
fluid.after_request receives a real Response, because it runs in
the ASGI middleware after the app has produced one. additive.after_request
runs inside the router's endpoint wrapper, before FastAPI serialises anything, so what it
receives is whatever your handler returned — a dict, a model, a string. Write
additive after-request processors against the return value, not against a response object.
Streaming responses skip after-request processors
The request middleware buffers the response so after_request can rewrite it,
and gives that up the moment the app announces more body to come. A
StreamingResponse therefore passes straight through untouched. A buffered
one keeps the headers the app produced, so a processor that changes the body has to fix
Content-Length itself — return a new response rather than mutating the
one you were handed.
url_for is None outside a request
Both the framework's context processor and the additive one build url_for
from the request in the current context and hand back None when there is
none. Rendering a template from a startup hook, a scheduled job or a mail routine
therefore fails with a NoneType is not callable error rather than a missing
name. Use fluid.url_path_for() or a configured BASE_URL in
templates that render off-request.
JWT keys live in the cache
Key rotation writes the signing keys into whatever CACHE_TYPE points at, and
a rotation also runs as a startup hook. With the legacy cache that store is in-process, so
every restart mints a new key and forgets the old ones, and every token issued before it
stops decoding. Run the JWT extension against Redis. A token whose kid is not
in the cache also fails with a raw TypeError from the signing library rather
than an InvalidTokenError, so catch broadly when you decode by hand.
Bearer grants assume a numeric subject
resolve_bearer looks the principal up with int(sub). A token
whose sub is a uuid or an email raises ValueError from inside the
fallback branch of the gate, which nothing catches, and the request comes back as a 500
instead of a 401. Keep sub the user's primary key.
A translation equal to its source falls through
The domain escalation accepts a catalog's answer only when it differs from the string it
was given, so a deliberate translation that happens to equal the source — an English
catalog translating Save to Save — is read as a miss and
the next domain in the chain gets to answer instead. Screaming-snake keys like
ERROR_TITLE avoid the whole class of collision, which is why the framework's
own catalog uses them.
Migrate template selection
wf migrate init chooses the single- or multi-database template from
SQLALCHEMY_BINDS in your config, at init time. 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 rather than 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
format_date takes fmt, not ftm
The typo is gone. format_date(d, ftm=...) is
format_date(d, fmt=...), which is what every other formatter in
webfluid.extensions.babel.utils already called it. Positional calls and the
dateformat Jinja filter are unaffected.
to_utc converts instead of stripping
It used to return dt.replace(tzinfo=None), which keeps the wall clock and
throws the offset away: a Berlin 12:00+02:00 came back as a naive
12:00 that every consumer then read as UTC, two hours off. It converts to UTC
before dropping the offset now, and reads a naive input as user-local time, which makes it
the inverse of to_user_timezone again. Code that relied on the old behaviour
to strip a tzinfo should call dt.replace(tzinfo=None) itself.
parse_best_match reports no match
A missing Accept-Language header used to be answered with the first supported
locale, so an app whose BABEL_SUPPORTED_LOCALES does not start with
BABEL_DEFAULT_LOCALE served the wrong language to every client that sends no
header. It returns None now and lets the caller fall back to the configured
default. If you call it yourself, handle the None.