Release Overview

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.

1.0.0b2 Beta August 03, 2026
View repository

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.