Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .env.example
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,6 +8,15 @@
SM_DATABASE_URL=sqlite+aiosqlite:///./app.db
# SM_DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/simple_module_python

# Postgres connection pool (per process). Defaults: pool_size 10 + max_overflow
# 20 = up to 30 connections per process. When running several uvicorn --workers,
# the total is workers × (pool_size + max_overflow) and must stay under the
# server's max_connections (Postgres default 100) — otherwise workers hit
# "asyncpg.TooManyConnectionsError: sorry, too many clients already" under load.
# e.g. 4 workers → keep the per-worker pool small:
# SM_DB_POOL_SIZE=5
# SM_DB_MAX_OVERFLOW=10

# Celery against the shared ../dev-services Redis. Host-run worker/beat use
# localhost; the docker-compose worker/beat override to the `redis` hostname.
# This project owns Redis logical DBs 4 (broker) and 5 (result backend).
Expand Down
8 changes: 7 additions & 1 deletion Makefile
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
.PHONY: install install-py install-js dev dev-api dev-ui build test test-py test-js test-e2e bench memray-run memray-flamegraph loadtest loadtest-memray lint doctor migrate migration downgrade migration-history docker-up docker-down kill new-module gen-pages sync-module-deps ci-python-lint ci-python-typecheck ci-js-lint ci-js-typecheck ci-check-file-size ci-check-hardcoded-strings ci-build-packages worker beat worker-docker
.PHONY: install install-py install-js dev dev-api dev-ui build test test-py test-js test-e2e bench memray-run memray-flamegraph loadtest loadtest-seed loadtest-memray lint doctor migrate migration downgrade migration-history docker-up docker-down kill new-module gen-pages sync-module-deps ci-python-lint ci-python-typecheck ci-js-lint ci-js-typecheck ci-check-file-size ci-check-hardcoded-strings ci-build-packages worker beat worker-docker

# Install
install:
Expand DownExpand Up@@ -69,8 +69,14 @@ memray-flamegraph: ## Render $(MEMRAY_OUT) as an HTML flamegraph
# Load testing. `make loadtest` assumes `make dev` is running separately.
# `make loadtest-memray` starts uvicorn under memray, runs locust headless,
# shuts down, and emits a flamegraph. Override locust args via LOCUST_ARGS=...
# Run `make loadtest-seed` once beforehand to fill the DB (faker) with realistic
# volumes — set SM_DATABASE_URL to a THROWAWAY database first. Override row
# counts via SEED_ARGS="5000 50000" (users, audit). See tests/loadtest/README.md.
LOCUST_HOST ?= http://localhost:8000
LOCUST_ARGS ?= -u 20 -r 5 -t 30s
loadtest-seed: ## Seed realistic faker data into $$SM_DATABASE_URL (users + audit)
uv run python tests/loadtest/seed.py $(SEED_ARGS)

loadtest: ## Run locust against a server already on $(LOCUST_HOST)
uv run locust -f tests/loadtest/locustfile.py --host $(LOCUST_HOST) --headless $(LOCUST_ARGS)

Expand Down
4 changes: 3 additions & 1 deletion docs/reference/deployment.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -44,7 +44,9 @@ COPY --from=frontend /app/static/dist /app/static/dist
CMD ["uv", "run", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--proxy-headers"]
```

Tune worker count with `--workers N` for multi-CPU boxes, or run behind a process manager like Gunicorn with Uvicorn workers.
Tune worker count with `--workers N` for multi-CPU boxes, or run behind a process manager like Gunicorn with Uvicorn workers. A single worker is CPU-bound (one process, the GIL) — multiple workers scale read throughput roughly linearly on a multi-core box.

**Size the DB pool to the worker count.** Each worker keeps its own connection pool of up to `SM_DB_POOL_SIZE + SM_DB_MAX_OVERFLOW` (default `10 + 20 = 30`) connections, so the deployment's ceiling is `workers × (pool_size + max_overflow)`. Keep that under the database's `max_connections` (Postgres default `100`) or workers will throw `asyncpg.TooManyConnectionsError: sorry, too many clients already` under load. For example, 4 workers want roughly `SM_DB_POOL_SIZE=5`, `SM_DB_MAX_OVERFLOW=10` (≤ 60 connections). For larger fleets, put PgBouncer in front instead of growing every pool.

## Running migrations on deploy

Expand Down
10 changes: 8 additions & 2 deletions docs/reference/env-vars.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -22,11 +22,17 @@ This is the full reference. See [Configuration](/guide/configuration) for a narr

| Variable | Default | Notes |
|---|---|---|
| `SM_DB_POOL_SIZE` | `10` | SQLAlchemy `pool_size`. |
| `SM_DB_MAX_OVERFLOW` | `20` | SQLAlchemy `max_overflow`. |
| `SM_DB_POOL_SIZE` | `10` | SQLAlchemy `pool_size` (per process). |
| `SM_DB_MAX_OVERFLOW` | `20` | SQLAlchemy `max_overflow` (per process). |
| `SM_DB_POOL_PRE_PING` | `true` | Test connections before use. |
| `SM_DB_POOL_RECYCLE` | `1800` | Recycle connections after N seconds (helps with LB idle drops). |

Pools are **per process**. With multiple `uvicorn --workers`, total connections =
`workers × (SM_DB_POOL_SIZE + SM_DB_MAX_OVERFLOW)`; keep it under the database's
`max_connections` (Postgres default 100) or workers raise
`asyncpg.TooManyConnectionsError` under load. See
[deployment](deployment.md#build) for sizing examples.

## Host settings (DB-backed, not env)

Multi-tenancy and i18n configuration live in the DB-backed host settings store (`HostSettings`, registered under `package="host"`), **not** in env vars. Edit them in the admin UI at `/settings/modules` under the host section. Their defaults:
Expand Down
60 changes: 60 additions & 0 deletions framework/hosting/simple_module_hosting/_inertia_setup.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,7 +2,10 @@

from __future__ import annotations

import hashlib
import json
import logging
import tempfile
from pathlib import Path

from fastapi import FastAPI
Expand All@@ -16,6 +19,59 @@
_ROOT_TEMPLATE_FILENAME = "index.html"
_ENTRYPOINT_FILENAME = "main.tsx"
_ROOT_DIRECTORY = "."
# Built assets are served from the "/static" mount under "dist/", so production
# asset URLs are prefixed with "static/dist".
_ASSETS_PREFIX = "static/dist"
_VITE_MANIFEST_RELPATH = Path("static") / "dist" / ".vite" / "manifest.json"


def _prod_manifest_path(project_root: Path) -> str:
"""Return a manifest path fastapi-inertia can read in production.

fastapi-inertia looks the entry up by ``f"{root_directory}/{entrypoint}"``
(here ``"./main.tsx"``), but Vite keys its manifest by the entry's path
relative to the Vite root (``"main.tsx"``) — so the raw Vite manifest would
``KeyError``. Read it, re-key the ``isEntry`` chunk under the key
fastapi-inertia expects, and write the normalized copy beside the build
output (falling back to a temp file if that dir is read-only). Returns ``""``
when no built manifest exists, leaving production assets unconfigured rather
than crashing at import time.
"""
candidates = [
project_root / "host" / _VITE_MANIFEST_RELPATH,
project_root / _VITE_MANIFEST_RELPATH,
]
vite_manifest = next((p for p in candidates if p.is_file()), None)
if vite_manifest is None:
logger.warning(
"Production Vite manifest not found (looked in %s)", [str(c) for c in candidates]
)
return ""
try:
data = json.loads(vite_manifest.read_text())
expected_key = f"{_ROOT_DIRECTORY}/{_ENTRYPOINT_FILENAME}"
if expected_key not in data:
entry = next((v for v in data.values() if v.get("isEntry")), None)
if entry is None:
# No entry to re-key: degrade gracefully (same as no-manifest)
# rather than returning a path that KeyErrors at render time.
logger.warning("No isEntry chunk in Vite manifest %s", vite_manifest)
return ""
data = {**data, expected_key: entry}
out = vite_manifest.parent / "inertia-manifest.json"
try:
out.write_text(json.dumps(data))
except OSError:
# Build dir read-only (e.g. immutable container layer): fall back to
# a temp file keyed by the source manifest path so multiple apps on
# one host don't clobber each other's normalized manifests.
digest = hashlib.sha1(str(vite_manifest).encode()).hexdigest()[:12]
out = Path(tempfile.gettempdir()) / f"sm-inertia-manifest-{digest}.json"
out.write_text(json.dumps(data))
return str(out)
except Exception:
logger.exception("Failed to prepare production Inertia manifest from %s", vite_manifest)
return ""


def setup_inertia(
Expand DownExpand Up@@ -82,6 +138,10 @@ def setup_inertia(
environment=inertia_environment,
version=_INERTIA_VERSION,
dev_url=settings.vite_dev_url if use_dev_server else "",
# Production reads built assets from the Vite manifest; dev serves them
# from the Vite dev server, so these only matter when not use_dev_server.
manifest_json_path="" if use_dev_server else _prod_manifest_path(project_root),
assets_prefix="" if use_dev_server else _ASSETS_PREFIX,
templates=templates,
root_template_filename=_ROOT_TEMPLATE_FILENAME,
entrypoint_filename=_ENTRYPOINT_FILENAME,
Expand Down
24 changes: 24 additions & 0 deletions framework/hosting/simple_module_hosting/_phase_helpers.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,6 +23,8 @@
from simple_module_core.exceptions import NotFoundError
from starlette.exceptions import HTTPException
from starlette.middleware.sessions import SessionMiddleware
from starlette.responses import Response
from starlette.types import Scope

from simple_module_hosting._error_handlers import (
http_exception_handler,
Expand All@@ -46,6 +48,28 @@

logger = logging.getLogger(__name__)

_IMMUTABLE_CACHE_CONTROL = "public, max-age=31536000, immutable"


class ImmutableStaticFiles(StaticFiles):
"""StaticFiles that marks Vite's content-hashed build assets immutable.

Vite emits files under ``dist/assets/`` with a content hash in the filename
(e.g. ``main-3YbShAJ4.js``), so the bytes for a given URL never change —
browsers can cache them indefinitely and skip even the revalidation
round-trip. The default StaticFiles only sets ETag/Last-Modified, forcing a
conditional GET per asset on every visit. Non-hashed paths (the manifest,
etc.) keep the default behaviour.
"""

async def get_response(self, path: str, scope: Scope) -> Response:
response = await super().get_response(path, scope)
# StaticFiles hands us an OS-separator path (backslashes on Windows), so
# normalize before matching the forward-slash asset prefix.
if response.status_code == 200 and path.replace("\\", "/").startswith("dist/assets/"):
response.headers["Cache-Control"] = _IMMUTABLE_CACHE_CONTROL
return response


def register_exception_handlers(app: FastAPI, modules: list) -> None:
"""Install framework-level exception handlers, then per-module handlers."""
Expand Down
6 changes: 4 additions & 2 deletions framework/hosting/simple_module_hosting/app_builder.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,7 +10,6 @@
from pathlib import Path

from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from simple_module_core.diagnostics import DiagnosticLevel, print_diagnostics, run_diagnostics
from simple_module_core.discovery import discover_modules, topological_sort
from simple_module_core.events import EventBus
Expand All@@ -26,6 +25,7 @@
from simple_module_hosting._host_services import _HostServices
from simple_module_hosting._inertia_setup import setup_inertia
from simple_module_hosting._phase_helpers import (
ImmutableStaticFiles,
attach_public_routes,
check_settings_registration,
install_middleware,
Expand DownExpand Up@@ -277,7 +277,9 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:

static_dir = _PROJECT_ROOT / "host" / _STATIC_DIR_NAME
if static_dir.is_dir():
app.mount(_STATIC_MOUNT_PATH, StaticFiles(directory=static_dir), name=_STATIC_DIR_NAME)
app.mount(
_STATIC_MOUNT_PATH, ImmutableStaticFiles(directory=static_dir), name=_STATIC_DIR_NAME
)

mount_module_static_dirs(app, modules)

Expand Down
71 changes: 71 additions & 0 deletions framework/hosting/tests/test_inertia_manifest.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
"""Tests for production Inertia manifest normalization (_prod_manifest_path).

fastapi-inertia looks the entry up by ``f"{root_directory}/{entrypoint}"`` =
``"./main.tsx"``, but Vite keys its manifest by the entry's source path
(``"main.tsx"``). _prod_manifest_path bridges the two so production page
rendering doesn't KeyError. Regression guard for that bug.
"""

from __future__ import annotations

import json
from pathlib import Path

from simple_module_hosting._inertia_setup import _prod_manifest_path


def _write_vite_manifest(project_root: Path) -> Path:
manifest_dir = project_root / "host" / "static" / "dist" / ".vite"
manifest_dir.mkdir(parents=True)
manifest = {
"main.tsx": {
"file": "assets/main-ABC123.js",
"css": ["assets/main-DEF456.css"],
"isEntry": True,
},
"pages/Foo.tsx": {"file": "assets/Foo-XYZ.js"},
}
path = manifest_dir / "manifest.json"
path.write_text(json.dumps(manifest))
return path


def test_rekeys_entry_for_fastapi_inertia(tmp_path: Path):
_write_vite_manifest(tmp_path)
result = _prod_manifest_path(tmp_path)

assert result, "expected a manifest path, got empty string"
data = json.loads(Path(result).read_text())
# fastapi-inertia will look up f"{root_directory}/{entrypoint}" == "./main.tsx"
assert "./main.tsx" in data
assert data["./main.tsx"]["file"] == "assets/main-ABC123.js"
assert data["./main.tsx"]["css"] == ["assets/main-DEF456.css"]
# original keys are preserved (other chunks still resolvable)
assert "pages/Foo.tsx" in data


def test_returns_empty_when_no_built_manifest(tmp_path: Path):
# No host/static/dist/.vite/manifest.json present.
assert _prod_manifest_path(tmp_path) == ""


def test_returns_empty_when_no_entry_chunk(tmp_path: Path):
# A manifest with no isEntry chunk must degrade to "" (not return a path
# that would KeyError at render) — same graceful path as no-manifest.
manifest_dir = tmp_path / "host" / "static" / "dist" / ".vite"
manifest_dir.mkdir(parents=True)
(manifest_dir / "manifest.json").write_text(json.dumps({"pages/Foo.tsx": {"file": "f.js"}}))
assert _prod_manifest_path(tmp_path) == ""


def test_scaffolded_layout_without_host_dir(tmp_path: Path):
# smpy-new apps keep static/ at the project root (no host/ subdir).
manifest_dir = tmp_path / "static" / "dist" / ".vite"
manifest_dir.mkdir(parents=True)
(manifest_dir / "manifest.json").write_text(
json.dumps({"main.tsx": {"file": "assets/main-A.js", "isEntry": True}})
)
result = _prod_manifest_path(tmp_path)
assert result
data = json.loads(Path(result).read_text())
assert data["./main.tsx"]["file"] == "assets/main-A.js"
37 changes: 37 additions & 0 deletions framework/hosting/tests/test_static_caching.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
"""ImmutableStaticFiles marks Vite's content-hashed assets immutable.

Hashed filenames (``main-3YbShAJ4.js``) are content-addressed, so browsers can
cache them forever and skip the per-asset revalidation round-trip. Non-hashed
paths keep StaticFiles' default (ETag/Last-Modified only).
"""

from __future__ import annotations

from pathlib import Path

from simple_module_hosting._phase_helpers import ImmutableStaticFiles

_GET_SCOPE = {"type": "http", "method": "GET", "headers": []}


def _make_tree(root: Path) -> None:
(root / "dist" / "assets").mkdir(parents=True)
(root / "dist" / "assets" / "main-ABC123.js").write_text("console.log(1)")
(root / "dist" / ".vite").mkdir(parents=True)
(root / "dist" / ".vite" / "manifest.json").write_text("{}")


async def test_hashed_asset_is_immutable(tmp_path: Path):
_make_tree(tmp_path)
static = ImmutableStaticFiles(directory=tmp_path)
resp = await static.get_response("dist/assets/main-ABC123.js", _GET_SCOPE)
assert resp.status_code == 200
assert resp.headers["cache-control"] == "public, max-age=31536000, immutable"


async def test_non_asset_keeps_default_caching(tmp_path: Path):
_make_tree(tmp_path)
static = ImmutableStaticFiles(directory=tmp_path)
resp = await static.get_response("dist/.vite/manifest.json", _GET_SCOPE)
assert resp.status_code == 200
assert "immutable" not in resp.headers.get("cache-control", "")
7 changes: 5 additions & 2 deletions host/client_app/vite.config.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -182,9 +182,12 @@ export default defineConfig({
},
},
server: {
port: 5050,
// If you override SM_VITE_PORT, set the backend's SM_VITE_DEV_URL to the
// same port — the backend uses it for the dev <script src> and the CSP
// script-src/connect-src, so a mismatch breaks asset loading + HMR.
port: Number(process.env.SM_VITE_PORT) || 5050,
strictPort: true,
origin: 'http://localhost:5050',
origin: `http://localhost:${Number(process.env.SM_VITE_PORT) || 5050}`,
fs: {
allow: [projectRoot, ...moduleFsAllow],
},
Expand Down
Loading
Loading