Development Guide¶
Daily development workflow for Vibetuner projects.
Package name convention
In all examples, app refers to your project's Python package (the directory under src/).
The actual name depends on your project slug (e.g., src/myproject/ for a project named
"myproject").
Development Environment¶
Vibetuner supports two development modes:
Docker Development (Recommended)¶
Run everything in containers with hot reload:
This starts:
- Database (MongoDB or SQL, if configured)
- Redis (if background jobs enabled)
- FastAPI application with auto-reload
- Frontend asset compilation with watch mode (tailwind + bundler watchers spawned inside the app container)
Changes to Python code, templates, and assets automatically reload — the
container syncs from the host via docker compose up --watch, granian
restarts workers on Python edits, and bun watchers rebuild
bundle.css/bundle.js on template/JS edits.
The host port is derived deterministically from the project directory
(in the 14000–17999 band), so two scaffolded projects can run just dev
side-by-side without colliding on 8000. The mapped port is printed when
the container starts; docker compose -f compose.dev.yml ps also shows it.
Tailwind v4 quirk: edits to
config.cssitself don't trigger a rebuild — restartjust devafter changing it. Edits to templates and other@source-watched directories rebuild without restart. See tailwindlabs/tailwindcss#14726.
Local Development¶
Run services locally without Docker:
just install-deps # Run once after cloning or updating lockfiles
just local-all # Runs server + assets with auto-port (recommended)
just local-all-with-worker # Includes background worker (requires Redis)
A database (MongoDB or SQL) is required if using database features. Redis is only required if background jobs are enabled.
local-all also derives a deterministic port (in the 10000–13999 band for
the frontend, 20000–23999 for the worker UI), distinct from just dev's
14000–17999 docker band. So just local-all (running against your shared
prod-style services) and just dev (running fully containerized against
local mongo/redis) can be alive simultaneously without port conflict.
Justfile Commands Reference¶
All project management tasks use just (command runner). Run just without arguments to see all
available commands.
Development¶
just local-all # Local dev: server + assets with auto-port (recommended)
just local-all-with-worker # Local dev with background worker (requires Redis)
just dev # Docker development with hot reload
just local-dev PORT=8000 # Local server only (run bun dev separately)
just worker-dev # Background worker only
Dependencies¶
just install-deps # Install from lockfiles
just update-repo-deps # Update root scaffolding dependencies
just update-and-commit-repo-deps # Update deps and commit changes
uv add package-name # Add Python package
bun add package-name # Add JavaScript package
Code Formatting¶
just format # Format ALL code (Python, Jinja, TOML, YAML)
just format-py # Format Python with ruff
just format-jinja # Format Jinja templates with djlint
just format-toml # Format TOML files with taplo
just format-yaml # Format YAML files with dprint
IMPORTANT: Always run ruff format . or just format-py after Python changes.
Code Linting¶
just lint # Lint ALL code
just lint-py # Lint Python with ruff
just lint-jinja # Lint Jinja templates with djlint
just lint-md # Lint markdown files
just lint-toml # Lint TOML files with taplo
just lint-yaml # Lint YAML files with dprint
just type-check # Type check at the repo root (template + tooling)
just type-check-py # Type check the vibetuner-py framework code with ty
Localization (i18n)¶
just i18n # Full workflow: extract, update, compile
just extract-translations # Extract translatable strings
just update-locale-files # Update existing .po files
just compile-locales # Compile .po to .mo files
just new-locale LANG # Create new language (e.g., just new-locale es)
just dump-untranslated DIR # Export untranslated strings
just i18n-fuzzy-audit # Report any fuzzy-marked entries across catalogs
update-locale-files runs msgmerge --no-fuzzy-matching, so new or
changed strings land with an empty msgstr (an honest "untranslated"
that falls back to the English msgid) rather than a confident wrong
guess copied from a textually similar entry. Run just i18n-fuzzy-audit
to find any leftover #, fuzzy entries — gettext ignores them at
runtime, but they read as real translations in the .po file and should
be cleaned up.
CI/CD & Deployment¶
just build-dev # Build development Docker image
just test-build-prod # Test production build locally
just build-prod # Build production image (requires clean tagged commit)
just release # Build and release production image
just deploy-latest HOST # Deploy to remote host
Scaffolding Updates¶
just update-scaffolding # Update project to latest vibetuner template
just deps-scaffolding # Update deps + scaffolding on the current branch (no PR)
just deps-scaffolding-pr # Update deps + scaffolding in a worktree and open a PR
Common Tasks¶
Adding New Routes¶
Create a new file in src/app/frontend/routes/. Routes are automatically discovered, no
registration needed:
# src/app/frontend/routes/blog.py
from fastapi import APIRouter
router = APIRouter(prefix="/blog", tags=["blog"])
@router.get("/")
async def list_posts():
return {"posts": []}
The framework finds any router variable in route files and registers it automatically.
@render Decorator¶
For simple routes, use @render() to eliminate render_template() boilerplate.
Return a dict and the decorator handles rendering:
from vibetuner import render
@router.get("/dashboard")
@render("dashboard.html.jinja")
async def dashboard(request: Request, user=Depends(get_current_user)) -> dict:
return {"user": user}
The decorator auto-extracts request from route parameters. If the route returns a
Response object (e.g. RedirectResponse) instead of a dict, it passes through
unchanged — this is the escape hatch for conditional logic:
@router.get("/items/{id}")
@render("items/detail.html.jinja")
async def item_detail(request: Request, id: str) -> dict:
item = await Item.get(id)
if not item:
return RedirectResponse("/items") # Passed through as-is
return {"item": item}
Streaming Large Pages¶
For large pages (dashboards, data tables), use render_template_stream() to send
HTML chunks as the template renders. The browser can start painting the <head> and
initial layout before the full page is ready:
from vibetuner import render_template_stream
@router.get("/dashboard")
async def dashboard(request: Request):
data = await get_dashboard_data()
return render_template_stream("dashboard.html.jinja", request, {"data": data})
Context merging works identically to render_template(). Best suited for full page
loads — HTMX partials are typically small and don't benefit from streaming.
Adding Database Models¶
Create models in src/app/models/. Models are automatically discovered and initialized.
MongoDB (Beanie ODM)¶
# src/app/models/post.py
from beanie import Document
from pydantic import Field
class Post(Document):
title: str
content: str
published: bool = Field(default=False)
class Settings:
name = "posts"
No __init__.py registration needed. The framework auto-discovers Beanie Documents.
Beanie class-level queries and ty
Beanie builds queries from class-level field access (Post.title == "x",
Eq(cls.author.id, user.id)). Static type checkers don't model this: ty
resolves Model.field from the field's instance annotation, not as a
query expression. Most comparisons type-check fine, but two patterns surface
a spurious unresolved-attribute from just lint. The first is an optional
link used in a query: field: Link[X] | None with cls.field.id reports
id is not defined on None. This one is fundamental, since ty rejects
attribute access on any X | None, so it can't be fixed by changing the
field type or the Link alias. The second is a query inside a classmethod
on a non-Document mixin (a BaseModel mixin), where cls.find and
cls.field aren't known to the checker.
Silence these on the exact line with an inline directive, e.g.
await cls.find_one(Eq(cls.author.id, user.id)) # ty: ignore[unresolved-attribute].
ty flags directives that stop matching (unused-ignore-comment), so keep
the comment on the line that errors and drop it once it's no longer needed.
SQL (SQLModel)¶
# src/app/models/post.py
from sqlmodel import SQLModel, Field
class Post(SQLModel, table=True):
id: int | None = Field(default=None, primary_key=True)
title: str
content: str
published: bool = Field(default=False)
Register SQL models explicitly in tune.py:
# src/app/tune.py
from vibetuner import VibetunerApp
from app.models.post import Post
app = VibetunerApp(
sql_models=[Post],
)
For SQL databases, create tables with: vibetuner db create-schema
MongoDB is optional
Projects that only use SQLModel/Postgres do not need MONGODB_URL.
The framework silently skips MongoDB initialization when the URL is not set.
Soft Delete¶
Instead of permanently removing documents, soft delete marks them with a deleted_at timestamp.
Soft-deleted documents stay in the database but are automatically excluded from queries.
Use DocumentWithSoftDelete instead of Document as your base class:
# src/app/models/post.py
from vibetuner.models import DocumentWithSoftDelete
from vibetuner.models.mixins import TimeStampMixin
class Post(DocumentWithSoftDelete, TimeStampMixin):
title: str
content: str
class Settings:
name = "posts"
This adds an optional deleted_at field to your document automatically.
Deleting and querying:
post = await Post.find_one(Post.title == "Draft")
await post.delete() # sets deleted_at, document stays in DB
post.is_deleted() # True
await Post.find_all().to_list() # excludes soft-deleted documents
await Post.find_many_in_all().to_list() # includes soft-deleted documents
await post.hard_delete() # permanently removes the document
find(), find_one(), and get() all automatically filter out soft-deleted documents.
Use find_many_in_all() when you need to access them (e.g., admin views, audit logs).
Restoring a soft-deleted document:
CRUD factory: The DELETE endpoint automatically performs a soft delete for models
that use DocumentWithSoftDelete. No configuration needed.
Encrypted Fields¶
Encrypt sensitive fields at rest using EncryptedFieldsMixin and the
EncryptedStr type. Fields are transparently encrypted before database
writes and decrypted on load:
from beanie import Document
from pydantic import Field
from vibetuner.models.mixins import EncryptedFieldsMixin, EncryptedStr
class ApiCredential(Document, EncryptedFieldsMixin):
provider: str
api_key: EncryptedStr = Field(..., description="Encrypted API key")
token: EncryptedStr | None = Field(default=None)
class Settings:
name = "api_credentials"
# Usage — encryption/decryption is automatic
cred = ApiCredential(provider="stripe", api_key="sk_live_xxx")
await cred.insert() # api_key is encrypted before write
loaded = await ApiCredential.get(cred.id)
print(loaded.api_key) # "sk_live_xxx" — decrypted on load
Encryption requires the FIELD_ENCRYPTION_KEY environment variable
(a Fernet key). When the key is not set, fields are stored as plaintext.
Use vibetuner crypto set-key to generate and configure a key, and
vibetuner crypto rotate-key to rotate it. See the
CLI Reference for details.
Creating Templates¶
Add templates in templates/frontend/:
<!-- templates/frontend/blog/list.html.jinja -->
{% extends "base/skeleton.html.jinja" %}
{% block content %}
<div class="container mx-auto">
<h1 class="text-3xl font-bold">Blog Posts</h1>
<div class="grid gap-4">
{% for post in posts %}
<article class="card">
<h2>{{ post.title }}</h2>
<div>{{ post.content }}</div>
</article>
{% endfor %}
</div>
</div>
{% endblock content %}
Template Path Convention¶
The template search path already includes templates/frontend/, so when calling
render_template() use paths relative to that directory:
# Correct - path relative to templates/frontend/
render_template("blog/list.html.jinja", request)
render_template("admin/dashboard.html.jinja", request)
# Wrong - "frontend/" prefix is redundant and causes TemplateNotFound
render_template("frontend/blog/list.html.jinja", request) # TemplateNotFound!
The same convention applies to {% extends %} and {% include %} inside templates:
{% extends "base/skeleton.html.jinja" %} {# correct #}
{% extends "frontend/base/skeleton.html.jinja" %} {# wrong #}
Passing Context¶
The user context dict can be passed positionally, as ctx=, or as context=
(the two keywords are aliases — context= exists because that's what most
Flask/Starlette muscle memory reaches for):
render_template("home.html.jinja", request, {"hero": hero}) # positional
render_template("home.html.jinja", request, ctx={"hero": hero}) # ctx kwarg
render_template("home.html.jinja", request, context={"hero": hero}) # alias
Passing both ctx= and context= raises TypeError. Misspelled or unknown
kwargs (e.g. contxt=) also raise TypeError — the signature is explicit, so
typos can't silently render with an empty context.
Skeleton Extension Points¶
The shipped base/skeleton.html.jinja exposes blocks and context variables so
projects can slot in customisations without copying the whole skeleton.
Extending it is preferable to overriding it: upstream changes (CSP nonce,
theming, etc.) flow through automatically.
Blocks (override in any child template):
| Block | Position | Use for |
|---|---|---|
extra_head_links |
After <title>, before bundle.css |
<link rel="alternate"> feeds, RSS, custom meta tags |
extra_scripts |
After the bundled <script> (and optional umami) |
Per-app scripts that don't replace bundle.js |
before_main |
Inside <body>, before block body |
Dev banners, sticky overlays |
after_main |
Inside <body>, after block body |
Persistent mini-players, floating toolbars |
Existing blocks already there: title, head, scripts, start_of_body,
header, body, content, footer, end_of_body.
Context variables (set via register_globals, a context provider, or
per-render ctx):
| Variable | Type | Default | Effect |
|---|---|---|---|
color_scheme |
str |
"light" |
Sets <meta name="color-scheme"> content |
canonical_url |
str \| None |
None |
When set, renders <link rel="canonical" href="…"> |
font_preloads |
list[dict] |
[] |
Each entry renders <link rel="preload" as="font" href="…" type="…" [crossorigin="…"]> |
# tune.py
from vibetuner import register_globals
register_globals({"color_scheme": "dark"}) # whole site is dark
# Per-page canonical URL
return render_template(
"blog/post.html.jinja",
request,
{"canonical_url": str(request.url_for("blog_post", slug=post.slug))},
)
# Self-hosted brand fonts (preload-scanner picks these up before bundle.css)
register_globals({
"font_preloads": [
{"href": "/static/fonts/brand.woff2", "type": "font/woff2", "crossorigin": "anonymous"},
],
})
<!-- templates/frontend/base/skeleton.html.jinja override -->
{% extends "base/skeleton.html.jinja" %}
{% block extra_head_links %}
<link rel="alternate" type="application/rss+xml"
title="My App" href="{{ url_for('rss').path }}" />
{% endblock extra_head_links %}
{% block after_main %}
{# Persistent player survives HTMX boost swaps #}
{% include "components/player.html.jinja" %}
{% endblock after_main %}
If you do need to override the whole skeleton (e.g. to wrap the body in an
HTMX-boost container, change the <html> attributes, or add markup before
<head>), prefer the smallest possible override and keep the upstream
blocks intact so future framework changes still apply.
Built-in Template Globals¶
Vibetuner provides these variables in every template automatically:
| Variable | Type | Value |
|---|---|---|
request |
Request |
The current Starlette Request |
language |
str |
Resolved request language (e.g. "en", "ca") |
DEBUG |
bool |
Mirrors settings.debug |
now |
datetime |
datetime.now(timezone.utc) — timezone-aware UTC datetime |
today |
str |
date.today().isoformat() — ISO date string (e.g., "2026-04-07") |
project |
ProjectConfiguration |
settings.project — project_name, company_name, copyright, fqdn, … |
brand |
BrandSettings |
settings.brand — see Brand Configuration |
csp_nonce |
str |
Per-request CSP nonce (see Security Headers and CSP Nonce) |
hotreload |
callable |
Dev-mode hot-reload helper |
<p>Page rendered at {{ now | format_datetime }}</p>
<p>Today is {{ today }}</p>
<footer>{{ project.copyright }}</footer>
<title>{{ project.project_name }}</title>
now and csp_nonce are re-evaluated on every render; project and
brand are app-level config snapshots resolved at startup. For custom
globals, see Template Context Providers.
Built-in Template Filters¶
Vibetuner provides several built-in template filters for common formatting needs:
| Filter | Usage | Output |
|---|---|---|
timeago |
{{ dt \| timeago }} |
"5 minutes ago" |
timeago(short=True) |
{{ dt \| timeago(short=True) }} |
"5m ago" |
format_date |
{{ dt \| format_date }} |
"January 15, 2025" |
format_datetime |
{{ dt \| format_datetime }} |
"January 15, 2025 at 2:30 PM" |
format_duration / duration |
{{ seconds \| duration }} |
"5 min" or "30 sec" |
timeago Filter¶
The timeago filter converts a datetime to a human-readable relative time string:
<span>Created {{ post.created_at | timeago }}</span>
<!-- Output: "5 minutes ago", "yesterday", "3 months ago", etc. -->
<!-- Short format for compact displays -->
<span>{{ post.created_at | timeago(short=True) }}</span>
<!-- Output: "5m ago", "1d ago", "3mo ago", etc. -->
Short format outputs:
| Time Range | Short Format |
|---|---|
| < 60 seconds | "just now" |
| < 60 minutes | "Xm ago" |
| < 24 hours | "Xh ago" |
| < 7 days | "Xd ago" |
| < 30 days | "Xw ago" |
| < 365 days | "Xmo ago" |
| < 4 years | "Xy ago" |
| >= 4 years | "MMM DD, YYYY" |
Adding Custom Template Filters¶
Register custom Jinja2 filters via the template_filters dict in VibetunerApp:
# src/app/frontend/templates.py
def uppercase(value):
"""Convert value to uppercase"""
return str(value).upper()
def format_money(value):
"""Format value as USD currency"""
try:
return f"${float(value):,.2f}"
except (ValueError, TypeError):
return str(value)
# src/app/tune.py
from vibetuner import VibetunerApp
from app.frontend.templates import uppercase, format_money
app = VibetunerApp(
template_filters={
"uppercase": uppercase,
"money": format_money,
},
)
Use in templates:
Adding Background Jobs¶
If you enabled background jobs, create tasks in src/app/tasks/. Task modules are
automatically discovered:
# src/app/tasks/emails.py
from vibetuner.tasks.worker import get_worker
from vibetuner.models import UserModel
from vibetuner.services.email import EmailService
worker = get_worker()
@worker.task()
async def send_welcome_email(user_id: str):
user = await UserModel.get(user_id)
if user:
email_service = EmailService()
await email_service.send_email(
to_address=user.email,
subject="Welcome!",
html_body="<h1>Welcome!</h1>",
text_body="Welcome!",
)
return {"status": "sent"}
Queue jobs from your routes:
from app.tasks.emails import send_welcome_email
@router.post("/signup")
async def signup(email: str):
# Create user
user = await create_user(email)
# Queue background task
task = await send_welcome_email.enqueue(str(user.id))
return {"message": "Welcome email queued", "task_id": task.id}
Styling with Tailwind¶
Vibetuner uses Tailwind v4 + DaisyUI. The scaffolded config.css lives at
the project root and pulls everything in via the shipped core stylesheet:
/* config.css */
@import "@alltuner/vibetuner/core.css";
@source "templates";
@source "assets/statics/js";
/* Add your custom styles below: */
core.css already imports tailwindcss and registers the daisyui
plugin, so you can use any DaisyUI component class and any Tailwind v4
utility in your templates without further configuration. Define
Tailwind tokens with @theme { ... } and write component classes
with @layer components { .btn-custom { @apply btn btn-primary; } }.
The build process compiles config.css to assets/statics/css/bundle.css
on just dev / just local-all / bun run build:css.
Brand palette (build-time)¶
To recolor DaisyUI's light / dark themes for the whole bundle,
override the [data-theme="…"] selectors below the @import:
@import "@alltuner/vibetuner/core.css";
@source "templates";
@source "assets/statics/js";
[data-theme="light"] {
--color-primary: oklch(64% 0.21 24);
--color-primary-content: oklch(98% 0 0);
--color-accent: oklch(82% 0.18 95);
}
[data-theme="dark"] {
--color-primary: oklch(64% 0.21 24);
--color-primary-content: oklch(98% 0 0);
--color-accent: oklch(82% 0.18 95);
}
core.css invokes @plugin "daisyui", which emits rules like
:where(:root),:root:has(input.theme-controller[value=light]:checked),[data-theme=light] { … }.
The override above shares specificity with the standalone
[data-theme=light] matcher and lands later in the file — cascade
picks it up automatically. For per-tenant runtime overrides on top of
this, see the Theming guide.
When a brand-new named theme is needed
DaisyUI's documented @plugin "daisyui/theme" { name: "my-brand"; … }
form lets projects define entirely new themes, but daisyui is a
private transitive of @alltuner/vibetuner, so resolution from a
consumer-side config.css fails under bun's isolated linker with
Error: Can't resolve 'daisyui/theme'. If you genuinely need this
(most projects don't — overriding [data-theme="…"] is enough),
add daisyui as a direct devDependency:
bun add -d daisyui and re-run bun install. The top-level
symlink makes @plugin "daisyui/theme" resolvable from
config.css.
Brand Configuration¶
DaisyUI tokens and CSS variables cover everything that renders inside the
page, but a few brand surfaces are read before any CSS runs (favicon meta
tags, the PWA manifest) or in clients that ignore CSS variables (email
clients). BrandSettings is an app-level pydantic-settings surface that
drives those specific surfaces:
# .env (all three are optional; defaults shown)
BRAND_PRIMARY_COLOR=#5b2333
BRAND_BROWSER_THEME_COLOR=#ffffff
BRAND_EMAIL_BUTTON_COLOR= # falls back to BRAND_PRIMARY_COLOR when unset
BRAND_PRIMARY_COLOR— Safari pinned-tabmask-iconcolor, Windows tile color (browserconfig.xml), and the magic-link email button when no override is set.BRAND_BROWSER_THEME_COLOR— mobile browser chrome (<meta name="theme-color">) and the PWA manifest'stheme_color/background_color.BRAND_EMAIL_BUTTON_COLOR— override slot for the magic-link email button when it needs to differ from the primary brand color.
Inputs accept any pydantic Color form (named, rgb(), hex short or
long); values canonicalise to long-form #rrggbb lowercase before
rendering.
from vibetuner.config import settings
settings.brand.primary_color # HexColor("#5b2333")
settings.brand.browser_theme_color # HexColor("#ffffff")
settings.brand.email_button # email_button_color or primary_color
settings.brand is exposed in every Jinja render via the shipped
_brand_context provider, so templates read {{ brand.primary_color }}
without wiring anything up. The companion _project_context provider
does the same for settings.project, so branded chrome (header logos,
footers, OpenGraph defaults) can reference {{ project.project_name }},
{{ project.copyright }}, {{ project.fqdn }}, etc. without per-route
context boilerplate. BrandSettings is deliberately app-level (favicon
assets are static files served before tenant resolution; the email
service does not see request context). For per-tenant in-page colors,
use TenantTheme.
Security Headers and CSP Nonce¶
Vibetuner includes SecurityHeadersMiddleware that sets security headers
(X-Content-Type-Options, X-Frame-Options, Referrer-Policy, etc.) and
generates a Content Security Policy with a unique nonce per request.
Script tags get the nonce auto-injected by the middleware. Do not add
nonce= attributes to <script> tags manually:
<!-- Correct — nonce is added automatically by middleware -->
<script src="/static/app.js"></script>
<!-- Wrong — manual nonce causes double-injection issues -->
<script nonce="{{ csp_nonce }}" src="/static/app.js"></script>
Style tags and other elements that need the nonce must use the
{{ csp_nonce }} template variable (available in all templates):
CSP is fully enforced in both production and debug mode by default, so
violations break the page locally and are caught before they ship. To
fall back to the legacy soft mode (where debug emits
Content-Security-Policy-Report-Only), set
CSP_ENFORCE_CSP_IN_DEBUG=false.
Configure extra allowed sources via environment variables:
| Variable | Description |
|---|---|
CSP_EXTRA_SCRIPT_SRC |
Additional script sources |
CSP_EXTRA_STYLE_SRC |
Additional style sources |
CSP_EXTRA_IMG_SRC |
Additional image sources |
CSP_EXTRA_CONNECT_SRC |
Additional connect sources |
CSP_EXTRA_FONT_SRC |
Additional font sources |
CSP_EXTRA_MEDIA_SRC |
Additional media sources |
CSP_ENFORCE_CSP_IN_DEBUG |
Enforce CSP in debug mode (default: true) |
CSP_STYLE_SRC_STRICT |
Use the request nonce on style-src instead of 'unsafe-inline' (default: false) |
Nosniff Content-Type guard. The middleware always sets
X-Content-Type-Options: nosniff. If a response has no Content-Type
header (e.g. a bare Response(status_code=404) with no media_type=),
the middleware fills in text/plain; charset=utf-8 so browsers do not
turn the response into an error page or a 0-byte download. App code
should still set media_type= explicitly when it matters, but the
guard prevents the worst-case UX when something slips through.
htmx CSP Protection (default-on)¶
Requires [email protected]
The hx-csp extension file ships with [email protected], pulled
transitively by the matching @alltuner/vibetuner release, and is
re-exported as @alltuner/vibetuner/htmx/csp (added in 10.20.0). When
upgrading from an older release that shipped htmx beta3, the extension
was carried as hx-nonce.js; see
Beta3 to Beta4 Changes for
the import-path update.
htmx 4.0.0-beta4 ships an hx-csp extension (renamed from hx-nonce
in beta3) that gates htmx attribute processing behind the page CSP
nonce. It is loaded by default from the framework-managed block of
config.js, alongside hx-preload and hx-live:
You do not add this yourself — it ships in the part of config.js you
must not edit. The HTML attribute is still named hx-nonce; only the
extension itself was renamed.
Why it is required, not optional. Vibetuner's CSP is
script-src 'nonce-…' 'strict-dynamic' with no 'unsafe-eval'. A
nonce plus strict-dynamic does not permit eval / new Function —
only 'unsafe-eval' does. In beta4, both hx-on: and hx-live
evaluate their JS through htmx core's new Function(), so without
mitigation every such expression throws an EvalError under the
enforced CSP and silently does nothing (an easy-to-miss runtime console
error, not a build failure — and CSP is enforced in debug too unless you
opt out). hx-csp fixes this: the framework-managed import flips
htmx.config.safeEval = true before the extension registers, and
with safeEval the extension replaces htmx's new Function()
evaluation with nonce-based <script> injection — which a nonce +
strict-dynamic CSP does permit. That is what makes hx-on: and
hx-live genuinely CSP-safe with no 'unsafe-eval'. No safeEval meta
tag is required; it is enabled by the framework config.
Fail-closed nonce gating, auto-stamped. The extension is
fail-closed: every element carrying an hx-* attribute must have an
hx-nonce attribute whose value matches the page CSP nonce, or htmx
strips its hx-* attributes. You do not stamp this manually.
SecurityHeadersMiddleware auto-stamps hx-nonce on every htmx element
in HTML responses, exactly the way it already injects the nonce into
<script> tags. There is no body_attrs nonce to add and no
per-element hx-nonce="{{ csp_nonce }}" to write.
The gate trusts what the server renders
Because the middleware stamps the nonce onto the rendered body, the
gate trusts whatever the server renders — the same trust model as the
existing script-nonce injection. If your project renders untrusted
HTML (markdown, user-supplied rich content), you must sanitize it;
hx-csp is not a substitute for sanitizing injected hx-* /
<script> in that content.
Trusted Types (further hardening): You can also tell the browser to
refuse non-htmx HTML sinks by adding
require-trusted-types-for 'script'; trusted-types htmx to your CSP.
Configure this via CSP_EXTRA_* if you need to merge it with other
directives, or extend SecurityHeadersMiddleware directly. With hx-csp
default-on this works as-is; if you add a trusted-types directive it
must include htmx in the allowlist.
Strict style-src (opt-in)¶
Requires vibetuner ≥ 10.11.0
SecurityHeadersSettings only learned about CSP_STYLE_SRC_STRICT
in 10.11.0. On older releases the env var is silently ignored
(pydantic's extra="ignore" discards unknown CSP options), so the
style-src directive keeps emitting 'unsafe-inline' without any
warning. Bump vibetuner in pyproject.toml and re-run uv sync
before relying on the flag.
By default, style-src is 'self' 'unsafe-inline' so that inline
style="..." attributes and unnonced <style> tags work. This is the
permissive baseline that lets existing templates ship without changes.
Set CSP_STYLE_SRC_STRICT=true to switch style-src to
'self' 'nonce-<request-nonce>'. This is a meaningful hardening:
inline style="..." attributes will be blocked by the browser,
and every <style> tag must carry nonce="{{ csp_nonce }}".
The framework's own templates already follow this pattern (the inline
styles in skeleton.html.jinja and theme.html.jinja are nonced; the
htmx 4.0.0-beta4 indicator stylesheet uses a constructable
CSSStyleSheet which does not need 'unsafe-inline'). Before flipping
this flag in your project:
- Audit your templates for
style="..."attributes — convert to utility classes or scoped<style>blocks. - Make sure every
<style>tag carriesnonce="{{ csp_nonce }}". - Test interactively: open the browser devtools and look for CSP violations on hover/focus/error states (these often have inline styles set by JS).
This default may flip to true in a future major version once the
ecosystem has had time to migrate.
Working with HTMX¶
Vibetuner uses HTMX for interactive features without JavaScript:
<!-- Load more posts -->
<button hx-get="/blog?page=2"
hx-target="#posts"
hx-swap="beforeend"
class="btn btn-primary">Load More</button>
<div id="posts">
<!-- Posts will be appended here -->
</div>
Server endpoint:
@router.get("/blog")
async def list_posts(page: int = 1):
posts = await Post.find().skip((page - 1) * 10).limit(10).to_list()
return templates.TemplateResponse("blog/posts.html.jinja", {
"posts": posts
})
HTMX Request Detection¶
Every request has request.state.htmx available (provided by the starlette-htmx
middleware). Use it to serve different responses for HTMX vs regular requests:
from fastapi import Request
from starlette.responses import HTMLResponse
from vibetuner import render_template, render_template_string
@router.get("/items")
async def list_items(request: Request):
items = await Item.find_all().to_list()
ctx = {"items": items}
if request.state.htmx:
# HTMX request — return just the partial
html = render_template_string("items/_list.html.jinja", request, ctx)
return HTMLResponse(html)
# Regular request — return the full page
return render_template("items/list.html.jinja", request, ctx)
Available properties on request.state.htmx:
| Property | Description |
|---|---|
bool(request.state.htmx) |
True if this is an HTMX request |
.boosted |
True if request came via hx-boost |
.target |
ID of the target element (hx-target) |
.trigger |
ID of the element that triggered the request |
.trigger_name |
Name attribute of the triggering element |
.current_url |
Browser's current URL when request was made |
.prompt |
User response from hx-prompt |
HTMX Response Headers¶
Helper functions for setting HTMX response headers. Import from vibetuner.htmx:
from vibetuner.htmx import hx_redirect, hx_location, hx_trigger
# Full-reload redirect (when <head> or scripts differ)
return hx_redirect("/items/123")
# HTMX-style navigation without full reload
return hx_location("/items", target="#main", swap="innerHTML")
# Trigger client-side events after swap
response = render_template("items/created.html.jinja", request, ctx)
hx_trigger(response, "itemCreated", {"id": str(item.id)})
return response
Available helpers: hx_redirect, hx_location, hx_trigger,
hx_trigger_after_settle, hx_trigger_after_swap, hx_push_url,
hx_replace_url, hx_reswap, hx_retarget, hx_refresh.
JSON serialization is handled internally — you never need to call json.dumps().
Response Caching (Server-Side)¶
Use the @cache decorator to cache route responses in Redis with a configurable TTL.
This is ideal for expensive queries, aggregation endpoints, or rendered pages that
don't change on every request:
from vibetuner.cache import cache
@router.get("/api/stats")
@cache(expire=60) # cache for 60 seconds
async def get_stats(request: Request):
return {"users": await count_users()}
The decorator uses vibetuner's existing Redis connection — no extra setup required
if you already have REDIS_URL configured.
Key features:
- Cache key derived from route path + sorted query parameters
- Respects
Cache-Control: no-cacherequest header (bypasses cache) - Works with JSON, HTML, and dict responses
- Disabled by default in debug mode — pass
force_caching=Trueto override - If Redis is not configured or unavailable, the decorator is a transparent no-op
Request-dependent cache keys with vary_on:
Use vary_on to cache different responses for different users, tenants, or
any other request-derived dimension:
# Per-user cache — each user gets their own cached dashboard
@router.get("/dashboard")
@cache(expire=120, vary_on=lambda r: str(r.state.user.id))
async def dashboard(request: Request):
return await render_dashboard(request)
# Per-tenant cache
@router.get("/reports")
@cache(expire=300, vary_on=lambda r: r.state.tenant_id)
async def reports(request: Request):
return await generate_reports(request)
# Vary by header
@router.get("/api/data")
@cache(expire=60, vary_on=lambda r: r.headers.get("x-tenant", ""))
async def data(request: Request):
return await fetch_data(request)
vary_on accepts a callable with signature (Request) -> str. The
returned string is included in the cache key, so different values produce
independent cache entries. When None (the default), all requests to the
same path and query share one cache entry.
Cache invalidation:
from vibetuner.cache import invalidate, invalidate_pattern
# Invalidate a specific path
await invalidate("/api/stats")
# Invalidate a specific query variant
await invalidate("/api/stats", query_params="page=1")
# Invalidate all matching paths (uses Redis SCAN)
await invalidate_pattern("/api/*")
Cache Control Headers (Browser-Side)¶
Use the @cache_control decorator to set Cache-Control HTTP headers declaratively
instead of manually manipulating response headers:
from vibetuner.decorators import cache_control
@router.get("/static-page")
@cache_control(max_age=300, public=True)
async def static_page(request: Request):
return render_template("static_page.html.jinja", request)
Supported directives: public, private, no_cache, no_store, max_age,
s_maxage, must_revalidate, stale_while_revalidate, immutable.
You can combine both decorators — @cache for server-side Redis caching and
@cache_control for browser-side HTTP caching:
@router.get("/api/stats")
@cache(expire=60)
@cache_control(max_age=30, public=True)
async def get_stats(request: Request):
return {"users": await count_users()}
Block Rendering for HTMX Partials¶
Use render_template_block() to render a single {% block %} from a template,
enabling one template to serve both full-page and HTMX partial responses:
<!-- templates/frontend/items/list.html.jinja -->
{% extends "base/skeleton.html.jinja" %}
{% block body %}
<div id="items-container">
{% block items_list %}
{% for item in items %}
<div class="item">{{ item.name }}</div>
{% endfor %}
{% endblock items_list %}
</div>
{% endblock body %}
from vibetuner import render_template, render_template_block
@router.get("/items")
async def list_items(request: Request):
items = await Item.find_all().to_list()
ctx = {"items": items}
if request.state.htmx:
return render_template_block(
"items/list.html.jinja", "items_list", request, ctx
)
return render_template("items/list.html.jinja", request, ctx)
For HTMX out-of-band swaps that update
multiple page regions in one response, use render_template_blocks() (plural):
from vibetuner import render_template_blocks
@router.post("/items")
async def create_item(request: Request):
item = await Item.insert(...)
items = await Item.find_all().to_list()
ctx = {"items": items, "item_count": len(items)}
return render_template_blocks(
"items/list.html.jinja",
["items_list", "notification_badge"],
request, ctx,
)
HTMX-Only Routes¶
Use the require_htmx dependency to reject non-HTMX requests with a 400 error:
from fastapi import Depends
from vibetuner.frontend.deps import require_htmx
@router.post("/items/create", dependencies=[Depends(require_htmx)])
async def create_item(request: Request):
# Only reachable via HTMX — non-HTMX requests get 400
...
Using hx-boost¶
For links and forms that should use HTMX navigation without writing custom
hx-get/hx-post attributes, use hx-boost="true" on a parent element. Boosted
links and forms swap the <body> content and update the URL without a full page
reload:
Boosted requests set request.state.htmx.boosted = True. Since boosted requests
expect a full page response (they swap the entire body), you typically don't need
to branch on request.state.htmx for boosted routes.
Internationalization¶
Framework-Shipped Translations¶
The vibetuner package ships compiled translation catalogs for its built-in
templates (login, profile, magic-link, etc.). When your app sets a supported
locale, framework strings translate automatically — you do not need to
re-extract _() calls from vibetuner/templates/ into your own catalog.
At startup, frontend/middleware.py loads catalogs in this order:
- Framework catalogs (
vibetuner/locales/<lang>/LC_MESSAGES/messages.mo) — bundled with the package, ship for the locales the framework supports (en,ca, with more coming). - Project catalogs (
<project>/locales/<lang>/LC_MESSAGES/messages.mo) — your app's own translations.
Both catalogs use the default messages domain and merge per locale, with
project catalogs winning on collision. Practical implications:
- Set your project locale to
caand/auth/loginrenders in Catalan out of the box — no extraction required. - If you don't like the framework's wording for a string, redefine it
in your project's own
messages.po. Your translation overrides the framework's at runtime. - If you support a locale the framework doesn't yet ship, framework
strings fall back to the English msgid (gettext's
NullTranslationsbehavior). You can either translate those strings yourself (extract them locally, override in your catalog) or contribute the locale upstream.
Contributing a Framework Locale¶
To add a new framework locale (e.g. Spanish):
just extract-framework-translations # refresh messages.pot
just new-framework-locale es # init es/LC_MESSAGES/messages.po
# Edit vibetuner-py/src/vibetuner/locales/es/LC_MESSAGES/messages.po
just compile-framework-locales # produce messages.mo
The full workflow is also wrapped as just i18n-framework. Both .po
and .mo files are committed to the repo so end users don't need
pybabel at install time — the wheel ships the .mo files directly.
update-framework-locales uses msgmerge --no-fuzzy-matching so new
strings stay empty rather than inheriting a wrong guess from a similar
entry; just i18n-framework-fuzzy-audit reports any #, fuzzy entries
in the framework catalogs. The recipes pass --no-wrap so entries stay
single-line and don't drift when gettext's line-wrapping differs between
macOS and the Linux used in CI.
Extracting Translations¶
After adding translatable strings to your code or templates:
This scans your code and templates for {% trans %} blocks and gettext() calls.
Catalog references record the source file only (#: templates/frontend/index.html.jinja),
not the line number. Moving a translatable string within a file therefore produces no catalog
churn, so a pure line shift never drifts messages.pot or your .po files.
The recipes also pass --no-wrap, so every msgid/msgstr stays on a single line. GNU
gettext's default line wrapping varies between versions and platforms (macOS vs the Linux used
in CI), which would otherwise rewrap long strings differently and drift the catalogs between a
developer's machine and CI. Single-line entries are deterministic everywhere.
Adding New Languages¶
Updating Translations¶
Edit .po files in translations/:
Compile translations:
Using in Templates¶
Using in Python¶
Template Context Variables for i18n¶
The following language-related variables are available in templates:
| Variable | Type | Description |
|---|---|---|
default_language |
str |
Default language code (e.g., "en") |
supported_languages |
set[str] |
Set of supported language codes |
locale_names |
dict[str, str] |
Language codes to native display names |
language |
str |
The active language for the current request |
A language_picker() Jinja global is also available — see below.
When to use which¶
locale_names and language_picker() overlap but solve different problems:
locale_names— locale-independent map of native names, frozen at app startup and sorted by name. Use it when every language must always render in its own script (e.g. a footer that reads "English / Català / Español" regardless of the visitor's language).language_picker()—[{code, name}]list with names rendered in the current request locale (or an explicitdisplay_locale). Use it for switchers that should render themselves in the user's active language.
If you want native names but prefer a single source of truth, call
language_picker(code) once per language instead of reading locale_names.
Using language_picker() for Locale-Aware Switchers¶
language_picker() is a Jinja global (also importable as
vibetuner.i18n.language_picker) that returns a sorted list of
{code, name} entries with names rendered in the current request's
locale. Browsing in Spanish gives "inglés / español / catalán";
browsing in Catalan gives "anglès / espanyol / català".
<select name="language">
{% for entry in language_picker() %}
<option value="{{ entry.code }}"
{% if entry.code == language %}selected{% endif %}>
{{ entry.name }}
</option>
{% endfor %}
</select>
Pass an explicit display_locale to render names in a specific language
regardless of the request locale: {% for e in language_picker("es") %}.
Using locale_names for native names¶
locale_names is locale-independent — each language is shown in its own native name
(e.g. {"ca": "Català", "en": "English", "es": "Español"}). Use this when you want a
consistent display regardless of the user's current language.
SEO-Friendly Language URLs¶
Vibetuner supports path-prefix language routing for SEO-friendly URLs (e.g., /ca/privacy,
/es/about).
How It Works¶
The LangPrefixMiddleware handles path-prefix language routing:
| URL | Behavior |
|---|---|
/ca/dashboard |
Strips prefix → /dashboard, sets lang=ca |
/dashboard (anonymous) |
Serves directly using detected/default language |
/dashboard (authenticated) |
301 redirects to /{user_lang}/dashboard |
/xx/dashboard (invalid) |
Returns 404 Not Found |
/ca |
Redirects to /ca/ |
/static/... |
Bypassed, serves static file directly |
Language Detection Priority¶
Languages are detected in this order (first match wins):
- Custom resolvers registered via
register_locale_resolver - Query parameter (
?l=es) - URL path prefix (
/ca/...) - User preference (from session, for authenticated users)
- Cookie (
languagecookie) - Accept-Language header (browser preference)
- Default language
The Accept-Language step negotiates region-aware: each browser preference is
matched against the supported languages by its full tag first, then by its
language-only subtag, so a region-qualified top preference like ca-ES resolves
to a supported ca instead of losing to a lower-ranked exact match such as es.
The highest-quality preference with any supported match wins.
Custom Locale Resolvers (register_locale_resolver)¶
For per-tenant or domain-specific locale rules, register a custom resolver at
startup. Resolvers run before all built-in selectors and the first one to
return a non-None value wins. Within the registered group, resolvers are
ordered by priority ascending (lower runs first).
from vibetuner.i18n import register_locale_resolver
def tenant_locale(conn):
tenant = getattr(conn.scope.get("state", {}), "tenant", None)
return tenant.language if tenant else None
register_locale_resolver(tenant_locale)
Resolvers must be synchronous (do any I/O upstream in middleware). If a resolver raises, the exception is logged and the chain falls through to the next resolver — a bad lookup never produces a 500.
Forcing a Language Mid-Request (set_request_language)¶
To change the active language partway through a request (e.g. right after a
session login), use set_request_language. It updates both the Babel context
(drives {% trans %}) and request.state.language (drives <html lang> and
the Content-Language header) in one call so they stay in sync.
from vibetuner.i18n import set_request_language
set_request_language(request, user.preferred_language)
The code is normalized to lowercase and validated; an invalid code raises
ValueError.
Programmatic Language Picker (language_picker)¶
When you need the picker output outside a template (e.g. JSON endpoint, email
rendering), call language_picker directly. By default the names are
rendered in the current request's locale.
from vibetuner.i18n import language_picker
choices = language_picker() # display in current locale
es_choices = language_picker(display_locale="es") # always in Spanish
Redirect Behavior¶
Localized routes follow these rules:
- Anonymous users: Served at unprefixed URL using detected/default language
- Authenticated users: 301 permanent redirect to
/{lang}/path
This approach optimizes for SEO: search engines crawl the unprefixed URL (which serves the
default language) and discover language variants via hreflang tags, while authenticated
users get a personalized, bookmarkable URL.
Using LocalizedRouter (Recommended)¶
Use LocalizedRouter to control localization at the router level. All routes automatically
handle language prefix redirects:
from fastapi import Request
from vibetuner.frontend import LocalizedRouter
from vibetuner import render_template
# All routes in this router are localized
legal_router = LocalizedRouter(prefix="/legal", localized=True)
@legal_router.get("/privacy")
async def privacy(request: Request):
return render_template("legal/privacy.html.jinja", request)
# Anonymous: served at /legal/privacy
# Authenticated: redirected to /{lang}/legal/privacy
# All routes in this router are non-localized (API endpoints)
api_router = LocalizedRouter(prefix="/api", localized=False)
@api_router.get("/users")
async def users():
return {"users": []} # Always at /api/users, no redirects
Using @localized Decorator¶
For individual routes on a regular APIRouter, use the @localized decorator:
from fastapi import APIRouter, Request
from vibetuner.frontend import localized
from vibetuner import render_template
router = APIRouter()
@router.get("/privacy")
@localized
async def privacy(request: Request):
return render_template("privacy.html.jinja", request)
Generating Language URLs in Templates¶
Two helpers are available for generating language-prefixed URLs:
lang_url_for: Uses the current request's language:
<a href="{{ lang_url_for(request, 'privacy') }}">Privacy Policy</a>
<!-- Output: /ca/privacy (if current language is Catalan) -->
url_for_language: Specify a target language explicitly (for language switchers):
Adding hreflang Tags for SEO¶
Use hreflang_tags to generate proper hreflang link tags in your page head:
<!-- In your base template <head> -->
{{ hreflang_tags(request, supported_languages, default_language)|safe }}
This outputs:
<link rel="alternate" hreflang="ca" href="https://example.com/ca/privacy" />
<link rel="alternate" hreflang="en" href="https://example.com/en/privacy" />
<link rel="alternate" hreflang="es" href="https://example.com/es/privacy" />
<link rel="alternate" hreflang="x-default" href="https://example.com/privacy" />
Note: x-default points to the unprefixed URL, which serves the default/detected language.
Complete Example¶
Route definition:
# src/app/frontend/routes/legal.py
from fastapi import Request
from vibetuner.frontend import LocalizedRouter
from vibetuner import render_template
router = LocalizedRouter(tags=["legal"], localized=True)
@router.get("/privacy")
async def privacy(request: Request):
return render_template("legal/privacy.html.jinja", request)
@router.get("/terms")
async def terms(request: Request):
return render_template("legal/terms.html.jinja", request)
Template with hreflang:
<!-- templates/legal/privacy.html.jinja -->
{% extends "base/skeleton.html.jinja" %}
{% block head %}
{{ hreflang_tags(request, supported_languages, default_language)|safe }}
{% endblock head %}
{% block content %}
<h1>{% trans %}Privacy Policy{% endtrans %}</h1>
<!-- Content -->
{% endblock content %}
Language switcher using url_for_language:
<!-- templates/partials/language_switcher.html.jinja -->
<div class="dropdown">
{% for code, name in locale_names.items() %}
<a href="{{ url_for_language(request, code, request.scope.endpoint.__name__) }}"
{% if code == language %}class="active"{% endif %}>
{{ name }}
</a>
{% endfor %}
</div>
CRUD Factory¶
Generate complete REST API endpoints from Beanie Document models with a single function call.
Basic Usage¶
from vibetuner.crud import create_crud_routes
from app.models.post import Post
post_routes = create_crud_routes(Post, prefix="/posts", tags=["posts"])
Include the router in your app via tune.py. CRUD routes are JSON API
endpoints, so use api_routes to keep them visible in /docs:
# src/app/tune.py
from vibetuner import VibetunerApp
from app.routes.posts import post_routes
app = VibetunerApp(api_routes=[post_routes])
This generates five endpoints:
| Method | Path | Description |
|---|---|---|
GET |
/posts |
List with pagination, filtering, search, sort |
POST |
/posts |
Create a new document |
GET |
/posts/{item_id} |
Read a single document |
PATCH |
/posts/{item_id} |
Partial update |
DELETE |
/posts/{item_id} |
Delete a document |
Filtering, Searching, and Sorting¶
post_routes = create_crud_routes(
Post,
prefix="/posts",
sortable_fields=["created_at", "title"],
filterable_fields=["status", "author_id"],
searchable_fields=["title", "content"],
page_size=25,
max_page_size=100,
)
Query examples:
GET /posts?status=published— equality filterGET /posts?search=python— text search across searchable fieldsGET /posts?sort=-created_at,title— sort descending by date, then titleGET /posts?offset=20&limit=10— paginationGET /posts?fields=title,status— sparse field selection
Lifecycle Hooks¶
Attach async callbacks to intercept create, update, and delete operations:
async def check_permissions(doc, data, request):
if request.state.user.id != str(doc.author_id):
raise HTTPException(403, "Not your post")
return data
post_routes = create_crud_routes(
Post,
prefix="/posts",
pre_update=check_permissions,
post_create=notify_subscribers,
)
Available hooks:
| Hook | Signature |
|---|---|
pre_create |
async (data, request) -> data |
post_create |
async (doc, request) |
pre_update |
async (doc, data, request) -> data |
post_update |
async (doc, request) |
pre_delete |
async (doc, request) |
post_delete |
async (doc, request) |
Custom Schemas¶
Override the auto-generated Pydantic schemas for create/update payloads or response serialization:
from pydantic import BaseModel
class PostCreate(BaseModel):
title: str
content: str
class PostResponse(BaseModel):
id: str
title: str
status: str
post_routes = create_crud_routes(
Post,
create_schema=PostCreate,
response_schema=PostResponse,
)
Restricting Operations¶
Only generate the endpoints you need:
from vibetuner.crud import create_crud_routes, Operation
# Read-only API
post_routes = create_crud_routes(
Post,
operations={Operation.LIST, Operation.READ},
)
Route-Level Dependencies¶
Apply FastAPI dependencies (e.g., authentication) to all generated routes:
from vibetuner.frontend.auth import require_auth
post_routes = create_crud_routes(
Post,
dependencies=[require_auth],
)
SSE / Real-Time Streaming¶
Server-Sent Events helpers for pushing real-time updates to the browser, designed for use with HTMX.
Channel-Based Streaming¶
Subscribe clients to a named channel and broadcast updates from anywhere:
from vibetuner.sse import sse_endpoint, broadcast
router = APIRouter()
# Endpoint that auto-subscribes to the "notifications" channel
@sse_endpoint("/events/notifications", channel="notifications", router=router)
async def notifications_stream(request: Request):
pass # channel kwarg handles the subscription
SSE path is relative to router prefix
The path argument is relative to the router's prefix, just like @router.get().
If your router has prefix="/api", use path="/events" (not path="/api/events")
to avoid a doubled path like /api/api/events.
Broadcast from any route or background task:
from vibetuner.sse import broadcast
@router.post("/posts")
async def create_post(request: Request):
post = await Post(**data).insert()
# Push HTML fragment to all subscribers
await broadcast(
"notifications",
"new-post",
data="<div>New post created!</div>",
)
return post
Dynamic Channels¶
Return a channel name from the decorated function for per-resource streams:
@sse_endpoint("/events/room/{room_id}", router=router)
async def room_stream(request: Request, room_id: str):
return f"room:{room_id}" # subscribe to this channel
Template-Rendered Broadcasts¶
Broadcast rendered Jinja2 partials instead of raw strings:
await broadcast(
"feed",
"new-post",
template="partials/post_card.html.jinja",
request=request,
ctx={"post": post},
)
Generator-Based Streaming¶
For full control, yield events directly from an async generator:
@sse_endpoint("/events/clock", router=router)
async def clock_stream(request: Request):
while True:
yield {"event": "tick", "data": datetime.now().isoformat()}
await asyncio.sleep(5)
HTMX Integration¶
Connect an SSE endpoint to HTMX using the built-in SSE support. The
hx-sse:connect attribute opens the stream; htmx v4 then handles messages two
ways depending on whether the broadcast carries an event name:
- Named events (the
eventyou pass tobroadcast()) are dispatched as DOM events on the connecting element. You consume them elsewhere withhx-trigger="<event> from:#<id>", typically to re-fetch current state. - Unnamed messages (an empty event name) are swapped into the connecting
element directly using its own
hx-target/hx-swap.
The recommended pattern broadcasts a named event as a signal and lets a consumer fetch the current state, so the rendered markup always reflects the server rather than a fragment that can be missed:
<!-- Opens the stream; carries no visible content itself -->
<div id="notifications-stream" hx-sse:connect="/events/notifications"></div>
<!-- Re-fetches current state whenever the "new-post" event fires -->
<div hx-get="/notifications"
hx-trigger="new-post from:#notifications-stream">
<!-- current notifications render here -->
</div>
# Broadcast the signal; the consumer re-fetches the current state.
await broadcast("notifications", "new-post")
Backgrounded Tabs Drop Events — Resync on Reconnect¶
Live views go stale when the tab is backgrounded
For hx-sse:connect, htmx v4 enables pauseOnBackground by default: when
the tab is hidden it closes the stream, and reopens it when the tab is
visible again. Any event broadcast() during the hidden window is lost —
there is no automatic replay — so the view stays stuck on its old state until
the next event or a manual reload. This is easy to miss in local testing
(focused tabs never pause) and most visible with fast transitions, where a
status flip lands during a glance away.
Recover by re-fetching current state on every (re)connection, not just on the
named event. The htmx:after:sse:connection event fires on the connecting
element on each connect and reconnect, so add it to the consumer's trigger:
<div id="notifications-stream" hx-sse:connect="/events/notifications"></div>
<div hx-get="/notifications"
hx-trigger="new-post from:#notifications-stream,
htmx:after:sse:connection from:#notifications-stream">
<!-- current notifications; re-fetched on each event and every reconnect -->
</div>
The reconnect fetch is one idempotent request that returns the view to the current server state, picking up anything missed while hidden. Because the consumer always renders server state, this stays correct no matter how many events were dropped.
To keep a stream live in the background instead (at the cost of holding the
connection open in hidden tabs), disable the pause per element with hx-config:
or globally in your entry point with htmx.config.sse = {pauseOnBackground: false}.
Multi-Worker Support¶
When Redis is configured (REDIS_URL), broadcasts are relayed across
all worker processes via Redis pub/sub automatically. No extra setup needed.
sse_endpoint(buffer_size=...) enables a per-channel ring buffer so a client
reconnecting with a Last-Event-ID header can replay missed events. Note that
event IDs are per-process and monotonic, so across multiple frontend workers
a reconnect that lands on a different worker cannot replay reliably. The
resync-on-reconnect pattern above is the robust answer regardless of worker
count, and it does not depend on buffer_size.
Reverse Proxies and CDNs¶
SSE responses are sent with Cache-Control: no-cache and
X-Accel-Buffering: no, which tell reverse proxies and CDNs (Caddy, nginx,
Cloudflare) to stream events through immediately instead of buffering the
response. Without these headers a buffering proxy can hold the connection
open and deliver nothing to the client until it closes, making live updates
appear to "only work on reload." Because the framework sets them for you, no
extra proxy configuration is required for SSE to work in production.
Template Context Providers¶
Inject variables into every render_template() call without passing them
explicitly each time.
Static Globals¶
Use register_globals() to set values available in all templates:
# src/app/config.py
from vibetuner.rendering import register_globals
register_globals({
"site_title": "My App",
"og_image": "/static/og.png",
"support_email": "[email protected]",
})
Values are merged into the template context on every render. Explicit
context passed to render_template() takes precedence over globals.
Dynamic Context Providers¶
Use @register_context_provider for values that are computed at render time:
from vibetuner.rendering import register_context_provider
from vibetuner.runtime_config import get_config
@register_context_provider
def feature_flags() -> dict[str, Any]:
return {"show_beta_banner": True}
# Also works with parentheses
@register_context_provider()
def analytics_context() -> dict[str, Any]:
return {"analytics_id": "UA-XXX"}
Provider functions must return a dict[str, Any]. They run synchronously on
every render_template() call. Multiple providers can be registered and their
results are merged.
Service Dependency Injection¶
Vibetuner provides FastAPI Depends() wrappers for built-in services.
Available Services¶
from fastapi import Depends
from vibetuner.services import (
get_email_service,
get_blob_service,
get_runtime_config,
)
Email Service¶
from vibetuner.services import get_email_service
from vibetuner.services.email import EmailService
@router.post("/contact")
async def contact(
email_svc: EmailService = Depends(get_email_service),
):
await email_svc.send_email(
to_address="[email protected]",
subject="Contact form",
html_body="<p>Hello</p>",
text_body="Hello",
)
Blob Storage Service¶
from vibetuner.services import get_blob_service
from vibetuner.services.blob import BlobService
@router.post("/upload")
async def upload(
blobs: BlobService = Depends(get_blob_service),
):
await blobs.put_object(...)
When BlobService() is constructed without an explicit default_bucket, it
falls back to R2_DEFAULT_BUCKET_NAME from the environment and logs a one-time
process warning naming the resolved bucket. Watch deploy logs for that warning
on first boot — if the bucket name doesn't match what this project should write
to, the env var has leaked in from another project and uploads will silently
land in the wrong bucket. Either set R2_DEFAULT_BUCKET_NAME explicitly for
the deploy, or pass default_bucket=... to BlobService().
Runtime Config¶
from vibetuner.services import get_runtime_config
from vibetuner.runtime_config import RuntimeConfig
@router.get("/settings")
async def app_settings(
config: RuntimeConfig = Depends(get_runtime_config),
):
dark_mode = await config.get("features.dark_mode")
return {"dark_mode": dark_mode}
The dependency automatically refreshes the config cache if it is stale.
Health Check Endpoints¶
Built-in health endpoints are available at /health for liveness probes,
readiness checks, and service diagnostics.
| Endpoint | Purpose |
|---|---|
GET /health |
Fast liveness check (status + version + uptime) |
GET /health?detailed=true |
Checks all configured services with latency |
GET /health/ready |
Readiness probe — all services must be reachable |
GET /health/ping |
Ultra-fast liveness — returns {"status": "ok"} |
GET /health/id |
Instance identification (slug, port, PID, startup time) |
Liveness Check¶
curl http://localhost:8000/health
# {"status": "healthy", "version": "1.0.0", "uptime_seconds": 3600}
Detailed Health Check¶
curl http://localhost:8000/health?detailed=true
# {
# "status": "healthy",
# "version": "1.0.0",
# "uptime_seconds": 3600,
# "services": {
# "mongodb": {"status": "connected", "latency_ms": 2.1},
# "redis": {"status": "connected", "latency_ms": 0.5}
# }
# }
If any service reports an error, the overall status becomes "degraded".
Readiness Probe¶
Use /health/ready in Kubernetes or Docker health checks to ensure all
services are reachable before routing traffic:
# docker-compose.yml
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health/ready"]
interval: 30s
timeout: 5s
retries: 3
Services checked automatically based on configuration: MongoDB, Redis, S3/R2 endpoint, and email (Resend or Mailjet).
Debugging¶
View Logs¶
Access Database¶
# MongoDB
docker compose exec mongodb mongosh
# PostgreSQL
docker compose exec postgres psql -U postgres
Interactive Shell¶
Testing¶
Run Tests¶
Test Coverage¶
Integration Tests¶
Integration tests should use a real database (not mocks):
import pytest
from app.models import Post
@pytest.mark.asyncio
async def test_create_post():
post = Post(title="Test", content="Content")
await post.insert() # MongoDB with Beanie
found = await Post.get(post.id)
assert found.title == "Test"
Built-in Test Fixtures¶
Vibetuner provides pytest fixtures in vibetuner.testing for common
test scenarios. Add this import to your conftest.py:
vibetuner_client — Async HTTP Test Client¶
Full-stack async test client with middleware, sessions, and HTMX support:
async def test_homepage(vibetuner_client):
resp = await vibetuner_client.get("/")
assert resp.status_code == 200
Override vibetuner_app fixture to supply a custom FastAPI app instance.
vibetuner_db — Shared MongoDB Test Database¶
Creates a single MongoDB database for the whole test session, runs Beanie index registration once, and truncates every non-system collection before and after each test so each test starts with empty collections:
async def test_create_post(vibetuner_db):
post = Post(title="Test", content="Content")
await post.insert()
assert await Post.get(post.id) is not None
The throwaway database is created on the configured Mongo server. By
default that is MONGODB_URL, so if your .env points at a remote or
production cluster the suite reaches that infrastructure (slow, and not
isolated). Set TEST_MONGODB_URL to keep tests on a local Mongo while
MONGODB_URL stays pointed at your app's database:
# .env (or .env.local)
MONGODB_URL=mongodb://prod-cluster.internal:27017/
TEST_MONGODB_URL=mongodb://localhost:27017/
# Or stand up a throwaway local Mongo just for the suite
docker run -d --rm -p 27017:27017 mongo:7
TEST_MONGODB_URL=mongodb://localhost:27017/ pytest
When TEST_MONGODB_URL is unset the fixtures fall back to MONGODB_URL,
and the session start logs the resolved server and throwaway database name.
The test is skipped when neither variable is set.
Caveats:
- All tests in a session share the same database. Don't assert on database-level state (existence, name, full collection drops) or on indexes being absent.
- Indexes (including unique constraints) are built once at session
scope and persist across tests.
DuplicateKeyErroris still raised by unique violations. - Concurrent runs need
pytest-xdist; the session DB name includes the worker id (PYTEST_XDIST_WORKER) so workers don't collide. - If a test crashes mid-run, the next test re-truncates on setup so state is self-healing.
mock_auth — Authentication Mocking¶
Patches the auth backend so requests appear authenticated without real sessions or cookies:
async def test_profile(vibetuner_client, mock_auth):
mock_auth.login(name="Alice", email="[email protected]")
resp = await vibetuner_client.get("/user/profile")
assert resp.status_code == 200
mock_auth.logout()
resp = await vibetuner_client.get("/user/profile")
assert resp.status_code != 200
mock_tasks — Background Task Mocking¶
Test background task enqueuing without Redis:
from unittest.mock import patch
async def test_signup(vibetuner_client, mock_tasks):
with patch(
"app.tasks.emails.send_welcome_email",
mock_tasks.send_welcome_email,
):
resp = await vibetuner_client.post("/signup", data={...})
assert mock_tasks.send_welcome_email.enqueue.called
override_config — Runtime Config Overrides¶
Override RuntimeConfig values for a single test with automatic cleanup:
async def test_feature_flag(override_config):
await override_config("features.dark_mode", True)
# Test code that reads the config value
Code Quality¶
Format Code¶
Runs:
ruff formatfor Python
Check Code¶
Runs:
ruff checkfor Python- Type checking
- Template validation
Environment Configuration¶
Development Settings¶
Copy .env.local to .env:
Edit as needed:
# .env
# MongoDB
MONGODB_URL=mongodb://localhost:27017/myapp
# Or SQL database (PostgreSQL, MySQL, MariaDB, SQLite)
DATABASE_URL=postgresql+asyncpg://user:pass@localhost/myapp
# DATABASE_URL=sqlite+aiosqlite:///./data.db
SESSION_KEY=your-secret-key-here
DEBUG=true
# OAuth (optional)
GOOGLE_CLIENT_ID=...
GOOGLE_CLIENT_SECRET=...
Production Settings¶
Use environment variables or .env file in production:
# MongoDB
MONGODB_URL=mongodb://prod-server:27017/myapp
# Or SQL database
DATABASE_URL=postgresql+asyncpg://user:pass@prod-server/myapp
SESSION_KEY=very-secret-key
DEBUG=false
Keeping the Scaffold Up to Date¶
When new versions of the template ship, update your project using either:
vibetuner scaffold update– works from anywhere; replays Copier with your saved answers.just update-scaffolding– runs inside the generated project and wrapscopier updateplus dependency sync.
Both commands modify tracked files, so commit or stash your work beforehand and review the changes afterward. See the Scaffolding Reference for a deeper walkthrough.
Dependency Management¶
Add Python Package¶
Add JavaScript Package¶
Sync All Dependencies¶
Syncs both Python and JavaScript dependencies.
Common Import Pitfalls¶
When working with tune.py and the framework internals, certain import patterns cause
circular imports. This section explains the rules and how to avoid problems.
Why Circular Imports Happen¶
The framework loads your tune.py via vibetuner.loader at startup. If tune.py imports
a module that itself imports vibetuner.loader (or anything that triggers tune.py to be
loaded again), you get a circular import.
Safe vs. Unsafe Imports in tune.py¶
| Import | Safe? | Notes |
|---|---|---|
from vibetuner.rendering import render_template |
Yes | rendering.py lives outside vibetuner.frontend specifically to avoid cycles |
from vibetuner import render_template |
Yes | Re-export of the above |
from vibetuner.frontend.templates import render_template |
No | vibetuner.frontend.__init__ imports from loader, which imports tune.py |
from vibetuner.frontend import localized |
No | Same reason — triggers vibetuner.frontend.__init__ |
from vibetuner.frontend.routing import LocalizedRouter |
Yes | Direct submodule import bypasses __init__ |
Rule of thumb: never import from vibetuner.frontend (the package) in tune.py.
Import from specific submodules (vibetuner.frontend.routing, vibetuner.frontend.deps)
or use the top-level re-exports (vibetuner.rendering).
Background Task Modules¶
Task modules listed in VibetunerApp.tasks are imported after model initialization.
If a task module imports a model at the top level, the model class must already be defined
in src/app/models/:
# src/app/tasks/emails.py
from vibetuner.tasks.worker import get_worker
from app.models.user import User # OK — models are initialized before tasks load
worker = get_worker()
@worker.task()
async def send_welcome(user_id: str):
user = await User.get(user_id)
...
Lifespan and Lazy Imports¶
The framework's lifespan() function uses lazy imports to break potential cycles:
# vibetuner/frontend/lifespan.py
async def lifespan(app):
# Lazy import — tune.py may import vibetuner.frontend submodules
from vibetuner.loader import load_app_config
app_config = load_app_config()
...
If you provide a custom frontend_lifespan in tune.py, follow the same pattern — use
lazy imports for any vibetuner.frontend submodules you need inside the lifespan body:
# src/app/tune.py
from contextlib import asynccontextmanager
from vibetuner import VibetunerApp
from vibetuner.frontend.lifespan import base_lifespan
@asynccontextmanager
async def my_lifespan(app):
async with base_lifespan(app):
# Lazy import to avoid circular dependency
from vibetuner.frontend.hotreload import hotreload # noqa: F811
...
yield
app = VibetunerApp(frontend_lifespan=my_lifespan)
Quick Checklist¶
- In
tune.py, import fromvibetuner.*(top-level) or specific submodules, never fromvibetuner.frontendas a package. - Task modules can import models at the top level — they load after model init.
- Custom lifespans should lazy-import
vibetuner.frontend.*inside the function body.
Next Steps¶
- Authentication - Set up OAuth providers
- Deployment - Deploy to production
- Architecture - Understand the system design